在数字三角形的路径 DP 上多加一维「已用次数」, dp[i][j][c] 表示走到 (i,j) 恰好用了 c 次三倍的最大得分; k 可先截断成 min(k,n).
OJ: luogu
题目 ID: P1544
难度:普及+/提高-
标签:动态规划线性DP状态设计
创建: 2026-06-21 13:24
更新: 2026-10-26 16:36
形式化题目
一个三角形网格, 第
从
求
样例
样例是 5 行的金字塔,
7
3 9
8 1 0
2 7 4 4
4 5 2 6 5最优方案走的路径是
顺带一个提醒: 路径
暴力解法
思路
按定义做两层枚举: 先枚举整条路径, 再枚举这条路径上每个格子乘不乘 3.
- 路径用递归生成: 第
row行在第col列时, 往(row+1, col)和(row+1, col+1)递归. - 走到第
行后, 用 01 序列递归枚举 , 第 dep层决定第dep行路径上的格子乘不乘 3. - 到叶子时累加得分, 若用掉的次数不超过
就更新答案.
它把两层选择序列都完整生成, 覆盖所有合法方案, 因此可以作为小数据对拍的基准.
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-26 13:40
* update_at: 2026-10-26 16:36
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 递归枚举两个选择序列:每层先选"下一步走左下还是右下",到达第 n 行后
// 再枚举路径上每个点"要不要乘 3",检查用的次数不超过 k 后统计得分。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 20 + 5;
ll n, k;
ll a[MAXN][MAXN]; // 数字金字塔,a[i][j] 是第 i 行第 j 个数
ll path[MAXN]; // 路径上每一行的列号:path[i] 表示第 i 行走的列
int choose[MAXN]; // 选择序列:choose[i]=1 表示第 i 行路径上的点乘 3
ll best_ans;
// 完整路径已生成(path[1..n]),统计它在选择序列 choose 下的得分。
ll calc_answer() {
ll used = 0;
ll sum = 0;
for (ll i = 1; i <= n; i++) {
ll j = path[i];
ll value = a[i][j];
if (choose[i] == 1) {
used++;
value = value * 3;
}
sum += value;
}
if (used <= k) {
return sum;
}
return LLONG_MIN; // 超过 k 次,方案不合法
}
// 枚举路径上每个点乘不乘 3,dep 是行号。生成完整 choose[1..n] 后检查。
void dfs_choose(int dep) {
if (dep == n + 1) {
ll value = calc_answer();
if (value != LLONG_MIN && value > best_ans) {
best_ans = value;
}
return;
}
// 这一层在决定第 dep 行路径上的点要不要乘 3。
for (int i = 0; i <= 1; i++) {
choose[dep] = i;
dfs_choose(dep + 1);
}
}
// 枚举从顶到底的路径:当前走到第 row 行第 col 列。
void dfs_path(int row, int col) {
path[row] = col;
if (row == n) {
dfs_choose(1);
return;
}
// 左下方是 (row+1, col),右下方是 (row+1, col+1)。
dfs_path(row + 1, col);
dfs_path(row + 1, col + 1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (ll i = 1; i <= n; i++) {
for (ll j = 1; j <= i; j++) {
cin >> a[i][j];
}
}
k = min(k, n); // 路径只有 n 个点,超过 n 的次数用不满
best_ans = LLONG_MIN;
dfs_path(1, 1);
cout << best_ans << '\n';
return 0;
}复杂度
路径
只有
瓶颈
瓶颈是枚举了整条路径. 同一个路径前缀在不同的选择序列下被反复重算.
再看一个更关键的浪费: 决定当前格子乘不乘 3 时, 之前具体哪些格子乘过 3 完全无关, 只需要知道已经用掉了几次. 也就是说, 整条路径上那么多格子的选择历史, 真正需要携带的只有"次数"这一个数.
正解
关键观察
把"已用次数"做成 DP 的一维, 路径前缀的最优值就和选择序列解耦了. 这个维度来自 0/1 背包, 位置维度来自数字三角形, 两者直接相乘:
转移枚举两个前驱, 每个前驱再分"当前格不乘"与"当前格乘"两种情况:
还有一个能省掉
样例推演
用样例前 3 行算 - 表示该状态不可达.
| 格子 | ||||
|---|---|---|---|---|
| 7 | 21 | - | - | |
| 10 | 24 | 30 | - | |
| 16 | 34 | 48 | - | |
| 18 | 34 | 48 | 54 | |
| 17 | 35 | 49 | 51 | |
| 16 | 34 | 48 | 48 |
表前看三点: 第 1 行只有 1 个格子, 所以
表后看最典型的一格
思路
有了状态和转移, 剩下三处容易写错的地方要交代.
- 为什么用"恰好
次"而不是"至多 次". 恰好让转移只需看 这一列, 边界清楚; 最后答案取 即可, 因为用不满 次是允许的. - 为什么答案不能只看
. 权值可以是负数, 把负数乘 3 只会更小, 所以用满次数往往不是最优. 必须遍历所有 取最大. - 哨兵要能承受加法. 不可达状态用
-inf标记, 但-inf + a[i][j]会溢出, 所以要选一个足够小又不会溢出的值.
正确性由两条夹逼给出:
的每个取值都对应一条真实方案: 归纳, 转移的每个分支都是"前驱方案接上 并不乘/乘 3". - 任取一条最优方案, 它在
只有"不乘"与"乘"两种情形, 分别由 DP 的两个分支覆盖; 前驱部分由归纳不超过对应的 值. 故方案得分不超过 . - 两个方向合起来
就是该状态的最优值.
代码
main.cpp 是正式主解. main.py 是同一算法的 Python 实现, 复杂度同阶, 放在同一节.
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-26 13:40
* update_at: 2026-10-26 16:36
*/
// main.cpp:数字金字塔 + 三倍次数的三维 DP,滚动数组实现。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 100 + 5;
const ll NEG_INF = LLONG_MIN / 4; // 不可达状态的哨兵,加一个格子权值也不会溢出
ll n, k; // 行数、乘 3 次数上限(读入后截断成 k' = min(k, n))
ll a[MAXN][MAXN]; // 数字金字塔,a[i][j] 是第 i 行第 j 个数
ll dp[2][MAXN][MAXN]; // 滚动数组:dp[cur][j][c] = 走到 (当前行, j) 且已乘 3 共 c 次的最大得分
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (ll i = 1; i <= n; i++) {
for (ll j = 1; j <= i; j++) {
cin >> a[i][j];
}
}
// 一条路径只经过 n 个格子,用不完 n 次以上的乘 3 机会,截断后状态数有限。
if (k > n) {
k = n;
}
int pre = 0; // 上一行对应的层
int cur = 1; // 当前行对应的层
// 整张滚动数组先全部置成 NEG_INF,避免读到上一行不存在的列时拿到旧值
for (ll i = 0; i < 2; i++) {
for (ll j = 0; j <= n; j++) {
for (ll c = 0; c <= k; c++) {
dp[i][j][c] = NEG_INF;
}
}
}
dp[pre][1][0] = a[1][1]; // 起点 (1,1),不乘 3
if (k >= 1) {
dp[pre][1][1] = a[1][1] * 3; // 起点乘 3
}
for (ll i = 2; i <= n; i++) {
// 当前行会在第 i 列被写入,但只会读第 1..i 列,多清到 n 防止读到旧行残留
for (ll j = 0; j <= n; j++) {
for (ll c = 0; c <= k; c++) {
dp[cur][j][c] = NEG_INF;
}
}
for (ll j = 1; j <= i; j++) {
for (ll c = 0; c <= k; c++) {
// 先从两个前驱里取较大值,再加当前格的贡献
ll best = dp[pre][j][c]; // 前驱 (i-1, j),即从左上方下来
if (dp[pre][j - 1][c] > best) {
best = dp[pre][j - 1][c]; // 前驱 (i-1, j-1),即从右上方下来
}
if (best != NEG_INF) {
dp[cur][j][c] = best + a[i][j]; // 当前格不乘 3
}
if (c >= 1) {
// 当前格乘 3:前驱要求上一行恰好用了 c-1 次
ll best2 = dp[pre][j][c - 1];
if (dp[pre][j - 1][c - 1] > best2) {
best2 = dp[pre][j - 1][c - 1];
}
if (best2 != NEG_INF) {
if (best2 + a[i][j] * 3 > dp[cur][j][c]) {
dp[cur][j][c] = best2 + a[i][j] * 3;
}
}
}
}
}
swap(pre, cur); // 当前行算完,滚动到下一行
}
// 答案:最后一行任意一列、任意已用次数(允许用不满 k 次,dp 值随 c 非降不保证,取 max)
ll ans = NEG_INF;
for (ll j = 1; j <= n; j++) {
for (ll c = 0; c <= k; c++) {
if (dp[pre][j][c] > ans) {
ans = dp[pre][j][c];
}
}
}
cout << ans << '\n';
return 0;
}#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-26 13:40
# update_at: 2026-10-26 13:40
import sys
NEG = None # 哨兵:状态不可达(本行该位置到不了)
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, k = next(data), next(data)
tri: list[list[int]] = [] # tri[i-1] 是第 i 行(下标 0 起)
for i in range(n):
tri.append([next(data) for _ in range(i + 1)])
# 一条路径只经过 n 个格子,用不完 n 次以上的乘 3 机会
limit = k if k < n else n
# prev[j][c]:走到上一行第 j 列(0 起)、已乘 3 共 c 次的最大得分;NEG 表示不可达
prev: list[list[int | None]] = [[NEG] * (limit + 1) for _ in range(n)]
prev[0][0] = tri[0][0]
if limit >= 1:
prev[0][1] = tri[0][0] * 3
for i in range(1, n): # 第 i+1 行,本行列号 0..i
width = limit + 1
cur: list[list[int | None]] = [[NEG] * width for _ in range(i + 1)]
for j in range(i + 1):
row = cur[j]
for c in range(width):
# 不乘 3:前驱 (j) 与 (j-1),各自恰好用了 c 次
best = prev[j][c] if j < i else NEG
left = prev[j - 1][c] if j >= 1 else NEG
pick = best if best is not None and (left is None or best >= left) else left
if pick is not None:
row[c] = pick + tri[i][j]
# 乘 3:前驱要求上一行恰好用了 c-1 次
if c >= 1:
best2 = prev[j][c - 1] if j < i else NEG
left2 = prev[j - 1][c - 1] if j >= 1 else NEG
pick2 = best2 if best2 is not None and (left2 is None or best2 >= left2) else left2
if pick2 is not None:
value = pick2 + tri[i][j] * 3
if row[c] is None or value > row[c]:
row[c] = value
prev = cur
reachable = [value for row in prev for value in row if value is not None]
print(max(reachable))
if __name__ == "__main__":
solve()复杂度
设
- 时间复杂度
, 最坏 时为 . 时约 个状态. - 空间复杂度
: 第 行只依赖第 行, 滚动数组压掉一维.
总结
本题是数字三角形 DP 与 0/1 背包的组合.
关键在于认出只需要携带"已用次数"这一个历史量: 路径怎么来的、前面哪些格子乘过 3,
都不影响后续决策. 把这个量做成第三维,
另外两个细节决定了程序是否正确: