Bootstrap

【普及动规】dp例题精讲+强化练习

本篇给大家带来一些好的 dp 题,大家可以学习一下。找找感觉。dp 这种东西主要还是靠分类总结+感觉。多练习永远不错。

T1.害羞的xxx

题面 :(由于某些原因无法公开原题,请见谅)

题目背景

保护好xxx,因为他随时会害羞。

题目描述

众所周知,xxx非常害羞。可是学校最近在选拔芭蕾舞演员,xxx不幸被选中了。

xxx穿着芭蕾舞裙和鞋子,登上了舞台。看到这么多不认识的人,xxx的脸立马就红了。但xxx有一个超能力,那就是降低自己的害羞度。他最多可以降低 k k k 的害羞度,每次选一个人,每个人都有一个攻击值 a t k atk atk,xxx可以降低这个人攻击值的害羞度(不能只降低一部分),他希望自己的害羞度尽可能小。那么请问xxx能得到的最小害羞度是多少呢?

输入格式

第一行三个正整数 n 、 m 、 k n、m、k nmk,表示人数、初始害羞度和降低害羞度的上限。第二行共 n n n 个数,分别是每个人的 a t k atk atk

输出格式

一行一个正整数 a n s ans ans,表示他能得到的最小害羞度。

样例 #1

样例输入 #1

3 10 5
2 3 1

样例输出 #1

5

提示

样例解释:可以选取攻击值为2、3的观众,使害羞度下降到5。

数据范围:

对于其中 50 % 50\% 50% 的数据, n , k , a t k ≤ 20 , m ≤ 1000 n,k,atk \le 20,m \le 1000 n,k,atk20m1000

对于 100 % 100\% 100% 的数据, 1 ≤ n ≤ 100 , 1 ≤ k ≤ 2 × 1 0 5 , 0 ≤ ∑ a t k ≤ m ≤ 1 0 9 1 \le n \le 100, 1 \le k \le 2 \times 10^5,0 \le \sum atk \le m \le 10^{9} 1n100,1k2×1050atkm109

the data made by @hank0920 & @ylch


题解 + Code

从atk里面选尽可能多的数,让这些数的和尽可能接近k
再用m减去这个和就可以(注意这里的k可以>m,要避免m被减出负数)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxk = 2e5 + 7;

int a[105], dp[105][maxk];

void solve()
{
	int n, m, k;
	cin >> n >> m >> k;
	for(int i = 1; i <= n; i ++){
		cin >> a[i];
	}
	for(int i = 1; i <= n; i ++){
		for(int j = 0; j <= k; j ++){
			dp[i][j] = dp[i - 1][j];
			if(j >= a[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - a[i]] + a[i]);
		}
	}
	int maxn = 0;
	for(int i = 0; i <= k; i ++) maxn = max(maxn, dp[n][i]);
	cout << max(0, m - maxn) << '\n';
}

signed main()
{
	ios :: sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);
	solve();
	return 0;
}

T2.洛谷P1091 [NOIP2004 提高组] 合唱队形

Link:Luogu - P1091

题目描述

n n n 位同学站成一排,音乐老师要请其中的 n − k n-k nk 位同学出列,使得剩下的 k k k 位同学排成合唱队形。

合唱队形是指这样的一种队形:设 k k k 位同学从左到右依次编号为 1 , 2 , 1,2, 1,2, , k ,k ,k,他们的身高分别为 t 1 , t 2 , t_1,t_2, t1,t2, , t k ,t_k ,tk,则他们的身高满足 t 1 < ⋯ < t i > t i + 1 > t_1< \cdots <t_i>t_{i+1}> t1<<ti>ti+1> > t k ( 1 ≤ i ≤ k ) >t_k(1\le i\le k) >tk(1ik)

你的任务是,已知所有 n n n 位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。

对于 50 % 50\% 50% 的数据,保证有 n ≤ 20 n \le 20 n20

对于全部的数据,保证有 n ≤ 100 n \le 100 n100


题解 + Code

思路:
我们可以去枚举中间点 t[i] 所在的位置 i,然后求出 [1,i-1] 区间内的最长上升子序列长度,[i+1,n] 区间的最长下降子序列长度,两者相加就是以位置 i 作为中心点的答案

当然,也可能不用选定这个中心点 i,即 a 序列本来就有序。可以先求出[1,n] 的最长上升、下降子序列长度,和枚举中心点得到的答案取 max 即可

总结:

  1. 求最长上升子序列
  2. 求最长下降子序列
  3. 枚举中间点,计算左右两段的最长上升子序列和最长下降子序列
    统计三种方案的最大值即可。

提示:可以用二分+贪心优化,把查找最长上升、最长下降子序列长度的部分优化到O(nlogn)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;
int a[110], dp[110]; // dp[i]表示当前i位置的最大长度

