算法设计与分析


绪论

计算机学院    张腾

tengzhang@hust.edu.cn

课程概况

授课:张腾 tengzhang@hust.edu.cn

地点:西十二楼 S308、西十二楼 S204

32 学时

  • 第 9 ~ 10 周:周一 5 ~ 6 节课、周三 1 ~ 2 节课 (西十二楼 S308)
  • 第 11 ~ 16 周:周一 3 ~ 4 节课 (西十二楼 S204)、周三 3 ~ 4 节课 (西十二楼 S308)

考核:闭卷考试 (70%)、平时作业 (30%)

主页:https://avanti1980.github.io/course-alg/

参考书目

本讲目标

回答三个问题

是什么 what

为什么 why

怎么样 how

什么是算法

要把大象装冰箱,拢共分几步?

  1. 把冰箱门打开
  2. 把大象装进去
  3. 把冰箱门带上

通俗的讲,算法是完成一个任务所需的一系列步骤

上课算法

  1. 锁门离开宿舍房间
  2. 骑自行车到西十二
  3. 到教室找位置坐下

刷牙算法:挤牙膏到牙刷上,牙刷贴到牙齿上,上下移动 N 秒

计算机算法

运行在计算机上,把输入转换成输出的一系列良定义的计算步骤

g 输入 输入 算法 算法 输入->算法 输出 输出 算法->输出

  • 步骤序列是有穷的
  • 输入输出由待求解的问题决定
计算机算法

计算机算法已接管生活

Algorithm 一词的由来

阿尔·花拉子米:9 世纪波斯数学家、天文学家、地理学家

  • 穆罕默德·伊本·穆萨·阿尔·花拉子米
  • Muḥammad ibn Mūsā al-Khwārizmī
  • محمد بن موسی خوارزمی
Algorithm 一词的由来

《射雕英雄传》第三十七回 从天而降

原来蒙古大军分路进军,节节获胜,再西进数百里,即是花剌子模的名城撒麻尔罕。成吉思汗哨探获悉,此城是花剌子模的新都,结集重兵十余万守御,城精粮足……

成吉思汗自进军花剌子模以来,从无如此大败,当晚在帐中悲痛爱孙之亡,怒如雷霆。郭靖回帐翻阅《武穆遗书》,要想学一个攻城之法,但那撒麻尔罕的城防与中国大异,遗书所载的战法均无用处……

郭靖正欲说出辞婚之事,忽听得远处传来……,只道城中投降了的花剌子模军民突然起事,……。成吉思汗笑道:“没事,没事。这狗城不服天威,累得我损兵折将,又害死了我爱孙,须得大大洗屠一番。大家都去瞧瞧。”

丘处机:城中常十余万户,国破以来,存者四之一

耶律楚材:寂莫河中府,声名昔日闻,城隍连畎亩,市井半邱坟

Algorithm 一词的由来

阿尔·花拉子米:9 世纪波斯数学家、天文学家、地理学家

  • 早年游学印度
  • 于公元 825 年写成《印度数字算术》:十进制记数法、基础算术

引入欧洲

  • 意大利数学家斐波那契前往阿拉伯地区学习,约于 1200 年回国
  • 名字和算术均被翻译成拉丁语 Algorismus,数字误解为阿拉伯数字

词汇演化

  • 拉丁语算术 Algorismus => 英语算术 Algorism [ˈælɡəˌrɪzəm]
  • 英语算术 Algorism 被 Arithmetic [əˈrɪθmətɪk] 替代
  • Algorism 的异体词 Algorithm [ˈælɡərɪðəm] => 计算机专业术语“算法”
本讲目标

回答三个问题

是什么 what

为什么 why

怎么样 how

整数乘法

输入:两个$n$位整数$x = X[0, \ldots, n-1]$$y = Y[0, \ldots, n-1]$

输出:乘积$xy = z = Z[0, \ldots, 2n-1]$

$$ \begin{align*} \quad \sum_{k=0}^{2n-1} Z[k] 10^k & = \left( \sum_{i=0}^{n-1} X[i] 10^i \right) \left( \sum_{j=0}^{n-1} X[j] 10^j \right) = \sum_{i=0}^{n-1} \sum_{j=0}^{n-1} X[i] Y[j] 10^{i+j} \\ & = \sum_{k=0}^{2n-2} \underbrace{\sum_{(i,j): i+j=k} X[i] Y[j]}_{c_k} 10^k = \sum_{k=0}^{2n-2} c_k 10^k \end{align*} $$

  • 左:$10^k$的线性组合,组合系数$Z[k]$是一位正整数
  • 右:$10^k$的线性组合,组合系数$c_k$是若干个一位正整数的乘积

