关于一类插入-合并类 dp 的思路

关于一类插入-合并dp的做法

前言

这类问题通常是有很多个小部分,dp 时要考虑其排列,但是我们无法知晓其顺序,而这些部分最后要合并为一个整体。这类问题需要用到这种思路。

例题

P5999 [CEOI2016] kangaroo

题意

给定一个长为 \(n\) 的数轴,一只袋鼠在上面要从 \(s\) 跳到 \(t\),跳跃过程中,每次跳跃方向必须与上一次相反,求方案数。

分析

拿到这个题其实还是蛮蒙的,但是如果我们转化(抽象)一下题意,就会发现这道题可以看作:

求以 \(s\) 开头,以 \(t\) 结尾的排列数,其中对于任一排列,都有排列中每个元素 \(a_i\) 满足 \(a_i>a_{i-1}\)\(a_i>a_{i+1}\),或 \(a_i<a_{i-1}\)\(a_i<a_{i+1}\)

这里的每个排列,其实就是跳跃顺序。

这样,我们就可以考虑从小到大,向已有的排列中加入数。设 \(dp_{i, j}\) 表示将有 \(i\) 个数的排列划分成 \(j\) 段的方案总数。那么,对于第 \(i\) 个元素,有三种去向:

1.连接两段元素

因为是按照从小到大插入,故一定满足插入的数大于两端的数。如果前 \(i-1\) 个数划分成 \(j+1\) 段,则有 \(j\) 个位置可以插入,插入后段数少 \(1\) 。于是有

\[dp_{i, j}+=dp_{i-1, j+1} \times j \]

2.单独成段,插入原排列

这种情况下,如果原序列有 \(j-1\) 段,那么新的这一段就有 \(j\) 个位置可以放置。但是要注意一点,就是当 \(s\)\(t\) 放入排列中后,排列首和尾将无法再放入,因此需要特殊处理;同样,对于 \(s\)\(t\),插入的时候只能在首或尾,最多只能连接一段。

于是有

\[dp_{i, j}+=dp_{i-1, j-1} \times(j-(i>s)-(i>t)) \]

3.插入排列中任意一段充当首或尾

我们发现,这样插入之后,必然会在之后把比它大的数放在旁边,这样就无法满足条件了。

代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 2020, mod = 1e9+7;

void add(int &a, int b){
    a = (1ll*a+1ll*b)%mod;
}
int dp[N][N];
int n, s, t;
int main(){
    scanf("%d%d%d", &n, &s, &t);
    dp[1][1] = 1;//第一个数分成1段有一种方案。
    for(int i = 2; i<=n; ++i){
        for(int j = 1; j<=i; ++j){
            if((i ^ s)&&(i ^ t)){
                add(dp[i][j], 1ll*dp[i-1][j+1]*j%mod);
                add(dp[i][j], 1ll*dp[i-1][j-1]*(j-(i>s)-(i>t))%mod);  
            } else{
                add(dp[i][j], dp[i-1][j-1]);
                add(dp[i][j], dp[i-1][j]);//特殊处理s和t。
            }
            
        }
    }
    printf("%d\n", dp[n][1]);
    system("pause");
    return 0;
}

豌豆射手

题目描述

现在有\(n\)个豌豆射手,要把它们放在长度为\(d\)的一列草坪上。

每一个豌豆射手都有一个攻击半径\(r_i\),如果将这个豌豆射手放在\(pos\)位置,那么\([pos-r_i+1,pos+r_i-1]\)将不能有其他的豌豆射手

戴夫要把这\(n\)个豌豆射手放在长度为\(d\)的草坪上,使得它们不会相互攻击,求方案数。答案对\(10^9+7\)取模

输入格式

第一行两个整数\(n,d\),表示有\(n\)个豌豆射手,草坪的长度为\(d\)

第二行\(n\)个整数\(r_i\),表示每一个豌豆射手的攻击半径

输出格式

一行一个整数,表示合法的方案数

样例 #1

样例输入 #1
4 4
1 1 1 1
样例输出 #1
24

样例 #2

样例输入 #2
3 47
4 8 9
样例输出 #2
28830

样例 #3

样例输入 #3
8 100000
21 37 23 13 32 22 9 39
样例输出 #3
923016564

数据范围

  • 对于\(20\%\)的数据:

\(n<=5\quad d<=14\)

  • 对于\(60\%\)的数据:

\(n<=20\)

  • 对于\(100\%\)的数据

\(\displaystyle n<=40\quad d<=10^5\quad 1<=r_i<=40\quad \sum_{i=1}^{n}r_i\leq d\)

思路

