Introduction to Algorithms¶

Foundation¶

Getting Started¶

这一章初识算法,讲了

  • 插入排序 aka. insertion sort
  • 证明算法正确性的方法
  • 怎样分析算法的空间代价、时间代价
  • 合并排序 aka. merge sort
  • 分治法 aka. divide and conquer

我觉得重点在于如何从现实中人遇到这种问题的思路出发、一步一步导出方便电脑处理的严格步骤。

Insertion sort¶

最容易理解的排序方法,也是现实中我们打牌的时候用的方法。想象桌上有一堆牌,正面朝下。右手从牌堆里拿牌,左手拿着已经排好序的牌。新牌插入到左手拿着的牌里。

每当来一张新牌,我们

  1. 把它放在左手牌堆的最右边
  2. 让它和左边紧邻的牌比较
  • 如果左边没牌了(可能是新牌是第一张牌、或者新牌比左手的所有牌都小),新牌排序完成,右手去拿下一张牌;
  • 如果左边牌比它大,新牌左移一格;重复让它和左边紧邻的牌比较;
  • 如果左边牌比它小或者相等,新牌不动;新牌排序完成,右手去拿下一张牌;
In [1]:
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
In [2]:
Solution().insertionSort([1, 3, 2, 5, -1])
Out[2]:
[-1, 1, 2, 3, 5]

证明算法正确性的方法¶

类似数学归纳法。首先回顾一下数学归纳法

  1. 证明初始条件下命题正确,即证明$P(0)$正确;
  2. 假设第n步条件下命题正确,即假设$P(n)$正确;
  3. 用初始条件、第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}$,可以这样做

  1. 当$n = 0$时,$1 + 2 + \ldots + n = 0 = {0 (0 + 1) \over 2}$;
  2. 假设$n = k$时,$1 + 2 + \ldots + k = {k (k + 1) \over 2}$;
  3. 当$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的本质就是分治法。首先

  1. 把n张牌的牌堆分成n堆。这样每堆都是1张牌
  2. 两两合并
  3. 两两合并
  4. ...
  5. 直到合并成一堆

那么该如何合并两堆已经各自排好序的牌堆呢?很简单,右手边放两个各自排好序的牌堆。永远只看两个牌堆最左端的牌。哪张牌小就把哪张抽出来放到左手牌堆的最右边。写成步骤

  1. 右手边放好各自排好序的牌堆1、牌堆2
  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个结果?
In [3]:
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]
In [4]:
Solution().mergeSort([1, 3, 2, 5, -1])
Out[4]:
[-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$ $$

Probabilistic Analysis and Randomized Algorithms¶

这章讲了

  • 雇佣问题 aka. the hiring problem

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. 把根节点拿出来

这样我们直接就找到了数组中最大的数。可是拿出来了之后根节点就空了,怎么办?

  1. 把尾节点里的数拿出来,放到根节点上

这样根节点就不是空的了。可是根节点被替换了,这个还满足大堆的定义吗?

  1. 从根节点开始,做一次堆维护

根节点被替换之后,很可能整个二叉树已经不是大堆了。所以要做一次操作,让二叉树再变成大堆。

但是我们发现,这个二叉树虽然不是大堆,但是显然和乱排的二叉树不一样:以这个二叉树的根节点的两个子节点为根节点的子二叉树,是大堆。

  1. 重复第1步,直到整个大堆被啃完

把乱序数组变成堆¶

堆维护¶

Quicksort¶

快速排序使用了分治法的思想。但是其实主要问题在分,不存在合并的问题,因为子问题全部解决了之后,整个数组已经排序完成了。

简单步骤

  • 从数组中选定一个基准数,通常选定数组的最后一个元素;

  • 从数组的第一个数开始到倒数第二个数,一个一个和基准数比较

    • 如果比基准数大,放到基准数的右边
    • 如果比基准数小、或者相等,放到基准数的左边

    至此,可以保证基准数左边所有的数都小于等于基准数,基准数右边所有的数都大于基准数。基准数的绝对位置已经决定了。下面只要保证左右两边分别是排好序的就可以了。

  • 分别对基准数左边、基准数右边两边的子数组重复第1步

    分治法的标准结局。

In [5]:
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
In [6]:
Solution().quickSort([1, 3, 2, 5])
Out[6]:
[1, 2, 3, 5]

我其实很不想写递归的,因为python限制最大递归深度(其实linux内核也限制栈大小),所以显然语言、OS设计者不鼓励用递归。但是我也一时想不起来怎么写成迭代。等我想到了再写吧。

写这个例子的时候,遇到了python的很多以前没见过的坑。比如

In [7]:
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

然后按横轴顺序往外吐数字。

  1. 0出现了1次,所以吐1个0
  2. 1出现了2次,所以连续吐2个1
  3. 2出现了1次,所以吐1个2
  4. ...

得到了0, 1, 1, 2, 3, 4,直接就排好序了。

In [8]:
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
In [9]:
Solution().countingSort([2, 5, 3, 0, 2, 3, 0, 3])
Out[9]:
[0, 0, 2, 2, 3, 3, 3, 5]

Radix sort¶