$Z[k]$和$c_k$之间的关系?

小学算法

已知$\sum_{k=0}^{2n-1} Z[k] 10^k = \sum_{k=0}^{2n-2} c_k 10^k$$Z[k]$$c_k$的关系?

右边$10^0$的系数$c_0 = \sum_{(i,j): i+j=0} X[i] Y[j] = X[0] Y[0]$,包含两部分

$$ \begin{align*} \quad \begin{cases} c_0 ~ \mathrm{mod} ~ 10, & \text{就是左边$10^0$的系数$Z[0]$} \\ h \leftarrow \lfloor c_0 / 10 \rfloor, & \text{作为进位参与$Z[1]$的计算} \end{cases} \end{align*} $$

右边$10^1$的系数$c_1 = X[0] Y[1] + X[1] Y[0]$,再加上进位$h$

$$ \begin{align*} \quad \begin{cases} (c_1 + h) ~ \mathrm{mod} ~ 10, & \text{就是左边$10^1$的系数$Z[1]$} \\ h \leftarrow \lfloor (c_1 + h) / 10 \rfloor, & \text{作为进位参与$Z[2]$的计算} \end{cases} \end{align*} $$

如此迭代,继续计算$Z[2], Z[3], \ldots, Z[2n-1]$

小学算法

输入:$X[0, \ldots, n-1]$$Y[0, \ldots, n-1]$

输出:$Z[0, \ldots, 2n-1]$

  1. 初始化进位$h \leftarrow 0$
  2. for $k = 0 \rightarrow 2n-1$
  3.   for each 二元组$(i,j): i+j=k$ do
  4.     $c \leftarrow h + X[i] Y[j]$
  5.   end for
  6.   $Z[k] = c ~ (\modd 10)$
  7.   下一位的进位$c \leftarrow \lfloor c/10 \rfloor$
  8. end for

算法第 2、3 行的二重 for 循环共遍历$n^2$个$(i,j)$二元组,即二维表中的每一格,因此算法时间复杂度为$\Theta(n^2)$,更好的算法?

小学算法 实现

def align(x, y, n_x, n_y):  # 若x和y长度不同 将短的左边补零
    if n_x < n_y:
        x = x.rjust(n_y, "0")
        n = n_y
    else:
        y = y.rjust(n_x, "0")
        n = n_x
    return x, y, n


def naive(x, y):
    n_x, n_y = len(x), len(y)
    x, y, n = align(x, y, n_x, n_y)  # 若x和y长度不同 将短的左边补零
    c = 0  # 初始进位为零
    z = str()
    for k in range(2 * n):
        # 遍历所有满足i+j=k的二元组(i,j) 注意i的范围防止越界
        for i in range(max(0, k - n + 1), min(k + 1, n)):
            j = k - i
            c += int(x[-1 - i]) * int(y[-1 - j])
        c, q = divmod(c, 10)
        z = str(q) + z
    z = z.lstrip("0")  # 去掉高位连续的0
    if z == "":  # 若乘积本就是0 去掉所有0后会变成空字符串
        return 0
    else:
        return int(z)
分治

$x$$y$的数位二等分,记$m = n/2$

  • $a = X[0, \ldots, n-m-1]$$b = X[n-m, n-1]$$x = a \cdot 10^m + b$
  • $c = Y[0, \ldots, n-m-1]$$d = Y[n-m, n-1]$$y = c \cdot 10^m + d$
  • $xy = (a \cdot 10^m + b)(c \cdot 10^m + d) = a c \cdot 10^{2m} + (a d + b c) 10^m + b d$

共产生$4$个子问题:$ac$$ad$$bc$$bd$,规模大致减半

$n$位数相乘的时间复杂度为$T(n)$,加法的时间复杂度显然为$\Theta(n)$

$$ \begin{align*} \quad T(n) = 4 \cdot T (n/2) + \Theta(n) \Longrightarrow T(n) = \Theta(n^2) \end{align*} $$

分解成$4$个规模大致减半的子问题并不是更优的算法

Karatsuba 算法

利用$a d + b c = (a+b) (c+d) - a c - b d$可得

$$ \begin{align*} \quad x y = a c \cdot 10^{2m} + ((a+b) (c+d) - a c - b d) \cdot 10^m + b d \end{align*} $$

乘法子问题减少为$3$

$$ \begin{align*} \quad T(n) = 3 \cdot T (n/2) + \Theta(n) \Longrightarrow T(n) = \Theta(n^{\log_2 3}) \end{align*} $$

分解成$3$个规模大致减半的子问题可以得到更优的算法

