数据结构与算法|第十七章:贪心算法

数据结构与算法|第十七章:贪心算法

  • [第十七章 贪心算法](#第十七章 贪心算法)
    • [17.1 贪心算法的核心思想与证明方法](#17.1 贪心算法的核心思想与证明方法)
      • [17.1.1 核心思想](#17.1.1 核心思想)
      • [17.1.2 贪心算法的证明方法](#17.1.2 贪心算法的证明方法)
        • [1. 归纳法(Induction)](#1. 归纳法(Induction))
        • [2. 交换论证法(Exchange Argument)](#2. 交换论证法(Exchange Argument))
        • [3. 拟阵理论(Matroid Theory)](#3. 拟阵理论(Matroid Theory))
    • [17.2 贪心 vs 动态规划的适用场景对比](#17.2 贪心 vs 动态规划的适用场景对比)
      • [17.2.1 核心区别](#17.2.1 核心区别)
      • [17.2.2 适用场景对比](#17.2.2 适用场景对比)
      • [17.2.3 判断标准:"贪心选择性质"](#17.2.3 判断标准:"贪心选择性质")
    • [17.3 经典实战](#17.3 经典实战)
      • [17.3.1 活动选择问题(Activity Selection)](#17.3.1 活动选择问题(Activity Selection))
      • [17.3.2 分数背包问题(Fractional Knapsack)](#17.3.2 分数背包问题(Fractional Knapsack))
      • [17.3.3 哈夫曼编码(Huffman Coding)](#17.3.3 哈夫曼编码(Huffman Coding))
      • [17.3.4 区间调度问题(合并区间)](#17.3.4 区间调度问题(合并区间))
      • [17.3.5 分发糖果(Candy)](#17.3.5 分发糖果(Candy))
      • [17.3.6 跳跃游戏 I & II(Jump Game)](#17.3.6 跳跃游戏 I & II(Jump Game))
        • [跳跃游戏 I(能否到达终点)](#跳跃游戏 I(能否到达终点))
        • [跳跃游戏 II(最少跳跃次数)](#跳跃游戏 II(最少跳跃次数))
    • 总结与预告

上篇:第十六章、二分查找与搜索

下篇:第十八章、动态规划(上)--- 基础篇

第十七章 贪心算法

在上一章中,我们学习了二分查找------一种"每次排除一半"的搜索策略。从本章开始,我们将正式进入算法设计 的核心领域。而第一个登场的,就是最具"直觉魅力"的算法策略:贪心算法(Greedy Algorithm)

贪心算法:在每一步决策时,都选择当前看起来最优 的选择,期望通过一系列局部最优决策,最终得到全局最优解

它的吸引力在于------思路直接、实现简单、时间复杂度通常很低。但它的陷阱也在于------局部最优不等于全局最优。贪心算法能工作的场景,必须满足特定的"贪心选择性质"和"最优子结构"。

本章将从贪心的核心思想与证明方法出发,通过贪心与动态规划的对比帮你判断"何时用贪心",最后用六大经典实战让你彻底掌握这一策略。


17.1 贪心算法的核心思想与证明方法

17.1.1 核心思想

贪心算法的核心流程可以概括为:


初始问题
当前步骤
在所有可选方案中

选择局部最优解
缩小问题规模
问题是否已解决?
得到最终解

贪心算法不回溯:一旦做出选择,就不会再更改。这是它与回溯算法、动态规划最本质的区别------贪心没有"后悔药"。

举一个生活化的例子:假设你在超市结账,收银员找零 63 元,纸币面额有 {20, 10, 5, 1}。收银员会怎么做?

  • 先拿最大面额 20:63 − 20 × 3 = 3(3 张 20 元)
  • 再拿 5?不行,3 < 5,跳过
  • 拿 1:3 − 1 × 3 = 0(3 张 1 元)
  • 结果:3 张 20 + 3 张 1 = 6 张纸币

这就是贪心------每一步都选面额最大的、不超过剩余金额的纸币。在这个面额体系下,这确实是最优解(最少张数)。但如果面额变成 {20, 15, 1},找零 30 元呢?

  • 贪心:20 + 1 × 10 = 11 张
  • 最优:15 + 15 = 2 张

这就是贪心失效的例子------贪心选择不一定总是全局最优

17.1.2 贪心算法的证明方法

要让贪心算法站得住脚,必须经过严格的数学证明。以下是三种主流证明方法:

1. 归纳法(Induction)

用数学归纳法证明:贪心策略在每一步做出的选择,不会导致后续无法得到最优解。

证明框架:假设贪心策略在前 k 步是正确的,证明第 k+1 步的贪心选择仍然保持最优性。通常需要配合"最优子结构"一起使用。

适用场景:大多数贪心问题都可以用归纳法证明,尤其是"每次选最小/最大"这类问题。

2. 交换论证法(Exchange Argument)

核心思想 :假设存在一个全局最优解 O,如果 O 的第一步选择与贪心策略 G 的第一步不同,我们可以交换某些元素,将 O "改造"成另一个最优解 O′,使得 O′ 的第一步与 G 一致,并且不损失最优性。通过反复交换,最终将任意最优解"对齐"到贪心解。

这是贪心算法最强有力的证明武器,尤其适用于活动选择、任务调度等"选与不选"类问题。

3. 拟阵理论(Matroid Theory)

如果一个组合优化问题可以建模为 拟阵(Matroid) 上的优化问题,那么贪心算法一定能得到最优解。拟阵是一个抽象的代数结构,定义了"独立集"的概念。

适用场景:最小生成树(Kruskal 算法)、最大权独立集等。这是贪心算法正确性的"终极解释",但门槛较高,面试和实际开发中较少用到。


17.2 贪心 vs 动态规划的适用场景对比

贪心和动态规划(DP)经常被放在一起比较,因为它们都依赖 "最优子结构"。但两者的决策方式截然不同。

17.2.1 核心区别

对比维度 贪心算法 动态规划
决策方式 每步做出一个不可撤销的局部最优选择 记录所有子问题的解,通过状态转移推导
回溯能力 不能回退,选了就不能改 可以回退,通过状态表保存所有可能
解空间探索 只走一条路(贪心选择那条) 遍历所有可能的子问题
时间复杂度 通常 O(n log n) 或更低 通常 O(n²) 或更高
正确性保证 需要严格的数学证明 状态转移方程推导即可
典型问题 活动选择、哈夫曼编码、最小生成树 背包问题(0-1)、编辑距离、LCS

17.2.2 适用场景对比







给定一个问题
问题是否具有

最优子结构?
贪心和DP都无法保证最优解

考虑回溯/暴力搜索
是否具有

贪心选择性质?
贪心算法 ✓

O n log n 或更低
子问题是否

大量重叠?
动态规划 ✓

O n² 或更高
分治法 ✓

如归并排序、快速排序

17.2.3 判断标准:"贪心选择性质"

贪心选择性质(Greedy Choice Property) :全局最优解可以通过一系列局部最优选择 得到。或者说,贪心的第一步选择,一定存在于某个全局最优解中

快速判断法

  • 如果"排序后按某种顺序贪心"能通过直觉验证 → 大概率可行,但需要严格证明
  • 如果问题中存在"选了 A 就不能选 B,且 A 和 B 之间有复杂依赖" → 大概率是 DP
  • 如果问题问的是"最大/最小值"且选择之间无后效性 → 先尝试贪心,再用反例验证

17.3 经典实战

17.3.1 活动选择问题(Activity Selection)

问题描述 :给定 n 个活动,每个活动 i i i 有开始时间 s i s_i si 和结束时间 f i f_i fi( s i < f i s_i < f_i si<fi)。同一时间只能参加一个活动。求最多能参加多少个互不重叠的活动。

示例

复制代码
活动:  A   B   C   D   E   F
开始:  1   3   0   5   8   5
结束:  4   5   6   7   9   9

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

直觉解释:结束得越早,留给后面活动的时间就越多,就越有机会安排更多活动。每次都选"最不贪心占用时间"的那个,最终反而能得到最多的数量。

交换论证证明概要 :假设全局最优解 O 不是按最早结束时间选的。设 O 的第一个活动为 a k a_k ak,贪心策略 G 的第一个活动为 a 1 a_1 a1( f 1 ≤ f k f_1 \le f_k f1≤fk)。用 a 1 a_1 a1 替换 a k a_k ak 得到 O ′ O' O′:因为 f 1 ≤ f k f_1 \le f_k f1≤fk, a 1 a_1 a1 不会和 O 中的后续活动冲突,所以 O ′ O' O′ 仍是可行解且活动数相同,仍是最优解。重复此过程,可将任意最优解逐步"对齐"到贪心解。

java 复制代码
import java.util.Arrays;
import java.util.Comparator;

/**
 * 活动选择问题 ------ 贪心算法
 * 策略:按结束时间升序排序,依次选择不冲突的活动
 * 时间复杂度:O(n log n)(排序)
 * 空间复杂度:O(n)(存储结果)
 */
public class ActivitySelection {

    /**
     * 活动类
     */
    static class Activity {
        int start;
        int finish;

        Activity(int start, int finish) {
            this.start = start;
            this.finish = finish;
        }

        @Override
        public String toString() {
            return "(" + start + ", " + finish + ")";
        }
    }

    /**
     * 选择最大数量的互不重叠活动
     * @param activities 活动数组
     * @return 最多可选的活动数量
     */
    public int selectMaxActivities(Activity[] activities) {
        if (activities == null || activities.length == 0) return 0;

        // 1. 按结束时间升序排序
        Arrays.sort(activities, Comparator.comparingInt(a -> a.finish));

        // 2. 贪心选择:第一个活动必选
        int count = 1;
        int lastFinish = activities[0].finish;

        // 3. 遍历剩余活动
        for (int i = 1; i < activities.length; i++) {
            if (activities[i].start >= lastFinish) {
                count++;
                lastFinish = activities[i].finish;
            }
        }
        return count;
    }

    /**
     * 返回具体选择的活动列表
     * @param activities 活动数组
     * @return 被选中的活动数组
     */
    public Activity[] getSelectedActivities(Activity[] activities) {
        if (activities == null || activities.length == 0) return new Activity[0];

        Arrays.sort(activities, Comparator.comparingInt(a -> a.finish));

        // 先统计数量
        int count = 1;
        int lastFinish = activities[0].finish;
        for (int i = 1; i < activities.length; i++) {
            if (activities[i].start >= lastFinish) {
                count++;
                lastFinish = activities[i].finish;
            }
        }

        // 再收集结果
        Activity[] result = new Activity[count];
        int idx = 0;
        result[idx++] = activities[0];
        lastFinish = activities[0].finish;

        for (int i = 1; i < activities.length; i++) {
            if (activities[i].start >= lastFinish) {
                result[idx++] = activities[i];
                lastFinish = activities[i].finish;
            }
        }
        return result;
    }
}

复杂度分析 :排序 O(n log n),遍历 O(n),总 O(n log n)。空间复杂度 O(n)(排序栈 + 结果数组)。


17.3.2 分数背包问题(Fractional Knapsack)

问题描述 :给定 n 个物品,每个物品 i i i 有重量 w i w_i wi 和价值 v i v_i vi。背包容量为 W。物品可以被分割(取任意比例)。求能装入背包的最大总价值。
与 0-1 背包的区别:0-1 背包中物品不可分割,必须用 DP;分数背包中物品可分割,贪心即最优。

贪心策略:按单位重量价值( v i / w i v_i / w_i vi/wi)降序排列,优先拿"性价比"最高的物品,拿完为止

复制代码
示例:W = 50
物品 A:重量 10,价值 60  → 性价比 6
物品 B:重量 20,价值 100 → 性价比 5
物品 C:重量 30,价值 120 → 性价比 4

贪心:先拿 A(10,价值 60),再拿 B(20,价值 100),剩余容量 20,拿 C 的 20/30 → 价值 80
总价值 = 60 + 100 + 80 = 240

正确性证明 :假设最优解没有按性价比排序。设最优解中拿了物品 i i i 和 j j j,但 i i i 性价比 < j j j 性价比。如果将 i i i 的微小重量 ε \varepsilon ε 换成 j j j(即少拿一点 i i i,多拿一点 j j j),总价值必然增加,与"最优"矛盾。因此最优解一定按性价比降序取物品。

java 复制代码
import java.util.Arrays;
import java.util.Comparator;

/**
 * 分数背包问题 ------ 贪心算法
 * 策略:按单位重量价值降序,依次取物品直至背包装满
 * 时间复杂度:O(n log n)
 * 空间复杂度:O(1)(原地排序)
 */
public class FractionalKnapsack {

    /**
     * 物品类
     */
    static class Item {
        int weight;
        int value;
        /** 单位重量价值(性价比) */
        double ratio;

        Item(int weight, int value) {
            this.weight = weight;
            this.value = value;
            this.ratio = (double) value / weight;
        }
    }

    /**
     * 计算分数背包的最大总价值
     * @param items    物品数组
     * @param capacity 背包容量
     * @return 最大总价值
     */
    public double getMaxValue(Item[] items, int capacity) {
        if (items == null || items.length == 0 || capacity <= 0) return 0.0;

        // 1. 按性价比降序排序
        Arrays.sort(items, Comparator.comparingDouble(a -> -a.ratio));

        double totalValue = 0.0;
        int remainingCapacity = capacity;

        // 2. 贪心:优先拿性价比最高的
        for (Item item : items) {
            if (remainingCapacity <= 0) break;

            if (item.weight <= remainingCapacity) {
                // 整个物品都能装入
                totalValue += item.value;
                remainingCapacity -= item.weight;
            } else {
                // 只能装入部分
                double fraction = (double) remainingCapacity / item.weight;
                totalValue += item.value * fraction;
                remainingCapacity = 0;
            }
        }
        return totalValue;
    }
}

复杂度分析 :排序 O(n log n),遍历 O(n),总 O(n log n)。空间复杂度 O(1)。


17.3.3 哈夫曼编码(Huffman Coding)

问题描述 :给定 n 个字符及其出现频率,构造一棵最优前缀编码树(哈夫曼树),使得编码后的总比特数最小。前缀编码要求:没有任何一个字符的编码是另一个字符编码的前缀。

贪心策略:使用优先队列(最小堆),每次取出两个频率最小的节点合并成一个新节点,新节点的频率为两者之和,再放回堆中。重复直到只剩一个根节点。

复制代码
示例:字符 a(5), b(9), c(12), d(13), e(16), f(45)

步骤:
1. 取 5+9=14  → 堆: {12, 13, 14, 16, 45}
2. 取 12+13=25 → 堆: {14, 16, 25, 45}
3. 取 14+16=30 → 堆: {25, 30, 45}
4. 取 25+30=55 → 堆: {45, 55}
5. 取 45+55=100 → 堆: {100}  完成!

最终哈夫曼树结构:
0
1
0
1
0
1
0
1
0
1
root (100)
f:45
55
25
30
c:12
d:13
14
e:16
a:5
b:9

复制代码
编码结果(左0右1):
f: 0
c: 100
d: 101
a: 1100
b: 1101
e: 111

正确性证明(交换论证):设 x 和 y 是频率最小的两个字符。假设最优树 T 中 x 和 y 不是最深的兄弟节点。将最深的一对兄弟叶子与 x、y 交换,由于 x、y 频率最小,交换后总代价不会增加,仍是最优解。由此证明贪心选择正确。

java 复制代码
import java.util.PriorityQueue;

/**
 * 哈夫曼编码 ------ 贪心算法
 * 策略:使用最小堆,每次合并频率最小的两个节点
 * 时间复杂度:O(n log n)
 * 空间复杂度:O(n)
 */
public class HuffmanCoding {

    /**
     * 哈夫曼树节点
     */
    static class HuffmanNode implements Comparable<HuffmanNode> {
        char ch;            // 字符(叶子节点有效)
        int freq;           // 频率
        HuffmanNode left;   // 左子树(编码 0)
        HuffmanNode right;  // 右子树(编码 1)

        HuffmanNode(char ch, int freq) {
            this.ch = ch;
            this.freq = freq;
        }

        HuffmanNode(int freq, HuffmanNode left, HuffmanNode right) {
            this.ch = '\0';   // 内部节点无字符
            this.freq = freq;
            this.left = left;
            this.right = right;
        }

        boolean isLeaf() {
            return left == null && right == null;
        }

        @Override
        public int compareTo(HuffmanNode other) {
            return Integer.compare(this.freq, other.freq);
        }
    }

    /**
     * 构建哈夫曼树
     * @param chars  字符数组
     * @param freqs  对应频率数组
     * @return 哈夫曼树的根节点
     */
    public HuffmanNode buildHuffmanTree(char[] chars, int[] freqs) {
        if (chars == null || freqs == null || chars.length != freqs.length) {
            throw new IllegalArgumentException("chars 和 freqs 长度必须相等且非空");
        }

        // 1. 将所有字符加入最小堆
        PriorityQueue<HuffmanNode> minHeap = new PriorityQueue<>();
        for (int i = 0; i < chars.length; i++) {
            minHeap.offer(new HuffmanNode(chars[i], freqs[i]));
        }

        // 2. 贪心合并:每次取两个频率最小的节点
        while (minHeap.size() > 1) {
            HuffmanNode left = minHeap.poll();
            HuffmanNode right = minHeap.poll();
            HuffmanNode parent = new HuffmanNode(
                left.freq + right.freq, left, right
            );
            minHeap.offer(parent);
        }

        // 3. 返回根节点
        return minHeap.poll();
    }

    /**
     * 打印每个字符的哈夫曼编码
     * @param root  哈夫曼树根节点
     * @param code  当前路径编码(递归参数)
     */
    public void printCodes(HuffmanNode root, String code) {
        if (root == null) return;

        if (root.isLeaf()) {
            System.out.println(root.ch + ": " + code);
            return;
        }
        printCodes(root.left, code + "0");
        printCodes(root.right, code + "1");
    }

    /**
     * 计算 WPL(带权路径长度)= 总编码比特数
     * @param root  哈夫曼树根节点
     * @param depth 当前深度
     * @return WPL 值
     */
    public int computeWPL(HuffmanNode root, int depth) {
        if (root == null) return 0;
        if (root.isLeaf()) {
            return root.freq * depth;
        }
        return computeWPL(root.left, depth + 1)
             + computeWPL(root.right, depth + 1);
    }
}

复杂度分析 :建堆 O(n),每次 poll 和 offer 都是 O(log n),共执行 n−1 次合并,总 O(n log n)。空间复杂度 O(n)。


17.3.4 区间调度问题(合并区间)

问题描述(LeetCode 56) :给定若干区间 intervals[i] = [start_i, end_i],将所有重叠的区间合并,返回合并后的区间数组。

复制代码
输入:[[1,3], [2,6], [8,10], [15,18]]
输出:[[1,6], [8,10], [15,18]]
解释:[1,3] 和 [2,6] 重叠 → 合并为 [1,6]

贪心策略:按区间起点升序排序,然后依次遍历。若当前区间与上一个合并区间重叠(curr[0] <= last[1]),则扩展 last[1] = max(last[1], curr[1]);否则,当前区间作为一个新的独立区间加入结果。

java 复制代码
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

/**
 * 合并区间 ------ 贪心算法
 * 策略:按起点排序,依次判断重叠并合并
 * 时间复杂度:O(n log n)
 * 空间复杂度:O(n)(存储结果)
 */
public class MergeIntervals {

    /**
     * 合并所有重叠区间
     * @param intervals 区间数组,每个区间为 [start, end]
     * @return 合并后的区间数组
     */
    public int[][] merge(int[][] intervals) {
        if (intervals == null || intervals.length <= 1) {
            return intervals;
        }

        // 1. 按起点升序排序
        Arrays.sort(intervals, Comparator.comparingInt(a -> a[0]));

        List<int[]> result = new ArrayList<>();
        int[] last = intervals[0];   // 当前合并中的区间

        // 2. 贪心合并
        for (int i = 1; i < intervals.length; i++) {
            int[] curr = intervals[i];
            if (curr[0] <= last[1]) {
                // 重叠:扩展终点
                last[1] = Math.max(last[1], curr[1]);
            } else {
                // 不重叠:保存上一个,开始新区间
                result.add(last);
                last = curr;
            }
        }
        result.add(last);   // 最后一个区间

        return result.toArray(new int[result.size()][]);
    }
}

复杂度分析 :排序 O(n log n),遍历 O(n),总 O(n log n)。空间复杂度 O(n)。


17.3.5 分发糖果(Candy)

问题描述(LeetCode 135) :n 个孩子排成一排,每个孩子有一个评分 ratings[i]。你需要给每个孩子分发糖果,满足:

  1. 每个孩子至少 1 颗糖
  2. 相邻两个孩子中,评分更高 的孩子必须获得更多糖果

求最少需要多少颗糖果。

复制代码
输入:[1, 0, 2]
输出:5(分法:2 + 1 + 2)

输入:[1, 2, 2]
输出:4(分法:1 + 2 + 1)

贪心策略:两趟扫描

  • 第一趟从左到右 :若 ratings[i] > ratings[i-1],则 candies[i] = candies[i-1] + 1
  • 第二趟从右到左 :若 ratings[i] > ratings[i+1],则 candies[i] = max(candies[i], candies[i+1] + 1)

为什么需要两趟? 一次遍历只能保证一个方向上的约束。"评分高 → 糖多"是双向约束------左邻居和右邻居都要满足。第一趟保证了"比左边高"的约束,第二趟补上"比右边高"的约束(同时不破坏已满足的左边约束,因为是取 max)。

java 复制代码
import java.util.Arrays;

/**
 * 分发糖果 ------ 贪心算法(两趟扫描)
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
public class Candy {

    /**
     * 计算最少需要的糖果总数
     * @param ratings 每个孩子的评分
     * @return 最少糖果总数
     */
    public int candy(int[] ratings) {
        if (ratings == null || ratings.length == 0) return 0;

        int n = ratings.length;
        int[] candies = new int[n];
        // 每人至少 1 颗
        Arrays.fill(candies, 1);

        // 第一趟:从左到右,保证"比左边高"的约束
        for (int i = 1; i < n; i++) {
            if (ratings[i] > ratings[i - 1]) {
                candies[i] = candies[i - 1] + 1;
            }
        }

        // 第二趟:从右到左,保证"比右边高"的约束
        for (int i = n - 2; i >= 0; i--) {
            if (ratings[i] > ratings[i + 1]) {
                candies[i] = Math.max(candies[i], candies[i + 1] + 1);
            }
        }

        // 求和
        int total = 0;
        for (int c : candies) {
            total += c;
        }
        return total;
    }
}

复杂度分析 :两趟遍历 + 一次求和,总 O(n)。空间复杂度 O(n)(candies 数组)。可以优化为 O(1) 空间(利用"递增递减序列"一次遍历),但 O(n) 写法更清晰,面试中优先使用。


17.3.6 跳跃游戏 I & II(Jump Game)

跳跃游戏 I(能否到达终点)

问题描述(LeetCode 55) :给定非负整数数组 nums,你最初位于下标 0。每个元素 nums[i] 表示从位置 i 最多可以跳跃的长度。判断能否到达最后一个下标。

复制代码
输入:[2, 3, 1, 1, 4]
输出:true(路径:0→1→4)

输入:[3, 2, 1, 0, 4]
输出:false(卡在位置 3,无法继续前进)

贪心策略:维护一个"最远可达位置" maxReach。遍历数组,若当前位置 ≤ maxReach,则更新 maxReach。一旦 maxReach ≥ 最后一个下标,说明可到达。

java 复制代码
/**
 * 跳跃游戏 I ------ 贪心算法
 * 时间复杂度:O(n)
 * 空间复杂度:O(1)
 */
public boolean canJump(int[] nums) {
    if (nums == null || nums.length == 0) return false;

    int maxReach = 0;
    int n = nums.length;

    for (int i = 0; i < n; i++) {
        // 当前位置不可达
        if (i > maxReach) {
            return false;
        }
        // 更新最远可达位置
        maxReach = Math.max(maxReach, i + nums[i]);
        // 提前终止:已经能到终点
        if (maxReach >= n - 1) {
            return true;
        }
    }
    return true;
}
跳跃游戏 II(最少跳跃次数)

问题描述(LeetCode 45) :给定非负整数数组 nums,保证一定能到达最后一个下标。求最少跳跃次数

复制代码
输入:[2, 3, 1, 1, 4]
输出:2(0→1→4,共2跳)

贪心策略:BFS 式的贪心。维护三个变量:

  • jumps:已跳跃次数
  • curEnd:当前跳跃能到达的最远边界
  • curFarthest:在遍历过程中,下一跳能到达的最远位置

遍历到 i == curEnd 时,意味着当前跳跃范围已耗尽,必须再跳一次(jumps++),并将 curEnd 更新为 curFarthest

直觉解释:想象你在做 BFS 层序遍历------每个"跳跃次数"对应 BFS 的一层。"这一跳"能到达的所有位置是当前层,"下一跳"能到达的新位置是下一层。

java 复制代码
/**
 * 跳跃游戏 II ------ 贪心算法(BFS 视角)
 * 时间复杂度:O(n)
 * 空间复杂度:O(1)
 */
public int jump(int[] nums) {
    if (nums == null || nums.length <= 1) return 0;

    int jumps = 0;          // 跳跃次数
    int curEnd = 0;         // 当前跳跃能到达的最远位置
    int curFarthest = 0;    // 下一跳能到达的最远位置
    int n = nums.length;

    // 注意:只需遍历到 n-2,因为到达 n-1 就结束了
    for (int i = 0; i < n - 1; i++) {
        curFarthest = Math.max(curFarthest, i + nums[i]);

        // 到达当前跳跃的边界,必须再跳一次
        if (i == curEnd) {
            jumps++;
            curEnd = curFarthest;

            // 提前终止
            if (curEnd >= n - 1) {
                break;
            }
        }
    }
    return jumps;
}

复杂度分析 :只遍历一次数组,O(n) 时间,O(1) 空间。


总结与预告

本章系统学习了贪心算法的完整体系:

  • 17.1 核心思想与证明:贪心 = 每步选局部最优,不可回退。三种证明方法------归纳法(基础)、交换论证法(最常用)、拟阵理论(高级)
  • 17.2 贪心 vs DP:贪心走一条路、不回退;DP 记所有路、可回退。判断标准是"贪心选择性质"是否存在
  • 17.3 六大经典实战
问题 贪心策略 时间复杂度
活动选择 按结束时间升序 O(n log n)
分数背包 按性价比降序 O(n log n)
哈夫曼编码 最小堆合并 O(n log n)
合并区间 按起点排序合并 O(n log n)
分发糖果 左右两趟扫描 O(n)
跳跃游戏 I & II 维护最远可达位置/BFS 层级 O(n)

贪心算法的核心套路

  1. 对数据进行排序(按某个维度)
  2. 按照排序后的顺序依次贪心选择
  3. 维护一个关键变量(如最大/最小值、最远距离、当前边界)

下一章我们将进入算法设计中的"重头戏"------动态规划。DP 是贪心算法失败时的"备选方案":当贪心选择性质不成立时,DP 通过穷举所有子问题来保证全局最优。我们将从斐波那契数列、爬楼梯等经典问题入手,学习 DP 的状态定义、状态转移方程和边界条件。


上篇:第十六章、二分查找与搜索

下篇:第十八章、动态规划(上)--- 基础篇

相关推荐
多加点辣也没关系1 小时前
数据结构与算法|第十四章:排序算法(上)— 比较类排序
数据结构·算法·排序算法
笨笨饿1 小时前
#72_聊聊I2C以及他们的变体
linux·c语言·网络·stm32·单片机·算法·个人开发
机器人图像处理1 小时前
6-自动白平衡(灰度世界算法)
opencv·算法·相机
Dr.Zeus1 小时前
从电芯到系统:BMS算法视角下的电池热管理深度解析作者署名
算法·能源
ulias2121 小时前
leetcode热题 - 6
linux·算法·leetcode
北顾笙9801 小时前
day42-数据结构力扣
数据结构
七颗糖很甜2 小时前
卫星通信遇到“太空天气”会怎样---电离层闪烁对卫星通信的影响
大数据·python·算法
风筝在晴天搁浅2 小时前
阿里淘天/京东 CodeTop LeetCode103.二叉树的锯齿形层序遍历
数据结构
小凡子空白在线学习2 小时前
工作拆分so总结
java·jvm·算法