前言
趣学算法笔记博客系列是对《趣学算法》的总结,并用Python3实现书中所有实例代码(原书用C语言)。每个算法书中都有完美图解,还有时间和空间复杂度分析,并且给出优化方法,但是这里不会给出,详细内容还是看书吧。
算法原理
贪心算法总是根据当前已有的信息做出当前最好的选择,颇有“目光短浅”的感觉,而且一旦做出了选择就不会在改变,所以贪心算法不是从整体考虑问题,它所做出的选择只是在某种意义上的局部最优,它期望通过局部最优选择从而得到全局最优的解决方案。
在贪心算法中需要注意以下几点:
- 没有后悔药。一旦做出选择,不可以改变;
- 有可能得到的是最优解的近似解;
- 选择什么样的贪心策略,直接决定算法的好坏。
贪心算法原理虽然简单易懂,但是在实际问题中,我们往往不清楚该问题是否适合用贪心算法。利用贪心算法求解的问题往往有两个重要特征:贪心选择性质和最优子结构性质。
- 贪心选择
贪心选择是指原问题的全局最优解可以通过一系列局部最优解得到。贪心算法在每次贪心选择过程中始终应用同一规则,这样就将原问题每次都变为一个规模更小的子问题,每步都是在选择子问题的最优解。 - 最优子结构
当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质。问题是否具有最优子结构性质是该问题是否可用贪心算法求解的关键。
知道了什么时候可以用贪心算法,剩下的就是怎么用的问题了。贪心算法可分为三步,
- 贪心策略选择
选择看上去最好的一个方案。例如挑选苹果,可以认为最大的是最好的,也可以认为最红的师最好的。选定一个策略后,以后每次选择都是按照这个策略,不可改变。 - 求解局部最优
根据策略,每次得到一个局部最优解。例如策略是认为最大的苹果是最好的,那么每次都是从剩下的苹果中选择最大的苹果,这个最大的苹果就是当前最优解。 - 得到全局最优解
把所有局部最优解组合,得到的就是全局最优解。
实例解析
1. 最优装载问题
(1)问题描述
有一天海盗们截获了一艘装满各种各样古董的货船,每一件都价值连城,一旦打碎就是去了价值,海盗船载重量为C,每件固定的重量为wi,海盗们该如何尽可能装载最多数量的古董呢?
古董重量清单:
重量w[i] | 4 | 10 | 7 | 11 | 3 | 5 | 14 | 2 |
---|
(2)问题分析
问题要求装载的古董数量最多,而船的载重量固定,因此可以选择每次拿最轻的物品,这样就能拿走数量最多的物品。显然这个问题可以用贪心算法解决。
(3)算法设计
- 船载重量固定为C,只要每次选择重量最小的古董,直到不能再装为止,这样装载的古董数量最大,这就是贪心策略;
- 把古董按重量从小到大排序,根据策略选出尽可能多的古董。
(4)实战演练:
# —————————————————— #
# Date: 2019.2.26
# Author: 鲁班七号
# 版本:Python3
# 第二章 2.2 加勒比海盗船
# —————————————————— #
# 定义每个古董重量
antique = [4, 10, 7, 11, 3, 5, 14, 2]
def max_ans(antique):
anti_sort = sorted(antique) # 对重量排序
ans, tmp = 0, 0 # ans记录装载古董数量,tmp记录装载古董重量
ship = [] # 记录装载的古董
for a in anti_sort:
tmp += a
if tmp <= 30:
ans += 1
ship.append(a)
print('装载古董数量:',ans)
print('装载的古董',ship)
max_ans(antique)
2.背包问题
(1)问题描述
假设山洞中有n种宝物,每种宝物有一定重量w和相应的价值v,毛驴运载能力有限,只能运走m重量的宝物,一种宝物只能拿一样,宝物可以分割。怎样才能使毛驴运走宝物的价值最大呢?
(2)问题分析
可以尝试三种贪心策略:
- 每次挑选价值最大的宝物装入背包;
- 每次挑选最重的宝物;
- 每次选取单位重量价值最大的宝物。
显然应该采用第三种策略。
(3)算法设计
- 计算出每件宝物的性价比,按照从高到低排序;
- 根据贪心策略,按性价比从大到小选取宝物,直到达到毛驴的运载能力。每次选择宝物后判断是否小于m,如果不小于则取走宝物的一部分,程序结束。
(4)实战演练
# —————————————————— #
# Date: 2019.2.27
# Author: 鲁班七号
# 版本:Python3
# 第二章 2.3 阿里巴巴与四十大盗
# —————————————————— #
# datas中每个元素代表一个古董,每个列表第一个元素代表古董重量,第二个元素代表古董价值
datas = [[4, 3], [2, 8], [9, 18], [5, 6], [5, 8], [8, 20], [5, 5], [4, 6], [5, 7], [5, 15]]
m = 30 # 毛驴运载能力
w = 0 # 获取的总价值
# 计算出每件宝物的性价比,按照从高到低排序
for i in range(len(datas)):
price = datas[i][1] / datas[i][0]
datas[i].append(price) # 增加性价比
datas.sort(key=lambda data: data[2], reverse=True) # 按性价比排序
# 按性价比从大到小选取宝物,直到达到毛驴的运载能力
for data in datas:
if data[0] <= m:
w += data[1]
m -= data[0]
else:
w += data[2] * m # 取走宝物的一部分
break
print('总价值:',w)
想一下如果宝物不可分割,贪心算法得到的是否是最优解?
物品可分割的装载问题称为背包问题,不可分割问题的装载问题称为0-1背包问题。0-1背包问题不具有贪心选择性质,贪心算法不能得到全局最优解,仅仅是最优解的近似解。0-1背包问题可用动态规划算法求解。
3. 会议安排
(1)问题描述
目的是在有限的时间内参加更多的会议,且不能同时参加两个会议。每个会议起始时间bi,结束时间ei,会议进行时间为[bi,ei),会议时间表如下:
会议i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
开始时间bi | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 17 | 18 | 16 |
结束时间ei | 10 | 11 | 15 | 14 | 16 | 17 | 17 | 18 | 20 | 19 |
有限的时间是限制条件,也有起始时间和结束时间,这里起始时间是会议最早开始的时间,结束时间是会议最晚结束的时间。
(2)问题分析
有三种贪心策略:
- 每次从会议中选择开始时间最早且与已安排会议时间不重合的会议;
- 每次从会议中选择时间最短的且与已安排会议时间不重合的会议;
- 每次从会议中选择结束时间最早且与已安排会议时间不重合的会议。
第三种策略最好(书中P39有解释),很多人可能会认为第二种策略最好,实际不是。我们可以简单举例说明,假如只有三个会议a、b、c,会议时间为a:1:00~5:00, b: 6:00 ~ 10:00, c : 4:00 ~ 6:30。显然按照第二种策略只能选择c会议,而按照第三种策略可以选择a和b。有限的时间越长,越能安排更多的会议,前提是这个有限时间是连续的。结束时间越早,剩余时间越多,就越有可能安排更多的会议。
(3)算法设计
- 将会议存储在列表meeting中;
- 第一次选择最早结束时间的会议,用last记录会议结束时间;
- 从剩下会议中选择与已安排会议不冲突的结束时间,直到没有会议可选。
(4)实战演练
# —————————————————— #
# Date: 2019.2.27
# Author: 鲁班七号
# 版本:Python3
# 第二章 2.4 会议安排
# —————————————————— #
# meetings中每个列表代表一个会议,列表中第一个元素表示会议编号,第二个元素表示会议开始时间,第三个元素表示会议结束时间
meetings = [[1, 3, 6], [2, 1, 4], [3, 5, 7], [4, 2, 5], [5, 5, 9], [6, 3, 8],\
[7, 8, 11], [8, 6, 10], [9, 8, 12], [10, 12, 14]]
meetings.sort(key=lambda meet: meet[2]) # 对会议按结束时间排序
schedule = [] # 选择的会议编号
last = 0 # 记录上次会议结束时间
for meet in meetings:
if meet[1] >= last:
schedule.append(meet[0])
last = meet[2]
print('选择的会议:',schedule)
leetcode上相似题目:435, 452
https://leetcode-cn.com/problems/non-overlapping-intervals/
https://leetcode-cn.com/problems/minimum-number-of-arrows-to-burst-balloons/
4. 最短路径
(1)问题描述
给定有向带权图G=(V, E),其中每条边的权都是正整数,从图中一顶点出发,计算到其他各顶点的最短路径长度,这里路径长度是指各边的权之和。
(2)问题分析
将顶点V划分为两个集合S和V-S,S中开始只有起点,其余顶点在集合V-S中。每次从V-S集合中找出与集合S中顶点直接相连的顶点集合,然后从这些顶点中找到与源顶点路径最短的那个,因为S中所有顶点都直接或者间接与源点联接,所以V-S中顶点只要与S中任意顶点相连,就说明该顶点与源点存在最短路径,否则该顶点与源点之间路径为无穷大,这样每次有新顶点加入S后,V-S中顶点与源点的路径都可能发生变化。直到S中包含了V中所有的顶点。
(3)算法设计
- 用列表maps存储数据,maps[u][v]表示顶点u到顶点v的路径权重;列表dist记录源点到所有顶点的最短路径长度,dist的索引就是顶点,值就是路径长度,初始时与源点不直接相连的顶点其路径都是无穷大; 列表p[i]记录i顶点的前驱;
- 初始化s集合,dist和p列表。这里假设源点为0索引点;
- 在V-S中寻找与源点路径最短的顶点t,也就是在dist中寻找最小值,并且这个值在dist中的索引还需在V-S中;
- 将t加入S中,同时从V-S中删除t;
- 判断V-S是否为空,为空则算法结束;
- 更新dist中的值,因为有新顶点t加入S,而V-S中可能有与t相连的顶点,这样可能导致V-S中与源点的最短路径发生变化。
这里很难用文字表述清楚,原书中图解很详细,这个其实就是Dijkstra算法
(4)实战演练
# —————————————————— #
# Date: 2019.3.2
# Author: 鲁班七号
# 版本:Python3
# 第二章 2.5 最短路径会议安排
# —————————————————— #
# 数据结构
maps = [[100, 2, 5, 100, 100],
[100, 100, 2, 6, 100],
[100, 100, 100, 7, 1],
[100, 100, 2, 100, 4],
[100, 100, 100, 100, 100]]
# 初始化
dist = maps[0]
s = [0]
v_s = [1, 2, 3, 4]
p = [-1] * len(maps)
for i in range(len(maps[0])):
if maps[0][i] < 100:
p[i] = 0
while True:
# 找最小
temp = []
for v in v_s:
temp.append(dist[v])
t = dist.index(min(temp))
# 加入S战队
s.append(t)
# 更新V-S战队
v_s.remove(t)
if len(v_s) == 0: # 判结束
break
else:
# 借东风
for vn in v_s:
if dist[vn] > maps[t][vn] + dist[t]: # 更新dist中的值
dist[vn] = maps[t][vn] + dist[t]
p[vn] = t
print('最短距离数组:', dist)
print('前驱数组:', p)
5.哈夫曼编码
(1)问题描述
五笔字型一级编码对应着25个汉字,因为这些汉字是使用频率最高的。五笔字型对汉字使用不定长编码,也就是经常使用的汉字编码较短,不常使用的汉字编码较长。对应的有一种编码方式是等长编码,所有汉字的编码长度相等。这两种编码方式各有优缺点。我们的目的是实现字符的不等长编码。不等长编码有两个问题:
- 编码尽可能短
让频率高的字符编码短,这样节省存储空间。 - 没有二义性
假设有如下编码:
A:0 B:1 C:01 D:10
现在有0110,那这个编码该怎么翻译呢?解决的方法是任何一个字符的编码不能是另一个字符编码的前缀。
(2)问题分析
不等长编码可用哈夫曼树构造,也称哈夫曼编码。利用哈夫曼树对字符进行编码,叶子节点是要编码的字符,字符频率作为叶子节点的权值,以自底向上的方式,通过n-1次合并后构造出一棵树,核心思想是权值越大的叶子离根越近。
(3)算法设计
- 初始化,定义节点类TreeNode,构造n个节点集合chars,列表values中每个元素是chars中对应元素的权重;
- 如果chars中只剩下一个节点,树构造成功,跳转到第4步。否则从chars中取出两个没有父节点且权重最小的节点,合并成一个新节点,新节点权重为两个节点权重之和,两个节点分别作为新节点的左右孩子节点(谁左谁右不重要);
- 从chars中删除上面的两个节点,并将新节点加入进来,跳转到第2步;
- 约定左分支上编码为‘0’,右分支上编码为‘1’。从叶子节点到根节点逆向求出每个字符的哈夫曼编码,从根节点到叶子节点路径上的字符组成的字符串为该叶子节点的哈夫曼编码。算法结束。
(4)实战演练
# —————————————————— #
# Date: 2019.3.3
# Author: 鲁班七号
# 版本:Python3
# 第二章 2.6 哈夫曼编码
# —————————————————— #
# 构造节点类
class TreeNode:
def __init__(self, x):
self.val = x
self.parent = None
self.left = None
self.right = None
# 定义数据
a = TreeNode(0.05)
b = TreeNode(0.32)
c = TreeNode(0.18)
d = TreeNode(0.07)
e = TreeNode(0.25)
f = TreeNode(0.13)
# chars中元素位置要和values中的保持一致
chars = [a, b, c, d, e, f]
values = [a.val, b.val, c.val, d.val, e.val, f.val]
while len(chars) > 1:
# 寻找权值最小的两个节点
v = sorted(values)
values1, values2 = v[0], v[1]
index1 = values.index(values1)
nodel = chars[index1]
index2 = values.index(values2)
noder = chars[index2]
# 两个节点合并为一棵树,根节点为两子节点值之和
n = TreeNode(values1+values2)
n.left = nodel
n.right = noder
nodel.parent = n
noder.parent = n
# 删除节点,并将新节点假如到chars和values中
values.remove(values1)
values.remove(values2)
chars.remove(nodel)
chars.remove(noder)
chars.append(n)
values.append(n.val)
# 从叶节点到根节点逆向求出每个字符的哈夫曼编码
dicts = {}
nodes = ['a', 'b', 'c', 'd', 'e', 'f']
i = 0
for char in [a, b, c, d, e, f]:
p = char.parent
l = []
while p is not None:
if p.left == char:
l.append(0)
else:
l.append(1)
char = p
p = p.parent
dicts[nodes[i]] = l[::-1] # 列表翻转
i += 1
print('每个字符的哈夫曼编码:',dicts)
6.最小生成树 1
(1)问题描述
某学校有10个学院,3个研究所,1个大型图书馆,4个实验室,现在要把这些机构用校园网连接。该问题用无向连通图G=(V, E)来表示通信网络,V表示顶点集合,E表示两个顶点之间的边,其值用两点之间布线费用表示,两节点之间没有边表示他们不能用网络连接,费用为无穷大。该如何设计网络使得费用最少?
(2)问题分析
对于n各顶点的连通图,只需n-1条边就可以使整个图连通,前提是不存在回路。所以我们只需要找出n-1条权值最小且没有回路的边即可,这其实就是最小生成树求解问题。
找出n-1条权值最小的边很容易,但如何保证他们之间没有回路?解决办法是避圈法:在生成树的过程中,把已经在生成树中的节点看作一个集合,把剩下的节点看作另一个集合,从连接两个集合的边中选择一条权值最小的边即可保证不会生成回路,这就是Prim算法。
(3)算法设计
- 初始化,用带权邻接矩阵C存储图G,C[u][x]表示顶点u与x相连边的权值,列表u存储已选择的节点,列表v_u存储还未选择的节点,列表s[i]=100表示顶点i已加入列表u,列表closest[j]表示v_u中的顶点到u中的最近邻点,lowcost[j]表示v_u中的顶点j到u中的最近邻点的边值,即(j, closest[j])的权值,初始时令u=[0],也就是从0节点出发,因为最小生成树包含所有节点,所以无论从哪个节点出发都可以生成最小生成树,不影响最终结果;
- 判断如果v_u为空,算法结束;
- 在lowcost中找最小值,并且该值的索引号在v_u中。在程序中用lowcost于s相乘,在结果中找最小值,这样得到的值可以保证其对应的顶点在v_u中。因为在u中的顶点对应的在s中的值都为100,与lowcost相乘后会很大,取min时会排出掉这些顶点;
- 将上面最小值对应的顶点加入到u中,并将其从v_u中删除;
- 对v_u中所有顶点更新lowcost和closest,更新方式见代码,转至步骤2。
1 | 2 | 3 | 4 | 5 | 6 | 7 | |
---|---|---|---|---|---|---|---|
closest | 2 | 1 | 2 |
上表closest中表示7号节点与2号节点相连,6号节点与1号节点相连,3号节点与2号节点相连。
1 | 2 | 3 | 4 | 5 | 6 | 7 | |
---|---|---|---|---|---|---|---|
lowcost | 0 | 23 | 20 | 100 | 100 | 28 | 1 |
上表表示7号节点到2号节点边值为1, 6号节点到1号节点边值为28, 3号节点到2号节点边值为20,值为100表示没有连接。
(4)实战演练
# —————————————————— #
# Date: 2019.3.5
# Author: 鲁班七号
# 版本:Python3
# 第二章 2.7 最小生成树(一)
# —————————————————— #
import numpy as np
# 数据结构
c = [[100, 23, 100, 100, 100, 28, 36],
[23, 100, 20, 100, 100, 100, 1],
[100, 20, 100, 15, 100, 100, 4],
[100, 100, 15, 100, 3, 100, 9],
[100, 100, 100, 3, 100, 17, 16],
[28, 100, 100, 100, 17, 100, 25],
[36, 1, 4, 9, 16, 25, 100]]
# 初始化
u = [0]
v_u = [1, 2, 3, 4, 5, 6]
closet = [0] * len(c)
lowcost = c[0]
s = [1] * len(c)
s[0] = 100
while len(v_u) > 1:
# 找最小
node = lowcost.index(min(np.multiply(np.array(lowcost),np.array(s))))
s[node] = 100 # 表示该节点已加入u
# 将节点加入到u中,并从v_u中删除
u.append(node)
v_u.remove(node)
# 更新closet和lowcost列表
for n in v_u:
if c[n][node]<lowcost[n]:
lowcost[n] = c[n][node]
closet[n] = node
print(closet)
print(lowcost)
7. 最小生成树 2
prim算法中是从顶点角度处理,每次选择两顶点间边值最小的未加入u的顶点。构造最小生成树还有一种方法,就是Kruskal算法,它是直接从边出发,每次选择边值最小的那条边而不是顶点。首先将边值按从小到大排序,每次选择边值最小的边,在保证没有回路的情况下,只要选择的边数小于n-1就继续下去,选取到n-1条边说明所有顶点都已连通。已选择的边存储在集合T中,未选择的边存储在集合TE中。
规避回路方法:集合避圈法,如果选择的边它的两个顶点都在同一个集合中,那么就一定会产生回路。
(1)算法设计
- 初始化,C存储图G;edge存储边;nodes存储edge中每个边的两个节点,以元组形式存储;nodeset存储孤立节点,key表示节点号,value是一个列表,存储所有与key节点联通的节点集合;edge_val存储已选择的边,node_val存储已选择边的两个节点;
- 判断已选择的边数是否小于n-1,如果不是则结束程序;
- 从edge中寻找边值最小的边;
- 判断所选边的两个节点是否在同一集合中,如果是则转到2;
- 将新的边加入edge_val中,同时更新node_val和nodeset;
- 从edge中删除上面选择的边;
(2)实战演练
# —————————————————— #
# Date: 2019.3.6
# Author: 鲁班七号
# 版本:Python3
# 第二章 2.7 最小生成树(二)
# —————————————————— #
# 数据结构
c = [[100, 23, 100, 100, 100, 28, 36],
[23, 100, 20, 100, 100, 100, 1],
[100, 20, 100, 15, 100, 100, 4],
[100, 100, 15, 100, 3, 100, 9],
[100, 100, 100, 3, 100, 17, 16],
[28, 100, 100, 100, 17, 100, 25],
[36, 1, 4, 9, 16, 25, 100]]
# 初始化
edge = [] # 存储边
nodes = [] # 存储edge中每个边对应的两个节点
nodeset = {} # 存储孤立节点,key表示节点号,value是一个列表,存储所有与key节点联通的节点集合
for i in range(len(c[0])):
for j in range(i, len(c[i])):
if c[i][j] < 100:
edge.append(c[i][j])
nodes.append((i, j))
nodeset[i] = [i]
print('原始数据中全部权值:', edge)
print('原始数据中两两连接的节点:', nodes, '\n')
# 生成树
edge_val = [] # 已选择的边
node_val = [] # 已选择边的两个节点
while len(edge_val)<len(c[0])-1: # 判断选择的边数是否小于n-1
index = edge.index(min(edge)) # 寻找最小边
n = nodes[index]
if n[0] not in nodeset[n[1]]: # 说明两个节点不在同一集合中
print('每步选择的两个节点:', (n[0], n[1]))
l = nodeset[n[1]]
for k in l:
nodeset[k].extend(nodeset[n[0]])
nodeset[n[0]].extend(nodeset[n[1]])
edge_val.append(edge[index])
node_val.append((n[0], n[1]))
edge[index] = 100 # 相当于删除边index
print('\n')
print('两节点之间的权值:',edge_val)
print('总权值:',sum(edge_val))
print('两两连接的节点',node_val)