C++状态压缩DP(例题:吃奶酪,Booster)

经过这两天的刻苦学习,总算是稍微入门了一点状态压缩dp,这个真的不是很好学,所以我稍微一会就来发表这一篇我的心得体会吧。因为淋过雨,所以想为其他雨中挣扎的同学们撑一把伞(*^_^*)

首先会看到这一篇的人,应该也不是第一次认识这个概念,如果你是第一次看到状态压缩dp的话建议先去看看别人的教程,我这篇更倾向于大概懂一点,但又没完全会的状压dp教学。

首先复习一下状压dp,状压dp由于时间复杂度很大,所以是适用于数据量较小,一般n<=20的题目(这个是很关键的提示信息,看到数据量是这么小的就可以往状压dp方面考虑)

状压dp的核心在于用二进制01010101的串,利用0或者1来表示信息,所以在状压dp中会频繁地用到位运算,左移右移还有&|的操作。

下面讲讲我对状压dp的核心理解:状压dp首先名字带了个dp,所以肯定有dp的特点,也就是状态转移,递推。又因为状压用二进制压缩情况,所以转移方程就是一个二进制情况转移到相邻的二进制情况,一般情况下是开一个二维数组f[i][j],其中一个代表总的点数量n,另一个代表此时的二进制串状态,所以这一维的大小要开1<<n,也就是f[n][1<<n](具体这两个顺序看个人习惯,没什么影响)

单单这么讲还是有点抽象,下面我拿状压dp的经典例题作为例子具体讲一讲。

《吃奶酪》

题目描述:

房间里放着 n 块奶酪(1<=n<=15)。一只小老鼠要把它们都吃掉,问至少要跑多少距离?老鼠一开始在 (0,0)(0,0) 点处。

输入格式

第一行有一个整数,表示奶酪的数量 n。

第 2到第 (n+1) 行,每行两个实数,第(i+1) 行的实数分别表示第 i 块奶酪的横纵坐标 xi​,yi​。

输出格式

输出一行一个实数,表示要跑的最少距离,保留 2 位小数。

输入样例: 

 

4
1 1
1 -1
-1 1
-1 -1

输出样例:

7.41

 

有许多点,问跑完这些点需要的最少时间, 这种类型的题算是状压dp的模板题,是典型要用状压解决的例子,n的范围最大到15也符合我上面说的情况。接下来就先来分析这种题应该怎么处理。

首先,要用二进制表示情况,很容易想到,把每个奶酪当成二进制的一位,0代表还没到达这个点,1代表已经到达了这个点。那么状态转移的过程就是从某种到达情况,转移到比这种情况多一个1新到达一个点的情况,加上这两个点之间的距离。这里因为涉及到距离,所以一般会先预处理一下,求出任意两个点之间的距离储存在数组中。

其实这种题的状压dp思路很明确,但据我个人经验所言,难在代码实现,就是思想方法都懂,但是不知道怎么用代码表示,个人建议可以先照着下面的代码看一行打一行,可以慢慢熟悉起来,千万别硬着头皮光看代码不动手!

#include<bits/stdc++.h>
#define ll long long
#define db double
#define pii pair<int,int>
using namespace std;
const int maxn = 1 << 16;
db f[17][1 << 17];//当前到第i个奶酪,并且已经经过的状态为j
db d[17][17];
db x[17] = {0}, y[17] = { 0 };
ll read() {
	ll x = 0, f = 0, ch = getchar();
	while (!isdigit(ch)) { if (ch == '-')f = 1;ch = getchar(); }
	while (isdigit(ch)) { x = x * 10 + ch - '0';ch = getchar(); }
	return f ? -x : x;
}
db dis(int i, int j) {
	return sqrt((x[i] - x[j]) * (x[i] - x[j]) + (y[i] - y[j]) * (y[i] - y[j]));
}
int main() {
	int n = read();
	memset(f, 127, sizeof(f));
	db ans = f[0][0];
	for (int i = 1;i <= n;i++)
		cin >> x[i] >> y[i];
	for (int i = 0;i <= n;i++)
		for (int j = i + 1;j <= n;j++)
			d[i][j] = d[j][i] = dis(i, j);//初始化任意两个奶酪之间的距离
	for (int i = 1;i <= n;i++)
		f[i][1 << (i - 1)] = d[0][i];
	for (int j = 0;j < (1 << n);j++) {//枚举当前已经走过的状态
		for (int i = 1;i <= n;i++) {//枚举当前已经到第i个奶酪
			if ((j & (1 << (i - 1))) == 0)//本应已经到了i个奶酪,但是还没走过i不合题意
				continue;
			for (int k = 1;k <= n;k++) {//枚举上一个走到的奶酪
				if (i == k) continue;//重复了跳过
				if ((j & (1 << (k - 1))) == 0) //目前走过的j没走过k
					continue;
				f[i][j] = min(f[i][j], f[k][j - (1 << (i - 1))] + d[i][k]);
				
			}
		}
	}
	for (int i = 1;i <= n;i++)
		ans = min(ans, f[i][(1 << n) - 1]);
	cout << fixed << setprecision(2) << ans << endl;
	return 0;
}

