Java 算法实践(八):贪心算法思路

深入探讨了动态规划(Dynamic Programming, DP) 。DP 的核心在于"重叠子问题"和"状态转移",它本质上是一种全局最优的搜索策略,通过遍历所有可能的状态并记录中间结果,确保最终解是全局最优的。

贪心算法(Greedy Algorithm) 则是一种更为直接、激进的策略。它在每一步决策时,不考虑整体最优,而是做出在当前看来局部最优 的选择。贪心算法的假设是:由一系列局部最优的选择,最终可以堆叠出全局最优解。

贪心算法没有固定的套路或模板(不像 DP 有明确的状态转移方程),其核心难点在于证明:为什么当前步骤的局部最优解一定能导致全局最优?

贪心算法(Greedy)与动态规划(DP)的主要区别在于:

  1. 决策过程
    • DP:在做当前决策时,会考虑之前的状态和未来的可能性(通过状态转移方程关联)。
    • Greedy:在做当前决策时,只关注当前状态,做出一个不可回撤的选择,然后处理剩下的子问题。
  2. 复杂度
    • 由于贪心算法通常只需遍历一次数据(或配合排序),其时间复杂度通常为 O ( N ) O(N) O(N) 或 O ( N log ⁡ N ) O(N \log N) O(NlogN),优于 DP 的 O ( N 2 ) O(N^2) O(N2)。

因此,如果一个问题能用贪心解决,那么贪心通常是该问题的最优解法


一、 线性序列贪心:利润最大化

这类问题通常涉及对数组的线性遍历。核心在于将一个跨越长周期的宏观问题,分解为若干个微观的、局部的决策问题。

1.1 实战例题:买卖股票的最佳时机 II

题目链接LeetCode 122. Best Time to Buy and Sell Stock II

题目描述:给定一个数组 prices ,其中 prices[i] 表示股票第 i 天的价格。在每一天,你可以决定买入和/或卖出股票。你也可以先买入,然后在同一天卖出。返回 你能获得的 最大利润 。(不限制交易次数)。

逻辑解析

本题的难点在于:如何在多变的股价波动中找到买卖的最佳时机点?

如果试图寻找"最低点买入"和"最高点卖出",情况会非常复杂,因为可能存在多个波段。

1. 利润的数学分解(核心推导)

假设我们在第 0 天买入,坚持持有到第 3 天卖出。

  • 总利润 : p r i c e s [ 3 ] − p r i c e s [ 0 ] prices[3] - prices[0] prices[3]−prices[0]。

  • 数学变换

    将每一天的价格差拆解开来:

    p r i c e s [ 3 ] − p r i c e s [ 0 ] = ( p r i c e s [ 3 ] − p r i c e s [ 2 ] ) + ( p r i c e s [ 2 ] − p r i c e s [ 1 ] ) + ( p r i c e s [ 1 ] − p r i c e s [ 0 ] ) prices[3] - prices[0] = (prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0]) prices[3]−prices[0]=(prices[3]−prices[2])+(prices[2]−prices[1])+(prices[1]−prices[0])

  • 物理含义

    这表明,在第 0 天买入并在第 3 天卖出 的操作,在利润结果上严格等价于 第 0 天买第 1 天卖,第 1 天买第 2 天卖,第 2 天买第 3 天卖 这一系列连续操作的总和。
    2. 贪心策略的构建

    既然任何长期的交易都可以分解为相邻两天 的交易组合,那么问题就简化为:
    对于每一天(第 i i i 天),是否应该利用它和前一天(第 i − 1 i-1 i−1 天)的差价进行获利?

  • 决策依据

    • 如果 p r i c e s [ i ] > p r i c e s [ i − 1 ] prices[i] > prices[i-1] prices[i]>prices[i−1]:说明这两天股票在涨。这一段区间的差价(利润)是正数。为了让总利润最大,必须赚取这一段利润。
    • 如果 p r i c e s [ i ] ≤ p r i c e s [ i − 1 ] prices[i] \le prices[i-1] prices[i]≤prices[i−1]:说明这两天股票在跌或持平。这一段区间的差价是负数或零。为了不亏钱,选择放弃这一段(即不持有股票,或者理解为利润为 0)。
  • 最终公式 :最大利润 = ∑ i = 1 n − 1 m a x ( p r i c e s [ i ] − p r i c e s [ i − 1 ] , 0 ) ∑i=1n−1max(prices[i]−prices[i−1],0) ∑i=1n−1max(prices[i]−prices[i−1],0)(n 为价格数组长度)

  • 局部最优 → \rightarrow → 全局最优

    • 不需要预测未来是涨是跌。
    • 只要今天比昨天贵,就把这部分的差价加到总利润里。
    • 最终的总利润就是所有正向差价之和。由于只累加正数,结果必然是理论上的最大值。

