动态规划基础
动态规划(Dynamic Programming,简称 DP)是算法竞赛中最核心、最常考查的思想之一,DP 的本质是将一个复杂的大问题拆解为若干个重叠的子问题,通过保存已解决子问题的答案,避免重复计算,从而大幅降低时间复杂度。
DP 核心概念 —— 三要素
- 阶段:解决问题的步骤与顺序(例如序列的第 \(i\) 个位置、网格的第 \(r\) 行 \(c\) 列)。
- 状态:用来描述某个阶段下子问题解的情况(通常用数组表示,如 \(f_i\) 或 \(f_{i,j}\))。
- 决策与转移:从已知的子问题状态推导到当前状态的操作,表达为状态转移方程。
DP 适用的“两大性质”
- 最优子结构:大问题的最优解可以由子问题的最优解直接推导出来。
- 无后效性:一旦某个状态的值被计算出来,它之后如何被使用不影响其已定的数值,即“未来不影响过去,只依赖现在”。
方案数问题



2025 CSP-S1 T14
斐波那契数列的定义为 \(F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)\)。使用朴素递归方法计算 \(F(n)\) 的时间复杂度是指数级的。而使用动态规划(或迭代)方法的时间复杂度是线性的。造成这种巨大差异的根本原因是?
- A. 递归函数调用栈开销过大
- B. 操作系统对递归深度有限制
- C. 朴素递归中存在大量的重叠子问题未被重复利用
- D. 动态规划使用了更少的数据存储空间
答案
C
在利用 \(F(n)=F(n-1)+F(n-2)\) 计算时,计算 \(F(n-1)\) 的过程中会展开包含 \(F(n-2),F(n-3)\) 等项的完整子树。紧接着,程序又会去独立计算等式右侧的 \(F(n-2)\),导致 \(F(n-2)\) 的整棵计算树被原封不动地重新计算了一次,这种无记忆的“树状展开”会产生极其庞大的重复计算。随着 \(n\) 的增大,递归树的节点总数呈指数级增长,其时间复杂度约为 \(O(1.618^n)\)。
动态规划的核心思想是“记忆”,它会将每一个已经计算出来的子问题结果 \(F(i)\) 存储下来(例如存在数组里)。当后续计算再次需要用到 \(F(i)\) 时,直接以 \(O(1)\) 的时间读取已知结果,从而瞬间剪去整棵庞大的递归子树。因为从 \(F(2)\) 到 \(F(n)\),每个状态实际上只被真正计算了一次,所以时间复杂度骤降为 \(O(n)\)。
例题:P1002 [NOIP2002 普及组] 过河卒