这种题比较模板化,首先因为题目求最小距离,所以先将f数组初始化成最大值,这里memset给double赋127是从别人那里看的,据说这样可以给f数组赋很大的值。然后我的f[i][j]表示当前到达第i个奶酪,并且已经到达的情况为j,这里的j就是二进制串,从右往左表示第一个奶酪,第二个奶酪。

那么初始化环节就是从原点出发,到达的第一个奶酪枚举一下,距离就是原点到第i个奶酪的距离d[0][i],对应f[i][1<<(i-1)],这里的1<<(i-1)很精髓,因为1左移i位是让1右边多出i个0,所以i实际上在i+1位,所以要左移(i-1)位,这个是否减1后面会慢慢熟悉,而且因为移位的运算优先级比较低,所以大家括号记得多打打。

然后核心的状态转移部分是3个for循环很固定,这里循环的顺序也要注意不能打乱(至于为什么我也不知道,我之前是ijk的for循环顺序但是结果就是会错,可能跟递推的顺序有关系)

for循环顺序是,最外层枚举所有的二进制串,即for(int j=0;j<(1<<n);j++),这里j<(1<<n)也很精髓,因为刚刚说过,左移n位其实会超了一点,但是这里又是<不是<=,所以相当于小等于二进制串1111(总共n个1),也就是所有的奶酪全吃了的情况,从00000枚举到111111,也就枚举了所有的二进制情况。里面的第二层循环枚举当前到达的点,即for(int i=1;i<=n;i++),总共n个点,然后来了第一个判断条件,1<<(i-1)就是表示第i位上的数字,&一下,如果为0就代表当前枚举的二进制情况j上第i个奶酪没有走过,这就和我们枚举的含义当前已经到达第i个奶酪矛盾,表明这种二进制情况不合题意,所以continue掉,枚举下一种二进制情况。

if ((j & (1 << (i - 1))) == 0)//本应已经到了i个奶酪,但是还没走过i不合题意

 到第三层循环,枚举上一个经过的奶酪,这也是核心,因为状态转移就是要从相邻的状态进行转移,所以枚举上一个奶酪后才可以写这两个状态之间的转移表达式。这里上一个奶酪的for循环我是用k表示,那么下面就有了2个判断条件。

if (i == k) continue;//重复了跳过
if ((j & (1 << (k - 1))) == 0) //目前走过的j没走过k
	continue;

第一条i==k代表枚举的当前走到的奶酪和上一个到达的奶酪重复了,明显是矛盾的,所以continue,枚举另一个上个走过的奶酪。第二个判断条件检查当前走过的二进制状态j里的第k位是否为1,因为k是上一个走过的奶酪,所以j里面的第k位一定要是1,如果不是就矛盾了,也跳过,再枚举另一个奶酪。如果满足了这两个条件,就可以开始我们的状态转移。

f[i][j] = min(f[i][j], f[k][j - (1 << (i - 1))] + d[i][k]);

 这个表达式真的是非常漂亮啊!位运算真的令人眼前一亮,这里面最主要的就是f[k][j-(1<<(i-1))]这个式子,之前有了思路之后,最难想的就是应该如何表达当前状态的上一个状态,可以想到上一个状态肯定是比当前状态少一个1,这里位运算后用减法,就可以完美表示上一个状态。j-(1<<(i-1))表示当前的j,减去第i位上的1,恰好就是上一个状态,再加上上一个到达的点k和当前到达的点i之间的距离d[i][k],完美表示了这个状态转移。这个式子值得认真地看上一分钟!

