3.1.贪心算法导论——为什么“局部最优“能推出“全局最优“?

3.1.贪心算法导论——为什么"局部最优"能推出"全局最优"?

系列:贪心算法 | 第 1 篇,共 8 篇
难度:⭐⭐☆☆☆ 入门-中等
标签贪心 正确性证明 交换论证 反证法 算法思想


上一篇:…
下一篇区间贪心——活动选择、区间调度与区间合并三类经典问题


前言

有一类题,如果你想到了正确的思路,代码极短,运行极快;但如果没想到,怎么想都觉得要 DP。

这类题叫贪心题

贪心的核心思想是:

每一步都做当前看起来最优的选择,不回头,不后悔。

听上去很冒险——为什么"眼前最优"能保证"最终最优"?

这篇要讲清楚三件事:

  • 贪心算法是什么,它解决什么类型的问题
  • 贪心正确性怎么证明(两种主流方法)
  • 贪心为什么会失效,怎么识别"伪贪心"

一、算法思想:每步做局部最优选择

贪心算法的核心动作就一句话:

在每个决策点,选当前最优的那个选项,直到问题结束。

最典型的日常例子:

  • 找零钱时,先用面值最大的硬币,凑不够再用小的

这就是贪心。它的特点是:

  • 不枚举所有可能
  • 不依赖"之前做了什么"
  • 每步只看眼前,快速决策

贪心能用的两个前提

贪心不是万能的。它能给出正确答案,需要问题满足:

1. 贪心选择性质

全局最优解可以通过每步的局部最优决策来构造。

也就是说,当前的"最优选择"不会让你在未来付出代价。

2. 最优子结构

问题的最优解包含子问题的最优解。

这和动态规划的要求一样。区别在于:

  • DP 需要枚举子问题
  • 贪心直接从子问题的最优选择出发

贪心 vs 动态规划

两者都要求"最优子结构",但选择策略不同:

对比项贪心动态规划
决策方式每步直接选当前最优枚举所有子问题,取全局最优
是否回退不回退依靠状态记忆所有可能
速度更快通常更慢
适用范围需证明贪心正确更通用

如果一个问题贪心能解,就不用 DP;但贪心的正确性需要证明。


二、完整图解:一个能用贪心的例子 vs 一个不能用的

能用贪心:硬币找零(特定面值)

面值为 [1, 5, 10, 25],目标凑出 41 分。

选 25(剩 16)→ 选 10(剩 6)→ 选 5(剩 1)→ 选 1(结束)
共 4 枚

每步都选当前能用的最大面值,最终用了最少的硬币。✅


不能用贪心:硬币找零(特殊面值)

面值为 [1, 3, 4],目标凑出 6。

贪心做法:

选 4(剩 2)→ 选 1(剩 1)→ 选 1(结束)
共 3 枚

最优做法:

选 3(剩 3)→ 选 3(结束)
共 2 枚 ✅

贪心给出了错误答案。原因:选了 4 之后,后续的选择空间被限制了,整体反而更差。

📌 核心结论:贪心失效的根本原因是——局部最优不等于全局最优。


三、正确性证明:两种主流方法

贪心最难的不是代码,而是证明它是对的

竞赛中最常用的两种方法:

方法一:交换论证(Exchange Argument)

思路

假设存在一个最优解,它的某一步选择和贪心不同。证明把那一步改成贪心的选择,结果不会变差(或更优)。

例子:活动选择问题

有若干活动,每个活动有开始时间和结束时间,只能同时参加一个。目标:最多参加多少个活动?

贪心策略:每次选结束时间最早的活动。

交换论证:

  1. 假设最优解不选"结束最早的活动 A",而选了另一个活动 B(结束更晚)
  2. 把 B 替换成 A:A 结束更早,不会影响后续活动的选择空间
  3. 替换后活动数量不减少
  4. 因此贪心选择不劣于任何最优解 ✅

方法二:反证法(Contradiction)

思路

假设贪心给出的不是最优解,然后推出矛盾。

例子:任务截止时间问题

有 n 个任务,每个任务有截止时间 d 和罚款 w,每次只能做一个任务(耗时 1)。目标:最小化超期罚款总和。

贪心策略:按截止时间排序,尽量在截止前完成,否则调换顺序推迟。

反证:若存在更优解,必然有某两个任务的顺序和贪心相反——但可以证明调整后结果不变或更优,矛盾。