Karatsuba 算法 实现

def karatsuba(x, y):
    n_x, n_y = len(x), len(y)

    if n_x == 1 or n_y == 1:  # 如果其中一个数只有1位 不再递归
        return int(x) * int(y)

    x, y, n = align(x, y, n_x, n_y)  # 若x和y长度不同 将短的左边补零

    m = round(n / 2)
    a, b = x[0:n - m], x[n - m:n]  # x = a 10^m + b
    c, d = y[0:n - m], y[n - m:n]  # y = c 10^m + d

    # 3个递归子问题
    ac = karatsuba(a, c)
    bd = karatsuba(b, d)
    ad_bc = karatsuba(str(int(a) + int(b)), str(int(c) + int(d))) - ac - bd

    return ac * 10**(2 * m) + ad_bc * 10**m + bd
整数乘法 时间对比

量水问题

两个无刻度水桶,总容量分别为$9$升和$6$升,如何量出$3$升的水?

量水问题

两个无刻度水桶,总容量分别为$9$升和$6$升,如何量出$3$升的水?

算法:

  • $9$升的桶装满水
  • $6$升的桶倒水,直到满
  • $9$升桶中剩下的即为$3$

如果总容量分别为$7$升和$5$升,如何量出$1$升的水?

量水问题

两个无刻度水桶,总容量分别为$7$升和$5$升,如何量出$1$升的水?

算法:

  • $5$升的桶装满水,$5/5$$0/7$
  • $5$升的桶中的$5$升水全部倒入$7$升的桶中,$0/5$$5/7$
  • $5$升的桶装满水,$5/5$$5/7$
  • $7$升的桶倒水,直到满,此时$5$升的桶中还剩$3$升水,$3/5$$7/7$
  • $7$升的桶中的水倒掉,$3/5$$0/7$
  • $5$升的桶中的$3$升水倒入$7$升的桶中,$0/5$$3/7$
  • $5$升的桶装满水,$5/5$$3/7$
  • $7$升的桶倒水,直到满,此时$5$升的桶中还剩$\class{blue}{1}$升水,$\class{blue}{1}/5$$7/7$
量水问题 一般化

两个无刻度水桶,总容量分别为$a$升和$b$

  1. 是否可以量出$t$升的水?
  2. 如果可以,请给出具体方案,其中$a,b,t \in \Zbb_+$

思路:装满对应$+a$或$+b$,倒掉对应$-a$或$-b$

是否可以通过若干次$+a$、$+b$、$-a$、$-b$得到$t$?即是否存在整数$x$、$y$使得$ax + by = t$?

前面的例子:$5 \cdot 3 - 7 \cdot 2 = 1$

$t$必须是$a,b$最大公约数的倍数,若$t=1$,则$a,b$必须互素

量水问题 一般化

问题 1:求最大公约数 (greatest common divisor, gcd)

输入$a, b \in \Zbb_+$,输出整数$d=\gcd(a,b)$

问题 2:求待定系数

输入$a, b \in \Zbb_+$$\gcd(a,b)=1$,输出整数$x,y$使得$ax+by=1$

暴力穷举法

$1$遍历到$\min(a,b)$,最大能同时整除$a,b$的数就是最大公约数

def gcd(a, b):
    for i in range(1, min(a, b) + 1):        # 最大公约数不会大于两者中的较小者
        if ((a % i == 0) and (b % i == 0)):  # 同时整除即为公约数
            gcd = i
    return gcd

$x$$1$遍历到$b$,检测$y$是否可同时为整数

def coef(a, b):
    for x in range(1, b):         # 0 a 2a ... (b-1)a构成一个模b的剩余系
        if (1 - a * x) % b == 0:  # 若y也为整数
            y = int((1 - a * x) / b)
            return x, y
欧几里得 辗转相除法

$a / b = q \cdots r$$r < b$,于是$\gcd(a,b) \mid r$

$\gcd(a,b) = \gcd(b,r)$,不断令$(a,b) = (b,r)$直到$r = 0$即能整除

def Euclidean(a, b):  # 辗转相除
    while a % b:
        a, b = b, a % b
    return b


def Euclidean_coef(a, b):  # 辗转相除
    if a % b == 0:         # 递归停止条件:若b可以整除a
        return b, 1, 1 - int(a / b)
    else:
        d, x, y = Euclidean_coef(b, a % b)
        return d, y, x - int(a / b) * y
  • 暴力穷举法迭代轮数为$\min(a, b)$,每轮做$2$次取模运算
  • 辗转相除法迭代轮数约为$\log(\max(a,b))$,每轮做$1$次取模运算
九章算术 更相减损术