这三层for循环之后,已经基本解决问题了,接下来就是找答案环节没什么难度,最终状态肯定j是全为1,只要枚举一下最后到达的那个点,找f[i][(1<<n)-1]里最小的那个值就是答案了。

不过写状压dp的时候,一定要注意位运算的优先级,当初我写这题的时候,判断条件==0那里因为没有把前面的&括号括起来导致运算优先级错误,卡了一段时间。所以大家一定要注意有位运算时要多加括号

上面吃奶酪大家照着我的代码手打之后,了解过流程后可以试着自己写写下面这一道题,跟吃奶酪的方法思路差不多,但是会更难一点,需要处理的细节增加了一些。

Boosbter

题干描述:

In a two-dimensional plane, there are N towns and M chests. Town i is at the coordinates (Xi​,Yi​), and chest i is at the coordinates (Pi​,Qi​).

Takahashi will go on a trip where he starts at the origin, visits all N towns, and then returns to the origin.
It is not mandatory to visit chests, but each chest contains an accelerator. Each time he picks up an accelerator, his moving speed gets multiplied by 2.

Takahashi's initial moving speed is 1. Find the shortest time needed to complete the trip.

数据范围:

 

  • 1≤N≤12
  • 0≤M≤5
  • −1e9≤≤Xi​,Yi​,Pi​,Qi​≤1e9
  • (0,0)(Xi​,Yi​), and(Pi​,Qi​) are distinct.
  • All values in the input are integers.

输入:

N M
X1​ Y1​
⋮⋮
XN​ YN​
P1​ Q1​
⋮⋮
PM​ QM​

输出:

Print the answer. Your output will be considered correct if the absolute or relative error from the judge's answer is at most 1e−6. 

样例输入:

2 1
1 1
0 1
1 0 

样例输出:

2.5000000000 

样例解释:

Here is one optimal way to complete the trip.

  • Go the distance 1 from the origin to chest 1 at a speed of 1, taking a time of 1.
  • Go the distance 1 from chest 1 to town 1 at a speed of 2, taking a time of 0.5.
  • Go the distance 1 from town 1 to town 2 at a speed of 2, taking a time of 0.5.
  • Go the distance 1 from town 2 to the origin at a speed of 2, taking a time of 0.5.

 题意翻译:

这道题的意思就是,总共有n个城镇,m个宝箱,以1的速度从原点出发,到达城镇或者宝箱,每经过一个宝箱速度乘2,宝箱不一定要全部经过,但是城镇一定要全部经过,问至少经过所有的城镇后回到原点的最短时间是多少。

那么这道题和吃奶酪很像,也是有若干点经过不经过的问题,不过这里引入的速度的概念,并且宝箱是不一定要全部吃完的,所以我们在二进制串表示情况的时候,可以令前m位表示宝箱,后n位表示城镇,然后对于每个情况j,通过位运算&1再右移来判断当前的速度,并存到数组v[j]表示当前情况为j的时候速度为v[j],即:

int speed(int j) {//表示在状态j的时候的速度
	if (v[j]) return v[j];
	int tem = 1,tem2=j;
	for (int i = 1;i <= m;i++, j >>= 1)//这里把j改动了,导致后面v数组得用原来的j赋值
		if (j & 1)tem *= 2;
	return v[tem2] = tem;
}

其他方面和吃奶酪几乎完全一致,同样用数组f[i][j]表达当前到达第i个点时,经过的二进制情况为j时的最短时间,三个for循环顺序和判断条件和吃奶酪一样,不过递推表达式稍微改一改,吃奶酪是距离直接加上距离就好,这里有时间和速度,所以加上的是相邻两点的距离除以上一个状态的速度,代码如下:

for (int j = 0;j < (1 << (m + n));j++) {//枚举所有的当前状态
		for (int i = 1;i <= (m + n);i++) {//枚举当前到了的点
			if ((j & (1 << (i - 1))) == 0)
				continue;//枚举的当前状态还没有到枚举当前点的话跳过
			for (int k = 1;k <= (m + n);k++) {//枚举上一个到达的顶点
				if (i == k)continue;
				if ((j & (1 << (k - 1))) == 0) continue;
				f[i][j] = min(f[i][j], f[k][j - (1 << (i - 1))] + d[i][k] / speed(j - (1 << (i - 1))));
			}
		}
	}

