目录
[一、为什么这题要用 DP?](#一、为什么这题要用 DP?)
[1.1 先看暴力做法的问题](#1.1 先看暴力做法的问题)
[1.2 问题具有最优子结构](#1.2 问题具有最优子结构)
[1.3 问题具有重叠子问题](#1.3 问题具有重叠子问题)
[二、如何想到 DP 状态设计?](#二、如何想到 DP 状态设计?)
[2.1 第一步:确定要记录什么信息](#2.1 第一步:确定要记录什么信息)
[2.2 第二步:尝试一维 DP(失败)](#2.2 第二步:尝试一维 DP(失败))
[2.3 第三步:加一维记录区间数](#2.3 第三步:加一维记录区间数)
[2.4 第四步:再加一维记录当前状态](#2.4 第四步:再加一维记录当前状态)
[三、DP 数组的构建过程](#三、DP 数组的构建过程)
[3.1 初始化](#3.1 初始化)
[3.2 填表顺序](#3.2 填表顺序)
[3.3 推导转移方程](#3.3 推导转移方程)
[i=1, b1=2](#i=1, b[1]=2)
[i=2, b2=-9](#i=2, b[2]=-9)
[i=3, b3=-2](#i=3, b[3]=-2)
[六、DP 设计的核心思想](#六、DP 设计的核心思想)
前记:
这几道题是我认为在赛时有希望能做出来的题,全做出来是60分,再加上另外几题的暴力大概最终能拿到75左右
合法密码
题目:
小蓝正在开发自己的 OJ 网站。他要求网站用户的密码必须符合以下条件:
- 长度大于等于 8 个字符,小于等于 16 个字符。
- 必须包含至少 1 个数字字符和至少 1 个符号字符。
例如 lanqiao2024!、+-*/0601、8((>w<))8 都是合法的密码。
而 12345678、##**##**、abc0!#、lanqiao20240601!? 都不是合法的密码。
请你计算以下的字符串中,有多少个子串可以当作合法密码?只要两个子串的开头字符和末尾字符在原串中的位置不同,就算作不同的子串。
字符串为:
cpp
kfdhtshmrw4nxg#f44ehlbn33ccto#mwfn2waebry#3qd1ubwyhcyuavuajb#vyecsycuzsmwp31ipzah#catatja3kaqbcss2th
思路:
暴力遍历全部可能的区间再判断即可,题目给的字符串中只有'#'这一个字符,所以要求二所说的至少包含一个字符只能是'#'
代码:
最终答案是400
cpp
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
bool check(string ss) {
int fg1 = 0, fg2 = 0;
for(int i = 0; i < ss.length(); i++) {
if(ss[i] == '#') fg1++;
if(ss[i] >= '0' && ss[i] <= '9') fg2++;
}
return (fg1 && fg2);
}
void solve() {
string s;
cin >> s;
int ans = 0;
int n = s.size();
for(int i = 0; i < n; i++) {
for(int j = 8; j <= 16; j++) {
if(i + j <= n) {
if(check(s.substr(i, j))) ans++;
}
}
}
cout << ans << endl;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
int t = 1;
while(t--)
solve();
return 0;
}
选数概率
题目描述
一个数组中有 a 个 1,b 个 2,c 个 3。设 Pi,j 表示在数组中随机选取两个数,其中一个数为 i,另一个数为 j 的概率。比如 P1,2=ab/C(a+b+c,2),其中 C(N,M) 为组合数,表示从 N 个不同元素中任取 M 个的方案数。
当 a=?,b=?,c=? 时,满足 P1,2=517/2091,P2,3=2632/10455,P1,3=308/2091,且 a+b+c 最小。保证 a+b+c 最小的解是唯一的。
你需要提交一个格式为 a,b,c 的字符串。例如假设你计算的结果是 a=12,b=34,c=56,那么你需要提交的字符串是 12,34,56。
思路:
数学化简:
P1,2,P2,3,P1,3的分母都是相同的(都是C(a+b+c,2)),故而可以直接相除约掉分母,得到:

可以得到a:b:c=55:94:56
答案要求a+b+c最小,故而最终答案就是55,94,56
蚂蚁开会
题目:
题目给定n个线段,让你找出这n个线段的交点为整数的个数
思路:
把这些线段上的全部整数点都用map(因为map键值可以是pair,故而用map记录很方便)记录出现次数,大于等于2的就是符合要求的
关键:如何通过两端点找到该线段上的全部点呢?
首先我们知道,线段上的点的间隔都是均匀的,故而如果我们知道线段上共有几个点,那就能挨个求出线段上的全部点
结论:线段上的整点个数 = gcd(x方向距离, y方向距离) + 1
x方向距离:两点间横向距离
y方向距离:两点间纵向距离
代码:
cpp
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int mod=1e9+7;
const int N=2e5+10;
int xx[510],yy[510],x[510],y[510];
void solve() {
int n;
cin >> n;
map<pair<int,int>,int> mp;
for (int i=0;i<n;i++) {
cin >> xx[i] >> yy[i];
cin >> x[i] >> y[i];
int dx=x[i]-xx[i],dy=y[i]-yy[i];
int d=__gcd(abs(dx),abs(dy));
计算点数
dx/=d,dy/=d;
从起点到终点,共有d+1个点,但是只需要走d步,故而是各自除以d得到每一步所走的长度,而非d+1,
for (int j=0;;j++) {
int xt=xx[i]+j*dx,yt=yy[i]+j*dy;
mp[{xt,yt}]++;
if (xt==x[i] && yt==y[i]) {
break;
}
}
}
int ans=0;
for (auto it=mp.begin();it!=mp.end();it++) {
if (it->second>=2) {
ans++;
}
}
使用迭代器遍历整个map
cout << ans << endl;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
int t=1;
// cin>>t;
while(t--)
solve();
}
数位翻转
题目描述
小明创造了一个函数 f(x) 用来翻转 x 的二进制的数位(无前导 0)。比如 f(11)=13,因为 11=(1011)2,将其左右翻转后,变为 13=(1101)2;再比如 f(3)=3,f(0)=0,f(2)=f(4)=f(8)=1 等等。
小明随机出了一个长度为 n 的整数数组 {a1,a2,⋯,an},他想知道,在这个数组中选择最多 m 个不相交的区间,将这些区间内的数进行二进制数位翻转(将 ai 变为 f(ai))后,整个数组的和最大是多少?
思路:
本题题目范围是n,m都小于1000,这是个提示点,意味着我们可以通过n方时间复杂度的思路来解决
我们仔细观察一下,发现如果拿翻转后的数和翻转前的数对应相减的话,可以得到一个贡献数组。例如:
cpp
翻转前:
11 12 13 14 15
翻转后:
13 3 11 7 15
贡献序列:
2 -9 -2 -7 0
此时思路就明朗了,只需要找x段最大的贡献子序列即可,注意不一定要找够m段,比如贡献序列如果全是负数的话就一段都不需要。
如何找呢?
暴力肯定不行,我们此时就能想到动态规划。我感觉我讲的不是很清楚,所以我把我思路发给AI让AI帮我润色了一下,下面是AI润色后的内容:
一、为什么这题要用 DP?
1.1 先看暴力做法的问题
如果我们暴力枚举所有方案:
-
每个数有选/不选两种可能
-
n 个数有 2^n 种组合
-
n=1000 时,2^1000 完全不可计算
1.2 问题具有最优子结构
观察这个问题:
cpp
考虑前 i 个数,选 j 段的最优解
可以由:前 i-1 个数的最优解 + 第 i 个数的决策 得到
这就是动态规划的核心:大问题的最优解包含小问题的最优解。
1.3 问题具有重叠子问题
计算时,会反复用到"前 i 个数,选 j 段"的结果,DP 可以避免重复计算。
二、如何想到 DP 状态设计?
2.1 第一步:确定要记录什么信息
我们要做决策:每个数选还是不选?
但仅仅这样不够,因为还需要知道:
-
当前选了几个区间了?(不能超过 m)
-
上一个数选了没有?(决定是否要新开区间)
所以需要记录:
-
位置 i:考虑到第几个数
-
区间数 j:已经选了几个区间
-
上个数状态:上一个数选了没有
2.2 第二步:尝试一维 DP(失败)
最初想法:dp[i] = 前 i 个数的最大贡献
但这样无法知道选了几个区间,可能选多了。
2.3 第三步:加一维记录区间数
cpp
dp[i][j] // 前 i 个数,选 j 个区间的最大贡献
但还是有问题:不知道第 i 个数选了没有,无法决定第 i+1 个怎么选。
2.4 第四步:再加一维记录当前状态
cpp
dp[i][j][0] // 前 i 个数,选 j 个区间,第 i 个数【不选】
dp[i][j][1] // 前 i 个数,选 j 个区间,第 i 个数【选】
这样就完美了!
三、DP 数组的构建过程
3.1 初始化
cpp
// 0 个数,选 0 段,无论第 0 个状态如何,贡献都是 0
for (int j = 0; j <= m; j++) {
dp[0][j][0] = 0;
dp[0][j][1] = 0; // 没有数,不可能选,设为 0 不影响后续
}
3.2 填表顺序
cpp
for (int i = 1; i <= n; i++) { // 外层:逐个考虑每个数
for (int j = 1; j <= m; j++) { // 内层:枚举区间数
// 计算 dp[i][j][0] 和 dp[i][j][1]
}
}
为什么这样填?
-
dp[i]只依赖dp[i-1],所以 i 从小到大 -
j 的依赖关系稍微复杂,但从小到大填没问题
3.3 推导转移方程
对于 dp[i][j][0](第 i 个不选):
cpp
前 i 个数,选 j 段,第 i 个不选
↓
等于前 i-1 个数,选 j 段的最优情况
↓
max(dp[i-1][j][0], dp[i-1][j][1])
为什么段数不变?因为第 i 个不选,不产生新段。
图示理解:
cpp
情况1:[ ... ✓ ] ✗ ← 第 i-1 选,第 i 不选
情况2:[ ... ✗ ] ✗ ← 第 i-1 也不选
↑前i-1个,j段 ↑第i个
对于 dp[i][j][1](第 i 个选):
需要分两种情况:
情况A:第 i 个新开一段
cpp
前 i-1 个数,选 j-1 段,第 i-1 不选
+
第 i 个数作为第 j 段
= dp[i-1][j-1][0] + b[i]
为什么第 i-1 必须不选?
-
如果第 i-1 选了,第 i 个又选,它们就连在一起了
-
连在一起就是同一段,不会增加段数
图示:
cpp
[ ... ] ✗ | ✓
↑前i-1个 ↑第i个(新段)
段数 j-1 段数变成 j
情况B:第 i 个接在后面
cpp
前 i-1 个数,选 j 段,第 i-1 选
+
第 i 个数接上
= dp[i-1][j][1] + b[i]
为什么段数不变?
-
第 i-1 已经选了,第 i 接在后面
-
它们属于同一段
图示:
cpp
[ ... ✓ ✓
↑前i-1个 ↑第i个
段数 j 段数还是 j
取两种情况的最大值:
cpp
dp[i][j][1] = max(dp[i-1][j-1][0], dp[i-1][j][1]) + b[i];
四、完整填表模拟
假设 b = [2, -9, -2], m = 2
初始化
cpp
dp[0][*][*] = 0
i=1, b1=2
cpp
dp[1][1][0] = max(dp[0][1][0], dp[0][1][1]) = 0
dp[1][1][1] = max(dp[0][0][0], dp[0][1][1]) + 2 = 0+2 = 2
dp[1][2][0] = 0
dp[1][2][1] = max(dp[0][1][0], dp[0][2][1]) + 2 = 0+2 = 2
理解:
-
dp[1][1][1]=2:选第1个数,占1段,贡献2 ✓ -
dp[1][2][1]=2:选第1个数,占2段?(实际不可能,但数值上不影响)
i=2, b2=-9
cpp
dp[2][1][0] = max(dp[1][1][0], dp[1][1][1])
= max(0, 2) = 2
→ 不选第2个,保留前面选第1个的贡献
cpp
dp[2][1][1] = max(dp[1][0][0], dp[1][1][1]) + (-9)
= max(0, 2) + (-9) = -7
→ 选第2个,且延续第1段:2-9=-7
cpp
dp[2][2][1] = max(dp[1][1][0], dp[1][2][1]) + (-9)
= max(0, 2) + (-9) = -7
→ 不选第2个,保持前面(虽然前面选了2段是不可能的,但值不影响)
cpp
dp[2][2][1] = max(dp[1][1][0], dp[1][2][1]) + (-9)
= max(0, 2) + (-9) = -7
→ 新开第2段:前1个选1段且第1个不选(0) + (-9) = -9
→ 延续:前1个选2段且第1个选(2) + (-9) = -7
→ 取 max = -7
i=3, b3=-2
cpp
dp[3][1][0] = max(dp[2][1][0], dp[2][1][1])
= max(2, -7) = 2
→ 不选第3个,保持最优(只选第1个)
cpp
dp[3][1][1] = max(dp[2][0][0], dp[2][1][1]) + (-2)
= max(0, -7) + (-2) = -2
→ 延续第1段:-7-2=-9;新开:0-2=-2;取-2
cpp
dp[3][2][0] = max(dp[2][2][0], dp[2][2][1]) = max(2, -7) = 2
→ 不选第3个,保持前面
cpp
dp[3][2][1] = max(dp[2][1][0], dp[2][2][1]) + (-2)
= max(2, -7) + (-2) = 0
→ 新开第2段:dp210=2(前2个选1段最优:只选第1个)
→ 2 + (-2) = 0
这个状态的含义:
-
前3个数,选2段,第3个选
-
方案:选第1个(段1)→ 跳过第2个 → 选第3个(段2)
-
贡献:2 + 0 + (-2) = 0
五、最终答案
这就是我上面说到的不一定要找够m段,比如贡献序列如果全是负数的话就一段也不需要。
cpp
int mx = 0;
for (int j = 1; j <= m; j++) {
mx = max({mx, dp[n][j][0], dp[n][j][1]});
}
cout << sum + mx << endl;
遍历所有可能的段数 j,取最大值。
六、DP 设计的核心思想
| 步骤 | 思考 |
|---|---|
| 1 | 确定决策:每个数选/不选 |
| 2 | 发现需要记录:位置、段数、上个数状态 |
| 3 | 设计状态:dp[i][j][0/1] |
| 4 | 推导转移:不选→继承;选→新开或延续 |
| 5 | 确定顺序:i 从小到大,j 从小到大 |
| 6 | 初始化:dp0**=0 |
| 7 | 最终答案:遍历所有可能状态取最大 |
代码:
cpp
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int mod=1e9+7;
const int N=2e5+10;
int n,m;
int a[1010];
int dp[1010][1010][2];
int fz(int x) {
int res=0;
while(x) {
res=(res<<1)+(x&1);
x>>=1;
}
return res;
}
void solve() {
cin >> n >> m;
int sum=0;
for(int i=1;i<=n;i++) cin >> a[i];
for(int i=1;i<=n;i++) {
sum+=a[i];
a[i]=fz(a[i])-a[i];
}
for(int i=1;i<=n;i++) {
for(int j=1;j<=m;j++) {
dp[i][j][0]=max(dp[i-1][j][0],dp[i-1][j][1]);
dp[i][j][1]=max(dp[i-1][j-1][0],dp[i-1][j][1])+a[i];
}
}
int mx = 0;
for (int j = 1; j <= m; j++) {
mx = max({mx, dp[n][j][0], dp[n][j][1]});
}
cout << sum + mx << endl;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
int t=1;
// cin>>t;
while(t--)
solve();
}
最小字符串
题目:
给你俩字符串ab,a是顺序不可变等着被插入的,b是顺序可变的,现在让你把b里的全部字符都插到a里面,问你怎么插字典序最小,输出这个字典序最小的字符串。
思路:
排序+双指针
对b进行排序,然后拿两个指针分别指向a和b的头,然后进行比较,若a<=b则输出a指针对应的字符,否则输出b指针对应的字符
代码:
cpp
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
void solve() {
int n,m;
cin >> n >> m;
string s1,s2;
cin >> s1 >> s2;
sort(s2.begin(),s2.end());
for(int i=0,j=0;i<s1.size()||j<s2.size();) {
if (i>=s1.size()||j>=s2.size()) {
if (i>=s1.size()&&j>=s2.size()) {
break;
}
if (i>=s1.size()) {
cout << s2[j];
j++;
}else {
cout << s1[i];
i++;
}
}
else {
if (s1[i]<=s2[j]) {
cout << s1[i] ;
i++;
}else {
cout << s2[j] ;
j++;
}
}
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
int t = 1;
while(t--)
solve();
return 0;
}
立定跳远
题目:
给定n个非递减的点,小明从0开始往这些点上跳,小明自己也可以在随意增加放置m个点,现在让你确定小明每一跳的最远距离L的最小值,保证此时小明能完整的跳过这些点,注意小明有个大招,释放大招后他的最长跳跃距离变成2L,但是大招只能使用一次
思路:
思路还是很明显的:二分答案,关键就是check函数怎么写
可以看我之前写的一篇介绍二分的文章,这个题目的二分类型是最大值最小
最大值最小:答案越大越符合条件,题目要求输出符合条件的最小值,看下图标蓝的就是最大值中的最小值。
最大值最小:我们要使得当前二分得到的mid是最大值,所以就要根据题目条件来让mid成为最大值,看看根据已知条件能否使得它成为最大值,如果能就return true,可以粗略想为值越大越符合条件,我们要找的是符合条件当中的最小值;

cpp
bool check(int x) {
int cnt=0;
if (a[0]>x)cnt+=(a[0]+x-1)/x-1;
for (int i=1;i<n;i++) {
if (a[i]-a[i-1]>x)cnt+=(a[i]-a[i-1]+x-1)/x-1;
}
return cnt>m?0:1;
}
计算当前二分到的x时所需要增加的检查点个数,大于m就不符合要求返回0,否则返回1
每两个点之间需要的增加的检查点个数是**⌈dis/L⌉−1**
上取整:a/b上取整等于(a+b-1)/b
注意:使用技能就相当于多一次随意增加跳跃点的机会
可以粗略的理解一下:只有两点间隔<=2L的情况下使用技能才是有效的,而此时增加一个检查点也可以达到同样效果;对于>=2L的情况下,增加一个检查点和使用大招都跳不过去,效果也是一样的。故而使用技能就相当于多一次随意增加跳跃点的机会
代码:
cpp
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int mod=1e9+7;
const int N=2e5+10;
int a[N];
int n,m;
bool check(int x) {
int cnt=0;
if (a[0]>x)cnt+=(a[0]+x-1)/x-1;
for (int i=1;i<n;i++) {
if (a[i]-a[i-1]>x)cnt+=(a[i]-a[i-1]+x-1)/x-1;
}
return cnt>m?0:1;
}
void solve() {
cin >> n >> m;
m++;
for(int i=0;i<n;i++) {
cin >> a[i];
}
int l=0,r=a[n-1]-a[0];
while (l+1!=r) {
int mid=(l+r)>>1;
if (check(mid))r=mid;
else l=mid;
}
cout << r << endl;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
int t=1;
// cin>>t;
while(t--)
solve();
}