$\gcd(a,b) = \gcd(a-b,b)$,不断令$(a,b) = (a-b,b)$直到$a=b$

def gxjs(a, b):  # 更相减损
    while True:
        if a > b:
            a = a - b
        elif a < b:
            b = b - a
        else:
            return b


def gxjs_coef(a, b):  # 更相减损
    if a == b:        # 递归停止条件:a = b
        return b, 1, 0
    elif a > b:
        d, x, y = gxjs_coef(a - b, b)
        return d, x, y - x
    else:
        d, x, y = gxjs_coef(a, b - a)
        return d, x - y, y
  • 避免了低效的取模运算,只做加减法
  • 最坏情况下迭代轮数为$\max(a, b)$,例如$\gcd(10000,1)$
改进的更相减损术

$a$$b$分奇偶性讨论

def gxjs2(a, b):  # 改进的更相减损
    if a == b:
        return a
    while True:
        if not (a & 1) and not (b & 1):  # 均为偶 gcd(a,b) = 2 * gcd(a/2, b/2)
            return gxjs2(a >> 1, b >> 1) << 1
        elif not (a & 1) and (b & 1):    # a偶 b奇 gcd(a,b) = gcd(a/2, b)
            return gxjs2(a >> 1, b)
        elif (a & 1) and not (b & 1):    # a奇 b偶 gcd(a,b) = gcd(a, b/2)
            return gxjs2(a, b >> 1)
        else:                            # 均为奇 更相减损 gcd(a,b) = gcd(a-b, b)
            if a > b:
                return gxjs2(a - b, b)
            else:
                return gxjs2(a, b - a)

迭代轮数约为$\log(\max(a, b))$,同辗转相除,但避免了取模运算

求最大公约数

四种方法

方法 迭代轮数 每轮操作
暴力穷举法 $\min(a, b)$ $2$次取模运算
辗转相除法 $\log(\max(a,b))$ $1$次取模运算
更相减损术 最坏$\max(a, b)$ 无取模运算
改进的更相减损术 $\log(\max(a,b))$ 无取模运算
  • 设计正确的算法:对每个输入,都能给出正确的输出
  • 分析算法的效率:时间开销、空间占用等,多中选优
排序

输入:数组$a = \langle a_1, a_2, \ldots, a_n \rangle$
输出:$a$的元素的重排列$\langle a'_1, a'_2, \ldots, a'_n \rangle$$a'_1 \le a'_2 \le \cdots \le a'_n$

  • 新华字典按音序排序
  • 银行流水按时间排序
  • 搜索引擎的结果按与搜索关键字的相关性排序
  • 服务于其他算法,如作为二分查找的前序步骤

借助这个最基本的问题,我们详细展示

  • 如何证明算法的正确性
  • 如何分析算法的效率

冒泡排序、选择排序、插入排序、归并排序、快速排序

冒泡排序

相邻两个元素比较,如果前者大于后者,则交换

def bubble_sort(a, n):
    for i in range(n-1):
        for j in range(n-1, i, -1):
            if a[j] < a[j-1]:
                a[j], a[j-1] = a[j-1], a[j]

前两轮外层循环

1 2 3 4 5 6 1 2 3 4 5 6
(1) 5 2 4 6 1 3 (6) 1 5 2 4 3 6
(2) 5 2 4 1 6 3 (7) 1 5 2 3 4 6
(3) 5 2 1 4 6 3 (8) 1 5 2 3 4 6
(4) 5 1 2 4 6 3 (9) 1 2 5 3 4 6
(5) 1 5 2 4 6 3
冒泡排序 正确性

def bubble_sort(a, n):
    for i in range(n-1):
        for j in range(n-1, i, -1):
            if a[j] < a[j-1]:
                a[j], a[j-1] = a[j-1], a[j]

如何证明算法的正确性?

循环不变式 (loop-invariant):一个满足如下三条性质的命题

  1. 初始:在初次进入循环前成立
  2. 保持:若每次进入循环前成立,则下次进入循环前还成立
  3. 终止:循环可以终止,此时循环不变式提供一个有用的性质

内层循环:每次进入循环前,子数组$a[j, \ldots, n-1]$的第一个元素最小
外层循环:每次进入循环前,子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素

冒泡排序 正确性

def bubble_sort(a, n):
    for i in range(n-1):
        for j in range(n-1, i, -1):
            if a[j] < a[j-1]:
                a[j], a[j-1] = a[j-1], a[j]

内层循环:每次进入循环前,子数组$a[j, \ldots, n-1]$的第一个元素最小
外层循环:每次进入循环前,子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素

