← 返回算法讲义

算法设计 / 基础

贪心:局部选择如何通向全局最优

从排序调度、最优合并、区间选择与按位构造出发,学习设计贪心策略并用交换论证证明正确性。

复杂度
通常 O(n log n);按位贪心为 O(B · Tcheck)
阅读
4 分钟
前置知识
排序、优先队列、位运算
  • 贪心
  • 交换论证
  • 优先队列

问题#

贪心算法在每一步选择当前看来最有利的决策,并且不撤销它。真正困难的部分不是“选最小”或“选最大”,而是回答两个问题:

  1. 这个局部选择是否至少出现在某个全局最优解中?
  2. 做出选择后,剩余问题是否仍是同一种问题?

下面用四个常见模型建立可复用的方法:

  • 按处理时间升序排队,使总等待时间最小;
  • 每次合并最小的两堆,使总代价最小;
  • 按结束时间升序选择区间,使互不相交区间数量最大;
  • 从高位到低位试探答案,用可行性检查构造最大值。

核心思想与不变量#

相邻交换:短任务优先#

若相邻两个任务耗时分别为 x>yx>y,顺序 x,y 比 y,x 多产生 x−yx-y 的等待代价。不断交换所有逆序对不会使答案变差,最终得到按耗时升序的最优序列。

不变量是:已经排在前面的任务按耗时升序,任何相邻逆序都可以被无损地消除。

最优合并:最小的两项先合并#

一次合并的重量会在祖先合并中反复计费,因此较小的重量应处在更深的位置。用最小堆反复取出两项合并,正是 Huffman 树的构造过程。

不变量是:堆中保存尚未连接到合并树的子树权值;取出的两个最小权值可作为最深的一对兄弟,而不损失最优性。

交换论证:最早结束的区间#

设当前最早结束的区间为 II。任取一个最优解,把其中第一个区间替换为 II,后续区间仍然可选且数量不变,所以总存在一个最优解包含 II。

不变量是:已经选择的区间互不相交,并且在选择同样数量的前提下,最后结束位置尽可能靠左。

按位贪心:优先保证高位#

非负整数的高位比所有更低位之和还重要。依次尝试把第 bb 位设为 1,如果存在一个满足此前高位前缀的完整可行解,就保留该位。

这里的 feasible(candidate) 必须回答“是否存在答案包含当前这些已确定的 1 位”,不能只是检查 candidate 本身是否可行;这是按位贪心正确性的关键契约。

复杂度#

  • 短任务优先和区间选择都先排序,时间 O(nlog⁡n)O(n\log n),排序后扫描 O(n)O(n)。
  • 最优合并进行 n−1n-1 次堆操作,时间 O(nlog⁡n)O(n\log n),空间 O(n)O(n)。
  • 按位贪心检查 BB 个二进制位,若一次可行性检查为 TcheckT_{check},总时间为 O(BTcheck)O(BT_{check})。

C++17 模板#

#include <algorithm>
#include <functional>
#include <iostream>
#include <numeric>
#include <queue>
#include <utility>
#include <vector>

using namespace std;

// 返回任务下标的最优顺序,以及总等待时间(第一人等待 0)。
pair<vector<int>, long long> shortest_job_first(const vector<long long>& time) {
    vector<int> order(time.size());
    iota(order.begin(), order.end(), 0);
    stable_sort(order.begin(), order.end(), [&](int lhs, int rhs) {
        return time[lhs] < time[rhs];
    });

    long long elapsed = 0;
    long long total_wait = 0;
    for (int id : order) {
        total_wait += elapsed;
        elapsed += time[id];
    }
    return {order, total_wait};
}

long long optimal_merge_cost(const vector<long long>& weights) {
    priority_queue<long long, vector<long long>, greater<long long>> heap(
        weights.begin(), weights.end());
    long long answer = 0;
    while (heap.size() > 1) {
        long long first = heap.top(); heap.pop();
        long long second = heap.top(); heap.pop();
        answer += first + second;
        heap.push(first + second);
    }
    return answer;
}

struct Interval {
    int left;
    int right; // 模板采用半开区间 [left, right)
};

vector<Interval> maximum_non_overlapping(vector<Interval> intervals) {
    sort(intervals.begin(), intervals.end(), [](const Interval& lhs, const Interval& rhs) {
        if (lhs.right != rhs.right) return lhs.right < rhs.right;
        return lhs.left < rhs.left;
    });

    vector<Interval> chosen;
    int last_right = 0;
    bool has_last = false;
    for (const Interval& interval : intervals) {
        if (!has_last || interval.left >= last_right) {
            chosen.push_back(interval);
            last_right = interval.right;
            has_last = true;
        }
    }
    return chosen;
}

template <class Feasible>
long long maximize_by_bits(int highest_bit, Feasible feasible) {
    long long answer = 0;
    for (int bit = highest_bit; bit >= 0; --bit) {
        long long candidate = answer | (1LL << bit);
        if (feasible(candidate)) answer = candidate;
    }
    return answer;
}

int main() {
    auto [order, waiting] = shortest_job_first({4, 1, 3, 2});
    cout << "order:";
    for (int id : order) cout << ' ' << id;
    cout << "\ntotal waiting = " << waiting << '\n';

    cout << "merge cost = " << optimal_merge_cost({1, 2, 3, 4}) << '\n';

    auto selected = maximum_non_overlapping({{1, 4}, {3, 5}, {0, 6}, {5, 7}, {8, 9}});
    cout << "intervals:";
    for (auto [left, right] : selected) cout << " [" << left << ',' << right << ')';
    cout << '\n';

    // 演示契约:所有不超过 limit 的数都可行,因此答案应为 limit。
    long long limit = 173;
    cout << "bitwise answer = "
         << maximize_by_bits(60, [&](long long candidate) { return candidate <= limit; })
         << '\n';
    return 0;
}

示例#

运行模板得到:

order: 1 3 2 0
total waiting = 10
merge cost = 19
intervals: [1,4) [5,7) [8,9)
bitwise answer = 173

任务耗时按 1,2,3,4 排列,总等待时间为 0+1+3+6=100+1+3+6=10。合并 1,2,3,4 时,代价依次为 3,6,103,6,10,总计 1919。

易错点#

  1. “每步选最优”不是证明。先尝试构造反例,再写交换论证、归纳或决策包容性证明。
  2. 区间端点语义必须统一:闭区间 [l,r] 与半开区间 [l,r) 的相交判断不同。
  3. 最优合并必须使用最小堆;默认的 priority_queue 是最大堆。
  4. 累加等待时间与合并代价容易超过 32 位,应使用 long long。
  5. 按位贪心的 feasible 要检查某个“高位前缀能否扩展成完整答案”,否则保留一个局部可行位可能堵死后续选择。
  6. 零钱问题中“每次取最大面额”只对某些币制成立,并不是通用定理。

延伸阅读(非课程主线)#

  • 区间覆盖、区间分组与选点问题看起来相似,但贪心排序键可能分别是左端点、右端点或长度,不能套用同一结论。
  • 带截止时间和收益的任务调度常需要“排序 + 堆”,扫描到当前位置时淘汰代价最大的选择。
  • 若局部选择以后可能反悔,可用“贪心 + 优先队列”维护当前选择集合。
  • 按位贪心常与线性基、Trie、区间可行性检查或 DP 组合;核心仍是高位前缀的可扩展性。