// 求a[l~r]的最长上升子序列
int fun1(int l, int r)
{
    for(int i = 0; i < 110; i ++) dp[i] = 1;
    for(int i = l; i <= r; i ++){
        for(int j = l; j <= i - 1; j ++){
            if(a[j] < a[i]){
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
    }
    
    int maxn = 0;
    for(int i = l; i <= r; i ++) maxn = max(maxn, dp[i]);
    return maxn;
}

// 求a[l~r]的最长下降子序列
int fun2(int l, int r)
{
    for(int i = 0; i < 110; i ++) dp[i] = 1;
    for(int i = l; i <= r; i ++){
        for(int j = l; j <= i - 1; j ++){
            if(a[j] > a[i]){
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
    }
    
    int maxn = 0;
    for(int i = l; i <= r; i ++) maxn = max(maxn, dp[i]);
    return maxn;    
}

void solve()
{
    int n;
    cin >> n;
    for(int i = 1; i <= n; i ++) cin >> a[i];
    int k1 = fun1(1, n), k2 = fun2(1, n); // 先求出这两个
    // 枚举分割点
    int k3 = 0;
    for(int i = 2; i <= n - 1; i ++){
        k3 = max(k3, fun1(1, i) + fun2(i, n) - 1);
    }
    cout << n - max({k1, k2, k3}) << "\n";
}

signed main()
{
    ios :: sync_with_stdio(false), cin.tie(0), cout.tie(0);
    solve();
    return 0;
}

T3.洛谷P1026 [NOIP2001 提高组] 统计单词个数

Link:Luogu - P1026

题目描述

给出一个长度不超过 200 200 200 的由小写英文字母组成的字母串(该字串以每行 20 20 20 个字母的方式输入,且保证每行一定为 20 20 20 个)。要求将此字母串分成
k k k 份,且每份中包含的单词个数加起来总数最大。

每份中包含的单词可以部分重叠。当选用一个单词之后,其第一个字母不能再用。例如字符串 this 中可包含 thisis,选用 this 之后就不能包含
th

单词在给出的一个不超过 6 6 6 个单词的字典中。

要求输出最大的个数。

对于 100 % 100\% 100% 的数据, 2 ≤ k ≤ 40 2 \le k \le 40 2k40 1 ≤ s ≤ 6 1 \le s \le 6 1s6

NOIP 2001 提高组第三题

题解 + Code

这道题和“P1018 [NOIP2000 提高组] 乘积最大”几乎就是一道题,都是分段类的 dp 问题

遇到这种分割类题目,我们习惯性地设 f[i][j] 表示前 i 个字符,分了 j 段能得到的最大个数,
转移:f[i][j]=max{f[j][k-1]+sum(j+1,i)},其中 j<i,sum(l,r) 表示字符串 [l,r] 区间中有多少个单词

可以暴力预处理 sum 函数,然后 dp 即可

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int p, k, len;
string s = "-"; // 让下标从1开始
vector <string> vis; // 用vector记录单词

// 读入
void input()
{
	cin >> p >> k;
	string tmp;
	for(int i = 1; i <= p; i ++){
		cin >> tmp;
		s += tmp;
	}
	len = s.size() - 1;
	int u; cin >> u;
	for(int i = 1; i <= u; i ++){
		cin >> tmp;
		vis.push_back(tmp); // 记录单词
	}
}

int sum[205][205];
int f[205][55];

// 初始化 sum
void init()
{
	for(int i = 1; i <= len; i ++){ // 枚举sum的左端点
		for(int j = i; j <= len; j ++){ // 枚举sum的右端点
			unordered_map <int, int> t; // 标记数组,t[i]表示是否有以位置i开头的子串已经被计算过了(因为题目要求相同开头、不同串,只算一次)
			for(auto it : vis){ // 遍历每一个单词
				int pos = s.find(it);
				while(pos != string::npos){
					if(i <= pos && pos+it.size()-1 <= j){ // 如果单词在[i,j]范围内
						if(!t[pos]) sum[i][j] ++, t[pos] = 1; // 标记
					}
					pos = s.find(it, pos + 1); // find的第二个参数表示查找的起始位置,这样岔开使得它会一直往后找
				}
			}
		}
	}
}

// 进行动态规划
void dp()
{
	int K = k; // 避免混淆
	for(int k = 1; k <= K; k ++){
		for(int i = k; i <= len; i ++){
			for(int j = k - 1; j <= i - 1; j ++){
				f[i][k] = max(f[i][k], f[j][k - 1] + sum[j + 1][i]);
			}
		}
	}
	cout << f[len][K] << "\n";
}

void solve()
{
	input();
	init();
	dp();
}

signed main()
{
	ios :: sync_with_stdio(false), cin.tie(0), cout.tie(0);
	solve();
	return 0;
}

T4.洛谷P1018 [NOIP2000 提高组] 乘积最大

Link:Luogu - P1018

题目描述

今年是国际数学联盟确定的“2000——世界数学年”,又恰逢我国著名数学家华罗庚先生诞辰 90 周年。在华罗庚先生的家乡江苏金坛,组织了一场别开生面的数学智力竞赛的活动,你的一个好朋友 XZ 也有幸得以参加。活动中,主持人给所有参加活动的选手出了这样一道题目:

设有一个长度为 N N N 的数字串,要求选手使用 K K K 个乘号将它分成 K + 1 K+1 K+1 个部分,找出一种分法,使得这 K + 1 K+1 K+1 个部分的乘积能够为最大。

同时,为了帮助选手能够正确理解题意,主持人还举了如下的一个例子:

有一个数字串: 312 312 312,当 N = 3 , K = 1 N=3,K=1 N=3,K=1 时会有以下两种分法:

  1. 3 × 12 = 36 3 \times 12=36 3×12=36
  2. 31 × 2 = 62 31 \times 2=62 31×2=62

这时,符合题目要求的结果是: 31 × 2 = 62 31 \times 2 = 62 31×2=62

现在,请你帮助你的好朋友 XZ 设计一个程序,求得正确的答案。

对于 60 % 60\% 60% 的测试数据满足 6 ≤ N ≤ 20 6≤N≤20 6N20
对于所有测试数据, 6 ≤ N ≤ 40 , 1 ≤ K ≤ 6 6≤N≤40,1≤K≤6 6N40,1K6

NOIP2000 提高组 T2


题解 + Code

设 f[i][k] 表示前 i 个里面,加入 k 个括号的最大乘积

那么转移的话可以选一个 j(k<=j<i),把新的乘号插入到j的后面,方程为:

f [ i ] [ k ] = m a x ( f [ i ] [ k ] , f [ j ] [ k − 1 ] × a [ j + 1 ∼ i ] ) f[i][k]=max(f[i][k],f[j][k-1] \times a[j+1 \sim i]) f[i][k]=max(f[i][k],f[j][k1]×a[j+1i])

枚举时,注意 i 要从 k+1 开始枚举,j 要从 k 开始枚举,原因都一样,因为必须要加 k 个乘号,最少要有 k+1 个字符,才能有 k 个空隙,可以插入 k 个乘号

注意n<=40,40位就算是int128也会爆炸,要用高精度

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

bool operator < (string a, string b){
    if(a.size() < b.size()) return true;
    if(a.size() > b.size()) return false;
    for(int i = 0; i < a.size(); i ++){
        if(a[i] < b[i]) return true;
        if(a[i] > b[i]) return false;
    }
    return false;
}

string max(string a, string b){
    return (a < b? b : a);
}

string operator * (string s1, string s2){
    vector <int> a(s1.size()), b(s2.size()), c(s1.size() + s2.size());

    // 提取字符串到数组中
    for(int i = 0; i < s1.size(); i ++) a[i] = s1[s1.size() - i - 1] - '0';
    for(int i = 0; i < s2.size(); i ++) b[i] = s2[s2.size() - i - 1] - '0';
    
    // 进行乘法运算
    int x = 0; // 处理进位
    for(int i = 0; i < s1.size(); i ++){
        for(int j = 0; j < s2.size(); j ++){
            c[i + j] += a[i] * b[j] + x;
            x = c[i + j] / 10;
            c[i + j] %= 10;
        }
        c[s2.size() + i] = x;
        x = 0;
    }

    // 转换为字符串返回
    int pos = c.size() - 1;
    while(pos >= 1 && c[pos] == 0) pos --;
    string ans;
    for(int i = pos; i >= 0; i --) ans += c[i] + '0';
    return ans;
}

string dp[45][10];
int n, K; // 这里采用大写字符K,避免和转移方程里的k混淆
string a;

string get(int l, int r){
    string t;
    // 注意l,r是以1开始的标准,但实际上a下标是从0开始的
    for(int i = l - 1; i <= r - 1; i ++) t += a[i];
    return t;
}

void solve()
{
    cin >> n >> K >> a;

    // 初状态:前i个数,加入0个乘号
    for(int i = 1; i <= n; i ++){
        dp[i][0] = get(1, i);
    }

    for(int k = 1; k <= K; k ++){ // 枚举要添加的括号数k
        for(int i = k + 1; i <= n; i ++){ // 枚举i,注意要添加k个乘号,所以i至少要从k+1开始枚举(k+1个字符,有k个放乘号的空隙)
            for(int j = k; j <= i - 1; j ++){ // 枚举中介点,把乘号添加到j的后面(枚举到i-1是因为,要添加乘号的话至少要留一个位置,在i-1后面插入乘号)
                dp[i][k] = max(dp[i][k], dp[j][k - 1] * get(j + 1, i));
            }
        }
    }

    cout << dp[n][K] << endl; // 从1开始输出,把前面用来占位的0躲开
}

signed main()
{
    ios :: sync_with_stdio(false), cin.tie(0), cout.tie(0);
    solve();
    return 0;
}

T4.洛谷P1233 木棍加工

Link:Luogu - P1233

题目描述

一堆木头棍子共有 n n n 根,每根棍子的长度和宽度都是已知的。棍子可以被一台机器一个接一个地加工。机器处理一根棍子之前需要准备时间。准备时间是这样定义的:

  • 第一根棍子的准备时间为 1 1 1 分钟。
  • 如果刚处理完长度为 l l l,宽度为 w w w 的棍子,那么如果下一个棍子长度为 l i l_i li,宽度为 w i w_i wi,并且满足 l ≥ l i l\ge l_i lli w ≥ w i w\ge w_i wwi,这个棍子就不需要准备时间,否则需要 1 1 1 分钟的准备时间。

计算处理完 n n n 根棍子所需要的最短准备时间。比如,你有 5 5 5 根棍子,长度和宽度分别为 ( 4 , 9 ) , ( 5 , 2 ) , ( 2 , 1 ) , ( 3 , 5 ) , ( 1 , 4 ) (4,9),(5,2),(2,1),(3,5),(1,4) (4,9),(5,2),(2,1),(3,5),(1,4),最短准备时间为 2 2 2(按 ( 4 , 9 ) , ( 3 , 5 ) , ( 1 , 4 ) , ( 5 , 2 ) , ( 2 , 1 ) (4,9),(3,5),(1,4),(5,2),(2,1) (4,9),(3,5),(1,4),(5,2),(2,1) 的次序进行加工)。

对于 100 % 100 \% 100% 的数据, 1 ≤ n ≤ 5000 1 \le n \le 5000 1n5000 1 ≤ l i , w i ≤ 10 4 1 \le l_i, w_i \le {10}^4 1li,wi104


题解 + Code

有两种思路:

  1. 贪心
    按照左端点从大到小,右端点从大到小的顺序降序排序。然后从1~n依次堆叠,如果满足条件,就把它加到当前的砖上面,标记一下。最后统计没被标记的个数,就是要花时间去堆叠的个数
  2. 动态规划
    就是要求最长上升子序列的长度。
    题目中每次都要选比它小的棍子,就是一个下降子序列。
    根据 Dilworth 定理,一个序列的需要的能覆盖整个序列的最少最长下降子序列的个数,等于这个序列的 LIS 的长度,这就是这题要求 LIS 的原因。(这种求 LIS 的模板非常常见,建议记住)

贪心代码:

#include <bits/stdc++.h>
using namespace std;

int n, vis[5010]; // vis[i]表示第i个砖头是否被使用过
struct Node{
	int a,b;
}block[5010];

// 降序排序
bool cmp(Node p1, Node p2)
{
	if(p1.a == p2.a) return p1.b > p2.b;
	return p1.a > p2.a;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i ++){
		cin >> block[i].a >> block[i].b;
	}
	sort(block + 1, block + n + 1, cmp);
	
	for(int i = 1; i <= n; i ++){
		if(!vis[i]){ // 如果没被使用过
			int nowa = block[i].a;
			int nowb = block[i].b;
			for(int j = i + 1; j <= n; j ++){ // 枚举下一个摞哪块砖头
				if(nowa >= block[j].a && nowb >= block[j].b && !vis[j]){ // 注意要判断是否使用过
					vis[j] = 1;
					nowa = block[j].a, nowb = block[j].b;
				}
			}
		}
	}
	
	int tim = 0;
	for(int i = 1; i <= n; i ++){
		if(!vis[i]) tim ++; // 如果没用过,说明要花时间去堆叠
	}
	cout << tim << "\n";
	return 0;
}

dp 代码:

#include <bits/stdc++.h>
using namespace std;

struct Node{
	int a, b;
}block[5005];

// 降序排序
bool cmp(Node p1, Node p2)
{
	if(p1.a == p2.a) return p1.b > p2.b;
	return p1.a > p2.a;
}

int dp[5005];

int main()
{
	int n;
	cin >> n;
	for(int i = 1; i <= n; i ++){
		cin >> block[i].a >> block[i].b;
	}
	sort(block + 1, block + n + 1, cmp);
	
	fill(dp + 1, dp + n + 1, 1); // 初始化:第i个数的初始最a上升子序列长度为1
	for(int i = 1; i <= n; i ++){
		for(int j = 1; j <= i - 1; j ++){
			if(block[i].a > block[j].a || block[i].b > block[j].b){ // 因为要求最长上升子序列,所以不能相等
				dp[i] = max(dp[i], dp[j] + 1);
			}
		}
	}
	
	cout << *max_element(dp + 1, dp + n + 1) << "\n";
	return 0;
}

T5.51Nod2487 小b和环

Link:51Nod - 2487

Description

b b b 有一个长度为 n n n 的环,每个点上有个数字。( 0 ≤ 0 \le 0 每个点上的数字 ≤ 10000 \le 10000 10000

现在请你选出一些点,满足选出的任意两个点在环上不相邻,且选出的点的数字之和最大,你只需输出这个最大值。


解法 + Code

无环做法:
设dp[i]表示选到位置i时的最大和,转移从选i、不选i两种情况转移
dp[i] = max(dp[i-2]+a[i], dp[i-1])

有环的做法:
名言:dp不动怎么办?–加一维。

设dp[i][0/1]表示第i个位置不取/取得到的最大值,
则转移方程如下:

  • f[i][0] = max(f[i][0], f[i-1][0], f[i-1][1]) // 第i个不选,前一个选不选都可以
  • f[i][1] = max(f[i][1], f[i-1][0]+a[i]) // 第i个选,前一个必须不选,注意加上a[i]

因为是个环,所以保证1和n不能相邻,就跑两遍。一次1不选,n可选可不选;一次1选,n必不选。这两种情况取max即可
注意可以通过初始化、i从3开始等手段,从位置2控制a[1]的选择

#include <bits/stdc++.h>
#define int long long // 要开longlong!
using namespace std;

void solve()
{
	int n; cin >> n;
	vector <int> a(n + 1);
	for(int i = 1; i <= n; i ++){
		cin >> a[i];
	}
	
	if(n == 1){
		cout << a[1] << "\n"; return ;
	}
	
	vector< vector<int> > f(n + 1, {0, 0});
	// 不选第一个,第n个可选可不选
	f[2][0] = 0, f[2][1] = a[2];
	for(int i = 3; i <= n; i ++){
		f[i][0] = max({f[i][0], f[i-1][0], f[i-1][1]}); // 第i个不选,前一个选不选都可以
		f[i][1] = max(f[i][1], f[i-1][0]+a[i]); // 第i个选,前一个必须不选,注意加上a[i]
	}
	int ans = max(f[n][0], f[n][1]); // 第n个可选可不选,取最大值
	
	for(int i = 1; i <= n; i ++){
		f[i][0] = f[i][1] = 0;
	}
	
	// 选第一个,第二个一定不选,第n个一定不选
	f[2][0] = a[1];
	for(int i = 3; i <= n; i ++){
		f[i][0] = max({f[i][0], f[i-1][0], f[i-1][1]}); // 第i个不选,前一个选不选都可以
		f[i][1] = max(f[i][1], f[i-1][0]+a[i]); // 第i个选,前一个必须不选,注意加上a[i]
	}
	ans = max(ans, f[n][0]); // 注意第n个一定不选,所以不能用
	
	cout << ans << "\n";
}

signed main()
{
	ios :: sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);
	solve();
	return 0;
}

T6.51Nod2484 小b和排序

Link:51Nod - 2484

Description

b b b 有两个长度都为 n n n 的序列 A , B A, B A,B

现在她需要选择一些 i i i ,然后交换 A [ i ] A[i] A[i] B [ i ] B[i] B[i] ,使得 A A A B B B 都变成严格递增的序列。

你能帮小 b b b 求出最少交换次数吗?

其中 1 ≤ n ≤ 1000 , 0 ≤ A [ i ] , B [ i ] ≤ 2000 1 \leq n \leq 1000, 0 \leq A[i], B[i] \leq 2000 1n1000,0A[i],B[i]2000 ,输入保证有解。

题解 + Code

设 dp[i][0/1] 表示在位置i交换/不交换时,当前的最少交换次数(不保证数列有序)。
发现使数列有序只和当前位置和前一个位置有关系,所以只要看当前位置和前一个位置如何交换使得数列有序即可。

  1. 数列原本就有序,即 a[i-1]<a[i] and b[i-1]<b[i],那么就正常从i-1转移就行了
    • dp[i][0] = min(dp[i][0],dp[i-1][0])
    • dp[i][1] = min(dp[i][1],dp[i-1][1]+1) // 选择交换,那么次数+1
  2. 数列原本无序,需要通过交换a[i]和b[i]使其有序,即 a[i-1]<b[i] and b[i-1]<a[i],这样就可以交换后使其有序
    • dp[i][0] = min(dp[i][0],dp[i-1][1])
    • dp[i][1] = min(dp[i][1],dp[i-1][0]+1)
  3. 因为题目保证有解,所以无需考虑其他不行的情况

最后,对位置n的两种情况取min即可。ans = min(dp[n][0],dp[n][1])

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

void solve()
{
    int n;
    cin >> n;
    vector <int> a(n + 1), b(n + 1);
    vector< vector<int> > dp(n + 1, {INT_MAX, INT_MAX}); // 因为答案取min,所以初始值赋值为正无穷

    for(int i = 1; i <= n; i ++) cin >> a[i];
    for(int j = 1; j <= n; j ++) cin >> b[j];

    a[0] = b[0] = INT_MIN; // 保证a[0]<a[1], b[0]<b[1]
    dp[0][0] = dp[0][1] = 0; // 初始化:位置0交换或不交换的次数为0
    for(int i = 1; i <= n; i ++){
        if(a[i - 1] < a[i] && b[i - 1] < b[i]){
            dp[i][0] = min(dp[i][0], dp[i - 1][0]);
            dp[i][1] = min(dp[i][1], dp[i - 1][1] + 1);
        }
        if(a[i - 1] < b[i] && b[i - 1] < a[i]){
            dp[i][0] = min(dp[i][0], dp[i - 1][1]);
            dp[i][1] = min(dp[i][1], dp[i - 1][0] + 1);
        }
    }

    cout << min(dp[n][0], dp[n][1]) << "\n";
}

signed main()
{
    ios :: sync_with_stdio(false), cin.tie(0), cout.tie(0);
    solve();
    return 0;
}

T7.51Nod2680 争渡

Link:51Nod - 2680

Description

人生如逆水行舟,不进则退。你一生中有 n n n 个阶段,每个阶段有一个状态下限 L i L_i Li ,也有一个状态上限 R i R_i Ri ,你想规划你的一生中各阶段的状态值,使得你的状态在 n n n 个阶段中始终在变好(严格递增)。请你计算有多少种不同的人生规划。由于答案较大,只需输出答案对 998244353 998244353 998244353 取余的结果。

数据范围: 1 ≤ n ≤ 200 , 1 ≤ L i ≤ R i ≤ 1 0 4 1 \leq n \leq 200, 1 \leq L_i \leq R_i \leq 10^4 1n200,1LiRi104


思路 + Code

考虑动态规划,设f[i][j]表示在第i个阶段、状态值为j的方案数,那么转移可以从前一阶段所有比现在状态值低的方案数转移:

f [ i ] [ j ] + = f [ i − 1 ] [ j − 1 ] ,   L [ i ] < = j < = R [ i ] f[i][j]+=f[i-1][j-1],\ L[i]<=j<=R[i] f[i][j]+=f[i1][j1], L[i]<=j<=R[i]

转移j-1是因为状态值必须严格递增

因为我们要枚举j,还要求和,可能会超时。可以用前缀和优化加速转移。

注意开long long,取模等问题。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int mod = 998244353;

int L[10005], R[10005];
int f[205][10005];

signed main(){
	int n;
	cin >> n;
	for(int i = 1; i <= n; i ++){
		cin >> L[i] >> R[i];
	}
	for(int i = L[1]; i <= R[1]; i ++){
		f[1][i] = 1;
	}
	for(int i = 2; i <= n; i ++){
		vector <ll> sum(10005); // ! 前缀和要开long long!不然会炸!!!
		for(int j = 1; j <= 10000; j ++){
			sum[j] = sum[j - 1] + f[i - 1][j];
		}
		for(int j = L[i]; j <= R[i]; j ++){
			f[i][j] = (1LL * f[i][j] + sum[j - 1]) % mod; // 因为题目要求状态值严格递增,所以前缀和要取到j-1
		}
	}
	int ans = 0;
	for(int i = L[n]; i <= R[n]; i ++){ // 方案数类的dp,要求和
		ans = (1LL * ans + f[n][i]) % mod;
	}
	cout << ans << endl;
	return 0;
}

T8.洛谷P1434 [SHOI2002] 滑雪

Link:Luogu - P1434

题目描述

Michael 喜欢滑雪。这并不奇怪,因为滑雪的确很刺激。可是为了获得速度,滑的区域必须向下倾斜,而且当你滑到坡底,你不得不再次走上坡或者等待升降机来载你。Michael 想知道在一个区域中最长的滑坡。区域由一个二维数组给出。数组的每个数字代表点的高度。下面是一个例子:

1   2   3   4   5
16  17  18  19  6
15  24  25  20  7
14  23  22  21  8
13  12  11  10  9

一个人可以从某个点滑向上下左右相邻四个点之一,当且仅当高度会减小。在上面的例子中,一条可行的滑坡为 24 − 17 − 16 − 1 24-17-16-1 2417161(从 24 24 24 开始,在 1 1 1 结束)。当然 25 25 25 24 24 24 23 23 23 … \ldots 3 3 3 2 2 2 1 1 1 更长。事实上,这是最长的一条。

对于 100 % 100\% 100% 的数据, 1 ≤ R , C ≤ 100 1\leq R,C\leq 100 1R,C100


思路 + Code

直接搜索会超时,发现一个点的最长滑坡长度是其四周比它小的格子的最长滑坡长度加一,
有点像dp,考虑用记忆化搜索来实现。这样可以优化复杂度,因为每个点只计算一遍

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int r, c;
vector < vector <int> > A;
vector < vector <int> > f; // 记忆化搜索数组,f[i][j]表示到A[i][j]的最长滑坡

vector <int> dx = {0, 1, 0, -1};
vector <int> dy = {1, 0, -1, 0};

int dfs(int x, int y){
    if(f[x][y] != -1) return f[x][y]; // 记忆化,搜索过直接返回
    f[x][y] = 1; // 自己就是一个长度为1的滑坡
    for(int i = 0; i < 4; i ++){
        int nx = x + dx[i], ny = y + dy[i];
        if(nx >= 0 && nx < r && ny >= 0 && ny < c && A[x][y] > A[nx][ny]){ // 当然后一个高度要比前一个低
            f[nx][ny] = dfs(nx, ny); // 搜索同时更新下一个的记忆化数组
            f[x][y] = max(f[x][y], f[nx][ny] + 1); // 打擂台求最大值
        }  
    }
    return f[x][y]; // 最后返回已经更新完毕的最长滑坡长度
}

int main()
{
    ios::sync_with_stdio(false); cin.tie(0);
    cin >> r >> c;
    for(int i = 0; i < r; i ++){ // 初始化数组
        A.push_back( vector<int>(c, 0) );
        f.push_back( vector<int>(c, -1) );
    }
    for(auto &i : A){ // 输入
        for(auto &j : i){
            cin >> j;
        }
    }
    int ans = 0;
    for(int i = 0; i < r; i ++){
        for(int j = 0; j < c; j ++){
            ans = max(ans, dfs(i, j)); // 每个地方都搜素一下,求最大值
        }
    }
    cout << ans << endl;
    return 0;
}

T9.洛谷P1140 相似基因

Link:Luogu - P1140

题目背景

大家都知道,基因可以看作一个碱基对序列。它包含了 4 4 4 种核苷酸,简记作 A, C, G, T。生物学家正致力于寻找人类基因的功能,以利用于诊断疾病和发明药物。

在一个人类基因工作组的任务中,生物学家研究的是:两个基因的相似程度。因为这个研究对疾病的治疗有着非同寻常的作用。

题目描述

两个基因的相似度的计算方法如下:

对于两个已知基因,例如 AGTGATGGTTAG,将它们的碱基互相对应。当然,中间可以加入一些空碱基 -,例如:

A G T G A T - G - G T - - T A G \def\arraystretch{1.5} \begin{array}{|c|c|c|c|c|c|c|c|} \hline \tt A & \tt G & \tt T & \tt G & \tt A & \tt T & \texttt - & \tt G \\ \hline \texttt - & \tt G & \tt T & \texttt - & \texttt - & \tt T & \texttt A & \tt G \\ \hline \end{array} A-GGTTG-A-TT-AGG

这样,两个基因之间的相似度就可以用碱基之间相似度的总和来描述,碱基之间的相似度如下表所示:

A C G T - A 5 − 1 − 2 − 1 − 3 C − 1 5 − 3 − 2 − 4 G − 2 − 3 5 − 2 − 2 T − 1 − 2 − 2 5 − 1 - − 3 − 4 − 2 − 1 ∗ \def\arraystretch{1.5} \begin{array}{ |c|c|c|c|c|c|} \hline & \tt A & \tt C & \tt G & \tt T & \texttt - \\ \hline \tt A & 5 & -1 & -2 & -1 & -3\\ \hline \tt C & -1 & 5 & -3 & -2 & -4 \\\hline \tt G & -2 & -3 & 5 & -2 & -2 \\\hline \tt T & -1 & -2 & -2 & 5 & -1 \\\hline \texttt - & -3 & -4 & -2 & -1 & * \\\hline \end{array} ACGT-A51213C15324G23522T12251-3421

那么相似度就是: ( − 3 ) + 5 + 5 + ( − 2 ) + ( − 3 ) + 5 + ( − 3 ) + 5 = 9 (-3)+5+5+(-2)+(-3)+5+(-3)+5=9 (3)+5+5+(2)+(3)+5+(3)+5=9。因为两个基因的对应方法不唯一,例如又有:

A G T G A T G - G T T A - G \def\arraystretch{1.5} \begin{array}{|c|c|c|c|c|c|c|} \hline \tt A & \tt G & \tt T & \tt G & \tt A & \tt T & \tt G \\ \hline \texttt - & \tt G & \tt T & \texttt T & \texttt A & \texttt - & \tt G \\ \hline \end{array} A-GGTTGTAAT-GG

相似度为: ( − 3 ) + 5 + 5 + ( − 2 ) + 5 + ( − 1 ) + 5 = 14 (-3)+5+5+(-2)+5+(-1)+5=14 (3)+5+5+(2)+5+(1)+5=14。规定两个基因的相似度为所有对应方法中,相似度最大的那个。


题解 + Code

这是典型的两个序列匹配类的问题。

设dp[i][j]表示第一个序列匹配到i,第二个序列匹配到j时,两条基因的相似度
设s[x][y]表示基因x和基因y的相似度则转移方程如下:
d p [ i ] [ j ] = m a x { dp[i][j] = max\{ dp[i][j]=max{
                               d p [ i − 1 ] [ j − 1 ] + s [ a [ i ] ] [ a [ j ] ] , \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ dp[i-1][j-1] + s[a[i]][a[j]],                               dp[i1][j1]+s[a[i]][a[j]],
                               d p [ i − 1 ] [ j ] + s [ a [ i ] ] [ ′ − ′ ] , \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ dp[i-1][j] + s[a[i]]['-'],                               dp[i1][j]+s[a[i]][],
                               d p [ i ] [ j − 1 ] + s [ ′ − ′ ] [ b [ j ] ] \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ dp[i][j-1] + s['-'][b[j]]                               dp[i][j1]+s[][b[j]]
                            } \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \}                            }

解释:(其中’-'表示和空序列匹配)
1. 两个基因序列中的位置i、j 匹配
2. 第一个基因序列中的位置i、第二个序列中插入的空序列 匹配
3. 第二个基因序列中的位置j、第一个序列中插入的空序列 匹配

注意初始化:对dp的两个维度有0的初始化,不然会发现推出来的都是0,因为最边缘没有初始化好
dp[0][0] = 0 // 匹配到(0,0)相似度自然为0(不写这一行也行)
dp[1~a.size][0] = dp[i-1][0] + s[a[i]][‘-’] // 预处理第一个基因串和空基因串匹配的值
dp[0][1~b.size] = dp[0][j-1] + s[‘-’][b[j]] // 预处理第二个基因串和空基因串匹配的值

#include <bits/stdc++.h>
using namespace std;

void solve()
{
    string a, b; int lena, lenb; cin >> lena >> a >> lenb >> b;
    vector< vector<int> > dp(lena + 1, vector<int>(lenb + 1, INT_MIN)); // 初始化为最小值,避免取max时出错
    vector< vector<int> > s = {
        {5, -1, -2, -1, -3},
        {-1, 5, -3, -2, -4},
        {-2, -3, 5, -2, -2},
        {-1, -2, -2, 5, -1},
        {-3, -4, -2, -1, 0}
    };
    unordered_map <char, int> vis; // 建立基因序列和s数组的映射关系
    vis['A'] = 0, vis['C'] = 1, vis['G'] = 2, vis['T'] = 3, vis['-'] = 4;

    // 定义函数,便于快速获取s数组中的值
    auto S = [&](char x, char y){ return s[vis[x]][vis[y]]; };

    // 初始化
    dp[0][0] = 0;
    for(int i = 1; i <= lena; i ++){
        dp[i][0] = dp[i - 1][0] + S(a[i - 1], '-'); // ! string下标从0开始,要减一
    }
    for(int j = 1; j <= lenb; j ++){
        dp[0][j] = dp[0][j - 1] + S('-', b[j - 1]);
    }

    // dp
    for(int i = 1; i <= lena; i ++){
        for(int j = 1; j <= lenb; j ++){
            dp[i][j] = max({
                dp[i - 1][j - 1] + S(a[i - 1], b[j - 1]),
                dp[i - 1][j] + S(a[i - 1], '-'),
                dp[i][j - 1] + S('-', b[j - 1])
            });
        }
    }

    cout << dp[lena][lenb] << '\n';
}

signed main()
{
    ios :: sync_with_stdio(false), cin.tie(0), cout.tie(0);
    solve();
    return 0;
}

T10.洛谷P1121 环状最大两段子段和

Link:Luogu - P1121

题目描述

给出一段长度为 n n n 的环状序列 a a a,即认为 a 1 a_1 a1 a n a_n an 是相邻的,选出其中连续不重叠且非空的两段使得这两段和最大。

对于全部的测试点,保证 2 ≤ n ≤ 2 × 1 0 5 2 \leq n \leq 2 \times 10^5 2n2×105 − 1 0 4 ≤ a i ≤ 1 0 4 -10^4 \leq a_i \leq 10^4 104ai104


思路 + Code

发现最后选的策略就两种情况:(0表示不选,+表示选)
一、00++++00+++++000
二、++00+++0000++++

对于情况一,可以求两段不相交的最大子序列和(+的部分),再加起来;
对于情况二,可以求两段不相交的最小子序列和(0的部分),再用总和减去,就是答案。

现在问题来了:如何求一个序列中两段不相交的最大/最小子序列和?
以求最大值为例,
可以想象成把原序列砍成左右两部分,然后分别求左、右两部分的最大子段和,最后相加
so,设f[i]表示原序列a[1i]的最大子段和,g[i]表示a[ni]的最大子段和(注意g是倒着的),
然后枚举原序列中间的分割点k,对得到的所有答案取max即可:
ans = max{f[i] + g[i + 1]} 1<=i<=n-1(值域很好理解,要是够到n,那么就会访问到g[n+1],这个地方未被赋值,会出现错误)

这样思路就很明确了。n<=2e5,a[i]<=1e4,n*a[i]<=2e9,用int足够
实现过程可以用函数模板,达到一函数两用的效果。复杂度稳定O(n),但常数略大

注意两种特殊的情况,需要特判:

  1. 数列里只有一个正数(或0),其余全是负数。这样的话我们找不到两个连续的正数,所以答案应该是正数+最大的负数
  2. 数列里全是负数,此时计算情况二时,会出现用总和-所有负数和=总和+|所有负数和|=0.答案不对
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 2e5 + 7;

int n, a[maxn];

struct Max{ int operator() (int x, int y){ return (x > y? x : y); } };
struct Min{ int operator() (int x, int y){ return (x < y? x : y); } };

template <typename _Compare>
int work(_Compare cmp){
	vector <int> f(n + 1), g(n + 1);
	f[1] = a[1]; // 初始化,避免比较时和f[0]=0出现比较出现错误。下面的循环直接从2开始即可
	for(int i = 2; i <= n; i ++) f[i] = cmp(f[i - 1] + a[i], a[i]); // 此时的f[i]表示1~i中以i结尾的最大(或最小)子段和
	for(int i = 2; i <= n; i ++) f[i] = cmp(f[i], f[i - 1]); // 此时的f[i]表示1~i中的最大(或最小)子段和
	g[n] = a[n]; // 初始化,避免比较时和g[n+1]=0出现比较出现错误。下面的循环直接从n-1开始即可
	for(int i = n-1; i >= 1; i --) g[i] = cmp(g[i + 1] + a[i], a[i]); // 此时的g[i]表示n~i中以i结尾的最大(或最小)子段和
	for(int i = n-1; i >= 1; i --) g[i] = cmp(g[i + 1], g[i]); // 此时g[i]表示n~i中的最大(或最小)子段和
	
	int ans = cmp(0, -1) + INT_MIN;
    /*上面是一个骚操作:当cmp为max函数时,ans=INT_MIN+0,正好是求最大值时的初始最小值
	  当cmp为min函数时,ans=INT_MIN-1,由于int自然溢出的特性,此时ans的值应为INT_MAX,正好是求最小值时的初始最大值*/
    for(int i = 1; i <= n - 1; i ++) ans = cmp(ans, f[i] + g[i + 1]);
	return ans;
}

void solve()
{
	cin >> n;
	int sum = 0, positive = 0; // sum记录Σa[i],positive记录其中非负数的个数(包括0和正整数)
	for(int i = 1; i <= n; i ++){
        cin >> a[i], sum += a[i];
        positive += (a[i] >= 0);
    }
    if(positive == 0){ // 全是负数,答案为两个最大的负数和
        sort(a + 1, a + n + 1, greater<int>()); // 从大到小排序
        cout << a[1] + a[2] << '\n'; // 题目保证n>=2
    }
    else if(positive == 1){ // 只有一个非负数:答案为这个非负数+最大的负数
        sort(a + 1, a + n + 1, greater<int>()); // 从大到小排序
        // 排序完后,a[1]一定是那个非负数,a[2]一定是那个最大的负数
        cout << a[1] + a[2] << '\n';
    }
    else{ // 正常情况
        cout << max(work(Max()), sum - work(Min())) << '\n';
    }
}

signed main()
{
	ios :: sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);
	solve();
	return 0;
}

T11.洛谷P2889 [USACO07NOV] Milking Time S

Link:Luogu - P2889

[USACO07NOV] Milking Time S

题目描述

Bessie 可以在接下来 N N N 个小时内产奶,为了方便,我们把这 N N N 个小时 1 … N 1\dots N 1N 编号。

FJ 在这 N N N 个小时内有 M M M 段时间可以来给 Bessie 挤奶,第 i i i 段时间从 S t a r t i Start_i Starti 开始到 E n d i End_i Endi 结束,可以得到 E f f i Eff_i Effi 加仑牛奶。

每次 FJ 给 Bessie 挤奶之后,Bessie 都要休息 R R R 个小时,FJ 才能开始下一次挤奶。

现在,FJ 需要您计算出 Bessie 在这 N N N 个小时内最多产多少奶。

对于全部的测试点,保证 1 ≤ N ≤ 1 0 6 1\le N\le 10^6 1N106 1 ≤ M ≤ 1 0 3 1\le M\le 10^3 1M103 1 ≤ S t a r t i < e n d i ≤ N 1\le Start_i<end_i\le N 1Starti<endiN 1 ≤ E f f i ≤ 1 0 6 1\le Eff_i\le 10^6 1Effi106


题解 + Code

解法一:
这属于线段覆盖问题,题意就是选取若干条互不覆盖的线段,求最大的总收益

可以设dp[i]表示到线段i为止的最多挤奶量
然后转移可以从i前面不与i重合的所有线段转移。注意转移时要从前面的最大值转移,这样肯定最优。可以维护一个前缀max解决。

选取i前面不与i重合的所有线段:
其实直接O(m^2)枚举打擂台是能过的,但对所有线段按照右端点排序后,所有线段就具有了单调性,可以二分优化到O(mlogm)的复杂度

解法二:
这可以看作是一道基于时间轴的dp。

枚举1~n的时间节点,对于时间i,可以保持不动;如果i正好是某条线段的右端点,那么我们可以加上这条线段的贡献。

小技巧:因为还要考虑休息时间,比较麻烦。可以在输入时直接给右端点加上R,这样一条线段的r就是能进行下一次挤奶的时间

设dp[i]表示到时间点i为止的最大收益,转移方程:
dp[i] : max{ dp[i-1] , for all ······相当于什么都不做
        1 \ \ \ \ \ \ \ 1        1{ dp[a[j].l]+a[j].w , a[j].r==i ······相当于选择区间j,并累加贡献

这样对 每条线段的右端点 预处理之后,时间复杂度为O(n+m)

注意!!!开dp数组时要考虑上休息时间R,在这道题里没给R,开2倍的n即可


#include <bits/stdc++.h>
using namespace std;

struct Node{
    int l, r, w; // 左端点、右端点、挤奶量
};

// 解法1
void solve1()
{
    int n, m, R; cin >> n >> m >> R;
    vector <int> dp(m + 1), Mx(m + 1); // Mx是dp的前缀最大值,便于dp转移
    vector <Node> a(m + 1);
    for(int i = 1; i <= m; i ++) cin >> a[i].l >> a[i].r >> a[i].w;

    sort(a.begin() + 1, a.end(), [](Node p1, Node p2){
        return p1.r < p2.r;
    });

    Mx[1] = dp[1] = a[1].w;
    for(int i = 2; i <= m; i ++){
        int l = 1, r = i - 1, pos = i; // 对pos赋初始值i,这样找不到的话就会算作不符合条件,这样不会出问题(因为a[i].r+R > a[i].l)
        while(l <= r){ // 二分最靠右的不和线段i重合的线段
            int mid = (l + r) >> 1;
            if(a[mid].r + R <= a[i].l) pos = mid, l = mid + 1; // a[mid].r别忘了加上休息时间
            else r = mid - 1;
        }
        // !!! 求前缀最大值非常重要,这体现对题目的理解,如果只从dp[pos]转移,那么转移的就只是从pos到i的答案。
        // !!! 但我们二分的目的并不是找最优答案,我们的目的只是为了隔绝出合法的(不与i重叠的)线段。正确的方法是从dp[1~pos]中的最大值转移。
        if(a[pos].r + R <= a[i].l) dp[i] = Mx[pos] + a[i].w; // 符合条件,从线段pos前所有线段的最大值进行转移
        else dp[i] = a[i].w; // 没有合适的线段,那就“自立门户”,重新开一个
        Mx[i] = max(Mx[i - 1], dp[i]);
    }

    cout << Mx[m] << '\n'; // 输出位置m的前缀最大值就是答案
}

// 解法二
void solve2()
{
    int n, m, R; cin >> n >> m >> R;
    vector <Node> a(m + 1);
    vector <int> dp(2*n + 1), vis[2*n + 1];
    for(int i = 1; i <= m; i ++){
        cin >> a[i].l >> a[i].r >> a[i].w; a[i].r += R;
        vis[a[i].r].push_back(i);
    }
    
    // dp
    for(int i = 1; i <= n + R; i ++){
        dp[i] = dp[i - 1]; // 如果什么都不做的话
        for(int j : vis[i]){ // 内层循环最多执行m次,不会增加复杂度级别
            dp[i] = max(dp[i], dp[a[j].l] + a[j].w);
        }
    }
    cout << dp[n + R] << '\n'; // 输出n+R时的时间就是答案
}

signed main()
{
    ios :: sync_with_stdio(false), cin.tie(0), cout.tie(0);
    // solve1();
    solve2();
    return 0;
}

End

谢谢大家!持续更新中~

;