插头DP && 概率DP / 期望DP

写在前面:

  快开学了,先发下最近整理的DP,还有过年前学的状压DP、记忆化搜索等基础DP还有图论等知识还在整理中…
  由于本人平时比较懒,所以把十几道题都弄在一篇博客,这篇博客比较长,近3万字,但我相信大家还是能看懂我在说什么的。

插头DP

P5056 【模板】插头dp


  有些状压DP问题要求我们记录状态的连通性信息,这类问题一般被形象的称为插头DP或连通性状态压缩DP。插头dp是基于连通性状态压缩的动态规划,一般的适用范围:数据范围小,并且处于网格图中,对连通性有要求(要求选择出来的格子是一个连通块或者是一个路径/回路)。这样说起来很抽象,我先介绍一道插头dp的模板题:
  给我们一个n * m的棋盘,这个棋盘里面有些格子是障碍,有些格子是空格,求这个棋盘有多少个不同的哈密顿回路(要经过每个格子,并且每个格子只能经过一遍),我们来简单回顾一下哈密顿回路的做法:先用一个状态S表示我们当前已经走过哪些点,另外用i表示我们当前走到了哪个点,然后枚举下一个点是哪一个点,假设下一个点是j,f(s, i)就变成了f(s | 1 << j, j),此时的时间复杂度是2n * n2,现在是一个棋盘,如果按照这种做法来做时间复杂度会太大。棋盘有个特点:每个格子只能和上下左右四个格子相连,并不是能和任意格子相连,所以可以按照一行一行来枚举所有状态(对于一般图我们就不能这样),我们递推的时候一般是按格来递推,举个例子,我们要考虑这个格子,这也意味着我们已经考虑完了这个红色线上方的格子,所以此时我们只要分析这个格子的状态就可以了。

  因为我们要组成一个回路,所以每个格子只能有两条边会被经过,假设有一个边是进来的,有一个边是出去的,也就是在它的四条边里边选两条边经过,这样经过每个格子有6种情况,对于整一个连通块,我们只关心它轮廓线上的状态(即每条边有没有线经过),还要维护轮廓线上方部分的连通性(经过轮廓线的边哪些是属于同个连通块的)

  (上面0和7,3和6就是属于同一个连通块)记录连通块一般有两种方法:1、最小表示法,从左往右遍历轮廓线,当遍历到没被标记的边(且有路径要经过它)的时候,按顺序标记,所以0和7标记成1,3和6标记成2(属于同个连通块标记相同),所以此时轮廓线的状态:10020021。2、括号表示法,括号表示法的适用范围要小一些,但效率更高(首选),因为我们要找的是一个哈密顿回路,这就意味着上半部分有一条边上去,就一定有一条边下来,这些边必然是两两配对的,而且这两条边之间的连通块是不可能交叉的(两条边所连的路径不可能交叉),因为是相邻的两两配对,所以可以看做是若干个括号,我们用1表示“左括号”,用2表示有括号,用一个三进制的数就能表示轮廓线的状态,所以此时轮廓线的状态:10010022。
  这道题用括号表示法比用最小表示法的优势是,我们用012就可以表示轮廓线的状态,而最小表示法要用到的数可能很大(有多少个连通块,最大就是多少)。另外,括号表示法每一位就012,可以把它看成是4进制,然后在更新状态的时候就可以用位运算去优化。
  状态表示是三维,f[i, j, s]表示当遍历到第i行第j列的格子的时候分界线的状态是s时,所有方案的数量,s有效的状态是4、5万个(因为它要满足1和2是两两配对的)。插头dp比较麻烦的就是状态转移,我们把目标格子(g[i, j])在轮廓线上的边标记成x和y,轮廓线上的状态为state,需要分成以下几种情况(转移的过程中x对应格子下面的边,y对应格子右边的边):
  1、g[i, j]是障碍物,x和y一定是0,且这个格子也不会有路径经过,所以state不变(x和y都是0,转移之后也是0)

