算法图解
一、算法简介
1.1 引言
算法:是一组完成任务的指令。任何代码片段都可视为算法
1.2 二分查找
二分查找可用于在一个有序的元素列表快速查找某一元素。使用二分查找时,每次都排除一半的数字。如下例:
在某一字典中查找一个单词,字典包含2400000个词,使用二分查找每次排除一半单词,直到最后只剩下一个单词。
对于包含n个元素的列表,二分查找只需 logn步。````
1.3 大O表示法
(1)大O表示法是一种特殊的表示法,指出了算法的速度有多快。假设列表包含n个元素。简单查找需要检查每个元素,因此需要执行n次操作。使用大O表示法,这个运行时间为O(n)。单位秒呢?没有——大O表示法指的并非以秒为单位的速度。 大O表示法让你能够比较操作数,它指出了算法运行时间的增速。
(2)一些常见的大O运行时间
O ( l o g 2 n ) O(log_2n) O(log2n),也叫对数时间,这样的算法包括二分查找。
O ( n ) O(n) O(n),也叫线性时间,这样的算法包括简单查找。
O ( n ∗ l o g n ) O(n*log_n) O(n∗logn),这样的算法包括快速排序,一种速度较快的排序算法。
O ( n 2 ) O(n^2) O(n2),这样的算法包括选择排序,一种速度较慢的排序算法。
O
(
n
!
)
O(n!)
O(n!),这样的算法包括旅行商的解决方法,一种非常慢的方法。
1.4 小结
二、选择排序
2.1 内存的工作原理
计算机就行很多抽屉的集合体,每个抽屉都有地址。需要将数据存储到内存时,你请求计算机提供存储空间,计算机给你一个存储地址。需要存储多项数据时,有两种基本方式——数组和链表。
2.2 数组和链表
2.2.1 链表
链表中的元素可以存储在内存的任何地方。其中的每个元素都存储着下一个元素的地址,从而使一系列算计的内存地址串在一起。
链表的优势在插入和删除元素方面。
(1)假设我们要存储一系列元素,使用数组存储意味着所有元素在内存中都是相连的。
2.2.2 数组
数组存储意味着所有元素在内存中都是相连的。如果要插入元素,而后面的位置被占用,此时就需要进行转移。
优势:需要随机地读取元素时,数组的效率很高,因为可迅速找到数组的任何元素。
2.2.3 术语
元素的位置称为索引。因此,不说“元素20的位置为1”,而说“元素20位于索引1处”。
两种访问方式:随机访问和顺序访问。
2.3 选择排序
选择排序是一种灵巧的算法,但速度不是很快,一半运行时间为 O ( n 2 ) O(n^2) O(n2)。
示例代码:
def findSmallest(arr):
smallest = arr[0] # 存储最小的值
smallest_index = 0 # 存储最小元素的索引
for i in range(1, len(arr)):
if arr[i] < smallest:
smallest = arr[i]
smallest_index = i
return smallest_index
def selectionSort(arr): # 对数组进行排序
newArr = []
for i in range(len(arr)):
smallest_index = findSmallest(arr) # 找出数组中最小的元素,并将其加入的新数组中
print(smallest_index)
newArr.append(arr.pop(smallest_index))
# print(arr.pop(smallest_index))
return newArr
print(selectionSort([5, 3, 6, 2, 10]))
运行结果:
2.4 小结
计算机内存犹如一大堆抽屉。
需要存储多个元素时,可使用数组或链表。
数组的元素都在一起。
链表的元素是分开的,其中每个元素都存储了下一个元素的地址。
数组的读取速度很快。
链表的插入和删除速度很快。
在同一个数组中,所有元素的类型都必须相同(都为int、 double等 )。
三、递归
3.1递归
-
举个例子:如果我们要从一个盒子堆中,找一把钥匙,就可采取递归的方法。
-
方法步骤:
(1) 检查盒子中的每样东西。
(2) 如果是盒子,就回到第一步。
(3) 如果是钥匙,就大功告成!
-
伪代码
def look_for_key(box): for item in box: if item.is_a_box(): look_for_key(item) # 递归 elif item.is_a_key(): print("found the key!")
3.2 极限条件和递归条件
每个递归函数都有两部分:基线条件( base case)和递归条件( recursive case) 。递归条件指的是函数调用自己,而基线条件则指的是函数不再调用自己,从而避免形成无限循环。
一个倒计时函数:
def countdown(i):
print(i)
if i <= 0: # 基线条件
return
else: # 递归条件
countdown(i-1)
3.3 栈
栈 可以比一个待办事项清单–一叠便条。插入的待办事项放在清单的最前面;读取待办事项时,你只读取
最上面的那个,并将其删除。因此这个待办事项清单只有两种操作: 压入(插入)和弹出(删除并读取)。
这种数据结构称为栈。
3.3.1 调用栈
计算机在内部使用被称为调用栈的栈。
3.3.2 递归调用栈
递归函数也在使用调用栈。看看阶乘的递归函数 fact(3)的调用栈。
代码示意图:
# 阶乘递归函数
def fact(x):
if x == 1:
return 1
else:
return x * fact(x-1)
分析过程:
注意,每个fact调用都有自己的x变量。在一个函数调用中不能访问另一个的x变量。
3.4 小结
递归指的是调用自己的函数。
每个递归函数都有两个条件:基线条件和递归条件。
栈有两种操作:压入和弹出。
所有函数调用都进入调用栈。
调用栈可能很长,这将占用大量的内存。
四、快速排序
4.1 快速排序
-
快速排序是一种常用的排序算法,比选择排序快得多。
-
实例:实现对数组中元素的快速排序。
(1)**基线条件:**数组为空或只含一个元素时,返回原数组,此时不用排序。
def quicksort(array):
if len(array) < 2:
return array
(2)如果包含三个或以上元素,此时需要将数组分解,直到满足基线条件。首先,从数组中找出一个元素,称为基准值(pivot)。
(3)找出比基准值小的元素以及比基准值大的元素,进行分区(partitioning)。
(4)此时两个子数组是无序的,在对子数组进行快速排序,从而得到有序数组:左边的数组+基准值+右边的数组。
quicksort([15, 10]) + [33] + quicksort([])
> [10, 15, 33] # 一个有序的数组
- 图解实例:
-
代码实例:
# 快速排序 def quicksort(arr): if len(arr) < 2: return arr else: pivot = arr[0] less = [i for i in arr[1:] if i <= pivot] greater = [i for i in arr[1:] if i >= pivot] return quicksort(less) + [pivot] + quicksort(greater) print(quicksort([10, 5, 11, 2, 3]))
运行结果:
4.2 小结
D&C将问题逐步分解。使用D&C处理列表时,基线条件很可能是空数组或只包含一个元
素的数组。
实现快速排序时,请随机地选择用作基准值的元素。快速排序的平均运行时间为O(n log n)。
大O表示法中的常量有时候事关重大,这就是快速排序比合并排序快的原因所在。
比较简单查找和二分查找时,常量几乎无关紧要,因为列表很长时, O(log n)的速度比O(n)
快得多。
五、散列表
Python提供的散列表实现为字典,可使用函数dict来创建散列表。
5.1 散列函数
1.散列函数“将输入映射的数字”,其要满足的要求:
它必须是一致的。例如,假设你输入apple时得到的是4,那么每次输入apple时,得到的都
必须为4。如果不是这样,散列表将毫无用处。
它应将不同的输入映射到不同的数字。 例如, 如果一个散列函数不管输入是什么都返回1,
它就不是好的散列函数。最理想的情况是,将不同的输入映射到不同的数字。
不断重复此过程,即可填满生个数组。
2.使用散列函数和数组创建了一种被称为**散列表(hash table)**的数据结构。
3.散列表小结:
模拟映射关系;
防止重复;
缓存/记住数据,以免服务器再通过处理来生成它们。
5.2 冲突
**冲突(collision)**是指给两个键分配的位置相同,解决办法就是在这个位置上存储一个链表。
在上图中,apple和avocado映射到了同一个位置,因此在这个位置存储一个链表。
5.3 性能
在平均的情况下,散列表只需各种操作的事件都为 O ( 1 ) O(1) O(1)。 O ( 1 ) O(1) O(1)被称为常量事件。
使用散列表时,避开最糟情况至关重要 ,为避免冲突,其需要:
较低的填装因子;
良好的散列函数。
5.3.1 填装因子
填 装 因 子 = 散 列 表 包 含 的 元 素 数 位 置 总 数 填装因子=\frac{散列表包含的元素数}{位置总数} 填装因子=位置总数散列表包含的元素数
5.3.2 良好散列函数
良好散列函数让数组中的值呈均匀分布。
5.4 小结
你可以结合散列函数和数组来创建散列表。
冲突很糟糕,你应使用可以最大限度减少冲突的散列函数。
散列表的查找、插入和删除速度都非常快。
散列表适合用于模拟映射关系。
一旦填装因子超过0.7,就该调整散列表的长度。
散列表可用于缓存数据(例如,在Web服务器上)。
散列表非常适合用于防止重复。
六、广度优先搜索(breadth-first search,BFS)
BFS可以帮助我们找到两样东西之间的“最短距离”。
6.1 图简介
首先,我们来学习下什么是图。图模拟一组链接。假设你与朋友玩牌,并要模拟谁欠谁钱,可像下面这样指出Alex欠Rama钱。
图有节点和边组成。一个节点可能与众多节点直接连接,这些节点被称为邻居。
6.2 广度优先搜索
广度优先搜索是一种用于图的查找算法,可帮助回答两类问题:
第一类问题:从节点A出发,有前往节点B的路径吗?
第二类问题:从节点A出发,前往节点B的哪条路径最短?
6.2.1 查找最短路径
引入问题:在你的关系网中,寻找芒果商。首先在你的朋友中查找,如果没有,再在朋友的朋友中查找。
6.2.2 队列
队列的工作原理与现实生活中的队列完全相同。假设你与朋友一起在公交车站排队,如果你排在他前面,你将先上车。队列的工作原理与此相同。队列类似于栈,你不能随机地访问队列中的元素。队列只支持两种操作: 入队和出队。
队列是一种先进先出( First In First Out, FIFO)的数据结构,而栈是一种后进先出( Last In
**First Out, LIFO)**的数据结构。
6.3 实现图
在我的朋友中,找到关系最近的芒果经销商。
Python中表示上图的映射关系:
graph = {}
graph["you"] = ["alice", "bob", "claire"]
graph["bob"] = ["anuj", "peggy"]
graph["alice"] = ["peggy"]
graph["claire"] = ["thom", "jonny"]
graph["anuj"] = []
graph["peggy"] = []
graph["thom"] = []
graph["jonny"] = []
6.4 实现算法
- 工作原理:
2.完整代码
# 广度优先搜索 breadth-first search
graph = {}
graph["you"] = ["alice", "bob", "claire"]
graph["bob"] = ["anuj", "peggy"]
graph["alice"] = ["peggy"]
graph["claire"] = ["thom", "jonny"]
graph["anuj"] = []
graph["peggy"] = []
graph["thom"] = []
graph["jonny"] = []
print('graph: ',graph)
from collections import deque
# 判断一个人是不是芒果商
def person_is_seller(name):
return name[-1] == 'm'
# 广度优先搜索函数BFS
def BFS(name):
search_queue = deque() # 创建一个队列
search_queue += graph[name] # 将你的邻居都加入到这个搜索队列中
searched = [] # 这个数组用于记录检查过的人
print('search_queue: ', search_queue)
while search_queue: # 只要队列不为空
person = search_queue.popleft() # 就取出其中的第一个人\
if not person in searched:
if person_is_seller(person):
print(person + ' is a mango seller') # 是芒果经销商
return True
else:
search_queue += graph[person]
searched.append(person)
print(search_queue)
return False
BFS("you")
3.运行结果
6.5 小结
广度优先搜索指出是否有从A到B的路径。
如果有,广度优先搜索将找出最短路径。
面临类似于寻找最短路径的问题时,可尝试使用图来建立模型,再使用广度优先搜索来
解决问题。
有向图中的边为箭头,箭头的方向指定了关系的方向,例如, rama→adit表示rama欠adit钱。
无向图中的边不带箭头,其中的关系是双向的,例如, ross - rachel表示“ross与rachel约
会,而rachel也与ross约会”。
队列是先进先出( FIFO)的。
栈是后进先出( LIFO)的。
你需要按加入顺序检查搜索列表中的人,否则找到的就不是最短路径,因此搜索列表必
须是队列。
对于检查过的人,务必不要再去检查,否则可能导致无限循环。
七、狄克斯特拉算法
狄克斯特拉算法( Dijkstra’s algorithm)适用于加权图,找到最快的路径。而BFS是找到段数最少的路径。
狄克斯特拉算法背后的关键理念: 找出图中最便宜的节点,并确保没有到该节点的更便宜的路径!
7.1 使用迪克斯特拉算法
(1) 找出最便宜的节点,即可在最短时间内前往的节点。
(2) 对于该节点的邻居,检查是否有前往它们的更短路径,如果有,就更新其开销。
(3) 重复这个过程,直到对图中的每个节点都这样做了。
(4) 计算最终路径。
7.2 实现
1.以下图为例:
2.完成代码:
# 狄克斯特算法实现
graph = {}
graph["start"] = {}
graph["start"]["a"] = 6
graph["start"]["b"] = 2
print(graph["start"].keys())
graph["a"] = {}
graph["a"]["fin"] = 1
graph["b"] = {}
graph["b"]["a"] = 3
graph["b"]["fin"] = 5
graph["fin"] = {} # 终点没有任何邻居
# 创建开销表
infinitely = float("inf") # 表示无穷大
costs={}
costs["a"] = 6
costs["b"] = 2
costs["fin"] = infinitely
# 存储父节点的散列表
parents = {}
parents["a"] = "start"
parents["b"] = "start"
parents["fin"] = None
# 数组 记录读取过的节点
processed = []
# 找出开销最低的节点
def find_lowest_cost_node(costs):
lowest_cost = float("inf")
lowest_cost_node = None
for node in costs: # 遍历所有节点
# print(node)
cost = costs[node]
if cost < lowest_cost and node not in processed: # 如果当前节点的开销更低且未处理过
lowest_cost = cost # 将其视为开销最低的节点
lowest_cost_node = node
return lowest_cost_node # 返回最低消耗节点
node = find_lowest_cost_node(costs) # 在未处理的节点中找开销最小的节点
print(node)
while node is not None: # 此while循环在所有节点被处理过结束
cost = costs[node]
neighbors = graph[node]
print('neighbors', neighbors)
for n in neighbors.keys():
new_cost = cost + neighbors[n]
if costs[n] > new_cost: # 如经过当前节点前往该邻居更近
costs[n] = new_cost # 更新该邻居开销
parents[n] = node # 同时将该邻居的节点设置为该节点
processed.append(node)
node = find_lowest_cost_node(costs) # 找出接下来要处理的节点,并循环
print(costs)
3.运行结果
可看出最低路程最短为6。
7.3 小结
广度优先搜索用于在非加权图中查找最短路径。
狄克斯特拉算法用于在加权图中查找最短路径。
仅当权重为正时狄克斯特拉算法才管用。
如果图中包含负权边,请使用贝尔曼福德算法。
八、贪婪算法
贪婪算法:每步都采取最优的做法。用专业术语来说,就是每步都选择局部最优解,最终得到就是全局最优解。
8.1 背包问题
假设有一个贪婪的小偷,背着可装35磅( 1磅≈ 0.45千克)重东西的背包,他力图王背包中装入加入价值最高的商品,应该采用哪种算法呢?
此时采用贪婪算法,就非常简单:
(1) 盗窃可装入背包的最贵商品。
(2) 再盗窃还可装入背包的最贵商品,以此类推。
但是在在以下情况,贪婪算法就可能不太好用了:
可以看出,贪婪算法显然不能获得最优解,但非常接近。
8.2 集合覆盖问题
**1.问题叙述:**假设你办了个广播节目,要让全美50个州的听众都收听得到。为此,你需要决定在哪些广播台播出。在每个广播台播出都需要支付费用,因此你力图在尽可能少的广播台播出。 每个广播台都覆盖特定的区域,不同广播台的覆盖区域可能重叠。
**2.近似算法:**下面使用贪婪算法获得近似解,
(1) 选出这样一个广播台,即它覆盖了最多的未覆盖州。即便这个广播台覆盖了一些已覆盖
的州,也没有关系。
(2) 重复第一步,直到覆盖了所有的州。
这是一种近似算法( approximation algorithm) 。在获得精确解需要的时间太长时,可使用近
似算法。判断近似算法优劣的标准如下:
速度有多快;
得到的近似解与最优解的接近程度。
3.代码仿真实现
# 贪婪算法的近似算法
# 创建一个列表,其中包含要覆盖的州
# 传入一个数组,它被转换为集合 集合不能包含重复的元素
states_needed = set(['mt', 'wa', 'or', 'id', 'nv', 'ut', 'ca', 'az'])
print(states_needed)
stations = {}
stations['kone'] = set(["id", "nv", "ut"])
stations["ktwo"] = set(["wa", "id", "mt"])
stations["kthree"] = set(["or", "nv", "ca"])
stations["kfour"] = set(["nv", "ut"])
stations["kfive"] = set(["ca", "az"])
# 使用集合来寻出最终选择的广播电台
final_stations = set()
# 不断循环,知道states_needed为空
while states_needed:
best_station = None
states_covered = set()
for station, states_for_station in stations.items():
covered = states_needed & states_for_station # 计算交集
if len(covered) > len(states_covered):
best_station = station # 将best_station设置为当前广播台。
states_covered = covered
final_stations.add(best_station) # best_station添加到最终的广播台列表中
states_needed -= states_covered # 更新states_needed,广播台覆盖了一些州,因此不用再覆盖这些州
print(final_stations)
4.运行结果
8.3 小结
贪婪算法寻找局部最优解,企图以这种方式获得全局最优解。
对于NP完全问题,还没有找到快速解决方案。
面临NP完全问题时,最佳的做法是使用近似算法。
贪婪算法易于实现、运行速度快,是不错的近似算法。
九、动态规划
9.1 背包问题
同第八章问题,此时小偷的背包可装4磅的东西,可盗窃的商品如下:
为了让盗窃的商品价值最高,你该选择哪些商品?
9.1.1 简单算法
最简单算法就是尝试各种可能组合,找出价值最高的组合,不过速度非常慢。在三种商品情况下,你需要计算8个不同的集合;有4件商品时,你需要计算16个集合。每增加一件商品,需要计算的集合数都将翻倍!这种算法的运行时间为O(2n) 。
9.1.2 动态规划
动态规划先解决子问题,在逐步解决大问题。每个动态规划算法都从一个网格开始,网格如下:
网格的各行为商品,各列为不同容量( 1~4磅)的背包。在每一行,可偷的商品都为当前行的商品以及之前各行的商品。
9.2 最长公共子串
1.启示:
动态规划可帮助你在给定约束条件下找到最优解。在背包问题中,你必须在背包容量给定的情况下,偷到价值最高的商品。
在问题可分解为彼此独立且离散的子问题时,就可使用动态规划来解决。要设计出动态规划解决方案可能很难,这正是本节要介绍的。下面是一些通用的小贴士。
每种动态规划解决方案都涉及网格。
单元格中的值通常就是你要优化的值。在前面的背包问题中,单元格的值为商品的价值。
每个单元格都是一个子问题,因此你应考虑如何将问题分成子问题,这有助于你找出网格的坐标轴。
2.计算两个英文单词的最长公共子串
9.3 小结
需要在给定约束条件下优化某种指标时,动态规划很有用。
问题可分解为离散子问题时,可使用动态规划来解决。
每种动态规划解决方案都涉及网格。
单元格中的值通常就是你要优化的值。
每个单元格都是一个子问题,因此你需要考虑如何将问题分解为子问题。
没有放之四海皆准的计算动态规划解决方案的公式。