这道题和上一道题有异曲同工之妙。
我们先来考虑,如果我们把豌豆射手的每一个排列都找出来,然后让他们密集地摆放,会发现,这个排列的长度(就是左右两端的豌豆射手之间的距离)应该为 \(\sum_{i = 1}^{n-1} \max(r_i, r_{i+1})\),我们令这个长度为 \(L\),则最后的方案数应该是 \(C_{n+d-L}^{n}\)。为什么呢?我们可以把这些豌豆射手当作板,右侧的空出来的位置看作小球,那么这就是个经典的插板问题。
然后我们来考虑如何搞出来对于每一个 \(L\) 的答案。我们把 \(r\) 从小到大排序设 \(f_{i, j, k}\) 表示当前有 \(i\) 株豌豆射手,有 \(j\) 个块,总长度为 \(k\)。这里的“块”定义为两端都可以插入新的豌豆射手的连续段,和上一题类似。
我们分类讨论。因为从小到大排好序,所以新加入的豌豆射手一定是可以贡献到长度上的。

  • 如果插入的豌豆射手合并到某个块的一端,有 \(f_{i, j, k} \gets f_{i-1, j, k-r_i}*j*2\)。这里的 \(j\) 是因为有 \(j\) 个块, \(2\) 是因为两端都可以放。
  • 如果插入的豌豆射手合并了原有的两个块,那么就有 \(f_{i, j, k} \gets f_{i-1, j+1, k-2*r_i+1}*j*(j+1)\)。这里的 \(j*(j+1)\) 是因为我们不知道要合并哪两块。
  • 如果插入的豌豆射手自成一个新块,有 \(f_{i, j, k} \gets f_{i-1, j-1, k-1}\)
    代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 50, M = 1e5+100, mod = 1e9+7;

inline int read(){
	int x = 0; char ch = getchar();
	while(ch<'0' || ch>'9') ch = getchar();
	while(ch>='0'&&ch<='9') x = x*10+ch-48, ch = getchar();
	return x;
} 
int f[N][N][M];

int inv[M<<1], jie[M<<1];
inline int fpow(int a, int b){
	int ret = 1;
	a%=mod;
	while(b){
		if(b & 1){
			ret = (1ll*ret*a)%mod;
		}
		b>>=1;
		a = (1ll*a*a)%mod;
	}
	return ret;
}
inline int C(int n, int m){
	if(n < m) return 0;
	return 1ll*jie[n]*inv[m]%mod*inv[n-m]%mod;
}
void add(int &a, int b){
	a = (a+b)%mod;
}
int n, d;
int R[N];
int mx;
int main(){
	n = read(), d = read();
	for(int i = 1; i<=n; ++i) R[i] = read(), mx = max(R[i], mx);
	sort(R+1, R+n+1);
	jie[0] = 1;
	for(int i = 1; i<=110000; ++i) jie[i] = 1ll*jie[i-1]*i%mod;
	inv[110000] = fpow(jie[110000], mod-2);
	for(int i = 110000-1; i>=0; --i) inv[i] = (1ll*inv[i+1]*(i+1))%mod;
	f[0][0][0] = 1;
	for(int i = 1; i<=n; ++i){
		for(int j = 1; j<=i; ++j){
			for(int k = 1; k<=mx*i; ++k){
				if(k>=R[i])add(f[i][j][k], 1ll*2*j*f[i-1][j][k-R[i]]%mod);
				if(k>=R[i]*2-1)add(f[i][j][k], 1ll*f[i-1][j+1][k-R[i]*2+1]*j%mod*(j+1)%mod);
				add(f[i][j][k], 1ll*f[i-1][j-1][k-1]);
			}
		}
	}
	int ans = 0;
	for(int i = 0; i<=min(n*mx, d); ++i){
		add(ans, 1ll*f[n][1][i]*C(n+d-i, n)%mod);
	}
	printf("%lld\n", ans);
	return 0;
}

P2612 [ZJOI2012] 波浪

简要题意

给定 \(n\)\(m\)\(k\),定义一个 \(1\)\(n\) 的排列的权值 \(L = \lvert P_2 - P_1 \rvert + \lvert P_3 - P_2 \rvert + … + \lvert P_n - P_{n-1} \rvert\),求随机一个 \(1\)\(n\) 的排列,权值不小于 \(m\) 的概率有多大。答案保留小数点后 \(k\) 位,四舍五入。

\(n \leq 100, k \leq 30, 0 \leq m \leq 2147483647\)

首先我们可以观察到,\(m\) 的范围比较唬人。实际上,权值根本不会超过 \(10000\),其实还会小,但并不需要精确算出范围。

那么,问题完全可以转化成求最后权值为 \(w\) 的排列有多少,答案就是所有权值不小于 \(m\) 的排列的数量除以 \(n!\)。那么,这个问题又可以转化为我们的插入-合并类 dp 模型。绝对值很烦人,我们不妨从小到大插入 \(i\),这样绝对值的问题就没了。剩下的都是类似的。

具体的讲,分为以下五种情况。

  • 在两边插入,单独成块,贡献 \(-i\),方案 \(2-p\)
  • 在两边插入,和边界连通块组成块,贡献 \(i\),方案 \(2-p\),要求 \(j>0\)
  • 在中间插入,独立成块,贡献 \(-2i\),方案 \(j-1+2-p\)
  • 在中间插入,与一个块相连,贡献 \(0\),方案 \(2 \times (j-1)+2-p\)
  • 在中间插入,连接两个块,贡献 \(2i\), 方案 \(j-1\)

