面试题47:礼物的最大价值
从左上到右下只能向右或向下,先用二维动态规划保存到格最优值,再用一维列数组压缩空间。
学习目标
- 能...
- 能...
- 能...
核心思路
理解问题的核心算法。
从“到达这一格之前在哪里”开始
先预测:站在棋盘内部任意一格时,最后一步只可能来自上方或左方。因为题目规定从左上角出发,只能向右或向下,所以所有到达当前格的合法路径,恰好被这两个来源分成互不重叠的两类。
题目要求收集礼物的最大价值,并不要求先枚举每条路径。更关键的问题是:到达每个格子时,最多已经收集多少价值?把这个量定义清楚,后面的选择就只依赖两个已经解决的子问题。
把从起点到位置行i、列j的最大累计价值称为。若当前礼物价值是value,来自上方的最优值是up,来自左方的是left,那么状态转移为:
best[i][j] = value[i][j] + max(
best[i - 1][j],
best[i][j - 1]
)不存在的方向在作者题设中按0处理。第一格因此是自身价值,第一行只能从左累加,第一列只能从上累加。
图中小字是原礼物价值,大字是到格最优。右下角53不是某一步的局部贪心结果,而是所有合法路径被逐格归并后的全局最优值。
为什么递推不会漏掉更好的路径
对任意格子,它的只有上方和左方。任意完整路径进入当前格时必经其中一个,因此没有第三类路径被遗漏。
再看为什么只保留每个前驱的最大值就够。假设一条经过上方格的路径在到达上方格时不是最优的,把它的前半段替换成上方格的最优路径,后半段仍然合法,且累计价值只会更大。于是经过上方的所有路径只需保留一个最优值;左方同理。最后在两类最优值中取较大者,再加当前礼物价值。
这就是动态规划的最优子结构。未来允许的移动只由当前位置决定,与此前具体走法无关,所以状态具有无后效性。若规则改成“同一行最多连续走两步”或“某把钥匙只能拾取一次”,只用行列坐标就不够,状态还需记录连续步数或钥匙集合。
作者(↡)第一解:保存完整二维累计表
作者输入不是二维容器,而是一段连续int内存。第i行第j列通过values[i乘cols加j]读取,这种按行连续存放的布局称为。
下面保留作者解法的结构:先检查指针和尺寸,为每一行分配累计数组,逐格填表,取右下角后再逐行释放。
#include <algorithm>
int getMaxValue_solution1(
const int* values,
int rows,
int cols) {
if (values == nullptr ||
rows <= 0 ||
cols <= 0) {
return 0;
}
int** maxValues = new int*[rows];
for (int i = 0; i < rows; ++i) {
maxValues[i] = new int[cols];
}
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
int left = 0;
int up = 0;
if (i > 0) {
up = maxValues[i - 1][j];
}
if (j > 0) {
left = maxValues[i][j - 1];
}
maxValues[i][j] =
std::max(left, up) +
values[i * cols + j];
}
}
const int result =
maxValues[rows - 1][cols - 1];
for (int i = 0; i < rows; ++i) {
delete[] maxValues[i];
}
delete[] maxValues;
return result;
}设网格有rows行、cols列。每格计算一次,时间复杂度为O(rows乘cols);累计表额外占用O(rows乘cols)空间。输入values只读,没有像旧页那样原地覆盖礼物值。
完整表的优势不只是便于讲解。若还要输出路线,可以从右下角反向比较上方与左方的累计值,选择较大的合法前驱,直到回到左上角,再反转所得坐标序列。
对于作者4乘4样例,一条最优路线收集1、12、5、7、7、16、5,合计53。累计值相同时可能存在多条最优路径;“向上优先”或“向左优先”只会选择不同代表,不影响最大价值。
手算4乘4累计表
原礼物矩阵为:
1 10 3 8
12 2 9 6
5 7 4 11
3 7 16 5第一行只能向右,得到1、11、14、22;第一列只能向下,得到1、13、18、21。其余位置取上、左较大值。例如第二行第三列的礼物是9,上方累计14,左方累计15,所以该格得到24。第四行第三列的礼物是16,上方29,左方32,所以得到48。
完整累计表是:
1 11 14 22
13 15 24 30
18 25 29 41
21 32 48 53手算时每填一格都要标注“上、左、当前价值”三个量。只写最终53无法检查状态定义和边界,面试官也无法判断答案是递推所得还是猜出。
作者第二解:只保留一行
计算第i行时,每个新状态只需要上一行同列和当前行前一列。作者用长度为cols的一维数组完成覆盖,这正是一维数组压缩空间。
更新列j之前,maxValues[j]仍保存上一行同列,也就是up;maxValues[j减1]已经在本轮更新,保存当前行左方,也就是left。写回后,maxValues[j]变成当前格到格最优。这一组随扫描位置改变解释、但始终维持上述关系的数组称为。
#include <algorithm>
int getMaxValue_solution2(
const int* values,
int rows,
int cols) {
if (values == nullptr ||
rows <= 0 ||
cols <= 0) {
return 0;
}
int* maxValues = new int[cols];
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
int left = 0;
int up = 0;
if (i > 0) {
up = maxValues[j];
}
if (j > 0) {
left = maxValues[j - 1];
}
maxValues[j] =
std::max(left, up) +
values[i * cols + j];
}
}
const int result =
maxValues[cols - 1];
delete[] maxValues;
return result;
}逐行切换图中的状态,可以看到数组依次变成1、11、14、22,再变成13、15、24、30,最终得到21、32、48、53。时间仍是O(rows乘cols),额外空间降为O(cols)。
若列数远大于行数,还可转置扫描逻辑,让数组长度取rows与cols的较小者,额外空间降为O(min(rows, cols))。但转置后必须同步修改行主序索引,不能只交换循环变量。
空间压缩为什么会丢失路线
二维累计表保留了每个格子的两个候选值,可从终点逐步判断前驱。一维数组在进入下一行时会覆盖上一行,大部分历史状态消失,因此默认只能返回最大总值,不能直接恢复整条路径。
从终点依据累计表反向寻找最优前驱的过程称为。若既要O(cols)工作空间又要输出路径,可使用分治式路径恢复,或另存每格方向位;后一种仍需要O(rows乘cols)位空间,只是常数比完整整数表小。
不能声称“空间压缩后仍是严格O(cols),同时免费输出任意长度路径”。输出路径本身至少有rows加cols减1个坐标,输出空间已有线性下界;恢复过程也需要足够信息区分局部并列。
正值契约决定边界能否写0
作者题目明确每件礼物价值大于0。因此在第一行,把不存在的up设为0不会胜过合法的正数left;第一列同理。左上角两个前驱都是0,结果就是起点价值。
如果扩展为允许负数,这一写法会错误地允许路径从网格外“中途进入”。例如一行礼物为负5、负2:合法路径必须从负5开始,总和负7;作者边界在第二格比较left等于负5与up等于0,会选0,错误得到负2。
因此工程扩展不能只改输入注释。状态初值、整数下界与加法溢出都要一起设计。对负无穷哨兵直接加礼物值还可能下溢,最好显式分支处理边界。
更稳健的工程实现
作者使用int累加。若每件礼物接近int上界,路径长度稍大就会有符号溢出。可改用int64_t,并用vector管理内存;以下版本仍按作者正值契约、行主序输入和O(cols)空间工作。
#include <algorithm>
#include <cstdint>
#include <span>
#include <stdexcept>
#include <vector>
std::int64_t maxGiftValue(
std::span<const int> values,
std::size_t rows,
std::size_t cols) {
if (rows == 0 || cols == 0) {
return 0;
}
if (rows > values.size() / cols ||
rows * cols != values.size()) {
throw std::invalid_argument(
"matrix dimensions mismatch");
}
std::vector<std::int64_t> best(cols, 0);
for (std::size_t i = 0; i < rows; ++i) {
for (std::size_t j = 0; j < cols; ++j) {
if (values[i * cols + j] <= 0) {
throw std::invalid_argument(
"gift values must be positive");
}
const std::int64_t up =
i == 0 ? 0 : best[j];
const std::int64_t left =
j == 0 ? 0 : best[j - 1];
best[j] = std::max(up, left) +
values[i * cols + j];
}
}
return best.back();
}尺寸校验先用除法避免rows乘cols在比较前溢出;随后乘法已知安全。若接口要接受带步长的二维视图,不能再假设行主序连续内存,应把stride纳入索引或传入二维span封装。
作者六组测试逐项核对
作者test辅助函数会分别调用二维解和一维解,并与同一个expected比较:
- 3乘3递增矩阵期望29,一条路径是1、4、7、8、9。
- 4乘4一般矩阵期望53,覆盖内部格上左竞争。
- 单行1、10、3、8期望22,证明第一行只能从左。
- 单列1、12、5、3期望21,证明第一列只能从上。
- 单格3期望3,证明起点就是终点。
- 空指针、0行、0列期望0,证明入口防御分支。
源码定义了test6,但main只调用test1到test5,因此空输入测试实际上没有执行。阅读测试代码时必须核对“函数存在”和“测试已运行”两个层次,不能看到定义就当成覆盖。
#include <cassert>
void testMaxGiftValue() {
const int grid3x3[] = {
1, 2, 3,
4, 5, 6,
7, 8, 9,
};
assert(getMaxValue_solution1(
grid3x3, 3, 3) == 29);
assert(getMaxValue_solution2(
grid3x3, 3, 3) == 29);
const int oneRow[] = {1, 10, 3, 8};
assert(getMaxValue_solution2(
oneRow, 1, 4) == 22);
const int oneCol[] = {1, 12, 5, 3};
assert(getMaxValue_solution2(
oneCol, 4, 1) == 21);
const int oneCell[] = {3};
assert(getMaxValue_solution2(
oneCell, 1, 1) == 3);
assert(getMaxValue_solution2(
nullptr, 0, 0) == 0);
}测试还应加入随机小矩阵:枚举所有右下路径求最大值,与二维、一维两版对拍。随机生成器必须遵守正值契约;负值测试应针对重新定义边界后的扩展版本,不能用来否定作者在原题条件下的正确实现。
常见错误与诊断顺序
把状态定义成“从当前格到终点”也能解,但扫描方向应从右下到左上,后继变为右方和下方。若状态定义与循环方向混用,会读取尚未计算的数据。
原地修改输入网格是另一种O(1)额外空间变体,却改变调用者数据,而且不是作者源码给出的两种实现。面试回答可以提出扩展,但要先准确还原作者的二维表和一维列数组,再说明变体的前提与副作用。
贪心地每步选择右方或下方礼物更大者不成立。较大的眼前礼物后面可能接一串小值,较小的下一步可能通向高价值区域。动态规划比较的是“到达前驱的完整累计最优”,不是只比较相邻礼物。
二维数组释放时若只delete外层指针,会泄漏每一行;若对new数组使用标量delete,行为未定义。工程版用vector可消除手工配对风险。
诊断错误答案时按以下顺序检查:状态含义是否固定;第一行第一列是否只来自唯一方向;一维覆盖顺序是否使j减1为当前行、j为上一行;行主序索引是否使用cols;最后返回是否为右下角;测试函数是否真的由main调用。
本章练习
练习
问题 1: 请说明...
问题 2: 请说明...
问题 3: 请说明...
本章回顾
- 礼物的最大价值可定义为每格的到格最优,而不是先枚举路径。
- 只能向右或向下,所以当前格所有路径恰好来自上方或左方。
- 动态规划转移取两个前驱最优的较大者,再加当前礼物价值。
- 作者第一解保存rows乘cols累计表,时间和额外空间均为二维规模。
- 作者第二解利用一维数组压缩空间,同列更新前是上方,左邻更新后是左方。
- 完整累计表可做路径回溯,一维覆盖版默认只保留最大总值。
- 缺失前驱设0依赖礼物价值大于0;允许负值时必须重写边界。
- 作者定义六组测试,但main遗漏空输入test6,测试覆盖应以实际调用为准。