Files
python/TangDou/AcWing/StateCompression/529_DP.cpp

125 lines
7.4 KiB
C++
Raw Normal View History

2025-08-30 18:35:01 +08:00
#include <bits/stdc++.h>
using namespace std;
/*
状压DP的二进制表示法,状态压缩DP有两个性质:
1、它说多大就设置多大,不要再扩大设置,速度会慢。
2、一般状态压缩DP数组下标从0开始
*/
const int N = 12; // 节点个数上限
const int M = 1 << N; // 节点的状态表示 2^N种状态
const int INF = 0x3f3f3f3f; // 正无穷
int g[N][N]; // 邻接矩阵,g[i][j]:i号点和j号点之间的距离
// 记录有效状态转移关系
int p[M]; // p[x]:状态x中的存在(指值为1)每一个数位i,通过g[i][j],可以连接到j1,j2,...等节点,最终可以到达的所有状态为p[x]
// 比如i=00001->0号节点值为1,0号节点与1、3号节点之间有边,那么p[i]=p[00001]=01011
int n, m; // 节点数,边数
/* 递推关系: f[S][j]=min(f[Spre][j-1]+cost)
S:当前状态,Spre是指S的所有前序状态,
也就是Spre通过引入某些合法边(当前状态某个位置有1,同时,由此处的1有相关的边。不是只有一条,是数位为1的所有节点都可以引入合法边)
,可以形成S这个状态。 由于引入边的不同,导致代价不一样,需要求代价的最小值
*/
int f[M][N]; // f[i][j]:所有二进制状态表示为i,且树高为j的生成树的集合,属性:最小代价
vector<int> v[N]; // i这个点,到哪些点有边
int main() {
scanf("%d %d", &n, &m); // 节点数量和边数量
// 1、地图初始化
memset(g, 0x3f, sizeof g); // 全部初始为正无穷,描述:所有节点之间均未连通
for (int i = 0; i < n; i++) g[i][i] = 0; // 每个点到自己的距离为0
// 2、读入边
while (m--) {
int a, b, w;
scanf("%d %d %d", &a, &b, &w); // 起点,终点,权值
a--, b--; // 状态压缩,习惯下标从0开始,题目给的节点号是从1开始的,需要统一处理减1
g[a][b] = g[b][a] = min(g[a][b], w); // 无向图 + 防止重边 + 留最小值
v[a].push_back(b), v[b].push_back(a); // 记录下a可以到达哪些点,其实如果不这么记忆的话,那么就需要枚举每个点对着哪些点,因为数据量小,速度应该也挺快
}
/*
3、预处理有效状态之间的转移关系 i->p[i]
转移办法:标识为1的节点上通过枚举已知边,扩展为新状态p[i]
*/
for (int i = 0; i < 1 << n; i++) // 枚举每个状态
for (int j = 0; j < n; j++) // 枚举状态的每个位置
if (i >> j & 1) { // 如果当前位是1
p[i] |= 1 << j; // 原来的i状态中的1,扩展后还是有的
for (int k = 0; k < v[j].size(); k++) p[i] |= 1 << v[j][k]; // 由j带来的扩展点有v[j].size()个
}
/*
Q:问题:
if (i >> j & 1) {
p[i] |= 1 << j;
for (int k = 0; k < v[j].size(); k++) p[i] |= 1 << v[j][k];
}
可以改写成:
if (i >> j & 1) {
p[i] += 1 << j;
for (int k = 0; k < v[j].size(); k++) p[i] += 1 << v[j][k];
} 吗?它们两个是等价的吗?
答:它们并不是完全等价。
这两个表达式都是将二进制数p[i]的第j位设置为1,但是p[i] |= 1 << j;相比p[i] += 1 << j;更加高效。
这是因为使用位运算符OR(|=)可以避免临时变量和函数调用,并且可以利用位级别的并行性加快操作速度。而加法运算则需要进行进位,速度相对较慢。
此外,p[i] += 1 << j; 如果第j位之前已经是1的话,会导致进位,此时p[i]的值会发生改变,而p[i] |= 1 << j; 不会改变已有的位。
回到上面的代码中,如果我们没有写成 | 运算符,而是采用了+运算符,那么根据业务上的逻辑,p[i]的值可能首先被
for (int k = 0; k < v[j].size(); k++) p[i] += 1 << v[j][k]; 进行填充,
那么 j'=v[j][k],就会提前将对应的数值加到p[i]中去,而下次来到执行 p[i] +=1<<j 时,就会造成重复累加,结果错误。
因此,在二进制位运算时,应尽量使用位运算符。
*/
// 4、动态规划
// 4.1 DP数组初始化
memset(f, 0x3f, sizeof f); // 预求最小,先设最大
for (int i = 0; i < n; i++) f[1 << i][0] = 0; // 赞助商决定免费赞助他打通一条从地面到某个宝藏屋的通道",选择打通哪一条都是一样的,都不要钱,所以在选择每一个节点为起点时,费用都是0
// 4.2 状态转移
for (int i = 0; i < 1 << n; i++) { // 枚举每个状态i
for (int j = i - 1; j; j = (j - 1) & i) { // 枚举i状态的真子集j,只有真子集才有资格进行转移
if ((p[j] & i) == i) { // 如果子状态j可以通过已知边,扩展为可以正好匹配i,才是真正可以转移的有效子状态
int x = i ^ j; // x:j->i增加出来的元素,也就是状态差集,都是j里没有,i里有的数字位1
int cost = 0; // cost记录在状态j到达状态i过程中用到的最小花费
for (int k = 0; k < n; k++) // 枚举x每一个节点k
if (x >> k & 1) { // 找出x中数位是1的节点k
int t = INF; // t表示从j到i使得覆盖掉第k个点需要的最小代价
for (int u = 0; u < n; u++) // 枚举j的每一个数位u
if (j >> u & 1) // 如果此数位是1,有资格引边
t = min(t, g[u][k]); // 如果k点是通过u点连接过去的,那么带来的代价就是g[u][k]
cost += t; // 把x的所有元素都纳入的最小代价和
}
/*
当前状态是i,前序状态是j,它们之间的差集是x, k为枚举x的每一个数字1位,为了点亮k点,最小代价是cost,
整体代价:cost*k(层数越深,挖掘成本系数越大)
迁移方式:加边(数位是1,并且,由此可以引出合法边。不只1条,可是多条。)
理解与感悟:
(1) 先有状态转移方程,再有填充顺序
(2) j->i 需要枚举有效状态转移关系
(3) k-1 -> k 就是层次增加了一层
(4) k∈[1,n) 是因为树根在0层,直线的话,最后一层是n-1
(5) 上面的都好办,关键在于这个 cost,这个东西需要枚举两个转移状态之间的对应关系,结合点与点之间的距离才能计算出来
最小的代价和,最小代价和 乘以 层数,才是真正的整体代价。
*/
for (int k = 1; k < n; k++) f[i][k] = min(f[i][k], f[j][k - 1] + cost * k);
}
}
}
// 5、枚举答案, 最小值可能出现在任意一层
int res = INF;
for (int i = 0; i < n; i++)
res = min(res, f[(1 << n) - 1][i]); //(1 << n) - 1表示全选状态,即111111...
// 6、输出结果
cout << res << endl;
return 0;
}