参考代码
#include <cstdio>
typedef long long LL;
const int N = 25;
int dx[8] = {-2, -2, -1, -1, 1, 1, 2, 2};
int dy[8] = {-1, 1, -2, 2, -2, 2, -1, 1};
bool control[N][N];
LL dp[N][N];
int main()
{
int n, m, x, y;
scanf("%d%d%d%d", &n, &m, &x, &y);
for (int i = 0; i < 8; i++) {
int xx = x + dx[i], yy = y + dy[i];
if (xx >= 0 && xx <= n && yy >= 0 && yy <= m) control[xx][yy] = true;
}
control[x][y] = true;
for (int i = 0; i <= m; i++) {
if (control[0][i]) break;
dp[0][i] = 1;
}
for (int i = 0; i <= n; i++) {
if (control[i][0]) break;
dp[i][0] = 1;
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (control[i][j]) dp[i][j] = 0;
else dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
printf("%lld\n", dp[n][m]);
return 0;
}
习题:P1644 跳马问题
在一个半张中国象棋棋盘中,左下角坐标为 \((0,0)\),右上角坐标为 \((m,n) \ (1 \le n,m \le 18)\)。棋子“马”从起点 \((0,0)\) 出发,目标是到达终点 \((m,n)\)。移动规则:
- 遵循中国象棋中“马走日”的规则。
- 规定只能往右跳。
求出从起点到达终点的合法路径方案总数。
解题思路
定义 \(f_{x,y}\) 表示从起点 \((0,0)\) 出发,到达坐标 \((x,y)\) 的路径方案总数。考虑哪些前置节点 \((x',y')\) 可以一步跳到当前节点 \((x,y)\),得到转移方程 \(f_{x,y} = \sum f_{x',y'}\)。
起点本身算 \(1\) 种到达方案,所以 \(f_{0,0}=1\)。在考虑前置节点 \((x',y')\) 时,必须满足 \(0 \le x' \le m\) 且 \(0 \le y' \le n\)。
因为转移依赖于更小的横坐标 \(x' \lt x\),所以按 \(x\) 从 \(1\) 到 \(m\) 升序外层循环计算,内层循环遍历纵坐标 \(y\) 从 \(0\) 到 \(n\)。
参考代码
#include <iostream>
using namespace std;
const int N = 20;
// 反向偏移数组:用来寻找能一步跳到 (x, y) 的前置节点 (nx, ny)
const int DX[4] = {-1, -1, -2, -2};
const int DY[4] = {-2, 2, -1, 1};
int dp[N][N]; // dp[x][y] 表示到达 (x, y) 的方案数
int main()
{
int n, m; cin >> n >> m;
// 起点初始化
dp[0][0] = 1;
// 按横坐标 x 从 1 到 m 顺序递推
for (int x = 1; x <= m; x++) {
for (int y = 0; y <= n; y++) {
// 尝试从 4 个可能的来源位置进行转移
for (int i = 0; i < 4; i++) {
int nx = x + DX[i], ny = y + DY[i];
// 边界合法性检查
if (nx >= 0 && ny >= 0 && ny <= n) {
dp[x][y] += dp[nx][ny];
}
}
}
}
// 输出到达终点 (m, n) 的总方案数
cout << dp[m][n] << "\n";
return 0;
}
外层循环遍历横坐标 \(x \in [1,m]\),中层循环遍历纵坐标 \(y \in [0,n]\),内层执行常数次(\(4\) 次)转移检查,总时间复杂度为 \(O(m \times n)\)。
例题:P1057 [NOIP2008 普及组] 传球游戏

当想清楚状态转移方程并使用递推方式去实现时,通常有两种写法:
- 填表法:枚举未知量,由之前的已知量计算出当前要求的未知量,就好像在填表格的一个一个空格一样。
- 刷表法:枚举已知量,并且根据这个已知量去更新依赖于它的未知量状态,这个未知量可以是前面的,也可以是后面的。
参考代码(填表法)
#include <cstdio>
const int N = 35;
int dp[N][N];
int main()
{
int n, m;
scanf("%d%d", &n, &m);
dp[0][1] = 1;
for (int i = 1; i <= m; i++) {
dp[i][1] = dp[i - 1][n] + dp[i - 1][2];
dp[i][n] = dp[i - 1][n - 1] + dp[i - 1][1];
for (int j = 2; j < n; j++)
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j + 1];
}
printf("%d\n", dp[m][1]);
return 0;
}
参考代码(刷表法)
#include <cstdio>
const int N = 35;
int dp[N][N];
int main()
{
int n, m;
scanf("%d%d", &n, &m);
dp[0][1] = 1;
for (int i = 0; i < m; i++) {
for (int j = 1; j <= n; j++) {
int l = (j == 1 ? n : j - 1);
dp[i + 1][l] += dp[i][j];
int r = (j == n ? 1 : j + 1);
dp[i + 1][r] += dp[i][j];
}
}
printf("%d\n", dp[m][1]);
return 0;
}
习题:P1077 [NOIP2012 普及组] 摆花
解题思路

#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 105;
const int MOD = 1000007;
int a[N], dp[N][N], sum[N];
int getsum(int l, int r) {
return l > 0 ? (sum[r] + MOD - sum[l - 1]) % MOD : sum[r];
}
int main()
{
int n, m;
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 0; i <= m; i++) sum[i] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++)
dp[i][j] = getsum(max(0, j - a[i]), j);
sum[0] = dp[i][0];
for (int j = 1; j <= m; j++) sum[j] = (sum[j - 1] + dp[i][j]) % MOD;
}
printf("%d\n", dp[n][m]);
return 0;
}
最优解问题

例题:P1216 [USACO1.5] [IOI1994]数字三角形 Number Triangles




