Java教程

状态压缩 DP 学习笔记【入门篇】

本文主要是介绍状态压缩 DP 学习笔记【入门篇】,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

前言

状态压缩 DP,简称状压 DP

之前一直觉得状压特别难,学了一下,发现基本形态挺简单的。

在学习之前,你需要掌握:

  1. 简单 DP(如线性 DP,背包)
  2. 基本二进制运算:& 运算、| 运算、\(\oplus\) 运算、左右移运算符。

什么是状压 DP

状态压缩,顾名思义,就是对当前的状态压缩。

怎么压缩呢?答案是二进制。

比如一个简单的例子:我有五个小球,依次编号 \(0\) 到 \(5\)。

我想表达『选 \(1\) 号与 \(3\) 号球』,怎么压缩?

很显然,对应二进制是 \((01010)_2\),转换成十进制就是 \(10\)。

这个就是状压 DP。

也就是说,状态的转移(二进制转移),可以变成整数的加减(十进制加减)。

二进制运算

既然都有二进制,二进制的简单技巧肯定少不了。

二进制数 \(x\),第 \(n\) 位是否为 \(k\)

此处 \(k\) 取值 \(1\) 或 \(0\)。

将 \(x\) 右移 \((n-1)\) 位,即 \(x >> (n-1)\)。

此时,\(x\) 的末位就是第 \(n\) 位,拉出个位即可:\(x >> (n-1)\) & $ 1$。

所以判断语句即为:if ( (x >> (n-1) & 1) == k)

* 在打代码时,需要注意括号。位运算优先级较为复杂,保险起见可以打括号,但必须保证可读性。

二进制数 \(x\),修改第 \(n\) 位为 \(1\)

利用或运算求解。

将第 \(n\) 位修改成 \(1\),相当于 \(x\) | \(100\cdots00\),应该有 \((n-1)\) 个 \(0\)。

所以修改语句即为:x | (1 << (n-1))

二进制数 \(x\) 最低位的 \(1\) 改成 \(0\)

比如说,将 \((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[][] 表示。

\(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\)。

大家肯定还是不理解,我们来看一道经典例题。

经典例题 - TSP 问题

题意

前置知识:\(\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

这篇关于状态压缩 DP 学习笔记【入门篇】的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!