
同样 50 个城市、同一张地图,最近邻贪心给出 5057.3 的路程,2-opt 只用了 25 步就把它压到 4001.9,少了 20.9%;模拟退火跑了 140 步拿到 4059.6,比最近邻好 19.7%,但还是没赢过 2-opt。三条路线都大于最小生成树给出的下界 3290.2(gap 分别是 53.7% / 21.6% / 23.4%)------也就是说,谁离最优解更近,和「谁更花哨」没有必然关系。
这是一页 index.html,零外部依赖、双击就能跑。页面自带 18 条断言的自检面板,交付时全部 PASS;下面每个数字都是我在本机重新打开页面读出来的,不是转述对话里的结论。
一、先看结果:三个规模跑下来,2-opt 每次都赢
页面默认 50 城、种子 42,右上角能一键换城市数和种子。我按三组参数各跑了一遍,把表格抄在下面(路程单位是画布像素距离,做横向比较用):
| 城市数 / 种子 | 最近邻 | 2-opt | 模拟退火 | 2-opt 相对最近邻 | 退火相对最近邻 | 最近邻 gap | 2-opt gap | 退火 gap | 2-opt 迭代 | 退火迭代 |
|---|---|---|---|---|---|---|---|---|---|---|
| 50 / 42 | 5057.3 | 4001.9 | 4059.6 | −20.9% | −19.7% | 53.7% | 21.6% | 23.4% | 25 | 140 |
| 60 / 7 | 5072.7 | 4249.7 | 4382.8 | −16.2% | −13.6% | 50.7% | 26.3% | 30.2% | 17 | 140 |
| 100 / 2024 | 6275.9 | 5140.2 | 5605.6 | −18.1% | −10.7% | 48.5% | 21.7% | 32.7% | 34 | 140 |
表格里「gap」的分母是最小生成树的权重,不是最优解------TSP 的最优解一定不小于 MST,所以这个 gap 只能算一个上界:真实差距只会更小。这是页面里唯一一个「用近似量当标尺」的地方,我在下面的踩坑部分会再说明一次。
三组数据里 2-opt 都赢了,而且赢得不小。城市从 50 涨到 100,最近邻的 gap 反而从 53.7% 掉到 48.5%(点多了、随机分布更均匀,贪心没那么容易踩坑),2-opt 的 gap 稳在 21% 出头。
二、为什么值得看:TSP 是 NP-hard,但「够好的解」几秒钟就能拿到
旅行商问题(TSP)是最出名的 NP-hard 问题之一:城市一多,穷举精确解就没有指望。但它同时也是最容易看出「算法差异」的题目------同一张图、同样的输入,换个策略路线就明显不一样,而且好坏能用一个数字量出来。
绕着这个问题,网上常见两类内容:一类只给公式和伪代码,读完还是不知道 2-opt 到底做了什么;另一类是刷题答案,跑完只告诉你「AC 了」,看不到路线怎么一步步变短。这个页面补的就是中间那段:三条路线画在同一张画布上,2-opt 每次翻转都会高亮「新接边(绿)」和「待解除旧边(红虚线)」,右边那张曲线图把路程随迭代的下降过程画出来。

顺便说一句:自检面板是实时刷新的,别把「运行中的 FAIL」当成 bug------图 2 里那条 FAIL 是因为退火还没跑完冷却步,等它走完 140 步就自动变 PASS。
三、准备环境:打开码道 Web
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示,浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source...,登录后就能在对话窗口里输入需求,不需要装软件。
四、任务描述:把「可验收的量」一起写进需求
需求我改了三遍才发出去,核心是把「我要看什么数字」写清楚。下面这段是原话:
text
做一个单文件 index.html 的「TSP 路线优化实验室」,中文界面、无外部依赖、双击就能跑。要求:1) 画布上随机撒 50 个城市点,默认固定随机种子,可调城市数 10~200、可一键重新生成;2) 三种算法同台竞速求解:最近邻贪心、2-opt 局部搜索、模拟退火,三条路线用三种颜色同屏显示,每条实时显示当前总路程、迭代次数、以及相对最近邻的缩短百分比;3) 2-opt 翻转要有动画并高亮被翻转的路径段,模拟退火要显示温度和接受概率;4) 右侧画「总路程 vs 迭代次数」三条曲线的折线图;5) 顶部放实测对比表格:算法 / 最终路程 / 相对最近邻的改进 / 相对最小生成树下界的 gap;6) 页面底部内置自检面板,逐条显示 12 条以上断言的 PASS/FAIL 和实测数值(例如每条路线恰好访问每个城市一次、2-opt 结果不劣于最近邻、路程按坐标计算一致、翻转后路程不增加等);7) 城市数输入非法(小于 3 或非数字)时给中文错误提示并保留上一次有效结果;8) 有一键重跑、调速、单步迭代。完成后打开预览,让我直接看到运行效果。
关键就三句:三算法同屏、实测对比表格、页面里要有自检并把结果显示出来。少写最后一句,回来的多半只是一个会动的路线图,没有可核对的东西。
五、码道实际做了什么
对话窗口里它先 ls 看环境,然后写出 index.html(756 行),接着自己起本地服务器、写验证脚本、用真实浏览器打开页面跑断言------中间发现严格模式下 i 未声明直接抛错,改成 var 声明后才出图。交付的最终版是一页 30 KB 左右的单文件,外加 18 条断言的自检面板。
它自己汇报的一组数是:seed=42、50 城,NN 5057.3 → 2-opt 4001.9 → SA 4059.6。我把页面下载到本地重新打开,读到的数字和它一致。
六、代码亮点:三处值得抄走的地方
1)2-opt 只算「差量」,不重算整条路线。 翻转一段路径后,只有两条边的代价变了,所以判据就是一个减法:
js
var delta = dmat[a][c] + dmat[b][d] - dmat[a][b] - dmat[c][d];
if (delta < -1e-9) {
reverseT(tour, i, j);
return { a: a, b: b, c: c, d: d, i: i, j: j, delta: delta };
}
dmat 是预先算好的距离矩阵,-1e-9 那个 epsilon 是为了避开浮点误差导致的反复横跳。整条路线的长度只在重置时算一次,之后每次翻转都用 cost += delta 维护,这也是它 25 步就能跑完 50 城的原因。
2)用最小生成树给路线定一个「不能低于」的下界。 页面里 prim() 算完 MST 的权重之后,才敢往表格上写 gap------因为 MST ≤ 最优 TSP,所以任何路线都不可能低于它。这条断言也写进了自检:三条路线路程均 ≥ MST 下界(gap ≥ 0)。
3)把自检渲染到页面上。 18 条断言每次迭代后重刷,每条都带实测值,例如:
2-opt 每步翻转后路程不增加(全程单调)→ 实测首=5057.3 → 末=4001.9NN 路程按坐标独立重算一致→ 实测矩阵=5057.3, 坐标=5057.3MST 连通且边数 = N-1→ 实测连通=true, 权重=3290.2

七、验证与踩坑:交付前真正出过问题的三个地方
坑一:严格模式下循环变量没声明,整个脚本挂掉。 第一版在 drawMain / drawChart 里写了 for (i = 0; ...),在严格模式下直接抛 ReferenceError,页面是白的。改成显式 var 声明之后才有图。
坑二:mn / mx 漏声明,同一类问题,画折线图的坐标轴范围时踩到,补上声明才画得出曲线。
坑三(最有意思的一个):非法输入被静默吞掉。 第一版给城市数输入框绑的是 change 事件,输入非法值时它把输入框悄悄还原成旧值、然后用旧值重新跑了一遍------用户既没看到提示,也不知道刚才那次输入被丢了。修完之后变成显式校验 + 中文提示:
js
function readCount() {
var raw = cityEl.value.trim();
var v = Number(raw);
if (raw === '' || isNaN(v) || !/^-?\d+$/.test(raw)) {
showErr('城市数量必须是 3~200 之间的整数,已保留上一次有效结果(当前 ' + S.n + ')。');
return null;
}
if (v < 3 || v > 200) {
showErr('城市数量 ' + v + ' 超出范围,要求 3~200,已保留上一次有效结果(当前 ' + S.n + ')。');
return null;
}
hideErr();
return v;
}
这三个问题都不是「性能优化」,是页面能不能用的问题。它们能暴露出来,靠的是需求里那句「要自检并显示结果」------有标尺才谈得上验收。
八、亲手点三下(第三下是失败路径)
- 看关键数值:保持默认 50 城 / 种子 42,读表格:最近邻 5057.3、2-opt 4001.9、退火 4059.6,MST 下界 3290.2。
- 换个规模:把城市数改成 100、种子改成 2024,点「重新生成城市」,再看 2-opt 相对最近邻的改进(我这边是 18.1%),并确认自检面板 18 条全绿。
- 走一遍失败路径 :把城市数改成
2(或者清空、输入12.5、abc),页面不应该白屏、也不应该静默吞掉这次输入,而是给出中文提示并保留上一次的有效结果:

text
城市数量 2 超出范围,要求 3~200,已保留上一次有效结果(当前 50)。
城市数量必须是 3~200 之间的整数,已保留上一次有效结果(当前 50)。
第二行是清空输入框或输入 12.5 时看到的提示。顺带一个实测细节:输入框是 type="number",手敲字母会被浏览器直接吞成空值,所以「abc」和「空」走的是同一条提示------这一点我是自己敲进去试出来的,不是猜的。

九、本地复现
bash
git clone https://atomgit.com/deli007/demo_park.git
cd demo_park/codearts-tsp-solver-lab
python3 -m http.server 8000 # 或者直接双击 index.html,效果一样
打开页面后按 F12 看 Console,正常应该是干净的;页面里没有任何 CDN、字体、构建步骤,也没有联网请求。18 条断言在页面底部实时刷新,点「立即自检」可以手动重跑一次。
十、直接下载试玩
这个 Demo 已经完整开源(源代码、本文档、5 张截图),托管在公开仓库,可直接下载来玩:
- Demo Park 仓库:atomgit.com/deli007/dem...
- 本案例目录:atomgit.com/deli007/dem...
进入 codearts-tsp-solver-lab 目录,把 index.html 下载到本地双击即可运行。
十一、使用码道体会
- 需求里写「实测值」,回来的才是可验收的东西。 我要求「每条路线实时显示总路程、迭代次数、相对最近邻的缩短百分比」和「页面里要有自检面板」,交付时这些数字都摆在页面上,我不用读代码就能验收。
- 让它自己跑浏览器验证,比只看代码有效。 那个严格模式未声明的
i、以及非法输入被吞掉的交互 bug,都是它自己写脚本、用真实浏览器打开页面跑出来的;静态读代码不一定能发现「输入被还原」这类行为问题。 - 失败路径要把提示文案写进需求。 我写了「非法输入要给中文错误提示并保留上一次有效结果」,交付的提示原文就是可验收的;不然很容易得到一个「默默还原成默认值」的实现。
- 不要把话说满:近似量要点明。 MST 只是下界,不是最优解;页面还有一处我特意没让它闭嘴------退火在三组参数里都没赢 2-opt。这类「不好看但真实」的结论留在页面和文章里,比只报喜可信。
- 参数是活的。 城市数 10~200、种子可换、速度 5 档、单步/暂停都有,换一组参数就能复现出完全不同的曲线形状------这比一张静态截图更有说服力。
十二、总结
一个 30 KB 的单文件网页,把 TSP 从「NP-hard 的黑话」变成了可以拖参数、可以单步、可以自己核对的实验台:默认 50 城下 2-opt 把最近邻的 5057.3 压到 4001.9(−20.9%),模拟退火 4059.6(−19.7%)屈居第二,三者的 MST 下界是 3290.2,页面 18 条断言全绿。想继续玩的话,可以试试点开「极速 64x」把城市数拉到 200 看曲线怎么变,或者把退火的初温和降温系数调一调,看看它需要多少步才能追上 2-opt。
说明:文中所有读数(三组参数的路程/gap、迭代次数、自检条目与实测值、错误提示原文)都是在本机实际运行页面取到的,不是转述。文中引用的三处代码均来自交付的
index.html原文。