参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 1005;
int a[N][N], dp[N][N];
int main()
{
int r;
scanf("%d", &r);
for (int i = 1; i <= r; i++)
for (int j = 1; j <= i; j++)
scanf("%d", &a[i][j]);
dp[1][1] = a[1][1];
for (int i = 2; i <= r; i++) {
dp[i][1] = dp[i - 1][1] + a[i][1];
dp[i][i] = dp[i - 1][i - 1] + a[i][i];
for (int j = 2; j < i; j++)
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1]) + a[i][j];
}
int ans = 0;
for (int i = 1; i <= r; i++) ans = max(ans, dp[r][i]);
printf("%d\n", ans);
return 0;
}
习题:P1387 最大正方形
在一个大小为 \(n \times m \ (1 \le n,m \le 100)\) 的包含 \(0\) 和 \(1\) 的矩阵中,寻找一个全由 \(1\) 组成的边长最大的正方形,并输出该正方形的边长,保证矩阵中至少包含一个 \(1\)。
解题思路
如果采用暴力枚举法,首先枚举正方形的左上角坐标 \((i,j)\),再枚举边长 \(L\),最后遍历 \(L \times L\) 的区域校验是否全为 \(1\),这种暴力做法的时间复杂度为 \(O(n \cdot m \cdot \min(n, m)^2)\)。
为了解决“快速校验某个 \(L \times L\) 的区域是否全为 \(1\)”的问题,可以引入二维前缀和进行优化,这样可以将时间复杂度降到 \(O(n \cdot m \cdot \min(n, m))\)。更进一步,由于正方形的可行边长具有单调性(如果存在边长为 \(L\) 的全 \(1\) 正方形,则必然存在边长更小的全 \(1\) 正方形),因此可以在 \([1, \min(n, m)]\) 范围内对边长 \(L\) 进行二分查找,整体复杂度降至 \(O(n \cdot m \log(\min(n, m)))\)。
尽管“二维前缀和 + 二分边长”已经将效率大幅提升,但使用动态规划可以直接将总时间复杂度优化至极致的 \(O(n \times m)\)。
考虑以 \((i,j)\) 作为右下角顶点的所有正方形:若格子 \((i,j)\) 本身的值为 \(0\),则不可能构成任何全 \(1\) 正方形;若格子 \((i,j)\) 的值为 \(1\),其能扩展出的最大正方形边长受制于它的三个邻居状态。假设以 \((i,j)\) 为右下角最大是长度为 \(k\) 的正方形,必须要求其上侧、左侧、左上侧各存在至少长度为 \(k-1\) 的正方形。因此,这三个方向能构成的最大边长的最小值再加上自身长度 \(1\),即为以 \((i,j)\) 为右下角能构成的最大正方形边长。
参考代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 105;
int a[N][N]; // 原矩阵,存储 0 和 1
int f[N][N]; // f[i][j] 表示以 (i, j) 为右下角的最大全 1 正方形边长
int main()
{
int n, m; cin >> n >> m;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> a[i][j];
}
}
int ans = 0; // 记录全局最大的正方形边长
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
// 只有当前格子为 1 时才有可能扩展为正方形
if (a[i][j] == 1) {
// 状态转移:受限于上方、左方和左上方能提供的最小正方形边长
f[i][j] = min({f[i - 1][j], f[i][j - 1], f[i - 1][j - 1]}) + 1;
ans = max(ans, f[i][j]);
}
}
}
cout << ans << "\n";
return 0;
}
例题:P1113 杂务
为了让每个任务尽早完成,应该让其在所有准备工作完成后立马开始,也就是跟在所有准备工作中最晚结束的那个后面。如果设 \(dp_i\) 表示任务 \(i\) 的最早完成时间,则 \(dp_i = \max\{dp_{pre}\} + len_i\),这里的 \(pre\) 是每一个准备工作,注意题目描述“杂务 \(k\) 的准备工作只可能在杂务 \(1\) 至 \(k-1\) 中”,因此每一个 \(dp_j\) 一定是在 \(dp_i\) 计算时已经确定的结果。
参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 1e4 + 5;
int dp[N];
int main()
{
int n; scanf("%d", &n);
int ans = 0;
for (int i = 1; i <= n; i++) {
int id, len; scanf("%d%d", &id, &len);
while (true) {
int pre; scanf("%d", &pre); // 读入准备工作
if (pre == 0) break;
dp[i] = max(dp[i], dp[pre]);
}
dp[i] += len;
ans = max(ans, dp[i]); // 完成所有杂务的最短时间是每个任务完成时间中的最大值
}
printf("%d\n", ans);
return 0;
}
例题:P2196 [NOIP1996 提高组] 挖地雷
设 \(dp_i\) 表示从某处开始挖地雷一直挖到第 \(i\) 个地窖能挖到的最多的地雷,则类似上一题,有 \(dp_i = \max \{ dp_{pre} \} + a_i\),这里的 \(a_i\) 指的是第 \(i\) 个地窖处的地雷数,\(pre\) 指的是所有能够连接到地窖 \(i\) 的地窖。由于输入的格式保证了一定是编号小的地窖连接到编号大的地窖,因此计算顺序可以是 \(1 \rightarrow n\)。并且输入的方式是给定每个地窖可以连向后面的某些地窖,因此可以考虑写成刷表法的形式。
注意,本题还要输出挖地雷的顺序,前面的计算过程并没有考虑最优方案对应的路径。
实际上,这只需要在更新 \(dp_i\) 时顺便记录一下转移来源即可(\(dp_i\) 的最大值是在哪个 \(pre\) 那里取到的)。
参考代码
#include <cstdio>
const int N = 25;
int a[N], dp[N], from[N], path[N];
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
int ans = 0, pos = 0;
for (int i = 1; i <= n - 1; i++) {
dp[i] += a[i];
if (dp[i] > ans) {
ans = dp[i]; pos = i;
}
for (int j = i + 1; j <= n; j++) {
int x; scanf("%d", &x);
if (x == 1) {
// i -> j
if (dp[i] > dp[j]) {
dp[j] = dp[i]; from[j] = i;
}
}
}
}
dp[n] += a[n];
if (dp[n] > ans) {
ans = dp[n]; pos = n;
}
int cnt = 0;
while (pos != 0) {
path[++cnt] = pos; pos = from[pos];
}
for (int i = cnt; i >= 1; i--) printf("%d ", path[i]);
printf("\n%d\n", ans);
return 0;
}
例题:P1006 [NOIP2008 提高组] 传纸条



参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 55;
int dp[N][N][N][N], a[N][N];
int main()
{
int m, n;
scanf("%d%d", &m, &n);
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
scanf("%d", &a[i][j]);
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
for (int k = 1; k <= m; k++)
for (int l = 1; l <= n; l++)
dp[i][j][k][l] = -1;
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
for (int k = 1; k <= m; k++) {
int l = i + j - k;
if (i * j * k * l == 1) dp[i][j][k][l] = a[1][1];
else {
// (i-1,j) (i,j-1)
// (k-1,l) (k,l-1)
int tmp = -1;
if (i > 1 && k > 1)
tmp = max(tmp, dp[i - 1][j][k - 1][l]);
if (i > 1 && l > 1)
tmp = max(tmp, dp[i - 1][j][k][l - 1]);
if (j > 1 && k > 1)
tmp = max(tmp, dp[i][j - 1][k - 1][l]);
if (j > 1 && l > 1)
tmp = max(tmp, dp[i][j - 1][k][l - 1]);
if (tmp == -1) dp[i][j][k][l] = -1;
else dp[i][j][k][l] = tmp + a[i][j] + a[k][l];
if (i == k && j == l && (i != m || j != n))
dp[i][j][k][l] = -1;
}
}
printf("%d\n", dp[m][n][m][n]);
return 0;
}
例题:P7074 [CSP-J2020] 方格取数


