第29篇-状态压缩DP-当状态很多时如何降维优化

概述:为什么需要状态压缩 DP

前面我们讲过线性 DP、背包 DP 和区间 DP。

这些 DP 的状态通常比较直观:

text 复制代码
dp[i]
dp[i][j]

但有些问题的状态不是一个位置、一个容量或一个区间,而是:

text 复制代码
一组元素是否已经被选择

比如:

  • 哪些任务已经完成
  • 哪些城市已经访问
  • 哪些数字已经使用
  • 哪些人已经被分配工作

如果直接用数组或集合去表示这些状态,代码会很复杂。

这时就可以使用 状态压缩 DP

状态压缩 DP 的核心思想是:

text 复制代码
用一个整数的二进制位表示一组选择状态

本篇会重点讲:

  1. 什么是状态压缩
  2. 二进制状态如何表示集合
  3. 如何枚举状态和判断某一位
  4. 任务分配问题怎么写状态压缩 DP
  5. TSP 类问题的基本模板
  6. 常见坑点和复杂度分析

学完这篇,你应该能看懂 mask1 << idp[mask] 这类写法,并能用二进制集合解决小规模选择问题。

核心概念:什么是状态压缩

状态压缩,就是把一个复杂状态压成一个整数。

假设有 4 个任务,编号为:

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

我们可以用一个四位二进制数表示哪些任务已经完成。

text 复制代码
0000 表示一个任务都没完成
0001 表示任务 0 已完成
0010 表示任务 1 已完成
0101 表示任务 0 和任务 2 已完成
1111 表示 4 个任务都已完成

这里每一位都代表一个元素的选择状态:

  • 0:没有选
  • 1:已经选

为什么可以用整数表示

因为二进制位天然就是一组开关。

一个整数有很多二进制位,每一位都可以表示一个元素是否存在。

这就把"集合"变成了"数字"。

状态压缩就是用二进制位表示集合,用整数表示复杂选择状态。

位运算基础:状态压缩必须掌握的操作

状态压缩 DP 经常用到这些位运算。

1. 表示第 i 位

java 复制代码
1 << i

例如:

text 复制代码
1 << 0 = 0001
1 << 1 = 0010
1 << 2 = 0100

2. 判断第 i 位是否为 1

java 复制代码
(mask & (1 << i)) != 0

如果结果不为 0,说明第 i 位是 1

3. 把第 i 位设置为 1

java 复制代码
mask | (1 << i)

这表示把元素 i 加入集合。

4. 把第 i 位设置为 0

java 复制代码
mask & ~(1 << i)

这表示把元素 i 从集合中移除。

5. 判断集合是否包含某个元素

java 复制代码
if ((mask & (1 << i)) != 0) {
    // i 已经被选择
}

6. 枚举所有状态

如果有 n 个元素,总状态数是:

text 复制代码
2^n

代码写成:

java 复制代码
for (int mask = 0; mask < (1 << n); mask++) {
    // 处理状态 mask
}

状态压缩 DP 的基础操作,就是检查某一位、添加某一位和枚举所有二进制状态。

为什么状态数量是 2^n

每个元素都有两种状态:

  • 不选

如果有 n 个元素,那么总状态数就是:

text 复制代码
2 * 2 * 2 * ... * 2 = 2^n

例如 n = 3 时,所有状态是:

text 复制代码
000
001
010
011
100
101
110
111

一共 8 个。

状态压缩适合多大规模

因为状态数量是指数级,所以状态压缩 DP 一般适合:

text 复制代码
n <= 20

如果转移里还要再枚举一个元素,复杂度通常是:

text 复制代码
O(n * 2^n)

如果还要枚举两个元素,可能变成:

text 复制代码
O(n^2 * 2^n)

状态压缩不是把指数级问题变成线性,而是把可控范围内的指数状态表示得更高效。

经典模型:任务分配的最小成本

先看一个很适合入门的状态压缩 DP。

题目描述:

n 个工人和 n 个任务,cost[i][j] 表示第 i 个工人做第 j 个任务的成本。每个工人只能做一个任务,每个任务也只能被一个工人做,求最小总成本。

例如:

text 复制代码
cost = [
  [9, 2, 7],
  [6, 4, 3],
  [5, 8, 1]
]

要给每个工人分配一个不同任务,使总成本最小。

为什么适合状态压缩

因为我们需要记录:

text 复制代码
哪些任务已经被分配

如果任务数量不大,就可以用一个 mask 表示任务集合。

任务分配问题的核心状态,是已经使用过哪些任务。

状态定义:dp[mask] 表示什么

定义:

text 复制代码
dp[mask] 表示已经分配了 mask 中这些任务时的最小成本

例如:

text 复制代码
mask = 101

表示任务 0 和任务 2 已经被分配。

当前应该给哪个工人分配任务

