Files
illustrated-algorithm/lc2035.html
T
wonder 49024c3e64 feat: 添加 LeetCode 2035 将数组分成两个数组并最小化数组和的差图解
- 实现 Meet in the Middle 算法的交互式可视化
- 包含前后半部分子集和生成、排序与二分查找过程的分步演示
- 支持自定义输入数组和调节动画速度
2026-08-17 10:00:02 +08:00

606 lines
23 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-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>035. 将数组分成两个数组并最小化数组和的差 – 图解</title>
<style>
:root {
--blue: #3b82f6; --green: #16a34a; --purple: #8b5cf6;
--bg: #f1f5f9; --card: #ffffff; --border: #e2e8f0;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: var(--bg); color: #0f172a;
}
.container { max-width: 1440px; margin: 0 auto; padding: 20px; }
h1 { font-size: 25px; margin: 0 0 12px; }
h3 { margin: 0 0 10px; font-size: 16px; }
.controls {
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
margin-bottom: 12px;
}
.controls input {
width: 240px; padding: 8px 10px; border: 1px solid #cbd5e1; border-radius: 8px;
font-family: monospace; font-size: 14px;
}
.controls button, .controls select {
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 8px;
background: #f8fafc; font-size: 14px; cursor: pointer;
}
.controls button:hover { background: #e2e8f0; }
.controls button:disabled { opacity: .45; cursor: not-allowed; }
.pipeline {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
background: #eef2ff; border-radius: 14px; padding: 10px 16px; margin-bottom: 12px;
font-size: 14px;
}
.pipe-step {
background: white; border: 1px solid #c7d2fe; border-radius: 999px;
padding: 4px 10px; color: #3730a3;
}
.pipe-step.active { background: #4f46e5; color: white; border-color: #4f46e5; font-weight: 600; }
.pipeline i { color: #94a3b8; font-style: normal; }
.hint {
background: #fff7ed; border-left: 4px solid #f59e0b; border-radius: 8px;
padding: 10px 16px; margin-bottom: 12px; line-height: 1.7;
}
.panels { display: flex; flex-direction: column; gap: 16px; }
.panel {
background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px;
}
.panel-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 900px) { .panel-grid { grid-template-columns: 1fr; } }
.formula-box {
background: #f0f9ff; border-radius: 12px; padding: 12px 16px;
font-size: 15px; line-height: 1.8; margin-bottom: 12px; color: #0c4a6e;
}
.nums-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.chip {
display: inline-block; min-width: 40px; text-align: center; padding: 6px 10px;
border-radius: 10px; font-family: monospace; font-weight: 600;
}
.chip.left { background: #dbeafe; color: #1e40af; }
.chip.right { background: #ffedd5; color: #9a3412; }
.split { font-size: 22px; font-weight: 800; margin: 0 8px; color: #64748b; }
.nums-meta { margin-top: 10px; font-size: 14px; color: #475569; }
.table-wrap { overflow-x: auto; margin-top: 8px; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border: 1px solid #e2e8f0; padding: 5px 8px; text-align: center; }
th { background: #f8fafc; color: #475569; }
.sign-plus { color: #16a34a; font-weight: 800; }
.sign-minus { color: #dc2626; font-weight: 800; }
.calc { font-family: monospace; font-size: 12px; color: #334155; }
.group-header { background: #eef2ff; font-weight: 600; }
.group-active { background: #c7d2fe; }
.nearest-row { background: #bbf7d0; }
.current-highlight { background: #bae6fd !important; }
.processed { opacity: .85; }
.dim { opacity: .3; }
.best-diff { font-weight: 800; color: var(--green); }
.nearest-val { color: var(--green); font-weight: 700; }
.chart-wrap {
background: #f8fafc; border: 1px solid var(--border); border-radius: 12px;
padding: 16px 16px 20px; margin-top: 14px;
}
.chart-scale {
position: relative; height: 80px; border-bottom: 2px solid #94a3b8;
margin: 0 8px 26px;
}
.dot {
position: absolute; transform: translateX(-50%); width: 14px; height: 14px;
border-radius: 50%; background: var(--blue); border: 2px solid white;
box-shadow: 0 0 0 2px var(--blue);
}
.dot.nearest { background: var(--green); box-shadow: 0 0 0 2px var(--green); }
.dot-label {
position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
background: rgba(255,255,255,.95); padding: 2px 5px; border-radius: 6px;
font-size: 12px; font-family: monospace; white-space: nowrap; border: 1px solid var(--border);
}
.target-line {
position: absolute; top: 0; bottom: 0; width: 2px; background: var(--purple);
transform: translateX(-50%);
}
.target-tick {
position: absolute; bottom: -22px; transform: translateX(-50%);
font-size: 12px; color: var(--purple); font-weight: 700; white-space: nowrap;
}
.scale-min-label { position: absolute; left: 0; bottom: -26px; font-size: 11px; color: #94a3b8; }
.scale-max-label { position: absolute; right: 0; bottom: -26px; font-size: 11px; color: #94a3b8; }
.calc-block {
background: #f8fafc; border-radius: 12px; padding: 12px 16px;
font-size: 15px; line-height: 1.9; margin-bottom: 10px;
}
.current-answer {
margin-top: 12px; padding: 10px 16px; background: #ecfdf5; border-radius: 10px;
font-size: 16px;
}
.final-answer {
margin-top: 16px; padding: 16px 20px; background: #ecfdf5;
border: 2px solid #86efac; border-radius: 14px; line-height: 1.9;
}
.final-answer b { color: var(--green); }
#debug {
display: none; background: #fee2e2; color: #b91c1c; padding: 12px; border-radius: 10px;
margin-bottom: 14px; white-space: pre-wrap;
}
</style>
</head>
<body>
<div class="container">
<h1>🧮 035. 将数组分成两个数组并最小化数组和的差</h1>
<div id="debug"></div>
<div class="controls">
<label for="numsInput">nums =</label>
<input id="numsInput" value="3, 9, 7, 3" title="偶数个整数,建议长度 ≤ 12">
<button id="applyBtn">生成图解</button>
<select id="exampleSelect">
<option value="[3,9,7,3]">示例 1:[3,9,7,3]</option>
<option value="[-36,36]">示例 2:[-36,36]</option>
<option value="[2,-1,0,4,-2,-9]">示例 3:[2,-1,0,4,-2,-9]</option>
</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">
<span class="pipe-step" data-stage="formula">① 符号转化</span><i>→</i>
<span class="pipe-step" data-stage="left">② 左半枚举</span><i>→</i>
<span class="pipe-step" data-stage="sort">③ 分组排序</span><i>→</i>
<span class="pipe-step" data-stage="right">④ 右半枚举 + 二分</span><i>→</i>
<span class="pipe-step" data-stage="done">⑤ 答案</span>
</div>
<div class="hint">
<span id="stepInfo"></span><br>
<span id="hintText"></span>
</div>
<div class="panels">
<div class="panel" id="numsPanel"></div>
<div class="panel-grid">
<div class="panel" id="leftPanel"></div>
<div class="panel" id="rightPanel"></div>
</div>
<div class="panel" id="resultPanel"></div>
</div>
</div>
<script>
"use strict";
(function() {
// ---------- 工具 ----------
const $ = id => document.getElementById(id);
function popcount(x) {
let c = 0;
while (x) { c += x & 1; x >>>= 1; }
return c;
}
function signHTML(signs) {
return signs.map(s => s === 1
? '<span class="sign-plus">+</span>'
: '<span class="sign-minus">−</span>').join('');
}
function signText(signs) {
return signs.map(s => s === 1 ? '+' : '-').join(' ');
}
function fmtArr(a) { return '[' + a.join(', ') + ']'; }
function signedTerm(s, v) {
const vv = v < 0 ? `(${v})` : `${v}`;
return (s === 1 ? '+' : '-') + vv;
}
// ---------- 全局状态 ----------
let nums = [3, 9, 7, 3];
let n = 0;
let left = [];
let right = [];
let leftRows = [];
let rightRows = [];
let leftGroups = [];
let steps = [];
let currentStep = 0;
let timer = null;
// ---------- 构建数据 ----------
function buildData() {
n = nums.length / 2;
left = nums.slice(0, n);
right = nums.slice(n);
leftRows = [];
rightRows = [];
const M = 1 << n;
for (let mask = 0; mask < M; mask++) {
const signs = [];
let sum = 0;
for (let i = 0; i < n; i++) {
const s = (mask & (1 << i)) ? 1 : -1;
signs.push(s);
sum += s * left[i];
}
leftRows.push({ id: mask, signs, k: popcount(mask), sum });
}
for (let mask = 0; mask < M; mask++) {
const signs = [];
let sum = 0;
for (let i = 0; i < n; i++) {
const s = (mask & (1 << i)) ? 1 : -1;
signs.push(s);
sum += s * right[i];
}
rightRows.push({ id: mask, signs, k: popcount(mask), sum });
}
leftGroups = [];
for (let k = 0; k <= n; k++) leftGroups.push([]);
for (const r of leftRows) leftGroups[r.k].push(r);
for (let k = 0; k <= n; k++) leftGroups[k].sort((a, b) => a.sum - b.sum);
// 构建步骤序列
steps = [];
steps.push({ type: 'formula', title: '问题转化' });
for (let i = 0; i < leftRows.length; i++) {
steps.push({ type: 'left', idx: i, title: '枚举左半(' + signText(leftRows[i].signs) + ')' });
}
steps.push({ type: 'sort', title: '左半分组排序' });
for (let i = 0; i < rightRows.length; i++) {
steps.push({ type: 'right', idx: i, title: '处理右半(' + signText(rightRows[i].signs) + ')' });
}
steps.push({ type: 'done', title: '完成' });
}
// ---------- 二分查找最接近目标 ----------
function nearestInGroup(group, target) {
let lo = 0, hi = group.length - 1;
if (target <= group[0].sum) return group[0];
if (target >= group[hi].sum) return group[hi];
while (lo + 1 < hi) {
const mid = (lo + hi) >> 1;
if (group[mid].sum < target) lo = mid;
else hi = mid;
}
return Math.abs(group[lo].sum - target) <= Math.abs(group[hi].sum - target) ? group[lo] : group[hi];
}
function processRight(row) {
const q = row.sum;
const needK = n - row.k;
const group = leftGroups[needK];
const target = -q;
const nearest = nearestInGroup(group, target);
const total = q + nearest.sum;
return {
rightRow: row,
q: q,
needK: needK,
target: target,
nearestRow: nearest,
p: nearest.sum,
total: total,
diff: Math.abs(total)
};
}
// ---------- 根据步骤号计算当前状态 ----------
function computeState(stepIndex) {
const state = {
stage: 'formula',
enumeratedLeftCount: 0,
sorted: false,
rightProcessed: [],
currentRight: null,
ans: Infinity,
best: null
};
for (let s = 0; s <= stepIndex; s++) {
const step = steps[s];
if (!step) continue;
if (step.type === 'formula') {
state.stage = 'formula';
} else if (step.type === 'left') {
state.stage = 'left';
state.enumeratedLeftCount++;
} else if (step.type === 'sort') {
state.stage = 'sort';
state.sorted = true;
} else if (step.type === 'right') {
const info = processRight(rightRows[step.idx]);
state.rightProcessed.push(info);
state.currentRight = info;
if (info.diff < state.ans) {
state.ans = info.diff;
state.best = info;
}
state.stage = 'right';
} else if (step.type === 'done') {
state.stage = 'done';
if (state.best) state.currentRight = state.best;
}
}
return state;
}
// ---------- 渲染 ----------
function renderNums(state) {
const h = [];
h.push('<h3>📦 数组拆分(折半)</h3>');
h.push('<div class="formula-box">');
h.push('最小化 <b>|sum(A) - sum(B)|</b>,等价于给每个元素分配符号:');
h.push(' <b>+1</b> 表示放入 A,<b>-1</b> 表示放入 B,且恰好 n 个 +1、n 个 -1。<br>');
h.push('注意这里的 +/− 是分组符号,原始元素可能为负数;贡献 = 符号 × 数值。');
h.push('</div>');
h.push('<div class="nums-line">');
h.push('<span class="half-label">左半 L</span>');
left.forEach(v => h.push(`<span class="chip left">${v}</span>`));
h.push('<span class="split">|</span>');
h.push('<span class="half-label">右半 R</span>');
right.forEach(v => h.push(`<span class="chip right">${v}</span>`));
h.push('</div>');
h.push(`<div class="nums-meta">n = ${n},共 ${2 * n} 个数,每半需枚举 2^${n} = ${1 << n} 种符号。</div>`);
$('numsPanel').innerHTML = h.join('');
}
function renderLeftTable(state) {
const h = [];
h.push(state.sorted ? '<h3>① 左半结果:按 k 分组,组内排序</h3>' : '<h3>① 左半枚举</h3>');
h.push('<div class="table-wrap"><table><thead><tr>');
h.push('<th>#</th><th>符号</th><th>k</th><th>计算</th><th>和</th>');
h.push('</tr></thead><tbody>');
if (!state.sorted) {
for (let i = 0; i < leftRows.length; i++) {
const r = leftRows[i];
const enumerated = i < state.enumeratedLeftCount;
const isCur = state.stage === 'left' && i === state.enumeratedLeftCount - 1;
const cls = `${enumerated ? '' : 'dim'} ${isCur ? 'current-highlight' : ''}`;
h.push(`<tr class="${cls}">`);
h.push(`<td>${r.id}</td><td>${signHTML(r.signs)}</td><td>${r.k}</td>`);
h.push(`<td class="calc">${r.signs.map((s, i) => signedTerm(s, left[i])).join(' ')}</td>`);
h.push(`<td>${r.sum}</td></tr>`);
}
} else {
for (let k = 0; k <= n; k++) {
const hl = state.currentRight && state.currentRight.needK === k;
h.push(`<tr class="group-header ${hl ? 'group-active' : ''}"><td colspan="5">k = ${k}(共 ${leftGroups[k].length} 个)</td></tr>`);
for (const r of leftGroups[k]) {
const isSel = state.currentRight && state.currentRight.nearestRow.id === r.id;
h.push(`<tr class="${isSel ? 'nearest-row' : ''}">`);
h.push(`<td>${r.id}</td><td>${signHTML(r.signs)}</td><td>${r.k}</td>`);
h.push(`<td class="calc">${r.signs.map((s, i) => signedTerm(s, left[i])).join(' ')}</td>`);
h.push(`<td>${r.sum}</td></tr>`);
}
}
}
h.push('</tbody></table></div>');
$('leftPanel').innerHTML = h.join('');
}
function renderRightTable(state) {
const h = [];
h.push('<h3>② 右半枚举 + 二分匹配</h3>');
h.push('<div class="table-wrap"><table><thead><tr>');
h.push('<th>#</th><th>符号</th><th>k</th><th>右半和 q</th><th>需 k\'</th><th>目标 -q</th><th>选 p</th><th>p+q</th><th>|diff|</th>');
h.push('</tr></thead><tbody>');
for (let i = 0; i < rightRows.length; i++) {
const r = rightRows[i];
const info = state.rightProcessed.filter(x => x.rightRow.id === r.id)[0];
const isCur = state.currentRight && state.currentRight.rightRow.id === r.id;
let cls = '';
if (!info) cls = 'dim';
else if (isCur) cls = 'current-highlight';
else cls = 'processed';
h.push(`<tr class="${cls}">`);
h.push(`<td>${r.id}</td><td>${signHTML(r.signs)}</td><td>${r.k}</td>`);
h.push(`<td>${r.sum}</td><td>${n - r.k}</td><td>${-r.sum}</td>`);
if (info) {
h.push(`<td class="nearest-val">${info.p}</td>`);
h.push(`<td>${info.total}</td>`);
h.push(`<td class="${info.diff === state.ans ? 'best-diff' : ''}">${info.diff}</td>`);
} else {
h.push('<td>-</td><td>-</td><td>-</td>');
}
h.push('</tr>');
}
h.push('</tbody></table></div>');
$('rightPanel').innerHTML = h.join('');
}
function renderChart(info) {
const group = leftGroups[info.needK];
const sums = group.map(r => r.sum);
const target = info.target;
const allSums = leftRows.map(r => r.sum);
let min = Math.min(...allSums, target);
let max = Math.max(...allSums, target);
const span = max - min;
const margin = span === 0 ? 1 : span * 0.1;
min -= margin; max += margin;
const pos = v => ((v - min) / (max - min)) * 100;
let h = '<div class="chart-wrap"><div class="chart-scale">';
h += `<div class="target-line" style="left:${pos(target)}%"></div>`;
h += `<div class="target-tick" style="left:${pos(target)}%">target ${target}</div>`;
for (let i = 0; i < sums.length; i++) {
const s = sums[i];
const cls = s === info.p ? 'dot nearest' : 'dot';
const bottom = 6 + (i % 3) * 6;
h += `<div class="${cls}" style="left:${pos(s)}%; bottom:${bottom}px;" title="候选 ${s}">`;
h += `<span class="dot-label">${s}</span></div>`;
}
h += `<div class="scale-min-label">${min.toFixed(1)}</div>`;
h += `<div class="scale-max-label">${max.toFixed(1)}</div>`;
h += '</div><div class="chart-caption">蓝色:当前分组候选;紫色竖线:目标值;绿色:二分选中的最近点。</div></div>';
return h;
}
function getPartition(info) {
const A = [], B = [];
for (let i = 0; i < n; i++) (info.nearestRow.signs[i] === 1 ? A : B).push(left[i]);
for (let i = 0; i < n; i++) (info.rightRow.signs[i] === 1 ? A : B).push(right[i]);
return { A, B };
}
function renderResult(state) {
const h = [];
h.push('<h3>④ 二分查找与答案</h3>');
if (state.stage === 'done' && state.best) {
const { A, B } = getPartition(state.best);
const sumA = A.reduce((a, b) => a + b, 0);
const sumB = B.reduce((a, b) => a + b, 0);
h.push('<div class="final-answer">');
h.push('<h3>✅ 最终答案(一种最优分组)</h3>');
h.push(`<p>最小数组和之差 = <b>${state.ans}</b></p>`);
h.push(`<p>A(+号)= ${fmtArr(A)},sum(A) = ${sumA}</p>`);
h.push(`<p>B(−号)= ${fmtArr(B)},sum(B) = ${sumB}</p>`);
h.push(`<p>|sum(A) - sum(B)| = |${sumA} − (${sumB})| = ${state.ans}</p>`);
h.push('</div>');
h.push('<div class="complexity" style="margin-top:10px;font-size:13px;color:#64748b;">复杂度:每半枚举 2^n 种,排序 + 二分查找,总时间复杂度 O(n·2^n),空间复杂度 O(2^n)。</div>');
$('resultPanel').innerHTML = h.join('');
return;
}
if (state.stage === 'formula') {
h.push('<div class="calc-block">先做问题转化:把分到第一个数组记为 +,分到第二个数组记为 −。</div>');
} else if (state.stage === 'left') {
h.push(`<div class="calc-block">正在枚举左半第 ${state.enumeratedLeftCount} / ${leftRows.length} 种符号组合。</div>`);
} else if (state.stage === 'sort') {
h.push('<div class="calc-block">左半枚举完毕。按 k 分组,组内按贡献和排序,方便之后二分查找。</div>');
} else if (state.currentRight) {
const info = state.currentRight;
const sums = leftGroups[info.needK].map(r => r.sum);
h.push('<div class="calc-block">');
h.push(`<div>当前右半:${signHTML(info.rightRow.signs)},q = ${info.q},正号个数 k = ${info.k}</div>`);
h.push(`<div>需要左半正号个数 = n − k = ${info.needK}</div>`);
h.push(`<div>左半候选 k=${info.needK} = [${sums.join(', ')}]</div>`);
h.push(`<div>目标 target = −q = ${info.target}</div>`);
h.push(`<div>二分查找最接近 target 的左半和:p = ${info.p}</div>`);
h.push(`<div>总符号和 = p + q = ${info.total}</div>`);
h.push(`<div>当前差值 = |${info.total}| = ${info.diff}</div>`);
h.push('</div>');
h.push(renderChart(info));
}
if (state.ans < Infinity) {
h.push(`<div class="current-answer">当前已找到的最小差值:<b>${state.ans}</b></div>`);
}
$('resultPanel').innerHTML = h.join('');
}
function hintText(state) {
switch (state.stage) {
case 'formula': return '问题转化:看成给每个元素分配 + 或 − 符号。';
case 'left': return `正在枚举左半第 ${state.enumeratedLeftCount} / ${leftRows.length} 种符号组合。`;
case 'sort': return '左半枚举完成,按 k 分组并排序。';
case 'right': return `正在处理右半第 ${state.rightProcessed.length} / ${rightRows.length} 种符号组合。`;
case 'done': return '全部组合处理完成,得到最小数组和之差。';
default: return '';
}
}
function render() {
const state = computeState(currentStep);
const step = steps[currentStep];
$('stepInfo').textContent = `步骤 ${currentStep + 1} / ${steps.length}:${step.title}`;
$('hintText').textContent = hintText(state);
document.querySelectorAll('.pipe-step').forEach(el => {
el.classList.toggle('active', el.dataset.stage === state.stage);
});
renderNums(state);
renderLeftTable(state);
renderRightTable(state);
renderResult(state);
}
// ---------- 控件 ----------
function stopAuto() {
if (timer) { clearInterval(timer); timer = null; $('autoBtn').textContent = '自动播放'; }
}
function stepBy(delta) {
stopAuto();
currentStep = Math.max(0, Math.min(steps.length - 1, currentStep + delta));
render();
}
function jumpToEnd() { stopAuto(); currentStep = steps.length - 1; render(); }
function reset() { stopAuto(); currentStep = 0; render(); }
function applyInput() {
stopAuto();
let raw = $('numsInput').value.trim();
const m = raw.match(/\[.*?]/);
if (m) raw = m[0];
raw = raw.replace(/^\[|\]$/g, '').replace(/,/g, ',');
const arr = raw.split(/[\s,]+/).filter(Boolean).map(Number);
if (arr.length < 2 || arr.length % 2 !== 0 || arr.some(x => !Number.isFinite(x))) {
alert('请输入偶数个整数,例如:3, 9, 7, 3');
return;
}
if (arr.length > 12) {
alert('为便于演示,元素个数不能超过 12。当前:' + arr.length);
return;
}
nums = arr;
buildData();
currentStep = 0;
render();
}
// ---------- 初始化 ----------
try {
buildData();
render();
$('applyBtn').addEventListener('click', applyInput);
$('exampleSelect').addEventListener('change', function() {
stopAuto();
nums = JSON.parse(this.value);
$('numsInput').value = nums.join(', ');
buildData();
currentStep = 0;
render();
});
$('prevBtn').addEventListener('click', () => stepBy(-1));
$('nextBtn').addEventListener('click', () => stepBy(1));
$('jumpBtn').addEventListener('click', jumpToEnd);
$('autoBtn').addEventListener('click', function() {
if (timer) { stopAuto(); return; }
if (currentStep >= steps.length - 1) { currentStep = 0; render(); }
timer = setInterval(() => {
if (currentStep < steps.length - 1) { currentStep++; render(); }
else stopAuto();
}, 800);
this.textContent = '暂停';
});
$('resetBtn').addEventListener('click', reset);
} catch (e) {
$('debug').style.display = 'block';
$('debug').textContent = '初始化失败:' + (e.message || e);
}
})();
</script>
</body>
</html>