算法设计与分析


绪论

计算机学院 张腾

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} \sum_{k=0}^{2n-1} Z[k] 10^k & = z = xy = \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{\class{blue}{\sum_{(i,j): i+j=k} X[i] Y[j]}}_{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$,其中$c_k = \sum_{(i,j): i+j=k} X[i] Y[j]$是若干个一位正整数的乘积,而$Z[k]$是一位正整数,它们的关系?

$c_0 = \sum_{(i,j): i+j=0} X[i] Y[j] = X[0] Y[0]$

  • $c_0 \bmod 10$:就是$Z[0]$
  • $h \leftarrow \lfloor c_0 / 10 \rfloor$:作为进位参与$Z[1]$的计算

$c_1 = \sum_{(i,j): i+j=1} X[i] Y[j] = X[0] Y[1] + X[1] Y[0]$,再加上进位$h$

  • $(c_1 + h) \bmod 10$:就是$Z[1]$
  • $h \leftarrow \lfloor (c_1 + h) / 10 \rfloor$:作为进位参与$Z[2]$的计算

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

小学算法

$x = 123$$y = 456$$z = 56088$为例

$c_k$ $h$ $c_k + h$ $Z[k]$
$c_0 = 3 \times 6 = 18$ $0$ $\class{red}{1}8$ $Z[0] = 8$
$c_1 = 3 \times 5 + 2 \times 6 = 27$ $\class{red}{1}$ $\class{yellow}{2}8$ $Z[1] = 8$
$c_2 = 3 \times 4 + 2 \times 5 + 1 \times 6 = 28$ $\class{yellow}{2}$ $\class{blue}{3}0$ $Z[2] = 0$
$c_3 = 2 \times 4 + 1 \times 5 = 13$ $\class{blue}{3}$ $\class{cyan}{1}6$ $Z[3] = 6$
$c_4 = 1 \times 4 = 4$ $\class{cyan}{1}$ $5$ $Z[4] = 5$
小学算法

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

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

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

算法第 2、3 行的二重 for 循环共遍历$n^2$个$(i,j)$二元组,因此算法时间复杂度$T(n) = \Theta(n^2)$,更好的算法?

$\Theta$是渐进符号,$T(n) = \Theta(n^2)$表示随着$n \rightarrow \infty$,$T(n)$与$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 grade_school(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, \ldots, n-1]$$x = a \cdot 10^m + b$
  • $c = Y[0, \ldots, n-m-1]$$d = Y[n-m, \ldots, n-1]$$y = c \cdot 10^m + d$

乘积$z = 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$个$n/2$位数相乘,$ac$、$ad$、$bc$、$bd$
  • 补零:$ac$后补$n$个、$a d + b c$前后各补$n/2$个,$bd$前补$n$个,共补$3n$个
  • 加法:$3$个$2n$位数相加,一重循环遍历,逐位相加,共$4n$个加法

设$n$位数相乘时间复杂度为$T(n)$,补零、加法时间复杂度为$c_1$、$c_2$

\begin{align} T(n) = 4 \cdot T (n/2) + c_1 \cdot 3n + c_2 \cdot 4n \overset{主方法}{\Longrightarrow} T(n) = \Theta(n^2) \end{align}

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

主方法是求解递推关系的一般性方法,详见第三讲“分治”

Karatsuba 算法

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

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

$n/2$位数相乘减少为$3$个:$(a+b) (c+d)$$a c$$b d$

补零不变,加法次数变多但时间复杂度依然为$\Theta(n)$

\begin{align} T(n) = 3 \cdot T (n/2) + \Theta(n) \overset{主方法}{\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
整数乘法 时间对比

这是对数坐标轴,由$\log_2 T = \log_2 (c \cdot n^k) = k \log_2 n + \log_2 c$可知,$T = c \cdot n^k$对应的是斜率为$k$的直线

量水问题

两个无刻度水桶,总容量分别为$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$都是正整数

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

能否通过若干次$\pm a$、$\pm b$得到$t$?即存在整数$x$、$y$使得$ax + by = t$?

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

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

量水问题 一般化

原问题:是否存在整数$x$$y$使得$ax + by = t$

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

输入正整数$a$$b$,输出$d = \gcd(a,b)$

令$a \gets a / d$、$b \gets b/ d$、$t \gets t / d$,此时问题不变但$\gcd(a,b) = 1$

问题 2:求整数$x$、$y$

输入互素的正整数$a$、$b$,输出整数$x$、$y$使得$ax+by=1$

$x \cdot t$、$y \cdot t$就是原问题的解

暴力穷举法

$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} 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} \\ & + 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} 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) \\ & \Longrightarrow T \in \Theta(n^2), ~ 最坏情况下交换次数 \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[smallest]$是子数组$a[i, \ldots, j-1]$的最小元素
外层循环:每次进入循环前,子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素,与冒泡排序相同

