Files
illustrated-algorithm/linked-list-cycle-ii/index.html
T
2026-08-24 04:35:13 +00:00

831 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>142. Linked List Cycle II — Floyd 双阶段算法可视化</title>
<link rel="stylesheet" href="../shared/style.css">
<style>
/* ── Base / Fallback if shared.css absent ── */
:root {
--bg: #0f1117; --card: #181b25; --border: #2a2e3a;
--fg: #e4e6ed; --muted: #8b8fa3; --accent: #6c8cff;
--slow: #3b82f6; --fast: #ef4444; --ptr1: #10b981; --ptr2: #a855f7;
--meeting: #f59e0b; --entry: #fbbf24; --code-bg: #0d0f14;
--phase1: #3b82f6; --phase2: #10b981;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg); color: var(--fg);
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.6;
}
#app {
max-width: 900px; margin: 0 auto; padding: 24px 16px 60px;
}
h1 { font-size: 1.55rem; font-weight: 700; letter-spacing: -0.02em; }
.subtitle { color: var(--muted); font-size: 0.92rem; margin-top: 2px; }
.badge {
display: inline-block; background: #1e2333; color: var(--accent);
border: 1px solid var(--border); border-radius: 6px;
padding: 1px 8px; font-size: 0.78rem; font-weight: 600;
}
/* ── Cards ── */
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 16px; margin-top: 14px;
}
.card-title {
font-size: 0.82rem; font-weight: 600; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px;
}
/* ── Example Buttons & Input ── */
.top-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 14px; }
.example-btn {
background: #1e2333; color: var(--fg); border: 1px solid var(--border);
border-radius: 8px; padding: 6px 14px; font-size: 0.82rem;
cursor: pointer; transition: all 0.15s;
}
.example-btn:hover { border-color: var(--accent); color: var(--accent); }
.example-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.input-group {
display: flex; gap: 6px; align-items: center; margin-left: auto;
}
.input-group input {
background: #12141c; color: var(--fg); border: 1px solid var(--border);
border-radius: 8px; padding: 6px 12px; font-size: 0.82rem;
width: 240px; outline: none; font-family: "SF Mono", "Fira Code", monospace;
}
.input-group input:focus { border-color: var(--accent); }
.input-group button {
background: var(--accent); color: #fff; border: none; border-radius: 8px;
padding: 6px 14px; font-size: 0.82rem; cursor: pointer; font-weight: 600;
}
/* ── Pipeline ── */
.pipeline {
display: flex; gap: 0; margin-top: 14px;
background: #12141c; border-radius: 10px; overflow: hidden;
border: 1px solid var(--border);
}
.pipeline-stage {
flex: 1; text-align: center; padding: 9px 6px; font-size: 0.78rem;
font-weight: 600; color: var(--muted); position: relative;
transition: all 0.25s;
}
.pipeline-stage.active { color: #fff; }
.pipeline-stage.active[data-color="blue"] { background: var(--phase1); }
.pipeline-stage.active[data-color="amber"] { background: #b47d09; }
.pipeline-stage.active[data-color="green"] { background: var(--phase2); }
.pipeline-stage.active[data-color="final"] { background: #059669; }
.pipeline-stage + .pipeline-stage { border-left: 1px solid var(--border); }
/* ── Hint ── */
.hint-box {
margin-top: 14px; padding: 10px 16px; border-radius: 10px;
background: #1b1f2e; border-left: 4px solid var(--accent);
font-size: 0.88rem; min-height: 40px; display: flex;
align-items: center; gap: 8px;
}
.hint-box .icon { font-size: 1.05rem; }
.hint-box .phase-tag {
display: inline-block; border-radius: 4px; padding: 1px 7px;
font-size: 0.72rem; font-weight: 700; color: #fff; flex-shrink: 0;
}
.hint-box .phase-tag.p1 { background: var(--phase1); }
.hint-box .phase-tag.p2 { background: var(--phase2); }
.hint-box .phase-tag.done { background: #059669; }
/* ── Visualisation SVG ── */
.viz-card { padding: 0; overflow: hidden; }
#viz-svg {
width: 100%; height: auto; display: block;
}
/* ── SVG styles ── */
.node-rect {
rx: 8; ry: 8; stroke-width: 2; fill: #1e2333; stroke: var(--border);
transition: stroke 0.3s, fill 0.3s;
}
.node-rect.meeting { fill: #2a2410; stroke: var(--meeting); stroke-width: 2.5; }
.node-rect.entry { fill: #2a2410; stroke: var(--entry); stroke-width: 2.5; }
.node-rect.both { fill: #2d2a10; stroke: var(--meeting); stroke-width: 2.5; }
.node-val { fill: #e4e6ed; font-size: 16px; font-weight: 700; text-anchor: middle; dominant-baseline: central; }
.node-idx { fill: var(--muted); font-size: 10px; text-anchor: middle; dominant-baseline: auto; }
.arrow-line { stroke: #4a4f63; stroke-width: 2; fill: none; }
.cycle-line { stroke: var(--meeting); stroke-width: 2.2; fill: none; stroke-dasharray: 7 4; }
.null-line { stroke: #4a4f63; stroke-width: 2; fill: none; }
.ptr-label {
font-size: 11px; font-weight: 700; text-anchor: middle; dominant-baseline: central;
}
.ptr-badge-rect { rx: 4; ry: 4; }
/* ── Controls ── */
.controls {
display: flex; align-items: center; justify-content: center;
gap: 8px; margin-top: 12px;
}
.ctrl-btn {
background: #1e2333; color: var(--fg); border: 1px solid var(--border);
border-radius: 8px; width: 38px; height: 34px; font-size: 1rem;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.ctrl-btn:hover { border-color: var(--accent); color: var(--accent); }
.ctrl-btn:disabled { opacity: 0.35; cursor: default; }
.ctrl-btn.playing { background: var(--accent); color: #fff; border-color: var(--accent); }
.step-counter { font-size: 0.82rem; color: var(--muted); min-width: 70px; text-align: center; }
/* ── Detail Panel ── */
.detail-body { font-size: 0.88rem; line-height: 1.7; white-space: pre-wrap; }
.detail-body .math {
background: #12141c; border-radius: 6px; padding: 8px 12px;
margin: 6px 0; font-family: "SF Mono", "Fira Code", Consolas, monospace;
font-size: 0.82rem; color: #c5cae0; display: block;
}
/* ── Result Panel ── */
.result-body { font-size: 0.92rem; }
.result-body .val { color: var(--entry); font-weight: 700; font-size: 1.1rem; }
.result-body .null-val { color: var(--muted); font-style: italic; }
.result-hidden { display: none; }
/* ── Code Panel ── */
.code-panel { background: var(--code-bg); }
.code-panel pre {
margin: 0; overflow-x: auto; font-size: 0.82rem; line-height: 1.65;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
color: #c5cae0;
}
.code-panel .kw { color: #c678dd; }
.code-panel .fn { color: #61afef; }
.code-panel .cm { color: #5c6370; font-style: italic; }
.code-panel .st { color: #98c379; }
.code-panel .num { color: #d19a66; }
.code-panel .op { color: #56b6c2; }
.code-panel .hl-line {
background: rgba(108,140,255,0.08); display: block;
margin: 0 -16px; padding: 0 16px; border-left: 3px solid var(--accent);
}
/* ── Pulse Animation ── */
@keyframes entryPulse {
0%, 100% { stroke-opacity: 1; filter: drop-shadow(0 0 4px rgba(251,191,36,0.4)); }
50% { stroke-opacity: 0.7; filter: drop-shadow(0 0 10px rgba(251,191,36,0.7)); }
}
.node-rect.entry { animation: entryPulse 1.4s ease-in-out infinite; }
@keyframes meetingPulse {
0%, 100% { stroke-opacity: 1; }
50% { stroke-opacity: 0.6; }
}
.node-rect.meeting { animation: meetingPulse 1.2s ease-in-out infinite; }
/* ── Responsive ── */
@media (max-width: 640px) {
.top-bar { flex-direction: column; align-items: stretch; }
.input-group { margin-left: 0; }
.input-group input { flex: 1; width: auto; }
}
</style>
</head>
<body>
<div id="app">
<!-- Title -->
<h1>142. Linked List Cycle II</h1>
<p class="subtitle">Floyd 双阶段算法 &mdash; 检测环 &amp; 找到入口 <span class="badge">Medium #026</span></p>
<!-- Examples & Input -->
<div class="top-bar">
<button class="example-btn active" data-idx="0">示例1: [3,2,0,-4] pos=1</button>
<button class="example-btn" data-idx="1">示例2: [1,2] pos=0</button>
<button class="example-btn" data-idx="2">示例3: [1] pos=-1</button>
<div class="input-group">
<input id="custom-input" placeholder='head=[3,2,0,-4], pos=1'>
<button id="run-btn">运行</button>
</div>
</div>
<!-- Pipeline -->
<div class="pipeline">
<div class="pipeline-stage active" data-color="blue" id="pipe-0">阶段1: 检测环</div>
<div class="pipeline-stage" data-color="amber" id="pipe-1">相遇</div>
<div class="pipeline-stage" data-color="green" id="pipe-2">阶段2: 找入口</div>
<div class="pipeline-stage" data-color="final" id="pipe-3">结果</div>
</div>
<!-- Hint -->
<div class="hint-box" id="hint-box">
<span class="icon">💡</span>
<span id="hint-text">点击示例或输入数据开始</span>
</div>
<!-- Visualisation -->
<div class="card viz-card">
<svg id="viz-svg" viewBox="0 0 820 280" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrowGray" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 1 L 9 5 L 0 9 z" fill="#4a4f63"/>
</marker>
<marker id="arrowCycle" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 1 L 9 5 L 0 9 z" fill="#f59e0b"/>
</marker>
<marker id="arrowNull" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 1 L 9 5 L 0 9 z" fill="#4a4f63"/>
</marker>
</defs>
<g id="svg-layer"></g>
</svg>
</div>
<!-- Controls -->
<div class="controls">
<button class="ctrl-btn" id="btn-first" title="第一步">⏮</button>
<button class="ctrl-btn" id="btn-prev" title="上一步">◀</button>
<button class="ctrl-btn" id="btn-play" title="播放/暂停">▶</button>
<button class="ctrl-btn" id="btn-next" title="下一步">▶</button>
<button class="ctrl-btn" id="btn-last" title="最后一步">⏭</button>
<span class="step-counter" id="step-counter">0 / 0</span>
</div>
<!-- Detail -->
<div class="card">
<div class="card-title">📋 详细说明</div>
<div class="detail-body" id="detail-body">等待开始…</div>
</div>
<!-- Result -->
<div class="card result-hidden" id="result-card">
<div class="card-title">✅ 结果</div>
<div class="result-body" id="result-body"></div>
</div>
<!-- Code -->
<div class="card code-panel">
<div class="card-title">🐍 Python 代码</div>
<pre id="code-block"><span class="kw">class</span> <span class="fn">Solution</span>:
<span class="kw">def</span> <span class="fn">detectCycle</span>(self, head):
<span class="kw">if not</span> head <span class="kw">or not</span> head.next:
<span class="kw">return</span> <span class="num">None</span>
slow <span class="op">=</span> fast <span class="op">=</span> head
<span class="cm"> # Phase 1: detect cycle</span>
<span class="kw">while</span> fast <span class="kw">and</span> fast.next:
slow <span class="op">=</span> slow.next
fast <span class="op">=</span> fast.next.next
<span class="kw">if</span> slow <span class="op">==</span> fast:
<span class="cm"> # Phase 2: find entry</span>
ptr <span class="op">=</span> head
<span class="kw">while</span> ptr <span class="op">!=</span> slow:
ptr <span class="op">=</span> ptr.next
slow <span class="op">=</span> slow.next
<span class="kw">return</span> ptr
<span class="kw">return</span> <span class="num">None</span></pre>
</div>
</div>
<script src="../shared/algo-viz.js"></script>
<script>
(function() {
'use strict';
/* ═══════════════════════════════════════════
StepController – fallback if shared JS absent
═══════════════════════════════════════════ */
const StepController = (typeof window.StepController !== 'undefined')
? window.StepController
: class {
constructor() {
this.steps = []; this.idx = 0; this.playing = false; this.timer = null;
this.speed = 1400; this.onStep = null; this.onPlay = null;
}
setSteps(s) { this.steps = s; this.idx = 0; this._emit(); }
cur() { return this.steps[this.idx]; }
total(){ return this.steps.length; }
goTo(i){ this.idx = Math.max(0, Math.min(i, this.steps.length-1)); this._emit(); }
next() { if(this.idx < this.steps.length-1){ this.idx++; this._emit(); } else this.pause(); }
prev() { if(this.idx > 0){ this.idx--; this._emit(); } }
first(){ this.goTo(0); }
last() { this.goTo(this.steps.length-1); }
play() {
if(this.playing) return; this.playing = true; this._playEmit();
this.timer = setInterval(()=> this.next(), this.speed);
}
pause(){
this.playing = false; clearInterval(this.timer); this._playEmit();
}
toggle() { this.playing ? this.pause() : this.play(); }
_emit() { if(this.onStep) this.onStep(this.idx, this.cur()); }
_playEmit(){ if(this.onPlay) this.onPlay(this.playing); }
};
/* ═══════════════════════════════════════════
Constants
═══════════════════════════════════════════ */
const NW = 66, NH = 44, GAP = 58; // node width / height / gap
const SVG_W = 820, SVG_H = 280;
const NODE_Y = 80; // centre-Y for top row
const PTR_Y = NODE_Y + NH/2 + 22; // pointer label Y
const CYCLE_BULGE = 70; // how far the cycle arc dips down
const EXAMPLES = [
{ values: [3, 2, 0, -4], pos: 1 },
{ values: [1, 2], pos: 0 },
{ values: [1], pos: -1 }
];
/* ═══════════════════════════════════════════
Parse input: "head=[3,2,0,-4], pos=1"
═══════════════════════════════════════════ */
function parseInput(str) {
const m1 = str.match(/head\s*=\s*\[([^\]]*)\]/i);
const m2 = str.match(/pos\s*=\s*(-?\d+)/i);
if (!m1 || !m2) return null;
const values = m1[1].split(',').map(s => {
const v = s.trim(); return v === '' ? 0 : Number(v);
});
const pos = parseInt(m2[1], 10);
return { values, pos };
}
/* ═══════════════════════════════════════════
Generate Steps
═══════════════════════════════════════════ */
function generateSteps(values, pos) {
const n = values.length;
const next = [];
for (let i = 0; i < n - 1; i++) next[i] = i + 1;
next[n - 1] = pos >= 0 ? pos : -1;
const steps = [];
const hasCycle = pos >= 0 && n > 0;
// ──── No cycle or empty ────
if (n === 0) {
steps.push({
phase: 'result', pipelineStage: 3,
slow: -1, fast: -1, ptr1: -1, ptr2: -1,
meetingIdx: -1, entryIdx: -1,
hint: '链表为空', phaseTag: 'done',
detail: '链表为空,直接返回 null。',
resultVal: null
});
return steps;
}
// ──── Step 0: Init ────
steps.push({
phase: 1, pipelineStage: 0,
slow: 0, fast: 0, ptr1: -1, ptr2: -1,
meetingIdx: -1, entryIdx: -1,
hint: `初始化: slow = fast = head (节点 ${values[0]})`,
phaseTag: 'p1',
detail: 'Floyd 算法开始:慢指针 slow 每次走 1 步,快指针 fast 每次走 2 步。\n若链表有环,二者必在环内相遇。'
});
if (!hasCycle) {
// fast can't move
steps.push({
phase: 'result', pipelineStage: 3,
slow: 0, fast: 0, ptr1: -1, ptr2: -1,
meetingIdx: -1, entryIdx: -1,
hint: `${n === 1 ? 'fast.next 为空' : 'fast 到达链表末尾'},无环`,
phaseTag: 'done',
detail: n === 1
? '链表仅有一个节点,head.next 为 null。fast 无法前进,说明链表无环。\n返回 null。'
: `快指针 fast 到达链表末尾 (null),说明链表无环。\n返回 null。`,
resultVal: null
});
return steps;
}
// ──── Phase 1: detect cycle ────
let slow = 0, fast = 0, round = 0;
const safetyLimit = n * n + 10;
while (round++ < safetyLimit) {
const ns = next[slow];
const nf1 = next[fast];
const nf2 = nf1 >= 0 ? next[nf1] : -1;
if (nf2 === -1) break; // shouldn't happen if cycle exists
slow = ns; fast = nf2;
if (slow === fast) {
// Meeting!
const distA = distFromHead(next, slow);
const cycLen = cycleLen(next, pos, n);
const distB = distInCycle(next, pos, slow);
const distC = cycLen - distB;
const k = Math.round((distA + distB) / cycLen); // how many extra laps fast made
steps.push({
phase: 1, pipelineStage: 1,
slow, fast, ptr1: -1, ptr2: -1,
meetingIdx: slow, entryIdx: -1,
hint: `slow 与 fast 在节点 ${values[slow]} 相遇!确认有环`,
phaseTag: 'p1',
detail:
`slow 每次走 1 步,fast 每次走 2 步,在节点 ${values[slow]} 处相遇。\n` +
`此时 slow 走了 ${distA + distB} 步(a=${distA}, b=${distB}),fast 走了 ${(distA + distB) * 2} 步。\n` +
`<span class="math">2(a+b) = a+b+kL → a = kL - b = (k-1)L + (L-b)\n` +
`其中 a=${distA}, b=${distB}, L=${cycLen}, k=${k}\n` +
`a = ${k}×${cycLen} - ${distB} = ${k * cycLen - distB} ✓</span>\n` +
`结论:从 head 和相遇点同时每次走 1 步,必在环入口相遇!`
});
break;
} else {
steps.push({
phase: 1, pipelineStage: 0,
slow, fast, ptr1: -1, ptr2: -1,
meetingIdx: -1, entryIdx: -1,
hint: `slow → ${values[slow]}, fast → ${values[fast]},未相遇`,
phaseTag: 'p1',
detail: `slow 走 1 步到节点 ${values[slow]},fast 走 2 步到节点 ${values[fast]}。\nslow ≠ fast,继续移动。`
});
}
}
const meetingIdx = slow;
// ──── Phase 2: find entry ────
let ptr1 = 0, ptr2 = meetingIdx;
steps.push({
phase: 2, pipelineStage: 2,
slow: meetingIdx, fast: meetingIdx, ptr1, ptr2,
meetingIdx, entryIdx: -1,
hint: `阶段2开始: ptr1 = head (${values[0]}), ptr2 = 相遇点 (${values[meetingIdx]})`,
phaseTag: 'p2',
detail:
`进入阶段 2 — 找环入口\n` +
`ptr1 从 head 出发,ptr2 从相遇点出发,每次各走 1 步。\n` +
`由数学推导 a = kL - b,两者必然在环入口相遇。`
});
round = 0;
while (ptr1 !== ptr2 && round++ < safetyLimit) {
ptr1 = next[ptr1];
ptr2 = next[ptr2];
if (ptr1 === ptr2) {
steps.push({
phase: 2, pipelineStage: 2,
slow: meetingIdx, fast: meetingIdx, ptr1, ptr2,
meetingIdx, entryIdx: ptr1,
hint: `ptr1 与 ptr2 在节点 ${values[ptr1]} 相遇 → 环入口!`,
phaseTag: 'p2',
detail:
`ptr1 从 head 走到节点 ${values[ptr1]},ptr2 从相遇点走到节点 ${values[ptr2]}。\n` +
`两者相遇,说明节点 ${values[ptr1]} 就是环的入口!\n` +
`<span class="math">入口索引 = ${pos},值 = ${values[ptr1]}</span>`
});
} else {
steps.push({
phase: 2, pipelineStage: 2,
slow: meetingIdx, fast: meetingIdx, ptr1, ptr2,
meetingIdx, entryIdx: -1,
hint: `ptr1 → ${values[ptr1]}, ptr2 → ${values[ptr2]},未相遇`,
phaseTag: 'p2',
detail: `ptr1 走 1 步到 ${values[ptr1]},ptr2 走 1 步到 ${values[ptr2]},继续前进。`
});
}
}
// ──── Result ────
steps.push({
phase: 'result', pipelineStage: 3,
slow: meetingIdx, fast: meetingIdx, ptr1, ptr2,
meetingIdx, entryIdx: ptr1,
hint: `环入口节点: ${values[ptr1]} (索引 ${pos})`,
phaseTag: 'done',
detail:
`检测到环,入口为节点 ${values[ptr1]}(索引 ${pos})。\n` +
`时间复杂度 O(n),空间复杂度 O(1)。\n` +
`<span class="math">detectCycle → 节点 ${values[ptr1]}</span>`,
resultVal: { idx: ptr1, val: values[ptr1] }
});
return steps;
}
/* ── Helpers for step generation ── */
function distFromHead(nextArr, target) {
let d = 0, cur = 0;
const visited = new Set();
while (cur !== target && !visited.has(cur)) { visited.add(cur); cur = nextArr[cur]; d++; }
return cur === target ? d : -1;
}
function cycleLen(nextArr, entry, n) {
let len = 1, cur = nextArr[entry];
while (cur !== entry) { cur = nextArr[cur]; len++; }
return len;
}
function distInCycle(nextArr, entry, target) {
if (entry === target) return 0;
let d = 1, cur = nextArr[entry];
while (cur !== target) { cur = nextArr[cur]; d++; }
return d;
}
/* ═══════════════════════════════════════════
SVG Renderer
═══════════════════════════════════════════ */
function computePositions(n, pos) {
const totalW = n * NW + (n - 1) * GAP;
const sx = Math.max(50, (SVG_W - totalW) / 2);
const positions = [];
for (let i = 0; i < n; i++) {
positions.push({ x: sx + i * (NW + GAP), y: NODE_Y });
}
return positions;
}
function render(values, pos, step) {
const svg = document.getElementById('svg-layer');
const n = values.length;
if (n === 0) { svg.innerHTML = '<text x="410" y="140" text-anchor="middle" fill="#8b8fa3" font-size="16">空链表</text>'; return; }
const positions = computePositions(n, pos);
const hasCycle = pos >= 0;
let html = '';
// ── Forward arrows ──
for (let i = 0; i < n - 1; i++) {
const from = positions[i], to = positions[i + 1];
const x1 = from.x + NW + 1, y1 = from.y;
const x2 = to.x - 1, y2 = to.y;
html += `<line class="arrow-line" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" marker-end="url(#arrowGray)"/>`;
}
// ── Cycle arrow (curved) ──
if (hasCycle) {
const last = positions[n - 1];
const entry = positions[pos];
const lx = last.x + NW / 2, ly = last.y + NH / 2 + 2;
const ex = entry.x + NW / 2, ey = entry.y + NH / 2 + 2;
const midX = (lx + ex) / 2;
const bulge = CYCLE_BULGE + Math.abs(lx - ex) * 0.12;
const cy = ly + bulge;
html += `<path class="cycle-line" d="M${lx},${ly} C${lx},${cy} ${ex},${cy} ${ex},${ey}" marker-end="url(#arrowCycle)"/>`;
// Label
const labelY = cy + 4;
html += `<text x="${midX}" y="${labelY}" text-anchor="middle" fill="#f59e0b" font-size="11" font-weight="600">cycle</text>`;
} else {
// Null indicator after last node
const last = positions[n - 1];
const nx = last.x + NW + 12, ny = last.y;
html += `<line class="null-line" x1="${last.x + NW + 1}" y1="${ny}" x2="${nx + 20}" y2="${ny}" marker-end="url(#arrowNull)"/>`;
html += `<text x="${nx + 30}" y="${ny + 4}" fill="#ef4444" font-size="13" font-weight="700" text-anchor="start">null</text>`;
}
// ── Nodes ──
for (let i = 0; i < n; i++) {
const p = positions[i];
let cls = 'node-rect';
const isMeeting = step.meetingIdx === i;
const isEntry = step.entryIdx === i;
if (isMeeting && isEntry) cls += ' both';
else if (isEntry) cls += ' entry';
else if (isMeeting) cls += ' meeting';
html += `<rect class="${cls}" x="${p.x}" y="${p.y - NH/2}" width="${NW}" height="${NH}"/>`;
html += `<text class="node-val" x="${p.x + NW/2}" y="${p.y}">${values[i]}</text>`;
html += `<text class="node-idx" x="${p.x + NW/2}" y="${p.y - NH/2 - 5}">idx${i}</text>`;
}
// ── Pointer badges ──
const badgeH = 17, badgeR = 4;
const activePointers = [];
if (step.phase === 1 || step.phase === 'result') {
if (step.slow >= 0) activePointers.push({ idx: step.slow, label: 'slow', color: 'var(--slow)', emoji: '🐢' });
if (step.fast >= 0) activePointers.push({ idx: step.fast, label: 'fast', color: 'var(--fast)', emoji: '🐇' });
}
if (step.phase === 2 || step.phase === 'result') {
if (step.ptr1 >= 0) activePointers.push({ idx: step.ptr1, label: 'ptr1', color: 'var(--ptr1)', emoji: '🟢' });
if (step.ptr2 >= 0) activePointers.push({ idx: step.ptr2, label: 'ptr2', color: 'var(--ptr2)', emoji: '🟣' });
}
// Group by node index to handle overlaps
const byNode = {};
activePointers.forEach(p => {
if (!byNode[p.idx]) byNode[p.idx] = [];
byNode[p.idx].push(p);
});
Object.keys(byNode).forEach(idxStr => {
const idx = parseInt(idxStr);
const ptrs = byNode[idx];
const p = positions[idx];
const centerX = p.x + NW / 2;
const totalW = ptrs.length * 42 - 6;
const startX = centerX - totalW / 2;
ptrs.forEach((ptr, pi) => {
const bx = startX + pi * 42;
const by = PTR_Y + (idx === step.meetingIdx && idx !== step.entryIdx ? -22 : 0);
const label = ptr.emoji + ' ' + ptr.label;
const tw = label.length * 6.5 + 8;
html += `<rect class="ptr-badge-rect" x="${bx}" y="${by - badgeH/2}" width="${tw}" height="${badgeH}" fill="${ptr.color}" fill-opacity="0.18" stroke="${ptr.color}" stroke-width="1"/>`;
html += `<text class="ptr-label" x="${bx + tw/2}" y="${by}" fill="${ptr.color}">${label}</text>`;
// Small connecting line from badge to node
html += `<line x1="${bx + tw/2}" y1="${by - badgeH/2}" x2="${bx + tw/2}" y2="${p.y + NH/2}" stroke="${ptr.color}" stroke-width="1" stroke-opacity="0.5" stroke-dasharray="3 2"/>`;
});
});
// ── Meeting / Entry markers ──
if (step.meetingIdx >= 0 && step.meetingIdx !== step.entryIdx) {
const p = positions[step.meetingIdx];
html += `<text x="${p.x + NW/2}" y="${p.y - NH/2 - 18}" text-anchor="middle" fill="#f59e0b" font-size="11" font-weight="700">⭐ 相遇点</text>`;
}
if (step.entryIdx >= 0) {
const p = positions[step.entryIdx];
const labelY = step.meetingIdx === step.entryIdx ? p.y - NH/2 - 30 : p.y - NH/2 - 18;
html += `<text x="${p.x + NW/2}" y="${labelY}" text-anchor="middle" fill="#fbbf24" font-size="11" font-weight="700">🎯 入口</text>`;
}
// ── Phase indicator in SVG corner ──
let phaseText = '', phaseColor = '';
if (step.phase === 1) { phaseText = 'PHASE 1: 检测环'; phaseColor = 'var(--phase1)'; }
else if (step.phase === 2) { phaseText = 'PHASE 2: 找入口'; phaseColor = 'var(--phase2)'; }
else if (step.phase === 'result') { phaseText = 'RESULT'; phaseColor = '#059669'; }
if (phaseText) {
html += `<rect x="14" y="12" width="${phaseText.length * 8 + 16}" height="24" rx="5" fill="${phaseColor}" fill-opacity="0.15" stroke="${phaseColor}" stroke-width="1"/>`;
html += `<text x="22" y="28" fill="${phaseColor}" font-size="12" font-weight="700">${phaseText}</text>`;
}
svg.innerHTML = html;
}
/* ═══════════════════════════════════════════
UI Update
═══════════════════════════════════════════ */
function updatePipeline(stageIdx) {
for (let i = 0; i < 4; i++) {
const el = document.getElementById('pipe-' + i);
el.classList.toggle('active', i <= stageIdx);
}
}
function updateHint(step) {
const hintEl = document.getElementById('hint-text');
const box = document.getElementById('hint-box');
let tag = '';
if (step.phaseTag === 'p1') tag = '<span class="phase-tag p1">阶段1</span>';
if (step.phaseTag === 'p2') tag = '<span class="phase-tag p2">阶段2</span>';
if (step.phaseTag === 'done') tag = '<span class="phase-tag done">完成</span>';
hintEl.innerHTML = tag + ' ' + escHtml(step.hint);
}
function updateDetail(step) {
document.getElementById('detail-body').innerHTML = step.detail;
}
function updateResult(step) {
const card = document.getElementById('result-card');
const body = document.getElementById('result-body');
if (step.phase === 'result') {
card.classList.remove('result-hidden');
if (step.resultVal === null) {
body.innerHTML = '该链表没有环,返回 <span class="null-val">null</span>';
} else {
body.innerHTML = `环的入口节点: <span class="val">${step.resultVal.val}</span> (索引 ${step.resultVal.idx})`;
}
} else {
card.classList.add('result-hidden');
}
}
function updateCodeHighlight(step) {
const block = document.getElementById('code-block');
const spans = block.querySelectorAll('span');
// Remove old highlights
spans.forEach(s => s.classList.remove('hl-line'));
// Determine which logical lines to highlight
const lines = block.innerHTML.split('\n');
let hlLine = -1;
if (step.phase === 1 && step.pipelineStage === 0 && step.slow === 0 && step.fast === 0 && !step.meetingIdx && step.meetingIdx !== 0) {
hlLine = 4; // slow = fast = head
} else if (step.phase === 1 && step.pipelineStage === 0) {
hlLine = 7; // while fast
} else if (step.phase === 1 && step.pipelineStage === 1) {
hlLine = 10; // if slow == fast
} else if (step.phase === 2 && step.ptr1 === 0) {
hlLine = 13; // ptr = head
} else if (step.phase === 2) {
hlLine = 15; // while ptr != slow
} else if (step.phase === 'result' && step.resultVal !== null) {
hlLine = 17; // return ptr
} else if (step.phase === 'result') {
hlLine = 18; // return None
}
// Apply highlight (simple approach: mark the line)
if (hlLine >= 0 && hlLine < lines.length) {
if (!lines[hlLine].includes('hl-line')) {
lines[hlLine] = lines[hlLine].replace(/^(<span[^>]*>)/, '<span class="hl-line">');
}
block.innerHTML = lines.join('\n');
}
}
function updateStepCounter(cur, total) {
document.getElementById('step-counter').textContent = `${cur + 1} / ${total}`;
}
function updateControls(idx, total, playing) {
document.getElementById('btn-prev').disabled = idx <= 0;
document.getElementById('btn-first').disabled = idx <= 0;
document.getElementById('btn-next').disabled = idx >= total - 1;
document.getElementById('btn-last').disabled = idx >= total - 1;
const playBtn = document.getElementById('btn-play');
playBtn.textContent = playing ? '⏸' : '▶';
playBtn.classList.toggle('playing', playing);
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* ═══════════════════════════════════════════
Main Controller
═══════════════════════════════════════════ */
const sc = new StepController();
let currentValues = [], currentPos = -1;
function loadExample(idx) {
const ex = EXAMPLES[idx];
currentValues = ex.values; currentPos = ex.pos;
document.getElementById('custom-input').value = `head=[${ex.values.join(',')}], pos=${ex.pos}`;
// Update button states
document.querySelectorAll('.example-btn').forEach((b, i) => b.classList.toggle('active', i === idx));
startViz();
}
function loadCustom() {
const str = document.getElementById('custom-input').value.trim();
const parsed = parseInput(str);
if (!parsed) { alert('输入格式有误,请使用 head=[3,2,0,-4], pos=1'); return; }
currentValues = parsed.values; currentPos = parsed.pos;
document.querySelectorAll('.example-btn').forEach(b => b.classList.remove('active'));
startViz();
}
function startViz() {
sc.pause();
const steps = generateSteps(currentValues, currentPos);
sc.setSteps(steps);
renderCurrent(0, steps[0]);
}
function renderCurrent(idx, step) {
render(currentValues, currentPos, step);
updatePipeline(step.pipelineStage);
updateHint(step);
updateDetail(step);
updateResult(step);
updateCodeHighlight(step);
updateStepCounter(idx, sc.total());
updateControls(idx, sc.total(), sc.playing);
}
sc.onStep = renderCurrent;
sc.onPlay = function(playing) {
updateControls(sc.idx, sc.total(), playing);
};
/* ── Event Listeners ── */
document.querySelectorAll('.example-btn').forEach(btn => {
btn.addEventListener('click', () => loadExample(parseInt(btn.dataset.idx)));
});
document.getElementById('run-btn').addEventListener('click', loadCustom);
document.getElementById('custom-input').addEventListener('keydown', e => {
if (e.key === 'Enter') loadCustom();
});
document.getElementById('btn-first').addEventListener('click', () => sc.first());
document.getElementById('btn-prev').addEventListener('click', () => sc.prev());
document.getElementById('btn-play').addEventListener('click', () => sc.toggle());
document.getElementById('btn-next').addEventListener('click', () => sc.next());
document.getElementById('btn-last').addEventListener('click', () => sc.last());
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'ArrowLeft') { e.preventDefault(); sc.prev(); }
if (e.key === 'ArrowRight') { e.preventDefault(); sc.next(); }
if (e.key === ' ') { e.preventDefault(); sc.toggle(); }
});
/* ── Init ── */
loadExample(0);
})();
</script>
</body>
</html>