外层循环不变式证明依赖内层的终止情况,先证明内层

  1. 初始:$j = n-1$,子数组$a[n-1]$只有一个元素,循环不变式显然成立
  2. 保持:若$a[j]$$a[j, \ldots, n-1]$中最小的,循环中比较$a[j-1]$$a[j]$并将小者置于前,则再次进入循环前$a[j-1, \ldots, n-1]$的第一个元素最小
  3. 终止:$j = i$,此时子数组$a[i, \ldots, n-1]$第一个元素最小
冒泡排序 正确性

def bubble_sort(a, n):
    for i in range(n-1):
        for j in range(n-1, i, -1):
            if a[j] < a[j-1]:
                a[j], a[j-1] = a[j-1], a[j]

内层循环:每次进入循环前,子数组$a[j, \ldots, n-1]$的第一个元素最小
外层循环:每次进入循环前,子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素

  1. 初始:$i=0$,子数组$a[0, \ldots, -1]$为空,循环不变式显然成立
  2. 保持:若子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素,又内层循环终止时$a[i, \ldots, n-1]$的第一个元素最小,因此子数组$a[0, \ldots, i]$亦有序排列着整个数组$a[]$的最小的$i+1$个元素
  3. 终止:$i = n-1$,此时子数组$a[0, \ldots, n-2]$有序排列着整个数组$a[]$的最小的$n-1$个元素,故剩余的$a[n-1]$是最大元素,从而整个数组$a[]$已排好序
冒泡排序 时间分析

bubble_sort(a, n): 时间 次数
for i in range(n-1): $c_1, c'_1$ $i$赋值$1$次、自增$n-1$
$c''_1$ $i$$n-1$比较$n$
  for j in range(n-1, i, -1): $c_2, c'_2$ $j$赋值$n-1$次、自减$\frac{n^2-n}{2}$
$c''_2$ $j$$i$比较$\frac{n^2+n-2}{2}$
    if a[j] < a[j-1]: $c_3$ 比较$\frac{n^2-n}{2}$
      a[j], a[j-1] = a[j-1], a[j] $c_4$ 交换$\sum_{i=0}^{n-2} t_i$

$$ \begin{align*} \quad & T = c_1 + c'_1 (n-1) + c''_1 n + c_2 (n-1) + c'_2 \frac{n^2-n}{2} + c''_2 \frac{n^2+n-2}{2} \\ & \qquad \quad + c_3 \frac{n^2-n}{2} + c_4 \sum_{i=0}^{n-2} t_i = a n^2 + b n + c + c_4 \sum_{i=0}^{n-2} t_i \end{align*} $$

冒泡排序 时间分析

$t_i$是外层循环第$i$轮中内层循环$a[j]$$a[j-1]$交换的次数

  • 最好情况下 (初始顺序) 发生$0$
  • 最坏情况下 (初始逆序) 发生$n-1-i$

$$ \begin{align*} \qquad & a n^2 + b n + c \le T \le a n^2 + b n + c + c_4 \sum_{i=0}^{n-2} (n-1-i) \\ & \qquad \Longrightarrow T \in \Theta(n^2), ~ \text{最坏情况下交换次数} \in \Theta(n^2) \end{align*} $$

  • 顺序结构中的语句只执行$1$
  • 循环结构中的语句执行次数等于循环被执行的次数,一重循环中的语句可以产生$n$的线性项,二重循环中的语句可以产生$n$的平方项
算法 最坏情况下运行时间 最好情况下运行时间 最坏情况下交换次数
冒泡排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n^2)$
选择排序

最小元与第一个元素互换,次小元与第二个元素互换,……

def selection_sort(a, n):
    for i in range(n-1):
        smallest = i
        for j in range(i+1, n):
            if a[j] < a[smallest]:
                smallest = j
        a[i], a[smallest] = a[smallest], a[i]
  • 第 5 行的比较操作在二重循环里,因此算法总时间$\in \Theta(n^2)$
  • 第 7 行的交换操作在一重循环里,因此最坏情况下交换次数$\in \Theta(n)$
算法 最坏情况下运行时间 最好情况下运行时间 最坏情况下交换次数
冒泡排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n^2)$
选择排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n)$
选择排序 正确性

def selection_sort(a, n):
    for i in range(n-1):
        smallest = i
        for j in range(i+1, n):
            if a[j] < a[smallest]:
                smallest = j
        a[i], a[smallest] = a[smallest], a[i]

内层循环:每次进入循环前,$a[\text{smallest}]$是子数组$a[i, \ldots, j-1]$的最小元素
外层循环:每次进入循环前,子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素,与冒泡排序相同

