贪心算法(Greedy Algorithm)是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法。贪心算法并不总是能够得到全局最优解,但在很多情况下都能产生不错的结果。
1 贪心算法
贪心算法的核心思想是逐步构建解,每一步都做出局部最优的选择,期望最终得到全局最优解。贪心算法通常用于解决优化问题,如最短路径问题、最小生成树问题等。
1.1.应用场景
1.1.1 找零问题
问题描述:假设商店老板需要找零n元钱,钱币的面额有:100元、50元、20元、5元、1元, 如何找零使得所需的钱币最少?
实现代码:
python
t = [100, 50, 20, 5, 1] # 用来存储用来找零钱币的面额
def change(t, n):
m = [0 for i in range(len(t))] # 创建列表用于表示每种面额的纸币需要多少张
for i, money in enumerate(t): # 遍历列表,返回下标和元素
m[i] = int(n // money) # 第i种面额的钱币需要多少张
n = n % money # 更新剩余的需要找零的钱数
return m, f"剩余{n}" # 返回钱数列表,和剩余的钱数
print(change(t, 569.5))
输出结果:
1.1.2背包问题
问题描述:一个小偷在商店发现有n个商品,第i个商品价值元,重
千克。他希望拿走的商品价值尽量高,但他的背包最多只能容纳w千克的东西。他应该拿走那些商品?
**0-1背包:**对于一个商品,小偷要么把他完整拿走,要么留下。不能只拿走一部分,或者把一个商品拿走多次。
**分数背包:**对于一个商品,小偷可以拿走其中任意一部分。
举例:商品1:=60,
=10 商品2:
=100,
=20 商品3:
=120,
=30
背包容量:w = 50 (假设每种商品只有一个)
python
goods = [(60, 10), (100, 20), (120, 30)] # 利用元组表示商品的价格和重量
def fractional_backpack(goods, w): # 传入商品价格重量,和背包容量
goods.sort(key=lambda x: x[0]/x[1] ,reverse=True) # 按照商品的单位价格进行排序
m = [0 for i in range(len(goods))] # 创建列表用于表示每种商品拿了多少
for i, (prize, weight) in enumerate(goods):
if weight <= w: # 如果商品重量小于背包重量,取走一个
m[i] = 1
w -= weight # 更新背包剩余空间
else: # 商品重量大于背包重量,不能全部拿走,只能拿走一部分
m[i] = w / weight
return m
print(fractional_backpack(goods, 50))
输出结果:
1.1.3 拼接最大数字问题
有n个非负整数,将其按照字符串拼接的方式拼接为一个整数。如何拼接可以使得得到的整数最大?
例:32,94,128,1286,6,71 可以拼接的最大整数为94716321286128
解决思路:利用字符串比较大小的规则,将需要拼接的数字排好序,再将数字拼接到一起。
代码实现:
python
# 拼接最大数字问题
from functools import cmp_to_key
'''
在 Python 中,cmp_to_key 是 functools 模块中的一个工具函数,
用于将一个自定义的比较函数转换为可以在排序等场景中使用的键函数。
它的主要作用是让你能够使用传统的比较函数(返回 -1、0 或 1)来定义排序规则,
而不是直接返回一个键值。
'''
li = [32, 94, 128, 1286, 6, 71]
def xy_cmp(x, y):
if x + y < y + x:
return 1 # 1表示要进行交换,因为y+x更大,所以把y放到前面
elif x + y > y + x:
return -1
else:
return 0
def number_join(li):
li = list(map(str, li))
li.sort(key=cmp_to_key(xy_cmp))
return "".join(li)
'''
在 Python 中,return "".join(li) 的作用是将列表 li 中的所有元素连接成一个字符串,
并将其作为返回值返回。join() 是字符串的一个方法,
用于将可迭代对象(如列表、元组等)中的元素连接成一个字符串。
'''
print(number_join(li))
输出结果:
补充:比较规则
-
按字符顺序比较:
-
比较两个字符串时,从第一个字符开始逐个比较它们的 Unicode 编码值。
-
如果第一个字符不同,则根据它们的 Unicode 值决定大小。
-
如果第一个字符相同,则比较第二个字符,依此类推。
-
-
长度影响:
- 如果所有对应位置的字符都相同,较短的字符串会被认为较小。比如"app<apple"
1.1.4 活动选择问题
假设有n个活动,这些活动要占用一片场地,而场地再某时刻只能供一个活动使用。
每个活动都有一个开始时间和结束时间
(题目中时间以整数表示),表示活动在
区间占用场地。
问:安排那些活动能够使该场地举办的活动的个数最多?
|-------------------------------------|---|---|---|---|---|---|----|----|----|----|----|
| | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| | 1 | 3 | 0 | 5 | 3 | 5 | 6 | 8 | 8 | 2 | 12 |
| | 4 | 5 | 6 | 7 | 9 | 9 | 10 | 11 | 12 | 14 | 16 |
贪心结论:最先结束的活动一定是最优解的一部分。
证明:假设a是所有活动中最先结束的活动,b是最优解中最先结束的活动。 如果a = b,结论成立。 如果a != b,则b的结束时间一定晚于a的结束时间,则此时用a替换掉最优解中的b,a一定不与最优解中的其他活动时间重叠,因此替换后的解也是最优解。
代码实现:
python
# 活动选择问题
# 使用元组存储活动开始的时间和活动结束的时间
activities = [(1,4), (3,5), (0,6), (5,7), (3,9), (5,9), (6,10), (8,11), (8,12), (2,14), (12,16)]
def activities_selection(activities):
# 保证活动是按照结束时间排好序的
activities.sort(key=lambda x: x[1])
# 先定义一个列表 先把activities中的第一个元素传进去 也就是结束时间最早的活动
res = [activities[0]]
for i in range(1, len(activities)): # 从第二个活动开始遍历活动
if activities[i][0] >= res[-1][1]: # 如果当前活动开始的时间大于等于前面已经入选的最后一个活动结束的时间
# 说明这个活动与前面的活动时间不冲突
res.append(activities[i])
return res
print(activities_selection(activities))
输出结果:
1.2 贪心算法评价
1.2.1.优点
-
简单直观:贪心算法的思路通常比较容易理解。
-
高效性:在很多情况下,贪心算法的时间复杂度较低,能够快速得到结果。
1.2.2.缺点
-
局部最优不等于全局最优:贪心算法只能保证每一步的局部最优,但无法保证最终结果是全局最优的。
-
适用范围有限:并非所有问题都适合用贪心算法来解决,需要满足贪心选择性质和最优子结构性质。
2 动态规划
动态规划 = 递推式 + 重复子问题
动态规划问题关键特征
什么问题可以使用动态规划方法?
最优子结构:
原问题的最优解中涉及多少个子问题
在确定最优解使用那些子问题时,需要考虑多少种选择。
重叠子问题
2.1 斐波那契数列
斐波那契数列:,默认前两项为1.
代码实现:
python
# 斐波那契数列
def fibnacci(n): # 递归实现
if n == 1 or n == 2: # 斐波那契数列开始两项为1
return 1
else:
return fibnacci(n-1) + fibnacci(n-2)
def fibnacci_no_recurision(n): # 非递归实现
f = [0, 1, 1] # 使用列表存储斐波那契数列的前2项
if n == 1 or n == 2:
return 1
else: # n >= 3
for i in range(n-2): # 求第n项时需要遍历的次数
num = f[-1] + f[-2]
f.append(num)
return f[n]
print(fibnacci(10))
print(fibnacci_no_recurision(10))
2.2 钢条切割问题
某公司出售钢条,出售价格与钢条长度之间的关系如下表:
|-------------------------------------|---|---|---|---|----|----|----|----|----|----|
| 长度 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 价格 | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
问题:现有一段长度为n的钢条和上面的价格表,求切割钢条方案,使得收益最大。
自顶向下实现
解:设长度为n的钢条切割后最优收益值为,可以得出递推式:
第一个参数
表示不切割是的收益 其他 n-1 个参数分别表示另外 n-1 种不同切割方案,对方案
将钢条切割为长度为
和
两段 方案
的收益为切割两段的最优收益之和 考察所有的
,选择其中受益最大的方案
简化递归求解方法:
从钢条的左边切割下长度为 i 的一段,只对右边剩下的一段继续进行切割,左边的不再切割 递推式简化为 不做切割的方案就可以描述为:左边一段长度为n,收益为
,剩余一段长度为0,收益为
.
代码实现:
python
# 钢条切割
p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30] # 对应长度的价格,长度为0,1,2···
# 第一种递归方式
def cut_rod(p, n): # n表示钢条的长度
# 这个函数表示长度为n的时候的最优切割方式
if n == 0:
return 0
else:
res = p[n] # 最开始的切割方案的价格就是它自己
for i in range(1, n): # 遍历n-1中切法
# 根据公式,令p[n]与其他切割情况作比较
res = max(res, cut_rod(p, i) + cut_rod(p, n-i))
return res
# 第二种递归方式
def cut_rod2(p, n):
if n == 0:
return 0
else:
res = 0
for i in range(1, n+1):
res = max(res, p[i] + cut_rod2(p, n-i))
return res
print(cut_rod(p, 9))
print(cut_rod2(p, 9))
简化之后的求解方式,避免了一些重复的求解计算,所以第二种会比第一种快一点。
自顶向下递归实现效率:时间复杂度
自底向上实现
动态规划的思想:每个子问题只求解一次,保存求解结果。之后需要此问题时,只需查找保存的结果。
python
# 使用动态规划写法
def cut_rod_dp(p, n):
r = [0]
for i in range(1,n+1): # 循环n次,也就是从长度为1到长度为n的情况依次进行求解
res = 0 # 用来暂存长度为i时的最优结果
for j in range(1, i+1):
res = max(res, p[j] + r[i-j]) # 在j<i中所有的结果中取最大值
r.append(res)
return r[n]
时间复杂度
重构解
如何修改动态规划算法,使其不仅输出最优解,还输出最优切割方案?
对每个子问题,保存最优切割时的左边的切割长度。长度为 i 时的最优切割时的左边不切割的最大长度记为,对应的最优价格记为
.
|----------------------------------------|---|---|---|---|----|----|----|----|----|----|----|
| | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| | 0 | 1 | 5 | 8 | 10 | 13 | 17 | 18 | 22 | 25 | 30 |
| | 0 | 1 | 2 | 3 | 2 | 2 | 6 | 1 | 2 | 3 | 10 |
代码实现:
python
def cut_rod_extend(p, n):
r = [0] # 存储切割方案的最大价值
s = [0] # 存储切割方案中的最左边不用切割的最大值
for i in range(1, n+1):
res_r = 0 # 用来记录价格的最优值
res_s = 0 # 最优切割方案时左边不切割的最大长度
for j in range(1, i+1):
if p[j] + r[i-j] > res_r: # 找到价格最优值
res_r = p[j] + r[i-j]
res_s = j # 存储长度为i时左边不切割的最大长度
r.append(res_r)
s.append(res_s)
return r[n], s
def cut_rod_solution(p, n): # 打印价格最优时对应的切割方案
r, s = cut_rod_extend(p, n)
ans = []
while n > 0:
ans.append(s[n])
n -= s[n]
return ans