参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long LL;
const int N = 1005;
const LL INF = 1e11;
int a[N][N];
LL dp[N][N][3]; // 0: from up, 1 from down, 2 from left
int main()
{
int n, m;
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) {
scanf("%d", &a[i][j]);
dp[i][j][0] = dp[i][j][1] = dp[i][j][2] = -INF;
}
dp[1][1][0] = dp[1][1][1] = dp[1][1][2] = a[1][1];
for (int i = 2; i <= n; i++) dp[i][1][0] = dp[i - 1][1][0] + a[i][1];
for (int j = 2; j <= m; j++) {
for (int i = 1; i <= n; i++) {
LL from = max(dp[i][j - 1][0], max(dp[i][j - 1][1], dp[i][j - 1][2]));
if (from != -INF) dp[i][j][2] = from + a[i][j];
}
for (int i = 2; i <= n; i++) {
LL from = max(dp[i - 1][j][0], dp[i - 1][j][2]);
if (from != -INF) dp[i][j][0] = from + a[i][j];
}
for (int i = n - 1; i >= 1; i--) {
LL from = max(dp[i + 1][j][1], dp[i + 1][j][2]);
if (from != -INF) dp[i][j][1] = from + a[i][j];
}
}
printf("%lld\n", max(dp[n][m][0], max(dp[n][m][1], dp[n][m][2])));
return 0;
}
习题:P8816 [CSP-J 2022] 上升点列
解题思路
这个题的状态其实不难想,设 \(dp_{i,j}\) 表示考虑到了第 \(i\) 个点,添加了 \(j\) 个整点时的最优解。
为了能够写出状态转移方程,强制 \(dp_{i,j}\) 表示必须选上第 \(i\) 个点的情况,这样可以从前边枚举一个 \(pre\),然后从 \(dp_{pre,j-add}\) 转移到 \(dp_{i,j}\),其中 \(add\) 表示需要增加的整点数。
那么就需要让整数点以 \(x\) 为第一关键字从小到大排序,\(x\) 相同时以 \(y\) 为第二关键字从小到大排序,在枚举 \(pre\) 时,计算如果从 \(pre\) 到 \(i\) 需要补充的整点数 \(add\),当 \(pre\) 和 \(i\) 的坐标满足相应的大小关系且 \(j \ge add\) 的时候才能转移。
时间复杂度为 \(O(n^2 k)\)。
#include <cstdio>
#include <algorithm>
using std::max;
using std::sort;
const int N = 505;
const int K = 105;
struct Point {
int x, y;
bool operator<(const Point& p) const {
return x != p.x ? x < p.x : y < p.y;
}
};
Point a[N];
int dp[N][K];
int main()
{
int n, k;
scanf("%d%d", &n, &k);
for (int i = 1; i <= n; i++) scanf("%d%d", &a[i].x, &a[i].y);
for (int i = 1; i <= n; i++)
for (int j = 0; j <= k; j++)
dp[i][j] = j + 1;
sort(a + 1, a + n + 1);
int ans = k + 1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= k; j++) {
for (int pre = 1; pre < i; pre++) {
if (a[pre].x <= a[i].x && a[pre].y <= a[i].y) {
int add = a[i].x - a[pre].x + a[i].y - a[pre].y - 1;
if (j >= add) {
dp[i][j] = max(dp[i][j], dp[pre][j - add] + add + 1);
ans = max(ans, dp[i][j]);
}
}
}
}
}
printf("%d\n", ans);
return 0;
}
习题:P3842 [TJOI2007] 线段
解题思路
显然每一行走完会留在左端点或右端点。
所以设 \(dp_{i,0/1}\) 分别表示走完第 \(i\) 行的线段,留在其左端点或右端点时,路径的最小长度。
转移时枚举是从上一行的哪个端点走过来的,合理的走法应该是先往下走一步,然后往最终要留的端点的另一侧端点方向走,然后再走整个线段的长度走到最终要留在的端点。以上一行的线段左端点走到当前行的线段左端点为例,应该是先走一步向下,然后奔向当前行线段的右端点,最后走过整个当前行线段留在左端点处。
注意初始化 \(dp_{1, 0/1}\),最终计算答案时因为最后一行的线段的左右端点不一定是 \((n,n)\),还要考虑加上到 \((n,n)\) 的距离。
#include <cstdio>
#include <algorithm>
using std::min;
const int N = 2e4 + 5;
const int INF = 1e9;
int l[N], r[N], dp[N][2];
// dp[][0] 留在右端点, dp[][1] 留在左端点
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d%d", &l[i], &r[i]);
dp[i][0] = dp[i][1] = INF;
}
dp[1][0] = r[1] - 1; dp[1][1] = r[1] - 1 + (r[1] - l[1]);
for (int i = 2; i <= n; i++) {
dp[i][0] = min(dp[i][0], dp[i - 1][0] + abs(l[i] - r[i - 1]) + r[i] - l[i] + 1);
dp[i][0] = min(dp[i][0], dp[i - 1][1] + abs(l[i] - l[i - 1]) + r[i] - l[i] + 1);
dp[i][1] = min(dp[i][1], dp[i - 1][0] + abs(r[i] - r[i - 1]) + r[i] - l[i] + 1);
dp[i][1] = min(dp[i][1], dp[i - 1][1] + abs(r[i] - l[i - 1]) + r[i] - l[i] + 1);
}
printf("%d\n", min(dp[n][0] + n - r[n], dp[n][1] + n - l[n]));
return 0;
}
习题:P1541 [NOIP2010 提高组] 乌龟棋
解题思路
设 \(dp_{i,j,k,l}\) 表示四种卡片分别使用了 \(i,j,k,l\) 张时能够获得的最大分值。为什么位置不用作为状态的一部分呢?实际上,当四张卡片的用量已知时,所处的格子位置是个定值。
那么此时只需要考虑最近一次用的卡片是哪一张即可,有四种决策策略,取最优的方案。
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 355;
const int A = 45;
int a[N], dp[A][A][A][A], cnt[5];
int main()
{
int n, m;
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= m; i++) {
int b; scanf("%d", &b);
cnt[b]++;
}
dp[0][0][0][0] = a[1];
for (int i = 0; i <= cnt[1]; i++)
for (int j = 0; j <= cnt[2]; j++)
for (int k = 0; k <= cnt[3]; k++)
for (int l = 0; l <= cnt[4]; l++) {
int cur = i + 2 * j + 3 * k + 4 * l + 1;
int tmp = 0;
if (i > 0) tmp = max(tmp, dp[i - 1][j][k][l]);
if (j > 0) tmp = max(tmp, dp[i][j - 1][k][l]);
if (k > 0) tmp = max(tmp, dp[i][j][k - 1][l]);
if (l > 0) tmp = max(tmp, dp[i][j][k][l - 1]);
dp[i][j][k][l] = tmp + a[cur];
}
printf("%d\n", dp[cnt[1]][cnt[2]][cnt[3]][cnt[4]]);
return 0;
}
例题:P1434 [SHOI2002] 滑雪
设 \(dp_{i,j}\) 表示从某个起点处一直滑雪到 \((i,j)\) 处的最长滑雪长度。则有 \(dp_{i,j} = \max\{dp_{neighbor}\}\) + 1,这里的 \(neighbor\) 指的是四个相邻位置,并且需要满足相邻位置的高度高于当前位置 \((i,j)\)。
这样就发现一个问题,计算顺序如何确定?如何保证计算某个位置时其四个相邻位置中可以转移过来的位置的结果已经计算完成。
实际上如果写成记忆化搜索的形式,就可以减少对计算顺序的思考。在记忆化搜索的模式下,必然可以保证计算顺序。
参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 105;
int r, c, a[N][N], dp[N][N];
int dx[4] = {-1, 0, 0, 1};
int dy[4] = {0, -1, 1, 0};
void dfs(int x, int y) {
if (dp[x][y] != 0) return;
for (int i = 0; i < 4; ++i) {
int xx = x + dx[i];
int yy = y + dy[i];
if (xx >= 1 && xx <= r && yy >= 1 && yy <= c && a[x][y] < a[xx][yy]) {
dfs(xx, yy);
dp[x][y] = max(dp[x][y], dp[xx][yy]);
}
}
dp[x][y]++; // 延伸一次滑雪
// printf("dp[%d][%d] = %d\n", x, y, dp[x][y]);
}
int main()
{
scanf("%d%d", &r, &c);
for (int i = 1; i <= r; i++)
for (int j = 1; j <= c; j++)
scanf("%d", &a[i][j]);
for (int i = 1; i <= r; i++)
for (int j = 1; j <= c; j++)
if (dp[i][j] == 0) dfs(i, j);
int ans = 1;
for (int i = 1; i <= r; i++)
for (int j = 1; j <= c; j++)
ans = max(ans, dp[i][j]);
printf("%d\n", ans);
return 0;
}
例题:SP6562 PRUBALL - Esferas
给定一栋高为 \(M \ (1 \le M \le 1000)\) 层的楼和 \(B \ (1 \le B \le 100)\) 个完全相同的鸡蛋,已知存在一个临界楼层 \(K \ (0 \le K \le M)\):在低于或等于 \(K\) 层的楼层放下鸡蛋,鸡蛋不会碎;在高于 \(K\) 层的楼层放下鸡蛋,鸡蛋会碎。要求在最坏情况下,设计一种最优策略,用最少的测试次数确定临界楼层 \(K\) 的具体数值。
定义 \(f_{b,m}\) 表示拥有 \(b\) 个鸡蛋,面对 \(m\) 层楼的区间时所需的最少测试次数。
假设在第 \(k\) 层楼扔下一个鸡蛋,可能出现两种情况:
- 鸡蛋碎了:说明临界楼层在下方,损失了 \(1\) 个鸡蛋,剩余 \(b-1\) 个鸡蛋需要解决下方的 \(k-1\) 层楼,开销为 \(f_{b-1,k-1}\)。
- 鸡蛋没碎:说明临界楼层在上方,依然拥有 \(b\) 个鸡蛋,但只需解决上方的 \(m-k\) 层楼,开销为 \(f_{b,m-k}\)。
最坏情况取两者最大值,而目标是选择最优的 \(k\),使得最坏情况下的测试次数最少,加上当前消费的 \(1\) 次测试,得到转移方程 \(f_{b,m} = 1 + \min\limits_{1 \le k \le m} \Big( \max(f_{b-1,k-1}, f_{b,m-k}) \Big)\)。
当 \(b=0\) 时,无法继续测试,开销为无穷大。当 \(m=0\) 时,不需要测试,开销为 \(0\)。
预处理状态数量为 \(B \times M\),每个状态转移需要循环枚举测试楼层 \(k \in [1,m]\),总复杂度为 \(O(B \cdot M^2)\)。
参考代码
#include <iostream>
#include <algorithm>
using namespace std;
const int M = 1005;
const int B = 105;
const int INF = (int)1e9;
// f[i][j]: i 个鸡蛋,j 层楼所需的最少测试次数
int f[B][M], g[B][M];
void init() {
// 初始化边界条件
for (int i = 1; i <= 1000; i++) {
f[0][i] = g[0][i] = INF;
}
// 状态转移
for (int i = 1; i <= 100; i++) {
for (int j = 1; j <= 1000; j++) {
int res = INF;
for (int k = 1; k <= j; k++) {
res = min(res, max(f[i - 1][k - 1], f[i][j - k]) + 1);
}
f[i][j] = res;
}
}
}
void solve() {
int id, b, m;
cin >> id >> b >> m;
cout << id << " " << f[b][m] << "\n";
}
int main()
{
init();
int t; cin >> t;
while (t--) {
solve();
}
return 0;
}
为了打破 \(O(M)\) 的区间枚举瓶颈,可以使用状态反转技巧:交换自变量与因变量。
定义 \(f_{m,b}\) 表示在允许测试 \(m\) 次、拥有 \(b\) 个鸡蛋的条件下,最多能够确定的楼层高度。
假设在某一适宜楼层扔下一个鸡蛋:
- 若鸡蛋碎了:消耗 \(1\) 次测试和 \(1\) 个鸡蛋,下方能覆盖 \(f_{m-1,b-1}\) 层。
- 若鸡蛋没碎:消耗 \(1\) 次测试,鸡蛋未碎,上方能覆盖 \(f_{m-1,b}\) 层。
- 加上当前测试的这 \(1\) 层楼。
三个区间紧密相连,最大覆盖楼层直接相加,消除了任何枚举决策:\(f_{m,b} = f_{m-1,b-1} + f_{m-1,b} + 1\)。
预处理完 \(f_{m,b}\) 后,使用二维数组 ans[b][fl] 记录有 b 个鸡蛋、楼层为 fl 时,满足 $f_{m,b} \ge $ fl 的最小 \(m\)。对于每组输入,查询 ans[b][m] 即可实现 \(O(1)\) 快速回答。
参考代码
#include <iostream>
using namespace std;
const int M = 1005;
const int B = 105;
// f[m][b]: m 次测试,b 个鸡蛋最多能确定的楼层数
// ans[b][fl]: b 个鸡蛋,测试 fl 层楼所需的最少测试次数
int f[M][B], ans[B][M];
void init() {
// 预处理 f[m][b]
for (int m = 1; m <= 1000; m++) {
for (int b = 1; b <= 100; b++) {
f[m][b] = f[m - 1][b - 1] + f[m - 1][b] + 1;
}
}
// 将 f[m][b] 的结果映射至 ans[b][fl]
for (int b = 1; b <= 100; b++) {
int pre = 0;
for (int m = 1; m <= 1000; m++) {
int cur = f[m][b];
// 楼层区间 (pre, cur] 内所需的最小测试次数均为 m
for (int fl = pre + 1; fl <= cur; fl++) {
ans[b][fl] = m;
}
pre = cur;
if (pre >= 1000) break; // 覆盖完 1000 层,提前退出
}
}
}
void solve() {
int id, b, m;
cin >> id >> b >> m;
cout << id << " " << ans[b][m] << "\n";
}
int main()
{
init(); // 预处理
int t; cin >> t;
while (t--) {
solve();
}
return 0;
}
2025 CSP-S1 阅读程序 T2
#include <algorithm>
#include <cstdio>
#include <cstring>
#define ll long long
int cnt_broken = 0;
int cnt_check = 0;
int n, k;
inline bool check(int h) {
printf("now check:%d\n", h);
++cnt_check;
if (cnt_broken == 2) {
printf("You have no egg!\n");
return false;
}
if (h >= k) {
++cnt_broken;
return true;
} else {
return false;
}
}
inline bool assert_ans(int h) {
if (h == k) {
printf("You are Right using %d checks\n", cnt_check);
return true;
} else {
printf("Wrong answer!\n");
return false;
}
}
inline void guess1(int n) {
for (int i = 1; i <= n; ++i) {
if (check(i)) {
assert_ans(i);
return;
}
}
}
inline void guess2(int n) {
int w = 0;
for (w = 1; w * (w + 1) / 2 < n; ++w)
;
for (int ti = w, nh = w; ; --ti, nh += ti, nh = std::min(nh, n)) {
if (check(nh)) {
for (int j = nh - ti + 1; j < nh; ++j) {
if (check(j)) {
assert_ans(j);
return;
}
}
assert_ans(nh);
return;
}
}
}
int main() {
scanf("%d%d", &n, &k);
int t;
scanf("%d", &t);
if (t == 1) {
guess1(n);
} else {
guess2(n);
}
return 0;
}
(注意:下述的“猜测数”为调用 check 函数的次数,即 cnt_check 的值;“猜测正确”的含义为 assert_ans 函数 return true 的情况,即执行第 25 行所在分支;所有输入保证 \(1 \le k \le n\))
(1)当输入为 6 5 1 时,猜测次数为 \(5\);当输入 6 5 2 时,猜测次数为 \(3\)。(正确/错误)
(2)不管输入的 \(n\) 和 \(k\) 具体为多少,\(t=2\) 时的猜测数总是小于等于 \(t=1\) 时的猜测数。(正确/错误)
(3)不管 \(t=1\) 或 \(t=2\),程序都一定会猜到正确结果。(正确/错误)
(4)函数 guess1 在运行过程中,cnt_broken 的值最多为?
(5)函数 guess2 在运行过程中,最多使用的猜测数的量级为?
(6)当输入的 \(n=100\) 的时候,代码中 \(t=1\) 和 \(t=2\) 分别需要的猜测次数最多分别为?
程序解析
“丢鸡蛋问题”,设定为拥有 \(2\) 枚鸡蛋。guess1 采用的是最基础的线性顺序查找(即只用 \(1\) 枚鸡蛋从下往上逐层试探),guess2 采用的是 \(2\) 枚鸡蛋的最优策略(基于等差数列的数学规划模型),通过计算初始步长 \(w\),使得最坏情况下的猜测次数最小化。
答案
(1)正确。guess1 采用从 \(1\) 到 \(n\) 的逐层线性扫描策略,总猜测次数为 \(5\)。guess2 采用基于等差数列的 2 蛋最优策略:
- 计算初始步长 \(w\):求解满足 \(\frac{w(w+1)}{2} \ge 6\) 的最小正整数,解得 \(w=3\)。
- 第一轮测试(
ti = 3, nh = 3):执行check(3)\(\rightarrow\) 返回false(第 \(1\) 次猜测)。 - 第二轮测试(更新步长
ti = 2, nh = 3 + 2 = 5):执行check(5)\(\rightarrow\) 返回true(第 \(2\) 次猜测,第一枚鸡蛋打破,进入if内部的分段线性查找)。 - 线性精确定位(区间为
j从 \(5-2+1=4\) 到 \(4\)):执行check(4)\(\rightarrow\) 返回false(第 \(3\) 次猜测)。循环结束,执行assert_ans(5),成功锁定 \(k=5\)。
(2)错误。guess2 采用的最优策略是为了最小化最坏情况下的猜测次数,而不是保证在所有具体情况下都比 guess1 快。当目标值 \(k\) 非常小的时候,guess1 这种从 \(1\) 开始逐个尝试的“笨办法”反而会更快。可以举一个简单的反例 \(n=10, k=1\):
- 当 \(t=1\) 时(调用
guess1):程序从 \(1\) 开始线性扫描,第一次执行check(1),因为 \(1 \ge k (k=1)\),直接返回true,成功找到答案,总猜测次数为 \(1\)。 - 当 \(t=2\) 时(调用
guess2):计算初始步长 \(w\),满足 \(\frac{w(w+1)}{2} \ge 10\) 的最小整数是 \(w=4\)。第一轮测试,跨步到nh = 4,执行check(4),因为 \(4 \ge 1\),第一枚鸡蛋摔碎,返回true(第 \(1\) 次猜测)。线性精确定位,区间回退到从 \(1\) 到 \(3\)(nh - ti + 1 = 4 - 4 + 1 = 1)。执行check(1),因为 \(1 \ge 1\),第二枚鸡蛋摔碎,返回true,成功找到答案(第 \(2\) 次猜测),总猜测次数为 \(2\)。
(3)正确。
guess1采用从 \(1\) 到 \(n\) 的逐层线性扫描:循环变量 \(i\) 从 \(1\) 依次递增到 \(n\),题目保证 \(1 \le k \le n\),因此在循环过程中必然会遇到 \(i=k\) 的情况。当 \(i=k\) 时,check(k)满足条件 \(h \ge k\),返回true。随后程序立即调用assert_ans(k),满足h == k,成功判定正确并退出。因此,\(t=1\) 必然能猜到正确结果。guess2采用基于等差数列的分段查找策略。- 外层大步长试探:初始步长 \(w\) 满足 \(\frac{w(w+1)}{2} \ge n\),外层循环每次将
nh增加当前的步长ti。由于总步长之和大于等于 \(n\),nh最终必然会到达或被std::min(nh, n)截断为 \(n\)。因为 \(k \le n\),在nh首次大于等于 \(k\) 时,check(nh)必返回true(第一枚鸡蛋打破),程序进入精确定位阶段。 - 内层线性精确定位:设前一次测试的楼层为
nh',此时目标 \(k\) 必定位于区间(nh', nh]内,内层循环从j = nh - ti + 1开始向上逐层检查。若nh未触及边界 \(n\),起始点nh - ti + 1正好等于nh' + 1,完美无缝覆盖待查区间。若nh因std::min受到截断,起始点nh - ti + 1可能会小于等于nh'。但对于这些已经测试过且已知小于 \(k\) 的楼层,check(j)会直接返回false(不消耗第二枚鸡蛋),循环继续递增 \(j\) 直至进入未知区间。 - 最终判定:若
k小于nh,内层循环在 \(j=k\) 时check(k)返回true,触发assert_ans(k)判定正确。若k等于nh,内层循环完全结束后,执行第 51 行的assert_ans(nh),同样判定正确。
- 外层大步长试探:初始步长 \(w\) 满足 \(\frac{w(w+1)}{2} \ge n\),外层循环每次将
(4)函数 guess1 在运行过程中,cnt_broken 的值最多为 \(1\)。guess1 的逻辑是从 i = 1 开始向 \(n\) 逐层循环,并在每次循环中调用 check(i)。只要 \(i \lt k\)(还没到达目标楼层),check(i) 就会走到 else 分支返回 false,此时 cnt_broken 保持为 \(0\),循环继续。当 i 刚好增加到等于 \(k\) 时,进入 check(i) 满足了 h >= k 的条件。此时,代码执行第 16 行 ++cnt_broken;,于是 cnt_broken 变为 \(1\),并且 check(i) 返回 true。回到 guess1 中,因为 check(i) 变成了 true,程序会进入第 33 行的 if 块内,执行 assert_ans(i),紧接着在第 35 行执行 return;。return 导致 guess1 函数直接终止,后续的楼层不再被测试。因此,在 guess1 的整个生命周期中,鸡蛋只要碎了第一颗,循环就立即结束了,cnt_broken 永远没有机会增加到 \(2\) 或以上,最大值只能是 \(1\)。
(5)函数 guess2 在运行过程中,最多使用的猜测数量级为 \(O(\sqrt{n})\)。guess2 采用的是 \(2\) 枚鸡蛋的最优策略,其核心思想是“第一枚鸡蛋打破时的试探次数”与“第二枚鸡蛋精确定位时的试探次数”之和在最坏情况下保持为一个常数 \(w\)。
- 初始步长 \(w\) 的计算:程序第 41 行的循环用于计算满足 \(\frac{w(w+1)}{2} \ge n\) 的最小正整数 \(w\),解这个一元二次不等式,可以得到 \(w \approx \sqrt{2n}\)。因此,初始步长 \(w\) 的量级是 \(O(\sqrt{n})\)。
- 最坏情况下的猜测总数分析
- 外层循环(第一枚鸡蛋):每次跨步的距离是递减的(\(w, w-1, w-2, \dots\)),假设在第 \(m\) 次试探时第一枚鸡蛋打破(即外层循环执行了 \(m\) 次
check)。 - 内层循环(第二枚鸡蛋):此时跨步的区间长度为 \(w-m+1\),在这个区间内使用第二枚鸡蛋进行逐层线性扫描,最坏情况下需要扫描到区间的倒数第二层,耗费 \((w-m+1)-1=w-m\) 次
check。 - 总次数相加:外层耗时 \(m\) 次,内层耗时 \(w-m\) 次,总猜测次数为 \(m+(w-m) = w\)。
- 外层循环(第一枚鸡蛋):每次跨步的距离是递减的(\(w, w-1, w-2, \dots\)),假设在第 \(m\) 次试探时第一枚鸡蛋打破(即外层循环执行了 \(m\) 次
- 因此,无论目标楼层 \(k\) 在哪里,
guess2的最大猜测次数都被严格限制在 \(w\) 次。因为 \(w \approx \sqrt{2n}\),所以该算法最坏情况下的猜测数量级为 \(O(\sqrt{n})\)。
(6)当输入的 \(n=100\) 时,\(t=1\) 要求的最多猜测次数为 \(100\) 次,\(t=2\) 要求的最多猜测次数为 \(14\) 次。guess1 采用自底向上的逐层线性查找,在最坏情况下(即目标楼层 \(k=100\) 时),从第 \(1\) 层逐层检查至第 \(100\) 层,必定需要调用 check 函数 \(100\) 次。guess2 采用基于等差数列的最优 2 蛋查找策略:程序求解满足 \(\frac{w(w+1)}{2} \ge 100\) 的最小正整数,解得 \(w=14\)。算法的设计保证了“外层大步长试探次数”与“内层线性精确查找次数”之和在任何区间都恒定不超过 \(w\):外层试探的楼层依次为 \(14, 27, 39, 50, 60, 69, 77, 84, 90, 95, 99, 100\),无论目标 \(k\) 在哪个区间,第一枚鸡蛋打破时的试探次数与第二枚鸡蛋在该区间内查找的次数相加,最多不超过 \(14\) 次。

浙公网安备 33010602011771号