十大经典排序算法动画与解析,看我就够了!(配代码完全版)
二分查找是一种算法,其输入是一个有序的元素列表(必须有序的原因稍后解释)。如果要查找的元素包含在列表中,二分查找返回其位置;否则返回 null
,使用二分查找
时,每次猜测的是中间的数字,从而将余下的数字排除一半。(仅仅当列表是有序的时候,二分查找才管用)
一般而言,对于包含n个元素的列表查找某个元素,使用二分法最多需要 l o g 2 n log_{2}n log2n 步(**时间复杂度为 l o g 2 n log_{2}n log2n **),简单查找最多需要 n
步。大 O 表示法指出了算法最糟糕情况下的运行时间。二分法实例代码如下:
def binary_search(list, item):
low = 0
high = len(list)-1
while low <= high:
mid = (low + high) // 2
if list[mid] == item:
return mid
elif list[mid] > item:
high = mid - 1
else:
low = mid + 1
return None
if __name__ == "__main__":
print(binary_search([1,2,3,4,6,7], 3)) # 输出 2
有序数组中的目标出现多次,利用二分查找返回在最左边出现的目标值或者是最右边出现的目标值,实例代码如下:
def binary_search2(arr, target, flag="left"):
if not arr:
return None
left = 0
right = len(arr) - 1
while left <= right:
mid = left + (right - left) // 2 # 防止数据过大溢出?
if arr[mid] < target:
left = mid + 1
elif arr[mid] > target:
right = mid -1
else:
if flag == "left":
if mid > 0 and arr[mid-1] == target:
right = mid -1 # 不断向最左边逼近
else:
return mid
elif flag == "right":
if mid + 1 < len(arr) and arr[mid + 1] == target:
left = mid + 1 # 不断向最右边逼近
else:
return mid
return None
if __name__ == "__main__":
print(binary_search2([1,1,1,3,3,3,4], 3, "left")) # 查找最左边出现的目标值, 输出3
print(binary_search2([1,1,1,3,3,3,4,4,4], 3, "right")) # 查找最右边出现的目标值, 输出5
在计算机中,存储多项数据时,有两种基本方式-数组和链表。但它们并非适用于所有情形。
链表中的元素可存储在内存的任何地方。
链表的每个元素都存储了下一个元素的地址,从而使一系列随机的内存地址串在一起。链表结构直观显示如下图所示:
链表的优势在插入元素方面,那数组的优势又是什么呢?
需要随机地读取元素时,数组的效率很高,因为可迅速找到数组的任何元素。在链表中,元素并非靠在一起的,你无法迅速计算出第五个元素的内存 地址,而必须先访问第一个元素以获取第二个元素的地址,再访问第二个元素以获取第三个元素 的地址,以此类推,直到访问第五个元素。
数组的元素带编号,编号从 0 而不是 1 开始,几乎所有的编程语言都从0开始对数组元素进行编号,比如 C/C++ 的数组结构和 Python 的列表结构。
元素的位置称为索引。下面是常见数组和链表操作的运行时间.
选择排序时间复杂度 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_index = i
smallest = arr[i]
return smallest
# 选择排序法对数组进行排序
def selectionSort(arr):
newArr = []
for i in range(len(arr)):
smallest = findSmallest(arr)
arr.remove(smallest)
newArr.append(smallest)
return newArr
# 实例应用
print(selectionSort([5, 3, 6, 100])) # [3, 5, 6, 100]
学习如何将问题分成基线条件和递归条件,学习如何使用递归算法,递归算法直观上更好理解,步骤简单。
编写递归函数时,必须告诉它何时停止,因此,每个递归函数有两个部分:基线条件(base case)和递归条件(recursive case)。递归条件指的是函数调用自己,而基线条件则 指的是函数不再调用自己,从而避免形成无限循环。
栈的定义:栈是一种只能从表的一端存取数据且遵循 “先进后出” 原则的线性存储结构。
调用栈(call stack)
计算机在内部使用被称为调用栈的栈。调用另一个函数时,当前函数暂停 并处于未完成状态。该函数的所有变量的值都还在内存中。栈顶的方框指出了当前执行 到了什么地方。
栈在递归中扮演着重要角色。使用栈虽然很方便,但是也要付出代价:存储详尽的信息可能占用大量的内存。每个函数调 用都要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息。在这种情况 下,你有两种选择。
快速排序使用分而治之的策略,分而治之是我们学习的第一种通用的问题解决办法。
分而治之(divide and conquer,D&C)-一种著名的递归式问题解决办法。
D&C算法是递归的。使用D&C解决问题的过程包括两个步骤:
D&C并非可直接用于解决问题的算法,而是一种解决问题的思路。
C语言标准库中的函数qsort实现的就是快速排序。快速排序也是用了D&C思想。
对数组进行快速排序,步骤如下:
在平均情况下,快速排序时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn)。快速排序代码如下:
def quicksort(array):
if len(array) < 2:
# 基线条件:为空或只包含一个元素的数组是“有序”的
return array
else:
# 递归条件
pivot = array[0]
less = [x for x in array[1:] if x <= pivot]
greater = [x for x in array[1:] if x > pivot]
return quicksort(less) + [pivot] + quicksort(greater)
print(quicksort([4, 90, 0, 2, 17, 79, 12])) # [0, 2, 4, 12, 17, 79, 90]
上面的代码空间复杂度很大,真正的快排是原地排序,空间复杂度为O(1),代码如下:
# _*_ coding:utf-8 _*_
def quick_sort(L):
return q_sort(L, 0, len(L)-1)
def q_sort(L, left, right):
if left < right:
pivot = Partition(L, left, right)
q_sort(L, left, pivot-1)
q_sort(L, pivot+1, right)
return L
def Partition(L, left, right):
pivotkey = L[left]
while left < right:
while left < right and L[right] >= pivotkey:
right -= 1
L[left] = L[right]
while left < right and L[left] <= pivotkey:
left += 1
L[right] = L[left] # 遇到比基准大的数, 则覆盖在之前尾部指针的位置
L[left] = pivotkey
return left
if __name__ == "__main__":
L = [5, 9, 1, 1, 11, 6, 7, 2, 4]
print(quick_sort(L))
快速排序的独特之处在于,其速度取决于选择的基准值。在讨论快速排序的运行时间前,我 们再来看看最常见的大O运行时间。
由对数的换底公式, l o g a n log_a n logan 和 l o g b n log_b n logbn 只有一个常数因子不同,这个因子在大O记法中被丢弃。因此记作 O ( l o g n ) O(log n) O(logn),而不论对数的底是多少,是对数时间算法的标准记法。
数组和链表结构可以用以查找,栈不行。散列表也叫哈希表(Hash table),散列表有些类似 Python 中的字典 dict
结构。散列表可用以:
给两个键分配的位置相同,这被称为冲突(collision)。处理冲突最简单的办法就是:如果两个键映射到了同一个位置,就在这个位置存储一个链表。
散列表,数组,链表的查找、插入删除元素的时间复杂度,如下表所示:
在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速 度与链表一样快,因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。 因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:
散列表是一种功能强大的数据结构,其操作速度快,还能让你以不同的方式建立数据模型。 你可能很快会发现自己经常在使用它。
图算法:广度优先搜索(breadth-first search, BFS)算法
广度优先搜索让你能够找出两样东西之间的最短距离,不过最短距离的含义有很多!使用广度优先搜索可以:
解决最短路径问题的算法被称为广度有限算法,一般步骤为:
图由节点(node)和边(edge)组成。
在广度优先搜索的执行过程中,搜索范围从起点开始逐渐向外延伸,即先检查一度关系,再检查二度关系。
图中每个节点都与相邻节点相连,散列表结构可以表示这种关系。
图分为有向图(directed graph)和无向图(undirected graph),有向图关系是单向的,无向图没有箭头,直接相连的节点互为邻居。对从自己出发有指向他人的箭头,则有邻居。
如果你在你的整个人际关系网中搜索芒果销售商,就意味着你将沿每条边前行(记住,边是从一个人到另一个人的箭头或连接),因此运行时间至少为 O ( 边 数 ) O(边数) O(边数)。你还使用了一个队列,其中包含要检查的每个人。将一个人添加到队列需要的时间是固定的,即为 O ( 1 ) O(1) O(1),因此对每个人都这样做需要的总时间为 O ( 人 数 ) O(人数) O(人数)。所以,广度优先搜索的运行时间为 O ( 人 数 + 边 数 ) O(人数 + 边数) O(人数+边数),这通常写作 O ( V + E ) O(V + E) O(V+E),其中 V V V 为顶点( vertice)数, E E E 为边数。
迪克斯特拉算法能够找出加权图中前往X的最短路径。对于寻找耗时最少的路径,迪克斯特拉算法包含4个步骤:
每个节点都有开销。开销指的是从起点前往该节点需要多长时间。
图可能有环,所谓环,是指由一个节点出发,走一圈后可以又回到原节点,如下图所示:
因此, 不能将狄克斯特拉算法用于包含负权边的图。在包含负权边的图中,要找出最短路径,可使用另一种算法——贝尔曼福德算法( Bellman-Ford algorithm)。
# 为了实现带权图,可使用散列表,散列表用Python字典实现
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'] = {}
# 创建存储每个节点开销的开销表
infinity = float("inf")
costs = {}
costs["a"] = 6
costs["b"] = 2
costs["fin"] = infinity
# 创建存储父节点的散列表
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:
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)
while node is not None:
cost = costs[node]
neighbors = graph[node]
# 遍历当前节点的邻居
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("Cost from the start to each node:")
print(costs)
贪婪算法思想很简单:每步都采取最优的做法,专业术语说,就是每步都选择局部最优解,最终得到的就是全局最优解。
根据给定课表,尽可能将更多的课程安排在某间教室。解决办法:贪婪算法可找到最优解。
背包重量有限,根据策略使得装入背包的物品价值最高。
在这里, 贪婪策略显然不能获得最优解,但非常接近。在有些情况下,完美是优秀的敌人。有时候,你只需找到一个能够大致解决问题的算法,此时贪婪算法正好可派上用场,因为它们实现起来很容易,得到的结果又与正确结果相当接近。
每个广播台都覆盖特定区域的州,找出覆盖全美50个州的最小广播集合。
贪婪算法解决这个问题,当广播台数量过多,算法所耗费的时间将激增。
集合覆盖问题举例:每个广播台都覆盖特定区域的州,找出覆盖全美50个州的最小广播集合。贪婪算法可以解决这个问题,当广播台数量过多,算法所耗费的时间将激增。
这是一种近似算法( approximation algorithm) 。在获得精确解需要的时间太长时,可使用近似算法。判断近似算法优劣的标准如下:
代码实例:
"""
准备工作
"""
# 创建一个列表,包含要覆盖的州
states_needed = set(["mt", "wa", "or", "id", "nv", "ut", "ca", "az"])
# 广播台清单
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()
"""
计算答案
"""
best_station = None
while states_needed:
best_station = None
states_covered = set()
for station, states in stations.items():
covered = states_needed & states
if len(covered) > len(states_covered):
best_station = station
states_covered = covered
states_needed -= states_covered
final_stations.add(best_station)
print(final_stations)
程序输出如下:
{‘kone’, ‘ktwo’, ‘kthree’, ‘kfive’}
贪心算法的实质是每次选出当前的最优解,不管整体,是基于一定假设下的最优解。
旅行商问题和集合覆盖问题有一些共同之处:你需要计算所有的解,并从中选出最小/最短的那个。这两个问题都属于NP完全问题。NP完全问题的简单定义是,以难解著称的问题,如旅行商问题和集合覆盖问题。很多非常聪明的人都认为,根本不可能编写出可快速解决这些问题的算法。
NP
完全问题无处不在!如果能够判断出要解决的问题属于 NP
完全问题就好了,这样就不用 去寻找完美的解决方案,而是使用近似算法即可。但要判断问题是不是NP完全问题很难,易于解决的问题和 NP
完全问题的差别通常很小。
但如果要找出经由指定几个点的的最短路径,就是旅行商问题——NP完全问题
。简言之,没办法判断问题是不是 NP
完全问题,但还是有一些蛛丝马迹可循的。
动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。在学习动态规划之前需要明确掌握几个重要概念,如下:
学习动态规划,这是一种解决棘手问题的方法,它将问题分成小问题,并先着手解决这些小问题,每个动态规划问题都是从一个网格入手,背包问题的网格如下:
工作原理:动态规划先解决子问题,再逐步解决大问题
。从背包问题的网格计算入手,可明白为何计算小背包可装入的商品的最大价值。余下了空间时,你可根据这些子问题的答案来确定余下的空间可装入哪些商品。计算每个单元格的价值时,使用的公式都相同。 这个公式如下:
网格的行顺序发生变化时,最终答案没有变化。各行的排列顺序对最终结果无关紧要。
动态规划功能强大,它能够解决子问题并使用这些答案来解决大问题。 但仅当每个子问题都是离散的,即不依赖于其他子问题时,动态规划才管用。 这意味着使用动态规划算 法解决不了去巴黎玩的问题。
通过动态规划问题,得到以下启示: