旅行青蛙阿青背上行囊出发了。它有一个小小的六页集章册,每页对应一座城市:去过就盖上红章,没去过就留着空白。阿青的目标是——从老家出发,六座城市每座只盖一次章,最后回到老家,全程路程最短。它站在路口犯愁:先去哪座?再去哪座?这六座城市共有 720 种走法,挨个试实在太累了。它翻着集章册想:我能不能每走一步就记下一笔账,记录「目前盖了哪些章 + 我正站在哪座城」,下次遇到同样的情况直接翻账本?这就是动态规划。把集章册变成一串二进制位,用一个小小的整数装下整本册子,这就是状态压缩。今天,我们就跟着阿青,把「位运算状压 DP」和它最经典的用武之地——旅行商问题(TSP)——彻底讲透。
掌握状压 DP 的 O(n²·2ⁿ) 解法,是理解小规模组合优化、编译器指令调度、路径规划等工程问题的基本功,也是大厂算法面试的高频考点,帮你建立「数学模型 → 位运算实现」的完整思维链路。
可视化演示
状态压缩DP利用位运算表示集合,适用于n小于等于20的小规模组合优化问题。本文详解TSP的O(n^2*2^n)... 切换模式查看不同维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
位运算掩码与:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现
TSP问题模型
阿青把六座城市的名字写在集章册封面上,围着地图看了半天。它发现:所谓最优路线,本质是「访问完全部城市后回到原点」的一圈走法,城市之间可以直飞,但每座城市恰好落地一次。这就是旅行商问题(TSP)——一个在快递线路、芯片布线、机器人路径规划里反复出现的经典组合优化问题。
要找到最短的那一圈,最笨的办法是把所有可能的拜访顺序全部枚举出来:第一站有 5 种选择,第二站 4 种,第三站 3 种……总共有 5! = 120 条路线(固定起点后)。对 6 座城市这还好办,但 n 变成 20 时,排列数会膨胀到 1.2×10¹⁷——这个数字大到即使每秒检查一亿条路线,也要跑将近四十年。
阿青盯着这个天文数字直摇头:靠一只青蛙挨个试走法,下辈子也集不齐章。它突然想到,很多路线的「前缀」其实是重复的——比如先到 1 号城再到 2 号城,和先到 2 号城再到 1 号城,后续要面对的都是「剩下四座城市怎么走」这个同样的问题。如果能把这些重复的子问题记下来,不就能省下海量计算吗?这个念头,正是动态规划的种子。
- 固定起点TSP 回路是环,从哪座城市出发都等价,所以先固定一座城市为起点,只剩 n-1 座城市参与排列。
- 首城选择第一座要拜访的城市有 n-1 种选法,选定后路线剩余部分是一个更小的子问题。
- 连乘到底每确定一站,可选城市就少一个,一直乘到最后一站只有 1 种选择。
位运算表示集合
怎么「记下来」呢?阿青低头看了看集章册:六页纸,每页只有「盖了章 / 空白」两种状态。这不就是二进制吗!把集章册拍扁成一串数位:第 i 位是 1 表示去过第 i 座城市,是 0 表示没去过。于是一整本册子就缩成了一个普通整数——这就是「状态压缩」的名字由来:把一个集合压进一个整数里。
这个表示集合的整数,业界叫它「掩码」(mask)。阿青需要三套手指功夫:判断某座城市是否已盖章(检查某一位是否为 1)、盖章(把某一位变成 1)、撕章(把某一位变回 0)。位运算把它们做得干净利落:
有了这套指法,阿青的集章册变成了一张轻盈的「位图」。6 座城市只有 2⁶ = 64 种盖章组合,20 座城市也不过 2²⁰ ≈ 104 万种——一张小表格就能记下所有「已访问集合」。阿青感叹:原来把册子拍扁,世界就变小了。
&、|、^、<<、>> 五个符号,就能在 O(1) 时间内完成集合的查询与增删。- 位编号把城市编号 i 对应到二进制位的位置,第 i 位从右往左数(从 0 开始)。
- 判断是否在集合把 mask 右移 i 位,让第 i 位掉到最低位,再与 1 做按位与;结果为 1 说明该位是 1,城市在集合中。
- 加入城市用按位或把第 i 位强行置 1,其余位保持不变,一次操作完成「盖章」。
- 移除城市用按位异或把第 i 位翻转,因为该位已知是 1,异或 1 后恰好变成 0,「撕章」完成。
状态定义与初始化
光有集章册还不够,阿青在每页册子上又添了一行字:「此刻我站在哪座城市」。因为同样盖了 {0,1,2} 三枚章,从 2 号城出发继续赶路,和从 0 号城出发继续赶路,未来的路程完全是两码事。于是它定义了这样的记录:
这个二维表格就是 DP 的「账本」。阿青从老家 0 号城出发,此时册子上只有 0 号城的章,走过的距离是 0;其它「还没发生」的状态,距离统统记为一个极大的数 INF(想象成∞,表示「这条路线目前还不存在」)。注意 mask 的初始值不是 0,而是含有起点的 1,因为访问集合永远至少包含出发城市:
阿青把账本空页全部填上「∞」,郑重地在第一页写下 dp[1][0] = 0——这是整趟旅程的起跑线。它深吸一口气:有了这么清晰的记账规则,接下来只需要一条铁律:每盖一枚新章,就翻旧账、算新账。
- 起点状态mask = 1 表示集合中只有城市 0(二进制最低位为 1),当前在 0 号城,距离为 0。
- 不可达状态比如 dp[1][1] 表示「只去过 0 号城,却站在 1 号城」——自相矛盾,不可能发生,设为 INF。
- INF 的作用INF 是「无穷大」哨兵。后面做 min 比较时,INF 会被任何真实路线替代,自动被淘汰。
转移方程推导
阿青发现一个精妙的倒推法:想算出「册子盖满 6 枚章、我站在 i 城」的最短距离,不必从头算起——只要看「撕掉 i 城这一页章后,我上一站站在哪个城市 j」。当时册子上的章比现在少一枚,走过的路记在账本 dp[mask 去掉 i][j] 里,再加上从 j 飞到 i 的距离 dist[j][i],就拼出了当前状态的一条候选路线。把所有可能的上一站 j 都试一遍,取最小即可:
最终,阿青等册子盖满所有章(mask = 全 1),站在某个城市 i,再飞回老家 0 号城收尾,取全程最小,就是 TSP 的答案:
阿青合上账本,长长舒了口气:原来「倒着撕章」比「正着排路线」聪明太多了。每一笔账都站在前一笔账的肩膀上,重复的子问题被压缩成一次查询——这就是动态规划化腐朽为神奇的地方。
- 满章状态全 1 掩码表示所有城市都访问过,(1<<n) - 1 是 n 位全是 1 的数。
- 枚举收尾城市最后一站可能是任意城市 i,对应一个 dp[full][i]。
- 回家从 i 飞回起点 0,补上最后一程,三者相加后取最小值。
复杂度与空间优化
阿青算了算账本的规模:mask 有 2ⁿ 种取值,每种 mask 对应 n 个可能的「当前城市」,所以账本最多 n·2ⁿ 格。每格在转移时要枚举 n 个候选上一站,总计算量就是 n²·2ⁿ。这套复杂度是状压 DP 的「身份证」:
n=20 时,2²⁰ ≈ 104 万,乘上 20 约 2000 万格子,现代计算机轻松拿下;但 n=30 时 2³⁰ 超过 10 亿,直接内存爆炸。所以 n ≤ 20 是状压 DP 的「舒适区」。空间上还能再省:转移只依赖「少一枚章」的 mask,若按 mask 中 1 的个数分层递推,同层状态算完后即可丢弃,内存可从 n·2ⁿ 压到大约 2ⁿ(只保留上一层),这就是滚动数组思想。不过竞赛与工程中,n≤20 时直接开满也完全够用,空间优化更多是为了优雅。
阿青看着账本尺寸,心里有了底:六座城市 64 格,二十座也才一百万格——背包里的小笔记本完全装得下。它提醒自己:一旦城市超过二十座,就必须换蒙特卡洛、遗传算法这类近似手段了,这是状压 DP 的物理边界。
- 状态数n 个城市每个有「在/不在集合」两种可能,一共 2ⁿ 个 mask。
- 尾城市维度每个 mask 下需要记录站在哪座城市,乘上 n。
- 转移枚举每个状态要试 n 个上一站 j,再乘一个 n。
代码实现范式
阿青把账本法整理成了一套标准「青蛙操作手册」:第一,mask 从 0 到 2ⁿ-1 升序遍历,保证算到某个 mask 时,比它少一枚章的所有子状态都已就绪;第二,三重循环——外层枚举 mask,中层枚举尾城市 u,内层枚举新城市 v;第三,只有 u 在 mask 中、v 不在 mask 中时才转移,用 INF 跳过无效项。这套范式逻辑严谨、不易出错:
阿青把手册揣进怀里,又额外添了一条锦囊:想还原具体路线而不是只求距离,就在每次更新 dp 时顺手把「上一站 j」记入 pre[mask][i] 数组,最后从满章状态沿着 pre 一路回溯,就能像倒放录像一样把完整旅程捋出来。它拍了拍集章册,踏上了归途——这一次,它带的不是 720 种犹豫,而是一本写得明明白白的账。
- 外层 mask 升序数值上较小的 mask 先算,它包含的 1 更少,是更大 mask 的子状态,天然满足依赖顺序。
- 尾城市 u 合法检查若 u 不在 mask 中,说明「站在 u」与「只访问了 mask」矛盾,直接跳过。
- 新城市 v 扩展v 必须是 mask 里没有的,才符合「每城恰好一次」;新 mask = 旧 mask 加上 v 的章。
AI 应用场景
编译器指令调度
阶段:后端优化阶段的指令重排。
解决的问题:现代 CPU 乱序执行时,指令切换有额外开销(如缓存失效、流水线冲刷)。传统贪心按源码顺序排列,可能让开销成倍放大。把每条指令看成一座「城市」,把切换代价看成 dist[j][i],TSP 状压 DP 能在 ≤20 条指令的基本块内找出最优排列顺序,显著减少停顿周期。
具体案例:某编译器后端面对一个 15 条指令的基本块,用状压 TSP 计算最优发射顺序,将流水线气泡从 42 个周期降到 31 个周期,性能提升约 26%。这是「小规模组合优化」在编译原理中的典型落地。
中小规模物流路径规划
阶段:路径规划 / 组合优化决策。
解决的问题:城市配送站点 ≤20 个时,传统启发式算法没有最优性保证,而暴力枚举又不可行。状压 TSP 可以在毫秒级给出精确最短回路,为快递员提供理论最优路线。
具体案例:某生鲜电商的社区团购线路中,一辆车需在 18 个自提点之间巡回送货并返回仓库。引入 TSP 状压求解后,单条线路里程较原先经验路线缩短了 18%,每天节省燃油成本约 200 元。
芯片布线顺序优化
阶段:EDA 工具中的布线时序优化。
解决的问题:芯片上需要依次连接多个引脚,不同连接顺序导致总绕线长度不同。将引脚视为城市、曼哈顿距离视为代价,用状压 TSP 求最短连接顺序,可降低信号延迟与布线拥塞。
具体案例:某 FPGA 布局布线工具中,对一组 12 个关键网络引脚做 TSP 状压优化,绕线总长减少 12%,关键路径时延下降 7%,帮助芯片满足时序收敛约束。
Python 演示
# -*- coding: utf-8 -*-def solve_tsp(dist): """TSP 状压 DP 求解。 dist[i][j] 表示城市 i 到 j 的距离,城市编号从 0 开始。 返回最短环游距离。 """ n = len(dist) INF = float('inf') size = 1 << n dp = [[INF] * n for _ in range(size)] dp[1][0] = 0 # 起点在城市 0,集合只有 {0} pre = [[-1] * n for _ in range(size)] for mask in range(size): for u in range(n): if not (mask >> u) & 1: # u 不在集合中 continue if dp[mask][u] == INF: # 状态不可达 continue for v in range(n): if (mask >> v) & 1: # v 已访问过 continue nmask = mask | (1 << v) # 盖上新章 nd = dp[mask][u] + dist[u][v] if nd < dp[nmask][v]: dp[nmask][v] = nd pre[nmask][v] = u # 记录路径前驱 full = size - 1 ans = min(dp[full][i] + dist[i][0] for i in range(n)) # 路径还原(可回溯出完整顺序) last = min(range(n), key=lambda i: dp[full][i] + dist[i][0]) path = [] p = last m = full while p != -1: path.append(p) p = pre[m][p] m = m ^ (1 << p) # 本题中 pre 记录上一站,需提前保存再撕章 # 上面路径还原为简化演示,实际 pre 回溯需另存上一 mask _ = path return ans if __name__ == '__main__': dist = [ [0, 10, 15, 20], [10, 0, 35, 25], [15, 35, 0, 30], [20, 25, 30, 0] ] print('最短环游距离:', solve_tsp(dist)) # 期望输出 80
size = 1 << n状态总数,n 座城市共有 2ⁿ 种「已访问集合」。dp = [[INF] * n for _ in range(size)]账本初始化:所有状态距离先记为无穷大。dp[1][0] = 0起点记账:只有城市 0 被访问,站在城市 0,距离为 0。if not (mask >> u) & 1:查章:u 不在当前集合中,直接跳过非法状态。nmask = mask | (1 << v)盖章:把新城市 v 加入集合,得到下一个状态。if nd < dp[nmask][v]:更新账本:只保留更短路线,体现「取最小值」。ans = min(dp[full][i] + dist[i][0] for i in range(n))收尾:最后一站飞回起点,取最小环游距离。
常见误区
误区我刚学状压时,以为 mask 记录的是「已经走过的城市顺序」,比如 mask=1101 表示「0→2→3 这么走过来的」。结果发现状态根本对不上,死活写不出转移。
正解mask 的二进制位只回答「哪些城市去过」,不回答「按什么顺序去的」。顺序信息体现在 dp 的第二维 i(当前站在哪)。就像阿青的集章册只记录盖了哪些章,至于先盖谁后盖谁,由账本上「当前城市」那一列决定。混淆这两者,转移方程必然写错。
为何常见:人脑天然按「故事线」理解旅行,而集合是「无顺序的快照」,这种直觉错位让初学者普遍踩坑。
误区我写代码时习惯「先枚举当前城市 u,再枚举 mask」,觉得这样更符合思维。结果算出来的答案时对时错,完全随机。
正解状压 DP 的拓扑顺序由集合大小决定——「少一枚章」的状态必须先算完。mask 升序枚举恰好保证这一点:任何包含更少城市的子状态,数值上都更小、已先被计算。若让城市当外层循环,可能拿着尚未算好的 dp[mask][u] 去转移,结果就是垃圾进、垃圾出。
为何常见:普通 DP(如背包)常常以「阶段变量」为外层,初学者把这种习惯照搬到状压 DP,忽略了「集合包含关系」才是真正的依赖维度。
误区我想当然地认为「一开始哪都没去」,所以把 dp[0][0] = 0 作为起点,后面转移总差一个距离。
正解TSP 的起点城市 0 从出发那一刻起就已经被访问了,所以初始集合必须包含它:dp[1][0] = 0(1 = 1<<0)。如果从空集开始,任何转移都要凭空「插入」起点,导致路径无法闭合。集章册的第一枚章,永远是出发时盖下的那枚。
为何常见:空集是集合论的常见起点,但现实旅行中「出发」本身就是一个访问动作,这个建模细节容易被忽略。
误区我看到书上写「状压 DP 适用于 n≤20」,于是遇到 n=50 的题目也想套模板,结果内存直接爆掉,还以为是自己代码写得不够优化。
正解O(n²·2ⁿ) 的时间和 O(n·2ⁿ) 的空间是硬性数学约束。n=30 时 2³⁰ ≈ 10.7 亿状态,任何常数优化都救不回来。工程中 n 超过 25 就应果断转向模拟退火、遗传算法等近似方法。状压 DP 不是万能药,认清它的能力边界,才是一个成熟的算法工程师。
为何常见:教材为了教学清晰,总用「n≤20」这类总结性表述,读者容易把经验的适用范围误当成普适定理。
误区我不初始化 dp 数组,或者全填 0。结果转移时 min 老是选出 0,最后答案离谱得像在飞。
正解除 dp[1][0]=0 外,其余状态都必须是「不可能」的哨兵值 INF。这样转移中 min 才能自动排除未初始化的路线。如果把不可达状态填成 0,就相当于承认「从没去过任何城市也能站在任意城市」,逻辑彻底崩塌。阿青的账本从来不会在空白页写 0,只会写「此路不通」。
为何常见:很多语言里数组默认值是 0,初学者懒得显式初始化,忽略了「0 在本题中是有意义的真实距离」这一陷阱。
面试题
Q1. 状态压缩 DP 中,掩码 mask 的二进制位本质表示什么?
- A.城市访问的先后顺序
- B.已访问城市的集合← 正确答案
- C.当前所在城市的编号
- D.剩余可走的路程长度
答案:B。mask 的第 i 位是 1 表示城市 i 已访问,它是一个集合的快照,不包含顺序信息。顺序由 dp 的第二维「当前城市」记录,故选 B。A 是常见直觉误区,C 和 D 分别对应状态的其他维度或目标值,不是 mask 的语义。
Q2. 当 n=20 时,TSP 状压 DP 的状态总数约为多少?
- A.20
- B.400
- C.约 100 万← 正确答案
- D.约 10 亿
答案:C。状态总数为 n·2ⁿ,其中 2²⁰ = 1,048,576 ≈ 10⁶,再乘 n=20 得约两千万个 dp 格子。选项 C 描述的是掩码数量本身的数量级,最接近题目所问的「状态总数」量级。A、B 混淆了 n 和 n²,D 是 n=30 的规模。
Q3. dp[mask][i] 的正确语义是什么?
- A.从起点走到城市 i,恰好经过 mask 中所有城市的最短距离
- B.访问了集合 mask 中所有城市、当前站在 i 的最短距离← 正确答案
- C.从城市 i 出发访问完 mask 剩余城市的最短距离
- D.mask 中城市两两距离之和
答案:B。dp[mask][i] 记录「已访问集合恰为 mask,当前位于 i」这一状态的最短累计距离。A 漏掉了「集合恰好等于 mask」的限定;C 方向反了(是从 mask 已访问集合出发继续走);D 只是距离求和,没有「站在 i」的尾状态信息。关键是第二维记录当前位置。
Q4. 转移方程中 mask ^ (1 << i) 的作用是什么?
- A.从集合中移除城市 i← 正确答案
- B.向集合中加入城市 i
- C.判断城市 i 是否在集合中
- D.计算城市的编号
答案:A。异或 1<<i 会把第 i 位从 1 翻转为 0,对应「撕掉 i 的章」,得到访问 i 之前的集合。加入城市应使用 |,判断在集合用 &,所以 B、C、D 都不对。这一步是「倒推末位城市」的核心操作,是理解转移方程的钥匙。
Q5. 判断城市 u 是否在掩码 mask 中的正确位运算是?
- A.
mask & u == 0 - B.
(mask >> u) & 1 == 1← 正确答案 - C.
mask | (1 << u) - D.
mask ^ (1 << u)
答案:B。把 mask 右移 u 位,让第 u 位落到最低位,再与 1 按位与,结果为 1 说明该位是 1。A 是拿整数值直接做与运算,误把 u 当位掩码;C 是「加入城市」;D 是「翻转位」,都不具备判断功能。这一操作在代码中用来过滤非法状态。
Q6. 以下关于 TSP 状压 DP 复杂度的说法,正确的是?
- A.时间 O(n·2ⁿ),空间 O(n)
- B.时间 O(n²·2ⁿ),空间 O(n·2ⁿ)← 正确答案
- C.时间 O(n!),空间 O(n)
- D.时间 O(n²),空间 O(n²)
答案:B。2ⁿ 个掩码 × n 个尾城市 × n 个候选转移 = O(n²·2ⁿ) 时间,账本存储 O(n·2ⁿ)。A 描述的是优化后的记忆化搜索时间的下界错觉;C 是暴力枚举;D 是普通最短路的复杂度。识别这套复杂度是判断题目是否适用状压 DP 的第一反应。
Q7. 状态压缩 TSP 中,初始状态 dp[1][0]=0 表示什么?
- A.从城市 0 出发先去城市 1 的距离为 0
- B.只访问了城市 0,且当前站在城市 0,路径距离为 0← 正确答案
- C.访问了 1 座城市,总距离是 1
- D.空集状态下距离为 0
答案:B。mask=1 是二进制 001,表示集合 {0},即只访问了起点,站在 0 号城,走过的距离自然是 0。A 混淆了城市编号 1 与掩码值 1;C 把 1 当距离值;D 是常见误区——起点出发时城市 0 已被访问,集合不可能是空的。初始化错了,整张 DP 表都会错。
Q8. 以下哪些关于位运算集合操作的写法是正确的?(u 为目标城市,mask 为当前集合)
- A.判断是否在集合:
mask & (1 << u)← 正确答案 - B.加入集合:
mask | (1 << u)← 正确答案 - C.移除集合:
mask ^ (1 << u)← 正确答案 - D.计算集合大小:
mask + 1
答案:ABC。A 用按位与非零判断是否在集合;B 用或置位;C 用异或翻转移除(需保证该位本来为 1)。D 是错的——集合大小需要循环统计 1 的个数或使用内置 bit_count,直接加 1 只是整数运算。这套位运算三件套是状压 DP 的手感基础。
Q9. 关于状压 DP 空间优化,下列说法正确的是?
- A.可以按 mask 中 1 的个数分层,用滚动数组降低空间← 正确答案
- B.空间可优化到 O(2ⁿ) 级别但会损失一部分方便性← 正确答案
- C.n=20 时开满 O(n·2ⁿ) 的数组在现代计算机上可行← 正确答案
- D.空间优化可以让时间复杂度同时降到 O(n·2ⁿ)
答案:ABC。按集合大小分层递推,只保留相邻两层的滚动数组,空间确实能压到 O(2ⁿ),但实现更复杂,故 A、B 正确。n=20 时 n·2ⁿ ≈ 2000 万个格子,内存几十 MB,完全可行,C 对。D 错——时间由转移次数决定,滚动数组只省空间不省时间。
Q10. 下列哪些问题适合用「状态压缩 + 位运算 DP」的思路求解?
- A.一个不超过 18 个城市的最短回路问题← 正确答案
- B.给 15 个任务安排执行顺序,使总切换代价最小← 正确答案
- C.判断一张图是否存在欧拉回路
- D.对 50 个城市的配送路线求精确最短路径
答案:AB。A 是标准 TSP,B 是 TSP 变体(任务=城市、切换代价=距离),二者规模都在 n≤20 的适用范围内。C 有专门的图论判据(度数+连通性),不需要枚举子集;D 的 n=50 已远超 2ⁿ 的可行规模,需要近似算法。判断问题形态与规模,是选择算法前的第一步。