前端面试专栏-算法篇:20. 贪心算法与动态规划入门
🔥 欢迎来到前端面试通关指南专栏!从js精讲到框架到实战,渐进系统化学习,坚持解锁新技能,祝你轻松拿下心仪offer。
前端面试通关指南专栏主页
前端面试专栏规划详情
贪心算法与动态规划入门
在计算机科学领域,算法是解决问题的核心工具。而贪心算法与动态规划作为两种重要的算法设计策略,广泛应用于优化问题中。本文将深入浅出地介绍这两种算法的基本概念、适用场景、实现方法,并通过经典案例帮助读者理解和掌握它们的核心思想。
一、贪心算法
1.1 基本概念
贪心算法(Greedy Algorithm)是一种在每一步选择中都采取当前状态下最优(局部最优)的选择,从而希望导致全局结果也最优的算法策略。与动态规划等需要考虑全局状态的算法不同,贪心算法的核心特征在于其**“短视性”**,即只关注眼前利益,不考虑长远影响。
其核心思想可以用**“今朝有酒今朝醉”**这句俗语来形象比喻,就像一个人只顾享受当下的美酒,而不去考虑明天的后果。在算法实现中,这种思想表现为:
- 只关注当前步骤的最优解
- 不考虑这种选择对后续步骤的影响
- 一旦做出选择就不可更改(无回溯)
典型的贪心算法应用场景包括:
- 货币找零问题:优先使用面值大的货币
- 活动选择问题:每次选择结束时间最早的活动
- 霍夫曼编码:构建最优前缀码
需要注意的是,贪心算法并不总能得到全局最优解,其有效性取决于问题是否具有"贪心选择性质"和"最优子结构性质"。在使用时需要先证明其正确性,否则可能得到一个看似合理但实际错误的解。例如在背包问题中,贪心算法就可能导致次优解。
1.2 适用场景
贪心算法适用于满足以下两个条件的问题:
-
贪心选择性质:每个阶段的最优选择都能导致全局最优解。具体来说,在每一步选择中,只需要考虑当前状态下最优的选择,而不需要考虑子问题的解。典型的例子包括:
- 找零钱问题:在货币面额合理的情况下(如人民币的1元、5元、10元等),每次选择最大面额不超过剩余金额的货币,最终使用的货币数量最少。
- 活动选择问题:在一系列活动中选择不相冲突的最大活动集合,每次选择结束时间最早的活动。
-
最优子结构:问题的最优解包含其子问题的最优解。这意味着可以通过组合子问题的最优解来构造原问题的最优解。常见的应用场景包括:
- 最小生成树问题(如Prim算法和Kruskal算法):整个图的最小生成树包含其子图的最小生成树。
- 最短路径问题(如Dijkstra算法):从起点到终点的最短路径必然包含路径上任意中间点到终点的最短路径。
需要注意的是,贪心算法虽然简单高效,但并不适用于所有问题。例如,对于某些背包问题或需要综合考虑全局信息的问题,贪心算法可能会得到次优解。因此,在应用贪心算法前,必须确认问题确实满足上述两个性质。
1.3 经典案例:找零问题
假设你是一名收银员,需要给顾客找零。现有面额为25美分、10美分、5美分和1美分的硬币,如何用最少的硬币找零?
分析:
- 贪心策略:每次选择当前可用的最大面额硬币。
- 例如,找零63美分:
- 选择25美分,剩余38美分
- 选择25美分,剩余13美分
- 选择10美分,剩余3美分
- 选择1美分,剩余2美分
- 选择1美分,剩余1美分
- 选择1美分,剩余0美分
- 共使用6枚硬币(25+25+10+1+1+1)。
代码实现:
def greedy_change(amount, coins=[25, 10, 5, 1]):coins.sort(reverse=True) # 按面额从大到小排序result = []for coin in coins:while amount >= coin:result.append(coin)amount -= coinreturn result# 测试
change = greedy_change(63)
print(f"找零方案:{change},共{len(change)}枚硬币")
1.4 贪心算法的优缺点
优点:
-
算法简单,实现容易
贪心算法通常只需要局部最优选择,不需要复杂的状态定义和转移方程,代码实现简洁明了。例如,找零问题的贪心解法只需几行代码即可完成。 -
时间复杂度低
由于贪心算法每一步只做一次选择,不需要回溯或枚举所有可能,因此时间复杂度通常较低,适合处理大规模数据。 -
空间效率高
贪心算法通常不需要存储大量中间结果,只需要维护当前状态,因此空间复杂度较低。
缺点:
-
不能保证全局最优解
贪心算法只考虑局部最优,可能导致最终结果偏离全局最优。例如,在某些背包问题中,贪心策略可能无法得到最优解。 -
适用范围有限
只有满足贪心选择性质和最优子结构的问题才能使用贪心算法,而这类问题相对较少。许多实际问题并不具备这些性质,因此贪心算法的应用受到限制。 -
缺乏灵活性
一旦选择了贪心策略,就无法回退或调整,因此对于动态变化的问题或需要全局考虑的问题,贪心算法可能不适用。
反例:硬币找零问题的局限性
如果硬币面额不是标准的(如25、10、5、1),贪心算法可能失效。例如,硬币面额为25、10、1时,找零30美分:
- 贪心策略:25 + 1 + 1 + 1 + 1 + 1 = 6枚硬币
- 最优解:10 + 10 + 10 = 3枚硬币
这个例子说明,贪心算法的正确性依赖于问题的特定结构,不具有通用性。
二、动态规划
2.1 基本概念
动态规划(Dynamic Programming,简称DP)是一种高效解决复杂问题的算法策略,它通过将原始问题分解为若干相互重叠的子问题,并存储这些子问题的解,从而避免重复计算,显著提高算法效率。这种方法特别适用于具有最优子结构和重叠子问题特性的问题。
核心思想
动态规划的核心可以概括为**“记住过去的解,避免重复劳动”**。具体来说,它包含以下三个关键步骤:
- 问题分解:将原问题分解为规模更小的子问题
- 记忆存储:解决并存储子问题的解(通常使用数组或哈希表)
- 组合优化:利用已存储的子问题解来构建原问题的解
关键特性
-
最优子结构:问题的最优解包含其子问题的最优解
- 示例:在最短路径问题中,从A到C的最短路径必然包含经过中间点B的最短路径
-
重叠子问题:在递归求解过程中,相同的子问题会被多次重复计算
- 示例:计算斐波那契数列时,fib(5)需要重复计算fib(3)和fib(2)
实现方式
动态规划通常有两种实现方法:
-
自顶向下的记忆化搜索(Memoization)
- 采用递归方法,但用表格存储已计算的结果
- 适合子问题数量不确定的情况
-
自底向上的表格填充(Tabulation)
- 从最小的子问题开始迭代求解
- 通常更高效,避免了递归的开销
典型应用场景
- 背包问题:0-1背包、完全背包等资源分配问题
- 路径规划:网格最短路径、机器人走迷宫
- 序列问题:最长公共子序列、最长递增子序列
- 字符串处理:编辑距离、正则表达式匹配
- 游戏理论:取石子游戏、博弈论问题
示例说明
以斐波那契数列为例:
# 传统递归方法(效率低)
def fib(n):if n <= 1: return nreturn fib(n-1) + fib(n-2)# 动态规划方法(高效)
def fib_dp(n):dp = [0] * (n+1)dp[0], dp[1] = 0, 1for i in range(2, n+1):dp[i] = dp[i-1] + dp[i-2]return dp[n]
在这个例子中,动态规划方法将时间复杂度从O(2^n)降低到O(n),空间复杂度为O(n)。进一步优化还可以将空间复杂度降至O(1)。
2.2 适用场景
动态规划适用于满足以下两个条件的问题:
-
最优子结构:问题的最优解包含其子问题的最优解。这意味着可以将复杂问题分解为更小的子问题,通过解决这些子问题来构建原问题的解决方案。例如,在经典的"最短路径问题"中,从A到C的最短路径必然包含从A到B的最短路径和从B到C的最短路径。
-
重叠子问题:在求解过程中,许多子问题会被重复计算。动态规划通过存储这些子问题的解(称为"记忆化")来避免重复计算,从而提高效率。典型的例子是斐波那契数列的计算,其中fib(n) = fib(n-1) + fib(n-2),在递归实现中fib(3)等子问题会被多次计算。
常见的适用场景包括:
- 背包问题(0-1背包、完全背包等)
- 最长公共子序列(LCS)问题
- 最短路径问题(如Floyd-Warshall算法)
- 字符串编辑距离计算
- 矩阵链乘法优化
在这些问题中,动态规划通常能显著提高计算效率,将指数级时间复杂度降低到多项式级别。
2.3 经典案例:斐波那契数列
斐波那契数列是一个经典的数学序列,其定义为:F(0)=0,F(1)=1, 对于n ≥ 2的情况,F(n)=F(n - 1)+F(n - 2)(n ∈ N*)。这个数列在自然界中广泛存在,比如向日葵的种子排列、贝壳的螺旋结构等都遵循斐波那契数列的规律。
算法分析:
-
递归方法的问题:
- 直接采用递归计算时,会出现大量重复计算。例如计算F(5)时需要计算F(4)和F(3),而计算F(4)又需要重新计算F(3)和F(2),这样会导致时间复杂度呈指数级增长,达到O(2ⁿ)。
- 以F(5)为例,递归调用树会包含多个重复的F(3)、F(2)等子问题。
-
动态规划优化:
- 动态规划通过"记忆化存储"(Memoization)技术,将已经计算过的子问题结果存储在数组中,避免重复计算。
- 这种方法只需要线性时间O(n)和O(n)空间即可完成计算,效率显著提升。
- 进一步优化可以使用滚动数组,将空间复杂度降低到O(1)。
代码实现与对比:
# 递归方法(未优化)
def fib_recursive(n):if n <= 1: # 基本情况return nreturn fib_recursive(n-1) + fib_recursive(n-2) # 递归调用# 动态规划方法(带备忘录)
def fib_dp(n):if n <= 1:return nmemo = [0] * (n + 1) # 初始化记忆数组memo[1] = 1 # 设置初始值for i in range(2, n + 1): # 自底向上计算memo[i] = memo[i-1] + memo[i-2]return memo[n]# 空间优化版本(滚动数组)
def fib_dp_optimized(n):if n <= 1:return nprev, curr = 0, 1for _ in range(2, n+1):prev, curr = curr, prev + currreturn curr# 测试对比
n = 35 # 足够大的n才能体现性能差异
print(f"递归方法:fib({n}) = {fib_recursive(n)}") # 计算明显缓慢
print(f"动态规划方法:fib({n}) = {fib_dp(n)}") # 即时得出结果
print(f"优化空间版本:fib({n}) = {fib_dp_optimized(n)}")
应用场景:
- 算法教学中递归与动态规划的经典案例
- 金融领域的黄金分割计算
- 计算机图形学中的自然图案生成
- 性能敏感的实时系统中需要高效计算
注意:当n非常大时(如n>50),递归方法可能会因为调用栈过深而导致栈溢出,而动态规划方法则能稳定运行。
2.4 动态规划的解题步骤
-
定义状态
明确子问题的定义,通常用一个数组或表格表示。状态的定义需要满足"无后效性",即当前状态只与之前的状态有关,而与之后的状态无关。- 示例:在斐波那契数列问题中,可以定义
dp[i]
表示第i个斐波那契数;在背包问题中,可以定义dp[i][j]
表示前i个物品在容量为j时的最大价值。
- 示例:在斐波那契数列问题中,可以定义
-
状态转移方程
确定子问题之间的关系,即如何从已知子问题的解推导出当前问题的解。这是动态规划的核心部分。- 示例:斐波那契数列的状态转移方程为
dp[i] = dp[i-1] + dp[i-2]
;背包问题的状态转移方程可能是dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
,其中w[i]和v[i]分别表示第i个物品的重量和价值。
- 示例:斐波那契数列的状态转移方程为
-
初始条件
确定最基本子问题的解。这些初始条件通常是显而易见的简单情况,但对于整个问题的求解至关重要。- 示例:斐波那契数列的初始条件是
dp[0] = 0, dp[1] = 1
;背包问题的初始条件可能是dp[0][j] = 0
(没有物品时价值为0)和dp[i][0] = 0
(容量为0时价值为0)。
- 示例:斐波那契数列的初始条件是
-
计算顺序
确定子问题的计算顺序,确保在计算某个子问题时,其依赖的所有子问题都已被解决。常见的计算顺序有:- 自底向上:从最小的子问题开始,逐步解决更大的子问题(如使用循环迭代)
- 自顶向下:从原问题出发,递归地分解为子问题(通常配合记忆化)
- 示例:在计算二维dp表格时,通常需要确定是按行优先还是列优先填充表格
三、贪心算法与动态规划的对比
特性 | 贪心算法 | 动态规划 |
---|---|---|
核心思想 | 每步做出局部最优选择,不考虑后续影响(如Dijkstra算法选择当前最短路径) | 存储子问题解,避免重复计算(如斐波那契数列中保存已计算的中间结果) |
适用条件 | 必须满足贪心选择性质(局部最优能导致全局最优)和最优子结构 | 必须具有最优子结构(问题最优解包含子问题最优解)和重叠子问题 |
解的质量 | 可能不是全局最优解(如某些背包问题),但通常接近最优 | 总能得到全局最优解(如最长公共子序列问题) |
时间复杂度 | 通常为O(n)或O(nlogn)(如活动选择问题) | 通常为O(n²)或O(n³)(如矩阵链乘法),但比暴力法的指数级复杂度低得多 |
实现难度 | 实现简单(如哈夫曼编码只需维护优先队列) | 较复杂,需要设计状态表示和状态转移方程(如编辑距离问题的二维DP表) |
空间复杂度 | 通常O(1)或O(n)(如区间调度问题只需存储结果) | 通常O(n)或O(n²)(如0-1背包问题需要二维数组),可通过滚动数组优化 |
典型应用场景 | 最小生成树(Prim/Kruskal)、最短路径(Dijkstra)、任务调度等 | 字符串匹配、资源分配、路径优化等问题 |
决策回溯 | 一旦做出选择就不能更改(如找零钱问题中优先选用大面额) | 会考虑所有可能的决策路径(如Floyd-Warshall算法计算所有节点对的最短路径) |
问题分解方式 | 自顶向下地做出当前最优选择 | 自底向上或记忆化搜索,逐步构建完整解决方案 |
注:在某些同时满足贪心和DP适用条件的问题(如硬币找零问题中硬币面额特殊时),两种方法可能得出相同结果,但DP保证正确性而贪心不一定。
四、进阶案例:背包问题
4.1 0-1背包问题
给定一组物品,每个物品有重量和价值,以及一个容量为C的背包。要求选择一些物品放入背包,使得总重量不超过C,且总价值最大。每个物品只能选择一次(这就是"0-1"的含义:要么选0次,要么选1次)。
问题示例
假设我们有4个物品:
- 物品1:重量2kg,价值3元
- 物品2:重量3kg,价值4元
- 物品3:重量4kg,价值5元
- 物品4:重量5kg,价值6元
背包容量为8kg。如何选择物品使总价值最大?
动态规划解法详解
动态规划是解决0-1背包问题的经典方法。其核心思想是通过构建状态转移表来记录子问题的最优解。
算法步骤:
- 定义DP状态:
dp[i][w]
表示考虑前i个物品时,背包容量为w时能获得的最大价值 - 初始化:当i=0或w=0时,价值为0(没有物品或没有容量)
- 状态转移:
- 如果当前物品重量超过剩余容量:
dp[i][w] = dp[i-1][w]
- 否则:
dp[i][w] = max(不选当前物品的情况,选当前物品的情况)
- 如果当前物品重量超过剩余容量:
- 最终结果:
dp[n][capacity]
优化说明:
- 时间复杂度:O(n×capacity),其中n是物品数量
- 空间复杂度:O(n×capacity),可以优化为O(capacity)使用一维数组
- 实际应用:常用于资源分配、投资组合优化等场景
def knapsack_01(weights, values, capacity):n = len(weights)# 创建二维数组dp[i][w]表示前i个物品放入容量为w的背包的最大价值dp = [[0 for _ in range(capacity + 1)] for _ in range(n + 1)]for i in range(1, n + 1): # 遍历每个物品for w in range(1, capacity + 1): # 遍历每个可能的容量if weights[i-1] > w:# 当前物品重量超过背包容量,不能放入dp[i][w] = dp[i-1][w]else:# 选择放入或不放入,取最大值# 不放入:保持前i-1个物品的结果# 放入:前i-1个物品在剩余容量w-weights[i-1]时的最优解+当前价值dp[i][w] = max(dp[i-1][w], dp[i-1][w-weights[i-1]] + values[i-1])return dp[n][capacity] # 返回最终最优解# 测试用例
weights = [2, 3, 4, 5] # 物品重量列表
values = [3, 4, 5, 6] # 物品价值列表
capacity = 8 # 背包容量
print(f"0-1背包最大价值:{knapsack_01(weights, values, capacity)}") # 预期输出:10
过程演示:
以测试用例为例,最终DP表的部分关键值:
- dp[3][6] = max(dp[2][6], dp[2][2]+5) = max(7, 3+5) = 8
- dp[4][8] = max(dp[3][8], dp[3][3]+6) = max(9, 4+6) = 10
最优选择是物品2+物品4,总重量3+5=8,总价值4+6=10
4.2 分数背包问题
分数背包问题与0-1背包问题类似,但允许将物品分割成任意大小进行选择。这种特性使得问题可以通过贪心算法得到最优解,而不需要像0-1背包那样使用动态规划。
问题描述:
给定n个物品,每个物品有重量w_i和价值v_i,以及一个容量为W的背包。目标是在不超过背包容量的前提下,选择物品(可以只选择部分)使总价值最大。
贪心解法思路:
- 计算每个物品的单位价值(价值/重量)
- 按照单位价值从高到低排序
- 依次选择单位价值高的物品,能全拿就全拿,不能全拿则拿部分
def fractional_knapsack(weights, values, capacity):"""分数背包问题的贪心算法实现参数:weights: 物品重量列表values: 物品价值列表 capacity: 背包容量返回:最大总价值"""n = len(weights)# 计算每个物品的单位价值,并存储为(单位价值,重量,价值)的元组value_per_weight = [(values[i]/weights[i], weights[i], values[i]) for i in range(n)]# 按单位价值降序排序value_per_weight.sort(reverse=True, key=lambda x: x[0])total_value = 0for vpw, w, v in value_per_weight:if capacity <= 0: # 背包已满break# 选择当前物品的全部或部分amount = min(w, capacity) # 能拿多少拿多少total_value += amount * vpwcapacity -= amountreturn round(total_value, 2) # 保留两位小数# 测试示例
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 8
print(f"分数背包最大价值:{fractional_knapsack(weights, values, capacity)}")
# 输出示例:分数背包最大价值:10.33
应用场景:
- 资源分配问题:如投资组合优化,可以部分投资某个项目
- 货物装载问题:如卡车装载散装货物
- 时间管理问题:将时间分配给不同收益的任务
算法分析:
- 时间复杂度:O(nlogn),主要由排序步骤决定
- 空间复杂度:O(n),需要存储单位价值信息
- 最优性:贪心算法总能得到全局最优解
注意事项:
- 物品必须是可以任意分割的
- 对于不可分割的物品(0-1背包问题),此解法不适用
- 当所有物品重量相同时,等价于按照价值排序选择
五、总结
贪心算法和动态规划是解决优化问题的两种重要策略,各有其适用场景和优缺点。贪心算法通过局部最优选择快速得到解,但不一定是全局最优;动态规划通过存储子问题解避免重复计算,通常能得到全局最优解,但实现复杂度较高。
在实际应用中,需要根据问题的特性选择合适的算法策略。对于满足贪心选择性质的问题,优先考虑贪心算法;对于存在重叠子问题和最优子结构的问题,动态规划是更好的选择。通过不断练习和实践,读者将逐渐掌握这两种算法的精髓,能够灵活应用于各种实际问题中。
本文补充了贪心算法的优缺点,并通过具体案例说明其局限性,帮助读者更全面地理解这两种算法策略。
📌 下期预告:链表、栈、队列的实现与应用
❤️❤️❤️:如果你觉得这篇文章对你有帮助,欢迎点赞、关注本专栏!后续解锁更多功能,敬请期待!👍🏻 👍🏻 👍🏻
更多专栏汇总:
前端面试专栏
Node.js 实训专栏
数码产品严选