示例推演
prices = [1, 5, 3, 6]

  1. 第 1 天价格 5,第 0 天价格 1。 5 − 1 = 4 > 0 5-1=4 > 0 5−1=4>0。累加 4
  2. 第 2 天价格 3,第 1 天价格 5。 3 − 5 = − 2 < 0 3-5=-2 < 0 3−5=−2<0。放弃
  3. 第 3 天价格 6,第 2 天价格 3。 6 − 3 = 3 > 0 6-3=3 > 0 6−3=3>0。累加 3
  4. 最大利润 = 4 + 3 = 7 4 + 3 = 7 4+3=7。
    等值式为
    ( p r i c e s [ 1 ] − p r i c e s [ 0 ] ) ⏟ 0 天买 1 天卖 + ( p r i c e s [ 3 ] − p r i c e s [ 2 ] ) ⏟ 2 天买 3 天卖 = 7 \underbrace{(prices[1] - prices[0])}{0天买1天卖} + \underbrace{(prices[3] - prices[2])}{2天买3天卖} = 7 0天买1天卖 (prices[1]−prices[0])+2天买3天卖 (prices[3]−prices[2])=7

Java 代码

java 复制代码
public int maxProfit(int[] prices) {
    int maxProfit = 0;
    
    // 从第 1 天(索引 1)开始遍历,每次回顾前一天(索引 i-1)
    for (int i = 1; i < prices.length; i++) {
        // 计算相邻两天的利润差
        int diff = prices[i] - prices[i - 1];
        
        // 贪心决策:
        // 只有当利润为正(diff > 0)时,才将其计入总利润。
        // 这相当于我们捕捉了股价走势图中每一个"上升"的片段。
        if (diff > 0) {
            maxProfit += diff;
        }
    }
    
    return maxProfit;
}

复杂度

  • 时间复杂度 : O ( N ) O(N) O(N)。只需遍历一次数组。
  • 空间复杂度 : O ( 1 ) O(1) O(1)。

二、 区间覆盖贪心:跳跃游戏系列

"跳跃游戏"系列问题的核心,并非在于"跳跃"这一动作本身,而在于维护一个动态伸缩的右边界(Cover Range)

我们将数组的索引视为一条数轴。数组中的每个元素 nums[i] 实际上定义了一个i 为左端点,i + nums[i] 为右端点的潜在覆盖区间。

贪心算法的任务就是将这些分散的小区间,通过线性扫描,融合为一个从起点出发的、连续的、尽可能远的大区间。

2.1 实战例题:跳跃游戏

题目链接LeetCode 55. Jump Game

题目描述:给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。

逻辑深度解析

  • 误区 :纠结于"到底跳几步"。在当前位置 i,可以跳 1 步,也可以跳 nums[i] 步,选择太多,容易陷入回溯思维。
  • 贪心视角 :不关心具体跳几步,只关心覆盖范围
    • 在位置 i,能够覆盖的最远距离是 i + nums[i]
    • 在遍历过程中,不断维护一个变量 cover,表示当前能够到达的最远索引。
  • 局部最优:每次都更新最大的覆盖范围。
  • 全局最优 :如果最终 cover 大于等于终点下标,则说明可达。

关键细节

遍历必须限制在 cover 范围内。即 for (int i = 0; i <= cover; i++)。因为如果 i 超过了 cover,说明这个位置根本无法到达,更别提从这个位置起跳了。

