最佳观光组合 图解题解
这道题到底在问什么
- 输入
- values=[8,1,5,2,6]
- 输出
- 11 (选 i=0、j=2:8+5+0−2=11)
最优解:为什么这么做
一句话答案:LeetCode 1014 最佳观光组合:得分拆成 (values[i]+i) 加 (values[j]−j),遍历 j 维护左边 values[i]+i 的最大值 best,一遍扫描取最优,时间 O(n)、空间 O(1)。
这道题到底在挑哪两个景点
给一排景点评分 values,任取两个不同景点,下标小的记 i、大的记 j(i 小于 j),得分是 values[i]+values[j]+i−j,要在所有点对里找最高分。
两个评分越高越好,但下标差 j−i 越大扣得越多——离得远的一对哪怕评分高,也可能被距离拖垮,不能只挑评分最高的两个。
为什么两两都配一遍会超时
双重循环把约 n²/2 对点对全算一遍,几万规模就是几十亿次配对,O(n²)(大 O 记号,规模变大时运算量怎么涨)跑不完。
慢在每换一个右端 j,都把它左边所有候选从头再扫一遍。只要扫到 j 就立刻知道左边最好的搭档是谁,一层循环就够。
为什么把 i−j 拆到两个下标身上
得分 values[i]+values[j]+i−j 难在 i 和 j 缠在一个式子里。把 +i 归给左端、把 −j 归给右端,重写成 (values[i]+i)+(values[j]−j),两个下标就各管各了。
固定右端 j 后 (values[j]−j) 是定值,只需让左边的 (values[i]+i) 最大。于是维护一个变量 best,记扫到当前 j 时它左边所有 values[i]+i 里的最大值——这个前缀最大值,就是本题动态规划塌成的一个滚动变量。
扫到每个 j 是先算分还是先更新
遍历右端 j 做两件事,顺序要紧:先用 best+values[j]−j 结算这对得分刷新答案,此刻 best 还只含 j 左边的候选;再把 values[j]+j 纳入 best,让 j 成为后面的候选左端。
顺序必须先结算、后更新。若先把 values[j]+j 塞进 best,best 就可能是 j 自己,等于让景点自我配对,i 小于 j 破掉。参考代码里 best_left 初始化成 values[0],ans 从极小值起,循环从下标 1 起。
拿 [8,1,5,2,6] 一遍扫到底
best 初值 values[0]+0=8。下标 1:得分 8+1−1=8,答案刷成 8;values[1]+1=2 没超 8,best 不动。下标 2:得分 8+5−2=11,答案刷成 11;values[2]+2=7 没超 8。
下标 3:8+2−3=7,没超 11。下标 4:8+6−4=10,没超 11;values[4]+4=10 超过 8,best 更新成 10,但已到末尾。扫完答案 11,即选下标 0 和下标 2:8+5+0−2=11,与题面吻合。
先更新 best 再结算,答案为什么会虚高
把两步顺序倒过来:先拿 values[j]+j 更新 best、再结算。best 里就可能混进 j 自己,等于把同一个景点配成一对,距离 j−i 变成 0、白拿两份评分,i 小于 j 失守。
复杂度上只从头扫一遍、每步常数次运算,时间 O(n);只留 best、ans 两个变量,空间 O(1)。两处别马虎:只有两个元素时 best 就是下标 0,循环只结算一次;ans 初值要够小(参考代码用 −10^18),否则起点太大正确答案就刷不进去。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3记住「拆成 (values[i]+i)+(values[j]−j),扫 j 维护左边最大的 values[i]+i」,下面每帧都在套它。
- 4开局把第 0 个景点作为左端候选:bestLeft = values[0]+0 = 8(绿色标出当前最佳左端)。从 j=1 开始,每个 j 都拿 bestLeft 来配对。
- 5右端点走到第 1 个(评分 1)。配上左边最佳端点(绿色,下标 0):得分 8 + 1 − 1 = 8。比旧的最大得分更高,刷新 ans=8。
- 6第 1 个作为左端候选只有 values[1]+1 = 2,没超过 bestLeft=8,最佳左端不动,绿色仍停在下标 0。
- 7右端点走到第 2 个(评分 5)。配上左边最佳端点(绿色,下标 0):得分 8 + 5 − 2 = 11。比旧的最大得分更高,刷新 ans=11。
- 8第 2 个作为左端候选只有 values[2]+2 = 7,没超过 bestLeft=8,最佳左端不动,绿色仍停在下标 0。
- 9右端点走到第 3 个(评分 2)。配上左边最佳端点(绿色,下标 0):得分 8 + 2 − 3 = 7。没超过当前最大 ans=11,保持。
- 10第 3 个作为左端候选只有 values[3]+3 = 5,没超过 bestLeft=8,最佳左端不动,绿色仍停在下标 0。
- 11右端点走到第 4 个(评分 6)。配上左边最佳端点(绿色,下标 0):得分 8 + 6 − 4 = 10。没超过当前最大 ans=11,保持。
- 12再把第 4 个也纳入「左端候选」:values[4]+4 = 10,比旧的 bestLeft 大,于是最佳左端(绿色)移到下标 4,bestLeft=10。后面的 j 会用这个更优的左端。
- 13右端点走到第 5 个(评分 7)。配上左边最佳端点(绿色,下标 4):得分 10 + 7 − 5 = 12。比旧的最大得分更高,刷新 ans=12。
- 14再把第 5 个也纳入「左端候选」:values[5]+5 = 12,比旧的 bestLeft 大,于是最佳左端(绿色)移到下标 5,bestLeft=12。后面的 j 会用这个更优的左端。
- 15右端点走到第 6 个(评分 3)。配上左边最佳端点(绿色,下标 5):得分 12 + 3 − 6 = 9。没超过当前最大 ans=12,保持。
- 16第 6 个作为左端候选只有 values[6]+6 = 9,没超过 bestLeft=12,最佳左端不动,绿色仍停在下标 5。
- 17右端点走到第 7 个(评分 9)。配上左边最佳端点(绿色,下标 5):得分 12 + 9 − 7 = 14。比旧的最大得分更高,刷新 ans=14。
- 18再把第 7 个也纳入「左端候选」:values[7]+7 = 16,比旧的 bestLeft 大,于是最佳左端(绿色)移到下标 7,bestLeft=16。后面的 j 会用这个更优的左端。
- 19右端点走到第 8 个(评分 4)。配上左边最佳端点(绿色,下标 7):得分 16 + 4 − 8 = 12。没超过当前最大 ans=14,保持。
- 20第 8 个作为左端候选只有 values[8]+8 = 12,没超过 bestLeft=16,最佳左端不动,绿色仍停在下标 7。
- 21右端点走到第 9 个(评分 10)。配上左边最佳端点(绿色,下标 7):得分 16 + 10 − 9 = 17。比旧的最大得分更高,刷新 ans=17。
- 22再把第 9 个也纳入「左端候选」:values[9]+9 = 19,比旧的 bestLeft 大,于是最佳左端(绿色)移到下标 9,bestLeft=19。后面的 j 会用这个更优的左端。
- 23扫完全程,最优是绿色这一对:下标 7(评分 9)和下标 9(评分 10),得分 9+10+7−9 = 17。一遍扫描、不回头就锁定了答案。
⚠️ 容易写错的地方
✗ 错:先更新 bestLeft 再结算 ans
✓ 对:必须先结算 ans 再更新 bestLeft
反了会把 i=j 也算进去,违反 i < j
✗ 错:把公式当成不可拆的整体去枚举点对
✓ 对:拆成 (values[i]+i)+(values[j]−j) 解耦
解耦后右端只需左边的前缀最大值,O(n) 即可
✗ 错:ans 初始化为 0
✓ 对:ans 初始化为极小值
得分可能很小甚至为负,用 0 会漏掉真实最大
完整代码(Python / C++ / Java)
Python
from typing import List
class Solution:
def maxScoreSightseeingPair(self, values: List[int]) -> int:
best_left = values[0]
ans = -10**18
for j in range(1, len(values)):
ans = max(ans, best_left + values[j] - j)
best_left = max(best_left, values[j] + j)
return ansC++
#include <vector>
#include <algorithm>
#include <climits>
using namespace std;
class Solution {
public:
int maxScoreSightseeingPair(vector<int>& values) {
int bestLeft = values[0], ans = INT_MIN;
for (int j = 1; j < (int)values.size(); ++j) {
ans = max(ans, bestLeft + values[j] - j);
bestLeft = max(bestLeft, values[j] + j);
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int maxScoreSightseeingPair(int[] values) {
int bestLeft = values[0], ans = Integer.MIN_VALUE;
for (int j = 1; j < values.length; j++) {
ans = Math.max(ans, bestLeft + values[j] - j);
bestLeft = Math.max(bestLeft, values[j] + j);
}
return ans;
}
}复杂度
时间
O(n)
只扫一遍
空间
O(1)
只用 bestLeft、ans 两个变量
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 最佳观光组合 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这道题和最大子数组和那类一遍扫描是一回事吗?+
神似。它们都靠『扫到当前位置时,顺手维护一个关于前面的最优量,一遍就出答案』。最大子数组和维护的是到前一位为止的最大后缀和,本题维护的是左边最大的 values[i]+i。差别只在维护的那个量和结算公式,套路都是把一个看似要两层枚举的问题,压成一趟扫描加一个滚动的最优值。
为什么一定要先结算答案、再把当前点纳入 best?+
这一步顺序是全题最容易写反的地方。best 的职责是严格在右端 j 左边的最优左端。先结算,用到的 best 还没算上 j,配出来的左端一定在 j 前面,i 小于 j 成立。若先把 values[j]+j 纳入 best 再结算,best 可能就是 j 本身,等于让景点自己跟自己组队,得分虚高且非法。两行代码谁前谁后,直接决定答案对错。
得分里是 i−j,凭什么能拆成 +i 和 −j 分给两边?+
因为 i−j 就等于 (+i) 加 (−j),是个纯粹的代数拆分,不改变任何一对的得分。拆开的意义在于:+i 只跟左端下标有关、−j 只跟右端下标有关,于是原式 values[i]+values[j]+i−j 能整理成 (values[i]+i)+(values[j]−j),左右两半各自独立。固定右端后右半是常量,最大化就只盯左半,两层枚举才塌成一层扫描。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 最佳观光组合 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。