如果 mask 里有 k1,说明已经分配了 k 个任务。

那么下一个要分配任务的工人就是:

text 复制代码
第 k 个工人

在 Java 里可以用:

java 复制代码
int worker = Integer.bitCount(mask);

Integer.bitCount(mask) 会返回 mask1 的个数。

mask 记录任务集合,bitCount(mask) 决定当前轮到哪个工人。

状态转移:枚举下一个任务

当前状态是 mask,当前工人是 worker

我们要枚举一个还没有被分配的任务 job

如果任务 job 没有被选过:

java 复制代码
(mask & (1 << job)) == 0

那么可以把它分配给当前工人。

新状态是:

java 复制代码
nextMask = mask | (1 << job)

转移方程是:

text 复制代码
dp[nextMask] = min(dp[nextMask], dp[mask] + cost[worker][job])

初始状态

一开始没有任何任务被分配:

text 复制代码
dp[0] = 0

其他状态初始化为一个很大的数。

最终答案

所有任务都被分配时:

text 复制代码
mask = (1 << n) - 1

所以答案是:

text 复制代码
dp[(1 << n) - 1]

从当前任务集合出发,选择一个没用过的任务加入集合,并更新最小成本。

Java 实现:任务分配最小成本

java 复制代码
import java.util.Arrays;

class Solution {
    public int minCost(int[][] cost) {
        int n = cost.length;
        int totalStates = 1 << n;
        int[] dp = new int[totalStates];

        Arrays.fill(dp, Integer.MAX_VALUE / 2);
        dp[0] = 0;

        for (int mask = 0; mask < totalStates; mask++) {
            int worker = Integer.bitCount(mask);

            if (worker >= n) {
                continue;
            }

            for (int job = 0; job < n; job++) {
                if ((mask & (1 << job)) == 0) {
                    int nextMask = mask | (1 << job);
                    dp[nextMask] = Math.min(
                        dp[nextMask],
                        dp[mask] + cost[worker][job]
                    );
                }
            }
        }

        return dp[totalStates - 1];
    }
}

代码关键点

  • totalStates = 1 << n:总状态数是 2^n
  • dp[0] = 0:没有分配任何任务时成本为 0
  • Integer.bitCount(mask):根据已分配任务数确定当前工人
  • nextMask:把新任务加入集合
  • dp[totalStates - 1]:所有任务都被分配后的最小成本

任务分配模型是状态压缩 DP 的标准入门题,核心是用 mask 表示已经选过的任务。

用一个小例子走一遍状态

假设有 3 个任务。

初始状态:

text 复制代码
mask = 000
dp[000] = 0

此时 bitCount(000) = 0,轮到工人 0

可以选择任务 012

text 复制代码
000 -> 001
000 -> 010
000 -> 100

下一轮如果状态是:

text 复制代码
mask = 010

说明任务 1 已经被分配。

此时 bitCount(010) = 1,轮到工人 1

还能选择任务 0 或任务 2

text 复制代码
010 -> 011
010 -> 110

最终会走到:

text 复制代码
111

也就是所有任务都已经分配完成。

每次转移都是从一个集合状态走向多选一个元素的新集合状态。

状态压缩 DP 的常见形式

状态压缩 DP 常见状态有两类。

一维状态:dp[mask]

这种状态只关心集合本身。

例如任务分配:

text 复制代码
dp[mask] 表示已经选了 mask 这些任务时的最小成本

常见转移:

text 复制代码
从 mask 添加一个新元素,得到 nextMask

二维状态:dp[mask][i]

这种状态不仅关心集合,还关心当前停在哪个元素。

例如旅行商问题:

text 复制代码
dp[mask][i] 表示已经访问 mask 这些城市,并且当前停在城市 i 的最短距离

这里的 i 很重要,因为下一步从哪里出发会影响代价。

如果只关心选了哪些元素,用 dp[mask];如果还关心当前停在哪里,用 dp[mask][i]

经典模型:TSP 旅行商问题

旅行商问题是状态压缩 DP 的经典模型。

题目简化描述:

n 个城市,任意两个城市之间有距离。从城市 0 出发,访问每个城市一次,最后回到城市 0,求最短路径。

状态定义

text 复制代码
dp[mask][i] 表示已经访问 mask 中的城市,并且当前停在城市 i 的最短距离

初始化

从城市 0 出发:

text 复制代码
dp[1][0] = 0

这里 1 的二进制是:

text 复制代码
0001

表示只访问了城市 0

状态转移

如果当前停在城市 i,下一步去城市 j,并且城市 j 没访问过:

text 复制代码
nextMask = mask | (1 << j)
dp[nextMask][j] = min(dp[nextMask][j], dp[mask][i] + dist[i][j])

最终答案

访问完所有城市后,还要回到城市 0

