293 lines
12 KiB
HTML
293 lines
12 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-Hans">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>093. 最长回文子串 – 图解</title>
|
|||
|
|
<link rel="stylesheet" href="../shared/style.css">
|
|||
|
|
<style>.vis-area { min-height: 120px; padding: 16px 0; } .code-section { margin-top: 16px; }
|
|||
|
|
.expansion-line{display:flex;margin-top:4px;justify-content:center;font-family:var(--mono);font-size:.75rem;color:var(--text2);}
|
|||
|
|
.bracket{padding:0 2px;}
|
|||
|
|
.arrow-l,.arrow-r{color:var(--orange);font-weight:700;}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body><div class="container">
|
|||
|
|
<h1>🟡 093. 最长回文子串 <span class="badge medium">中等</span></h1>
|
|||
|
|
<p class="subtitle">分类:字符串 | LeetCode Hot 100</p>
|
|||
|
|
<div class="controls" id="controls">
|
|||
|
|
<label>输入:</label><input type="text" id="inputArea" value="babad">
|
|||
|
|
<button id="applyBtn" class="primary">生成图解</button>
|
|||
|
|
<select id="exampleSelect"></select>
|
|||
|
|
<span style="flex:1"></span>
|
|||
|
|
<button id="prevBtn">◀ 上一步</button>
|
|||
|
|
<button id="nextBtn">下一步 ▶</button>
|
|||
|
|
<button id="jumpBtn">⏭ 跳到结果</button>
|
|||
|
|
<button id="autoBtn">自动播放</button>
|
|||
|
|
<button id="resetBtn">重置</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="pipeline" id="pipeline"></div>
|
|||
|
|
<div class="hint info" id="hintBox"><span id="stepInfo"></span><br><span id="hintText"></span></div>
|
|||
|
|
<div class="panels">
|
|||
|
|
<div class="panel" id="mainPanel"><h3>📊 可视化</h3><div class="vis-area" id="vizArea"></div></div>
|
|||
|
|
<div class="panel-grid">
|
|||
|
|
<div class="panel" id="detailPanel"><h3>📝 当前步骤详情</h3><div id="detailContent"></div></div>
|
|||
|
|
<div class="panel" id="resultPanel"><h3>✅ 结果</h3><div id="resultContent"></div></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="panel code-section"><h3>💻 参考代码(Python)</h3><div id="codeArea"></div></div>
|
|||
|
|
<footer>Powered by QwenPaw · 图解算法 · LeetCode Hot 100</footer>
|
|||
|
|
</div>
|
|||
|
|
<script src="../shared/algo-viz.js"></script>
|
|||
|
|
<script>"use strict";(function(){
|
|||
|
|
const CODE = `def longestPalindrome(s):
|
|||
|
|
def expand(l, r):
|
|||
|
|
while l >= 0 and r < len(s) and s[l] == s[r]:
|
|||
|
|
l -= 1
|
|||
|
|
r += 1
|
|||
|
|
return l + 1, r - 1 # 回文起止
|
|||
|
|
|
|||
|
|
best_l, best_r = 0, 0
|
|||
|
|
for i in range(len(s)):
|
|||
|
|
# 奇数长度
|
|||
|
|
l1, r1 = expand(i, i)
|
|||
|
|
if r1 - l1 > best_r - best_l:
|
|||
|
|
best_l, best_r = l1, r1
|
|||
|
|
# 偶数长度
|
|||
|
|
l2, r2 = expand(i, i + 1)
|
|||
|
|
if r2 - l2 > best_r - best_l:
|
|||
|
|
best_l, best_r = l2, r2
|
|||
|
|
return s[best_l:best_r+1]`;
|
|||
|
|
|
|||
|
|
const EXAMPLES = [
|
|||
|
|
{ name: '例1: babad', input: 'babad' },
|
|||
|
|
{ name: '例2: cbbd', input: 'cbbd' },
|
|||
|
|
{ name: '例3: a', input: 'a' },
|
|||
|
|
{ name: '例4: racecar', input: 'racecar' },
|
|||
|
|
{ name: '例5: abacdfgdcaba', input: 'abacdfgdcaba' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
let controller = null;
|
|||
|
|
|
|||
|
|
function expandAroundCenter(s, l, r) {
|
|||
|
|
const steps = [];
|
|||
|
|
while (l >= 0 && r < s.length && s[l] === s[r]) {
|
|||
|
|
steps.push({ l, r, matched: true });
|
|||
|
|
l--; r++;
|
|||
|
|
}
|
|||
|
|
steps.push({ l: l + 1, r: r - 1, matched: false, isFinal: true });
|
|||
|
|
return steps;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function genSteps(s) {
|
|||
|
|
const steps = [];
|
|||
|
|
const n = s.length;
|
|||
|
|
let bestL = 0, bestR = 0;
|
|||
|
|
|
|||
|
|
steps.push({
|
|||
|
|
desc: '初始化:对每个位置做中心扩展',
|
|||
|
|
hint: '枚举每个中心位置,向两侧扩展,找到最长回文。',
|
|||
|
|
detail: '<b>思路</b>:中心扩展法<br>• 对每个下标 i,分别以 (i,i) 和 (i,i+1) 为中心<br>• 向两侧扩展直到不等<br>• 记录最长回文的起止位置<br><br>时间 O(n²),空间 O(1)',
|
|||
|
|
hlLine: -1, centerIdx: -1, centerType: '',
|
|||
|
|
expL: -1, expR: -1, bestL: 0, bestR: 0,
|
|||
|
|
cellCls: s.split('').map(() => ''),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (let i = 0; i < n; i++) {
|
|||
|
|
// odd length
|
|||
|
|
const oddSteps = expandAroundCenter(s, i, i);
|
|||
|
|
for (const es of oddSteps) {
|
|||
|
|
const cellCls = s.split('').map(() => '');
|
|||
|
|
// mark palindrome range
|
|||
|
|
if (es.l >= 0 && es.r < n) {
|
|||
|
|
for (let j = es.l; j <= es.r; j++) cellCls[j] = 'palindrome';
|
|||
|
|
}
|
|||
|
|
// mark center
|
|||
|
|
cellCls[i] = 'center';
|
|||
|
|
// mark expansion pointers
|
|||
|
|
if (es.l !== i || es.r !== i) {
|
|||
|
|
if (es.l >= 0) cellCls[es.l] = es.matched ? 'expand' : 'palindrome';
|
|||
|
|
if (es.r < n) cellCls[es.r] = es.matched ? 'expand' : 'palindrome';
|
|||
|
|
}
|
|||
|
|
// mark best
|
|||
|
|
for (let j = bestL; j <= bestR; j++) cellCls[j] = 'best';
|
|||
|
|
// but center takes priority visual
|
|||
|
|
|
|||
|
|
if (es.isFinal) {
|
|||
|
|
const newBest = es.r - es.l > bestR - bestL;
|
|||
|
|
if (newBest) { bestL = es.l; bestR = es.r; }
|
|||
|
|
steps.push({
|
|||
|
|
desc: `中心 i=${i}(奇数):扩展到 [${es.l}, ${es.r}],回文 "${s.substring(es.l, es.r + 1)}"${newBest ? ' → 更新最优!' : ''}`,
|
|||
|
|
hint: newBest ? `找到更长回文 "${s.substring(es.l, es.r + 1)}"(长度 ${es.r - es.l + 1})` : `回文长度 ${es.r - es.l + 1},未超过当前最优 ${bestR - bestL + 1}`,
|
|||
|
|
detail: `中心 i=<b>${i}</b>(奇数长度)<br>扩展结果:[${es.l}, ${es.r}] = "${s.substring(es.l, es.r + 1)}"<br>长度 = ${es.r - es.l + 1}${newBest ? '<br><span style="color:var(--green)">更新最优!</span>' : '<br>未超过当前最优 ' + (bestR - bestL + 1)}`,
|
|||
|
|
hlLine: 10,
|
|||
|
|
centerIdx: i, centerType: 'odd',
|
|||
|
|
expL: es.l, expR: es.r, bestL, bestR,
|
|||
|
|
cellCls,
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
steps.push({
|
|||
|
|
desc: `中心 i=${i}(奇数):s[${es.l}]='${s[es.l]}' == s[${es.r}]='${s[es.r]}',扩展到 [${es.l}, ${es.r}]`,
|
|||
|
|
hint: `字符匹配,继续向外扩展。`,
|
|||
|
|
detail: `中心 i=<b>${i}</b>(奇数)<br>比较 s[${es.l}]='${s[es.l]}' ↔ s[${es.r}]='${s[es.r]}'<br>✅ 匹配!范围 [${es.l}, ${es.r}]`,
|
|||
|
|
hlLine: 3,
|
|||
|
|
centerIdx: i, centerType: 'odd',
|
|||
|
|
expL: es.l, expR: es.r, bestL, bestR,
|
|||
|
|
cellCls,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// even length
|
|||
|
|
if (i + 1 < n) {
|
|||
|
|
const evenSteps = expandAroundCenter(s, i, i + 1);
|
|||
|
|
for (const es of evenSteps) {
|
|||
|
|
const cellCls = s.split('').map(() => '');
|
|||
|
|
if (es.l >= 0 && es.r < n) {
|
|||
|
|
for (let j = es.l; j <= es.r; j++) cellCls[j] = 'palindrome';
|
|||
|
|
}
|
|||
|
|
cellCls[i] = 'center';
|
|||
|
|
if (i + 1 < n) cellCls[i + 1] = 'center';
|
|||
|
|
if (es.l !== i || es.r !== i + 1) {
|
|||
|
|
if (es.l >= 0 && es.l !== i) cellCls[es.l] = es.matched ? 'expand' : 'palindrome';
|
|||
|
|
if (es.r < n && es.r !== i + 1) cellCls[es.r] = es.matched ? 'expand' : 'palindrome';
|
|||
|
|
}
|
|||
|
|
for (let j = bestL; j <= bestR; j++) cellCls[j] = 'best';
|
|||
|
|
|
|||
|
|
if (es.isFinal) {
|
|||
|
|
const newBest = es.r - es.l > bestR - bestL;
|
|||
|
|
if (newBest) { bestL = es.l; bestR = es.r; }
|
|||
|
|
steps.push({
|
|||
|
|
desc: `中心 i=${i},${i+1}(偶数):扩展到 [${es.l}, ${es.r}],回文 "${s.substring(es.l, es.r + 1)}"${newBest ? ' → 更新最优!' : ''}`,
|
|||
|
|
hint: newBest ? `找到更长回文 "${s.substring(es.l, es.r + 1)}"(长度 ${es.r - es.l + 1})` : `回文长度 ${es.r - es.l + 1},未超过当前最优 ${bestR - bestL + 1}`,
|
|||
|
|
detail: `中心 i=<b>${i},${i+1}</b>(偶数长度)<br>扩展结果:[${es.l}, ${es.r}] = "${s.substring(es.l, es.r + 1)}"<br>长度 = ${es.r - es.l + 1}${newBest ? '<br><span style="color:var(--green)">更新最优!</span>' : '<br>未超过当前最优 ' + (bestR - bestL + 1)}`,
|
|||
|
|
hlLine: 12,
|
|||
|
|
centerIdx: i, centerType: 'even',
|
|||
|
|
expL: es.l, expR: es.r, bestL, bestR,
|
|||
|
|
cellCls,
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
steps.push({
|
|||
|
|
desc: `中心 i=${i},${i+1}(偶数):s[${es.l}]='${s[es.l]}' == s[${es.r}]='${s[es.r]}',扩展到 [${es.l}, ${es.r}]`,
|
|||
|
|
hint: `字符匹配,继续向外扩展。`,
|
|||
|
|
detail: `中心 i=<b>${i},${i+1}</b>(偶数)<br>比较 s[${es.l}]='${s[es.l]}' ↔ s[${es.r}]='${s[es.r]}'<br>✅ 匹配!范围 [${es.l}, ${es.r}]`,
|
|||
|
|
hlLine: 3,
|
|||
|
|
centerIdx: i, centerType: 'even',
|
|||
|
|
expL: es.l, expR: es.r, bestL, bestR,
|
|||
|
|
cellCls,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// final
|
|||
|
|
const finalCls = s.split('').map(() => '');
|
|||
|
|
for (let j = bestL; j <= bestR; j++) finalCls[j] = 'best';
|
|||
|
|
steps.push({
|
|||
|
|
desc: `遍历结束,最长回文子串 = "${s.substring(bestL, bestR + 1)}"`,
|
|||
|
|
hint: '中心扩展法穷举了所有可能的回文中心。',
|
|||
|
|
detail: `<b>结果</b>:最长回文子串 = <b>"${s.substring(bestL, bestR + 1)}"</b><br>位置:[${bestL}, ${bestR}]<br>长度:${bestR - bestL + 1}`,
|
|||
|
|
hlLine: -1, centerIdx: -1, centerType: '',
|
|||
|
|
expL: bestL, expR: bestR, bestL, bestR,
|
|||
|
|
cellCls: finalCls, result: s.substring(bestL, bestR + 1), isFinal: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return steps;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderStep(step) {
|
|||
|
|
if (!step) {
|
|||
|
|
$('vizArea').innerHTML = '<div style="color:var(--text2);text-align:center;padding:40px;">点击「生成图解」开始</div>';
|
|||
|
|
$('detailContent').innerHTML = $('resultContent').innerHTML = '';
|
|||
|
|
$('stepInfo').textContent = $('hintText').textContent = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$('stepInfo').textContent = step.desc;
|
|||
|
|
$('hintText').textContent = step.hint;
|
|||
|
|
$('detailContent').innerHTML = step.detail;
|
|||
|
|
|
|||
|
|
const s = $('inputArea').value.trim();
|
|||
|
|
|
|||
|
|
// string cells
|
|||
|
|
const row = document.createElement('div');
|
|||
|
|
row.className = 'str-row';
|
|||
|
|
s.split('').forEach((c, i) => {
|
|||
|
|
const cell = document.createElement('div');
|
|||
|
|
const cls = step.cellCls[i] || '';
|
|||
|
|
cell.className = 'str-cell ' + cls;
|
|||
|
|
cell.innerHTML = `${c}<span class="str-idx">${i}</span>`;
|
|||
|
|
row.appendChild(cell);
|
|||
|
|
});
|
|||
|
|
$('vizArea').innerHTML = '';
|
|||
|
|
$('vizArea').appendChild(row);
|
|||
|
|
|
|||
|
|
// expansion arrows
|
|||
|
|
if (step.expL >= 0 && step.expR >= 0 && step.centerIdx >= 0) {
|
|||
|
|
const arrow = document.createElement('div');
|
|||
|
|
arrow.className = 'expansion-line';
|
|||
|
|
const ptrs = s.split('').map((_, i) => {
|
|||
|
|
if (i === step.expL && i === step.expR) return '<span style="color:var(--orange)">▲</span>';
|
|||
|
|
if (i === step.expL) return '<span class="arrow-l">◀</span>';
|
|||
|
|
if (i === step.expR) return '<span class="arrow-r">▶</span>';
|
|||
|
|
return ' ';
|
|||
|
|
});
|
|||
|
|
arrow.innerHTML = ptrs.join(' ');
|
|||
|
|
$('vizArea').appendChild(arrow);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// stats
|
|||
|
|
const stats = document.createElement('div');
|
|||
|
|
stats.style.cssText = 'text-align:center;margin-top:8px;font-family:var(--mono);font-size:.82rem;';
|
|||
|
|
const best = step.bestR - step.bestL + 1;
|
|||
|
|
stats.innerHTML = `当前最优 = <span style="color:var(--green)">"${s.substring(step.bestL, step.bestR + 1)}"</span>(长度 ${best})`;
|
|||
|
|
$('vizArea').appendChild(stats);
|
|||
|
|
|
|||
|
|
const legend = document.createElement('div');
|
|||
|
|
legend.className = 'legend';
|
|||
|
|
legend.innerHTML = `
|
|||
|
|
<span class="legend-item"><span class="legend-dot" style="background:var(--orange)"></span> 中心位置</span>
|
|||
|
|
<span class="legend-item"><span class="legend-dot" style="background:var(--purple)"></span> 扩展边界</span>
|
|||
|
|
<span class="legend-item"><span class="legend-dot" style="background:var(--green)"></span> 最长回文</span>
|
|||
|
|
`;
|
|||
|
|
$('vizArea').appendChild(legend);
|
|||
|
|
|
|||
|
|
renderCode($('codeArea'), CODE, step.hlLine);
|
|||
|
|
|
|||
|
|
if (step.isFinal) {
|
|||
|
|
$('resultContent').innerHTML = `<div class="result-box">最长回文子串 = <span class="val">"${step.result}"</span><br><small>位置 [${step.bestL}, ${step.bestR}],长度 ${step.bestR - step.bestL + 1}</small></div>`;
|
|||
|
|
} else {
|
|||
|
|
$('resultContent').innerHTML = `<div style="color:var(--text2);font-size:.85rem;">等待遍历完成…</div>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function init() {
|
|||
|
|
const sel = $('exampleSelect');
|
|||
|
|
EXAMPLES.forEach((ex, i) => {
|
|||
|
|
const o = document.createElement('option');
|
|||
|
|
o.value = i; o.textContent = ex.name;
|
|||
|
|
sel.appendChild(o);
|
|||
|
|
});
|
|||
|
|
sel.onchange = () => { $('inputArea').value = EXAMPLES[sel.value].input; build(); };
|
|||
|
|
$('applyBtn').onclick = build;
|
|||
|
|
$('inputArea').onkeydown = e => { if (e.key === 'Enter') build(); };
|
|||
|
|
build();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function build() {
|
|||
|
|
const s = $('inputArea').value.trim();
|
|||
|
|
const steps = genSteps(s);
|
|||
|
|
if (controller) controller.stopAuto();
|
|||
|
|
controller = new StepController(steps, { onRender: renderStep });
|
|||
|
|
$('nextBtn').onclick = () => controller.next();
|
|||
|
|
$('prevBtn').onclick = () => controller.prev();
|
|||
|
|
$('jumpBtn').onclick = () => controller.jumpEnd();
|
|||
|
|
$('resetBtn').onclick = () => controller.reset();
|
|||
|
|
$('autoBtn').onclick = () => { if (controller.autoTimer) controller.stopAuto(); else controller.startAuto(); };
|
|||
|
|
controller.next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
|||
|
|
else init();
|
|||
|
|
})()</script>
|
|||
|
|
</body></html>
|