Java 代码

java 复制代码
public boolean canJump(int[] nums) {
    // cover 表示当前能跳到的最远位置,初始在 0
    int cover = 0;
    
    // 注意终止条件:只能在 cover 范围内移动
    // 同时也无需遍历到最后一个元素,只需判断 cover 是否能覆盖 nums.length - 1
    for (int i = 0; i <= cover; i++) {
        // 更新当前能覆盖的最远距离
        // i + nums[i] 是从当前位置能触及的最远边界
        cover = Math.max(cover, i + nums[i]);
        
        // 剪枝:如果已经能覆盖到末尾,直接返回 true
        if (cover >= nums.length - 1) {
            return true;
        }
    }
    
    // 如果遍历结束还无法覆盖到末尾
    return false;
}

2.2 实战例题:跳跃游戏 II

题目链接LeetCode 45. Jump Game II

题目描述:给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。生成的测试用例可以保证你总是可以到达数组的最后一个位置。目标是使用最少的跳跃次数到达数组的最后一个位置。

逻辑解析

这是上一题的进阶版。上一题只问"能不能",这一题问"最少几次",上一题我们只维护了一个"最远边界",而在这道题中,我们需要维护每一跳的边界

这个问题本质上是一个隐式的 BFS(广度优先搜索) ,但不需要队列,只需要通过贪心维护两个边界变量即可实现 O ( N ) O(N) O(N) 的层序遍历。

  • 决策困境 :在位置 0,可以跳到 1, 2, ... nums[0]。应该跳到哪里?
  • 贪心策略 :不要只看这一步跳多远,要看这一步跳出去后,下一步能覆盖多远 。我们希望每一步都尽可能地扩大覆盖范围。
    为了实现 O ( N ) O(N) O(N) 遍历,需要维护两个变量:
  1. curCover (当前覆盖边界) :表示当前步数下,最远能走到的位置。在达到这个边界之前,不需要增加步数。
  2. nextCover (下一步覆盖边界) :表示如果在当前覆盖范围内选择一个起跳点,下一步最远能走到哪里。

算法流程

  • 从头开始遍历数组。
  • 在遍历过程中,不断计算 i + nums[i],并更新 nextCover(记录潜在的最大射程)。
  • 当遍历指针 i 到达了 curCover(即当前这一步走到了尽头):
    • 必须进行下一次跳跃:步数 steps++
    • 更新边界:curCover = nextCover(将当前步数的终点延伸到刚才记录的最大射程)。
    • 如果 curCover 已经覆盖了终点,循环结束。

Java 代码

java 复制代码
public int jump(int[] nums) {
    if (nums.length == 1) return 0;
    
    int curCover = 0;  // 当前跳跃步数能到达的边界
    int nextCover = 0; // 下一步能到达的最远边界
    int steps = 0;     // 记录跳跃次数
    
    // 注意:遍历到 nums.length - 2 即可
    // 因为当我们到达 nums.length - 1 时,已经不需要再跳了
    for (int i = 0; i < nums.length - 1; i++) {
        // 贪心策略:实时统计下一步能覆盖的最远距离
        nextCover = Math.max(nextCover, i + nums[i]);
        
        // 边界判断:如果走到了当前步数的边界
        if (i == curCover) {
            // 必须进行一次跳跃
            steps++;
            // 更新当前边界为刚才统计出的最大范围
            curCover = nextCover;
            
            // 剪枝:如果已经覆盖到终点,提前结束
            if (curCover >= nums.length - 1) {
                break;
            }
        }
    }
    
    return steps;
}

复杂度

  • 时间: O ( N ) O(N) O(N)。不需要像 DP 那样 O ( N 2 ) O(N^2) O(N2) 计算所有状态。
  • 空间: O ( 1 ) O(1) O(1)。

三、 字符串/序列切分:划分字母区间

这类问题涉及对序列进行切分(Partitioning),要求切分后的片段满足特定条件(如互不重叠、包含特定元素等)。

3.1 实战例题:划分字母区间

