- gf24240 的博客
《梦溪笔谈·C++》卷四十八:励志刷500道DP之二
- @ 2026-10-1 14:21:38
前言
当然在 上一篇 之后我还写了很多 dp 但没有收录。于是算了。
四十九也算是个好数字。
第 道:Time is Mango🥭
链接:https://www.luogu.com.cn/problem/P6005
(下面这些,与其说「题解」,不如说「思考过程」更合适)
首先审题。一个有向图,每个节点有价值。使价值之和减去【「经过的边数」的平方乘以一个常数的值】最大。
数据范围,。一看就是在暗示我用 左右的算法。
样例是个正环。如果按最短路一直绕回最大化。但需要减去一个值。这个值是平方的,增长速度快于这个环。就会停下来。
我计划将状态定义为:dp[i][j] 经过了 i 条边,上一个点是 j 时赚最多的钱。
但是在样例解释中经过的边数 为 远大于总边数 。 的范围是什么?大概要看 。 最小最小只有 。就算总权值只有 ,那么 也要超过 。。好像不是很好求。
算了先想状态转移方程,在过程中判断会不会超过。
for i = 1, i <= inf:
for j = 1 to n: # 比较「上一个点」只可能是 1~n
# 那「这一个点」要选什么呢?
dp[i][j] = dp[i - 1][j]。。。
难道是定义为 XXX 在不超过 的情况下能走多久?但这变成 最大而不是钱最多了。
话说为什么要说「上一个点是 j」呢,直接「目前走到 j」不就好了?
因此:dp[i][j]:走了 i 步到 j 点能拿到最多的钱。
for i = 1 to inf:
for t = 1 to m: # 遍历所有 **边**
[u, v] = edg[t]
dp[i][v] = max(dp[i][v], dp[i - 1][u] + c_v)
不过 的尽头在哪里呢?如果 dp[i][v] < c * i ^ 2,那是不是就可以结束了。
空间上也可以优化。第 i 层只涉及到 i - 1 层。所以可以用滚动数组优化。
答案的话就要枚举了。枚举 。取 dp[k][1] 的 。
第 题:[NOIP 2003 提高组] 加分二叉树
链接:直接在 BCOI 或 洛谷 搜索「加分二叉树」。下面的是我在 BCOI 里我写的的题解复制的。
一个思路:枚举根节点。将根所在区间分割成两半。如果设根的区间内节点是 ,枚举到根为 。那么分成两半区间为 和 。可以用区间 DP。
定义状态变量
定义 为子树内节点为 的最大价值。
状态转移方程
枚举根节点 (或是区间 DP 定义内的“断点”)。那么左子树(左区间)就是 ,价值是 。右子树价值 。根据公式,如果根为 ,那么价值为 。取所有根的 即可。
边界
注意空子树价值为 。在上面的转移中,。同时 和 有可能不在 范围内。此时那个区间就是空子树。
所以可以定义 为空子树,即价值为 。
叶子节点的价值就是节点的价值。所以当 时,。
答案
。
前序遍历
可以额外设置一个数字 ,表示区间 的根为 。然后递归遍历 和 。
代码
代码很占位置就不放了(🌫️)
第 题:[CSP-J 2024] 小木棍
贪心分讨
首先看特殊性质,分别给了 。那么其实可以推出 。
然后写个暴力乱搞能求出 以内数字的结果就能找出规律了。
暴力
bool lest(string a, string b)
{ // a < b ?
if (a.size() != b.size()) return a.size() < b.size();
return a < b;
}
void dfs(string k, int sum)
{
if (sum > mxn) return ;
if (st.count(sum) && lest(st[sum], k))
return;
st[sum] = k;
for (char i = '0'; i <= '9'; ++i)
{
if (sum == 0 && i == '0') continue;
dfs(k + i, sum + skk[i - '0']);
}
}
然后手玩几个数据:
- ;
- ;
- ;
- ;
- ;
算了,自己手算几个样例可得:
贪心分类讨论
void solu()
{
cin >> n;
// n <= 1 -> -1
if (n < 7)
{
if (n <= 1) cout << -1;
if (n == 2) cout << 1;
if (n == 3) cout << 7;
if (n == 4) cout << 4;
if (n == 5) cout << 2;
if (n == 6) cout << 6;
}
// n = 7k -> 88...88(k)
else if (n % 7 == 0)
{
for (int i = 1; i <= (n / 7); ++i)
cout << 8;
}
// n = 7k + 1 -> 10 + 88...88
else if (n % 7 == 1)
{
cout << 10;
n -= 8;
for (int i = 1; i <= (n / 7); ++i)
cout << 8;
}
// n = 7k + 2 -> 1 + 88...88
else if (n % 7 == 2)
{
cout << 1;
n -= 2;
for (int i = 1; i <= (n / 7); ++i)
cout << 8;
}
// n = 7k + 3 -> 200 + 88...88
else if (n % 7 == 3)
{
if (n == 10) cout << 22; // 10 -> 22
else
{
cout << 200;
n -= 17;
for (int i = 1; i <= (n / 7); ++i)
cout << 8;
}
}
// n = 7k + 4 -> 20 + 88...88
else if (n % 7 == 4)
{
cout << 20;
n -= 11;
for (int i = 1; i <= (n / 7); ++i)
cout << 8;
}
// n = 7k + 5 -> 2 + 88...88
else if (n % 7 == 5)
{
cout << 2;
n -= 5;
for (int i = 1; i <= (n / 7); ++i)
cout << 8;
}
// n = 7k + 6 -> 6 + 88...88
else
{
cout << 6;
n -= 6;
for (int i = 1; i <= (n / 7); ++i)
cout << 8;
}
cout << '\n';
}
时间复杂度是 。
动态规划
定义 表示 根木棍能拼出的最小数字。转移:设最后一个数字是 。拼出 的木棍数是 。那么可以用 (此处加法表示拼接)更新 。即 。答案就是 。 是不会变的。可以一开始读入所有 。对最大的 进行 DP。
时间复杂度 。前提是拼接的时间是 。DP 比较简单。问题在于如何表示答案。
答案可能很大。,答案可能有 。所以不能用 int 或 long long 甚至 int_128 存。
字符串
dxd 说用字符串。
只要不超空间,字符串能表示非常长的数字。但字符串赋值的时间复杂度是 。也就是说,如果 dp 用字符串存,时间会退化为 。
字符串 50 分 TLE
#include <vector>
#include <iostream>
#include <algorithm>
using namespace std;
using ll = long long;
string min_string(string a, string b) {
if (a.size() != b.size()) {
return a.size() < b.size() ? a : b;
} else {
return a < b ? a : b;
}
}
int main() {
freopen("sticks.in", "r", stdin);
freopen("sticks.out", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
int n, max_n = 0;
cin >> n;
vector <int> a(n + 1, 0);
vector <int> number_size{6, 2, 5, 5, 4, 5, 6, 3, 7, 6};
for (int i = 1; i <= n; i++) {
cin >> a[i];
max_n = max(max_n, a[i]);
}
vector <string> ans(n + 1, "0"); // 记录答案
// dp 使用滚动数组避免 MLE
vector <string> dp{"-1", "-1", "1", "7", "4", "2", "6", "8"};
for (int i = 1; i <= 7; ++i) { // a 中可能包含 dp 初值
for (int j = 1; j <= n; ++j) { // 记录答案
if (a[j] == i) {
ans[j] = dp[i];
}
}
}
for (int i = 8; i <= max_n; i++) {
int from = i - number_size[0];
dp[i % 8] = dp[from % 8] + '0'; // 避免原有内容污染
for (int j = 1; j <= 9; ++j) {
int from = i - number_size[j];
dp[i % 8] = min_string(dp[i % 8], dp[from % 8] + char(j + '0'));
}
for (int j = 1; j <= n; ++j) { // 记录答案
if (a[j] == i) {
ans[j] = dp[i % 8];
}
}
}
for (int i = 1; i <= n; ++i) {
cout << ans[i] << '\n';
}
return 0;
}
结构体
考场为了 BFS 瞎写的结构体:
struct fu
{
bool nos; // 不能拼
int num[10]; // 0~9 的数量
};
输出的时候,如果 nos 为真,直接输出 。
然后在 num 找到除 之外最小的数。先输出这个数。然后再把除这个数之外的数字(包括 )都输出。可以避免前导零。
关键在于比较。看看字符串是怎么比较的:
- 长度短的数小。
- 从高位往低位比。高位小的数字更小。
对于这个结构体:
位数自然就是 数量之和。
高位到低位可以参考输出逻辑。首先找 之外最小的数输出,然后依次输出。那么从高位到低位就是 最小的数字 → 。所以可以:
- 先比较最小的数字。最小的数字小的数小。剩下的最小的数字相同。
- 比较 的数量。 数量多的数字小。
- 比较 的数量。……
结构体
#include <iostream>
#include <algorithm>
using namespace std;
using ll = long long;
const int N = 1e5 + 5;
int n, a[N], m;
int skk[10] = {6, 2, 5, 5, 4, 5, 6, 3, 7, 6};
struct fu
{
bool nos; // -1
int num[10]; // 0~9 数量
}dp[8], ans[N];
// dp: 以 i 根木棍拼出的最小数字
// ans: 记录 i 的答案
fu minf(const fu & A, const fu & B)
{
int sA = A.num[0] + A.num[1] + A.num[2]
+ A.num[7] + A.num[8] + A.num[3]
+ A.num[6] + A.num[5] + A.num[4]
+ A.num[9];
int sB = B.num[0] + B.num[1] + B.num[2]
+ B.num[7] + B.num[8] + B.num[3]
+ B.num[6] + B.num[5] + B.num[4]
+ B.num[9];
if (A.nos && !B.nos) return B;
if (B.nos && !A.nos) return A;
if (sA != sB) return (sA < sB ? A : B);
else
{
int flA = 0, flB = 0;
for (int i = 1; i <= 9; ++i)
{
if (A.num[i] && !flA) flA = i;
if (B.num[i] && !flB) flB = i;
if (flA && flB) break;
}
if (flA != flB) return (flA < flB ? A : B);
if (A.num[0] != B.num[0])
return (A.num[0] > B.num[0] ? A : B);
for (int i = 1; i <= 9; ++i)
{
if (i == flA) continue;
if (A.num[i] != B.num[i])
return (A.num[i] > B.num[i] ? A : B);
}
}
return A;
}
fu FU_MAX()
{
return {1, {10000, 1111,
2222, 3333, 4444, 5555,
6666, 7777, 8888, 9999}};
}
void print(fu & f)
{
if (f.nos)
{
cout << "-1\n";
return ;
}
f.num[2] += f.num[3];
f.num[3] = 0;
int flag = -1;
for (int i = 1; i <= 9; ++i)
{
if (f.num[i])
{
flag = i;
for (int j = 1; j <= f.num[i]; ++j)
cout << i;
break;
}
}
for (int i = 0; i <= 9; ++i)
{
if (i == flag) continue;
for (int j = 1; j <= f.num[i]; ++j)
cout << i;
}
cout << '\n';
}
int main()
{
freopen("sticks.in", "r", stdin);
freopen("sticks.out", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n;
for (int i = 1; i <= n; ++i)
{
cin >> a[i];
m = max(m, a[i]);
}
// dp 初值
dp[0] = {1, {0}};
dp[1] = {1, {0}};
// 0 1 2 3 4 5 6 7 8 9
dp[2] = {0, {0, 1, 0, 0, 0, 0, 0, 0, 0, 0}};
dp[3] = {0, {0, 0, 0, 0, 0, 0, 0, 1, 0, 0}};
dp[4] = {0, {0, 0, 0, 0, 1, 0, 0, 0, 0, 0}};
dp[5] = {0, {0, 0, 1, 0, 0, 0, 0, 0, 0, 0}};
dp[6] = {0, {0, 0, 0, 0, 0, 0, 1, 0, 0, 0}};
dp[7] = {0, {0, 0, 0, 0, 0, 0, 0, 0, 1, 0}};
for (int i = 1; i <= 7; ++i)
for (int j = 1; j <= n; ++j)
if (a[j] == i) ans[j] = dp[i];
for (int i = 8; i <= m; ++i)
{
dp[i % 8] = FU_MAX();
for (int j = 0; j <= 9; ++j)
{
int t = (i - skk[j]) % 8;
fu dt = dp[t];
++dt.num[j];
dp[i % 8] = minf(dp[i % 8], dt);
}
for (int j = 1; j <= n; ++j)
{
if (a[j] == i)
ans[j] = dp[i % 8];
}
}
for (int i = 1; i <= n; ++i)
{
print(ans[i]);
}
return 0;
}
第 题:[CSP-J2019] 纪念品
可以先从 考虑。
例如样例里这样行:
如果买入 物品并卖出去,那么价值是 。代价是 。背包容量是金币数量 。以此进行完全背包,新得出的最大价值即为利益。利益加上原有钱就是新的金币数量。
第 题:[CSP-J2020] 方格取数
定义 表示走到 并且第 列是从下往上走的。
定义 表示走到 并且第 列是从上往下走的。
转移:
- :可以从前一列过来。前一列的位置是 。最大值是分别求从上往下和从下往上的最大值即 。也可以从上面下来,即 。然后要加上当前格子的价值。
- 同理。不过 是从下面上来。所以枚举行的时候要倒序。
边界:
- 第一列只能从上往下走。但是最后一列要取最大值因为可以从左边过来。
答案:如上面所说,要取最大值。
第 题:分组背包模板
这题看似模板,其实真的是模板 是 dxd 拿来给我们 CSP 模拟的我当时竟然没有写出来
首先可以定义 表示选了前 组,恰好花费 元的方案数。
初始化, 因为什么都不做的方案。不然后面不能转移。
转移。枚举 。第 组没有选,枚举第 组内选择第 个物品,价值是 。就可以从 转移。注意第 类物品可以不选,此时价值等于 。
答案即 。
注意点
这题是模板所以很简单。但有一些坑。
- 不能只定义一维价值 ,不然会有很多重复(或者可以,毕竟只涉及到 和 )
- 一定要考虑第 类可以不选这种情况。不然你会本来已经写出 dp 基本完整的代码但乱打对拍和 dfs 暴力代码甚至 dfs 都考虑了不选物品发现错了调半天都不知道错在哪只好写个 dfs 水个 50 分别问我怎么知道的。。。
- 如果你第二步成功了,那1这一步其实也不需要。样例是水的。如果没有 ,统计答案时直 样例可过但随便弄个数据就会 hack 掉。
- 重要的事情说三遍,一定要考虑 不选这种边界/特殊情况!!!