文章

算法刷题系列(二十六):贪心算法——从局部选择到正确性证明

算法刷题系列(二十六):贪心算法——从局部选择到正确性证明

算法刷题系列(二十六):贪心算法——从局部选择到正确性证明

贪心算法每一步都作出一个当下看来有利的选择,并且通常不回头修改。实现可能很短,证明却不能省略:局部最优选择并不自动等于全局最优。本文用区间调度说明如何把选择规则、交换论证和不变式连起来。

一、例子:最多安排多少个互不重叠活动

每个活动用半开区间 [start, end) 表示。若前一个活动在 4 结束,另一个从 4 开始,两者不重叠。目标是在所有活动中选出尽可能多的互不冲突活动。

例如 [1,4)、[3,5)、[4,7)、[6,8)、[7,9)。按结束时间从早到晚排序,先选 [1,4),再选 [4,7),最后选 [7,9),总数为 3。

二、贪心规则与交换证明

规则是:在当前可选活动中,选择结束最早的活动。为什么不是选择开始最早或时长最短?因为结束越早,留给后续活动的可用时间越多;但这个直觉需要证明。

设某个最优方案的第一个活动是 A,贪心选择是 G。由于 G 结束不晚于任何可行首活动,特别是 end(G) <= end(A)。把最优方案中的 A 换成 G,后续活动原本在 A 结束后才开始,因此也会在 G 结束后开始。替换不减少活动数量,于是存在一个以 G 开头的最优方案。删除已选的 G 后,剩余问题仍是同类的区间调度问题。逐步重复可证贪心解最优。

三、Java 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

public class Main {
    static List<int[]> selectActivities(int[][] activities) {
        int[][] sorted = new int[activities.length][2];
        for (int i = 0; i < activities.length; i++) {
            if (activities[i] == null || activities[i].length != 2
                    || activities[i][0] > activities[i][1]) {
                throw new IllegalArgumentException("each activity needs start <= end");
            }
            sorted[i] = Arrays.copyOf(activities[i], 2);
        }
        Arrays.sort(sorted, Comparator
                .comparingInt((int[] item) -> item[1])
                .thenComparingInt(item -> item[0]));

        List<int[]> chosen = new ArrayList<>();
        boolean hasPrevious = false;
        int previousEnd = 0;
        for (int[] activity : sorted) {
            if (!hasPrevious || activity[0] >= previousEnd) {
                chosen.add(activity);
                previousEnd = activity[1];
                hasPrevious = true;
            }
        }
        return chosen;
    }

    public static void main(String[] args) {
        List<int[]> chosen = selectActivities(new int[][]{
                {1, 4}, {3, 5}, {4, 7}, {6, 8}, {7, 9}
        });
        assert chosen.size() == 3;
        assert chosen.get(0)[0] == 1 && chosen.get(1)[0] == 4
                && chosen.get(2)[0] == 7;
        assert selectActivities(new int[0][2]).isEmpty();
    }
}

四、Python 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from typing import Optional

def select_activities(activities: list[tuple[int, int]]) -> list[tuple[int, int]]:
    for start, end in activities:
        if start > end:
            raise ValueError("each activity needs start <= end")

    ordered = sorted(activities, key=lambda activity: (activity[1], activity[0]))
    chosen: list[tuple[int, int]] = []
    previous_end: Optional[int] = None
    for start, end in ordered:
        if previous_end is None or start >= previous_end:
            chosen.append((start, end))
            previous_end = end
    return chosen

assert select_activities([(1, 4), (3, 5), (4, 7), (6, 8), (7, 9)]) == [
    (1, 4), (4, 7), (7, 9)
]
assert select_activities([]) == []

五、复杂度、边界与常见误判

排序耗时 O(n log n),扫描耗时 O(n);输出至多 O(n)。空活动集合返回空结果。相同结束时间的次序不影响最优性,示例以开始时间作稳定的次级排序。零长度区间在半开区间约定下可与同一时刻开始或结束的区间兼容;若题目使用闭区间,兼容条件需改为严格大于。

另一个常见误区是把“贪心”当作证明。比如硬币面值并非特定规范体系时,每次拿最大硬币未必能用最少枚数;局部规则可能卡在后续选择。对新问题,应寻找交换论证、最优子结构或可维持的安全不变式;若证明不成立,考虑动态规划或其他方法。

小结与衔接

区间调度的早结束规则之所以正确,是因为任意最优解都能交换成包含这次贪心选择的解。贪心强调决策与证明。下一篇回到树结构:递归同样需要明确的子问题定义,但它不是贪心选择,而是把节点问题拆成子树问题。

本文由作者按照 CC BY 4.0 进行授权