两个最好的不重叠活动 图解题解
这道题到底在问什么
- 输入
- events=[[1,3,2],[4,5,2],[2,4,3]]
- 输出
- 4 (选活动 [1,3,2] 与 [4,5,2],2 加 2)
- 输入
- events=[[1,5,3],[1,5,1],[6,6,5]]
- 输出
- 8 (选 [1,5,3] 与 [6,6,5],3 加 5)
最优解:为什么这么做
一句话答案:LeetCode 2054 两个最好的不重叠活动:等号也算重叠。按开始时间排序后能接的搭档是一段后缀,倒着填后缀最大值 f,逐活动二分找开始严格大于结束的边界配对取最大。时间 O(n log n)、空间 O(n)。
结束于 5、开始于 5,凭什么也算重叠
每个活动给三个数:开始、结束、价值。最多挑两个参加,求价值之和最大,只挑一个也行。规则里埋着一个等号:开始和结束两个时刻都算占用——结束于 5、另一个从 5 开始,就在时刻 5 撞上,后者至少从 6 开始才接得上。写成条件就是后一个的开始必须严格大于前一个的结束,差一个等号,合法性完全两样。
活动两两硬配,为什么 n 一大就试不完
n 个活动两两配成对约有 n²/2 种,几万个活动就是十亿量级,逐对查重叠、算价值和,跑不完(O(n²),大 O 描述数据量变大时操作次数怎么涨)。慢的根子是同一个数被重算了 n 遍:固定一个活动后,真正想知道的只有一件事——在它结束之后才开始的那批活动里,最大价值是多少。提前存好就省了。
f 这一列为什么要从最后一行倒着填
先按开始时间从小到大排序,把排序后的活动列成表、一行一个活动。某个活动的开始一旦大于我的结束,排它后面的活动也都大于——能接上的搭档连成一段尾巴。尾巴里只关心价值最高的那个,于是定义后缀最大值 f[i](从第 i 行起到最后一行,价值最大是多少)。
这是一小段动态规划(先把『第 i 行往下价值最高是多少』逐行填好,配对时直接查):f[i]=max(f[i+1], 本行价值),每格依赖下一格,只能从最后一行倒着往上填,最后一行的 f 就是它自己的价值。
每个活动的搭档,为什么二分一次就锁定
『开始大于我的结束』在排好的数组里从某一行起整段成立,一刀切的分界正适合二分(每次看中间那行、一半一半排除的查找法),找出第一个开始严格大于当前结束的下标 idx,f[idx] 加本活动价值就是以它打头的最好组合。idx 越界说明谁都接不上,本活动单独参加,裸价值也参与取最大,『只选一个』就这么算进去了。
拿题面三个活动,亲手把 4 算出来
用题面第一组 events=[[1,3,2],[4,5,2],[2,4,3]]。按开始排序得 [[1,3,2],[2,4,3],[4,5,2]]。倒着填 f(下标从 0 数起):f[2]=2;f[1]=max(2,3)=3;f[0]=max(3,2)=3,f=[3,3,2]。
逐个配对:[1,3,2] 结束于 3,第一个开始大于 3 的是下标 2 的 [4,5,2],得 2+f[2]=2+2=4;[2,4,3] 结束于 4,下标 2 的开始是 4,不严格大于 4,接不上,单独算 3;[4,5,2] 结束于 5,后面没人,单独算 2。候选 4、3、2 取最大,答案 4,题面给的也是 4。
边界写成大于等于,5 分为什么会冒出来
二分条件一旦写成大于等于,[2,4,3] 就会接上开始于 4 的 [4,5,2],得 3+2=5——多出的 1 分来自时刻 4 被占两次的非法组合。f 的方向也别弄错,f[i] 靠 f[i+1] 推,从上往下填拿到的全是没算好的值;二分还依赖排序,没排就没有整段分界可找。
排序 O(n log n) 是大头,填 f 一趟 O(n),逐活动二分共 O(n log n),合计 O(n log n);额外一列 f,空间 O(n)。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记牢这套:排序,填后缀最大值 f,再逐个活动找第一个开得下的边界、用本价值加边界处的 f。下面按这套一步步填表。
- 4五个活动待处理先把五个活动摊成一张表,每一行是一个活动,列从左到右是开始、结束、价值,右边两列 f 和配对是我们待会儿要算的辅助值,现在先空着。左边那一列是活动的名字 A 到 E。你看开始时间这一列是 3,1,6,2,4,乱的,第一步得把它排好。
- 5已按开始升序按开始时间从小到大排一遍。现在左边活动名的顺序变成了 B、D、A、E、C,开始时间这一列(绿色)是 1、2、3、4、6,规规矩矩升序。排序这一步很关键:排好之后,只要某个活动的开始时间大于我的结束时间,它以及它下面所有活动的开始时间都大于我的结束,天然接得上,后面找边界才能一刀切。
- 6辅助列待填正式开算前,先说清右边两列是干嘛的。f 这一列,表示从这一行往下、价值最高的活动值多少,它帮我们一眼看出结束之后能接的最好活动。配对这一列,是把本活动价值,加上它结束后能接的那个最优价值,得到以这个活动打头的最好组合。两列都从下往上、从上往下分两趟填,先填 f。
- 7f 填到第 5 行从最后一行活动 C 开始填 f。它下面没有别人了,所以从它往下价值最大的就是它自己,f 记成它的价值 6。
- 8f 填到第 4 行往上填活动 E 这一行的 f。比较两个数:下面一行的 f 是 6,本行自己的价值是 3,谁大取谁,f = 6。下面已经有更高价值的活动,f 沿用 6。
- 9f 填到第 3 行往上填活动 A 这一行的 f。比较两个数:下面一行的 f 是 6,本行自己的价值是 7,谁大取谁,f = 7。本行价值更大,f 被自己刷新了。
- 10f 填到第 2 行往上填活动 D 这一行的 f。比较两个数:下面一行的 f 是 7,本行自己的价值是 5,谁大取谁,f = 7。下面已经有更高价值的活动,f 沿用 7。
- 11f 填到第 1 行往上填活动 B 这一行的 f。比较两个数:下面一行的 f 是 7,本行自己的价值是 4,谁大取谁,f = 7。下面已经有更高价值的活动,f 沿用 7。
- 12f 列就绪f 这一列填完了,从上到下是 7、7、7、6、6。它的含义再强调一遍:比如第四行 E 的 f 是 6,意思是从 E 往下(也就是 E 和 C 里)价值最高的是 6。有了这一列,任何活动只要知道结束后从哪一行接得上,就能立刻查出那之后能拿的最高价值。接下来逐个活动算配对。
- 13为 B 找搭档轮到活动 B 当打头的,它结束于 2。往下找第一个开始时间严格大于 2 的活动,是活动 A,它的开始是 3(紫色是本活动结束,黄色是接得上的分界)。从这一行往下的活动都不会和 B 撞。
- 14B 配对 = 11接得上就好办了。从分界那一行往下最高价值就是那行的 f,等于 7。用 B 自己的价值 4 加上 7,配对总价值是 11(绿色),记到配对列。目前所有配对里最大的是 11。
- 15为 D 找搭档轮到活动 D 当打头的,它结束于 4。往下找第一个开始时间严格大于 4 的活动,是活动 C,它的开始是 6(紫色是本活动结束,黄色是接得上的分界)。从这一行往下的活动都不会和 D 撞。
- 16D 配对 = 11接得上就好办了。从分界那一行往下最高价值就是那行的 f,等于 6。用 D 自己的价值 5 加上 6,配对总价值是 11(绿色),记到配对列。目前所有配对里最大的是 11。
- 17为 A 找搭档轮到活动 A 当打头的,它结束于 5。往下找第一个开始时间严格大于 5 的活动,是活动 C,它的开始是 6(紫色是本活动结束,黄色是接得上的分界)。从这一行往下的活动都不会和 A 撞。
- 18A 配对 = 13接得上就好办了。从分界那一行往下最高价值就是那行的 f,等于 6。用 A 自己的价值 7 加上 6,配对总价值是 13(绿色),记到配对列。目前所有配对里最大的是 13。
- 19为 E 找搭档轮到活动 E 当打头的,它结束于 6。可它已经排在很后面,下面再没有哪个活动的开始时间大于 6 了,谁都接不上。那它只能单独参加。
- 20E 配对 = 3接不上,活动 E 只能自己参加,配对总价值就是它自己的价值 3(绿色)。目前所有配对里最大的仍是 13。
- 21为 C 找搭档轮到活动 C 当打头的,它结束于 7。可它已经排在很后面,下面再没有哪个活动的开始时间大于 7 了,谁都接不上。那它只能单独参加。
- 22C 配对 = 6接不上,活动 C 只能自己参加,配对总价值就是它自己的价值 6(绿色)。目前所有配对里最大的仍是 13。
- 23答案 = 13五个活动的配对都算完了,配对这一列从上到下是 11、11、13、3、6。里头最大的是 13,出现在活动 A 那一行(绿色)。这个 13 就是最终答案。
- 24最优 = 13把这个 13 拆开看是谁凑的。打头的是活动 A,开始 3 结束 5,价值 7;它结束于 5,接上开始于 6 的活动 C,价值 6。两段时间不碰,价值 7 加 6 等于 13,这就是最优组合。
⚠️ 容易写错的地方
✗ 错:把不重叠写成开始 ≥ 前一个结束
✓ 对:必须开始严格大于前一个结束
结束时间也算占用,结束于 t 的活动,下一个要从 t 加 1 起,写成大于等于会误判两个挨着的活动可以同选
✗ 错:忘了只参加一个也算答案
✓ 对:配对里天然含单独参加(接不上时配对就是本价值)
有时一个高价值大活动比任何两个小活动加起来都强,漏掉会算小
✗ 错:找边界时忘了排序前提
✓ 对:先按开始时间排序再二分
二分要求开始时间单调,不排序直接二分找到的分界是错的
✗ 错:f 从前往后填
✓ 对:f 是后缀最大值,必须从后往前填
f 在某行要用到它下面一行的结果,顺序反了就取不到正确的后缀最大值
完整代码(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 maxTwoEvents(self, events: List[List[int]]) -> int:
events.sort()
n = len(events)
f = [events[-1][2]] * n
for i in range(n - 2, -1, -1):
f[i] = max(f[i + 1], events[i][2])
ans = 0
for _, e, v in events:
idx = bisect_right(events, e, key=lambda x: x[0])
if idx < n:
v += f[idx]
ans = max(ans, v)
return ansC++
#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:
int maxTwoEvents(vector<vector<int>>& events) {
sort(events.begin(), events.end());
int n = events.size();
vector<int> f(n + 1);
for (int i = n - 1; ~i; --i) {
f[i] = max(f[i + 1], events[i][2]);
}
int ans = 0;
for (const auto& e : events) {
int v = e[2];
int left = 0, right = n;
while (left < right) {
int mid = (left + right) >> 1;
if (events[mid][0] > e[1]) {
right = mid;
} else {
left = mid + 1;
}
}
if (left < n) {
v += f[left];
}
ans = max(ans, v);
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int maxTwoEvents(int[][] events) {
Arrays.sort(events, (a, b) -> a[0] - b[0]);
int n = events.length;
int[] f = new int[n + 1];
for (int i = n - 1; i >= 0; --i) {
f[i] = Math.max(f[i + 1], events[i][2]);
}
int ans = 0;
for (int[] e : events) {
int v = e[2];
int left = 0, right = n;
while (left < right) {
int mid = (left + right) >> 1;
if (events[mid][0] > e[1]) {
right = mid;
} else {
left = mid + 1;
}
}
if (left < n) {
v += f[left];
}
ans = Math.max(ans, v);
}
return ans;
}
}复杂度
时间
O(n log n)
排序是 O(n log n),是主导项。填后缀最大值 f 扫一遍是 O(n)。之后枚举每个活动、各做一次二分找边界,是 O(n log n)。合起来 O(n log n)
空间
O(n)
按峰值算。额外开了一列长度约 n 的后缀最大值数组 f,所以是 O(n)。排序本身的额外开销三语言不同,但都不超过这一列的量级
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 两个最好的不重叠活动 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么按开始时间排序,而不是像会议室类题那样按结束时间排?+
因为每个活动要找的是『开始在我结束之后』的活动集合,按开始时间排序后这批活动恰好是数组末尾连续的一段,一列后缀最大值 f 就能把『那之后的最优』全部预处理出来。按结束时间排也能做:改成维护前缀最大值,对每个活动二分找最后一个结束早于它开始的位置,两种是镜像写法,复杂度相同。
算法哪里保证了最多只选两个活动?+
配对时每个候选都是『本活动价值加 f[idx]』,而 f 存的是单个活动的价值最大值,不是某种组合的和,所以任何候选至多由两个活动构成;idx 越界时只剩本活动一个。若题目放宽到最多选 k 个,一列 f 就不够用了,得把它扩成带『还能再选几个』这一维的表再递推。LeetCode 1235 和 2008 就是这种不限两个的同族带权区间调度题。
不用二分,还有别的写法吗?+
有,扫描线加最小堆:按开始时间排序后逐个活动扫,堆按结束时间排、存扫过的活动。每到一个新活动,先把堆里结束时间小于它开始时间的全部弹出,同时更新『已结束活动的最大价值』,再用这个最大价值加当前活动价值去更新答案。每个活动进出堆各一次,整体仍是 O(n log n)。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 两个最好的不重叠活动 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。