状态压缩DP:小数据下的“暴力美学”

如果说普通DP是"用空间换时间"的优雅舞蹈,那么状态压缩DP就是"用二进制撬动组合爆炸"的暴力美学------它把看似不可能的集合枚举问题,压缩进了几个int变量里,让指数级复杂度在小数据范围内变得可行。

引言

你是一个旅行商,需要从家出发,恰好经过N个城市各一次,最后回到起点。N=5时,方案数有(5-1)!=24种,穷举没问题;但当N=15时,方案数暴增到87178291200种------穷举?这辈子都算不完。

但如果N≤20呢?我们有没有办法在1秒内算出答案?

这就是状态压缩DP(状压DP) 登场的舞台。它的核心思想极其暴力却又极其巧妙:用一个二进制整数表示"哪些元素已经被使用过",把集合状态压缩进一个数字里,然后在这个数字上进行DP。

"如果说集合是算法竞赛中的'百宝箱',那么状压DP就是给这个百宝箱装上了二进制'密码锁'------你只需要一个int,就能精准地控制箱子里每个元素的取舍状态。"

前置知识

在阅读本文之前,建议你熟悉以下概念:

  1. 动态规划(DP) :理解"最优子结构"和"重叠子问题"的基本思想。

  2. 位运算&(与)、|(或)、^(异或)、<<(左移)、>>(右移)------状压DP的"基本功"。

  3. 集合与子集:理解用二进制位表示集合的方法(第i位为1表示元素i在集合中)。

  4. 旅行商问题(TSP) :经典的NP-hard问题,状压DP的"招牌应用题"。

  5. 时间复杂度分析:理解 O(n⋅2^n) 和 O(2^n) 级别的复杂度。

第一章:为什么需要状态压缩------从集合到整数

1.1 传统DP的"盲区"

普通的线性DP,状态通常是一维或二维的,比如dp[i]表示前i个物品的最优值,dp[i][j]表示第i行第j列的最优值。但有一类问题的状态天然是集合------"已经选过哪些元素"、"哪些城市已经被访问过"。集合无法用简单的整数下标表示,传统DP束手无策。

举个例子:有N个物品,每件选或不选,有 2N2N 种组合。如果N=20,220=1,048,576220=1,048,576,这个数量级完全可以在程序中枚举。但如何用数组下标来表示一个集合呢?

1.2 二进制的天然优势

答案就是二进制。一个N位的二进制数,每一位代表一个元素的状态:

  • 第i位 = 1,表示元素i被选中/已访问/可用

  • 第i位 = 0,表示元素i未被选中/未访问/不可用

这样一来,一个集合就变成了一个0到 2N−12N−1 之间的整数 。我们可以用dp[S]表示"集合S状态下的最优值",数组大小只需要 2N2N。

1.3 一个生活化类比

想象你有5个行李箱要打包上飞机,每个行李箱都有自己的重量和价值,但飞机只允许带总重量不超过W的行李。你想知道:哪些箱子组合价值最大?

  • 不压缩:5个箱子,每个箱子"选/不选",穷举需要写5层循环。

  • 压缩后:用一个5位二进制数00000~11111表示选择方案,第i位为1表示带第i个箱子。遍历这32个数字,每个数字就是一个完整的"选择方案"。

当N=20时,遍历100万个数字,现代计算机不到0.1秒。这就是状压的威力。

第二章:状压DP的"三板斧"

2.1 核心三要素

任何一个状压DP题目,都离不开这三个要素:

  1. 状态定义dp[S],其中S是一个二进制整数,表示当前已选元素的集合。

  2. 状态转移 :从 dp[S] 转移到 dp[S | (1 << i)],即往集合S中加入一个新元素i。

  3. 初始状态和答案 :通常 dp[0] = 0,答案可能是 dp[(1<<N)-1](全部选完)。

2.2 位运算操作速查表

