规划兼职工作 图解题解
这道题到底在问什么
- 输入
- start=[1,2,3,3], end=[3,4,5,6], profit=[50,10,40,70]
- 输出
- 120 (选第 1 份收益 50 + 第 4 份收益 70)
最优解:为什么这么做
一句话答案:LeetCode 1235 规划兼职工作用排序加 DP 加二分:工作按结束时间排好,dp[i] 记前 i 份最大收益,每份在「不接」与「接它加二分找到的前驱」之间取大,时间 O(n log n)、空间 O(n)。
规划兼职工作到底在挑哪几份
给三个等长数组 startTime、endTime、profit,第 i 份工作占区间 [开始, 结束)(右端不含:上一份结束正好等于下一份开始,不算重叠)、做完拿 profit[i]。挑若干份、时间互不重叠,让收益之和最大。题面 start=[1,2,3,3]、end=[3,4,5,6]、profit=[50,10,40,70],答案 120。
为什么挨个试选或不选会爆
每份工作都有「选」「不选」两条岔路,n 份就有 2ⁿ 种组合,逐一验重叠求和,n 一大数不完,且组合前半截大量重合被反复重算。把「前面若干份的最优收益」记下来复用就省了——这就是动态规划(DP,把前若干份工作的最大报酬记表、后面直接取)。
按结束时间排序,dp[i] 到底存什么
先把工作按结束时间从小到大排好,本例得结束 = [3,4,5,6]、开始 = [1,2,3,3]、收益 = [50,10,40,70]。排好后,任一份工作「能接在前面的那批」必是连续的一段前缀(前缀=最靠前的一段),才好用二分(在有序数据里对半砍、快速定位的查法)切出来。据此定义 dp[i]:前 i 份、不重叠时能拿的最大收益。dp[0] = 0:没考虑任何工作。
接一份工作,前驱该从哪一格接
填 dp[i] 时,第 i 份只有两条路。不接它,收益沿用 dp[i-1]。接它,就先找前面「结束时间 ≤ 它的开始时间」的工作,份数记作 j,用二分找结束时间里「第一个大于当前开始时间的位置」,那个下标就是 j。
前 j 份的最优收益存在 dp[j] 里,接上加本份 profit 就是「接这份」的收益。两条取大,转移(由已算好的格子推出当前格的式子)即 dp[i] = max(dp[i-1], dp[j] + profit)。
拿题面四份工作把表填出来
接上一节排好的顺序填表,dp[0] = 0。前两份开始 1、2。第 1 份最靠前、没有前驱;第 2 份开始 2,排在它前面的只有第 1 份、结束在 3,3 > 2 接不上,两份都只能单干:dp[1] = 50;第 2 份自身 10 不如沿用,dp[2] = 50。第 3 份开始 3、收益 40,结束 ≤ 3 有 1 份,接 dp[1] 得 90 胜过 50,dp[3] = 90。第 4 份开始 3、收益 70,结束 ≤ 3 有 1 份,接 dp[1] 得 120 胜过 90,dp[4] = 120。
二分边界写成 <,正好接得上的那份为什么会丢
把二分等号写丢——用「结束时间 < 开始时间」找前驱,第 3 份卡在 3 = 3 的工作被排除,dp[4] 接不到 dp[1],120 缩水成 90,题面明明写着结束等于开始算可接,代码就得用 ≤。另一处是排序键:必按结束时间排,可接工作才凑成能二分的前缀,错按开始或收益排就失效。
排序花 O(n log n)(大 O 记号描述数据规模变大时操作数怎么涨),之后填 n 格、每格一次二分是 O(log n),合起来仍是 O(n log n),对 n 到 5 万完全够用;dp 与结束时间数组都是 O(n) 空间。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3记住这句口诀:dp[i] = max( 不接(dp[i-1]) , 接它(dp[二分位置] + 本份收益) )。下面每填一格都在套它。
- 4第一步,把四份工作按结束时间升序排好,得到结束 = [3,4,5,6]、开始 = [1,2,3,3]、收益 = [50,10,40,70]。排序的好处是:任何一份工作能配的「前面那批」一定是排序后连续的前缀,这样才好用二分去切。
- 5dp 第 0 格固定是 0:还没考虑任何工作,收益自然是 0。绿色这一格是后面所有计算的地基。
- 6现在要填 dp[1]。当前这份工作开始 1、结束 3、收益 50。它有两条路:不接它,或者接它。先看接它能配上谁。
- 7接它的话,前面只能配「结束时间 ≤ 它的开始时间 1」的工作。在已排好的结束时间上二分,数出这样的工作有 0 份(一份都没有,它开始得太早)。这 0 份的最优组合正是 dp[0](蓝色)。
- 8两条路算清楚:不接这份,收益沿用 dp[0] = 0;接这份,收益是它配上 dp[0] = 0,再加自己的 50,得 50。接它更高,选 50。
- 9第 1 格结算:dp[1] = 50(绿色锁定)。后面更晚结束的工作会接着用它。
- 10现在要填 dp[2]。当前这份工作开始 2、结束 4、收益 10。它有两条路:不接它,或者接它。先看接它能配上谁。
- 11接它的话,前面只能配「结束时间 ≤ 它的开始时间 2」的工作。在已排好的结束时间上二分,数出这样的工作有 0 份(一份都没有,它开始得太早)。这 0 份的最优组合正是 dp[0](蓝色)。
- 12两条路算清楚:不接这份,收益沿用 dp[1] = 50;接这份,收益是它配上 dp[0] = 0,再加自己的 10,得 10。不接更高,选 50。
- 13第 2 格结算:dp[2] = 50(绿色锁定)。后面更晚结束的工作会接着用它。
- 14现在要填 dp[3]。当前这份工作开始 3、结束 5、收益 40。它有两条路:不接它,或者接它。先看接它能配上谁。
- 15接它的话,前面只能配「结束时间 ≤ 它的开始时间 3」的工作。在已排好的结束时间上二分,数出这样的工作有 1 份(结束时间 3 都 ≤ 3)。这 1 份的最优组合正是 dp[1](蓝色)。
- 16两条路算清楚:不接这份,收益沿用 dp[2] = 50;接这份,收益是它配上 dp[1] = 50,再加自己的 40,得 90。接它更高,选 90。
- 17第 3 格结算:dp[3] = 90(绿色锁定)。后面更晚结束的工作会接着用它。
- 18现在要填 dp[4]。当前这份工作开始 3、结束 6、收益 70。它有两条路:不接它,或者接它。先看接它能配上谁。
- 19接它的话,前面只能配「结束时间 ≤ 它的开始时间 3」的工作。在已排好的结束时间上二分,数出这样的工作有 1 份(结束时间 3 都 ≤ 3)。这 1 份的最优组合正是 dp[1](蓝色)。
- 20两条路算清楚:不接这份,收益沿用 dp[3] = 90;接这份,收益是它配上 dp[1] = 50,再加自己的 70,得 120。接它更高,选 120。
- 21第 4 格结算:dp[4] = 120(绿色锁定)。后面更晚结束的工作会接着用它。
- 22表填满,最右一格 dp[4] = 120 就是答案。
- 23回看具体选了谁:接第 1 份(结束 3、收益 50),它结束在 3;再接第 4 份(开始正好是 3、结束 6、收益 70),开始时间等于上一份结束时间,不冲突。两份相加 50 + 70 = 120,正是最大收益。
⚠️ 容易写错的地方
✗ 错:按开始时间排序
✓ 对:按结束时间排序
只有按结束时间排,才能保证「能配的前面那批」是连续前缀,二分才成立
✗ 错:二分条件写成 ends[m] < s 找下界
✓ 对:upper_bound 语义: ends[m] ≤ s 时往右
结束时间正好等于开始时间算不冲突可接,用 ≤ 才能把这份也算进可配范围
✗ 错:接它时配 dp[i-1]
✓ 对:接它时配二分得到的 dp[j]
dp[i-1] 可能包含与本份时间冲突的工作,只有 dp[j] 才保证全部结束于本份开始之前
完整代码(Python / C++ / Java)
Python
from typing import List
from bisect import bisect_right
class Solution:
def jobScheduling(self, startTime: List[int], endTime: List[int], profit: List[int]) -> int:
jobs = sorted(zip(endTime, startTime, profit))
ends = [e for e, _, _ in jobs]
dp = [0] * (len(jobs) + 1)
for i, (e, s, p) in enumerate(jobs, 1):
j = bisect_right(ends, s, 0, i - 1)
dp[i] = max(dp[i - 1], dp[j] + p)
return dp[-1]C++
#include <algorithm>
#include <tuple>
#include <vector>
using namespace std;
class Solution {
public:
int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit) {
vector<tuple<int,int,int>> jobs;
for (int i = 0; i < (int)startTime.size(); ++i) jobs.push_back({endTime[i], startTime[i], profit[i]});
sort(jobs.begin(), jobs.end());
vector<int> ends, dp(jobs.size() + 1);
for (auto [e, s, p] : jobs) ends.push_back(e);
for (int i = 1; i <= (int)jobs.size(); ++i) {
auto [e, s, p] = jobs[i - 1];
int j = upper_bound(ends.begin(), ends.begin() + i - 1, s) - ends.begin();
dp[i] = max(dp[i - 1], dp[j] + p);
}
return dp.back();
}
};Java
import java.util.*;
class Solution {
public int jobScheduling(int[] startTime, int[] endTime, int[] profit) {
int n = startTime.length;
int[][] jobs = new int[n][3];
for (int i = 0; i < n; i++) { jobs[i][0] = endTime[i]; jobs[i][1] = startTime[i]; jobs[i][2] = profit[i]; }
Arrays.sort(jobs, Comparator.comparingInt(a -> a[0]));
int[] ends = new int[n], dp = new int[n + 1];
for (int i = 0; i < n; i++) ends[i] = jobs[i][0];
for (int i = 1; i <= n; i++) {
int s = jobs[i - 1][1], p = jobs[i - 1][2];
int j = upperBound(ends, i - 1, s);
dp[i] = Math.max(dp[i - 1], dp[j] + p);
}
return dp[n];
}
private int upperBound(int[] a, int hi, int target) {
int l = 0, r = hi;
while (l < r) {
int m = (l + r) >>> 1;
if (a[m] <= target) l = m + 1; else r = m;
}
return l;
}
}复杂度
时间
O(n log n)
排序 O(n log n);填表每格一次二分 O(log n),共 n 格
空间
O(n)
dp 数组与 ends 数组,长度都是 n 级别
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 规划兼职工作 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么必须按结束时间排序,按开始时间排不行吗?+
排序的目的是让「能接在某份工作前面的那批工作」凑成一段连续前缀,好用二分快速切出来。判断能不能接,看的是「前一份的结束时间 ≤ 这一份的开始时间」,所以按结束时间排:结束时间有序时,所有结束得足够早的工作正好落在数组最前面一段,二分找一次边界就数清有几份。若按开始时间或收益排,这段前缀性质就不成立,一份工作的合法前驱会散落在数组各处,没法二分,只能退回逐个扫描。
二分找到的那个 j,为什么正好能当 dp 的下标?+
因为工作按结束时间排好后,前 j 份恰好就是「结束时间 ≤ 当前开始时间」的全部工作。dp[j] 的定义正是「前 j 份工作的最大收益」,也就是「接当前这份之前,所有合法前驱能凑出的最优收益」。二分数出的份数和 dp 的下标语义天然对齐——数出几份,就接哪一格。这也是为什么二分要找「第一个严格大于开始时间的位置」,那个位置的下标恰好等于结束时间 ≤ 开始时间的工作个数。
这道题和只求最多能做几份、不带收益的区间调度有什么关系?+
不带收益、只求最多做几份的那类区间调度,用贪心就能解:按结束时间排序后,能接就接、每次都留结束最早的,是经典的贪心最优。但一旦每份带上不同收益、要的是收益最大而非份数最多,贪心「留结束最早」的策略就可能丢掉高收益的长工作,不再最优,必须换成这里的排序 + DP + 二分。认出「带权区间调度 = 排序 + DP + 二分」这个母题,是这道题真正可迁移的部分。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 规划兼职工作 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。