四、代码结构:贪心的通用写法

贪心算法没有固定模板,但结构上基本都是:

1. 对问题按某个"贪心标准"排序
2. 从头到尾扫描,按贪心策略做决策
3. 维护当前状态(不需要回溯)

五、复杂度分析

大多数贪心算法的时间复杂度来自两部分:

步骤时间复杂度说明
排序(如果需要)O(n log n)最常见的前置步骤
扫描决策O(n) * k线性扫描一遍,做每一个选择的时间复杂度是k,k有可能是常数,也有可能是O(m)、O(logm)等,取决于选择的具体情况
整体O(n log n) + O(n) * k选择简单则瓶颈在排序,选择复杂则瓶颈在选择

贪心算法几乎不会有空间上的额外开销,通常是 O(1)O(n)


六、贪心常见题型分类

题型贪心策略典型题目
区间调度按结束时间排序活动选择、会议室安排
任务调度按截止/权重排序带截止任务、带权完成时间
分配问题排序后双端配对糖果分配、救生艇
跳跃问题维护最远可达位置跳跃游戏
字典序构造单调栈贪心移除k个数字、最小字符串
数学贪心利用数学性质最大乘积、分组

七、OJ 例题讲解

例题 1:LeetCode 455 — 分发饼干(贪心入门)

题目来源:LeetCode,题号 455    难度:⭐☆☆☆☆ 简单
题目链接https://leetcode.cn/problems/assign-cookies/

题目描述

每个孩子有一个胃口值 g[i],每块饼干有一个尺寸 s[j]。当饼干尺寸 s[j] >= g[i] 时,孩子 i 满足。每个孩子最多分一块饼干,求最多满足多少个孩子。

输入样例

g = [1,2,3], s = [1,1]

输出样例

1

解题思路

贪心策略:优先用最小能满足需求的饼干去满足胃口最小的孩子。

  1. gs 分别升序排序
  2. 双指针扫描:饼干能满足当前孩子就分配,否则换下一块更大的饼干
  3. 统计满足孩子数

正确性:用大饼干满足小胃口的孩子是浪费,最小够用的饼干留给最小需求的孩子,后续资源更充裕。

Python 解法

from typing import List

class Solution:
    def findContentChildren(self, g: List[int], s: List[int]) -> int:
        g.sort()
        s.sort()
        i = j = 0
        while i < len(g) and j < len(s):
            if s[j] >= g[i]:
                i += 1  # 孩子被满足,看下一个孩子
            j += 1      # 不管满没满足,饼干都用掉了
        return i

C++ 解法

class Solution {
public:
    int findContentChildren(vector<int>& g, vector<int>& s) {
        sort(g.begin(), g.end());
        sort(s.begin(), s.end());
        int i = 0, j = 0;
        while (i < (int)g.size() && j < (int)s.size()) {
            if (s[j] >= g[i]) i++;
            j++;
        }
        return i;
    }
};

代码讲解

  • j 始终向前推进,表示当前考察的饼干
  • i 只在饼干满足孩子时前进,表示已满足的孩子数
  • 最终 i 就是答案

例题 2:LeetCode 860 — 柠檬水找零(状态模拟型贪心)

题目来源:LeetCode,题号 860    难度:⭐⭐☆☆☆ 简单
题目链接https://leetcode.cn/problems/lemonade-change/

题目描述

柠檬水每杯 5 美元。顾客按序付款,每次付 5、10 或 20 美元。需要找零时,判断是否始终能正确找零。

输入样例

bills = [5,5,10,10,20]

输出样例

false

解题思路

模拟找零过程,关键在于:

  • 收到 10 元:必须找 1 张 5 元
  • 收到 20 元:优先找 1 张 10 元 + 1 张 5 元;若没有 10 元,则找 3 张 5 元

贪心:收到 20 元时优先消耗 10 元(因为 5 元比 10 元更万能),这是核心决策点。

Python 解法

from typing import List

class Solution:
    def lemonadeChange(self, bills: List[int]) -> bool:
        five = ten = 0
        for bill in bills:
            if bill == 5:
                five += 1
            elif bill == 10:
                if five == 0: return False
                five -= 1
                ten += 1
            else:  # bill == 20
                if ten > 0 and five > 0:
                    ten -= 1; five -= 1
                elif five >= 3:
                    five -= 3
                else:
                    return False
        return True

