【笔记】状压 DP

状压 \(DP\)

状态压缩是设计 \(dp\) 状态的一种方式。

当普通的 \(dp\) 状态维数很多(或者说维数与输入数据有关),但每一维总量很少时,可以将多维状态压缩为一维来记录。

这种题目最明显的特征就是:都存在某一给定信息的范围非常小(在 \(20\) 以内),而我们在 \(dp\) 中所谓压缩的就是这一信息,或者是在做题过程中分析出了某一信息种类数很少。

其实本质就是很暴力的记录状态,只不过利用了题目本身的特殊条件(这一维很小),使得我们并不会因此复杂度过高。

同时也就是说,如果题目本身没有这样一个较小的信息,就不能应用状态压缩。

注意一下题目所给的条件,状态压缩 \(dp\) 肯定是有一维是指数级的,这正是状态压缩的特点。

【经典题】

\(Description\)

给出一个 \(n \times m\) 的棋盘,要放上一些棋子,要求不能有任意两个棋子相邻,求方案数。

\(n \leq 100\)\(m \leq 8\)

\(Solution\)

我们发现这个 \(m\) 是非常小的,这样就可以启发我们对每一行 \(2^m\) 状态压缩。

\(dp[i][S]\) 表示到了第 \(i\) 行,第 \(i\) 行的状态是 \(S\) 的方案数是多少。

其中 \(S\) 的第 \(j\) 位为 \(1\),表示 \(i\) 这行第 \(j\) 位放了一个棋子。