内层循环:

  1. 初始:$j=i+1$,子数组$a[i, \ldots, j-1]$只有$a[smallest]$,循环不变式成立
  2. 保持:若$a[smallest]$是子数组$a[i, \ldots, j-1]$的最小元素,经过第 5 ~ 6 行比较后,$a[smallest]$将是子数组$a[i, \ldots, j]$的最小元素
  3. 终止:$j = n$$a[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[smallest]$是子数组$a[i, \ldots, j-1]$的最小元素
外层循环:每次进入循环前,子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素,与冒泡排序相同

外层循环保持:若子数组$a[0, \ldots, i-1]$有序排列着整个数组$a[]$的最小的$i$个元素,又$a[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

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

  1. 初始:子数组$a[0]$只有一个元素,已经排好序
  2. 保持:内层循环终止时,要么$j=-1$$key < a[0]$,故令$a[0] = cf$;要么$key > a[j]$ (注意同时有$key \le a[j+1]$),故令$a[j+1] = 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} T = b n + c + d \sum_{i=1}^{n-1} t_i \end{align}

插入排序 时间分析

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

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

\begin{align} 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[low, \ldots, high]$分成$a[low, \ldots, mid]$$a[mid+1, \ldots, high]$两部分,为使子问题规模相同,$mid$$(low + high)/2$取整
  • 治:分别对$a[low, \ldots, mid]$$a[mid+1, \ldots, high]$进行排序,递归实现
  • 合:将排好序的$a[low, \ldots, mid]$$a[mid+1, \ldots, high]$合并成$a[low, \ldots, high]$
def merge_sort(a, low, high):
    if low < high:
        mid = int((low + high) / 2)  # 中间点
        merge_sort(a, low, mid)
        merge_sort(a, mid + 1, high)
        merge(a, low, mid, high)
  • 分:第 3 行计算分解的中间点
  • 治:第 4 ~ 5 行对子数组递归调用归并排序
  • 合:第 6 行合并已排好序的两个子数组
归并排序

def merge_sort(a, low, high):
    if low < high:
        mid = int((low + high) / 2)  # 中间点
        merge_sort(a, low, mid)
        merge_sort(a, mid + 1, high)
        merge(a, low, mid, high)

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

归并排序

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

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

def merge(a, low, mid, high):

    # 子数组的长度
    n1, n2 = mid - low + 1, high - mid

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

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

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

    i, j, k = 0, 0, low

    # 归并L和R到a[low,...,high]
    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
归并排序 时间分析

设将两个长度为$n/2$的有序数组合并的时间为$f(n)$

  • 最好情况:其中一个的最大元素小于另一个的最小元素,$f(n) = n/2$
  • 最坏情况:一直要比到两个数组的最大元素,$f(n) = n-1$

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

\begin{align} T(n) = \begin{cases} 1, & n = 1 \\ 2 \cdot T (n/2) + f(n), & n > 1 \end{cases} \overset{主方法}{\Longrightarrow} T(n) = \Theta(n \lg n) \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)$
快速排序

分治:将原问题分成若干同类型、规模更小的子问题递归求解

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

def quick_sort(a, low, high):
    if low < high:
        m = partition(a, low, high)
        quick_sort(a, low, m - 1)
        quick_sort(a, m + 1, high)

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, low, high):

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

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

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

    # 所有小于主元的元素已位于主元左边
    # 当前的i就是主元应该放的位置
    # 当前的a[i]大于主元
    a[i], a[high] = a[high], 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} T(n) = \begin{cases} 1, & n = 1 \\ T(n) = T(k) + T(n-1-k) + \Theta(n), & n > 1 \end{cases} \end{align}

  • 最好情况:主元是中位数,$T(n) = 2 \cdot T (n/2) + \Theta(n)$,同归并排序
  • 最坏情况:主元是最大或最小元素,只产生一个规模为$n-1$的子问题,此时递推关系为$T(n) = T (n-1) + \Theta(n)$,易知有$T(n) = \Theta(n^2)$

如何改进?

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

算法 最坏情况下运行时间 最好情况下运行时间 最坏情况下交换次数
冒泡排序 $\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} & \frac{2 \cdot (10^7)^2 \class{base01}{\text{指令}}}{10^{10} \class{base01}{\text{指令/秒}}} = 20000 \class{base01}{\text{秒}} > 5.5 \class{base01}{\text{小时}} \\ & \frac{50 \cdot 10^7 \lg 10^7 \class{base01}{\text{指令}}}{10^7 \class{base01}{\text{指令/秒}}} \approx 1163 \class{base01}{\text{秒}} < 20 \class{base01}{\text{分钟}} \end{align}

为什么研究算法

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

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

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

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

回答三个问题

是什么 what

为什么 why

怎么样 how

课程大纲

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

作业

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

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