然后到了最后的找答案环节,这里因为不是所有的点都要经过,所以答案不是直接在状态为(1<<(m+n))-1的f数组里面找,只需要后n位全为1就是符合条件的状态,所以我先构造了一个数,前m位为0,后n位为1,如果这个数&状态j后不改变,说明状态j的后n位肯定也都是1。又因为题目要求最后回到原点,所以在这样符合题意的状态需要的时间再加上最后到达的点再去原点的时间,注意此时的速度也要用当时状态下的速度,所以找答案环节的代码改动成下面所示:

int end = 0;//end表示符合题意的最终状态
	for (int i = 0;i < n;i++) {
		end <<= 1;end |= 1;
	}
	end <<= m;
	for (int i = 1;i <= (m + n);i++) {
		for (int j = 0;j < (1 << (m + n));j++) {
			if ((j & end) == end)//符合题意
				ans = min(ans, f[i][j] + d[i][0] / speed(j));//加上到原点的距离
		}
	}

结合上面所说,最终的总代码如下:

#include<bits/stdc++.h>
#define ll long long
#define db double
using namespace std;
const int maxn = 20;
db d[20][20],f[20][1<<maxn];//当前到第i个点,并且已经经过的状态为j时的最短时间
int x[20], y[20];//记录第i个点的横纵坐标
int v[1 << maxn],n,m;
db dis(int i, int j) {
	return sqrt(pow(x[i] - x[j], 2)+ pow(y[i] - y[j], 2));
}
int speed(int j) {//表示在状态j的时候的速度
	if (v[j]) return v[j];
	int tem = 1,tem2=j;
	for (int i = 1;i <= m;i++, j >>= 1)//这里把j改动了,导致后面v数组得用原来的j赋值
		if (j & 1)tem *= 2;
	return v[tem2] = tem;
}
ll read() {
	ll x = 0, f = 0, ch = getchar();
	while (!isdigit(ch)) { if (ch == '-')f = 1;ch = getchar(); }
	while (isdigit(ch)) { x = x * 10 + ch - '0';ch = getchar(); }
	return f ? -x : x;
}
int main() {
	n = read(), m = read();
	for (int i = m+1;i <= n+m;i++)
		x[i] = read(), y[i] = read();
	for (int i =  1;i <=  m;i++)
		x[i] = read(), y[i] = read();
	memset(f, 127, sizeof(f));
	db ans = f[0][0];
	for (int i = 0;i <= m + n;i++)
		for (int j = i + 1;j <= m + n;j++)
			d[i][j] = d[j][i] = dis(i, j);//初始化每两个点的距离
	for (int i = 1;i <= (m + n);i++)
		f[i][1 << (i - 1)] = dis(0, i);//初始化从原点到所有点的时间为距离
	for (int j = 0;j < (1 << (m + n));j++) {//枚举所有的当前状态
		for (int i = 1;i <= (m + n);i++) {//枚举当前到了的点
			if ((j & (1 << (i - 1))) == 0)
				continue;//枚举的当前状态还没有到枚举当前点的话跳过
			for (int k = 1;k <= (m + n);k++) {//枚举上一个到达的顶点
				if (i == k)continue;
				if ((j & (1 << (k - 1))) == 0) continue;
				f[i][j] = min(f[i][j], f[k][j - (1 << (i - 1))] + d[i][k] / speed(j - (1 << (i - 1))));
			}
		}
	}
	int end = 0;//end表示符合题意的最终状态
	for (int i = 0;i < n;i++) {
		end <<= 1;end |= 1;
	}
	end <<= m;
	for (int i = 1;i <= (m + n);i++) {
		for (int j = 0;j < (1 << (m + n));j++) {
			if ((j & end) == end)//符合题意
				ans = min(ans, f[i][j] + d[i][0] / speed(j));//加上到原点的距离
		}
	}
	cout << fixed << setprecision(10) << ans << endl;
	return 0;
}

吃奶酪是照着别人的代码写,这道题就是我自己写的了,纯自己写出来的第一道状压dp真的很有成就感,大家快来试试吧!

希望这篇心得能够帮助你理解状压dp,谢谢观看。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值