Introduction to Algorithms¶
Foundation¶
Getting Started¶
这一章初识算法,讲了
- 插入排序 aka. insertion sort
- 证明算法正确性的方法
- 怎样分析算法的空间代价、时间代价
- 合并排序 aka. merge sort
- 分治法 aka. divide and conquer
我觉得重点在于如何从现实中人遇到这种问题的思路出发、一步一步导出方便电脑处理的严格步骤。
Insertion sort¶
最容易理解的排序方法,也是现实中我们打牌的时候用的方法。想象桌上有一堆牌,正面朝下。右手从牌堆里拿牌,左手拿着已经排好序的牌。新牌插入到左手拿着的牌里。
每当来一张新牌,我们
- 把它放在左手牌堆的最右边
- 让它和左边紧邻的牌比较
- 如果左边没牌了(可能是新牌是第一张牌、或者新牌比左手的所有牌都小),新牌排序完成,右手去拿下一张牌;
- 如果左边牌比它大,新牌左移一格;重复让它和左边紧邻的牌比较;
- 如果左边牌比它小或者相等,新牌不动;新牌排序完成,右手去拿下一张牌;
class Solution:
def insertionSort(self, l):
sortedList = [] # 左手
for item in l: # 右手从牌堆里一张一张拿牌
itemIndex = len(sortedList) # 新牌一开始放在左手牌堆的最右边
for index in range(len(sortedList) - 1, -1, -1): # 从右往左一张一张比较。注意这里隐含了左边没牌的情况
if sortedList[index] > item: # 左边牌比新牌大
itemIndex = itemIndex - 1 # 新牌左移一格
else: # 左边牌比新牌小或者一样大
break # 新牌不动
sortedList.insert(itemIndex, item) # 插入新牌
return sortedList
Solution().insertionSort([1, 3, 2, 5, -1])
[-1, 1, 2, 3, 5]
证明算法正确性的方法¶
类似数学归纳法。首先回顾一下数学归纳法
- 证明初始条件下命题正确,即证明$P(0)$正确;
- 假设第n步条件下命题正确,即假设$P(n)$正确;
- 用初始条件、第n步条件,证明第n+1步命题正确,用$P(0), P(n)$证明$P(n + 1)$正确;
Mathematical induction proves that we can climb as high as we like on a ladder, by proving that we can climb onto the bottom rung (the basis) and that from each rung we can climb up to the next one (the step).
Concrete Mathematics, page 3 margins.
举个例子。如果要证明$\forall n \in N, 1 + 2 + \ldots + n = {n (n + 1) \over 2}$,可以这样做
- 当$n = 0$时,$1 + 2 + \ldots + n = 0 = {0 (0 + 1) \over 2}$;
- 假设$n = k$时,$1 + 2 + \ldots + k = {k (k + 1) \over 2}$;
- 当$n = k + 1$时,$\underbrace{1 + 2 + \ldots + k}_{第2步} + (k + 1) = \underbrace{k (k + 1) \over 2}_{第2步} + (k + 1) = {(k + 1) (k + 2) \over 2}$
分析算法的代价¶
分治法¶
分治法是一种非常有效的思路。步骤是
- 大问题切分成非常容易解决的小问题、原子问题
- 解决原子问题
- 两两合并原子问题的结果
分治法把思考问题的重点转移到了思考怎样把2个处理完成的集合快速合并成1个大的处理完成的集合。
Merge sort¶
Merge sort的本质就是分治法。首先
- 把n张牌的牌堆分成n堆。这样每堆都是1张牌
- 两两合并
- 两两合并
- ...
- 直到合并成一堆
那么该如何合并两堆已经各自排好序的牌堆呢?很简单,右手边放两个各自排好序的牌堆。永远只看两个牌堆最左端的牌。哪张牌小就把哪张抽出来放到左手牌堆的最右边。写成步骤
- 右手边放好各自排好序的牌堆1、牌堆2
- 比较牌堆1和牌堆2各自最左边的牌(最小的牌)
- 如果牌堆1没牌了,直接把牌堆2整个放到左手边牌堆的最右边
- 如果牌堆2没牌了,直接把牌堆1整个放到左手边牌堆的最右边
- 如果牌堆1最左边的牌大于牌堆2最左边的牌,把牌堆2最左边的牌抽出来,放到左手边牌堆的最右边
- 如果牌堆1最左边的牌小于或等于牌堆2最左边的牌,把牌堆1最左边的牌抽出来,放到左手边牌堆的最右边
重复第2步。
值得一提的是n可能不是偶数,所以这时最后一堆只能假装已经合并了,先放着。下次总有机会会和前一堆合并。
n个结果合并多少次才能变成1个结果?
- 1个结果合并0次
- 2个结果合并1次
- 3个结果合并2次
- 4个结果合并2次
- 5个结果合并4次
- 6个结果合并5次
- ...
- n个结果?
class Solution:
def mergeSort(self, l):
def _merge(l1, l2): # 定义一个合并函数,输入两个各自排好序的列表,输出它们合并后的、排好序的列表
l = []
while (l1 != []) and (l2 != []): # 牌堆1和牌堆2都有牌
if l1[0] > l2[0]: # 当牌堆1最左边的牌大于牌堆2最左边的牌
l.append(l2.pop(0)) # 抽出牌堆2最左边的牌,放到左手边牌堆的最右端
else:
l.append(l1.pop(0)) # 抽出牌堆1最左边的牌,放到左手边牌堆的最右端
if l1 == []: # 牌堆1没牌了
l.extend(l2) # 把牌堆2的所有牌整个原封不动放到左边牌堆的最右端
else: # 牌堆2没牌了
l.extend(l1) # 把牌堆1的所有牌整个原封不动放到左边牌堆的最右端
return l
sortedPiles = [[i] for i in l] # 把所有n张牌分成n堆,每堆一张牌
while len(sortedPiles) != 1: # 桌上没有形成完整的1个牌堆
# print(sortedPiles)
if len(sortedPiles) % 2 == 0: # 桌上牌堆的数量是偶数,可以两两合并
sortedPiles = [_merge(sortedPiles[2 * i], sortedPiles[2 * i + 1]) for i in range(0, len(sortedPiles) // 2)]
else: # 桌上牌堆的数量是奇数,前面的可以两两合并;最后一堆只能放着,下一次再排
sortedPiles = [_merge(sortedPiles[2 * i], sortedPiles[2 * i + 1]) for i in range(0, len(sortedPiles) // 2)] + sortedPiles[-1:]
return sortedPiles[0]
Solution().mergeSort([1, 3, 2, 5, -1])
[-1, 1, 2, 3, 5]
Growth of Functions¶
这一章主要是数学。涉及了同阶无穷大的概念。但是书里的$O(g(x))$指的是上界,好像和微积分里用的$O(g(x))$之类的用法不一样。我觉得不是很有必要。毕竟优化算法的目的是控制输入规模增长时、所花的代价(时间、空间)的增长,即研究的是$n \to +\infty$时的问题。
书里的$o(g(x))$指的是低阶无穷大。意思是比$g(x)$更快接近无穷大的函数集。
顺便回忆一下微积分里的定义。
粗略来看,算法时间复杂度其实很容易看(猜)出来
算法里只有1层for循环
- 如果for循环次数与输入规模n无关,那么时间复杂度是$O(1)$
- 如果for循环次数与输入规模n正比,那么时间复杂度是$O(n)$
算法里有2层for循环,并且第一重for循环与n正比
- 如果第二重for循环次数与上一层循环的index无关,那么时间复杂度是$O(n)$
- 如果第二重for循环次数与上一层循环的index正比,那么时间复杂度是$O(n^2)$
算法里有明显的分治法结构,时间复杂度往往是$O(n \ln n)$
肯定是有反例存在的。比如最后一条,对于矩阵乘法,简单分治法的时间复杂度和naive强算时间复杂度一样。
Divide and Conquer¶
这一章重点讲了分治法。
- 最大子序列问题 aka. maximum subarray problem
- 矩阵乘法 aka. Strassen's algorithm for matrix multiplication
Maximum subarray problem¶
我觉得这个问题不太算分治法的典型代表。
Strassen's algorithm for matrix multiplication¶
我觉得这个问题是比较典型的分治法。
先来思考最普通的矩阵乘法的步骤,也是我们手算矩阵乘法的方法。假设A、B都是$n \times n$的方形矩阵 $$ C = A \times B \implies C_{i, j} = \sum_{k = 1}^{n} A_{i, k} B_{k, j} $$ 也就是
- A的第1行和B的第1列点积,变成C的第1行、第1列的元素
- A的第1行和B的第2列点积,变成C的第1行、第2列的元素
- ...
- A的第1行和B的第n列点积,变成C的第1行、第n列的元素
- A的第2行和B的第1列点积,变成C的第2行、第1列的元素
- ...
- A的第k行和B的第l列点积,变成C的第k行、第l列的元素
- ...
这种方法的时间复杂度很容易看出来。对于A的每一行,它都要依次和B的每一列点积,才能生成出C中的一行元素。一次点积的时间复杂度,加法要$n - 1$次、乘法要$n$次,所以是$O(n)$。那么每生成C中的一行元素就要算$n$次点积,那么时间复杂度是$O(n^2)$。而总共有n行,所以时间复杂度是$O(n^3)$。
- 算A一行B一列的点积,时间复杂度是$O(n)$
- A有n行、B有n列,所以总共需要算$n \times n$次点积,时间复杂度是$n^2 O(n) = O(n^3)$
那么如果用简单分治法,时间复杂度是多少?假如分成四个小矩阵,每个小矩阵是$n / 2 \times n / 2$ $$ \left(\begin{matrix} C_{1, 1} & C_{1, 2} \\ C_{2, 1} & C_{2, 2} \\ \end{matrix}\right) = \left(\begin{matrix} A_{1, 1} & A_{1, 2} \\ A_{2, 1} & A_{2, 2} \\ \end{matrix}\right) \times \left(\begin{matrix} B_{1, 1} & B_{1, 2} \\ B_{2, 1} & B_{2, 2} \\ \end{matrix}\right) $$ 这样就可以假装是2x2的矩阵,表面上看只要点积4次,但是由于每次点积的元素不是数字,而是小矩阵,所以每次点积的复杂度不再是$O(n)$了,而是$O(n^3)$。
所以粗略来看,其实时间复杂度还是$O(n^3)$,没有变化。
递归时间复杂度怎么算¶
形如 $$ \left\{\begin{aligned} T(n) &= a T\left(\left\lfloor{n \over b}\right\rfloor\right) + f(n) \\ T(1) &= C \end{aligned}\right. $$ 我觉得可以这样算。首先做变量替换,令$n = b^m$,把那个烦人的floor记号去掉。这样 $$ \begin{aligned} T(b^m) &= a T(b^{m - 1}) + f(b^m) \\ &= a \left[a T(b^{m - 2}) + f(b^{m - 1})\right] + f(2^m) \\ &\vdots \\ &= a^m T(b^0) + \sum_{k = 0}^m a^k f(b^{m - k}) \\ &= a^m T(1) + \sum_{k = 0}^{m - 1} a^k f(b^{m - k}) \end{aligned} $$ 再变回来,令$m = \log_b n$ $$
The hiring problem¶
问题是这样的。你要雇一个秘书。但是这个人才市场很坑,每次只给你推荐一个面试者,而且要求你面试完一个就要决定是否雇她,否则不给你推荐下一个。面试一个秘书要花$c_i$,决定雇一个秘书要花$c_h$。如果这个秘书比你上次雇的秘书好,那么你就要把上次雇佣的秘书辞掉,雇佣这个新的秘书。
这样就会有一个问题。如果总共有n个面试者,它们的水平按面试顺序排一个比一个好,那么你就会
- 面试了第1个秘书,雇了第1个秘书;
- 面试了第2个秘书,发现第2个秘书比第1个好,于是辞退了第1个秘书,雇佣第2个秘书;
- 面试了第3个秘书,发现第3个秘书比第2个好,于是辞退了第2个秘书,雇佣第3个秘书
- ...
- 面试了第n个秘书,发现第n个秘书比第n-1个好,于是辞退了第n-1个秘书,雇佣了第n个秘书
终于你精疲力尽,花了$n c_i + n c_h$终于雇到了最好的秘书。
可是如果这n个面试者的水平按面试顺序排一个比一个差,那么你第一次就能雇到最好的秘书
- 面试了第1个秘书,雇了第1个秘书;
- 面试了第2个秘书,发现没有第1个秘书好,保留第1个秘书;
- 面试了第3个秘书,发现没有第1个秘书好,保留第1个秘书;
- ...
- 面试了第n个秘书,发现没有第1个秘书好,保留第1个秘书
这样你只是精疲力尽,钱包没瘪,花了$n c_i + c_h$就雇到了最好的秘书。
现实中,面试者的水平按面试顺序的排序是随机的,这时候就存在一个花费的期望值。
Sorting and Order Statistics¶
从这个part开始重点讲各种排序算法。在part1讲到的两种排序算法各有优劣。
Insertion sort
优点
- 原地排序。不需要额外空间
缺点
- 时间复杂度$O(n^2)$
Merge sort
优点
- 时间复杂度$O(n \ln n)$,是基于比较的排序算法时间复杂度的下界
缺点
- 不是原地排序。需要额外空间
这个part讲了
- 堆排序 aka. heapsort
- 快速排序 aka. quick sort
Heapsort¶
在学会堆排序之前,需要了解它依赖的一种数据结构,也就是堆。
堆是一种二叉树。但是它需要满足每个节点都大于等于它的两个子节点才能成为大堆,或者满足每个节点都小于等于它的两个子节点才能成为小堆。
先不考虑如何把任何一个数列变成大堆或者小堆,先假设我们已经有个函数能把任意数列变成堆了。我们先考虑,如果手头上有个堆,以大堆为例,怎样把这个堆变成排好序的数列。
根据刚才大堆的性质,我们发现,根节点是堆里最大的数。所以我们可以
- 把根节点拿出来
这样我们直接就找到了数组中最大的数。可是拿出来了之后根节点就空了,怎么办?
- 把尾节点里的数拿出来,放到根节点上
这样根节点就不是空的了。可是根节点被替换了,这个还满足大堆的定义吗?
- 从根节点开始,做一次堆维护
根节点被替换之后,很可能整个二叉树已经不是大堆了。所以要做一次操作,让二叉树再变成大堆。
但是我们发现,这个二叉树虽然不是大堆,但是显然和乱排的二叉树不一样:以这个二叉树的根节点的两个子节点为根节点的子二叉树,是大堆。
- 重复第1步,直到整个大堆被啃完
把乱序数组变成堆¶
堆维护¶
Quicksort¶
快速排序使用了分治法的思想。但是其实主要问题在分,不存在合并的问题,因为子问题全部解决了之后,整个数组已经排序完成了。
简单步骤
从数组中选定一个基准数,通常选定数组的最后一个元素;
从数组的第一个数开始到倒数第二个数,一个一个和基准数比较
- 如果比基准数大,放到基准数的右边
- 如果比基准数小、或者相等,放到基准数的左边
至此,可以保证基准数左边所有的数都小于等于基准数,基准数右边所有的数都大于基准数。基准数的绝对位置已经决定了。下面只要保证左右两边分别是排好序的就可以了。
分别对基准数左边、基准数右边两边的子数组重复第1步
分治法的标准结局。
class Solution:
def quickSort(self, l):
if len(l) in (0, 1): # 如果传进来的数组是空的(没有数比基准数大),或者只有一个
return l # 那就不用排序
elif len(l) == 2: # 如果有两个
if l[0] <= l[1]: # 并且如果已经排好序了
return l # 也不用排序
else: # 但是如果倒序
return [l[1], l[0]] # 就要排好再返回
temp = [l[-1]] # 如果数组长度大于等于3
pos = 0 # 这个变量用于记录基准数的index
for i in l[0: -1]: # 逐个比较
if i <= l[-1]: # 遇到比基准数小的或相等的
temp.insert(0, i) # 就放到前面
pos += 1 # 基准数的index增加1
else: # 遇到比基准数大的
temp.append(i) # 放到基准数后面,同时基准数index不用增加
temp[0: pos] = self.quickSort(temp[0: pos]) # 把基准数前面的子数组排一遍
temp[pos + 1:] = self.quickSort(temp[pos + 1:]) # 把基准数后面的子数组排一遍
return temp
Solution().quickSort([1, 3, 2, 5])
[1, 2, 3, 5]
我其实很不想写递归的,因为python限制最大递归深度(其实linux内核也限制栈大小),所以显然语言、OS设计者不鼓励用递归。但是我也一时想不起来怎么写成迭代。等我想到了再写吧。
写这个例子的时候,遇到了python的很多以前没见过的坑。比如
def f(l):
l.reverse()
a = [1, 2, 3, 4]
f(a)
print(a) # 当然是[4, 3, 2, 1]
f(a[0: 2])
print(a) # 你可能会期待看到[3, 4, 2, 1]
f(a[:])
print(a) # 你可能会期待看到[1, 2, 4, 3]
[4, 3, 2, 1] [4, 3, 2, 1] [4, 3, 2, 1]
但是其实不能算坑,是自己对python的细节还不够了解。上面的例子说明list切片操作是创建一个新的数组,而不是创建原来的数组的一个view。
Sorting in Linear Time¶
这一章介绍了几个不是基于比较的排序算法
- 计数排序 aka. counting sort
- 桶排序 aka. bucket sort
到这里位置介绍的所有排序算法,都是基于比较的,可以粗略地理解成,元素的先后顺序的信息都是用多次比较来获得的。
一切基于比较的排序算法,时间复杂度下界都是$O(n \ln n)$。
Counting sort¶
非常好理解的一种算法。简而言之就是拿到原始数列,先做一次直方图统计,统计每一个整数出现的次数,然后再按直方图的横坐标,一个一个往外吐,吐的次数是当前这个数字出现的次数,这样就变成了排好序的数组。
举个例子,比如2, 1, 3, 0, 4, 1
,先做直方图,横轴是数字,纵轴是数字出现的次数
n: #
----
0: 1
1: 2
2: 1
3: 1
4: 1
然后按横轴顺序往外吐数字。
0
出现了1次,所以吐1个0
1
出现了2次,所以连续吐2个1
2
出现了1次,所以吐1个2
- ...
得到了0, 1, 1, 2, 3, 4
,直接就排好序了。
class Solution:
def countingSort(self, l):
if not l:
return l
counter = {} # 直方图,也可以叫计频器
minimum = l[0] # 最小的数字
maximum = l[0] # 最大的数字
for value in l: # 一遍迭代就可以得到直方图、最大数、最小数
if value < minimum:
minimum = value
if value > maximum:
maximum = value # 一边遍历一边得到最小最大值,可以省时间
counter[value] = counter.get(value, 0) + 1 # 直方图生成中
# 该吐数据了
sortedList = []
for value in range(minimum, maximum + 1): # 从最小值到最大值
for times in range(counter.get(value, 0)): # 吐数字,它出现几次就吐几个
sortedList.append(value)
return sortedList
Solution().countingSort([2, 5, 3, 0, 2, 3, 0, 3])
[0, 0, 2, 2, 3, 3, 3, 5]