操作 位运算表达式 含义
检查第i位是否为1 S & (1 << i) 判断元素i是否在集合S中
将第i位设为1 `S (1 << i)`
将第i位设为0 S & ~(1 << i) 从集合S中移除元素i
将第i位取反 S ^ (1 << i) 翻转元素i的状态
枚举S的子集 for (sub = S; sub; sub = (sub-1) & S) 枚举S的所有非空子集
去掉S的最低位的1 S & (S - 1) 常用于统计1的个数

2.3 转移的"起手式"

状压DP的转移通常有两种写法:

写法一:正向递推(推荐)

cpp

复制代码
for (int S = 0; S < (1 << N); S++) {
    if (dp[S] == INF) continue;
    for (int i = 0; i < N; i++) {
        if (!(S & (1 << i))) {  // i不在S中
            int nxt = S | (1 << i);
            dp[nxt] = min(dp[nxt], dp[S] + cost[S][i]);
        }
    }
}

写法二:记忆化搜索

cpp

复制代码
int dfs(int S) {
    if (S == (1 << N) - 1) return 0;
    if (dp[S] != -1) return dp[S];
    int ans = INF;
    for (int i = 0; i < N; i++) {
        if (!(S & (1 << i))) {
            ans = min(ans, dfs(S | (1 << i)) + cost[S][i]);
        }
    }
    return dp[S] = ans;
}

两种写法本质相同,正向递推适合所有题目,记忆化搜索在转移条件复杂时更灵活。

第三章:实战前的理论准备------复杂度与优化

3.1 空间与时间的天花板

状压DP的复杂度通常是:

  • 状态数:O(2N)O(2N)

  • 转移数:每个状态枚举 O(N)O(N) 个未选元素

  • 总复杂度:O(N⋅2N)O(N⋅2N)

当 N≤20N≤20 时,N⋅2N≈20×106N⋅2N≈20×106,完全可行。

当 N=25N=25 时,25×225≈8.4×10825×225≈8.4×108,已经接近极限。

当 N≥30N≥30 时,状压DP基本失效,需要考虑其他算法(如搜索+剪枝、启发式算法等)。

3.2 常见优化手段

  1. 预处理合法状态:很多题目中,不是所有 2N2N 个状态都合法。我们可以先DFS或枚举所有合法状态存入数组,只在这些状态上做DP。

  2. 预处理转移代价 :如果转移代价 cost[S][i] 计算复杂,可以提前预处理成数组,避免在DP循环中重复计算。

  3. 滚动数组降维:如果只依赖上一层的状态,可以用滚动数组优化空间。

  4. 子集枚举优化 :某些题目需要枚举当前集合的子集,此时用 for (sub = S; sub; sub = (sub-1) & S) 可以达到 O(3^N),但 NN 较小时可以接受。

3.3 什么时候用状压DP?

  • N ≤ 20(通常)

  • 状态天然是"子集选择型"

  • 每个元素的取舍或访问顺序对结果有影响

  • 可以设计出清晰的"从子集到超集"的转移关系

第四章:经典例题精解------洛谷 P1171 售货员的难题

4.1 题目呈现

题目来源:洛谷 P1171 售货员的难题

题目描述

某乡有 nn 个村庄(1<n≤20),有一个售货员,他要到各个村庄去售货,各村庄之间的路程 ss 是已知的。售货员从村庄1出发,经过所有村庄恰好一次,最后回到村庄1。问:最短的行程是多少?

输入格式

  • 第一行:一个整数 n

  • 接下来 n 行,每行 n 个整数,第 i 行第 j 列表示村庄 i 到村庄 j 的距离

输出格式

  • 一个整数,表示最短行程

输入样例

复制代码
3
0 2 1
1 0 2
2 1 0

输出样例

复制代码
3

4.2 解题思路推导

这是最经典的 TSP问题(旅行商问题) ,也是状压DP的"hello world"。

状态定义

dp[S][i] 表示:已经访问过的村庄集合为 S,当前所在村庄为 i 时,从村庄1出发到达此状态的最短路径长度。