内层循环:

  1. 初始:$j=i+1$,子数组$a[i, \ldots, j-1]$只有$a[\text{smallest}]$,循环不变式成立
  2. 保持:若$a[\text{smallest}]$是子数组$a[i, \ldots, j-1]$的最小元素,经过第 5 ~ 6 行比较后,$a[\text{smallest}]$将是子数组$a[i, \ldots, j]$的最小元素
  3. 终止:$j = n$$a[\text{smallest}]$$a[i, \ldots, n-1]$的最小元素
选择排序 正确性

def selection_sort(a, n):
    for i in range(n-1):
        smallest = i
        for j in range(i+1, n):
            if a[j] < a[smallest]:
                smallest = j
        a[i], a[smallest] = a[smallest], a[i]

内层循环:每次进入循环前,$a[\text{smallest}]$是子数组$a[i, \ldots, j-1]$的最小元素
外层循环:每次进入循环前,子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素,与冒泡排序相同

外层循环保持:若子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素,又$a[\text{smallest}]$是子数组$a[i, \ldots, n-1]$的最小元素,经过第 7 行的交换,$a[i]$$a[i, \ldots, n-1]$的最小元素,因此子数组$a[0, \ldots, i]$有序排列着整个数组$a[]$的最小的$i+1$个元素

插入排序

在前半部分构建有序子数组,将后半部分的未排序元素插入其中

def insertion_sort(a, n):
    for i in range(1, n):
        key = a[i]
        j = i - 1
        while j >= 0 and key < a[j]:
            a[j+1] = a[j]
            j -= 1
        a[j+1] = key
1 2 3 4 5 6 1 2 3 4 5 6
(1) 5 2 4 6 1 3 (2) 2 5 4 6 1 3
(3) 2 4 5 6 1 3 (4) 2 4 5 6 1 3
(5) 1 2 4 5 6 3 (6) 1 2 3 4 5 6
插入排序 正确性

def insertion_sort(a, n):
    for i in range(1, n):
        key = a[i]
        j = i - 1
        while j >= 0 and key < a[j]:
            a[j+1] = a[j]
            j -= 1
        a[j+1] = key

内层循环:每次进入循环前,$\text{key} \le a[j+1]$
外层循环:每次进入循环前,子数组$a[0, \ldots, i-1]$已经排好序

  1. 初始:子数组$a[0]$只有一个元素,已经排好序
  2. 保持:内层循环终止时,要么$j=-1$$\text{key} < a[0]$,故令$a[0] = \text{key}$;要么$\text{key} > a[j]$(注意同时有$\text{key} \le a[j+1]$),故令$a[j+1] = \text{key}$,无论哪种情况都为$a[i]$在已经排好序的子数组$a[0, \ldots, i-1]$中找到了正确的插入位置,故下次进入循环前$a[0, \ldots, i]$已经排好序
  3. 终止:$i = n$$a[0, \ldots, n-1]$已经排好序
插入排序 时间分析

insertion_sort(a, n): 时间 次数
for i in range(1, n): $c_1, c'_1$ $i$赋值$1$次、自增$n-1$
$c''_1$ $i$$n$比较$n$
  key = a[i] $c_2$ $key$赋值$n-1$
  j = i - 1 $c_3$ $j$赋值$n-1$
  while j >= 0 and key < a[j]: $c_4,c'_4$ 比较$2 \sum_{i=1}^{n-1} t_i + \{1,2\}$
    a[j+1] = a[j] $c_5$ 赋值$\sum_{i=1}^{n-1} t_i$
    j -= 1 $c_6$ $j$赋值$\sum_{i=1}^{n-1} t_i$
  a[j+1] = key $c_7$ 赋值$n-1$

$$ \begin{align*} \qquad T = b n + c + d \sum_{i=1}^{n-1} t_i \end{align*} $$

插入排序 时间分析

$t_i$是外层 for 循环第$i$轮中内层 while 循环的执行次数

  • 最好情况下 (初始顺序) 执行$0$
  • 最坏情况下 (初始逆序) 执行$i$

$$ \begin{align*} \qquad b n + c \le b n + c + d \sum_{i=1}^{n-1} t_i \le b n + c + d \cdot \frac{n^2-n}{2} \end{align*} $$

算法 最坏情况下运行时间 最好情况下运行时间 最坏情况下交换次数
冒泡排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n^2)$
选择排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n)$
插入排序 $\Theta(n^2)$ $\Theta(n)$ $\Theta(n^2)$
归并排序

