从零开始的数据结构教程(六) 贪心算法


🍬 标题一:贪心核心思想------发糖果时的最优分配策略

贪心算法 (Greedy Algorithm) 是一种简单直观的算法策略。它在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望得到一个全局最优解。这就像你作为班主任发糖果,每次都想让眼前的孩子满意,希望这种局部最优能带来整体最优的效果。

三大适用条件

并非所有问题都适合用贪心算法解决,它通常需要满足以下三个条件:

  1. 贪心选择性质 (Greedy Choice Property):每一步的局部最优选择,都能导致最终的全局最优解。例如,在"硬币找零"问题中,如果硬币面额系统是"标准"的(如 1, 5, 10, 25 美分),那么每次都优先选择最大面额的硬币就能得到最少硬币数。
  2. 无后效性 (Optimal Substructure):当前的选择不会影响后续子问题的最优解,即后续问题的最优解不依赖于之前的选择,只依赖于当前状态。例如,在"区间调度"中,一旦你选择了最早结束的会议,后续会议的选择空间并不会因此受限,而是基于剩余时间继续做决策。
  3. 子问题可合并 (Overlapping Subproblems) :虽然这是动态规划的特征之一,但在贪心算法中,这意味着局部解能够直接或以简单的方式构成全局解。例如,在霍夫曼编码中,每次合并两个最小频率的节点,最终能构建出最优的二叉树。

与 DP 的关键区别

贪心算法和动态规划都涉及将问题分解为子问题,但它们在解决方式上存在根本差异:

  • 贪心 :做选择时不考虑未来 ,只看当前局部最优,并且一旦做出选择就不可回退。它不需要存储所有子问题的解。
  • 动态规划 :会探索所有可能的局部最优解 ,并存储它们,通过状态转移方程来构建全局最优解。它具有"回退"机制,可以从之前的多种选择中推导出当前的最优。
python 复制代码
# 贪心 vs 动态规划:以「硬币找零」为例
# 假设硬币面额为 [1, 5, 10, 25],找零 30
# 贪心:25 + 5 (2枚)
# DP:10 + 10 + 10 (3枚) 或 25 + 1 + 1 + 1 + 1 + 1 (6枚)

# 贪心(可能无法得到最优解,例如面额 [1, 15, 25], 找零 30, 贪心会是 25+1+1+1+1+1 (6枚), 最优是 15+15 (2枚))
def coinChangeGreedy(coins, amount):
    coins.sort(reverse=True) # 优先使用大面额硬币
    count = 0
    for coin in coins:
        while amount >= coin:
            amount -= coin
            count += 1
    return count if amount == 0 else -1 # 如果 amount 不为 0,说明无法找零

# DP(保证最优解)
def coinChangeDP(coins, amount):
    # dp[i] 表示凑齐金额 i 所需的最少硬币数
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0 # 凑齐金额 0 需要 0 枚硬币

    for i in range(1, amount + 1): # 遍历所有可能的金额
        for coin in coins:         # 遍历所有硬币面额
            if i >= coin:
                # 如果当前金额 i 大于等于当前硬币面额 coin
                # 那么凑齐 i 的最少硬币数可能是:
                # 1. 不使用当前硬币,沿用 dp[i] (之前可能计算过)
                # 2. 使用当前硬币,那么剩下的金额 i - coin 需要 dp[i - coin] 枚硬币
                dp[i] = min(dp[i], dp[i - coin] + 1)
    return dp[amount] if dp[amount] != float('inf') else -1 # 如果为 inf,表示无法凑齐

# print(coinChangeGreedy([1, 15, 25], 30)) # 输出 6
# print(coinChangeDP([1, 15, 25], 30))     # 输出 2

⏰ 标题二:区间调度问题------会议室的高效安排

区间调度问题是一个经典的贪心算法应用场景。

经典问题

给定若干会议的开始和结束时间 [start, end],如何安排最多的不重叠会议,使得一个会议室可以安排尽可能多的会议?

贪心策略

按结束时间排序:优先选择最早结束的会议。为什么?因为最早结束的会议会给后续会议留出最大的时间空隙,从而使得我们有机会安排更多的会议。

