JavaScript (ES2020), 736 730 720 bytes
-3 thanks to Kevin Cruijssen
-10 thanks to Neil
(t,e,l="slice",n=0)=>(f=(t,e=Object.assign([],Object.fromEntries(t[a="map"](((a,f)=>[2*f,a<0?-a:a,a>0])).flatMap((([a,f,p])=>[[a,[--f,p]],[f,[a,!p]]])))))=>n=(f[(e.map((a=>e.find((([a,f],p)=>a==p+1?e=[...e[l](0,p),...e[l](p+2)].map((([a,f])=>[a-2*(a>p),f])):p<a&&a==e[p+1][0]+1&e[p+1][1]==f?e=[e[l](0,p),e[l](p+2,a-1),e[l](a+1)].flat().map((([f,m])=>[f-2*(f>p)-2*(f>a),m])):0)))),e)]||(f[e]=t,f(t,[...e[l](1),...e[l](0,1)].map((([a,f])=>[(a||e.length)-1,f]))),f(t,e.map((([a,f])=>[a,!f]))),(m=[1,-1]).map((a=>m.map((m=>e.map((([e,l],n,[...o],s=o[n+1]?.[0])=>s+1&&l==o[n+1][1]&s+m===o[e+a]?.[0]&&f(t,o,[o[n],o[n+1],o[e],o[e+a],o[s],o[s+m]]=[[s+m,l],[e+a,l],[s,p=o[e+a][1]],[n+1,!l],[e,!p],[n,!l]]))))))),t))!=t|n)(t)|f(e)
input.value="# interactive; edit or add lines and the annotation will update\n\n# isotopic\n[6, -12, 2, 8, -4, -10] [6, -8, 2, -4]\n[4, 6, 2] [8, 6, 10, -2, 4]\n[6, -12, 2, 8, -4, -10] [-6, 12, -2, -8, 4, 10]\n[6, -12, 2, 8, -4, -10] [4, -6, 10, -2, -8, 12]\n[4, 6, 2] [6, -8, 2, -4]\n[] [2]\n[2] []\n[2] [2]\n[] [6, 4, 2]\n[] [6, -4, 2]\n[] []\n\n# not isotopic\n[] [4, 6, 2]\n[] [6, -8, 2, -4]\n[2] [6, -8, 2, -4]\n[6, -4, 2] [6, -8, 2, -4]";(input.onchange=input.oninput=()=>{input.style.minHeight="1px";input.style.minHeight = (input.scrollHeight)+"px";output.textContent=input.value.split("\n").map(x=>{if(!x.trim()||x.startsWith("#"))return"";let M=/^(\[\s*((?:-?\d+\s*,\s*)*-?\d+\s*)?\]) (?:\[\s*((?:-?\d+\s*,\s*)*-?\d+\s*)?\])$/.exec(x);if(M){let[A,B]=[M[2],M[3]].map(x=>x?x.trim().split(/\s*,\s*/).map(x=>+x):[]);try{f=(
(t,e,l="slice",n=0)=>(f=(t,e=Object.assign([],Object.fromEntries(t[a="map"](((a,f)=>[2*f,a<0?-a:a,a>0])).flatMap((([a,f,p])=>[[a,[--f,p]],[f,[a,!p]]])))))=>n=(f[(e.map((a=>e.find((([a,f],p)=>a==p+1?e=[...e[l](0,p),...e[l](p+2)].map((([a,f])=>[a-2*(a>p),f])):p<a&&a==e[p+1][0]+1&e[p+1][1]==f?e=[e[l](0,p),e[l](p+2,a-1),e[l](a+1)].flat().map((([f,m])=>[f-2*(f>p)-2*(f>a),m])):0)))),e)]||(f[e]=t,f(t,[...e[l](1),...e[l](0,1)].map((([a,f])=>[(a||e.length)-1,f]))),f(t,e.map((([a,f])=>[a,!f]))),(m=[1,-1]).map((a=>m.map((m=>e.map((([e,l],n,[...o],s=o[n+1]?.[0])=>s+1&&l==o[n+1][1]&s+m===o[e+a]?.[0]&&f(t,o,[o[n],o[n+1],o[e],o[e+a],o[s],o[s+m]]=[[s+m,l],[e+a,l],[s,p=o[e+a][1]],[n+1,!l],[e,!p],[n,!l]]))))))),t))!=t|n)(t)|f(e)
);let r=f(A,B);return" ".repeat(M[1].length)+(r?"=":"≠")}catch(e){}}return" ".repeat(x.length+1)+"?"}).join("\n")})()
#input{width:100%;height:100%;border:0;outline:0;padding:5px}body,html{width:100%;height:100%;margin:0}body{overflow:auto}*{box-sizing:border-box}#output{position:absolute;pointer-events:none;top:0;left:0;padding:5px;white-space:pre;opacity:.5}#input,#output{font-family:monospace;font-size:15px;overflow:hidden}
<div id="output"></div><textarea id="input"></textarea>
Ungolfed / Explanation
// First, we'll convert the dowker notation to a more convenient format:
// Original:
// 6, -12, 2, 8, -4, -10
// Reconstruct the pairs:
// (1,6), (3,-12), (5,2), (7,8), (9,-4), (11,-10)
// Add in the even pairs:
// (1,6), (2,-5), (3,-12), (4,-9), (5,2), (6,-1), (7,8), (8,-7), (9,-4), (10,11), (11,-10), (12,3)
// Extract the sign:
// (1,6,+), (2,5,-), (3,12,-), (4,9,-), (5,2,+), (6,1,-), (7,8,+), (9,4,-), (10,11,+), (11,10,-), (12,3,+)
// Zero-index:
// (0,5,+), (1,4,-), (2,11,-), (3,8,-), (4,1,+), (5,0,-), (6,7,+), (8,3,-), (9,10,+), (10,9,-), (11,2,+)
// Convert to array: [
// [5, true],
// [4, false],
// [11, false],
// [8, false],
// [1, true],
// [0, false],
// [7, true],
// [6, false],
// [3, false],
// [10, true],
// [9, false],
// [2, true],
// ]
function dowkerToKnot(dowker) {
return Array.from({
// Twice as many pairs
length: dowker.length * 2,
...Object.fromEntries(
dowker
.map((x, i) => [i * 2, Math.abs(x) - 1, Math.sign(x) == 1])
.flatMap(([a, b, x]) => [
[a, [b, x]],
[b, [a, !x]],
])
),
});
}
// Continually simplify using Reidemeister moves I and II
function simplify(knot) {
let prev;
while (prev !== (prev = knot)) {
for (let a of knot.keys()) {
/* Reidemeister move I */ {
// Reidemeister move I resolves simple twists like
// \ _
// a↘\ / \
// /↙a+1 |
// / \ _ /
// /
// This can be expressed by the condition:
// a' = a + 1 [where x' is the other number associated with the crossing at x]
if (knot[a][0] /* a' */ === a + 1) {
knot = [
...knot.slice(0, a), // the crossings before the loop
...knot.slice(a + 2), // the crossing after the loop
].map(([x, o]) => [x > a ? x - 2 : x /* adjust the indices */, o]);
break;
}
}
/* Reidemeister move II */ {
// Reidemeister move II resolves strings tucked under/over another like
// \ |
// a↘\|
// |↑b+1
// |\
// | |
// | |
// |/↙a+1
// b↑|
// /|
// / |
// This can be expressed as the conditions:
// b' = a+1
// a' = b+1
// #a = #(a+1) [where #x is the over/under sign associated with x]
let b = knot[a + 1]?.[0]; // (b = (a+1)' is the same as b' = a+1)
if (
a < b /* a and b are symmetric, so this dedupes */ &&
knot[a][1] /* #a */ === knot[a + 1][1] /* #(a+1) */ &&
knot[a][0] /* a' */ === b + 1
) {
knot = [
...knot.slice(0, a) /* before a */,
...knot.slice(a + 2, b) /* between a and b (we know that a < b) */,
...knot.slice(b + 2) /* after b */,
].map(([x, a]) => [
x - (x > a) * 2 - (x > b) * 2 /* adjust indices */,
a,
]);
break;
}
}
}
}
return prev;
}
// Applies manipulations to a given knot, calling processKnot for every resulting knot.
// Manipulations include:
// - Shifting the starting location
// - Mirroring
// - Reidemeister move III
function manipulate(knot, processKnot) {
// Shift the starting location by 1
if (knot.length)
processKnot(
knot
.slice(1)
.concat([knot[0]]) /* cycle the crossings */
.map(([a, b]) => [(a || knot.length) - 1 /* adjust indices */, b])
);
// Mirror
processKnot(knot.map(([a, b]) => [a, !b /* reverse over/under */]));
/* Reidemeister move III */ {
// Reidemeister move III moves a string across a crossing:
// \ / \ /
// \ / -------------
// \ / \ /
// / → /
// / \ / \
// ------------- / \
// / \ / \
// Let's label the crossings in the original:
// \ /
// \ /
// \ /
// b+1↗/↖c+1
// / \
// a→ / \ →a+1
// ---------------
// b↗/ \↖c
// / \
// This can be expressed with the following conditions:
// b = a'
// c = (a+1)'
// (b+1)' = c+1
// #a = #(a+1)
// Now, the b and c strings could go in either direction.
// Here's another example, with b reversed (note b-1):
// \ /
// \ /
// \ /
// b-1↙/↖c+1
// / \
// a→ / \ →a+1
// ---------------
// b↙/ \↖c
// / \
// Thus, we'll generalize the conditions as follows, where B and C are each either 1 or -1:
// b = a'
// c = (a+1)'
// (b+B)' = c+C
// #a = #(a+1)
for (let B of [1, -1])
for (let C of [1, -1])
for (let a of knot.keys()) {
let b = knot[a][0];
// Edge case: a + 1 might not exist, as it could wrap around the array.
// This is fine, as it will be handled by one of the knots with a shifted starting location.
let c = knot[a + 1]?.[0];
if (
c != null &&
knot[a][1] /* #a */ === knot[a + 1][1] /* #(a+1) */ &&
knot[b + B]?.[0] /* (b+B)' */ === c + C
) {
// Now that we've identified a location to apply this manipulation,
// we need to determine how to modify the knot.
// Here's the original knot, again (this uses B=1 and C=1, but is generally applicable):
// \ /
// \ /
// \ /
// b+1↗/↖c+1
// / \
// a→ / \ →a+1
// ---------------
// b↗/ \↖c
// / \
// This needs to be transformed to:
// \ /
// \ /
// ---------------
// \ /
// \ /
// /
// / \
// / \
// / \
// Labeling the crossings, we get:
// \ /
// a→ \ / →a+1
// ---------------
// c+1↖\ /↗b+1
// \ /
// b↗/↖c
// / \
// / \
// / \
let newKnot = [...knot];
newKnot[a] = [c + C, knot[a][1]];
newKnot[a + 1] = [b + B, knot[a][1]];
newKnot[b] = [c, knot[b + B][1]];
newKnot[b + B] = [a + 1, !knot[a][1]];
newKnot[c] = [b, !knot[b + B][1]];
newKnot[c + C] = [a, !knot[a][1]];
processKnot(newKnot);
}
}
}
}
// We can finally checks if two knots are equal!
function isotopic(a, b) {
// We'll track the result in this variable.
let result = false;
// First, we're going to create an empty object to serve as a record.
// This will serve as a dictionary from knots to either "a" or "b".
let record = {};
function processKnot(knot, marker /* either "a" or "b" */) {
// Before doing anything with the knot, we'll first simplify it.
knot = simplify(knot);
// Then, we'll check to see if the record already has an entry for this knot.
let value = record[knot];
if (value === undefined) {
// We've never seen this knot; add it to the record.
record[knot] = marker;
// Process all of the knots we can make by manipulating this one.
manipulate(knot, newKnot => processKnot(newKnot, marker));
} else if (value === marker) {
// We've already seen this knot for this marker, so we don't need to do anything.
} else {
// We've already seen this knot, but for a different marker.
// This means that both original knots (a and b) are isotopic to this knot.
// By transitivity, they're isotopic to each other as well.
result = true;
}
}
processKnot(a, "a");
processKnot(b, "b");
return result;
}
function main(a, b) {
// Convert each of a and b to our format and then check if they're isotopic.
return isotopic(dowkerToKnot(a), dowkerToKnot(b));
}