这题一个恶心的点就在于要根据数据范围来考虑采用 long double 还是 __float128,另外一点就是 \(n!\) 要分到每次去除以,否则会掉精度。反正就是……挺恶心的。另一个要注意的是枚举的时候要剪枝。

代码:

```cpp
#include<bits/stdc++.h>
using namespace std;
const int N = 105;

int n, m, K;
int now;
namespace solve1{
    long double ans, f[2][N][10010][3];//插入第 i 个数,有 j 个段,价值为 k,用了几个边界
    void output(double ret){
    	if(ret+1e-14 >= 1) {
    		cout << "1." << string(K, '0') << endl;
    		return;
		}
		cout << "0.";
		ret*=10;
		for(int i = 1; i<=K; ++i){
			cout << (int)(ret + (K == i) * 0.5);
			ret = (ret-(int)ret)*10;
		}
	}
}

namespace solve2{
    __float128 ans, f[2][N][10010][3];
    void output(__float128 ret){
    	if(ret+1e-14 >= 1) {
    		cout << "1." << string(K, '0') << endl;
    		return;
		}
		cout << "0.";
		ret*=10;
		for(int i = 1; i<=K; ++i){
			cout << (int)(ret + (K == i) * 0.5);
			ret = (ret-(int)ret)*10;
		}
	}
}
//在两边插入,单独成块,贡献-i,方案2-p 
//在两边插入,和边界连通块组成块,贡献i,方案2-p, j>0; 
//在中间插入,独立成块,贡献-2i,方案j-1+2-p;
//在中间插入,与一个块相连,贡献0,方案2*(j-1)+2-p
//在中间插入,连接两个块,贡献2i, 方案 j-1 
int main(){
    scanf("%d%d%d", &n, &m, &K);
    if(K<=8){
    	using namespace solve1;
//    	if(m>10099){
//    		ans = 0;
//    		cout << fixed << setprecision(K) << ans << endl;
//    		return 0;
//		}
		f[0][0][5000][0] = 1;
		for(int i = 1; i<=n; ++i){
//			double tmp = 1.0/i;
			now^=1;
			memset(f[now], 0, sizeof(f[now]));
			for(int k = 0; k<=10000; ++k){//后面枚举的都是i-1 
				for(int j = 0; j<i; ++j){
					for(int p = 0; p<=2; ++p){
						if(!f[now^1][j][k][p]) continue;
						if(p<2){
							f[now][j+1][k-i][p+1]+=f[now^1][j][k][p]*(2-p)/i;
						}
						if(j>0 && p<2){
							f[now][j][k+i][p+1]+=f[now^1][j][k][p]*(2-p)/i;
						}
						f[now][j+1][k-2*i][p]+=f[now^1][j][k][p]*(j+1-p)/i;
						if(j>0){
							f[now][j][k][p]+=f[now^1][j][k][p]*(2*j-p)/i;
						}
						if(j>=2){
							f[now][j-1][k+2*i][p]+=f[now^1][j][k][p]*(j-1)/i;
						}
					}
				}
			}
		}
		ans = 0;
		for(int i = m; i<=5000; ++i){
			ans+=f[now][1][i+5000][2];
		}
		output(ans);
	} else{
		using namespace solve2;
//		if(m>10099){
//    		ans = 0;
//    		output(ans);
//    		return 0;
//		}
		f[0][0][5000][0] = 1;
		for(int i = 1; i<=n; ++i){
//			__float128 tmp = 1.0/i;
			now^=1;
			memset(f[now], 0, sizeof(f[now]));
			for(int k = 0; k<=10000; ++k){//后面枚举的都是i-1 
				for(int j = 0; j<i; ++j){
					for(int p = 0; p<=2; ++p){
						if(!f[now^1][j][k][p]) continue;
						if(p<2){
							f[now][j+1][k-i][p+1]+=f[now^1][j][k][p]*(2-p)/i;
						}
						if(j>0 && p<2){
							f[now][j][k+i][p+1]+=f[now^1][j][k][p]*(2-p)/i;
						}
						f[now][j+1][k-2*i][p]+=f[now^1][j][k][p]*(j+1-p)/i;
						if(j>0){
							f[now][j][k][p]+=f[now^1][j][k][p]*(2*j-p)/i;
						}
						if(j>=2){
							f[now][j-1][k+2*i][p]+=f[now^1][j][k][p]*(j-1)/i;
						}
					}
				}
			}
		}
		ans = 0;
		for(int i = m; i<=5000; ++i){
			ans+=f[now][1][i+5000][2];
		}
		output(ans);
	}
    
    
    return 0;
}

总结

这类 dp 问题的重点是考虑全面,注意细节,然后通过合理调整 dp 顺序来达到降低难度的目的。比较常见的情况分类有:是否作为边界;连接两个块,接在一个块上,还是单独成块。此博客还会随例题的增多而更新。

posted @ 2023-06-13 21:26  霜木_Atomic  阅读(82)  评论(0编辑  收藏  举报