题目链接LeetCode 763. Partition Labels

题目描述:给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。

逻辑解析

  • 题目核心约束 :如果字符 'a' 出现在片段 A 中,那么字符串中所有的 'a' 都必须出现在片段 A 中。
  • 推论 :一个片段的结束位置 ,至少要是该片段内所有字符在字符串中出现的最远位置

贪心策略

  1. 预处理 :先遍历一遍字符串,记录每个字符最后一次出现的下标。例如 lastIndex['a'] = 8
  2. 遍历与扩展
    • 维护两个指针:left(当前片段起始)和 right(当前片段结束)。
    • 遍历字符串 i 从 0 到 s.length
    • 对于遇到的每个字符 c,更新当前片段的结束边界 right
      • right = Math.max(right, lastIndex[c])
      • 含义 :既然当前片段包含了 c,那么这个片段至少要延伸到 c 最后出现的位置。
  3. 切割时机
    • 当遍历指针 i 到达了 right 时,说明下标 0i 之间出现的所有字符,它们最后出现的位置都没有超过 i
    • 此时可以安全地切一刀。记录长度 right - left + 1,并更新 left = i + 1 开始下一个片段。

Java 代码

java 复制代码
public List<Integer> partitionLabels(String s) {
    // 预处理:记录每个字符最后出现的位置
    int[] lastOccur = new int[26];
    char[] chars = s.toCharArray();
    for (int i = 0; i < chars.length; i++) {
        lastOccur[chars[i] - 'a'] = i;
    }
    
    List<Integer> result = new ArrayList<>();
    int left = 0;
    int right = 0;
    
    // 遍历字符串
    for (int i = 0; i < chars.length; i++) {
        // 贪心策略:找到当前范围内所有字符的最远边界
        right = Math.max(right, lastOccur[chars[i] - 'a']);
        
        // 切割时机:当扫描位置 i 追上了当前的最远边界 right
        if (i == right) {
            // 记录当前片段长度
            result.add(right - left + 1);
            // 更新下一片段的起始位置
            left = i + 1;
        }
    }
    
    return result;
}

复杂度

  • 时间: O ( N ) O(N) O(N)。两次遍历(一次预处理,一次切割)。
  • 空间: O ( 1 ) O(1) O(1)。lastOccur 数组固定大小为 26。

四、 排序 + 贪心:资源分配与重构

很多贪心题目本身没有显而易见的贪心策略,但一旦将数据按照特定维度排序,贪心策略就会浮现。这类题目通常涉及两个维度的权衡(如:孩子胃口 vs 饼干尺寸,身高 vs 前面人数)。

解决此类问题的黄金法则:在两个维度权衡中,一定要先确定一个维度,再处理另一个维度。 如果两个维度一起考虑,必然顾此失彼。

4.1 实战例题:根据身高重建队列

题目链接LeetCode 406. Queue Reconstruction by Height

题目描述:假设有打乱顺序的一群人站成一个队列。每个人由一个整数对 (h, k) 表示,其中 h 是这个人的身高,k 是排在这个人前面且身高大于或等于 h 的人数。编写一个算法来重建这个队列。

逻辑解析