分治法:将原问题分解为类似原问题的子问题进行递归求解

  • 分解:将子数组$a[l,\ldots,r]$分成$a[l,\ldots,m]$$a[m+1,\ldots,r]$两部分,其中$m$$(l+r)/2$取整
  • 解决:分别对子数组$a[l,\ldots,m]$$a[m+1,\ldots,r]$进行递归排序
  • 合并:将分别排好序的$a[l,\ldots,m]$$a[m+1,\ldots,r]$合并成$a[l,\ldots,r]$
def merge_sort(a, l, r):
    if l < r:
        m = int((l+r)/2)  # 取中间点分开
        merge_sort(a, l, m)
        merge_sort(a, m+1, r)
        merge(a, l, m, r)
  • 分解:第 3 行计算分解的中间点
  • 解决:第 4 ~ 5 行对子数组递归调用归并排序
  • 合并:第 6 行合并已排好序的两个子数组
归并排序

def merge_sort(a, l, r):
    if l < r:
        m = int((l+r)/2)  # 取中间点分开
        merge_sort(a, l, m)
        merge_sort(a, m+1, r)
        merge(a, l, m, r)

g n1 5 2 4 6 1 3 n2 5 2 4 n1->n2 n3 6 1 3 n1->n3 n4 5 2 n2->n4 n5 4 n2->n5 n7 3 n3->n7 n6 6 1 n3->n6 n8 5 n4->n8 n9 2 n4->n9 n14 2 4 5 n5->n14 n15 1 3 6 n7->n15 n10 6 n6->n10 n11 1 n6->n11 n12 2 5 n8->n12 n9->n12 n13 1 6 n10->n13 n11->n13 n12->n14 n13->n15 n16 1 2 3 4 5 6 n14->n16 n15->n16

归并排序

合并:取两个子数组的最小元素做比较,并将小者取出

def merge(a, l, m, r):

    # 子数组的长度
    n1, n2 = m - l + 1, r - m

    # 创建临时数组
    L, R = [0] * n1, [0] * n2

    # 拷贝数据到临时数组L
    for i in range(0, n1):
        L[i] = a[l + i]

    # 拷贝数据到临时数组R
    for j in range(0, n2):
        R[j] = a[m + 1 + j]

    i, j, k = 0, 0, l

    # 归并L和R到a[l..r]
    while i < n1 and j < n2:
        if L[i] <= R[j]:
            a[k] = L[i]
            i += 1
        else:
            a[k] = R[j]
            j += 1
        k += 1

    # 拷贝L的剩余元素
    while i < n1:
        a[k] = L[i]
        i += 1
        k += 1

    # 拷贝R的剩余元素
    while j < n2:
        a[k] = R[j]
        j += 1
        k += 1

g n1 (1) 2 4 5 1 3 6 1 (2) 2 4 5 1 3 6 1 2 (3) 2 4 5 1 3 6 1 2 3 (4) 2 4 5 1 3 6 1 2 3 4 (5) 2 4 5 1 3 6 1 2 3 4 5 (6) 2 4 5 1 3 6 1 2 3 4 5 6

归并排序 时间分析

设排序长度为$n$的数组的时间为$T(n)$,则有递推式

$$ \begin{align*} \qquad T(n) = 2 \cdot T \left( \frac{n}{2} \right) + \underbrace{\Theta(n)}_{\text{合并}} \end{align*} $$

根据主方法可得$T(n) \in \Theta(n \lg n)$

算法 最坏情况下运行时间 最好情况下运行时间 最坏情况下交换次数
冒泡排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n^2)$
选择排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n)$
插入排序 $\Theta(n^2)$ $\Theta(n)$ $\Theta(n^2)$
归并排序 $\Theta(n \lg n)$ $\Theta(n \lg n)$ $\Theta(n \lg n)$
快速排序

分治法:将原问题分解为类似原问题的子问题进行递归求解

  • 分解:将最后一个元素作为主元并确定其在$a[]$中的正确位置$m$,将小/大于主元的元素分别挪到主元的左/右边
  • 解决:分别对子数组$a[l,\ldots,m-1]$$a[m+1,\ldots,r]$进行递归排序
  • 合并:什么也不用做
def quick_sort(a, l, r):
    if l < r:
        m = partition(a, l, r)
        quick_sort(a, l, m-1)
        quick_sort(a, m+1, r)
  • 分解:第 3 行计算主元的位置
  • 解决:第 4 ~ 5 行对子数组递归调用快速排序
快速排序

def quick_sort(a, l, r):
    if l < r:
        m = partition(a, l, r)
        quick_sort(a, l, m-1)
        quick_sort(a, m+1, r)