初始状态

dp[1][0] = 0,即只访问了村庄1(下标为0),当前在村庄1,路程为0。

状态转移

从当前状态 (S, i) 出发,如果村庄 j 还没有被访问(!(S & (1<<j))),则可以走到 j

dp[S | (1<<j)][j] = min(dp[S | (1<<j)][j], dp[S][i] + dist[i][j])

最终答案

访问完所有村庄后,还要回到起点村庄1:

ans = min(dp[(1<<n)-1][i] + dist[i][0]),其中 ii 是最后一个访问的村庄。

4.3 完整AC代码

复制代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 20;
const int INF = 0x3f3f3f3f;

int n;
int dist[MAXN][MAXN];
int dp[1 << MAXN][MAXN];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    
    cin >> n;
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            cin >> dist[i][j];
        }
    }
    
    // 初始化:所有状态赋为无穷大
    memset(dp, INF, sizeof(dp));
    dp[1][0] = 0;  // 起点:只访问了村庄0(即村庄1)
    
    // 枚举所有状态
    for (int S = 0; S < (1 << n); S++) {
        // 如果当前状态不包含起点村庄0,则状态不合法,跳过
        if (!(S & 1)) continue;
        
        for (int i = 0; i < n; i++) {
            // 如果i不在已访问集合中,或者dp[S][i]不可达,跳过
            if (!(S & (1 << i))) continue;
            if (dp[S][i] == INF) continue;
            
            // 尝试从i走到下一个未访问的村庄j
            for (int j = 0; j < n; j++) {
                if (S & (1 << j)) continue;  // j已经访问过了
                int nxtS = S | (1 << j);
                dp[nxtS][j] = min(dp[nxtS][j], dp[S][i] + dist[i][j]);
            }
        }
    }
    
    // 从任意最后访问的村庄回到起点0
    int ans = INF;
    for (int i = 0; i < n; i++) {
        ans = min(ans, dp[(1 << n) - 1][i] + dist[i][0]);
    }
    
    cout << ans << "\n";
    return 0;
}

4.4 代码详解与注意事项

第20行 :为什么只从S包含起点0的状态开始转移?因为售货员从村庄1出发,所有合法路径都必须包含起点。如果某个状态不包含起点,它不可能从起点转移而来,直接忽略可以节省时间。

第30行dp[1<<n - 1][i] 是访问完所有村庄后最后一个村庄为i的状态。要回到起点,需要加上dist[i][0]

复杂度分析

  • 状态数:n⋅2^n=20×1,048,576=20,971,520

  • 每个状态需要枚举下一个村庄 j,平均 n/2 次

  • 总转移次数约 O(n^2⋅2^n)≈4×108 次,在 n=20 时稍显紧张。

  • 优化:可以预处理每个状态中"最后一个访问的村庄",只枚举最后一个村庄 ii,减少一层循环。但上述代码已经能在1秒内通过洛谷数据。

4.5 空间优化:滚动数组(进阶)

如果 nn 接近25,dp[1<<n][n] 会爆内存。我们可以用滚动数组按状态数量分层 ,或者直接用 map / unordered_map 存储有效状态。

但在 n≤20n≤20 的场景下,dp[1<<20][20] = 20MB 左右,完全可接受。

第五章:状压DP的经典变种与拓展

5.1 变种一:求方案数(计数DP)

有些题目不要求最优值,而是问有多少种方案 。此时把min换成+=即可。

例题:POJ 2441 Arrange the Bulls

  • N头牛,M个谷仓,每头牛有自己喜欢的一些谷仓,求所有牛都分配到谷仓的方案数。

  • 状态:dp[S] 表示谷仓占用状态为S时的方案数,第i头牛尝试分配到未被占用的喜欢谷仓。

5.2 变种二:二进制枚举子集(子集DP)

有些题目需要枚举"一个集合的所有子集",如集合划分问题。

