状态压缩 DP,简称状压 DP。
之前一直觉得状压特别难,学了一下,发现基本形态挺简单的。
在学习之前,你需要掌握:
&
运算、|
运算、\(\oplus\) 运算、左右移运算符。状态压缩,顾名思义,就是对当前的状态压缩。
怎么压缩呢?答案是二进制。
比如一个简单的例子:我有五个小球,依次编号 \(0\) 到 \(5\)。
我想表达『选 \(1\) 号与 \(3\) 号球』,怎么压缩?
很显然,对应二进制是 \((01010)_2\),转换成十进制就是 \(10\)。
这个就是状压 DP。
也就是说,状态的转移(二进制转移),可以变成整数的加减(十进制加减)。
既然都有二进制,二进制的简单技巧肯定少不了。
此处 \(k\) 取值 \(1\) 或 \(0\)。
将 \(x\) 右移 \((n-1)\) 位,即 \(x >> (n-1)\)。
此时,\(x\) 的末位就是第 \(n\) 位,拉出个位即可:\(x >> (n-1)\) & $ 1$。
所以判断语句即为:if ( (x >> (n-1) & 1) == k)
。
* 在打代码时,需要注意括号。位运算优先级较为复杂,保险起见可以打括号,但必须保证可读性。
利用或运算求解。
将第 \(n\) 位修改成 \(1\),相当于 \(x\) | \(100\cdots00\),应该有 \((n-1)\) 个 \(0\)。
所以修改语句即为:x | (1 << (n-1))
。
比如说,将 \((100101)_2\) 修改为 \((100100)_2\) ,将 \((110100)_2\) 修改为 \((110000)_2\)。
方法一:利用 \(\texttt{lowbit()}\) 修改:x - (x&-x)
。其中的 \(\texttt{lowbit()}\) 在树状数组中有使用,不理解没关系。
方法二:
对于一个数 \(x\),\((x-1)\) 总是等于:将末尾 \(0\) 变成 \(1\),最低位 \(1\) 变成 \(0\),前面不变。
所以,x & (x-1)
就是答案。
你可能听不懂,那么举个例子。
若 \(x = (110\space100)_2\),则 \((x - 1) = (110\space 001)_2\)。
容易发现,前三位进行 &
运算,不变;后三位进行 &
运算,必为 \(0\)。
这下懂了吧!
一般地,状态都用一个二维数组 dp[][]
表示。
\(dp_{i, j}\) 的第一维 \(i\),通常是二进制数,表示哪些物品被选过。
第二维 \(j\) 则比较多变,需要根据题目转换。
这里还需要知道一个东西:\(i\) 的取值范围。
假如一共有 \(n+1\) 个点,编号 \(0\) 至 \(n\),则 \(i = [0, 2^0 + 2^1 + \cdots + 2^n]\)。
后半段的 \(2^0 + 2^1 + \cdots + 2^n\) 是等比数列,简单普及一下求解方法。
令 \(S = 2^0 + 2^1 + \cdots + 2^n\)。
则 \(2\cdot S = 2^1 + 2^2 + \cdots + 2^n + 2^{n+1}\)。
两式相减得:\(2\cdot S - S = 2^{n+1} - 2^0\)。
简化得:\(S = 2^{n+1} - 1\)。
大家肯定还是不理解,我们来看一道经典例题。
前置知识:\(\texttt{Floyd}\) / \(\texttt{dijkstra}\) 算法。
旅行商问题(Traveling salesman problem,即 TSP),是组合优化中的 NP 问题。
至今还没有多项式解法,仅有指数级做法。
具体问题如下:
有一张图(一般为无向图),保证所有点均连通。
有一个旅行商在 \(0\) 号点,要求他从 \(0\) 号点出发,访问过所有的城市并回到原点。
给定每对城市之间的距离,求出最短路。
如果用暴力,时间复杂度过高。
所以使用状压 DP 实现。
首先,看图的稠密性决定使用 \(\texttt{floyd}\) 还是 \(\texttt{dijkstra}\)。
反正,用最短路算法,求出任意两点的最短距离。
设 \(dp_{i, j}\) 表示行走过的点的状态为 \(i\)(对应二进制数),最后一个点是 \(j\) 号点。
我们可以枚举 \(k = [0, \texttt{maxn}]\)。
接下来再枚举 \(i\) 与 \(j\),表示几号点。
我们可以大致写出有关 \(i \to j\) 的状态转移方程:
如果 \(k\) 的第 \(j\) 位为 \(0\),说明可以尝试转移:
\[\color{black}{dp[\space}\color{red}{k | (1<<n)}\color{black}{\space}][\space j\space] = \min\begin{cases}\color{black}{dp[\space}\color{red}{k | (1<<n)}\color{black}{\space ][\space j\space]}\\\\ \color{black}{dp[\space}\color{red}{k}\color{black}{\space ][\space i\space]} + e_{j, i}\end{cases} \]啊这个方程写崩溃了。
最后的答案即为:\(dp[\texttt{maxn}][0]\)。
对了,不要忘记初始化 \(dp_{i, j} = \infty\),\(dp_{0, 0} = 0\)。
给出代码。
大致讲一下这份代码的读入格式。
第一行 \(n\) 表示有 \((n+1)\) 个点,编号 \(0\) 至 \(n\)。
接下来一个 \((n+1)\times(n+1)\) 的矩阵,矩阵的第 \(i\) 行 \(j\) 列表示 \(dis[i \to j]\)。
道路是单向的。
这里由于图是稠密图,所以使用 \(\texttt{floyd}\)。
#include <iostream> #include <cstdio> #include <cstring> #define INF 0x3f3f3f3f #define N 20 using namespace std; int n, e[N][N], dp[1<<N][N]; void Input() { scanf("%d", &n); for (int i = 0; i <= n; i++) for (int j = 0; j <= n; j++) scanf("%d", &e[i][j]); } void floyd() { for (int k = 0; k <= n; k++) for (int i = 0; i <= n; i++) for (int j = 0; j <= n; j++) e[i][j] = min(e[i][j], e[i][k] + e[k][j]); } void DP() { memset(dp, INF, sizeof(dp)); dp[0][0] = 0; int maxn = (1 << n+1) - 1; for (int k = 0; k <= maxn; k++) for (int i = 0; i <= n; i++) for (int j = 0; j <= n; j++) if ( ((k >> j) & 1) == 0) //判断第 j 位是否为 0。 //把第 j 位改成 1。 dp[ k|(1<<j) ][j] = min(dp[ k|(1<<j) ][j], dp[k][i] + e[j][i]); printf("%d", dp[maxn][0]); } int main() { Input(); floyd(); DP(); }
通过这道题目,我们发现:状压 DP 的时间复杂度一般是 \(O(2^n \cdot n^2)\)。
由此可得,状压 DP 的适用范围不会很广,\(n\le20\) 左右。
如果 \(n\) 再大一点,不说时间问题,空间都爆炸啦(空间至少需要 \(2^n\),本题空间 \(2^n \cdot n + n^2\))!
考虑到这个后,你就可以一眼知道,一道 DP 题有没有使用状压 DP 的可能性。
貌似没有实战例题,以后有时间再补例题吧。
状压 DP 真的不难,希望大家努力学会!
此外,推荐两道状压入门题:link1 & link2。
还有大神整理的题单:link。
首发:2022-06-12 17:34:29