接下来g[i, j]不是障碍物的情况:
  2、x和y均为0,更新后state中x = 1, y = 2

  3、x为0,y非0,更新后state中状态不变或者x = y, y = 0


  4、x非0,y为0,更新后state中y = x, x = 0或者状态不变


  5、x = y = 1,x和y相连,此时state中原来x和y的位置变成0,然后离y最近的2变成1,又因为格子已经有两条边了,所以更新之后x和y对应的位置为0。

  6、x = y = 2,x和y相连,此时state中原来x和y的位置变成0,然后离x最近的1变成2,又因为格子已经有两条边了,所以更新之后x和y对应的位置为0。

  7、x = 2, y = 1,x与其前面第一个1配对,y与后面第一个2配对,更新后state中x = 0, y = 0。

  8、x = 1, y = 2,x是某个路径的左半边,y是某个路径的右半边,又因为x和y在轮廓线中是相邻的,所以x和y是属于同一个路径的,这种情况只会发生在最后一个合法的格子。
  接下来分析数据,在f[i, j, s]中s是轮廓线的状态,因为我们是用四进制来表示三进制,而4的13次方是等于67108864,而有效的状态只有4w多个,所以插头dp在存状态的时候一般是用哈希表来存的,而且哈希表最好不要用stl中的哈希表,因为插头dp的常数比较大,题目卡得比较严,所以现在我们要手写哈希表…

手写哈希表的方法:

  1、开放寻址法
  2、拉链法
  哈希表的作用:从较庞大的空间/值域,把它映射到一个较小的空间(0 ~ N)。
  结合这道题来介绍手写哈希表的两种做法:

  当要存储的数是x时,我们可以构造一个哈希函数h(x),这个函数它可以把-109 ~ 109的一个数映射到0 ~ 105之间的一个数,通常会采取x % 105的做法,值域比较大,但是映射的结果比较小,所以必然会产生冲突,也就是我们可能会把两个相同的数映射成同一个数,对于冲突的解决方式可以采用两种方法(开放寻址法/拉链法)。
  拉链法
  先开一个长度为105的数组,当两个数发生冲突的时候,就在某个位置i拉下一条链,把之前的数放在链的第一个位置,然后把新得到的数放在i位置,类似于对于某一个数组元素接一个邻接表。哈希算法是一种期望算法,虽然说每一个位置都有可能拉下来一条链,但是在平均情况下,每一条链的长度可以看成是常数,所以哈希表的时间复杂度可以看成O(1)。
  我们在建立一个哈希表的长度的时候一般取成一个质数(就是取模的数),而且这个质数要离2的n次方尽可能远。定义h[N]哈希数组,对于每一个“槽”,我们要向下拉一条链(类似于之前的链表),链表要存两个东西,一个是它的值e[idx],另一个是下一个值的下标ne[idx],具体做法和对链表的插入查找相同。

拉链法的代码如下:
const int N = 100003;
	int h[N], e[N], ne[N], idx;
	int insert(int x){
   
		int k = (x % N + N) % N;
		e[idx] = x;
		ne[idx] = h[k];
		h[k] = idx++; 
	}
	int find(int x){
   
		int k = (x % N + N) % N;
		for(int i = h[k]; i != -1; i = ne[i]){
   
			if(e[i] == x) return 1;
		}
		return 0;
	}
	int main(){
   
	    int n, m;
	    cin >> n;
	    char p[2];
	    memset(h, -1, sizeof(h));
	    while(n--){
   
	    	scanf("%s%d", p, &m);
	    	if(p[0] == 'I') insert(m);
	    	else{
   
	    		if(find(m)) cout << "Yes\n";
				else cout << "No\n";
			} 
		}
	    return 0;
	}

  开放寻址法
  它只开了一个一维数组,一维数组的长度一般要开到题目数据范围的两到三倍(这道题开到20w ~ 30w),这样的话冲突的概率就小一点。开放寻址法就像我们去厕所找坑位,我想要的坑位有人了,就按顺序看看下一个空位有没有人,以此类推,直到找到坑位为止。