例题:洛谷 P3052 USACO12MAR Cows in a Skyscraper

  • N个物品,每个物品有重量,电梯有最大载重W,求最少需要多少趟电梯运完。

  • 状态:dp[S] 表示运输完集合S所需的最少趟数,rest[S] 表示最后一趟的剩余容量。

  • 转移:从 S 转移到一个超集 S | T,其中 TS 补集的一个子集,且 T 的总重量不超过 W

5.3 变种三:三进制状态(罕见但有趣)

有些元素有三种状态(如"未选""选了A""选了B"),此时需要三进制压缩。用两个二进制位表示一个元素的状态,或者直接用 vector 存储状态。算法竞赛中较少见,但偶尔会出现。

5.4 与记忆化搜索的对比

维度 正向递推 记忆化搜索
代码量 简洁 略长
适用范围 所有DAG转移 所有DAG转移
效率 略快(无函数递归开销) 略慢
可读性 需要理解转移顺序 直观,与DFS思路一致
推荐场景 常规状压题 转移条件复杂、需要剪枝时

总结

状态压缩DP是算法竞赛中"以时间换空间"思维的极致体现。它用二进制这把钥匙,打开了集合型问题的大门,让原本指数级难度的组合问题在小数据范围内变得可解。无论是经典的TSP问题,还是各类排列组合计数、集合划分问题,状压DP都提供了一套清晰、统一、可复用的解题框架。

三个关键点

  1. 核心是把集合压缩成整数:一个N位二进制数表示一个子集,dpSdpS 存储该状态下的最优值。

  2. 转移本质是"向集合中加元素":从子集到超集,用位运算判断元素是否已在集合中。

  3. N≤20是黄金适用区间:O(N⋅2N)O(N⋅2N) 的复杂度在 N=20N=20 时约为2000万级别,C++可轻松通过。

"状压DP教会我们:当问题足够小时,暴力不再是一种鲁莽,而是一种艺术------用二进制去枚举所有可能,用DP去筛选最优答案,这就是算法竞赛中最美的'以力破巧'。"

参考文献与延伸阅读

  1. 《算法竞赛入门经典(第二版)》 ------ 刘汝佳,第9章(动态规划)

  2. OI-Wiki:状态压缩DP

  3. 洛谷 P1171 售货员的难题

  4. 洛谷 P3052 USACO12MAR Cows in a Skyscraper

  5. POJ 2441 Arrange the Bulls

  6. 《挑战程序设计竞赛》 第2章------状态压缩DP

相关推荐
Gauss松鼠会1 小时前
【GaussDB】GaussDB锁阻塞源头查询
java·开发语言·前端·数据库·算法·gaussdb·经验总结
oier_Asad.Chen1 小时前
【洛谷题解/AcWing题解】洛谷P4011 孤岛营救问题/AcWing1131拯救大兵瑞恩
数据结构·笔记·学习·算法·动态规划·图论·宽度优先
冻柠檬飞冰走茶2 小时前
PTA基础编程题目集 7-19 支票面额(C语言实现)
c语言·开发语言·数据结构·算法
你怎么知道我是队长2 小时前
计算机虚拟存储管理与页面置换算法详解
服务器·网络·算法
罗西的思考2 小时前
【Agentic RL / 强化学习 / OPD】OpenClaw-RL 源码阅读笔记 — (14)— Teacher
人工智能·笔记·深度学习·算法·机器学习
塔能物联运维3 小时前
塔能两相液冷:600W/cm²极限能力,为下一代AI芯片预留充足散热余量
人工智能·算法·两相液冷
醉城夜风~3 小时前
手撕 C++ string:从零实现一个简易字符串类
开发语言·c++·算法
手写码匠3 小时前
华为云Flexus+DeepSeek征文|DeepSeek+RAG知识库实战:基于Flexus X实例与Dify构建企业级智能问答系统
人工智能·深度学习·算法·aigc
青山木4 小时前
Hot 100 --- 组合总和
java·数据结构·算法·leetcode