text 复制代码
ans = min(dp[fullMask][i] + dist[i][0])

其中:

text 复制代码
fullMask = (1 << n) - 1

TSP 需要 dp[mask][i],因为不仅要知道访问了哪些城市,还要知道当前在哪个城市。

Java 模板:TSP 状态压缩 DP

java 复制代码
import java.util.Arrays;

class Solution {
    public int tsp(int[][] dist) {
        int n = dist.length;
        int totalStates = 1 << n;
        int[][] dp = new int[totalStates][n];
        int inf = Integer.MAX_VALUE / 2;

        for (int[] row : dp) {
            Arrays.fill(row, inf);
        }

        dp[1][0] = 0;

        for (int mask = 0; mask < totalStates; mask++) {
            for (int i = 0; i < n; i++) {
                if ((mask & (1 << i)) == 0) {
                    continue;
                }

                for (int j = 0; j < n; j++) {
                    if ((mask & (1 << j)) != 0) {
                        continue;
                    }

                    int nextMask = mask | (1 << j);
                    dp[nextMask][j] = Math.min(
                        dp[nextMask][j],
                        dp[mask][i] + dist[i][j]
                    );
                }
            }
        }

        int fullMask = totalStates - 1;
        int ans = inf;

        for (int i = 1; i < n; i++) {
            ans = Math.min(ans, dp[fullMask][i] + dist[i][0]);
        }

        return ans;
    }
}

为什么要判断城市 imask

因为 dp[mask][i] 的含义要求:

text 复制代码
当前停在城市 i,并且城市 i 已经被访问

如果 i 不在 mask 中,这个状态本身就是无效的。

为什么城市 j 不能在 mask

因为旅行商问题要求每个城市访问一次。

如果 j 已经访问过,就不能再访问。

mask 控制已经访问的城市,i 控制当前所在城市,j 控制下一步去哪里。

子集枚举:状态压缩里的进阶技巧

有些题需要枚举一个集合的所有子集。

例如给定:

text 复制代码
mask = 1110

你可能需要枚举它的所有非空子集。

常见写法是:

java 复制代码
for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
    // sub 是 mask 的一个非空子集
}

为什么这样能枚举子集

sub - 1 会改变二进制低位结构,再和 mask 做与运算,就能保证结果始终是 mask 的子集。

这个技巧在集合划分、分组 DP 中很常见。

包含空集怎么办

如果也要处理空集,可以在循环结束后单独处理:

java 复制代码
// sub = 0

或者使用 do while,但初学阶段建议写清楚一点。

sub = (sub - 1) & mask 是枚举某个集合所有子集的经典写法。

状态压缩 DP 的常见题型

状态压缩 DP 常见于这些场景。

1. 选择集合类

题目会问:

  • 哪些元素已经选过
  • 哪些任务已经完成
  • 哪些数字已经使用

常用状态:

text 复制代码
dp[mask]

2. 路径访问类

题目会问:

  • 已经访问了哪些点
  • 当前停在哪个点
  • 访问所有点的最小代价

常用状态:

text 复制代码
dp[mask][i]

3. 分组划分类

题目会问:

  • 把若干元素分成几组
  • 每组满足某个条件
  • 最少分组或最大收益

常用技巧:

text 复制代码
枚举 mask 的子集

只要题目需要记录"哪些元素已经被使用",就可以优先考虑状态压缩。

常见错误点

1. 位下标和元素下标混乱

i 个元素对应:

java 复制代码
1 << i

不要写成 1 << (i + 1),除非你明确从 1 开始编号。

2. 忘记初始化不可达状态

求最小值时,不能把所有 dp 默认成 0

应该初始化成较大值:

java 复制代码
Arrays.fill(dp, Integer.MAX_VALUE / 2);

否则很多不合法状态会被当成合法状态。

3. 溢出问题

如果用 Integer.MAX_VALUE,再加一个成本可能溢出。

所以常用:

java 复制代码
Integer.MAX_VALUE / 2

4. mask 范围写错

总状态数是:

java 复制代码
1 << n

循环范围是:

java 复制代码
mask < (1 << n)

最终全集是:

java 复制代码
(1 << n) - 1

5. 没判断当前状态是否合法

dp[mask][i] 里,要确认:

java 复制代码
(mask & (1 << i)) != 0

否则可能从不存在的状态转移。

6. 数据规模太大还硬套状态压缩

如果 n = 30,状态数就是:

text 复制代码
2^30

这通常已经不可接受。

状态压缩 DP 的错误多数来自位运算边界、初始化和状态合法性判断。

复杂度分析:指数级但可控

状态压缩 DP 的复杂度主要看状态数量。

dp[mask] 类型

状态数量是:

text 复制代码
2^n

如果每个状态枚举一个元素,时间复杂度通常是:

text 复制代码
O(n * 2^n)

空间复杂度是:

text 复制代码
O(2^n)

dp[mask][i] 类型

状态数量是:

text 复制代码
n * 2^n

如果转移还要枚举下一个点,时间复杂度通常是:

text 复制代码
O(n^2 * 2^n)

空间复杂度是:

text 复制代码
O(n * 2^n)

为什么仍然有价值

虽然是指数级,但它比暴力排列要好很多。

例如访问 n 个城市的暴力排列复杂度接近:

text 复制代码
O(n!)

而状态压缩 DP 可以降到:

text 复制代码
O(n^2 * 2^n)

对于小规模 n,这是非常大的优化。

状态压缩 DP 仍然是指数级,但通常能把排列级复杂度降到可接受范围。

标准思考流程:遇到状态压缩题怎么想

可以按下面顺序分析。

第一步:判断是否需要记录集合

如果题目要求记录:

  • 哪些元素用过
  • 哪些点访问过
  • 哪些任务完成过

就可以考虑用 mask

第二步:确定每一位代表什么

写清楚:

text 复制代码
第 i 位表示第 i 个元素是否被选择

第三步:定义 dp

常见定义:

text 复制代码
dp[mask]
dp[mask][i]

第四步:确定转移方向

通常有两种:

  • 从旧集合添加一个元素
  • 从当前集合删除一个元素

初学阶段更推荐"添加元素"的写法。

第五步:确定初始状态和最终状态

常见初始状态:

text 复制代码
dp[0] = 0
dp[1 << start][start] = 0

常见最终状态:

text 复制代码
dp[(1 << n) - 1]
dp[(1 << n) - 1][i]

状态压缩题先定义每一位的含义,再围绕 mask 设计状态和转移。

模板总结:状态压缩 DP 怎么写

一维集合模板

java 复制代码
int totalStates = 1 << n;
int[] dp = new int[totalStates];
Arrays.fill(dp, inf);
dp[0] = 0;

for (int mask = 0; mask < totalStates; mask++) {
    for (int i = 0; i < n; i++) {
        if ((mask & (1 << i)) == 0) {
            int nextMask = mask | (1 << i);
            dp[nextMask] = Math.min(dp[nextMask], dp[mask] + cost);
        }
    }
}

二维路径模板

java 复制代码
int totalStates = 1 << n;
int[][] dp = new int[totalStates][n];

for (int[] row : dp) {
    Arrays.fill(row, inf);
}

dp[1 << start][start] = 0;

for (int mask = 0; mask < totalStates; mask++) {
    for (int i = 0; i < n; i++) {
        if ((mask & (1 << i)) == 0) {
            continue;
        }

        for (int j = 0; j < n; j++) {
            if ((mask & (1 << j)) != 0) {
                continue;
            }

            int nextMask = mask | (1 << j);
            dp[nextMask][j] = Math.min(dp[nextMask][j], dp[mask][i] + cost);
        }
    }
}

子集枚举模板

java 复制代码
for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
    // sub 是 mask 的非空子集
}

状态压缩 DP 的模板核心,就是围绕 mask 做判断、添加元素和更新状态。

总结

状态压缩 DP 看起来代码里有很多位运算,但本质仍然是动态规划。

你可以重点记住下面几句话:

  • 状态压缩用二进制位表示集合
  • mask 是一个整数,但可以看作一组选择状态
  • 1 << i 表示第 i 个元素
  • dp[mask] 适合只关心集合本身的问题
  • dp[mask][i] 适合既关心集合又关心当前位置的问题
  • 总状态数通常是 2^n,适合小规模问题
  • 初始化不可达状态时要用较大值,避免非法转移
  • 子集枚举可以用 (sub - 1) & mask

状态压缩 DP 的难点不在于背代码,而在于先把"每一位表示什么"讲清楚。

只要 mask 的含义稳定,转移就会变得有迹可循。

相关推荐
weixin_446729161 小时前
javaweb--文件上传
java
令狐掌门1 小时前
2026华为OD面试题020:日志文件异常检测
算法·leetcode·华为od
炸薯条!1 小时前
从零开始学C++(4) --类和对象
开发语言·c++·算法
渡我白衣1 小时前
打印宏与socket模块设计
java·linux·开发语言·c++·ide·人工智能·eclipse
haluhalu.1 小时前
prompts.chat:07-few-shot-prompting
人工智能·算法
智码看视界1 小时前
Day18 SpringBoot自动配置原理:从@SpringBootApplication开始
java·spring boot·后端·自动装配
Jerry1 小时前
LeetCode 150. 逆波兰表达式求值
算法
浩瀚地学1 小时前
【面试算法笔记】0106-数组-区间和
笔记·算法·面试
Jerry7 小时前
LeetCode 1047. 删除字符串中的所有相邻重复项
算法