java 复制代码
import java.util.Arrays;
import java.util.Comparator; // 引入 Comparator 接口

class Solution {
    public int maxEvents(int[][] intervals) {
        // Step 1: 按结束时间升序排序会议
        // 如果结束时间相同,则按开始时间升序排序(可选,但通常不影响结果)
        Arrays.sort(intervals, (a, b) -> a[1] - b[1]);

        int count = 0; // 记录安排的会议数量
        int lastEndTime = Integer.MIN_VALUE; // 记录上一个已安排会议的结束时间

        // Step 2: 遍历排序后的会议
        for (int[] meeting : intervals) {
            int currentStart = meeting[0];
            int currentEnd = meeting[1];

            // 如果当前会议的开始时间 >= 上一个已安排会议的结束时间
            // 说明当前会议可以安排,且不与之前的会议重叠
            if (currentStart >= lastEndTime) {
                count++;            // 安排该会议
                lastEndTime = currentEnd; // 更新上一个会议的结束时间
            }
        }
        return count;
    }
}

变形题

  • 需要多间会议室时:这通常会转化为其他问题,例如"最小箭射气球"(LeetCode 452),它寻找最少数量的"箭头"能射爆所有气球(代表区间),本质上是寻找最少数量的区间来覆盖所有给定区间。
  • 带权值的区间 :如果每个会议有不同的"价值"或"收益",需要最大化总收益,那么纯粹的贪心可能不再适用,通常需要结合动态规划来解决(例如"最大收益的兼职工作")。

🏃 标题三:跳跃游戏------从起点到终点的最少步数

跳跃游戏系列问题也是贪心算法的经典应用。

两类经典问题

  1. 能否到达终点 (LeetCode 55):给定一个非负整数数组 nums,每个元素 nums[i] 代表你在位置 i 最多可以向前跳跃的长度。判断你是否能从第一个位置跳到最后一个位置。

    python 复制代码
    def canJump(nums):
        max_reach = 0 # 记录当前能到达的最远位置
        # 遍历数组,直到到达终点或无法继续前进
        for i in range(len(nums)):
            if i > max_reach: # 如果当前位置 i 已经超出了之前能到达的最远位置
                return False  # 说明无法到达终点
    
            # 更新能到达的最远位置:当前位置 i + 从当前位置能跳的距离 nums[i]
            max_reach = max(max_reach, i + nums[i])
    
            if max_reach >= len(nums) - 1: # 如果能到达或越过终点
                return True                # 则成功到达
        return True # 如果循环结束(即已经遍历完数组),说明可以到达终点
  2. 最少跳跃次数 (LeetCode 45):给定一个非负整数数组 nums,同上,求到达最后一个位置的最少跳跃次数

    python 复制代码
    def jump(nums):
        if len(nums) <= 1:
            return 0
    
        jumps = 0          # 跳跃次数
        current_end = 0    # 当前跳跃能到达的最远边界
        farthest = 0       # 在当前跳跃范围内,下一步能到达的最远位置
    
        for i in range(len(nums) - 1): # 遍历到倒数第二个位置,因为最后一个位置不需要再跳
            # 更新在当前跳跃范围内能到达的最远位置
            farthest = max(farthest, i + nums[i])
    
            if i == current_end: # 如果当前位置 i 达到了当前跳跃的边界
                jumps += 1       # 进行一次跳跃
                current_end = farthest # 更新下一次跳跃的边界为当前能到达的最远位置
                # 注意:这里不需要检查 farthest 是否能到达终点,
                # 因为 for 循环条件保证了最终 current_end 会达到或超过 len(nums) - 1
    
        return jumps

关键洞察

贪心地在每一步选择能跳最远的选项(但不是盲目地跳到最远,而是规划好下一次跳跃的边界)。


🛒 标题四:高频面试题------分发糖果(双向贪心)

问题

在一个班级里,有 n 个孩子,他们的能力值用整数数组 ratings 表示。你需要给这些孩子分发糖果,并满足以下两个条件:

  1. 每个孩子至少分到 1 颗糖果。
  2. 能力值比其相邻孩子高的孩子,必须分到比相邻孩子更多的糖果。

求最少需要分发多少颗糖果。

