为什么最近邻路线总在绕远?2-opt 把 50 城 TSP 缩短了 20.9%

同样 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.9
  • NN 路程按坐标独立重算一致 → 实测 矩阵=5057.3, 坐标=5057.3
  • MST 连通且边数 = 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;
}

这三个问题都不是「性能优化」,是页面能不能用的问题。它们能暴露出来,靠的是需求里那句「要自检并显示结果」------有标尺才谈得上验收。

八、亲手点三下(第三下是失败路径)

  1. 看关键数值:保持默认 50 城 / 种子 42,读表格:最近邻 5057.3、2-opt 4001.9、退火 4059.6,MST 下界 3290.2。
  2. 换个规模:把城市数改成 100、种子改成 2024,点「重新生成城市」,再看 2-opt 相对最近邻的改进(我这边是 18.1%),并确认自检面板 18 条全绿。
  3. 走一遍失败路径 :把城市数改成 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 张截图),托管在公开仓库,可直接下载来玩:

进入 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 原文。

相关推荐
guslegend6 小时前
底层数据设计:读写分离、分库分表与热点数据隔离
前端
Southern Wind6 小时前
AIAgent——第一章:本地 Agent 与 Vue 3 流式全栈实战
前端·javascript·vue.js
可乐鸡翅yeah_6 小时前
HLS 业务 Referer‑Policy 网页元标签引发播放异常排错
前端·javascript·网络·ffmpeg·php·音视频
jj_ccwgw6 小时前
从 0 到 1 搭建 pnpm + Workspaces + Turborepo Monorepo 保姆级指南
前端·node.js
智驭未来掌门人6 小时前
AI编程时代的设计系统落地指南:从分类选型到项目实战
前端
vipxieliang6 小时前
深入理解 JavaScript 数据类型与类型转换
前端·javascript
用户15741568165346 小时前
告别硬编码下拉选项:Vue 3 企业项目字典化改造实录
前端
明航咨询-陈老师6 小时前
2026信创“硬门槛“解码:国测(Ⅲ级首现/首个Ⅱ级OS) × LS(LS1-4) 双资质实操对照
前端
前端snow6 小时前
别再死磕单 Agent 了!多智能体架构 + LangGraph 实战,一篇全讲透
前端