欢迎来到前端面试通关指南专栏!从js精讲到框架到实战,渐进系统化学习,坚持解锁新技能,祝你轻松拿下心仪offer。
前端面试通关指南专栏主页
前端面试专栏规划详情
在计算机科学领域,算法是解决问题的核心工具。而贪心算法与动态规划作为两种重要的算法设计策略,广泛应用于优化问题中。本文将深入浅出地介绍这两种算法的基本概念、适用场景、实现方法,并通过经典案例帮助读者理解和掌握它们的核心思想。
贪心算法(Greedy Algorithm)是一种在每一步选择中都采取当前状态下最优(局部最优)的选择,从而希望导致全局结果也最优的算法策略。与动态规划等需要考虑全局状态的算法不同,贪心算法的核心特征在于其**“短视性”**,即只关注眼前利益,不考虑长远影响。
其核心思想可以用**“今朝有酒今朝醉”**这句俗语来形象比喻,就像一个人只顾享受当下的美酒,而不去考虑明天的后果。在算法实现中,这种思想表现为:
典型的贪心算法应用场景包括:
需要注意的是,贪心算法并不总能得到全局最优解,其有效性取决于问题是否具有"贪心选择性质"和"最优子结构性质"。在使用时需要先证明其正确性,否则可能得到一个看似合理但实际错误的解。例如在背包问题中,贪心算法就可能导致次优解。
贪心算法适用于满足以下两个条件的问题:
贪心选择性质:每个阶段的最优选择都能导致全局最优解。具体来说,在每一步选择中,只需要考虑当前状态下最优的选择,而不需要考虑子问题的解。典型的例子包括:
最优子结构:问题的最优解包含其子问题的最优解。这意味着可以通过组合子问题的最优解来构造原问题的最优解。常见的应用场景包括:
需要注意的是,贪心算法虽然简单高效,但并不适用于所有问题。例如,对于某些背包问题或需要综合考虑全局信息的问题,贪心算法可能会得到次优解。因此,在应用贪心算法前,必须确认问题确实满足上述两个性质。
假设你是一名收银员,需要给顾客找零。现有面额为25美分、10美分、5美分和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 -= coin
return result
# 测试
change = greedy_change(63)
print(f"找零方案:{change},共{len(change)}枚硬币")
算法简单,实现容易
贪心算法通常只需要局部最优选择,不需要复杂的状态定义和转移方程,代码实现简洁明了。例如,找零问题的贪心解法只需几行代码即可完成。
时间复杂度低
由于贪心算法每一步只做一次选择,不需要回溯或枚举所有可能,因此时间复杂度通常较低,适合处理大规模数据。
空间效率高
贪心算法通常不需要存储大量中间结果,只需要维护当前状态,因此空间复杂度较低。
不能保证全局最优解
贪心算法只考虑局部最优,可能导致最终结果偏离全局最优。例如,在某些背包问题中,贪心策略可能无法得到最优解。
适用范围有限
只有满足贪心选择性质和最优子结构的问题才能使用贪心算法,而这类问题相对较少。许多实际问题并不具备这些性质,因此贪心算法的应用受到限制。
缺乏灵活性
一旦选择了贪心策略,就无法回退或调整,因此对于动态变化的问题或需要全局考虑的问题,贪心算法可能不适用。
反例:硬币找零问题的局限性
如果硬币面额不是标准的(如25、10、5、1),贪心算法可能失效。例如,硬币面额为25、10、1时,找零30美分:
这个例子说明,贪心算法的正确性依赖于问题的特定结构,不具有通用性。
动态规划(Dynamic Programming,简称DP)是一种高效解决复杂问题的算法策略,它通过将原始问题分解为若干相互重叠的子问题,并存储这些子问题的解,从而避免重复计算,显著提高算法效率。这种方法特别适用于具有最优子结构和重叠子问题特性的问题。
动态规划的核心可以概括为**“记住过去的解,避免重复劳动”**。具体来说,它包含以下三个关键步骤:
最优子结构:问题的最优解包含其子问题的最优解
重叠子问题:在递归求解过程中,相同的子问题会被多次重复计算
动态规划通常有两种实现方法:
自顶向下的记忆化搜索(Memoization)
自底向上的表格填充(Tabulation)
以斐波那契数列为例:
# 传统递归方法(效率低)
def fib(n):
if n <= 1: return n
return fib(n-1) + fib(n-2)
# 动态规划方法(高效)
def fib_dp(n):
dp = [0] * (n+1)
dp[0], dp[1] = 0, 1
for 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)。
动态规划适用于满足以下两个条件的问题:
最优子结构:问题的最优解包含其子问题的最优解。这意味着可以将复杂问题分解为更小的子问题,通过解决这些子问题来构建原问题的解决方案。例如,在经典的"最短路径问题"中,从A到C的最短路径必然包含从A到B的最短路径和从B到C的最短路径。
重叠子问题:在求解过程中,许多子问题会被重复计算。动态规划通过存储这些子问题的解(称为"记忆化")来避免重复计算,从而提高效率。典型的例子是斐波那契数列的计算,其中fib(n) = fib(n-1) + fib(n-2),在递归实现中fib(3)等子问题会被多次计算。
常见的适用场景包括:
在这些问题中,动态规划通常能显著提高计算效率,将指数级时间复杂度降低到多项式级别。
斐波那契数列是一个经典的数学序列,其定义为:F(0)=0,F(1)=1, 对于n ≥ 2的情况,F(n)=F(n - 1)+F(n - 2)(n ∈ N*)。这个数列在自然界中广泛存在,比如向日葵的种子排列、贝壳的螺旋结构等都遵循斐波那契数列的规律。
递归方法的问题:
动态规划优化:
# 递归方法(未优化)
def fib_recursive(n):
if n <= 1: # 基本情况
return n
return fib_recursive(n-1) + fib_recursive(n-2) # 递归调用
# 动态规划方法(带备忘录)
def fib_dp(n):
if n <= 1:
return n
memo = [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 n
prev, curr = 0, 1
for _ in range(2, n+1):
prev, curr = curr, prev + curr
return 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),递归方法可能会因为调用栈过深而导致栈溢出,而动态规划方法则能稳定运行。
定义状态
明确子问题的定义,通常用一个数组或表格表示。状态的定义需要满足"无后效性",即当前状态只与之前的状态有关,而与之后的状态无关。
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)。计算顺序
确定子问题的计算顺序,确保在计算某个子问题时,其依赖的所有子问题都已被解决。常见的计算顺序有:
特性 | 贪心算法 | 动态规划 |
---|---|---|
核心思想 | 每步做出局部最优选择,不考虑后续影响(如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保证正确性而贪心不一定。
给定一组物品,每个物品有重量和价值,以及一个容量为C的背包。要求选择一些物品放入背包,使得总重量不超过C,且总价值最大。每个物品只能选择一次(这就是"0-1"的含义:要么选0次,要么选1次)。
假设我们有4个物品:
动态规划是解决0-1背包问题的经典方法。其核心思想是通过构建状态转移表来记录子问题的最优解。
算法步骤:
dp[i][w]
表示考虑前i个物品时,背包容量为w时能获得的最大价值dp[i][w] = dp[i-1][w]
dp[i][w] = max(不选当前物品的情况,选当前物品的情况)
dp[n][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表的部分关键值:
分数背包问题与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 = 0
for vpw, w, v in value_per_weight:
if capacity <= 0: # 背包已满
break
# 选择当前物品的全部或部分
amount = min(w, capacity) # 能拿多少拿多少
total_value += amount * vpw
capacity -= amount
return 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
应用场景:
算法分析:
注意事项:
贪心算法和动态规划是解决优化问题的两种重要策略,各有其适用场景和优缺点。贪心算法通过局部最优选择快速得到解,但不一定是全局最优;动态规划通过存储子问题解避免重复计算,通常能得到全局最优解,但实现复杂度较高。
在实际应用中,需要根据问题的特性选择合适的算法策略。对于满足贪心选择性质的问题,优先考虑贪心算法;对于存在重叠子问题和最优子结构的问题,动态规划是更好的选择。通过不断练习和实践,读者将逐渐掌握这两种算法的精髓,能够灵活应用于各种实际问题中。
本文补充了贪心算法的优缺点,并通过具体案例说明其局限性,帮助读者更全面地理解这两种算法策略。
下期预告:链表、栈、队列的实现与应用
❤️❤️❤️:如果你觉得这篇文章对你有帮助,欢迎点赞、关注本专栏!后续解锁更多功能,敬请期待!
更多专栏汇总:
前端面试专栏
Node.js 实训专栏