259 lines
10 KiB
HTML
259 lines
10 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>092. 最小路径和 – 图解</title>
|
|||
|
|
<link rel="stylesheet" href="../shared/style.css">
|
|||
|
|
<style>.vis-area { min-height: 120px; padding: 16px 0; } .code-section { margin-top: 16px; }
|
|||
|
|
.grid-vis{display:inline-grid;gap:3px;margin:8px auto;}
|
|||
|
|
.grid-cell2{min-width:44px;min-height:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:var(--mono);border-radius:4px;background:var(--surface2);border:2px solid var(--border);transition:all .25s;padding:2px;}
|
|||
|
|
.grid-cell2 .gval{font-size:.8rem;font-weight:600;}
|
|||
|
|
.grid-cell2 .gdp{font-size:.65rem;color:var(--text2);}
|
|||
|
|
.grid-cell2.computing{border-color:var(--orange);background:var(--orange-dim);}
|
|||
|
|
.grid-cell2.filled{border-color:var(--accent);background:rgba(108,126,255,.08);}
|
|||
|
|
.grid-cell2.path{border-color:var(--green);background:var(--green-dim);}
|
|||
|
|
.grid-cell2.origin{border-color:var(--purple);background:var(--purple-dim);}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body><div class="container">
|
|||
|
|
<h1>🟡 092. 最小路径和 <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="[[1,3,1],[1,5,1],[4,2,1]]">
|
|||
|
|
<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 minPathSum(grid):
|
|||
|
|
m, n = len(grid), len(grid[0])
|
|||
|
|
dp = [[0]*n for _ in range(m)]
|
|||
|
|
dp[0][0] = grid[0][0]
|
|||
|
|
# 第一行
|
|||
|
|
for j in range(1, n):
|
|||
|
|
dp[0][j] = dp[0][j-1] + grid[0][j]
|
|||
|
|
# 第一列
|
|||
|
|
for i in range(1, m):
|
|||
|
|
dp[i][0] = dp[i-1][0] + grid[i][0]
|
|||
|
|
# 其余
|
|||
|
|
for i in range(1, m):
|
|||
|
|
for j in range(1, n):
|
|||
|
|
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
|
|||
|
|
return dp[m-1][n-1]`;
|
|||
|
|
|
|||
|
|
const EXAMPLES = [
|
|||
|
|
{ name: '例1: [[1,3,1],[1,5,1],[4,2,1]]', input: '[[1,3,1],[1,5,1],[4,2,1]]' },
|
|||
|
|
{ name: '例2: [[1,2,3],[4,5,6]]', input: '[[1,2,3],[4,5,6]]' },
|
|||
|
|
{ name: '例3: [[1]]', input: '[[1]]' },
|
|||
|
|
{ name: '例4: [[1,2],[1,1]]', input: '[[1,2],[1,1]]' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
let controller = null;
|
|||
|
|
|
|||
|
|
function genSteps(grid) {
|
|||
|
|
const steps = [];
|
|||
|
|
const m = grid.length, n = grid[0].length;
|
|||
|
|
const dp = Array.from({length: m}, () => Array(n).fill(null));
|
|||
|
|
|
|||
|
|
// init step
|
|||
|
|
steps.push({
|
|||
|
|
desc: '初始化 DP 表:dp[0][0] = grid[0][0]',
|
|||
|
|
hint: '从左上角出发,只能向右或向下走,求到右下角的最小路径和。',
|
|||
|
|
detail: '<b>思路</b>:dp[i][j] = 从 (0,0) 到 (i,j) 的最小路径和<br>• dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]<br>• 第一行、第一列只有一条路径<br><br>时间 O(mn),空间 O(mn)',
|
|||
|
|
hlLine: -1, curI: -1, curJ: -1,
|
|||
|
|
dp: dp.map(r => [...r]), pathCells: [],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// (0,0)
|
|||
|
|
dp[0][0] = grid[0][0];
|
|||
|
|
steps.push({
|
|||
|
|
desc: `dp[0][0] = ${grid[0][0]}(起点)`,
|
|||
|
|
hint: '左上角只有一个值,路径和就是自身。',
|
|||
|
|
detail: `dp[0][0] = grid[0][0] = <b>${grid[0][0]}</b>`,
|
|||
|
|
hlLine: 3, curI: 0, curJ: 0,
|
|||
|
|
dp: dp.map(r => [...r]), pathCells: [[0,0]],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// first row
|
|||
|
|
for (let j = 1; j < n; j++) {
|
|||
|
|
dp[0][j] = dp[0][j-1] + grid[0][j];
|
|||
|
|
steps.push({
|
|||
|
|
desc: `第一行 dp[0][${j}] = dp[0][${j-1}] + grid[0][${j}] = ${dp[0][j-1]} + ${grid[0][j]} = ${dp[0][j]}`,
|
|||
|
|
hint: '第一行只能从左边来。',
|
|||
|
|
detail: `dp[0][${j}] = dp[0][${j-1}] + grid[0][${j}]<br>= ${dp[0][j-1]} + ${grid[0][j]} = <b>${dp[0][j]}</b>`,
|
|||
|
|
hlLine: 6, curI: 0, curJ: j,
|
|||
|
|
dp: dp.map(r => [...r]), pathCells: [],
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// first col
|
|||
|
|
for (let i = 1; i < m; i++) {
|
|||
|
|
dp[i][0] = dp[i-1][0] + grid[i][0];
|
|||
|
|
steps.push({
|
|||
|
|
desc: `第一列 dp[${i}][0] = dp[${i-1}][0] + grid[${i}][0] = ${dp[i-1][0]} + ${grid[i][0]} = ${dp[i][0]}`,
|
|||
|
|
hint: '第一列只能从上边来。',
|
|||
|
|
detail: `dp[${i}][0] = dp[${i-1}][0] + grid[${i}][0]<br>= ${dp[i-1][0]} + ${grid[i][0]} = <b>${dp[i][0]}</b>`,
|
|||
|
|
hlLine: 9, curI: i, curJ: 0,
|
|||
|
|
dp: dp.map(r => [...r]), pathCells: [],
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// rest
|
|||
|
|
for (let i = 1; i < m; i++) {
|
|||
|
|
for (let j = 1; j < n; j++) {
|
|||
|
|
const fromTop = dp[i-1][j];
|
|||
|
|
const fromLeft = dp[i][j-1];
|
|||
|
|
const choice = fromTop <= fromLeft ? 'top' : 'left';
|
|||
|
|
dp[i][j] = Math.min(fromTop, fromLeft) + grid[i][j];
|
|||
|
|
steps.push({
|
|||
|
|
desc: `dp[${i}][${j}] = min(dp[${i-1}][${j}]=${fromTop}, dp[${i}][${j-1}]=${fromLeft}) + grid[${i}][${j}]=${grid[i][j]} = ${dp[i][j]}`,
|
|||
|
|
hint: `从${choice === 'top' ? '上方' : '左方'}来更优,dp[${i}][${j}] = ${dp[i][j]}。`,
|
|||
|
|
detail: `dp[${i}][${j}] = min(${fromTop}, ${fromLeft}) + ${grid[i][j]}<br>= min(←${fromLeft}, ↑${fromTop}) + ${grid[i][j]}<br>= <b>${dp[i][j]}</b><br>选择:${choice === 'top' ? '↑ 上方' : '← 左方'}`,
|
|||
|
|
hlLine: 12, curI: i, curJ: j,
|
|||
|
|
dp: dp.map(r => [...r]), pathCells: [],
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// backtrack path
|
|||
|
|
const pathCells = [];
|
|||
|
|
let pi = m - 1, pj = n - 1;
|
|||
|
|
pathCells.push([pi, pj]);
|
|||
|
|
while (pi > 0 || pj > 0) {
|
|||
|
|
if (pi === 0) pj--;
|
|||
|
|
else if (pj === 0) pi--;
|
|||
|
|
else if (dp[pi-1][pj] <= dp[pi][pj-1]) pi--;
|
|||
|
|
else pj--;
|
|||
|
|
pathCells.push([pi, pj]);
|
|||
|
|
}
|
|||
|
|
pathCells.reverse();
|
|||
|
|
|
|||
|
|
steps.push({
|
|||
|
|
desc: `遍历完成,最小路径和 = ${dp[m-1][n-1]}`,
|
|||
|
|
hint: '回溯找到最优路径。',
|
|||
|
|
detail: `<b>结果</b>:最小路径和 = <b>${dp[m-1][n-1]}</b><br>路径:${pathCells.map(([i,j]) => `(${i},${j})`).join(' → ')}`,
|
|||
|
|
hlLine: 13, curI: -1, curJ: -1,
|
|||
|
|
dp: dp.map(r => [...r]), pathCells, result: dp[m-1][n-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 grid = parseGrid($('inputArea').value);
|
|||
|
|
const m = grid.length, n = grid[0].length;
|
|||
|
|
const dp = step.dp;
|
|||
|
|
|
|||
|
|
const pathSet = new Set((step.pathCells || []).map(([i,j]) => i+','+j));
|
|||
|
|
|
|||
|
|
const g = document.createElement('div');
|
|||
|
|
g.className = 'grid-vis';
|
|||
|
|
g.style.gridTemplateColumns = `repeat(${n}, auto)`;
|
|||
|
|
|
|||
|
|
for (let i = 0; i < m; i++) {
|
|||
|
|
for (let j = 0; j < n; j++) {
|
|||
|
|
const cell = document.createElement('div');
|
|||
|
|
let cls = 'grid-cell2';
|
|||
|
|
if (i === step.curI && j === step.curJ) cls += ' computing';
|
|||
|
|
else if (dp[i][j] !== null) cls += ' filled';
|
|||
|
|
if (pathSet.has(i+','+j)) cls += ' path';
|
|||
|
|
if (i === 0 && j === 0) cls += ' origin';
|
|||
|
|
cell.className = cls;
|
|||
|
|
cell.innerHTML = `<span class="gval">${grid[i][j]}</span><span class="gdp">${dp[i][j] !== null ? 'dp=' + dp[i][j] : ''}</span>`;
|
|||
|
|
g.appendChild(cell);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$('vizArea').innerHTML = '';
|
|||
|
|
const wrapper = document.createElement('div');
|
|||
|
|
wrapper.style.cssText = 'display:flex;justify-content:center;';
|
|||
|
|
wrapper.appendChild(g);
|
|||
|
|
$('vizArea').appendChild(wrapper);
|
|||
|
|
|
|||
|
|
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(--accent)"></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.pathCells.map(([i,j]) => `(${i},${j})`).join(' → ')}</small></div>`;
|
|||
|
|
} else {
|
|||
|
|
$('resultContent').innerHTML = `<div style="color:var(--text2);font-size:.85rem;">等待填充完成…</div>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseGrid(s) {
|
|||
|
|
try {
|
|||
|
|
const a = JSON.parse(s.replace(/'/g, '"'));
|
|||
|
|
if (Array.isArray(a) && a.every(r => Array.isArray(r) && r.every(x => typeof x === 'number'))) return a;
|
|||
|
|
} catch(e) {}
|
|||
|
|
return [[1,3,1],[1,5,1],[4,2,1]];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 grid = parseGrid($('inputArea').value);
|
|||
|
|
const steps = genSteps(grid);
|
|||
|
|
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>
|