本题有两个维度:身高 h 和人数 k。我们需要分析它们之间的依赖关系

  1. 依赖性分析

    • 如果我们在一个"高个子"前面插入一个"矮个子":高个子的 k不会受到影响 。因为 k 只统计"大于等于"自己身高的人,矮个子对他来说是"透明"的。
    • 如果我们在一个"矮个子"前面插入一个"高个子":矮个子的 k会被破坏。因为高个子会被矮个子统计在内。
  2. 贪心策略确定

    为了保证后续的操作不破坏前面的成果,必须先处理高个子,再处理矮个子

    • 排序规则
      • 优先按身高 h 从高到低 排序。(确保我们在处理第 i 个人时,已经排好队的 0i-1 个人全部都比第 i 个人高或相等)。
      • 如果身高 h 相同,按 k 从小到大 排序。(例如 [7,0][7,1],显然 [7,0] 应该在前面)。
  3. 插入逻辑推导

    假设排序后的数组为:[7,0], [7,1], [6,1], [5,0], [5,2], [4,4]

    需要构建一个结果队列 queue

    • 步骤 1 :拿出 [7,0]queue 为空,直接放入。queue: [[7,0]]
    • 步骤 2 :拿出 [7,1]。插入到索引 1。queue: [[7,0], [7,1]]
    • 步骤 3 :拿出 [6,1]
      • 此时 queue 里的人是 [7,0], [7,1]
      • 关键点 :由于是降序处理的,所以 queue 里的所有人(7)都比当前这个人(6)高。
      • 当前这个人要求 k=1(前面有 1 个比他高的)。
      • 既然 queue 里现有的全比他高,那么我们只要把他插入到 索引 1 的位置,他前面就自然会有 1 个人(即索引 0 的那个人),完全满足 k=1 的定义。
      • queue 变为:[[7,0], [6,1], [7,1]]
    • 步骤 4 :拿出 [5,0]
      • 此时 queue 里的人都 ≥ 5 \ge 5 ≥5。
      • 要求 k=0。直接插入到 索引 0
      • queue 变为:[[5,0], [7,0], [6,1], [7,1]]
      • 验证 :这次插入有没有破坏后面的人?
        • 对于 [7,0]:前面插入了 5。但 5 < 7,不影响 [7,0] 的计数。
        • 对于 [6,1]:前面插入了 5。但 5 < 6,不影响 [6,1] 的计数。

    结论 :对于排序后的每个人 [h, k],只需要将其插入到 queue第 k 个位置(即 index = k) 即可。

Java 代码

java 复制代码
public int[][] reconstructQueue(int[][] people) {
    // 排序
    // 优先按身高 h 降序 (从大到小)
    // 如果身高 h 相同,按 k 升序 (从小到大)
    Arrays.sort(people, (a, b) -> {
        if (a[0] == b[0]) {
            return a[1] - b[1];
        }
        return b[0] - a[0];
    });
    
    // 插入
    // 使用 LinkedList,因为涉及大量的"中间插入"操作,链表性能优于数组
    // 这里的泛型是 int[],即每个节点存一个人的信息
    LinkedList<int[]> queue = new LinkedList<>();
    
    for (int[] p : people) {
        // p[1] 就是 k 值
        // 逻辑:因为 queue 中已有的所有人身高都 >= p[0]
        // 所以把 p 插在第 k 个位置,p 的前面就正好有 k 个比他高的人
        queue.add(p[1], p);
    }
    
    // 3. 转换回数组返回
    return queue.toArray(new int[people.length][]);
}

复杂度分析

  • 时间复杂度 : O ( N 2 ) O(N^2) O(N2)。排序耗时 O ( N log ⁡ N ) O(N \log N) O(NlogN)。但在循环中,LinkedList.add(index, val) 的平均复杂度是 O ( N ) O(N) O(N)(虽然链表插入是 O ( 1 ) O(1) O(1),但需要先寻找位置),循环执行 N 次,总共 O ( N 2 ) O(N^2) O(N2)。
  • 空间复杂度 : O ( N ) O(N) O(N)。用于存储结果队列。

五、 总结

贪心算法(Greedy)是算法面试中一种高风险高回报的策略。它不像动态规划那样有明确的模板(状态转移方程),而是依赖于对问题本质的深刻洞察。

5.1 贪心 vs 动态规划

在面对一道求"最值"的题目时,如何快速判断是该用 Greedy 还是 DP?

特征 贪心算法 (Greedy) 动态规划 (DP)
决策依据 只看当下:仅依赖当前状态,做出局部最优选择。 瞻前顾后:依赖子问题的解(重叠子结构),通过状态转移方程关联过去与未来。
可回撤性 不可回撤(一条路走到黑)。选了就定了。 可回撤(隐式)。DP 本质是遍历所有可能路径并取最优,相当于回撤重选。
核心难点 证明局部最优能导致全局最优(反证法)。 构建状态转移方程与初始化边界。
复杂度 通常 O ( N ) O(N) O(N) 或 O ( N log ⁡ N ) O(N \log N) O(NlogN)(较快)。 通常 O ( N 2 ) O(N^2) O(N2) 或更高(较慢)。
适用场景 区间覆盖、跳跃游戏、股票买卖 II(无后效性)。 0/1 背包、最长公共子序列(有后效性,选了 A 可能导致 B 装不下)。

