基础知识
算法基础
增量方法
插入排序
1 | INSERTION-SORT(A) |
循环不变式
循环不变式主要用来帮助我们理解算法的正确性。关于循环不变式,我们必须证明三条性质:
- 初始化:循环的第一次迭代之前,它为真
- 保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍然为真
- 终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的
分治法
分治法的思想:将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解
分治模式在每层递归时都有三个步骤:
- 分解原问题为若干子问题,这些子问题是原问题的规模较小的实例
- 解决这些子问题,递归地求解各子问题。然而,若子问题的规模足够小,则直接求解
- 合并这些子问题的解成原问题的解
归并排序
- 分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列
- 解决:使用归并排序递归地排序两个子序列
- 合并:合并两个已排序的子序列以产生已排序的答案
我们通过调用一个辅助过程 MERGE(A, p, q, r)
来完成合并,其中是一个数组, 、和是数组下标,满足。该过程假设子数组和都已排好序
过程 MERGE
需要的时间,其中是带合并元素的总数
1 | MERGE(A, p, q, r) |
1 | MERGE-SORT(A, p, r) |
分析分治算法
假设是规模为的一个问题的运行时间:
- 若问题规模足够小,则直接求解需要常量时间
- 假设把原问题分解为个子问题,每个子问题的规模是原问题的。为了求解一个规模为的子问题,需要的时间,所以需要的时间来求解个子问题
- 分解问题成子问题需时间
- 合并子问题的解成原问题的解需时间
那么得到递归式:
对于归并排序,其最坏情况运行时间的递归式:
函数增长
渐进记号
记号
对于一个给定的函数,用来表示以下函数的集合:
我们称是的一个渐进紧确界
记号
对于一个给定的函数,用来表示以下函数的集合:
我们称是的一个渐进上界
记号
对于一个给定的函数,用来表示以下函数的集合:
我们称是的一个渐进下界
记号
对于一个给定的函数,用来表示以下函数的集合:
我们称是的一个非渐进紧确上界
记号
对于一个给定的函数,用来表示以下函数的集合:
我们称是的一个非渐进紧确下界
比较各种函数
- 传递性
- 自反性
- 对称性
- 转置对称性
- 类比
标准记号与常用函数
多项式
对于一个次渐进正的多项式,有:
指数
任意底大于1的指数函数比任意多项式函数增长得快:
对数
对所有实数,,和,有:
任意正的多项式函数都比任意多底数函数增长得快:
阶乘
斯特林近似公式:
给出了一个更紧确的上界和下界:
多重函数
假设为实数集上的一个函数,对非负整数,我们递归地定义:
多重对数函数
定义多重对数函数为:
多重对数是一个增长非常慢的函数:
分治策略
- 代入法:我们猜测一个界,然后用数学归纳法证明这个界是正确的
- 递归树法:将递归式转换为一棵树,其结点表示不同层次的递归调用产生的代价。然后采用边界和技术来求解递归式
- 主方法:可求解形如下面公式的递归式的界:
最大子数组问题
寻找A的和最大的非空连续子数组,这样的连续子数组为最大子数组
使用分治策略的求解方法
的任何连续子数组所处的位置必然是一下三种情况之一:
- 完全位于子数组中,因此
- 完全位于子数组中,因此
- 跨越了中点,因此
过程 FIND-MAX-CROSSING-SUBARRAY(A, low, mid, high)
接收数组和下标,和为输入,返回一个下标元组划定跨越中点的最大子数组的边界,并返回最大子数组中值的和:
1 | FIND-MAX-CROSSING-SUBARRAY(A, low, mid, high) |
1 | FIND-MAXINUM-SUBARRAY(A, low, high) |
分治算法的分析
FIND-MAXINUM-SUBARRAY
运行时间的递归式:
矩阵乘法的Strassen算法
基础的矩阵乘法
复杂度
1 | SQUARE-MATRIX-MULTIPLY(A, B) |
简单的分治算法
假定将,和均分解为4个的子矩阵:
可以将公式改写为:
1 | SQUARE-MATRIX-MULTIPLY-RECURSIVE(A, B) |
SQUARE-MATRIX-MULTIPLY-RECURSIVE
运行时间的递归式:
Strassen方法
Strassen
运行时间的递归式:
代入法求解递归式
代入法求解递归式分为两步:
- 猜测解的形式
- 用数学归纳法求出解的常数,并证明解是正确的
例如,确定下列递归式的上界:
猜测其解为
首先假定次上界对所有正数都成立,特别对于,有。将其带入递归式,有:
其中,只要,最后一步都会成立
递归树求解递归式
在递归树中,每个结点表示一个单一子问题的代价,子问题对应某次递归函数调用
我们将树中的每层中的代价求和,得到每层代价,然后将所有层的代价求和,得到所有层次的递归调用的总代价
主方法求解递归式
令和是常数,是一个函数,是定义在非负整数上的递归式:
- 若对某个常数有,则
- 若对于某个常数有,则
- 若对某个常数有,且对某个常数和所有足够大的有,则
随机算法
雇佣问题
1 | HIRE-ASSISTANT(n) |
最坏情况分析
在最坏情况下,当应聘者质量按出现的次序严格递增时,总费用是
随机算法
如果一个算法的行为不仅由输入决定,而且也由随机数生成器产生的数值决定,则称这个算法是随机的
指示器随机变量
给定一个样本空间和一个事件,那么事件对应的指示器随机变量定义为:
给定一个样本空间和中的一个事件,设,那么
用指示器随机变量分析雇佣问题
应聘者比应聘者1到更有资格的概率为
所以有:
算法 HIRE-ASSISTANT
总的雇佣费用平均情形下为
随机算法
过程 RANDOMIZED-HIRE-ASSISTANT
的雇佣费用期望是:
1 | RANDOMIZED-HIRE-ASSISTANT(n) |
随机排列数组
假设所有优先级都不同,则过程 PERMUTE-BY-SORTING
产生输入的均匀随机排列:
1 | PERMUTE-BY-SORTING(A) |
过程 RANDOMIZE-IN-PLACE
可计算出一个均匀随机排列:
1 | RANDOMIZE-IN-PLACE(A) |
排序和顺序统计量
堆排序
堆
堆是一个数组,它可以被看成一个近似的完全二叉树
- 树上的每一个结点对应数组中的一个元素
- 除了最底层外,该树是完全充满的
- 表示数组元素的个数
- 表示有多少个堆元素存储在该数组中
- 一个堆中的结点的高度就为该结点到叶结点最长简单路径上边的数目
计算父结点、左孩子和右孩子的下标:
1 | PARENT(i) |
在最大堆中,最大堆性质指除了根以外的所有结点都要满足:
在最小堆中,最小堆性质指除了根以外的所有结点都要满足:
维护堆的性质
假定根结点为 LEFT(i)
和 RIGHT(i)
的二叉树都是最大堆,MAX-HEAPIFY
通过让的值在最大堆中“逐级下降”,从而使得以下标为根结点的子树重新遵循最大堆的性质:
1 | MAX-HEAPIFY(A, i) |
因为每个孩子的子树的大小至多为(最坏情况发生在树的最底层恰好半满的时候),我们可以用下面递归式刻画 MAX-HEAPIFY
的运行时间:
建堆
可以用自底向上的方法利用过程 MAX-HEAPIFY
把一个大小为的数组转换为最大堆:
1 | BUILD-MAX-HEAP(A) |
在一个高度为的结点上运行 MAX-HEAPIFY
的代价是,我们可以将 BUILD-MAX-HEAP
的总代价表示为:
因此,我们可以在线性时间内,把一个无序数组构造成一个最大堆
堆排序算法
HEAPSORT
过程的时间复杂度是:
1 | HEAPSORT(A) |
优先队列
优先队列是一种用来维护由一组元素构成的集合的数据结构,其中的每一个元素都有一个相关的值,称为关键字。一个最大优先队列支持以下操作:
INSERT(S, x)
:把元素插入集合中MAXIMUM(S)
:返回中具有最大键字的元素EXTRACT-MAX(S)
:去掉并返回中的具有最大键字的元素INCREASE-KEY(S, x, k)
:将元素的关键字增加到,这里假设的值不小于的原关键字值
优先队列可以用堆来实现:HEAP-MAXMIUM
时间复杂度:
1 | HEAP-MAXMIUM(A) |
HEAP-EXTRACT-MAX
时间复杂度:
1 | HEAP-EXTRACT-MAX(A) |
HEAP-INCREASE-KEY
时间复杂度:
1 | HEAP-INCREASE-KEY(A, i, key) |
MAX-HEAP-INSERT
时间复杂度:
1 | MAX-HEAP-INSERT(A, key) |
快速排序
快速排序描述
对一个子数组进行快速排序的三步分治过程:
- 分解:数组被划分为两个(可能为空)子数组和,使得中的每一个元素都小于等于,而也小于等于中的每个元素。其中,计算下标也是划分过程的一部分
- 解决:通过递归调用快速排序,对子数组和进行排序
- 合并:因为子数组都是原址排序的,所以不需要合并操作:数组已经有序
1 | QUICKSORT(A, p, r) |
PARTITION
过程实现了对子数组的原址重排:
1 | PARTITION(A, p, r) |
快速排序性能
最坏情况划分
当划分产生的两个子问题分别包含了个元素和0个元素时,为最坏情况
此时算法递归式可以表示为:
最好情况划分
在可能的最平衡的划分中,PARTITION
得到的两个子问题的规模都不大于
此时算法递归式可以表示为:
平衡的划分
任何一种常数比例的划分都会产生深度为的递归树,其中每一层的时间代价都是
因此,只要划分是常数比例的,算法的运行时间总是
对于平均情况的直观观察
当好和差的划分交替出现时,快速排序的时间复杂度与全是好的划分时一样,仍然是。区别只是符号中隐含的常数因子要略大一些
快速排序随机化版本
通过对序列的随机抽样,我们期望在平均情况下,对输入数组的划分是比较均衡的:
1 | RANDOMIZED-PARTITION(A, p, r) |
1 | RANDOMIZED-QUICKSORT(A, p, r) |
快速排序分析
最坏情况分析
快速排序的最坏情况运行时间是
期望运行时间
快速排序的期望运行时间是
线性时间排序
排序算法的下界
决策树模型
比较排序可以被抽象为一棵决策树
决策树是一棵完全二叉树,它可以表示在给定输入规模情况下,某一给定排序算法对所有元素的比较操作
在决策树中,每个内部结点都以标记,其中和满足,是输入序列中的元素个数
每一个内部结点表示一次比较
- 左子树表示一旦我们确定之后的后续比较
- 右子树表示一旦我们确定之后的后续比较
对于一个正确的比较排序算法来说,个元素的种可能的排列都应该出现在决策树的叶结点上。而且,每一个叶结点都必须是可以从根结点经由某条路径到达的
最坏情况的下界
在最坏情况下,任何比较排序算法都需要做次比较:
考虑一棵高度为,具有个可达叶结点的决策树,它对应一个对个元素所做的比较排序。因为输入数据的种可能的排列都是叶结点,所以有。由于在一个高度为的二叉树中,叶结点的数目 不多于,所以有:
对该式两边取对数,有
计数排序
计数排序假设个输入元素中的每一个都是在0到区间内的一个整数,其中为某个整数。当时,排序的运行时间为
在计数排序的代码中,假设输入是一个数组,,存放排序的输出,提供临时存储空间:
1 | COUNTING-SORT(A, B, k) |
计数排序时间复杂度,当时,时间复杂度
基数排序
假设个位的元素存放在数组中,其中第1位是最低位,第位是最高位:
1 | RADIX-SORT(A, d) |
给定一个位数和任何正整数,如果 RADIX-SORT
使用的稳定排序算法对数据取值区间是0到的输入进行排序耗时,那么它就可以在时间内将这些数据排好序
桶排序
桶排序假设输入数据服从均匀分布,平均情况下它的时间代价为
假设输入是一个包含个元素的数组,且每个元素满足。算法还需要一个临时数组来存放链表(即桶),并假设存在一种用于维护这些链表的机制:
1 | BUCKET-SORT(A) |
桶排序的期望运行时间为:
中位数和顺序统计量
最小值和最大值
假设该集合元素存放在数组中,且:
1 | MINIMUM(A) |
为了确定最小值,必须要做次比较
同时找到最小值和最大值
最多次比较就可以同时找到最小值和最大值:
首先,我们将一对输入元素相互进行比较,然后把较小的与当前最小值比较,把较大的与当前最大值进行比较。这样,对每两个元素共需3次比较:
- 如果是奇数,我们就将最小值和最大值的初值设为第一个元素的值,然后成对地处理余下的元素
- 如果是偶数,就对前两个元素做一次比较,以决定最小值和最大值的初值,然后成对处理余下的元素
期望为线性时间的选择算法
RANDOMIZED-SELECT
返回数组中第小的元素:
1 | RANDOMIZED-SELECT(A, p, r, i) |
RANDOMIZED-SELECT
的最坏情况运行时间为,期望运行时间为
最坏情况为线性时间的选择算法
通过执行下列步骤,算法 SELECT
可以确定一个有个不同元素的输入数组中的第小的元素
- 将输入数组的个元素划分为组,每组5个元素,且至多只有一组由剩下的个元素组成
- 寻找这组中每一组的中位数:首先对每组元素进行插入排序,然后确定每组有序元素的中位数
- 对第2步中找出的个中位数,递归调用
SELECT
以找出其中位数(如果有偶数个中位数,为了方便,约定是较小的中位数) - 利用修改过的
PARTITION
版本,按中位数的中位数对输入数组进行划分。让比划分的低区中的元素数目多1,因此是第小的元素,并且有个元素在划分的高区 - 如果,则返回。如果,则在低区递归调用
SELECT
来找出第小的元素。如果,则在高区递归查找第小的元素
在第2步找出的中位数中,至少有一半大于或等于中位数的中位数。因此,在这个组中,除了当不能被5整除时产生的所含元素少于5的那个组和包含的那个组之外,至少有一半的组中有3个元素大于。不算这两个组,大于的元素个数至少为:
类似地,至少有个元素小于。因此,在最坏情况下,在第5步中,SELECT
的递归调用最多作用于个元素。
由此可以得到如下递归式:
高级设计和分析技术
动态规划
我们通常按如下4个步骤来设计一个动态规划算法:
- 刻画一个最优解的结构特征
- 递归地定义最优解的值
- 计算最优解的值,通常采用自底向上的方法
- 利用计算出的信息构造一个最优解
钢条切割
给定一段长度为英寸的钢条和一个价格表,求切割钢条方案,使得销售收益最大
自顶向下 CUT-ROD
过程,加入了备忘机制,时间复杂度:
1 | MEMORIZED-CUT-ROD(p, n) |
自底向上版本,时间复杂度:
1 | BOTTOM-UP-CUT-ROD(p, n) |
重构解
BOTTOM-UP-CUT-ROD
的扩展版本,它对长度为的钢条不仅计算最大收益值,还保存最优解对应的第一段钢条的切割长度:
1 | EXTENDED-BOTTOM-UP-CUT-ROD(p, n) |
最后输出长度为的钢条的完整的最优切割方案:
1 | PRINT-CUT-ROD-SOLUTION(p, n) |
矩阵链乘法
给定一个个矩阵的序列(矩阵链),我们希望计算它们的乘积:
由于矩阵乘法满足结合律,因此任何加括号的方法都会得到相同的计算结果
我们称有如下性质的矩阵乘积链为完全括号化的:它是单一矩阵,或者是两个完全括号化的矩阵乘积链的积
给定个矩阵的链,矩阵的规模为,求完全括号化方案,使得计算乘积所需标量乘法次数最少
令表示计算矩阵所需标量乘法次数的最小值,则最小代价括号化方案的递归求解公式为:
MATRIX-CHAIN-ORDER
时间复杂度,另外还需要的内存空间来保存表和:
1 | MATRIX-CHAIN-ORDER(p) |
调用 PRINT-OPTIMAL-PARENS
可输出的最优括号化方案:
1 | PRINT-OPTIMAL-PARENS(s, i, j) |
最长公共子序列(LCS)
给定一个序列,令一个序列满足如下条件时称为的子序列:存在一个严格递增的的下标序列,对所有,满足
给定两个序列和,求和长度最长的公共子序列
我们定义表示和的 LCS
的长度,可得如下公式:
LCS-LENGTH
时间复杂度:
1 | LCS-LENGTH(X, Y) |
调用 PRINT-LCS
可打印出和的一个 LCS
:
1 | PRINT-LCS(b, X, i, j) |
最优二叉搜索树
给定一个个不同关键字的已排序的序列,我们希望用这些关键字构造一棵二叉搜索树,对每个关键字,都有一个概率表示其搜索频率。有些要搜索的值可能不在中,因此我们还有个“伪关键字”表示不在中的值。表示所有小于的值,表示所有大于的值,对,伪关键字表示所有在和之间的值。对每个伪关键字,也都有一个概率表示对应的搜索频率。每个关键字是一个内部结点,而每个伪关键字是一个叶结点:
有如下公式:
在中进行一次搜索的期望代价为:
对于一个给定的概率集合。我们希望构造一棵期望搜索代价最小的二叉搜索树,我们称为最优二叉搜索树
定义为在包含关键字的最优二叉搜素树中进行一次有所的期望代价,其中,且(当时,子树不包含实际关键字,只包含伪关键字)
当一棵子树成为一个结点的子树时,对于包含关键字的子树,子树的期望搜索代价的增加值为:
递归公式:
OPTIMAL-BST
时间复杂度:
1 | OPTIMAL-BST(p, q, n) |
贪心算法
活动选择问题
假定有一个个活动的集合。这些活动使用同一个资源,而这个资源在某个时刻只能供一个活动使用。每个活动都有一个开始时间和结束时间,其中。如果被选中,任务发生在半开时间区间期间。如果两个活动和满足和不重叠,则称它们是兼容的
在活动选择问题中,我们希望选出一个最大兼容活动集,假定活动已按结束时间的单调递增顺序排列:
最优子结构
用表示集合的最优解的大小,则可得递归式:
贪心选择
考虑任意非空子问题,令是中结束时间最早的活动,则在的某个最大兼容活动子集中
递归贪心算法
求解原问题可调用 RECURSIVE-ACTIVITY-SELECTOR(s, f, 0, n)
,在输入活动已按结束时间排序的前提下,时间复杂度:
1 | RECURSIVE-ACTIVITY-SELECTOR(s, f, k, n) |
迭代贪心算法
在输入活动已按结束时间排序的前提下,时间复杂度:
1 | GREEDY-ACTIVITY-SELECTOR(s, f) |
霍夫曼编码
前缀码
前缀码,即没有任何码字是其他码字的前缀。前缀码可以保证达到最优数据压缩率
使用二叉树来表示前缀码:其叶结点为给定的字符。字符的二进制码字用从根结点到该字符叶结点的简单路径表示。其中 0
意味着“转向左孩子”,1
意味着“转向右孩子”:
文件的最优编码方案总是对应一棵满二叉树,即每个非叶结点都有两个孩子结点
若为字母表且所有字符的出现频率为正数,则最优前缀码对应的树恰好有个叶结点,每个叶结点对应字母表中的一个字符,且恰有个内部结点
给定一棵对应前缀码的树。对于字母表中的每个字符,令属性表示在文件中出现的频率,令表示的叶结点在树中的深度。则编码文件需要:
个二进制位,我们将定义为的代价
构造霍夫曼编码
假定是一个个字符的集合,而其中每个字符都是一个对象,其属性给出了字符的出现频率。算法使用了一个以属性为关键字最小优先队列:
1 | HUFFMAN(C) |
假定是使用最小二叉堆实现,HUFFMAN
时间复杂度
如果将最小二叉堆换为 van Emde Boas
树,时间复杂度
摊还分析
聚合分析
这种方法用来确定一个个操作的序列的总代价的上界。因而每个操作的平均代价为。我们将平均代价作为每个操作的摊还代价,因此所有操作具有相同的摊还代价
栈操作
考虑由个 PUSH
、POP
和 MULTIPOP
组成的操作序列在一个空栈上的执行情况。其代价至多为,一个操作的平均时间为。所以,所有三种栈操作的摊还代价都是
二进制计数器递增
我们用一个位数组作为计数器,其中。当计数器中保存的二进制值为时,的最低位保存在中,而最高位保存在中。初始时,因此对所有,。为了将1(模)加到计数器的值上,我们使用如下过程:
1 | INCEREMENT(A) |
一般地,对一个初值为0的计数器,在执行一个由个 INCEREMENT
操作组成的序列的过程中,会翻转次。总翻转次数为:
因此,对一个初值为0的计数器,执行一个个 INCEREMENT
操作的序列的最坏情况时间为。每个操作的平均代价,即摊还代价为
核算法
用核算法进行摊还分析时,我们对不同操作赋予不同费用,赋予某些造成的费用可能多于或少于其实际代价。我们将赋予一个操作的费用称为它的摊还代价
当一个操作的摊还代价超出其实际代价时,我们将差额存入数据结构中的特定对象,存入的差额称为信用
对于后续操作中摊还代价小于实际代价的情况,信用可以用来支付差额
如果用表示第个操作的真实代价,用表示其摊还代价,则对任意个操作的序列,要求:
即数据结构所关联的信用必须一直为非负值
栈操作
为操作赋予如下摊还代价:
操作 | 代价 |
---|---|
PUSH | 2 |
POP | 0 |
MULTIPOP | 0 |
用1美元支付压栈操作的实际代价,将剩余1美元存为信用,用来支付将来出栈操作的代价
由于栈中的每个元素都存有1美元的信用,而栈中的元素始终是非负的,因此可以保证总信用值是非负的
因此,对任意个 PUSH
、POP
和 MULTIPOP
操作组成的序列,总摊还代价为总实际代价的上界由于总摊还代价为,总实际代价也是
二进制计数器递增
为操作赋予如下摊还代价:
操作 | 代价 |
---|---|
一次置位操作 | 2 |
当进行置位时,用1美元支付置为操作的实际代价,另1美元存为信用,用来支付将来复位操作的代价
由于每位都存有1美元的信用,而计数器中1的个数始终是非负的,因此可以保证总信用值是非负的
因此,对任意个 INCREMENT
操作,总摊还代价为总实际代价的上界。由于总摊还代价为,总实际代价也是
势能法
我们将对一个初始数据结构执行个操作。对每个,令为第个操作的实际代价,令为在数据结构上执行第个操作得到的结果数据结构。势函数将每个数据结构映射到一个实数,此值即为关联到数据结构的势。第个操作的摊还代价用势函数定义为:
个操作的总摊还代价为:
如果能定义一个势函数,使得,则总摊还代价给出了总实际代价的一个上界
我们通常将简单定义为0,然后说明对所有,有
栈操作
对于初始的空栈,有。由于栈中对象数目永远不可能为负,所以第步操作得到的栈具有非负的势,即:
如果第个操作是 PUSH
操作,此时栈中包含个对象,则势差为:
PUSH
摊还代价为:
如果第个操作是 MULTIPOP
操作,将个对象弹出栈,则势差为:
MULTIPOP
摊还代价为:
如果第个操作是 POP
操作,此时栈中包含个对象,则势差为:
POP
摊还代价为:
每个操作的摊还代价都是,因此,个操作的总摊还代价为,为总实际代价的上界,所以个操作的最坏情况时间为
二进制计数器递增
将计数器执行次 INCREMENT
操作后的势定义为:次操作后计数器中1的个数
假设第个 INCREMENT
操作将个位复位,则其实际代价至多为。势差为:
摊还代价为:
如果计数器从0开始,则。由于对所有均有,因此,一个个 INCREMENT
操作的序列的总摊还代价是总实际代价的上界,最坏情况时间为
动态表
表扩张
1 | TABLE-INSERT(T, x) |
第个操作的代价为:
个 TABLE-INSERT
操作的总代价为:
图算法
基本的图算法
图的表示
邻接链表
对于图来说,其邻接链表表示由一个包含条链表的数组所构成,每个结点有一条链表
对于每个结点,邻接链表包含所有与结点之间有边相连的结点
- 如果是一个有向图,则对于边来说,结点将出现在链表里,因此,所有邻接链表的长度之和等于
- 如果是一个无向图,则对于边来说,结点将出现在链表里,结点将出现在链表里,因此,所有邻接链表的长度之和等于
- 不论是有向图还是无向图,邻接链表存储空间需求均为
对邻接链表稍加修改,就可以用来表示权重图:只需要将边的权重值存放放在结点的邻接链表里
邻接链表的一个潜在缺陷是无法快速判断一条边是否是图中的一条边
邻接矩阵
图的邻接矩阵表示由一个的矩阵予以表示,该矩阵满足下述条件:
不管一个图有多少条边,邻接矩阵的空间需求均为
邻接矩阵也可以用来表示权重图:直接将边的权重存放在邻接矩阵中的第行第列记录上
广度优先搜索(BFS)
为了跟踪算法的进展,广度优先搜索在概念上将每个结点涂上白色、灰色或黑色。所有结点在一开始的时候均涂上白色。在算法的推进过程中,这些结点可能会变成灰色或黑色
如果边且结点是黑色,则结点既可能是灰色也可能是黑色。也就是说,所有与黑色结点邻接的结点都以被发现。对于灰色结点来说,其邻接结点中可能存在未被发现的白色结点
在执行广度优先搜索的过程中将构造出一棵广度优先树
假定输入图是以邻接链表所表示的:
1 | BFS(G, s) |
分析
广度优先搜索的总运行时间为
最短路径
我们定义从源结点到结点的最短路径距离为从结点到结点之间所有路径里面最少的边数
如果从结点到结点之间没有路径,则
我们称从结点到结点的长度为的路径为到的最短路径
设为一个有向图或无向图,又假设 BFS
以为源结点在图上运行。那么在算法执行过程中,BFS
将发现从源结点可以到达的所有结点,并在算法终止时,对于所有的。而且,对于任意可以从到达的结点,从源结点到结点的其中一条最短路径为从结点到结点的最短路径再加上边
广度优先树
我们定义图的前驱子图为,其中,
当运行在一个有向或无向图上时,BFS
过程所建造出来的属性使得前驱子图成为一棵广度优先树
PRINT-PATH
可打印出从源结点到结点的一条最短路径上的所有结点,这里假定 BFS
已经计算出一棵广度优先树:
1 | PRINT-PATH(G, s, v) |
深度优先搜索(DFS)
我们定义图的前驱子图为,其中
与广度优先搜索不同,深度优先搜索的前驱子图可能由多颗树组成,因为搜索可能从多个源结点重复进行
深度优先搜索的前驱子图形成一个由多颗深度优先树构成的深度优先森林,森林中的边仍然称为树边
像广度优先搜索一样,深度优先搜索算法在搜索过程中也是对结点进行涂色来指明结点的状态。每个结点的初始颜色都是白色,在结点被发现后变为灰色,在其邻接链表被扫描完成后变为黑色。该方法可以保证每个结点仅在一棵深度优先树中出现,因此,所有的深度优先树是不相交的
深度优先算法在每个结点盖上一个时间戳。每个结点有两个时间戳:第一个时间戳记录结点第一次被发现的时间(涂上灰色的时候),第二个时间戳记录的是搜索完成对的邻接链表扫描的时间(涂上黑色的时候)
输入图既可以是无向图,可以是有向图。变量是一个全局变量,用来计算时间戳:
1 | DFS(G) |
分析
深度优先搜索的总运行时间为
深度优先搜索的性质
括号化定理:在对有向或无向图进行的任意深度优先搜索中,对于任意两个结点和来说,下面三种情况只有一成立:
- 区间和区间完全分离,在深度优先森林中,结点不是结点的后代,结点也不是结点的后代
- 区间完全包含在区间内,在深度优先树中,结点是结点的后代
- 区间完全包含在区间内,在深度优先树中,结点是结点的后代
后代区间的嵌套:在有向或无向图的深度优先森林中,结点是结点的真后代当且仅当成立
白色路径定理:在有向或无向图的深度优先森林中,结点是结点的后代当且仅当在发现结点的时间,存在一条从结点到结点的全部由白色结点所构成的路径
边的分类
- 树边:为深度优先森林中的边,如果结点是因算法对边的探索而首先被发现,则是一条树边
- 后向边:后向边是将结点连接到其在深度优先树中(一个)祖先结点的边。由于有向图中可以有自循环,自循环也被认为是后向边
- 前向边:是将结点连接到其在深度优先树中一个后代结点的边
- 横向边:指其他所有的边。这些边可以连接同一棵深度优先树中的结点,只要其中一个结点不是另外一个结点的祖先,也可以连接不同深度优先树中的两个结点
结点的颜色能够告诉我们关于该条边的一些信息:
- 结点为白色表明该条边是一条树边
- 结点为灰色表明该条边是一条后向边
- 结点为黑色表明该条边是一条前向边或横向边
在对无向图进行深度优先搜索时,每条边要么是树边,要么是后向边
拓扑排序
对于一个有向无环图来说,其拓扑排序是中所有结点的一种线性次序,该次序满足如下条件:
如果图包含边,则结点在拓扑排序中处于结点的前面(如果图包含环路,则不可能排出一个线性次序)
下面的简单算法可以对一个有向无环图进行拓扑排序,完成时间:
1 | TOPOLOGICAL-SORT(G) |
强连通分量
有向图的强连通分量是一个最大结点集合,对于该集合中的任意一对结点和来说,路径和路径同时存在
下面的 Kosaraju
算法使用两次深度优先搜索来计算有向图的强连通分量,时间复杂度:
1 | STRONGLY-CONNECTED-COMPONENTS(G) |
最小生成树
最小生成树的形成
假定有一个连通无向图和权重函数。我们希望找出图的一棵最小生成树
使用贪心策略来生成最小生成树:
1 | GENERIC-MST(G, w) |
Kruskal算法
Kruskal
算法使用一个不相交集合数据结构来维护几个互不相交的元素集合。每个集合代表当前森林中的一棵树。通过测试 FIND-SET(u)
是否等于 FIND-SET(v)
来判断结点和结点是否属于同一棵树,使用 UNION
过程来对两棵树进行合并
时间复杂度:
1 | MST-KRUSKAL(G, w) |
Prim算法
Prim
算法每一步在连接集合和之外的结点的所有边中,选择一条轻量级边加入到中
连通图和最小生成树的根结点将作为算法的输入。在算法的执行过程中,所有不在树中的结点都存放在一个基于属性的最小优先队列中。对每个结点,属性保存的是连接和树中结点的所有边中最小边的权重。属性给出的是结点在树中的父结点
若使用二叉最小优先队列,时间复杂度;若使用斐波那契堆来实现最小优先队列,时间复杂度:
1 | MST-PRIM(G, w, r) |
单源最短路径
在最短路径问题中,我们给定一个带权重的有向图和权重函数,该权重函数将每条边映射到实数值的权重上
图中一条路径的权重是构成该路径的所有边的权重之和:
定义从结点a$u$到结点的最短路径权重如下:
从结点到结点的最短路径定义为任何一条权重为的从到的路径
常规概念
负权重的边
如果图不包含从源结点可以到达的权重为负值的环路,则对于所有的结点,最短路径权重都有精确定义,即使其取值为负数
如果图包含从可以达到的权重为负值的环路,则最短路径权重无定义
环路
最短路径不能包含权重为负值的环路
最短路径不能包含权重为正值的环路
最短路径的表示
值所诱导的前驱子图定义如下:
在算法终止时,是一棵最短路径树
最短路径不一定是唯一的,最短路径树也不一定是唯一的
松弛操作
对于每个结点,我们维持一个属性来记录从原结点到结点的最短路径权重的上界。我们称为到的最短路径估计。我们使用下面运行时间为的算法来对最短路径估计和前驱结点进行初始化:
1 | INITIALIZE-SINGLE-SOURCE(G, s) |
RELAX
过程对边在时间内进行松弛操作:
1 | RELAX(u, v, w) |
最短路径和松弛操作的性质
三角不等式性质:对于任何边,有
上界性质:对于所有的结点,有。一旦的取值达到,其值将不再发生变化
非路径性质:如果从结点到结点之间不存在路径,则有
收敛性质:对于某些结点,如果是图中的一条最短路径,并且在对边进行松弛前的任意时间有,则在之后的所有时间有
路径松弛性质:如果是从源结点到结点的一条最短路径,且对中的边所进行的松弛的次序为,,…,,则,该性质的成立与任何其他的松弛操作无关,即使这些松弛操作是与对上的边所进行的松弛操作是穿插进行的
Bellman-Ford算法
Bellman-Ford
算法解决一般情况下的单元最短路径问题,边的权重可以为负值Bellman-Ford
算法返回 TRUE
值当且仅当输入图不包含可以从源结点到达的权重为负值的环路:
1 | BELLMAN-FORD(G, w, s) |
Bellman-Ford
算法总运行时间
有向无环图中的单源最短路径问题
根据结点的拓扑排序次序来对带权重的有向无环图进行边的松弛操作,我们便可以在时间内计算出从单个源结点到所有结点之间的最短路径:
1 | DAG-SHORTEST-PATHS(G, w, s) |
Dijkstra算法
Dijkstra
算法要求所有边的权重都为非负值:
1 | DIJKSTRA(G, w, s) |
Dijkstra
算法总运行时间依赖于最小优先队列的实现:
- 通过利用结点的编号为来维持最小优先队列。在这种情况下,每次
INSERT
和DECREASE-KEY
操作的执行时间为,每次EXTRACT-MIN
的操作时间为,算法总运行时间为 - 若图为稀疏图,特别地,如果,则可以通过二叉堆来实现最小优先队列,每次
EXTRACT-MIN
的操作时间为,每次DECREASE-KEY
的操作时间为,构建最小二叉堆的成本为,算法总运行时间为。若所有结点都可以从源结点到达,则该时间为 - 若使用斐波那契堆来时间最小优先队列,每次
EXTRACT-MIN
的操作时间为,每次DECREASE-KEY
的操作时间为,算法总运行时间为
差分约束和最短路径
差分约束系统
设向量为差分约束系统的一个解,设为任意常数,则也是该差分约束系统的一个解
约束图
给定差分约束系统,其对应的约束图是一个带权重的有向图,这里:
- 约束图包含一个额外的结点,用来保证图中至少存在一个结点,从其出发可以到达所以其他的结点
- 如果是一个差分约束条件,则边的权重为
- 所有从结点发出的边的权重为0
给定差分约束系统,设是该差分约束系统所对应的约束图。如果图不包含权重为负值的环路,则:
是该系统的一个可行解。如果图包含权重为负值的环路,则该系统没有可行解
求解差分约束系统
一个有个未知变量和个约束条件的差分约束系统所生成的约束图有个结点和条边。使用 Bellman-Ford
算法可以在时间内求解该系统
所有结点对的最短路径
假定结点的编号为,因此,算法的输入将是一个的矩阵,该矩阵代表的是一个有个结点的有向图的边的权重,即,其中:
我们允许存在负权重的边,但假定图中不包括权重为负值的环路
所有结点对最短路径算法的表格输出也是一个的矩阵,其中代表的是从结点到结点的一条最短路径的权重
前驱结点矩阵,其中在或从到不存在路径时为,在其他情况下给出的是从结点到结点的某条最短路径上结点的前驱结点。由矩阵的第行所诱导的子图一个是一棵根结点为的最短路径树。对于每个结点,定义图对于结点的前驱子图为,其中:
如果是一棵最短路径树,则下面的过程将打印出从结点到结点的一条最短路径:
1 | PRINT-ALL-PAIRS-SHORTEST-PATH(Π, i, j) |
最短路径和矩阵乘法
最短路径的结构
考虑从结点到结点的一条最短路径,假定至多包含条边,还假定没有权重为负值的环路,且为有限值。如果,则的权重为0且不包含任何边。如果结点和结点不同,则将路径分解为,其中路径至多包含条边,则有:
所有结点对最短路径问题的递归解
设为从结点到结点的至多包含条边的任意路径中的最小权重。当时,从结点到结点之间存在一条没有边的最短路径当且仅当,因此有:
递归定义:
最短路径权重可以由下面的公式给出:
自底向上计算最短路径权重
EXTEND-SHORTEST-PATHS
过程可以在给定和的情况下,计算出,算法运行时间:
1 | EXTEND-SHORTEST-PATHS(L, W) |
设表示由算法 EXTEND-SHORTEST-PATHS(A, B)
所返回的矩阵“乘积”,我们可以计算出下面由个矩阵所构成的矩阵序列:
…
SLOW-ALL-PAIRS-SHORTEST-PATHS
过程可以在的时间内计算出矩阵:
1 | SLOW-ALL-PAIRS-SHORTEST-PATHS(W) |
可以仅用个矩阵乘积来计算矩阵:
…
由于,最后有FASTER-ALL-PAIRS-SHORTEST-PATHS
过程使用重复平方技术来计算上述矩阵序列,运行时间为:
1 | FASTER-ALL-PAIRS-SHORTEST-PATHS(W) |
Floyd-Warshall算法
所有结点对最短路径问题的一个递归解
设为从结点到结点的所有中间结点全部取自集合的一条最短路径的权重。递归定义如下:
矩阵给出的就是最后的答案:对于所有的,
自底向上计算最短路径权重
FLOYD-WARSHALL
算法的输入为一个的矩阵,返回最短路径权重矩阵,运行时间为:
1 | FLOYD-WARSHALL(W) |
构建一条最短路径
在 Floyd-Warshall
算法中,有多种不同的方法来构建最短路径
- 先计算最短路径权重矩阵,然后从矩阵来构造前驱矩阵
- 可以在计算矩阵的同时计算前驱矩阵。即计算一个矩阵序列,,…,。定义为从结点到结点的一条所有中间结点都取自集合的最短路径上的前驱结点
当时,从到的一条最短路径没有中间结点,因此:
对于,如果考虑路径,则所选择的结点的前驱与我们选择的从结点到结点的一条中间结点全部取自集合的最短路径上的前驱是一样的。否则,所选择的结点的前驱与选择的从结点到结点的一条中间结点全部取自集合的最短路径上的前驱是一样的。也就是说,对于,有:
有向图的传递闭包
定义图的传递闭包为图,其中
一种时间复杂度为的计算图的传递闭包的办法是给的每条边赋予权重1,然后运行 Floyd-Warshall
算法。如果存在一条从结点到结点的路径,则有;否则
另一种类似办法是:以逻辑或操作()和逻辑与操作()来替换 Floyd-Warshall
算法中的算术操作 min
和 +
,以此节省时间和空间
对于,定义:如果图中存在一条从结点到结点的所有中间结点都取自集合的路径,则为1;否则,为0。递归定义如下:
对于:
1 | TRANSITIVE-CLOSURE(G) |
用于稀疏图的Johnson算法
Johnson
算法可以在时间内找到所有结点对之间的最短路径
Johnson
算法使用的技术称为重新赋予权重:
如果图中所有的边权重均为非负值,则可以通过对每一个结点运行一次 Dijkstra
算法来找到所有结点对之间的最短路径;如果使用斐波那契堆最小优先队列,该算法的运行时间
如果图包含权重为负值的边,但没有权重为负值的环路,则只要计算出一组新的非负权重值,然后使用同样的方法。新赋予的权重必须满足以下两个重要性质:
- 对于所有结点对,一条路径是在使用权重函数时从结点到结点的一条最短路径,当且仅当是在使用权重函数时从到的一条最短路径
- 对于所有的边,新权重为非负值
重新赋予权重来维持最短路径
给定带权重的有向图,其权重函数为,设为任意函数,该函数将结点映射到实数上。对于每条边,定义:
设为从结点到结点的任意一条理解,那么是在使用权重函数时从结点到结点的一条最短路径,当且仅当是在使用权重函数时从结点到结点的一条最短路径,即当且仅当。而且,图在使用权重函数时不包含权重为负值的环路,当且仅当在使用权重函数也不包括权重为负值的环路。
计算所有结点对之间的最短路径
Johnson
算法在执行过程中需要使用 Bellman-Ford
算法和 Dijkstra
算法作为子程序来计算所有结点对之间的最短路径。该算算假定所有的边都保持在邻接链表里,其返回一个的矩阵,或者报告输入图包含一个权重为负值的环路:
1 | JOHNSON(G, w) |
如果使用斐波那契堆来实现 Dijkstra
算法里的最小优先队列,则 Johnson
算法的运行时间为,使用更简单的二叉最小堆实现则运行时间为
最大流
流网络
流网络和流
流网络是一个有向图,图中每条边有一个非负的容量值
如果边集合包含一条边,则图中不存在反方向的边
在图中不允许自循环,对于每个结点,流网络都包含一条路径
流网络图是连通的,且由于除源结点外的每个结点都至少有一条进入的边,有
流的形式化定义:
设为一个流网络,其容量函数为。设为网络的源结点,为汇点。中的流是一个实值函数:,满足下面的两条性质:
- 容量限制:对于所有的结点,要求
- 流量守恒:对于所有的结点,要求:
当时,从结点到结点之间没有流,因此
一个流的值定义如下:
Ford-Fulkerson方法
Ford-Fulkerson
方法循环增加流的值。在开始的时候,对于所有的结点,,给出的初始流值为0。在每次迭代中,我们将图的流值进行增加,方法就是在一个关联的残存网络中寻找一条增广路径。重复对流进行这一过程,直到残存网络中不再增加增广路径为止:
1 | FORD-FULKERSON-METHOD(G, s, t) |
残存网络
残存网络由那些仍有空间对流量进行调整的边构成。流网络的一条边可以允许的额外流量等于该边的容量减去该边上的流量:
- 如果该差值为正,则将该条边置于图中,并将其残存容量设置为;同时将边加入到图中,并将其残存容量设置为
- 如果边的流量等于其容量,则其,该条边将不属于图
形式化地,假定有一个流网络,其源结点为,汇点为。设为图中的一个流,考虑结点对,定义残存容量:
给定一个流网络和一个流,则由所诱导的图的残存网络为,其中:
如果是的一个流,是对应的残存网络中的一个流,定义为流对流的递增,它是一个从的函数,其定义如下:
增广路径
给定流网络和流,增广路径是残存网络中一条从源结点到汇点的简单路径
我们称在一条增广路径上能够为每条边增加的流量的最大值为路径的残存容量,该容量由下面的表达式给出:
流网络的切割
流网络中的一个切割将结点集合划分为和两个集合,使得,。若是一个流,则定义横跨切割的净流量如下:
切割的容量:
一个网络的最小切割是整个网络中容量最小的切割
设为流网络的一个流,该流网络的源结点为,汇点为,设为流网络的任意切割,则横跨切割的净流量为
流网络中任意流的值不能超过的任意切割的容量
设为流网络中的一个流,该流网络的源结点为,汇点为,则下面的条件是等价的:
- 是的一个最大流
- 残存网络不包括任何增广路径
- ,其中是流网络的某个切割
基本的Ford-Fulkerson算法
在 Ford-Fulkerson
方法的每次迭代中,寻找某条增广路径,然后使用来对流进行修改(增加)。通过为每条边更新流属性来计算流网络中的最大流:
1 | FORD-FULKERSON(G, s, t) |
如果表示转换后网络中的一个最大流,则 Ford-Fulkerson
算法的运行时间为
Edmonds-Karp算法
通过在算法第3行寻找增广路径的操作中使用广度优先搜索来改善 Ford-Fulkerson
算法的效率:
在残存网络中选择的增广路径是一条从源结点到汇点的最短路径,其中每条边的权重为单位距离Edmonds-Karp
算法的运行时间为