状压其实是一种很暴力的算法,因为他需要遍历每个状态,所以将会出现2n ,从0到2n -1 的情况数量,不过这并不代表这种方法不适用:一些题目可以依照题意,排除不合法的方案,使一行的总方案数大大减少从而减少枚举。
状态压缩类动态规划,状压dp一般会有明显的数据范围特征,即数据的行数和列数n,m一般都在20以内。
考虑到每行每列之间都有互相的约束关系。因此,我们可以用行和列描述2n种状态。用一个新的方法表示行和列的状态:数字。考虑任何一个十进制数都可以转化成一个二进制数,而一行的状态就可以表示成这样——例如:1010(2)
1表示有,0表示没有。
然后DP处理,枚举当前有效状态和上一行有效状态的关系。
91. 最短Hamilton路径
题目描述
给定一张 n 个点的带权无向图,点从 0∼n−1 标号,求起点 0 到终点 n−1 的最短 Hamilton 路径。
Hamilton 路径的定义是从 0 到 n−1 不重不漏地经过每个点恰好一次。
输入格式
第一行输入整数 n。
接下来 n 行每行 n 个整数,其中第 i 行第 j 个整数表示点 i 到 j 的距离(记为 a[i,j])。
对于任意的 x,y,z,数据保证 a[x,x]=0,a[x,y]=a[y,x] 并且 a[x,y]+a[y,z]≥a[x,z]。
输出格式
输出一个整数,表示最短 Hamilton 路径的长度。
数据范围
1≤n≤20
0≤a[i,j]≤107
输入样例:
5
0 2 4 5 1
2 0 6 5 3
4 6 0 8 3
5 5 8 0 5
1 3 3 5 0
输出样例:
18
分析
一、状态表示:dp[i][j]
表示从顶点0到顶点j,且经过的顶点的集合为i的所有路径。
dp[i][j]
的值表示这些所有路径的和的最小值。
i 就是一个状态表示,用二进制数表示,假设i=10011,根据下标,低位到高位分别是0到4,即经过了顶点0、1、4。
二、状态计算: 根据dp[i][j]
的含义,如果集合i的顶点里不包含终点j和起点0,那么这个值就没有含义,规定为无穷大。
例如,假如集合 i 等于11 (2),则表示该状态经过顶点0和顶点1,即直接求出0->1的距离即可。
如果集合i表示的状态是100 (2),那就表示该路径经过的顶点不过起点0,可直接判断为不存在。
因此求dp[i][j]
的值,需要在状态i表示的集合经过起点0的情况下,即i&1!=0
,去掉集合i中的顶点j,得到集合t,即t=i-{j}
,然后在集合t中寻找新的终点k,这时有dp[t][k]
,还需要顶点k到顶点j的代价g[k][j]
,这时只需要取min(dp[i][j],dp[t][k]+g[k][j]);
就好了。
二进制i表示的十进制数肯定是要大于二进制t表示的十进制数的,求后面的状态dp[i][j]
时要用到前面的状态dp[t][k]
,所以按照状态更新的拓扑序,应先枚举状态,再枚举到达的点。
即,对于二维表dp,应该按行更新,先求出每种路径状态i下可到达各个终点j的距离。
举个例子,按照我们的逻辑,在计算
f[101][1]
(101是二进制下的数,点的编号从000开始)的时候,我们中间会用到f[001][2] + g[2][1]
来更新该状态。 不会存在 f[111][1] 这样数据,求到达顶点1时,顶点1已经从集合i中去掉了
如果先枚举到达的点的话,我们会先计算f[101][1]
,再计算f[001][2]
。那么我们在用f[001][2]
更新f[101][1]
的时候,由于f[001][2]
还没计算过,所以还是正无穷,那么更新的f[101][1]
的值就是错误的。
最终结果:就是经过所有顶点,即状态111……11,且到达的顶点是n-1,即dp[(1<<n)-1][n-1]
。
三、初始化问题
dp[0][j]
,状态i为0,表示集合里不包括任何顶点,即 0->j 一个顶点也不经过,显然不可能,初始化为正无穷。dp[0][j]=INF;
dp[i][0]
,说明经过集合i的顶点后到达顶点0,
- 若i为1,只有顶点0被选中,说明集合i中只有顶点0,那么可以初始化为1;
dp[1][0]=1;
- 若i大于0,说明在经过一系列顶点后还要到达顶点0,显然不可能,初始化为正无穷。
dp[i][0]=INF
状态i等于二进制数1说明只经过顶点0,终点也为顶点为0,0->0的距离为0,且只能更新dp[1][0]
这一个距离,
几个特殊情况:
假如路径的状态i是2^k时,即100……0,必不包括顶点0,所以到任何顶点的距离都不会更新。
假如路径的状态i是(2^k)+1时,即100……01,只包含顶点0和顶点k,那么只会更新0->k的直接距离,0为终点,k为起点,不经过其它顶点。
代码实现
#include <iostream>
#include <cstring>
#define read(x) scanf("%d",&x)
using namespace std;
const int N=20,M=1<<N;
int g[N][N],dp[M][N];
int main()
{
int n;
read(n);
for (int i=0;i<n;i++)
for (int j=0;j<n;j++) read(g[i][j]);
"初始化状态0和状态1,状态0均为INF"
memset(dp,0x3f,sizeof dp);
dp[1][0]=0; "初始化状态为1的情况,由于只包含一个顶点,也只能初始化一个"
"开始递推"
for (int i=2;i<(1<<n);i++) {"n个顶点,枚举2^n种状态,状态0和1都已经初始化过了,从状态2开始 "
if (i&1==0) continue; "不包含起点0的话直接下一个状态"
for (int j=1;j<n;j++) "当前状态i下,寻找每个点都做一次终点,顶点0就不用做终点了,已经初始化过了"
if (i>>j&1) { "如果顶点j可以做顶点的话"
int t=i-(1<<j); "减去该顶点j ,i^(1<<j),异或也行"
for (int k=0;k<n;k++) "从顶点0开始,再找一个新的顶点,且必须从0开始"
if (t>>k&1) dp[i][j]=min(dp[i][j],dp[t][k]+g[k][j]);
}
}
printf("%d",dp[(1<<n)-1][n-1]);
return 0;
}
注意:
- 开始递推的第1个for循环,让状态从2开始是因为状态1已经初始化过了,也可以从1开始循环,由于集合中只有1个顶点,去掉该顶点作为终点后,集合t为0,不包含任何顶点,所以后面的都是空操作,相当于不执行。
- 开始递推的第2个for循环,寻找可以去掉的顶点,顶点0就不用去掉了,从顶点1开始查找到顶点n-1,因为从前面的分析中可以得知,在前面已经初始化过了。
- 开始递推的第3个for循环,寻找当前状态i的次顶点k,0->……->k,然后k->j,最后凑成0->j,顶点k必须要从顶点0开始找,因为可能存在前面说到过的100001(6位)这种状态,只用更新顶点0到顶点5的状态,其它顶点不存在也不能更新。