《数据结构与算法之美》笔记
1. 冒泡排序(Bubble Sort)
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。
当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。如图所示:
Python 代码实现:
def bubble_sort(a):
length = len(a)
if length <= 1:
return a
for i in range(length-1):
swap = False
for j in range(length-1-i):
if a[j] > a[j+1]:
a[j], a[j+1] = a[j+1], a[j]
swap = True
if not swap:
break
return a
(1). 冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。
(2). 在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
(3). 最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)。
2. 插入排序(Insertion Sort)
首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
Python 代码实现:
def insertion_sort(a):
length = len(a)
if length <= 1:
return a
for i in range(1, length):
value = a[i]
for j in range(i-1, -1, -1):
if a[j] > value:
a[j], a[i] = a[i], a[j]
i -= 1
else:
break
return a
(1). 插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。
(2). 在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
(3). 如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)。
3. 选择排序(Selection Sort)
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
Python 代码实现:
def selection_sort(a):
length = len(a)
if length <= 1:
return a
for i in range(length-1):
min_index = i
for j in range(i+1, length):
if a[min_index] > a[j]:
min_index = j
a[i], a[min_index] = a[min_index], a[i]
return a
(1). 选择排序空间复杂度为 O(1),是一种原地排序算法。
(2). 选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n2)。
(3). 选择排序是一种不稳定的排序算法。
4. 归并排序(Merge Sort)
如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。如图所示:
Python 代码实现:
def merge(left, right):
result = []
while left and right:
if left[0] <= right[0]:
result.append(left.pop(0))
else:
result.append(right.pop(0))
result += left if left else right
return result
def merge_sort(a):
length = len(a)
if length <= 1:
return a
mid = length // 2
left = a[:mid]
right = a[mid:]
return merge(merge_sort(left), merge_sort(right))
(1). 归并排序稳不稳定关键要看 merge() 函数,也就是两个有序子数组合并成一个有序数组的那部分代码。在合并的过程中,如果 A[p...q]和 A[q+1...r]之间有值相同的元素,那我们可以先把 A[p...q]中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。
(2). 归并排序的时间复杂度是 O(nlogn)。
(3). 归并排序不是原地排序算法。这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
5. 快速排序(Quick Sort)
快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
方式一: 空间复杂度大,使用了两个列表解析式,而且每次选取进行比较时需要遍历整个序列。快排就不是原地排序算法了。
Python 代码实现:
def fake_quick_sort(a):
if len(a) <= 1:
return a
else:
pivot = a[0]
left = [x for x in a[1:] if x <= pivot]
right = [x for x in a[1:] if x > pivot]
return fake_quick_sort(left) + [pivot] + fake_quick_sort(right)
方式二:原地排序,不稳定的排序算法
Python 代码实现:
def quick_sort(array, l, r):
if l < r:
q = partition(array, l, r)
quick_sort(array, l, q - 1)
quick_sort(array, q + 1, r)
return array
def partition(array, left_ind, right_ind):
pivot = array[right_ind]
i = left_ind
for j in range(left_ind, right_ind+1):
if array[j] < pivot:
array[i], array[j] = array[j], array[i]
i += 1
array[i], array[j] = array[j], array[i]
return i
print(quick_sort([2,5,3,6,6,6], 0, 5))
(1). 快排的时间复杂度也是 O(nlogn)。
6. 桶排序(Bucket Sort)
核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
7. 计数排序(Counting Sort)
计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
8. 拓扑排序(Topological Sort)
我们知道,一个完整的项目往往会包含很多代码源文件。编译器在编译整个项目的时候,需要按照依赖关系,依次编译每个源文件。比如,A.cpp 依赖 B.cpp,那在编译的时候,编译器需要先编译 B.cpp,才能编译 A.cpp。
我们可以把源文件与源文件之间的依赖关系,抽象成一个有向图。每个源文件对应图中的一个顶点,源文件之间的依赖关系就是顶点之间的边。
Python 代码实现:
from collections import deque
from itertools import filterfalse
class Graph:
def __init__(self, num_vertices: int):
self._num_vertices = num_vertices
self._adjacency = [[] for _ in range(num_vertices)]
def add_edge(self, s: int, t: int) -> None:
self._adjacency[s].append(t)
def tsort_by_kahn(self):
"""如果 s 需要先于 t 执行,那就添加一条 s 指向 t 的边
找出一个入度为 0 的顶点,将其输出到拓扑排序的结果序列中(对应代码中就是把它打印出来),
并且把这个顶点从图中删除(也就是把这个顶点可达的顶点的入度都减 1)。
我们循环执行上面的过程,直到所有的顶点都被输出。
最后输出的序列,就是满足局部依赖关系的拓扑排序。
"""
in_degree = [0] * self._num_vertices #计算入度
for v in range(self._num_vertices):
if len(self._adjacency[v]):
for neighbour in self._adjacency[v]:
in_degree[neighbour] += 1
q = deque(filterfalse(lambda x: in_degree[x], range(self._num_vertices))) #入度为0的点的队列
while q:
v = q.popleft() #取出入度为0的点
print(f"{v} -> ", end="")
for neighbour in self._adjacency[v]:
in_degree[neighbour] -= 1 #与其相连接的点入度减1
if not in_degree[neighbour]: #如果入度为0,加入队列
q.append(neighbour)
print("\b\b\b ")
def tsort_by_dfs(self):
"""(1). 通过邻接表构造逆邻接表。
邻接表中,边 s->t 表示 s 先于 t 执行,也就是 t 要依赖 s。
在逆邻接表中,边 s->t 表示 s 依赖于 t,s 后于 t 执行。
(2). 递归处理每个顶点。对于顶点 vertex 来说,
我们先输出它可达的所有顶点,也就是说,先把它依赖的所有的顶点输出了,然后再输出自己。
"""
inverse_adjacency = [[] for _ in range(self._num_vertices)]
for v in range(self._num_vertices):
if len(self._adjacency[v]):
for neighbour in self._adjacency[v]:
inverse_adjacency[neighbour].append(v)
visited = [False] * self._num_vertices
def dfs(v):
if len(inverse_adjacency[v]):
for neighbour in inverse_adjacency[v]:
if not visited[neighbour]:
visited[neighbour] = True
dfs(neighbour)
print(f"{v} -> ", end="")
for v in range(self._num_vertices):
if not visited[v]:
visited[v] = True
dfs(v)
print("\b\b\b ")
if __name__ == "__main__":
dag = Graph(4)
dag.add_edge(1, 0)
dag.add_edge(2, 1)
dag.add_edge(1, 3)
dag.tsort_by_kahn()
dag.tsort_by_dfs()