开放寻址法的代码如下:
const int N = 200003, null = 0x3f3f3f3f;
int find(int x){
   
	int k = (x % N + N) % N;
	while(h[k] != null && h[k] != x){
   
		k++;
		if(k == N) k = 0;
	}
	return k;
}
int main(){
   
    int n, m;
    cin >> n;
    char p[2];
    memset(h, 0x3f, sizeof(h));
    while(n--){
   
    	scanf("%s%d", p, &m);
    	if(p[0] == 'I') h[find(m)] = m;
    	else{
   
    		if(h[find(m)] == null) cout << "No\n";
			else cout << "Yes\n";
		} 
	}
    return 0;
}

  言归正传,我在插头dp这道题用开放寻址法,另外,这个题目合法状态大约4w多个,估算一下5w * 144(格子数),大概是700多w个状态,有些题目的数量可能更多,所以要用到滚动数组。由于对于每个格子,总共的状态有6k多w个,合法状态只有不到5w个,我们去枚举的时候就没必要去枚举所有状态,而要用一个数组先存储每一次滚动的有效状态。估算下时间复杂度:f[i, j, s]假设所有有效状态数量是S(最多不到5w),再乘以i、j(n2),然后再算状态的时候,对于5和6的情况,我们是需要枚举找出与某条边配对的边,最坏情况下是需要O(N)的计算量,所以整个算法的时间复杂度就是S * n3,大概是5w * 12 * 12 * 12 = 8.64 * 107,但本质上来说没有这么高(有效状态不到5w,也不是所有状态都要枚举8种情况),这就是插头dp的具体思路。

接下来是这道题的代码实现:

  (具体有点复杂,为了大家方便理解,我每一行我都加了注释,虽然我自己都觉得有点恶心…)

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cmath>
#include <string>
#include <cstring>
#include <map>
#include <queue>
using namespace std;
typedef long long LL;
 
void debug_out(){
   
    cerr << endl;
}
template<typename Head, typename... Tail>
void debug_out(Head H, Tail... T){
   
    cerr << " " << to_string(H);
    debug_out(T...);
}
#ifdef local
#define debug(...) cerr<<"["<<#__VA_ARGS__<<"]:",debug_out(__VA_ARGS__)
#else
#define debug(...) 55
#endif
using namespace std;
const int N = 50005, M = N * 2 + 7;
//状态数量,3^13 ~ 6000万个,有效的大概为4万~5万个
//开放寻址法的数组通常是2~3倍,同时取成质数,用于取模
int g[15][15], q[2][N], cnt[2], h[2][M], ex, ey; 
//g是判断是不是障碍物
//q是滚动数组,存储的是某个有效状态对应在哈希表的下标
//h是滚动数组,滚动hash表,第二维是哈希值的位置,存储的是哈希表某个下标对应的具体有效状态(哈希值) 
//cnt数组是每一次滚动过程中(当前格子)有效状态的数量 
LL v[2][M], ans;
//v是有效状态对应的方案数,ans累加所有方案数 
int set(int k, int v){
   	//构造四进制的第k位数字为v的数字
	return v * (1 << k * 2);
}
int get(int state, int k){
    //求第k个格子的状态,四进制的第k位数字
	return state >> (k * 2) & 3; 
}
int find(int cur, int x){
   	//开放寻址法找到给有效状态存储的哈希表下标 
	int t = x % M;	//取模找到预定存储的位置 
	while(h[cur][t] != -1 && h[cur][t] != x){
   	//直到找到空位置或者发现已经存过了 
		if(++t == M) t = 0;	//在按顺序寻找空位置的过程中,要是到了数组的右边界,就回到数组的头部再找 
	}
	return t;	//返回有效状态存储的哈希表下标 
}
int insert(int cur, int state, LL w){
   	//在滚动哈希表中新加入有效状态 
	int t = find(cur, state);	//给有效状态找存储下标 
	if(h[cur][t] == -1){
   	//这个有效状态之前没存过 
		q[cur][++cnt[cur]] = t;	//储存它的下标 cnt更新当前格子的有效状态数 
		h[cur][t] = state;	//储存具体的有效状态 
		v[cur][t] = w;	//储存符合当前有效状态的所有方案数 
	}
	else v[cur][t] += w;	//这个有效状态之前存过,把它的合法方案数累加 
}

int main(){
   
//    ios::sync_with_stdio(false);
//    cin.tie(0), cout.tie(0);
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++){
   	//读入n * m的矩阵 
	char a[15];
    	scanf("%s", a + 1);
    	for(int j = 1; j <= m; j++){
   
    		if(a[j] == '.'){
   
    			g[i][j] = 1;	//不是障碍标为1,是障碍标为0 
    			ex = i, ey = j;	//记录最后一个有效格子,我们要在最后一个有效格子记录所有方案 
			}
		}
	}
	memset(h, -1, sizeof(h));	//初始化滚动哈希表("两个"哈希表的元素值均为-1) 
	int cur = 0;
	insert(cur, 0, 1);	//最初的分界线,对应的方案数为1
	for(int i = 1; i <= n; i++){
   	 //枚举所有行
		for(int j = 1; j <= cnt[cur]; j++){
   	 //更新下一行新的分界线状态,从轮廓线是 一条直线和上方最右边格子的最右边 转换到 一条直线和下方最左边格子的最左边  
			h[cur][q[cur][j]] <<= 2;	//新的一行所有有效状态都整体右移一位(用<<的原因是所有有效状态是从右往左记录的) 
		}
		for(int j = 1; j <= m; j++){
   	//枚举所有格子
			int l = cur;	//保存之前的状态(h[l][q[l][k]]是之前格子转移后的有效状态,v[l][q[l][k]]是之前格子转移后的方案数) 
			cur ^= 1;	//cur ^= 1,更新为当前格子 
			cnt[cur] = 0;	//当前格子转移后的有效状态数量初始化为0 
			memset(h[cur], -1, sizeof(h[cur]));	//清空滚动哈希表中记录当前的有效状态 
			for(int k = 1; k <= cnt[l]; k++){
   	//遍历从上一个格子转移之后得到的所有有效状态 
				int state = h[l][q[l][k]];	//state是具体的有效状态,便于后续对状态中某一特定位上的数进行修改 
				LL w = v[l][q[l][k]];	//w是符合这个有效状态的所有方案数 
				int x = get(state, j - 1), y = get(state, j);	//x和y的具体位置跟之前图示的x和y相同 
				//下面是上面图示的所有情况 
				if(g[i][j] == 0){
   	//第一种情况 
					if(!x && !y){
   
						insert(cur, state, w);
					}
				}
				else if(!x && !y){
   	//第二种情况 
					if(g[i + 1][j] && g[i][j + 1]) insert(cur, state + set(j - 1, 1) + set(j, 2), w);
				}
				else if(!x && y){
   	//第三种情况 
					if(g[i + 1][j]) insert(cur, state + set(j - 1, y) - set(j, y), w);
					if(g[i][j + 1]) insert(cur, state, w);
				}
				else if(x && !y){
   	//第四种情况 
					if(g[i][j + 1]) insert(cur, state + set(j, x) - set(j - 1, x), w);
					if(g[i + 1][j]) insert(cur, state, w);
				}
				else if(x == 1 && y == 1){
   	//第五种情况 
					for(int s = 1, u = j + 1;;u++){
   
						int p = get(state, u);
						if
  • 11
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值