cswork / 模板打开 500 路题单

第 08 站 · 线性结构

贪心与调度

按结束时间排序选区间 时间 O(n log n)

周赛难度分 难度分 1303–难度分 2260· 中位 难度分 1681(15/25 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 08 站 · 25 题
先学
学完去
没有后续站依赖它。
面试频次
6 家公司考过 · 面经出现 6 次Amazon 1、Apple 1、DoorDash 1、LinkedIn 1、Microsoft 1

先做这道

LC 455分发饼干简单
卡住再看

排序需求和资源,用最小可用资源满足当前最小需求。

要点

  1. 总是选结束最早的区间,给后面留的空间最多。交换论证:任何最优解的第一个区间都能换成它而不变差。
  2. 最少删除数 = 总数 - 最多保留数;「最少几支箭」也是同一个循环,只是端点相接也算重叠。
  3. 写贪心之前先说清楚「为什么局部最优不吃亏」;说不清楚时,多半要换成动态规划。

易错点

  • 按开始时间排序再贪心,会被一个开始很早、持续很长的区间卡住。
  • 端点相接算不算重叠由题意决定:>= 和 > 只差一个字符,答案就不一样。
  • 比较器写成 a[1] - b[1],端点接近 ±2^31 时会溢出。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

区间贪心 · 按右端点选不重叠区间

最多能选出多少个互不重叠的区间(端点可以相接)?

换个输入
1–4#0
3–5#1
0–6#2
5–7#3
3–9#4
6–10#5
8–11#6
当前处理已发现 / 已计算答案
已选
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 count
C++
// 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 jumps
C++
// 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()
}

为什么对

  1. 交换论证:设最优解里结束最早的区间是 o,贪心选的是全体里结束最早的 g,g.end ≤ o.end。把 o 换成 g,最优解里其余区间都在 o.end 之后开始,自然也在 g.end 之后,仍然互不重叠,数量不变。
  2. 归纳:换完之后,剩下的是「在 g.end 之后开始的区间里选最多」,和原问题同构,对剩余部分重复同样的论证即可。
  3. 按开始时间或按长度排序都有反例:[1,10]、[2,3]、[4,5] 按开始时间只能选 1 个;[1,5]、[4,7]、[6,10] 先选最短的 [4,7] 也只能选 1 个,而最优都是 2 个。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
选最多的不重叠区间,我按结束时间排序,能放就放。理由是交换论证:最优解里结束最早的那个区间,总可以换成我选的这个结束更早的区间,后面的区间不受影响,数量不变。换完后剩下的是同样的问题,归纳下去就证明了贪心最优。排序 O(n log n),扫描 O(n);按开始时间或长度排序都有反例。
English
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 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/3)
这道题你会了吗?

小测

3 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

3 道题
  1. 从一组区间中选出最多个互不重叠的区间,正确的贪心策略是每次选?
  2. 硬币面值是 {1, 3, 4},要凑出 6。「每次拿不超过剩余金额的最大硬币」的贪心用几枚?最少需要几枚?
  3. 「能否跳到最后一个下标」(a[i] 是从 i 出发最多能跳的步数):贪心从左到右扫描,记录目前能到达的最远下标 far。什么时候可以判定跳不到?

作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。

模板练习

用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。

下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。

分档练习

本站在 500 路里的 25 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。

基础 2

#题目一句话思路难度分
455分发饼干排序需求和资源,用最小可用资源满足当前最小需求。
1338数组大小减半按元素频次优先删除,达到总长度减半的最少类别数。难度分 1303

核心 17

#题目一句话思路难度分
55跳跃游戏维护最远可达位置,并在当前位置超出可达范围时停止。
45跳跃游戏 II以当前可达层的边界推进跳跃次数,避免局部选择最大跳步。
134加油站通过亏损区间排除起点候选,同时检查全局总油量是否足够。
135分发糖果分别满足左右相邻约束,再合并每个位置的最低需求。
763划分字母区间用字符最后出现位置确定不可再拆的区段边界。难度分 1443
179最大数用两种拼接次序定义排序比较器,并处理全零结果。
1029两地调度从统一基准成本出发按改派差价排序,满足两地人数约束。难度分 1348
621任务调度器分析最高频任务形成的冷却间隔,并比较公式与堆模拟。
767重构字符串优先安排剩余最多且不等于前一字符的字符,检查可构造条件。难度分 1681
1405最长快乐字符串在禁止三连的限制下选择最高剩余频次,必要时切换次优字符。难度分 1821
881救生艇最重者必须占船,再判断能否配上最轻者证明双指针策略。难度分 1530
870优势洗牌能获胜时用最小可胜元素,否则牺牲最小元素保存强者。难度分 1648
948令牌放置低成本获取分数、高价值换回能量,并记录历史最高分数。难度分 1762
945使数组唯一的最小增量排序后将每个数提升到不小于前值加一,累计最小修改量。难度分 1448
1007行相等的最少多米诺旋转只需检查首张骨牌两面的候选值,再统计统一方向的最少翻转。难度分 1541
826安排工作以达到最大收益按能力排序工人,扫描可承担任务并维护最高收益。难度分 1709
1642可以到达的最远建筑把有限梯子留给最大的爬升,用堆动态替换资源分配。难度分 1962

进阶 6

#题目一句话思路难度分
678有效的括号字符串维护可能的未匹配左括号数量区间,避免枚举星号的三种含义。
630课程表 III按截止时间安排课程,超时则撤销耗时最长者保存后续容量。
871最低加油次数先推进可达范围,燃料不足时回选已路过站点的最大补给。难度分 2074
502IPO按资金门槛解锁项目,再从可选项目中选择最大利润。
857雇佣 K 名工人的最低成本枚举工资质量比上限,用质量总和最小的候选组降低总成本。难度分 2260
1383最大的团队表现值按效率下界扫描,维护最多k人的速度和以最大化团队表现。难度分 2091