双向遍历策略

这个问题不能简单地从左到右或从右到左遍历一次,因为它有双向的依赖关系。需要进行双向贪心

  1. 从左到右遍历:确保右边的孩子比左边高的,能获得更多糖果。
  2. 从右到左遍历:确保左边的孩子比右边高的,能获得更多糖果。
  3. 取两者最大值 :最终每个孩子分到的糖果数,是两次遍历结果的最大值
python 复制代码
def candy(ratings):
    n = len(ratings)
    # left 数组:从左到右遍历,保证 ratings[i] > ratings[i-1] 时,left[i] > left[i-1]
    left = [1] * n # 初始化每个孩子至少1颗糖

    for i in range(1, n):
        if ratings[i] > ratings[i - 1]:
            left[i] = left[i - 1] + 1

    # right 变量:从右到左遍历,保证 ratings[i] > ratings[i+1] 时,当前孩子获得更多糖
    # total:总糖果数,先加上 left 数组的最后一个元素
    right = 1
    total = left[n - 1] # 最后一个孩子的糖果数,至少是 left[n-1]

    for i in range(n - 2, -1, -1): # 从倒数第二个孩子开始,向左遍历
        if ratings[i] > ratings[i + 1]:
            right += 1 # 如果当前孩子比右边高,right 增加
        else:
            right = 1  # 否则,重置 right 为 1 (因为右边孩子可能比自己高或相等)

        # 当前孩子实际获得的糖果数,是 left[i] 和 right 两者中的最大值
        # 这样才能同时满足左右两个方向的条件
        total += max(left[i], right)

    return total
  • 时间复杂度 : O ( n ) O(n) O(n),因为进行了两次线性遍历。
  • 空间复杂度 : O ( n ) O(n) O(n),因为使用了 left 数组。可以进一步优化到 O ( 1 ) O(1) O(1) 空间,但代码会更复杂。

📊 总结表:贪心算法适用场景

问题类型 典型例题 贪心策略
区间问题 无重叠区间(LeetCode 435) 结束时间排序,优先选择最早结束的。
分配问题 分发糖果(LeetCode 135) 双向遍历(从左到右,从右到左)满足约束。
跳跃问题 跳跃游戏 II(LeetCode 45) 维护当前能到达的最远位置,并在必要时进行跳跃。
字符串构造 重构字符串(LeetCode 767) 优先使用剩余最多的字符,避免连续。
硬币/货币 硬币找零(标准面额系统) 优先使用最大面额的硬币。

相关推荐
向左转, 向右走ˉ15 分钟前
为什么分类任务偏爱交叉熵?MSE 为何折戟?
人工智能·深度学习·算法·机器学习·分类·数据挖掘
云边有个稻草人1 小时前
【C++】第十九节—一文万字详解 | AVL树实现
数据结构·c++·avl树·avl树的插入·avl树的旋转·avl树实现·avl树的结构
霜绛1 小时前
机器学习笔记(四)——聚类算法KNN、Kmeans、Dbscan
笔记·算法·机器学习·kmeans·聚类
晨非辰2 小时前
#C语言——学习攻略:深挖指针路线(三)--数组与指针的结合、冒泡排序
c语言·开发语言·数据结构·学习·算法·排序算法·visual studio
小码哥学习中2 小时前
centos7 安装mysql5.7.36和mysql8.0.32(同时存在)
数据结构
zzywxc7872 小时前
编程算法在金融、医疗、教育、制造业等领域的落地案例
人工智能·算法·金融·自动化·copilot·ai编程
zzywxc7872 小时前
编程算法在金融、医疗、教育、制造业的落地应用。
人工智能·深度学习·算法·机器学习·金融·架构·开源
conkl3 小时前
构建 P2P 网络与分布式下载系统:从底层原理到安装和功能实现
linux·运维·网络·分布式·网络协议·算法·p2p
Shan12053 小时前
递归算法的一些具体应用
算法
paopaokaka_luck5 小时前
婚纱摄影管理系统(发送邮箱、腾讯地图API、物流API、webSocket实时聊天、协同过滤算法、Echarts图形化分析)
vue.js·spring boot·后端·websocket·算法·echarts