g n1 5 2 4 6 1 3 n2 2 1 3 6 5 4 n1->n2 n3 2 1 n2->n3 n4 3 n2->n4 n5 6 5 4 n2->n5 n6 1 2 n3->n6 n7 4 5 6 n5->n7 n8 1 n6->n8 n9 2 n6->n9 n10 4 n7->n10 n11 5 6 n7->n11 n12 5 6 n11->n12 n13 5 n12->n13 n14 6 n12->n14

快速排序

def partition(a, l, r):

    # 最右元素作为主元
    pivot = a[r]

    # 小于主元的元素的存放位置 初始为最左
    i = l

    # l -> r-1 遍历其他元素
    for j in range(l, r):
        if a[j] <= pivot:
            # 小于主元的元素放到主元左边
            a[i], a[j] = a[j], a[i]
            i += 1  # 存放位置右移一位

    # 所有小于主元的元素已位于主元左边
    # 当前的i就是主元应该放的位置
    # 当前的a[i]大于主元
    a[i], a[r] = a[r], a[i]

    return i

g n1 ij 5 2 4 6 1 3 n2 i j 5 2 4 6 1 3 n1->n2 n3 ij 2 5 4 6 1 3 n2->n3 n5 i j 2 5 4 6 1 3 n3->n5 n6 i j 2 1 4 6 5 3 n5->n6 n7 i j 2 1 3 6 5 4 n6->n7

快速排序 时间分析

设排序长度为$n$的数组的时间为$T(n)$,则有递推式

$$ \begin{align*} \quad T(n) = T(k) + T(n-1-k) + \underbrace{\Theta(n)}_{\text{与主元比较}} \end{align*} $$

  • 最好情况:主元是中位数,$T(n) = 2 \cdot T (n/2) + \Theta(n)$,同归并排序
  • 最坏情况:主元是最大/小元素,造成规模极不平衡的两个子问题

$$ \begin{align*} \quad T(n) = T (n-1) + \Theta(n) \Longrightarrow T(n) = \Theta(n^2) \end{align*} $$

如何改进?

  • 随机选取主元
  • 随机选取三个元素并将其中位数作为主元
  • 与其他排序方法杂交,比如当子问题规模较小时改用插入排序
排序小结

算法 最坏情况下运行时间 最好情况下运行时间 最坏情况下交换次数
冒泡排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n^2)$
选择排序 $\Theta(n^2)$ $\Theta(n^2)$ $\Theta(n)$
插入排序 $\Theta(n^2)$ $\Theta(n)$ $\Theta(n^2)$
归并排序 $\Theta(n \lg n)$ $\Theta(n \lg n)$ $\Theta(n \lg n)$
快速排序 $\Theta(n^2)$ $\Theta(n \lg n)$ $\Theta(n^2)$

除最坏/好情况分析,还有平均情况分析、“基准实例”测试

我们通常更关心算法在最坏情况下的运行时间

  • 最坏情况执行时间给出了运行时间的上界,方便我们做最坏打算
  • 对某些算法最坏情况经常出现,对很多算法平均往往与最坏相同
排序小结

$\Theta(n \lg n)$$\Theta(n^2)$的差别有多大?

设对长度一千万的数组进行排序,$n = 10^7$

CPU 算法 时间复杂度
计算机 A $10^{10}$条指令/秒 插入排序 $2 \cdot n^2$
计算机 B $10^7$条指令/秒 归并排序 $50 \cdot n \lg n$

$$ \begin{align*} \qquad & \frac{2 \cdot (10^7)^2 \text{ instructions}}{10^{10} \text{ instructions/s}} = 20000 \text{ s} > 5.5 \text{ h} \\ & \frac{50 \cdot 10^7 \lg 10^7 \text{ instructions}}{10^7 \text{ instructions/s}} \approx 1163 \text{ s} < 20 \text{ m} \end{align*} $$

为什么研究算法

像对待计算机硬件一样把算法看成是一种技术

  • 选择更快的硬件可以提升总体性能
  • 运行更好的算法也可以提升总体性能,甚至可以成为胜负手

如果计算机计算存储资源无限,那么我们还需研究算法吗?

  • 硬件的计算能力虽然日新月异,但终归不可能无限
  • 大数据时代问题的规模越来越大,算法间的效率差异也越来越显著
  • 有一个好的算法基础,可以做更多的事情,同时也能做得更好
本讲目标

回答三个问题

是什么 what

为什么 why

怎么样 how

课程大纲

算法分治动态规划贪心回溯分支限界迭代改进Karatsuba算法归并排序、快速排序最大子数组逆序对计数最近点对矩阵加法、矩阵乘法Strassen矩阵乘法代入法、递归树、主方法

作业

课外阅读:算法导论 4th 前两章

习题:1.2-2、1.2-3、2.1-3、2.1-4