从一道 LeetCode Hard 到吃透一类题:加权区间调度「排序 + 二分 + DP」完整模板与 6 题详解
前言
最近做到了 LeetCode 3414:
3414. 不重叠区间的最大得分
这道题第一眼看起来比较复杂:
- 有很多区间;
- 每个区间有一个权重;
- 最多只能选择 4 个;
- 选择的区间不能重叠;
- 要让总权重最大;
- 如果最大权重相同,还要返回字典序最小的原始下标数组。
但做完之后会发现,它的核心其实是一个非常经典的算法模型:
Weighted Interval Scheduling(加权区间调度)
核心方法只有三个:
text
排序
+
二分查找
+
动态规划
为了彻底掌握这个模型,我又连续做了 5 道类似题:
text
3414 不重叠区间的最大得分
↓
1235 规划兼职工作
↓
2054 两个最好的不重叠活动
↓
1751 最多可以参加的会议数目 II
↓
2830 销售利润最大化
↓
2008 出租车的最大盈利
这篇文章就从这 6 道题出发,总结这一类题目的统一模型、识别方法、DP 状态设计、二分查找以及不同题目之间的变化。
一、什么是加权区间调度?
最基本的问题是:
给你 n 个区间:
text
[start, end, weight]
其中:
text
start = 开始位置
end = 结束位置
weight = 选择这个区间获得的收益
要求选择若干个互不重叠的区间,使:
text
weight 总和最大
例如:
text
A = [1, 3, 5]
B = [2, 5, 8]
C = [4, 6, 6]
如果 A 和 C 不重叠,那么:
text
A + C = 11
虽然单独看:
text
B = 8
比:
text
A = 5
C = 6
都大,但是:
text
A + C = 11 > 8
所以不能简单地每次选择权重最大的区间。
这就是为什么这类问题通常不能使用简单贪心。
二、怎么识别这种题?
以后看到下面几个关键词同时出现:
text
区间
+
每个区间有价值 / 收益 / 权重
+
选择的区间不能重叠
+
最大化总价值
应该马上想到:
加权区间调度
也就是:
text
排序 + 二分查找 + DP
例如:
text
工作:
[startTime, endTime, profit]
活动:
[startTime, endTime, value]
卖房:
[startHouse, endHouse, gold]
出租车订单:
[startPoint, endPoint, earning]
它们表面完全不同,但本质上都是:
text
[start, end, value]
三、为什么不能直接贪心?
普通的「最多选择多少个不重叠区间」可以按照结束时间贪心。
但是如果每个区间有不同收益,就不行了。
例如:
text
A = [1, 10], value = 100
B = [1, 4], value = 60
C = [5, 10], value = 60
如果每次选择价值最大的:
text
A = 100
最终收益:
text
100
但实际上:
text
B + C = 120
更大。
因此:
区间数量最大化 和区间权重最大化是两类不同问题。
看到:
text
最大收益
最大权重
最大价值
要警惕不能直接贪心。
四、这类题最核心的思想:选 or 不选
假设现在正在处理第 i 个区间。
对于当前区间,我们其实只有两个选择:
text
当前区间 i
│
┌─────────┴─────────┐
↓ ↓
不选 选
↓ ↓
dp[i - 1] dp[previous] + value[i]
所以核心状态转移就是:
text
dp[i] = max(
dp[i - 1],
dp[previous] + value[i]
)
这几乎就是整个加权区间调度的灵魂。
五、为什么需要排序?
如果区间完全乱序:
text
[8, 10]
[1, 3]
[6, 7]
[2, 5]
我们很难快速判断:
当前区间之前有哪些区间可以和它同时选择?
所以一种非常经典的处理方式是:
按照右端点
end从小到大排序。
排序以后:
text
[1, 3]
[2, 5]
[6, 7]
[8, 10]
对应的右端点:
text
3 5 7 10
天然有序。
这样就可以二分查找。
六、二分查找到底在找什么?
假设当前区间:
text
[6, 9]
前面区间的右端点:
text
2 3 5 7 8
如果题目要求两个区间完全不能共享端点,那么我们需要:
text
previous.end < current.start
当前:
text
current.start = 6
所以合法的是:
text
2 3 5
不合法:
text
7 8
我们可以二分寻找:
第一个
end >= 6的位置。
假设位置为:
text
3
那么:
text
[0, 3)
恰好有 3 个区间满足:
text
end < 6
因此:
cpp
dp[3]
就是可以和当前区间组合的前半部分最优解。
七、这里有一个非常重要的坑:端点是否算重叠
这 6 道题并不是完全一样。
必须看清楚题目对于端点的定义。
情况一:共享端点算重叠
例如 3414:
text
[1, 3]
[3, 5]
算重叠。
所以必须:
cpp
previous.right < current.left
也就是:
text
right < left
3414 明确规定共享边界也算重叠。(LeetCode Wiki)
2054 同样是闭区间,前一个活动在 t 结束时,下一个活动至少要从 t + 1 开始。(LeetCode)
情况二:共享端点不算重叠
1235 则不同。
如果:
text
工作 A 在 3 结束
工作 B 在 3 开始
允许同时做。
因此条件是:
cpp
previous.end <= current.start
LeetCode 1235 的题目明确允许一个工作在 X 结束后,另一个工作在 X 开始。(LeetCode)
2008 出租车也是:
可以在一个点放下乘客,同时在这个点接另一个乘客。
因此也是:
text
previous.end <= current.start
(LeetCode)
这个细节非常重要。
八、最基础的 DP 模板
假设:
text
arr[i] = [left, right, weight]
并且已经按照:
text
right 升序
排序。
定义:
cpp
dp[i]
表示:
前
i个区间能够得到的最大收益。
对于第 i 个区间:
不选
cpp
dp[i] = dp[i - 1];
选择
先二分找到 previous:
text
前 previous 个区间
都可以和当前区间共存
那么:
cpp
dp[i] = dp[previous] + weight;
最后:
cpp
dp[i] = max(dp[i - 1], dp[previous] + weight);
这就是最标准的 Weighted Interval Scheduling。
九、如果题目要求最多选择 K 个怎么办?
这时候一维 DP 不够了。
定义:
cpp
dp[i][k]
表示:
前
i个区间里面,最多选择k个时能够获得的最大收益。
仍然只有两个选择。
不选:
cpp
dp[i - 1][k]
选择:
cpp
dp[previous][k - 1] + weight
所以:
text
dp[i][k]
=
max(
dp[i - 1][k],
dp[previous][k - 1] + weight[i]
)
这就是 1751 和 3414 的核心。
十、六道题之间到底是什么关系?
可以把它们放到一棵树里:
text
加权区间调度
│
排序 + 二分 + DP
│
┌──────────────┴──────────────┐
↓ ↓
不限制选择数量 限制选择数量
│ │
1235 / 2830 2054 / 1751 / 3414
2008 │
↓
dp[i][k]
│
┌─────────────┴─────────────┐
↓ ↓
k 固定 k 是输入
2054: 2 1751
3414: 4
其中 3414 又额外要求:
text
返回原始下标
+
最大权重相同时
返回字典序最小方案
所以它又需要保存方案。
十一、LeetCode 3414:不重叠区间的最大得分
题目
给:
text
intervals[i] = [li, ri, weighti]
最多选择 4 个互不重叠区间,使权重之和最大。
如果存在多个最大权重方案:
返回字典序最小的原始下标数组。
而且共享边界也算重叠。(LeetCode Wiki)
例如:
text
intervals =
[
[1,3,2],
[4,5,2],
[1,5,5],
[6,9,3],
[6,7,1],
[8,9,1]
]
答案:
text
[2,3]
难点
3414 比标准模板多了两个东西。
第一:最多选择 4 个
所以:
cpp
dp[i]
变成:
cpp
dp[i][k]
其中:
text
k = 0 ~ 4
第二:需要返回具体方案
如果:
cpp
dp[i][k]
只保存一个数字:
text
最大权重 = 100
我们最后不知道到底选择了哪些区间。
因此定义:
cpp
struct State
{
long long SumWeight = 0;
vector<int> res;
};
其中:
text
SumWeight = 当前方案总权重
res = 当前方案选择的原始下标
比较两个状态
优先比较:
text
总权重
如果相同:
text
比较 res 的字典序
因此:
cpp
auto Compare = [](const State& a, const State& b) {
if(a.SumWeight != b.SumWeight)
{
return a.SumWeight > b.SumWeight;
}
return a.res < b.res;
};
完整代码
cpp
class Solution
{
public:
struct State
{
long long SumWeight = 0;
vector<int> res;
};
struct newintervals
{
int left;
int right;
int weights;
int place;
};
static bool cmp(const newintervals& a, const newintervals& b)
{
if(a.right != b.right)
{
return a.right < b.right;
}
if(a.left != b.left)
{
return a.left < b.left;
}
return a.place < b.place;
}
vector<int> maximumWeight(vector<vector<int>>& intervals)
{
int n = intervals.size();
vector<newintervals> arr;
for(int i = 0; i < n; i++)
{
arr.push_back({intervals[i][0], intervals[i][1], intervals[i][2], i});
}
sort(arr.begin(), arr.end(), cmp);
vector<vector<State>> dp(n + 1, vector<State>(5));
auto Compare = [](const State& a, const State& b) {
if(a.SumWeight != b.SumWeight)
{
return a.SumWeight > b.SumWeight;
}
return a.res < b.res;
};
for(int i = 1; i <= n; i++)
{
int nowleft = arr[i - 1].left;
int left = 0, right = i - 1;
while(left < right)
{
int mid = left + (right - left) / 2;
if(arr[mid].right < nowleft)
{
left = mid + 1;
}
else
{
right = mid;
}
}
int previous = left;
for(int k = 1; k <= 4; k++)
{
State nochoose = dp[i - 1][k];
State choose = dp[previous][k - 1];
choose.SumWeight += arr[i - 1].weights;
choose.res.push_back(arr[i - 1].place);
sort(choose.res.begin(), choose.res.end());
if(Compare(choose, nochoose))
{
dp[i][k] = choose;
}
else
{
dp[i][k] = nochoose;
}
}
}
return dp[n][4].res;
}
};
十二、例题 1:LeetCode 1235 规划兼职工作
这是整个专题里我认为最应该掌握的一道模板题。
题目给:
text
startTime[i]
endTime[i]
profit[i]
要求:
选择若干互不重叠工作,使总收益最大。
这道题允许:
text
前一个工作 end == 后一个工作 start
也就是说:
text
[1,3]
[3,6]
可以同时选择。(LeetCode)
DP 定义
定义:
cpp
dp[i]
表示:
前
i个工作能够得到的最大收益。
当前工作不选
cpp
dp[i] = dp[i - 1];
当前工作选择
找到前面满足:
text
end <= current.start
的所有工作。
假设有:
text
previous
个。
那么:
cpp
dp[previous] + current.profit
状态转移
text
dp[i]
=
max(
dp[i - 1],
dp[previous] + current.profit
)
C++ 代码
cpp
class Solution
{
public:
struct Job
{
int start;
int end;
int profit;
};
static bool cmp(const Job& a, const Job& b)
{
return a.end < b.end;
}
int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit)
{
int n = startTime.size();
vector<Job> arr;
for(int i = 0; i < n; i++)
{
arr.push_back({startTime[i], endTime[i], profit[i]});
}
sort(arr.begin(), arr.end(), cmp);
vector<int> dp(n + 1, 0);
for(int i = 1; i <= n; i++)
{
int nowStart = arr[i - 1].start;
int left = 0, right = i - 1;
while(left < right)
{
int mid = left + (right - left) / 2;
if(arr[mid].end <= nowStart)
{
left = mid + 1;
}
else
{
right = mid;
}
}
int previous = left;
dp[i] = max(dp[i - 1], dp[previous] + arr[i - 1].profit);
}
return dp[n];
}
};
和 3414 的关系
3414:
cpp
dp[i][k]
1235:
cpp
dp[i]
因为 1235:
不限制最多选几个。
所以可以把第二维直接去掉。
这就是 3414 的基础版本。
十三、例题 2:LeetCode 2054 两个最好的不重叠活动
题目:
text
events[i] = [start, end, value]
最多参加:
text
2 个
不重叠活动,使价值最大。(LeetCode)
这道题和 3414 非常像。
3414:
text
最多 4 个
2054:
text
最多 2 个
DP
定义:
cpp
dp[i][k]
表示:
前
i个活动最多选择k个的最大价值。
其中:
text
k = 1 ~ 2
状态转移
text
不选择当前活动:
dp[i - 1][k]
选择当前活动:
dp[previous][k - 1] + value
所以:
text
dp[i][k]
=
max(
dp[i - 1][k],
dp[previous][k - 1] + value
)
注意端点
2054 的:
text
[start, end]
是闭区间。
所以:
text
[1,3]
[3,5]
不能同时参加。
必须:
text
previous.end < current.start
(LeetCode)
C++ 代码
cpp
class Solution
{
public:
static bool cmp(const vector<int>& a, const vector<int>& b)
{
return a[1] < b[1];
}
int maxTwoEvents(vector<vector<int>>& events)
{
int n = events.size();
sort(events.begin(), events.end(), cmp);
vector<vector<int>> dp(n + 1, vector<int>(3));
for(int i = 1; i <= n; i++)
{
int nowStart = events[i - 1][0];
int left = 0, right = i - 1;
while(left < right)
{
int mid = left + (right - left) / 2;
if(events[mid][1] < nowStart)
{
left = mid + 1;
}
else
{
right = mid;
}
}
int previous = left;
for(int k = 1; k <= 2; k++)
{
dp[i][k] = max(dp[i - 1][k], dp[previous][k - 1] + events[i - 1][2]);
}
}
return dp[n][2];
}
};
十四、例题 3:LeetCode 1751 最多可以参加的会议数目 II
这一题可以认为是:
2054 的通用版本。
2054:
text
最多选择 2 个
1751:
text
最多选择 k 个
所以核心直接变成:
cpp
dp[i][j]
其中:
text
i = 前多少个活动
j = 最多选择多少个活动
状态转移
还是完全一样:
text
不选择:
dp[i - 1][j]
选择:
dp[previous][j - 1] + value
因此:
text
dp[i][j]
=
max(
dp[i - 1][j],
dp[previous][j - 1] + value
)
你做到这里应该发现:
2054、1751、3414 的 DP 几乎就是同一个东西。
区别只是:
text
2054:K = 2
1751:K = 输入的 k
3414:K = 4 + 还要保存答案下标
C++ 代码
cpp
class Solution
{
public:
static bool cmp(const vector<int>& a, const vector<int>& b)
{
return a[1] < b[1];
}
int maxValue(vector<vector<int>>& events, int k)
{
int n = events.size();
sort(events.begin(), events.end(), cmp);
vector<vector<int>> dp(n + 1, vector<int>(k + 1));
for(int i = 1; i <= n; i++)
{
int nowStart = events[i - 1][0];
int left = 0, right = i - 1;
while(left < right)
{
int mid = left + (right - left) / 2;
if(events[mid][1] < nowStart)
{
left = mid + 1;
}
else
{
right = mid;
}
}
int previous = left;
for(int j = 1; j <= k; j++)
{
dp[i][j] = max(dp[i - 1][j], dp[previous][j - 1] + events[i - 1][2]);
}
}
return dp[n][k];
}
};
十五、例题 4:LeetCode 2830 销售利润最大化
这道题把「时间区间」换成了:
房子的编号区间。
有:
text
offers[i] = [start, end, gold]
买家愿意购买:
text
start ~ end
之间所有房子,并支付:
text
gold
同一套房不能卖给两个买家。(LeetCode)
是不是还是:
text
[start, end, value]
没变。
为什么还是加权区间调度?
一个 offer:
text
[1,4,10]
意味着:
text
使用区间 [1,4]
收益 10
两个买家不能买相同房子:
text
区间不能重叠
目标:
text
最大化 gold
所以:
text
区间
+
权重
+
不能重叠
+
最大收益
标准加权区间调度。
DP
定义:
cpp
dp[i]
表示:
前
i个 offer 可以得到的最大收益。
然后:
text
不选:
dp[i - 1]
选择:
dp[previous] + gold
C++ 代码
cpp
class Solution
{
public:
static bool cmp(const vector<int>& a, const vector<int>& b)
{
return a[1] < b[1];
}
int maximizeTheProfit(int n, vector<vector<int>>& offers)
{
int m = offers.size();
sort(offers.begin(), offers.end(), cmp);
vector<int> dp(m + 1, 0);
for(int i = 1; i <= m; i++)
{
int nowStart = offers[i - 1][0];
int left = 0, right = i - 1;
while(left < right)
{
int mid = left + (right - left) / 2;
if(offers[mid][1] < nowStart)
{
left = mid + 1;
}
else
{
right = mid;
}
}
int previous = left;
dp[i] = max(dp[i - 1], dp[previous] + offers[i - 1][2]);
}
return dp[m];
}
};
这也是标准的排序 + 二分 + DP 解法。(LeetCode Wiki)
十六、例题 5:LeetCode 2008 出租车的最大盈利
这一题包装得更深一点。
给:
text
rides[i] = [start, end, tip]
如果接这个乘客,赚:
text
end - start + tip
而出租车同一时间最多载一个乘客。(LeetCode)
所以我们先把:
text
[start, end, tip]
转换成:
text
[start, end, value]
其中:
text
value = end - start + tip
然后问题瞬间变成:
选择若干不重叠区间,使 value 总和最大。
又回到了 Weighted Interval Scheduling。
特别注意
出租车允许:
text
在位置 X 放下一个乘客
然后立刻在 X 接另一个乘客
所以:
text
previous.end <= current.start
即可。
这和 1235 是一样的。(LeetCode)
C++ 代码
cpp
class Solution
{
public:
static bool cmp(const vector<int>& a, const vector<int>& b)
{
return a[1] < b[1];
}
long long maxTaxiEarnings(int n, vector<vector<int>>& rides)
{
int m = rides.size();
sort(rides.begin(), rides.end(), cmp);
vector<long long> dp(m + 1, 0);
for(int i = 1; i <= m; i++)
{
int nowStart = rides[i - 1][0];
int left = 0, right = i - 1;
while(left < right)
{
int mid = left + (right - left) / 2;
if(rides[mid][1] <= nowStart)
{
left = mid + 1;
}
else
{
right = mid;
}
}
int previous = left;
long long value = rides[i - 1][1] - rides[i - 1][0] + rides[i - 1][2];
dp[i] = max(dp[i - 1], dp[previous] + value);
}
return dp[m];
}
};
十七、六道题统一起来看
这是整个专题最重要的一张表:
| 题目 | 区间 | 权重 | 最多选择 | DP |
|---|---|---|---|---|
| 1235 | 工作时间 | profit | 无限 | dp[i] |
| 2054 | 活动时间 | value | 2 | dp[i][2] |
| 1751 | 活动时间 | value | k | dp[i][k] |
| 2830 | 房屋区间 | gold | 无限 | dp[i] |
| 2008 | 乘客行程 | 路程 + tip | 无限 | dp[i] |
| 3414 | 普通区间 | weight | 4 | dp[i][4] + State |
所以其实不是 6 套算法。
而是:
1 套算法,6 种包装。
十八、真正应该背下来的模板
如果题目不限制选择数量:
cpp
sort(intervals.begin(), intervals.end(), 按右端点排序);
vector<long long> dp(n + 1);
for(int i = 1; i <= n; i++)
{
int previous = 二分找到前面不冲突区间数量;
long long nochoose = dp[i - 1];
long long choose = dp[previous] + 当前收益;
dp[i] = max(nochoose, choose);
}
return dp[n];
这就是最基础模板。
十九、限制最多选择 K 个的模板
如果题目出现:
text
最多选择 K 个
马上想到:
cpp
dp[i][k]
模板:
cpp
vector<vector<long long>> dp(n + 1, vector<long long>(K + 1));
for(int i = 1; i <= n; i++)
{
int previous = 二分找到前面不冲突的位置;
for(int k = 1; k <= K; k++)
{
dp[i][k] = max(
dp[i - 1][k],
dp[previous][k - 1] + weight
);
}
}
这个模板直接覆盖:
text
2054
1751
3414
二十、如果还要求返回具体选择方案
3414 又进一步要求:
text
不是返回最大值
而是返回选了哪些区间
那就不能只保存:
cpp
long long
而需要:
cpp
struct State
{
long long weight = 0;
vector<int> res;
};
也就是说:
text
普通 DP:
dp[i][k] = 最大值
需要恢复答案:
dp[i][k] = {
最大值,
具体方案
}
二十一、为什么二分是这类题的关键?
如果不用二分,对于每一个区间 i,我们都从前往后寻找:
text
谁和当前区间不冲突?
可能需要:
text
O(n)
一共有 n 个区间:
text
O(n²)
但是排序之后:
text
end[0] <= end[1] <= end[2] <= ...
就可以二分:
text
O(log n)
所以整体:
text
排序:
O(n log n)
n 次二分:
O(n log n)
DP:
O(n)
最终:
text
O(n log n)
这也是「排序 + 二分 + DP」三者为什么经常一起出现。
二十二、二分查找到底用 < 还是 <=?
这是这一组题最容易写错的地方之一。
不要背代码。
看题目对于端点的定义。
如果:
text
[1,3]
[3,5]
算重叠:
cpp
previous.end < current.start
如果不算重叠:
cpp
previous.end <= current.start
这 6 道可以记成:
| 题目 | 合法条件 |
|---|---|
| 3414 | previous.end < current.start |
| 2054 | previous.end < current.start |
| 1751 | previous.end < current.start |
| 2830 | previous.end < current.start |
| 1235 | previous.end <= current.start |
| 2008 | previous.end <= current.start |
这个表非常值得记。
二十三、为什么 DP 经常开 n + 1?
例如:
cpp
vector<int> dp(n + 1);
不是随便多开一个。
我们故意让:
text
dp[0]
表示:
一个区间都没有考虑。
自然:
text
dp[0] = 0
然后:
text
dp[1] = 前 1 个区间
dp[2] = 前 2 个区间
...
dp[n] = 前 n 个区间
这样处理第 i 个 DP 状态时,对应实际数组:
cpp
arr[i - 1]
所以经常看到:
cpp
for(int i = 1; i <= n; i++)
{
当前区间 = arr[i - 1];
}
二十四、为什么 previous 可以直接作为 DP 下标?
这是刚开始做这类题最容易迷惑的地方。
假设:
text
end = [2, 4, 5, 8, 10]
当前:
text
start = 6
合法:
text
2
4
5
一共有:
text
3 个
于是:
cpp
previous = 3;
而:
cpp
dp[3]
恰好表示:
前 3 个区间的最优答案。
所以可以直接:
cpp
dp[previous]
这也是为什么:
DP 使用"前 i 个区间"的定义特别方便。
二十五、最容易犯的错误
错误 1:忘记排序
二分查找的前提是:
text
右端点有序
所以必须先:
cpp
sort(...)
错误 2:共享端点判断错
把:
cpp
<
写成:
cpp
<=
可能直接 WA。
一定先确认:
text
共享端点到底算不算重叠?
错误 3:dp[i] 和 arr[i] 混淆
如果:
cpp
dp[i]
表示前 i 个区间,
那么当前区间一定是:
cpp
arr[i - 1]
不是:
cpp
arr[i]
错误 4:选择当前区间以后还用 dp[i - 1]
错误:
cpp
choose = dp[i - 1] + weight;
因为:
text
dp[i - 1]
里面可能已经选择了和当前区间重叠的区间。
必须:
cpp
choose = dp[previous] + weight;
错误 5:最多选择 K 个却只写一维 DP
如果:
text
最多选 K 个
那么需要记录:
text
已经允许选择多少个
所以通常:
cpp
dp[i][k]
错误 6:需要返回方案却只保存最大值
3414 如果只写:
cpp
vector<vector<long long>> dp;
最后只能知道:
text
最大权重
不知道:
text
具体选择哪些下标
所以需要 State。
二十六、面试时应该怎么推导?
如果面试遇到这种题,不建议一上来就说:
我背过 Weighted Interval Scheduling。
更好的推导方式是:
第一步:发现不能贪心
因为每个区间价值不同。
第二步:想到 DP
对于当前区间:
text
选
or
不选
第三步:发现选当前区间需要知道前面哪里还能接
于是需要寻找:
text
previous
第四步:如果线性寻找 previous
复杂度:
text
O(n²)
第五步:发现可以排序
按照:
text
end
排序后,右端点有序。
第六步:二分 previous
把:
text
O(n)
降到:
text
O(log n)
最终:
text
O(n log n)
这个推导过程其实比直接默写代码更重要。
二十七、这类题和普通区间贪心怎么区分?
这是面试中非常重要的分类能力。
类型 1:合并区间
例如:
text
把所有重叠区间合并
通常:
text
排序 + 贪心
类型 2:最多选择多少个不重叠区间
如果每个区间价值都一样,只问数量:
text
排序 + 贪心
类型 3:会议室数量
例如:
text
最少需要几个会议室
通常:
text
排序 + 堆
或者:
text
扫描线
类型 4:区间有不同价值,求最大收益
text
区间
+
weight
+
不能重叠
+
最大收益
通常:
text
排序 + 二分 + DP
这才是本文讨论的 Weighted Interval Scheduling。
二十八、六道题真正训练了什么?
刷完这 6 道以后,真正需要获得的能力不是:
text
记住 6 份代码
而是看到题目之后能够做这个转换:
text
工作
活动
房屋
乘客
普通区间
↓
全部去掉题目背景
↓
[start, end, value]
↓
选择不重叠区间
↓
最大化 value
↓
Weighted Interval Scheduling
↓
排序 + 二分 + DP
这才叫真正掌握一个题型。
二十九、最终万能思维模板
以后遇到区间收益题,可以按照下面的顺序思考:
text
① 每个元素是不是一个区间?
↓ 是
② 每个区间是不是有收益 / 权重?
↓ 是
③ 是不是要求选择的区间不能重叠?
↓ 是
④ 是不是要求最大化总收益?
↓ 是
加权区间调度
↓
按端点进行排序
↓
二分找 previous
↓
动态规划
↓
当前区间选 / 不选
然后继续判断:
text
是否限制最多选择 K 个?
如果没有:
cpp
dp[i]
如果有:
cpp
dp[i][k]
如果还要求:
text
返回具体选择方案
那么:
cpp
dp[i][k]
里面不只保存数字,而保存:
cpp
State
{
value,
answer
}
三十、最后总结
这 6 道题看起来分别是:
text
3414:选择区间
1235:安排工作
2054:参加活动
1751:参加会议
2830:出售房屋
2008:出租车接乘客
但把业务背景全部去掉以后,其实全部都是:
text
给我若干:
[start, end, value]
选择若干不冲突区间
让:
Σ value
最大。
最终统一成:
text
Weighted Interval Scheduling
│
↓
按区间端点排序
│
↓
二分寻找 previous
│
↓
动态规划 DP
│
┌──────────┴──────────┐
↓ ↓
不选 i 选择 i
↓ ↓
dp[i - 1][k] dp[previous][k - 1]
+
value[i]
\ /
\ /
└────── max ──────┘
↓
dp[i][k]
如果不限制数量:
text
dp[i]
=
max(
dp[i - 1],
dp[previous] + value[i]
)
如果最多选择 K 个:
text
dp[i][k]
=
max(
dp[i - 1][k],
dp[previous][k - 1] + value[i]
)
如果还要求返回方案:
text
数字 DP
↓
State DP
↓
同时保存最大收益 + 选择方案
所以这 6 道题最终只需要记住一句话:
区间 + 权重 + 不重叠 + 最大收益 = 加权区间调度;先排序,用二分找到前驱,再用 DP 在"选当前区间"和"不选当前区间"之间取最优。
而真正需要形成的条件反射是:
text
看到题目
↓
去掉业务背景
↓
抽象成 [start, end, value]
↓
判断端点能不能相接
↓
排序
↓
二分 previous
↓
DP:选 / 不选
做到这里,这一类题基本就算真正掌握了。(LeetCode)