5.2 贪心题目的解题步骤

  1. 从简单用例推导
    不要空想证明。先拿纸笔画几个简单的 Test Case,看看是否存在某种"每次选最大/最小/最长"就能得到答案的规律。
    • 例如:跳跃游戏,每次跳得越远越好?买卖股票,只要涨就赚?
  2. 验证"无后效性"
    试着反问:"如果这一步选了局部最优,会不会导致后面无路可走,或者错失了更好的机会?"
    • 会(有后效性) → \rightarrow → 放弃贪心,转向 DP。(如 0/1 背包:选了体积大价值高的,可能导致后面两个体积小但总价值更高的装不下)。
    • 不会(无后效性) → \rightarrow → 贪心。(如区间覆盖:选了结束时间最早的区间,留给后面的空间就越大,绝对不会亏)。
  3. 确定排序规则
    如果是处理集合类问题(如区间、队列),90% 的贪心都需要先排序 。尝试按照"开始时间"、"结束时间"、"大小"等维度排序,观察是否有规律。在涉及两个维度的权衡时(如身高 vs 前面人数),一定要先确定一个维度(排序),再贪心地处理另一个维度。切忌同时处理两个维度。

5.3 复杂度分析

  • 时间复杂度
    • 线性扫描类 (如股票 II、跳跃游戏 I/II): O ( N ) O(N) O(N)。只需一次遍历。
    • 排序贪心类 (如划分字母区间、重构队列):取决于排序,通常为 O ( N log ⁡ N ) O(N \log N) O(NlogN)。
    • 如果题目数据规模 N ≈ 10 5 N \approx 10^5 N≈105,通常暗示 O ( N ) O(N) O(N) 或 O ( N log ⁡ N ) O(N \log N) O(NlogN) 的解法,贪心是首选对象。
  • 空间复杂度
    • 大多数贪心算法只需常数个变量(如 cover, maxDiff),空间为 O ( 1 ) O(1) O(1)。
    • 如果需要存储排序后的结果或辅助数组(如 lastIndex 映射),则为 O ( N ) O(N) O(N) 或 O ( C ) O(C) O(C)(字符集大小)。

贪心算法没有万能模板,它更像是一种思维直觉的训练。通过大量练习上述提到的覆盖、切分、排序类题目,可以有效培养这种"直觉"。

相关推荐
w***71101 小时前
常见的 Spring 项目目录结构
java·后端·spring
今儿敲了吗1 小时前
23| 画展
c++·笔记·学习·算法
Jasmine_llq2 小时前
《AT_arc081_d [ARC081F] Flip and Rectangles》
算法·动态规划(dp)·贪心思想扩展 / 收缩边界·预处理转换网格状态·二维数组遍历实现逐点计算
野犬寒鸦2 小时前
深入解析HashMap核心机制(底层数据结构及扩容机制详解剖析)
java·服务器·开发语言·数据库·后端·面试
##学无止境##3 小时前
从0到1吃透Java负载均衡:原理与算法大揭秘
java·开发语言·负载均衡
梵得儿SHI3 小时前
Spring Cloud 核心组件精讲:负载均衡深度对比 Spring Cloud LoadBalancer vs Ribbon(原理 + 策略配置 + 性能优化)
java·spring cloud·微服务·负载均衡·架构原理·对比单体与微服务架构·springcloud核心组件
Desirediscipline3 小时前
#define _CRT_SECURE_NO_WARNINGS 1
开发语言·数据结构·c++·算法·c#·github·visual studio
范纹杉想快点毕业3 小时前
C语言550例编程实例说明
算法
知识即是力量ol3 小时前
多线程并发篇(八股)
java·开发语言·八股·多线程并发