扣分后的最大得分 图解题解
这道题到底在问什么
- 输入
- points=[[1,2,3],[1,5,1],[3,1,1]]
- 输出
- 9 (选列 1、列 1、列 0:2+5+3=10,扣 |1-1|+|1-0|=1,净得 9)
- 输入
- points=[[1,5],[2,3],[4,2]]
- 输出
- 11 (选列 1、列 1、列 0:5+3+4=12,扣 1,净得 11)
先想最直接的笨办法
核心就是这套:dp 逐行往下推,难点在那个绝对值。把它按方向拆成左右两段,各扫一遍取最大,就能把每格的枚举省成常数。下面把这套搬到表格上,一格一格看。(动画第 3 步)
最优解:为什么这么做
一句话答案:LeetCode 1937 扣分后的最大得分:逐行 DP,把跨行代价(上下两行所选列的差距)按左右方向拆成两遍前缀最大值扫描,每格免去枚举上一行整排,O(m·n²) 降到 O(m·n),空间 O(n)。
每行选一格还要扣列号差,这题在权衡什么
给 m 行 n 列的矩阵 points,格子记作 (r,c)(行号、列号,从 0 数起)。每行必须选一格,把 points[r][c] 加进总分;相邻两行选的列号差多少就扣多少。想选大分,又想列对齐少扣分,得两头权衡。
每格回头扫上一行整排,O(m·n²) 的账怎么算
第 r 行落在哪格只受上一行落点影响,正好用逐行的动态规划(DP,把「第 r 行落在第 c 列」的最优净得分记成 dp[r][c],推下一行直接取)。转移(由上一行推出这一行的式子):dp[r][c]=points[r][c]+max(dp[r-1][k]-|c-k|),k 取遍上一行各列。
麻烦在 max:每格要枚举上一行整排,一行 n 格就是 n²,m 行合计 O(m·n²)(大 O 描述输入变大时计算量怎么涨),行列一多跑不动。
|c-k| 把两个列号绑在一起,怎么按方向拆开
绝对值同时牵着 c 和 k。k 在 c 左边(k≤c)时 |c-k|=c-k,候选变成 dp[r-1][k]+k-c,对固定的 c 只需左侧 dp[r-1][k]+k 的最大值;k 在右边时 |c-k|=k-c,只需右侧 dp[r-1][k]-k 的最大值。两边要的都是前缀最大值(扫到当前列为止见过的最大值),一遍扫描带着走。
lmx、rmx 各扫一遍,k=c 算两次要不要紧
从左往右扫:到第 c 列先把 dp[r-1][c]+c 并入 lmx(lmx=左侧 dp[r-1][k]+k 的最大值,开局设负无穷;rmx 同理管右侧),落左候选 points[r][c]+lmx-c;再从右往左维护 rmx,落右候选 points[r][c]+rmx+c,每格取较大者。k=c 两遍都算,差是 0、候选相同,重复无妨。第 0 行没上一行,直接抄 points 首行。
三行三列的示例,两遍扫描各扫出什么
示例 [[1,2,3],[1,5,1],[3,1,1]],第 0 行照抄 dp=[1,2,3]。第 1 行左扫:lmx 依次 1、max(1,2+1)=3、5,左候选 1+1-0=2、5+3-1=7、1+5-2=4;右扫:rmx 依次 1、1、1,右候选 4、7、2(按扫描方向、即从右往左列),都没超过,第 1 行定成 [2,7,4]。
第 2 行左扫:lmx 依次 2、8、8,左候选 3+2-0=5、1+8-1=8、1+8-2=7;右扫:rmx 依次 2、6、6,右候选 1+2+2=5、1+6+1=8、3+6+0=9,第 0 列的 5 被 9 换掉。末行 [9,8,7],最大 9 即答案:三行选列 1、列 1、列 0,2+5+3=10 扣 |1-1|+|1-0|=1。
rmx 初值偷懒写成 0,答案为什么会虚高
rmx 记的 dp[r-1][k]-k 可能是负数;初值写 0 等于捏造不存在的来源,答案跟着虚高。哨兵(绝不会被选上的极小初值)得够小,Python 用负无穷;总分会越加越大,C++ 要 long long、Java 要 long。
时间 O(m·n),每行左右各扫一遍;空间 O(n),只留上一行、当前行两个数组滚动。单行没有相邻行,答案是首行最大值。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3核心就是这套:dp 逐行往下推,难点在那个绝对值。把它按方向拆成左右两段,各扫一遍取最大,就能把每格的枚举省成常数。下面把这套搬到表格上,一格一格看。
- 4先看输入这就是输入矩阵 points,三行三列。规则再念一遍:每一行必须选一格,把分数加起来;相邻两行选的列号差多少,就扣多少。比如上一行选第 2 列、这一行选第 0 列,虽然分可能高,但要扣掉 2 分。接下来我们不再看原始分,而是把它变成一张 dp 表,记录每一格作为落脚点时的最优净得分。
- 5dp[0] 就位先填第 0 行。它上面没有别的行,不存在相邻行的扣分,所以在第 0 行落脚的最优得分就等于这一格本身的分数。dp 第 0 行直接抄下 points 第 0 行:1、2、3。这三个数是后面所有推导的地基,下面每一行都要踩着上一行往下算。
- 6朴素 O(n²) 太慢先看笨办法,好理解优化从哪来。想算 dp[1][1],也就是第 1 行落在第 1 列。它可以从上一行任意一列转过来,得分是上一行那格的 dp 减去两列的列号差。上一行第 0 列给 1 减 1 等于 0,第 1 列给 2 减 0 等于 2,第 2 列给 3 减 1 等于 2,取最大的 2,再加本格的 5,得到 7。问题是:每一格都这样枚举上一行全部列,一行 n 格就是 n 的平方,行多列多就慢了。
- 7左右两遍优化的钥匙就是那个绝对值。当上一行的列 k 在当前列 c 的左边,也就是 k 不大于 c,列号差就是 c 减 k,把它塞进转移里整理一下,只跟 dp[k] 加 k 有关,对固定的 c 来说 c 是常数。于是从左往右扫,维护一个 dp[k] 加 k 的最大值 lmx,每格拿它减去 c 就行。反过来,k 在右边时列号差是 k 减 c,只跟 dp[k] 减 k 有关,再从右往左扫一遍,维护 dp[k] 减 k 的最大值 rmx。两遍扫完,每格取较大的那个结果,枚举就省掉了。
- 8第 1 行 左扫中开始第 1 行的左扫,从第 0 列起。lmx 记的是上一行走到这里为止,dp[k] 加 k 的最大值。第 0 列只能看上一行第 0 列,lmx 就是 1。本格净得分等于本格分 1 加 lmx 再减去本列列号 0,算出 2,先落一个左扫的临时值。这只是考虑左边来源的临时值,右扫还要再比一次。
- 9第 1 行 左扫中往右挪到第 1 列。lmx 顺手把上一行第 1 列的 dp 加 1 也纳入比较,更新成 3,这正是它比一格一格枚举省事的地方,前面看过的最大值一路带着走。本格分 5 加 lmx 3 减列号 1,得到 7。这只是考虑左边来源的临时值,右扫还要再比一次。
- 10第 1 行 左扫中往右挪到第 2 列。lmx 顺手把上一行第 2 列的 dp 加 2 也纳入比较,更新成 5,这正是它比一格一格枚举省事的地方,前面看过的最大值一路带着走。本格分 1 加 lmx 5 减列号 2,得到 4。这只是考虑左边来源的临时值,右扫还要再比一次。
- 11第 1 行 右扫中右扫从最右一列往回走,先看第 2 列。rmx 更新到 1,右候选算出 4,和左扫的临时值 4 比一下取大的,这格定成 4。左边来源已经够好,值保持不变。
- 12第 1 行 右扫中右扫从最右一列往回走,到第 1 列。rmx 更新到 1,右候选算出 7,和左扫的临时值 7 比一下取大的,这格定成 7。左边来源已经够好,值保持不变。
- 13第 1 行 右扫打量右扫回到第 0 列。rmx 记的是上一行 dp[k] 减 k 的最大值,现在等于 1。它代表从右边某列斜过来的最好来源。本格右候选等于本格分 1 加 rmx 1 再加列号 0,等于 2。而它左扫时的临时值是 2。两个数摆在一起,下一帧见分晓,看右边的来源能不能把它抬高。
- 14第 1 行 右扫定案取两者较大:左扫的临时值 2 已经不小于右候选 2,所以第 0 列取较大后维持 2 不变。这一遍右侧来源没能超过它,但右扫这一遍仍不能省,换组数据右边就可能更优。这一格正式定案。
- 15第 1 行 就位第 1 行左右两遍都扫完了,三格的最优净得分定下来:2、7、4。注意第 1 列的 7,正是刚才朴素办法算出的那个 7,说明两遍扫描给出的结果和逐列枚举完全一致,只是快得多。这一行马上又成为下一行的上一行,继续往下推。
- 16第 2 行 左扫中开始第 2 行的左扫,从第 0 列起。lmx 记的是上一行走到这里为止,dp[k] 加 k 的最大值。第 0 列只能看上一行第 0 列,lmx 就是 2。本格净得分等于本格分 3 加 lmx 再减去本列列号 0,算出 5,先落一个左扫的临时值。这只是考虑左边来源的临时值,右扫还要再比一次。
- 17第 2 行 左扫中往右挪到第 1 列。lmx 顺手把上一行第 1 列的 dp 加 1 也纳入比较,更新成 8,这正是它比一格一格枚举省事的地方,前面看过的最大值一路带着走。本格分 1 加 lmx 8 减列号 1,得到 8。这只是考虑左边来源的临时值,右扫还要再比一次。
- 18第 2 行 左扫中往右挪到第 2 列。lmx 顺手把上一行第 2 列的 dp 加 2 也纳入比较,更新成 8,这正是它比一格一格枚举省事的地方,前面看过的最大值一路带着走。本格分 1 加 lmx 8 减列号 2,得到 7。这只是考虑左边来源的临时值,右扫还要再比一次。
- 19第 2 行 右扫中右扫从最右一列往回走,先看第 2 列。rmx 更新到 2,右候选算出 5,和左扫的临时值 7 比一下取大的,这格定成 7。左边来源已经够好,值保持不变。
- 20第 2 行 右扫中右扫从最右一列往回走,到第 1 列。rmx 更新到 6,右候选算出 8,和左扫的临时值 8 比一下取大的,这格定成 8。左边来源已经够好,值保持不变。
- 21第 2 行 右扫打量右扫回到第 0 列。rmx 记的是上一行 dp[k] 减 k 的最大值,现在等于 6。它代表从右边某列斜过来的最好来源。本格右候选等于本格分 3 加 rmx 6 再加列号 0,等于 9。而它左扫时的临时值是 5。两个数摆在一起,下一帧见分晓,看右边的来源能不能把它抬高。
- 22第 2 行 右扫定案取两者较大:右候选 9 比左扫的 5 更高,把它抬上来,第 0 列最终定成 9。看,右扫这一遍不是走过场,正是它把这格从 5 拉到了 9,说明这一格的最优来源在它右边那一列。这一格正式定案。
- 23第 2 行 就位第 2 行左右两遍都扫完了,三格的最优净得分定下来:9、8、7。这一行就是最后一行,每一格代表在这里收尾能拿到的最大净得分。答案就藏在这三个数里。
- 24答案 = 9最后一行填的是走到底、分别落在各列时的最优净得分。我们可以在最后一行的任意一列收尾,所以答案就是这一行里最大的那个数。三个数 9、8、7 里,第 0 列的 9 最大,这就是最终答案。
- 25路径净得 = 9倒回去看它是怎么长出来的。末行第 0 列的 9,是右扫时从第 1 行第 1 列的 7 转过来的;那个 7 又是从第 0 行第 1 列的 2 接上来的。对应到原始选格,就是第 0 行选第 1 列拿 2,第 1 行选第 1 列拿 5,第 2 行选第 0 列拿 3,原始分共 10,扣掉列号差 0 加 1 等于 1,净得 9。上一行选第 2 列拿 3 也能并列得到同样的 9,殊途同归。这条链子把 dp 表和真实选法对上了。
⚠️ 容易写错的地方
✗ 错:每格都枚举上一行所有列取最大
✓ 对:把绝对值拆成左右两段,各用一遍前缀最大值
直接枚举是 O(m·n²),数据一大就超时;拆方向后每行两遍线性扫描降到 O(m·n)
✗ 错:绝对值不拆方向直接算 |c-k|
✓ 对:按 k≤c 与 k≥c 拆成 c-k 与 k-c 两段
不拆方向就没法把式子整理成只与 dp[k]+k 或 dp[k]-k 有关,也就用不上前缀最大值优化
✗ 错:用 int 存 dp 和累加值
✓ 对:C 加加用 long long、Java 用 long
行列规模和分值都可达十万量级,累加会超出 int 范围溢出,Python 整数天然不溢出
✗ 错:只做左扫或只做右扫就取值
✓ 对:左右两遍都做,每格取两者较大
最优来源可能在当前列的左边也可能在右边,漏掉一侧就会少算,必须两遍都比
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def maxPoints(self, points: List[List[int]]) -> int:
n = len(points[0])
f = points[0][:]
for p in points[1:]:
g = [0] * n
lmx = -inf
for j in range(n):
lmx = max(lmx, f[j] + j)
g[j] = max(g[j], p[j] + lmx - j)
rmx = -inf
for j in range(n - 1, -1, -1):
rmx = max(rmx, f[j] - j)
g[j] = max(g[j], p[j] + rmx + j)
f = g
return max(f)C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
long long maxPoints(vector<vector<int>>& points) {
using ll = long long;
int n = points[0].size();
vector<ll> f(n);
const ll inf = 1e18;
for (auto& p : points) {
vector<ll> g(n);
ll lmx = -inf, rmx = -inf;
for (int j = 0; j < n; ++j) {
lmx = max(lmx, f[j] + j);
g[j] = max(g[j], p[j] + lmx - j);
}
for (int j = n - 1; ~j; --j) {
rmx = max(rmx, f[j] - j);
g[j] = max(g[j], p[j] + rmx + j);
}
f = move(g);
}
return *max_element(f.begin(), f.end());
}
};Java
import java.util.*;
class Solution {
public long maxPoints(int[][] points) {
int n = points[0].length;
long[] f = new long[n];
final long inf = 1L << 60;
for (int[] p : points) {
long[] g = new long[n];
long lmx = -inf, rmx = -inf;
for (int j = 0; j < n; ++j) {
lmx = Math.max(lmx, f[j] + j);
g[j] = Math.max(g[j], p[j] + lmx - j);
}
for (int j = n - 1; j >= 0; --j) {
rmx = Math.max(rmx, f[j] - j);
g[j] = Math.max(g[j], p[j] + rmx + j);
}
f = g;
}
long ans = 0;
for (long x : f) {
ans = Math.max(ans, x);
}
return ans;
}
}复杂度
时间
O(m·n)
m 行,每行做左、右两遍长度为 n 的线性扫描,每格只做常数次比较与加减,总量随格子数线性增长。相比朴素每格枚举上一行 n 列的 O(m·n²),省掉了一层
空间
O(n)
按峰值算。只需保存上一行 dp 和当前行 dp 两个长度为 n 的数组滚动使用,不必存整张 m 行的表,所以是 O(n) 而不是 O(m·n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 扣分后的最大得分 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
左扫已经把每格都算过一遍了,右扫真的省不掉吗?+
省不掉。左扫只看得见来自左侧(含同列)的来源,最优来源在右边时它一无所知。示例第 2 行第 0 列就是:左候选只有 5,真正的最优是从上一行第 1 列的 7 转过来、扣 1 列差再加本格的 3 得 9,这个 9 只能靠右扫的 rmx 带回来。哪一遍都不能单独收工,每格必须两个候选取大。
lmx 为什么维护 dp[r-1][k]+k,而不是直接维护 dp[r-1][k]?+
左侧来源的真实贡献是 dp[r-1][k]-(c-k)=dp[r-1][k]+k-c。同一格的比较里 c 是公共项,谁的 dp[r-1][k]+k 大谁就是最好来源,所以把 +k 折进去先比出最大,落格时统一减 c。直接比 dp[r-1][k] 会漏掉「离得远扣得多」这笔账,选出的未必是净贡献最大的那列。同款拆绝对值+前缀最值技巧亦见 LeetCode 1014 最佳观光组合。
dp 表有 m 行,为什么空间只要 O(n)?+
算第 r 行只用到第 r-1 行,更早的行再也不会被读。所以只开两个长度 n 的数组 f、g:f 存上一行、g 存当前行,算完一行把 g 赋给 f 接着往下推,整张 m 行的表不必存,空间从 O(m·n) 降到 O(n)。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 扣分后的最大得分 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。