C++ 解法

class Solution {
public:
    bool lemonadeChange(vector<int>& bills) {
        int five = 0, ten = 0;
        for (int bill : bills) {
            if (bill == 5) {
                five++;
            } else if (bill == 10) {
                if (!five) return false;
                five--; ten++;
            } else {
                if (ten && five) { ten--; five--; }
                else if (five >= 3) { five -= 3; }
                else return false;
            }
        }
        return true;
    }
};

代码讲解

  • 维护 fiveten 两个计数器即可
  • 收到 20 时先用 10+5 找零(优先消耗 10 元,保留 5 元给更多场景)
  • 任意时刻找不开直接返回 false

例题 3:HDU 2037 — 今年暑假不 AC(活动选择贪心入门)

题目来源:HDU OJ,题号 2037    难度:⭐⭐☆☆☆ 简单
题目链接http://acm.hdu.edu.cn/showproblem.php?pid=2037

题目描述

暑假期间有 n 个节目,每个节目有开始时间和结束时间。每次只能看一个节目,求最多能看多少个完整节目。

输入样例

12
1 3
3 4
0 7
3 8
15 19
15 20
10 15
8 18
6 12
5 10
4 14
2 9
0

输出样例

5

解题思路

经典活动选择问题:

  1. 按结束时间对所有节目升序排序
  2. 维护 lastEnd(当前已选节目的最晚结束时间)
  3. 扫描:若节目开始时间 >= lastEnd,选它,更新 lastEnd

按结束时间贪心的正确性:每次选最早结束的节目,为后续留出最大空间。

Python 解法

import sys
input = sys.stdin.readline

while True:
    n = int(input())
    if n == 0:
        break
    programs = []
    for _ in range(n):
        s, e = map(int, input().split())
        programs.append((e, s))  # 先按结束时间排
    programs.sort()

    count = 0
    last_end = 0
    for end, start in programs:
        if start >= last_end:
            count += 1
            last_end = end
    print(count)

C++ 解法

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    while (cin >> n && n) {
        vector<pair<int,int>> progs(n);  // {end, start}
        for (int i = 0; i < n; i++) {
            int s, e;
            cin >> s >> e;
            progs[i] = {e, s};
        }
        sort(progs.begin(), progs.end());  // 按结束时间升序

        int count = 0, lastEnd = 0;
        for (auto& [e, s] : progs) {
            if (s >= lastEnd) {
                count++;
                lastEnd = e;
            }
        }
        cout << count << '\n';
    }
    return 0;
}

代码讲解

  • {end, start} 形式存储,直接按 pair 默认升序排就是按结束时间排
  • lastEnd 记录上一个选中节目的结束时间
  • 每次遇到不冲突的节目就贪心选择,最终 count 即为答案

八、适用场景

场景是否适合贪心原因
活动选择 / 区间调度经典贪心,按结束时间排序
找零 / 分配✅(特定条件下)需满足贪心选择性质
背包问题(0-1 背包)不满足贪心选择性质,用 DP
最短路(无负权)✅(Dijkstra)满足贪心性质
有限制的任务调度按截止/权重排序

九、常见错误总结

错误原因正确做法
没有证明正确性就乱用贪心感觉对但其实错先用交换论证或反证法验证
贪心标准选错比如区间调度按开始时间而非结束时间想清楚哪个维度是关键
忽视排序步骤直接扫描未排序数组大多数贪心需要先排序
用贪心解 0-1 背包典型错误0-1 背包是 DP,分数背包才能贪心
证明时只给举例举例不能证明正确性必须用形式化论证

总结

要点内容
核心思想每步做局部最优,不回退
成立条件贪心选择性质 + 最优子结构
正确性证明交换论证 / 反证法
典型失效场景0-1 背包、特殊面值找零
常见前置步骤排序
时间复杂度通常 O(n log n)(瓶颈在排序),选择局部最优复杂时瓶颈在遍历选择O(n * k)

一句话记住贪心:

贪心不是"随便选",而是能证明"当前最优不会影响后续最优"的有根据的选择。


上一篇:…
下一篇区间贪心——活动选择、区间调度与区间合并三类经典问题


💬 看完有收获的话,点个赞再走~ 有问题欢迎评论区讨论 🙏

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值