状态转移:\(dp[i][S]= dp[i][S] + \{dp[i-1][ S' ] | S \& S' == 0 \}\)

你会发现这样记录很暴力,状态数是与 \(m\) 相关的指数级的,但同时也就是
因为 \(m\) 小我们就确实可以这么做。

【互不侵犯】

\(Description\)

\(n \times n\) 的棋盘上放置 \(k\) 个国王,使得任意两个国王互相不攻击。一个国王可以攻击到周围八个格子,求放置方案数。

\(n \leq 9\)

\(Solution\)

\(f[i][j][S]\) 表示放完前 \(i\) 行,第 \(i\) 行的放置状态为 \(S\) 的方案数, \(j\) 个国王。

\(S\) 为一个 \(n\) 位二进制数,表示第 \(i\) 行的每一位上是否有棋子。

由于棋子不能相邻,也就是这个二进制不能有相邻的 \(1\),所以可以先对求出对一行来说合法的状态(合法的二进制数有哪些)。

转移就直接暴力 :

\[f[i][j][s] += \{f[i - 1][j - cnt(s)][T] \ \ | \ \ (T |((T << 1)|(T >> 1)) \& S) == 0\} \]

时间复杂度 : \(O(n \times tot ^ 2)\) (\(tot\) 是总共合法的状态数)

\(Code\)

#include <cmath>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <algorithm>
#define ll long long
#define N 1010

using namespace std;

ll n,m,tot,ans=0;
ll cnt[N],flag[N];
ll sum[11][N][N];

int read() {
	int s = 0, f = 0; char ch = getchar();
	while (!isdigit(ch)) f |= (ch == '-'), ch = getchar();
	while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
	return f ? -s : s;
}

void prepare()
{
	for(int i=0;i<(1<<n);i++)
	{
		int k=0,s=i;
		while(s)
		{
			if(s&1) k++;
			s>>=1;
		}
		cnt[i]=k;
		if((((i<<1)&i)==0)&&(((i>>1)&i)==0)) flag[++tot]=i;
	}
	return;
}

signed main()
{
	cin>>n>>m;
	prepare();
	sum[0][0][0]=1;
	for(int i=1;i<=n;i++)
	{	
		for(int j=1;j<=tot;j++)
		{
			int a=flag[j];
			for(int k=1;k<=tot;k++)
			{
				int b=flag[k];
				if(a&b) continue;
				if((a<<1)&b) continue;
				if((a>>1)&b) continue;
				for(int h=0;h<=m;h++)
				{
					if(h-cnt[a]>=0)
					sum[i][h][a]+=sum[i-1][h-cnt[a]][b];
				}
			}
		}
	}
	for(int i=1;i<=tot;i++)
	{
		ans+=sum[n][m][flag[i]];
	}
	cout<<ans;
	return 0;
}

话说看自己写的代码码风好难受。

位运算

状态压缩有 \(k\) 进制的,论题目出现频率的话二进制的题目居多,同时因为计算机本身存储数就是以二进制方式,所以二的幂次的进制处理很方便,状态压缩 \(dp\) 中有时巧用位运算可以产生很好的效果,比如本题,我们就不需要 \(O(n)\) 枚举每一位判断是否冲突了。

一些简单的技巧 :

  • \((S \ \ \And \ \ (1 << i) )\) : 判断第 \(i\) 位是否是为 \(1\)
  • \(S = S \ \ | \ \ (1 << i)\) : 把第 \(i\) 位设置为 \(1\)
  • \(S = S \ \ \And \ \ (\)~\((1 << i))\) : 把第 \(i\) 位设置为 \(0\)
  • \(S = S \ \ xor \ \ (1 << i)\) : 把第 \(i\) 位取反。
  • \(S = S \ \ \& \ \ (S - 1)\) : 把一个数字 \(S\) 二进制下最靠右的第一个 \(1\) 去掉。

依次枚举 \(S\) 的子集。

\[(x + y) ^ n = \sum _{k = 0} ^ n \dbinom{n}{k} x ^ {n - k} y ^ k = \sum _{k = 0} ^ n \dbinom{n}{k} x ^ {k} y ^ {n - k} \]

二项式定理/jk

拓扑序个数问题

\(Description\)

给你一张拓扑图,求这张拓扑图有多少种不同的拓扑序。

\(n \leq 20\)

\(Solution\)

\(dp[S]\) 表示当前 \(S\) 集合中的点都已经在拓扑序中的方案数,转移考虑枚举下一个点选什么,下一个选的点要满足它在 \(s\) 中的点选完后的入度为 \(0\),也就是指向它的点都已经加进拓扑序里了,转移到 \(dp[S \ \ | \ \ (1 << i-1)]\)

复杂度 : \(O(2 ^ n \times n)\)

\(Mondriaan's Dream\)

\(Description\)

\(1 \times 2\) 的骨牌去填满 \(N \times M\) 的棋盘,问一共有多少种方案?

\(n \leq 100\)\(m \leq 8\)

\(Solution\)

以行为阶段,令 \(f[i][state]\) 表示放置到第 \(i\) 行,第 \(i\) 行的状态为 \(state\) 的方案 。\(state\) 是一个 \(m\) 位的 2 进制数, 1 表示这是一个竖着的骨牌的上半部分下面需要接骨牌, 0 表示下面不需要接。

则我们可以尝试枚举下一行的状态 \(state_{next}\),如果 \(state_{next}\)\(state\) 能正好合上,则说明:

\[f[i+1][state_{next}] += f[i][state] \]

那么如何判断两个状态是否相容呢?

如果当前 \(state\) 的第 \(k\) 位是 1,那么此处必须接一个竖着的骨牌的下半部分,也就是 \(state_{next}\) 这一位必须是 0。

如果当前 \(state\) 的第 \(k\) 位是 0,说明下一行的 \(state_{next}\) 状态对应位置是可以随意选的,可 1 可 0。

首先可以预处理所有合法的转移方案。这样转移的时候判断就是 \(O(1)\) 的了,这其实是个很常用的技巧。

  1. 上下两个 1 不能挨着。
  2. 同一行的两个之间的距离为偶数。

时间复杂度 : \(O((2 ^ m) ^ 2 \times n)\)

【愤怒的小鸟】

\(Description\)

平面上有 \(n\) 头猪,每次可以从 \((0,0)\) 出发发射一只沿抛物线 \(y=ax^2+bx\) 飞行的小鸟,可以消灭所有在飞行路线上的猪,问消灭所有猪至少要几只小鸟。

\(n \leq 18\)

\(Solution\)

两头猪加上原点即可确定抛物线,于是不同的抛物线只有 \(O(n^2)\) 种。

\(f[S]\) 为已经消灭的猪的集合为 \(S\) 时的最少次数,暴力的转移方法为依次枚举抛物线去更新所有 \(f\) 数组,这样做时间复杂度为 \(O(n^2 \times 2^n)\)

更快的转移方法为从小到大枚举 \(S\),每次打掉编号最小的还没消灭的猪,
由于包含该猪的抛物线只有 \(O(n)\) 种,所以时间复杂度为 \(O(n \times 2^n)\)

\(Code\)

//你不会真以为我写了代码了吧?

【交换茸角】

\(Description\)

动物园里有 \(n\) 头麋鹿。每头麋鹿有两支茸角,每支茸角有一个重量。然而,一旦某头麋鹿上两支茸角的重量之差过大,这头麋鹿就会失去平衡摔倒。

为了避免这种悲剧发生,动物园院长决定交换某些茸角,使得任意一头麋鹿的两角重量差不超过 \(c\)。然而,交换两支茸角十分麻烦,不仅因为茸角需要多个人来搬运,而且会给麋鹿造成痛苦。

因此,你需要计算出最少交换次数,使得任意一头麋鹿的两角重量差不超过 \(c\)

注意 : 交换两支茸角只能在两头麋鹿之间进行。因为交换同一头麋鹿的两支角是没有意义的。

对于 \(100\%\) 的数据,\(n \leq 16\), \(c \leq 10 ^ 6\), 每支茸角重量不超过 \(10 ^ 6\)

\(Solution\)

首先其实最终肯定是把这些鹿分成一些组,每一组内通过组的大小减一次操作来满足题目要求的条件。注意对于一个组,我们将所有的角排序,第 \(2 \times i - 1\)\(2 \times i\) 个要保证之差小于等于 \(C\),才是合法的一组。

其实就是选尽量多合法的组并起来等于全集,枚举子集的状态压缩 \(dp\) 即可,\(ok\) 代表这个方案合法。

\[f[i] = \max\{f[j] + f[i ^ j]\ \ | \ \ j \in i \ \ \And\And \ \ ok\} \]

总结

像这种某些集合是可行的,或者说每个集合有一个价值,然后我们要选
择一些不相交的集合并起来等于全集,每个选择的集合都要求可行,且
希望总权值尽量大。

这一类 \(dp\) 往往是要用到状态压缩 \(dp\) 同时利用枚举子集来进行转移,转移的过程中常常可以控制最低位的 1 必选来减少 \(dp\) 重复的计算。像这前两道题,以及之前的愤怒的小鸟也一样,只不过小鸟那题通过分析题目的特点使得我们在转移的过程中不需要枚举全部的子集,只需要枚举 \(n\) 个抛物线即可。

\(Travelling\)

\(Description\)

现在有一个具有 \(n\) 个顶点和 \(m\) 条边的无向图(每条边都有一个距离权值),小明可以从任意的顶点出发,他想走过所有的顶点而且要求走的总距离最小,并且他要求过程中走过任何一个点的次数不超过 2 次。

\(1 \leq n \leq 10\)

\(Solution\)

\(dp[S][i]\) 表示到过点的情况集合为 \(S\),\(S\) 是个三进制数,第 \(i\) 为 0/1/2 分别表示到过次数。\(i\) 为当前所在的点,转移的话就考虑下一步走到那条边就好。

答案就是 \(dp[ok \ \ \And \And \ \ S][i]\)的最小值。(\(OK\) 表示合法的)

其实是 TSP 旅行商问题的一种较为麻烦的形式。

\(LIS\) 问题】

\(Description\)

给出一个 \(1\) ~ \(n\) 排列的其中一种最长上升子序列,求原序列可能的种数。

\(n \leq 15\)

\(Solution\)

\(f[i][j]\) 表示所选的数字集合为 \(i\),此时的做最长上升子序列题时那个辅助数组状态为 \(j\)。对于那个数组 \(h\)\(h[i]\) 表示长度为 \(i\) 的上升子序列结尾的最小值。

肯定只有数字集合 \(i\) 中的一些数出现在了 \(h\) 数组中,而 \(j\) 就是出现在 \(h\) 数组中的数的集合。而我们每在这个序列末尾加一个数 \(x\),就会在 \(h\) 中把大于 \(x\) 最小的数替换掉。

\(j\) 状态中,就是把比 \(x\) 高位最近的 1 去掉,然后把 \(x\) 这位附为 1 即可(位运算是可以搞的)。

转移的话枚举下一个数是啥,如果这个数在输入的序列中出现过,则需要保证序列中它前一个数也已经出现过了。

\(f[i][j]\) 可以转移到 \(f[i+(1<<x)][k]\)。其中 \(k\) 表示转移后的最长上升子序列辅助数组的状态,通过之前所说的位运算乱搞或者预处理可以解决。

时间复杂度为 \(O(n \times 3^n)\),转移是 \(O(n)\) 的,枚举子集是 \(3^n\)

时间复杂度 & 总结

  • \(N=20\) 一般是 \(2^n\) 或者 \(n \times 2^n\)

  • \(N \leq 16\) 大概率是 \(3^n\),约是 \(4 \times 10^7\),那很可能做法就和之前讲枚举子集有关了。

  • \(N \leq 15\) 大概率是 \(3^n\) 或者 \(n \times 3^n\)

  • 选择一些不相交的可行集合并起来等于全集,且希望选出集合总权值尽量大,通常要 \(O(3^n)\) 枚举子集的技巧。而通过强制枚举的子集包含最低位的 1,可以避免重复的计算。

  • 位运算在状压 \(dp\) 中,经常能发挥大的作用,灵活的使用位运算可以降低算法的时间复杂度。

待整理 :【炮兵阵地】

posted @ 2021-03-07 09:47  Ti_Despairy  阅读(104)  评论(0)    收藏  举报