第 08 站 · 线性结构
贪心与调度
按结束时间排序选区间 时间 O(n log n)
- 先学
- 学完去
- 没有后续站依赖它。
先做这道
LC 455分发饼干卡住再看
排序需求和资源,用最小可用资源满足当前最小需求。
要点
- 总是选结束最早的区间,给后面留的空间最多。交换论证:任何最优解的第一个区间都能换成它而不变差。
- 最少删除数 = 总数 - 最多保留数;「最少几支箭」也是同一个循环,只是端点相接也算重叠。
- 写贪心之前先说清楚「为什么局部最优不吃亏」;说不清楚时,多半要换成动态规划。
易错点
- 按开始时间排序再贪心,会被一个开始很早、持续很长的区间卡住。
- 端点相接算不算重叠由题意决定:>= 和 > 只差一个字符,答案就不一样。
- 比较器写成 a[1] - b[1],端点接近 ±2^31 时会溢出。
看动画
一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。
区间贪心 · 按右端点选不重叠区间
最多能选出多少个互不重叠的区间(端点可以相接)?
换个输入
- 已选
- 0
- 上一个结束于
- −∞
区间已按右端点从小到大排好,图里每一行是一个区间。
始终成立交换论证:任何最优解的第一个区间都能换成结束最早的那个,数量不变。
模板
选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 455。
Python
def max_non_overlapping(intervals: list[list[int]]) -> int:
"""Most intervals you can keep with no overlap; [a, b] and [b, c] may touch."""
count, end = 0, float("-inf")
for start, finish in sorted(intervals, key=lambda iv: iv[1]):
if start >= end: # fits after the last kept interval
count += 1
end = finish
return countC++
// Most intervals you can keep with no overlap; [a, b] and [b, c] may touch.
int maxNonOverlapping(vector<vector<int>> intervals) {
sort(intervals.begin(), intervals.end(),
[](const vector<int>& a, const vector<int>& b) { return a[1] < b[1]; });
int count = 0;
long long end = LLONG_MIN;
for (auto& iv : intervals)
if (iv[0] >= end) { // fits after the last kept interval
count++;
end = iv[1];
}
return count;
}Java
class Solution {
// Most intervals you can keep with no overlap; [a, b] and [b, c] may touch.
int maxNonOverlapping(int[][] intervals) {
Arrays.sort(intervals, (a, b) -> Integer.compare(a[1], b[1]));
int count = 0;
long end = Long.MIN_VALUE;
for (int[] iv : intervals)
if (iv[0] >= end) { // fits after the last kept interval
count++;
end = iv[1];
}
return count;
}
}Go
import (
"math"
"sort"
)
// maxNonOverlapping returns the most intervals you can keep with no overlap;
// [a, b] and [b, c] may touch.
func maxNonOverlapping(intervals [][]int) int {
sort.Slice(intervals, func(i, j int) bool { return intervals[i][1] < intervals[j][1] })
count, end := 0, math.MinInt
for _, iv := range intervals {
if iv[0] >= end { // fits after the last kept interval
count++
end = iv[1]
}
}
return count
}常见变体
这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。
最远可达边界:一层一层地跳时间 O(n) · 空间 O(1)
每个位置给出能跳多远,问能不能到终点或最少跳几次:边走边更新「下一跳最远能到哪」,走到当前这一跳的边界时跳数加一、边界换成最远处。这其实是不建队列的 BFS。LC 45 跳跃游戏 II;LC 55 只要判断最远处是否一直不落后于 i。
Python
def min_jumps(nums: list[int]) -> int:
"""Fewest jumps from index 0 to the last; nums[i] is the longest jump from i (LC 45)."""
jumps = end = far = 0 # indexes up to end take `jumps` jumps; far: the next jump's reach
for i in range(len(nums) - 1):
far = max(far, i + nums[i])
if i == end: # this jump's range is used up: take another
jumps += 1
end = far
return jumpsC++
// Fewest jumps from index 0 to the last; nums[i] is the longest jump from i (LC 45).
int minJumps(const vector<int>& nums) {
int jumps = 0, end = 0, far = 0; // indexes up to end take `jumps` jumps; far: the next jump's reach
for (int i = 0; i + 1 < (int)nums.size(); i++) {
far = max(far, i + nums[i]);
if (i == end) { // this jump's range is used up: take another
jumps++;
end = far;
}
}
return jumps;
}Java
class Solution {
// Fewest jumps from index 0 to the last; nums[i] is the longest jump from i (LC 45).
int minJumps(int[] nums) {
int jumps = 0, end = 0, far = 0; // indexes up to end take `jumps` jumps; far: the next jump's reach
for (int i = 0; i + 1 < nums.length; i++) {
far = Math.max(far, i + nums[i]);
if (i == end) { // this jump's range is used up: take another
jumps++;
end = far;
}
}
return jumps;
}
}Go
// minJumps returns the fewest jumps from index 0 to the last; nums[i] is the longest jump from i.
func minJumps(nums []int) int {
jumps, end, far := 0, 0, 0 // indexes up to end take `jumps` jumps; far: the next jump's reach
for i := 0; i+1 < len(nums); i++ {
far = max(far, i+nums[i])
if i == end { // this jump's range is used up: take another
jumps++
end = far
}
}
return jumps
}反悔堆:先选上,超了再踢掉最差的时间 O(n log n) · 空间 O(n)
按截止时间(或位置)扫描,每样东西先选上,一旦超出限制,就从已选的里踢掉代价最大的那个,用堆在 O(log n) 里找到它。LC 630 课程表 III、LC 1642 可以到达的最远建筑、LC 871 最低加油次数都是这种「可以反悔」的贪心。
Python
import heapq
def schedule_course(courses: list[list[int]]) -> int:
"""Most courses [duration, last day] you can finish one at a time (LC 630)."""
taken: list[int] = [] # chosen durations as a max-heap (negated)
day = 0
for duration, last in sorted(courses, key=lambda c: c[1]): # earliest deadline first
heapq.heappush(taken, -duration)
day += duration
if day > last: # past the deadline: drop the longest course taken so far
day += heapq.heappop(taken)
return len(taken)C++
// Most courses {duration, last day} you can finish one at a time (LC 630).
int scheduleCourse(vector<pair<int, int>> courses) {
sort(courses.begin(), courses.end(), [](auto& a, auto& b) { return a.second < b.second; }); // earliest deadline first
priority_queue<int> taken; // chosen durations, longest on top
long long day = 0;
for (auto [duration, last] : courses) {
taken.push(duration);
day += duration;
if (day > last) { // past the deadline: drop the longest course taken so far
day -= taken.top();
taken.pop();
}
}
return taken.size();
}Java
class Solution {
// Most courses {duration, last day} you can finish one at a time (LC 630).
int scheduleCourse(int[][] courses) {
int[][] sorted = courses.clone();
Arrays.sort(sorted, Comparator.comparingInt(c -> c[1])); // earliest deadline first
PriorityQueue<Integer> taken = new PriorityQueue<>(Comparator.reverseOrder()); // longest on top
long day = 0;
for (int[] c : sorted) {
taken.offer(c[0]);
day += c[0];
if (day > c[1]) day -= taken.poll(); // past the deadline: drop the longest course taken so far
}
return taken.size();
}
}Go
import (
"container/heap"
"sort"
)
// maxHeap is a max-heap of ints for container/heap.
type maxHeap []int
func (h maxHeap) Len() int { return len(h) }
func (h maxHeap) Less(i, j int) bool { return h[i] > h[j] }
func (h maxHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *maxHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *maxHeap) Pop() any {
old := *h
x := old[len(old)-1]
*h = old[:len(old)-1]
return x
}
// scheduleCourse returns the most courses {duration, last day} you can finish one at a time (LC 630).
func scheduleCourse(courses [][2]int) int {
sorted := append([][2]int(nil), courses...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i][1] < sorted[j][1] }) // earliest deadline first
taken := &maxHeap{} // chosen durations, longest on top
day := 0
for _, c := range sorted {
heap.Push(taken, c[0])
day += c[0]
if day > c[1] { // past the deadline: drop the longest course taken so far
day -= heap.Pop(taken).(int)
}
}
return taken.Len()
}为什么对
- 交换论证:设最优解里结束最早的区间是 o,贪心选的是全体里结束最早的 g,g.end ≤ o.end。把 o 换成 g,最优解里其余区间都在 o.end 之后开始,自然也在 g.end 之后,仍然互不重叠,数量不变。
- 归纳:换完之后,剩下的是「在 g.end 之后开始的区间里选最多」,和原问题同构,对剩余部分重复同样的论证即可。
- 按开始时间或按长度排序都有反例:[1,10]、[2,3]、[4,5] 按开始时间只能选 1 个;[1,5]、[4,7]、[6,10] 先选最短的 [4,7] 也只能选 1 个,而最优都是 2 个。
30 秒口述稿
面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。
选最多的不重叠区间,我按结束时间排序,能放就放。理由是交换论证:最优解里结束最早的那个区间,总可以换成我选的这个结束更早的区间,后面的区间不受影响,数量不变。换完后剩下的是同样的问题,归纳下去就证明了贪心最优。排序 O(n log n),扫描 O(n);按开始时间或长度排序都有反例。To keep the most non-overlapping intervals, I sort by end time and take each interval that fits. The exchange argument says the earliest-ending interval of any optimal answer can be swapped for mine, which ends no later, without breaking the rest or changing the count. What remains is the same problem, so induction shows greedy is optimal. Sorting costs O(n log n) and the scan O(n), and sorting by start or by length both have counterexamples.自评一下
对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。
小测
3 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习
作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。
模板练习
用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。
下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。
分档练习
本站在 500 路里的 25 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。
核心 17
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 55 | 跳跃游戏 | 维护最远可达位置,并在当前位置超出可达范围时停止。 | |
| 45 | 跳跃游戏 II | 以当前可达层的边界推进跳跃次数,避免局部选择最大跳步。 | |
| 134 | 加油站 | 通过亏损区间排除起点候选,同时检查全局总油量是否足够。 | |
| 135 | 分发糖果 | 分别满足左右相邻约束,再合并每个位置的最低需求。 | |
| 763 | 划分字母区间 | 用字符最后出现位置确定不可再拆的区段边界。 | |
| 179 | 最大数 | 用两种拼接次序定义排序比较器,并处理全零结果。 | |
| 1029 | 两地调度 | 从统一基准成本出发按改派差价排序,满足两地人数约束。 | |
| 621 | 任务调度器 | 分析最高频任务形成的冷却间隔,并比较公式与堆模拟。 | |
| 767 | 重构字符串 | 优先安排剩余最多且不等于前一字符的字符,检查可构造条件。 | |
| 1405 | 最长快乐字符串 | 在禁止三连的限制下选择最高剩余频次,必要时切换次优字符。 | |
| 881 | 救生艇 | 最重者必须占船,再判断能否配上最轻者证明双指针策略。 | |
| 870 | 优势洗牌 | 能获胜时用最小可胜元素,否则牺牲最小元素保存强者。 | |
| 948 | 令牌放置 | 低成本获取分数、高价值换回能量,并记录历史最高分数。 | |
| 945 | 使数组唯一的最小增量 | 排序后将每个数提升到不小于前值加一,累计最小修改量。 | |
| 1007 | 行相等的最少多米诺旋转 | 只需检查首张骨牌两面的候选值,再统计统一方向的最少翻转。 | |
| 826 | 安排工作以达到最大收益 | 按能力排序工人,扫描可承担任务并维护最高收益。 | |
| 1642 | 可以到达的最远建筑 | 把有限梯子留给最大的爬升,用堆动态替换资源分配。 |
进阶 6
本站 OA 真题 285
- Group StudentsAmazon
- Cart Prefix ZeroAmazon
- Get Score DifferenceAmazon
- Get Winner CountAmazon
- Find Longest RegexAmazon
- Get Minimum Pen Drive SizeAmazon
- Get Max ThroughputAmazon
- Find Max Value (Matrix Compression)Amazon
- Get Minimum Health (Armor Round)Amazon
- Find Optimal PermutationAmazon