原文链接:http://harttle.com/2013/10/28/introduction-to-algorithms.html
//INSERTION-SORT(A)
for j = 2 to A.length
key = A[j]
//Insert A[j] into the sorted sequence A[1..j-1]
i = j - 1
while i > 0 and A[i] > key
A[i + 1] = A[i]
i = i - 1
A[i+1] = key
//MERGE(A,p,q,r)
n1 = q - p + 1
n2 = r - q
let L[1..n1+1] and R[1..n2+1] be new arrays
for i = 1 to n1
L[i]=A[p+i-1]
for j = 1 to n2
R[j] = A[q+j]
L[n1+1] = MAX
R[n2+1] = MAX
for k = p to r
if L[i] <= R[j]
A[k] = L[i]
i = i+1
else
A[k] = R[j]
j = j+1
//MERGE-SORT(A,p,r)
if p<r
q=(p+r)/2
MERGE-SORT(A,p,q)
MERGE-SORT(A,q+1,r)
MERGE(A,p,q,r)
归并排序算法的分析
最坏情况运行时间
T(n)=⎧⎩⎨⎪⎪O(1)=c,n=12T(n/2)+O(n)=2T(n/2)+cn,n>1
T(n)=Θ(nlgn)
在分治策略中,我们递归地求解一个问题,在每层递归中应用如下三个步骤:
三种求解递归式的方法:
任何连续数组所处的位置必然是以下三种之一:
运行时间
T(n)=Θ(nlgn)
SQUARE-MATRIX-MULTIPLY: T(n)=Θ(n3)
T(n)=Θ(n3) ,简单的分治算法并不由于直接的SQUARE-MATRIX-MULTIPLY过程。
Strassen算法包括四个步骤:
递归式:
T(n)=⎧⎩⎨⎪⎪O(1)=c,n=17T(n/2)+O(n)=2T(n/2)+cn,n>1
根据主方法,得到该递归式的解为 Ω(nlg7)
对于递归式 T(n)=aT(n/b)+f(n) ,将函数 f(n) 与 nlogba 进行比较:
假设应聘办公助理的候选人编号为1到n,在面试完应聘者i后,如果他比目前的办公助理更合适,就会辞掉当前的办公助理,然后聘用他。估算雇佣过办公助理的总费用(雇佣一个办公助理费用为 ch )。
HIRE-ASSISTENT(n)
best = 0 //candidate 0 is a least-qualified dummy candidate
for i = 1 to n
interview candidate i
if candidate i is better than candidate best
best = i
hire candidate i
总费用为 O(chn)
给定样本空间S和一个事件A,那么事件A对应的 指示器随机变量 IA 定义为:
I{A}=⎧⎩⎨⎪⎪1, if A happened0, if A didn′t happen
举一个简单的例子,我们来确定抛掷硬币时正面朝上的期望次数。样本空间为 S={H,T} ,其中 Pr{H}=Pr{T}=1/2 ,指示器随机变量
XH=I{H}=⎧⎩⎨⎪⎪1, if H happened0, if T happened
在一次抛掷中,正面朝上的期望次数为指示器变量 XH 的期望值:
E[XH]=E[I{H}]=1⋅Pr{H}+0⋅Pr{T}=1⋅(1/2)+0⋅(1/2)=1/2
n次抛掷中出现正面的总次数 X=∑ni=1Xi
正面朝上次数的期望 E[X]=E[∑ni=1Xi]=∑ni=1E[Xi]=∑ni=11/2=n/2
应聘者i比1到i-1更有资格的概率为1/i,因而 E[Xi]=1/i
故雇佣总数为 E[X]=E[∑ni=1Xi]=∑ni=11/i=lnn ,雇佣费用平均情形下为 O(chlnn)
算法中的随机排列使得输入次序不再相关,因而没有特别的输入会引出它的最坏情况行为。
对于雇用问题,只需要随机地变换应聘者序列
RANDOMIZED-HIRE-ASSISTANT(n)
randomly permute the list of candidates
best = 0 //candidate 0 is a least-qualified dummy candidate
for i = 1 to n
interview candidate i
if candidate i is better than candidate best
best = i
hire candidate i
产生 均匀随机排列 (等可能地产生数字1~n的每一种排列)
PERMUTE-BY-SORTING(A)
n = A.length
let P[1...n] be a new array
for i = 1 to n
P[i] = RANDOM(1, n**3)
sort A, using P as sort keys
可以证明,P中所有元素都唯一的概率至少是 1−1/n 。假设所有优先级都不同,则过程PERMUTE-BY-SORTING产生输入的均匀随机排列。
RANDOMIZE-IN-PLACE(A)
n = A.length
for i =1 to n
swap A[i] with A[RANDOM(i, n)]
可以证明,过程RANDOMIZE-IN-PLACE可计算出一个均匀随机排列。
具有n个元素的 k排列(k-permutation)是包含这n个元素中的k个元素的序列,并且不重复,一共有 n!/(n−k)! 种可能的k排列。
算法 | 最坏情况运行时间 | 平均情况/期望运行时间 |
---|---|---|
插入排序 | Θ(n2) | Θ(n2) |
归并排序 | Θ(nlgn) | Θ(nlgn) |
堆排序 | O(nlgn) | — |
快速排序 | Θ(n2) | Θ(nlgn) (期望) |
计数排序 | Θ(k+n) | Θ(k+n) |
基数排序 | Θ(d(n+k)) | Θ(d(n+k)) |
桶排序 | Θ(n2) | Θ(n) (平均情况) |
算法 | 时间复杂度 | 空间原址性 |
---|---|---|
插入排序 | O(n2) | 是 |
归并排序 | O(nlgn) | 否 |
堆排序 | O(nlgn) | 是 |
计算父节点、左右孩子节点下标
PARENT(i)
return i/2
LEFT(i)
return 2i
RIGHT(i)
return 2i+1
MAX-HEAPIFY(A, i) 通过逐级下降,使得下标为 i 的根节点的子树符合最大堆的性质
MAX-HEAPIFY(A, i)
l = LEFT(i)
r = RIGHT(i)
if l <= A.heap-size and A[l] > A[i]
largest = l
else
largest = i
if r <= A.heap-size and A[r] > A[largest]
largest = r
if largest != i
exchange A[i] with A[largest]
MAX-HEAPIFY(A, largest)
每个孩子的子树的大小最多为 2n/3 (最坏情况发生在树的最底层半满的时候),故MAX-HEAPIFY运行时间
T(n)≤T(2n/3)+Θ(1) ,解为 T(n)=O(lgn) 。
BUILD-MAX-HEAP 把大小为 n = A.length 的数组 A[1..n] 转换为最大堆。
BUILD-MAX-HEAP(A)
A.heap-size = A.length
// 子数组 $A(n/2+1..n)$ 中的元素都是叶节点
for i = A.length/2 downto 1
MAX-HEAPIFY(A, i)
HEAPSORT(A)
BUILD-MAX-HEAP(A)
for i = A.length downto 2
exchange A[1] wiith A[i]
A.heap-size = A.heap-size - 1
MAX-HEAPIFY(A, 1)
MAX-HEAPIFY 时间复杂度为 O(lgn) ,被HEAPSORT n-1 次调用,故 HEAPSORT 的时间复杂度为 O(nlgn)
HEAP-MAXINUM(A)
return A[1]
HEAP-EXTRACT-MAX(A)
if A.heap-size < 1
error "heap underflow"
max = A[1]
A[1] = A[A.heap-size]
A.heap-size = A.heap-size - 1
MAX-HEAPIFY(A, 1)
return max
HEAP-EXTRACT-MAX 的时间复杂度为 O(lgn) (取决于MAX-HEAPIFY的时间复杂度)。
HEAP-INCREASE-KEY(A, i, key)
if key < A[i]
error "new key is smaller than current key"
A[i] = key
while i > 1 and A[PARENT(i)] < A[i]
exchange A[i] with A[PARENT(i)]
i = PARENT(i)
HEAP-INCREASE-KEY 中当前元素不断与父元素比较,当前元素大则将二者交换,直至当前元素的关键字小于父节点。时间复杂度为 O(lgn) 。
MAX-HEAP-INSERT(A, key)
A.heap-size = A.heap-size + 1
A[A.heap-size] = - MAX_INT
HEAP-INCREASE-KEY(A, A.heap-size, key)
MAX-HEAP-INSERT的时间复杂度为 O(lgn) 。
快速排序的时间复杂度为 Θ(nlgn) ,能够进行原址排序。
QUICKSORT(A, p, r)
if p < r
q = PARTITION(A, p, r)
QUICKSORT(A, p, q-1)
QUICKSORT(A, q+1, r)
//将数组分为两部分,返回临界值下标
PARTITION(A, p, r)
x = A[r] //以最后一个数为主元(pivot element)
i = p-1 //小于主元子数组的下标上限
for j = p to r-1
if A[j] <= x
i = i+1 //增加小于主元子数组的大小
exchange A[i] with A[j] //将A[j]加入小于主元的子数组
exchange A[i+1] with A[r] //将主元从数组末尾移动至子数组之间
return i + 1
为了排序一个数组A,初始调用为:QUICKSORT(A, 1, A.length)。
当划分产生的两个子问题分别包含 n-1 和 0 个元素时,最坏情况发生。划分操作的时间复杂度为 Θ(n) , T(0)=Θ(1) ,这时算法运行时间的递归式为
T(n)=T(n−1)+T(0)+Θ(n)=T(n−1)+Θ(n) ,解为 T(n)=Θ(n2) 。
当划分产生的两个子问题分别包含 ⌊n/2⌋ 和 ⌈n/2⌉−1 个元素时,最好情况发生。算法运行时间递归式为
T(n)=2T(n/2)+Θ(n) ,解为 T(n)=Θ(nlgn) 。
只要划分是常数比例的,算法的运行时间总是 O(nlgn) 。
假设按照 9:1 划分,每层代价之多为 cn,递归深度为 log10/9n=Θ(lgn) ,故排序的总代价为 O(nlgn) 。
从直观上看,差划分引起的二次划分代价 Θ(n−1) 可以被吸收到差划分代价 Θ(n) 中去,而得到与好划分一样好的结果。
可以通过在算法中引入随机性,使得算法对所有输入都能获得较好的期望性能。
//新的划分程序,只是在真正进行划分前进行一次交换
RANDOMIZED-PARTITION(A, p, r)
i = RANDOM(p, r)
exchange A[r] with A[i]
return PARTITION(A, p, r)
使用代入法证明快速排序的时间复杂度为 O(n2) 。假设T(n)为最坏情况下 QUICKSORT 在输入规模为 n 的数据集合上所花费的时间,则有
T(n)=max0≤q≤n−1(T(q)+T(n−q−1))+Θ(n)
将 T(n)≤cn2 带入右侧,得到
T(n)≤max0≤q≤n−1(cq2+c(n−q−1)2)+Θ(n)=cn2−c(2n−1)+Θ(n)≤cn2 。
故 T(n)=O(n2)
设 PARTITION 的第4行所做的比较操作次数为X,则 QUICKSORT 的运行时间为 O(n+X) 。
因为 PARTITION 至多被调用n次,每次调用包括固定的工作量和for循环,for循环都要执行第4行。
我们考察第四行的比较操作的实际执行次数:
因每一对元素至多比较一次,故总的比较次数:
X=∑n−1i=1∑nj=i+1Xij ,
总比较次数的期望:
E(X)=∑n−1i=1∑nj=i+1Pr(zi compared with zj) 。
zi 与 zj 进行比较,当且仅当Z_ij Zij (共j-i+1个元素)中被选中的第一个主元为z_i zi 或z_j zj ,即:
Pr(z_i~compared~with~z_j) = \frac{2}{j-i+1} Pr(zi compared with zj)=2j−i+1
故总比较次数期望:
E(X) = \sum_{i=1}^{n-1}\sum_{j=i+1}^{n}\frac{2}{j-i+1} \lt \sum_{i=1}^{n-1}\sum_{k=1}^{n}\frac{2}{k} = \sum_{i=1}^{n-1}O(\lg n) = O(n\lg n) E(X)=∑n−1i=1∑nj=i+12j−i+1<∑n−1i=1∑nk=12k=∑n−1i=1O(lgn)=O(nlgn)
比较排序可以被抽象为一棵 决策树。决策树是一棵完全二叉树,它可以表示在给定输入规模情况下,某一特定排序算法对所有元素的比较操作。
在决策树中,每个内部结点以被比较数的下标 i:j 标记,每个叶节点都标注一个序列。排序算法的执行对应于一条从根节点到叶节点的路径,每个内部结点表示一次比较,左子树表示 a[i]<=a[j]的后续比较,右子树表示a[i]>a[j]的后续比较。如图。
在最坏情况下,任何比较排序算法都需要做 Ω(nlgn) 次比较。
- 因为输入数据的 n! 中可能的情况都必须出现在叶节点,故 n!≤2h ,即 h≥lg(n!)=Θ(nlgn) , h=Ω(nlgn) 。
- 比较算法最坏情况下的比较次数等于其决策树的高度。
- 堆排序和归并排序都是渐近最优的比较排序算法。
计数排序假设n个输入元素均为[0, k]的整数,当 k=O(n) 时,排序的运行时间为 Θ(n) 。
计数排序的基本思想是:对每一个输入x,确定小于x的元素个数,然后把x直接放到输出数组的相应位置上。
COUNTING-SORT(A, B, k)
let C[0..k] be a new array
for i = 0 to k
C[i] = 0
for j = 1 to A.length
C[A[j]]=C[A[j]]+1
//now C[i] contains the number of elements equal to i
for i = 1 to k
C[i]=C[i]+C[i-1]
//now C[i] contains the number of elements <= i
for j = A.length downto 1
B[C[A[j]]]=A[j]
C[A[j]]=C[A[j]]-1
基数排序 先按 最低有效位 进行排序,之后用同样的方法按次低有效位进行排序,直至所有数都排好。
- 计数排序是一种用在卡片排序机上的算法,因卡片机需要排成一排而不能从高位递归地排序。
- 为了确保基数排序的正确性,一位数排序算法必须是稳定的。
RADIX-SORT(A, d)
for i = 1 to d
use a stable sort to sort array A on digit i
给定n个k进制d位数,如果使用的稳定排序算法耗时 Θ(n+k) ,那么RADIX-SORT的时间代价为 Θ(d(n+k)) 。
给定一个b位2进制数(k=2)和正整数r<=b,如果使用的稳定排序算法耗时 Θ(n+k) ,那么RADIX-SORT的时间代价为 Θ((b/r)(n+2r)) 。
将b位2进制数转化为b/2位 2r 进制数。
假设 b≥⌊lgn⌋ ,选择 r=⌊lgn⌋ ,得到RADIX-SORT运行时间为 Θ(bn/lgn) 。特殊地,如果 b=O(lgn) ,将得到基数排序的运行时间: Θ(n) 。
渐近意义上,基数排序要比快速排序的期望运行时间( Θ(nlgn) )更好,但是两个表达式中隐含的常数因子是不同的。
利用计数排序作为中间稳定排序的基数排序不是原址排序。
桶排序假设输入数据服从均匀分布,平均情况下时间代价为 O(n)。
桶排序将 [0, 1) 区间划分为 n 个相同大小的子区间,称为 桶。然后将 n 个输入按大小放入各个桶中,先对每个桶中的数进行排序,然后遍历输出每个桶中的数。
BUCKET-SORT(A)
n = A.length
let B[0..n-1] be a new array
for i = 0 to n-1
make B[i] an empty list
for i = 1 to n
insert A[i] into list B[nA[i]]
for i = 0 to n-1
sort list B[i] with insertion sort
concatenate the lists B[0],B[1],...,B[n-1] together in order
桶排序的时间代价为:
T(n)=Θ(n)+∑n−1i=0O(n2i)
期望运行时间:
E[T(n)]=E[Θ(n)+∑n−1i=0O(n2i)]=Θ(n)+∑n−1i=0O(E[n2i])
定义指示器随机变量 Xij=I{A[j] in bucket i} ,则 ni=∑nj=1Xij ,
E[n2i]=E[(∑nj=1Xij)2]=∑nj=1E[X2ij]+∑1≤j≤n∑1≤k≤n,k≠jE[XijXik]
而 E[XijXik]=E[X2ij]=1n2 , E[X2ij]=12⋅1n+02⋅(1−1n))=1n ,
故 E[n2i]=2−1/n ,桶排序的期望运行时间 E[T(n)]=Θ(n)+n⋅O(2−1/n)=Θ(n) 。
即使输入数据不服从均匀分布,只要所有桶的大小的平方和与元素数呈线性关系,期望运行时间就是 Θ(n)
在一个由n个元素组成的集合中,第i个 顺序统计量(order statistic)是该集合中第i小的元素, 最小值 是第一个顺序统计量, 最大值 是第n个顺序统计量, 中位数 是所属集合的“中点元素”。
n为奇数时,中位数是唯一的;n为偶数时,存在两个中位数,分别为 上中位数和 下中位数。
选择问题定义为:
MINIMUM(A)
min = A[1]
for i = 2 to A.length
if min > A[i]
min = A[i]
return min
如果n为奇数,将最大值最小值设为第一个元素,成对比较其余元素,将较大者与最大值比较,将较小者与最小值比较;如果n为偶数,比较前两个,将最大值设为较大者,最小值设为较小者,此后继续成对比较。
RANDOMIZED-SELECT(A, p, r, i)
if p == r
return A[p]
q = RANDOMIZED-PARTITION(A, p, r)
k = q-p+1
if i == k //the pivot value is the answer
return A[q]
else if i < k
return RANDOMIZED-SELECT(A, p, q-1, i)
else return RANDOMIZED-SELECT(A, q+1, r, i-k)
RANDOMIZED-SELECT的最坏情况运行时间为
Θ(n2) ,即使找最小元素也是如此,因为在每次划分时极不走运地总是按余下元素中最大的来进行划分,而划分操作需要
Θ(n) 时间。 假设所有元素都是互异的,在期望线性时间内,我们可以找到任一顺序统计量,特别是中位数。即RANDOMIZED-SELECT算法的期望运行时间为
Θ(n) 。
可通过定义子数组A[p..q]正好包含k个元素的顺序统计量来得到递归式并使用归纳法加以证明。直观地讲,因为平均每次只保留一半,每层调用的执行时间将是等比数列,求和后得到总时间为2n。
STACK-EMPTY(S)
if S.top == 0
return TRUE
else return FALSE
PUSH(S, x)
S.top = S.top + 1
S[S.top] = x
POP(S)
if STACK-EMPTY(S)
error "underflow"
else S.top = S.top - 1
return S[S.top +1]
**队列**(queue)实现的是一种先进先出策略。
ENQUEUE(Q, x)
Q[Q.tail] = x
if Q.tail == Q.length
Q.tail = 1
else Q.tail = Q.tail + 1
DEQUEUE(Q)
x = Q[Q.head]
if Q.head == Q.length
Q.head = 1
else Q.head = Q.head + 1
return x
- 如果x.prev=NIL,则元素x没有先驱,因此是链表的第一个元素,即链表的 头(head);如果x.next=NIL,则元素x没有后继,因此是链表的最后一个元素,即链表的 尾(tail)。
- L.head 指向链表的第一个元素。如果L.head=NIL,则链表为空。
- 如果一个链表是 单链接的(singly linked),则省略每个元素中的prev指针。
LIST-SEARCH(L, k)
x = L.head
while x != NIL and x.key != k
x = x.next
return x
LIST-INSERT(L, x)
x.next = L.head
if L.head != NIL
L.head.prev = x
L.head = x
x.prev = NIL
LIST-DELETE(L, x)
if x.prev != NIL
x.prev.next = x.next
else L.head = x.next
if x.next != NIL
x.next.prev = x.prev
对于存在 **哨兵**(sentinel)的双向循环链表(circular,doubly linked list with a sentinel),L.nil.next指向表头,L.nil.prev指向表尾。
慎用哨兵,假如有许多个很短的链表,哨兵将造成严重的存储浪费。仅当可以真正简化代码时才使用哨兵。
LIST-DELETE'(L, x) x.prev.next = x.next x.next.prev = x.prev LIST-SEARCH'(L, k)
x = L.nil.next
while x != L.nil and x.key != k
x = x.next
return x
LIST-INSERT'(L, x) x.next = L.nil.next L.nil.next.prev = x L.nil.next = x x.prev = L.nil
对象的分配与释放
//全局变量free指向自由表中的第一个元素
ALLOCATE-OBJECT()
if free == NIL
error "out of space"
else x = free
free = x.next
return x
FREE-OBJECT(x)
x.next = free
free = x
**二叉树**T的属性p、left、right分别存放指向父结点、左孩子和右孩子的指针。
如果x.p = NIL,则x是根节点;如果x没有左孩子,则 x.left = NIL,右孩子的情况与此类似;属性T.root 指向整棵树T的根节点。如果T.root = NIL,则该树为空。
分支无限制的有根数可以使用 左孩子有兄弟表示法(left-child,right-sibling representation)
x.left-child 指向结点x最左边的孩子结点
x.right-sibling 指向右侧相邻的兄弟结点。
树的其他表示方法:对一棵完全二叉树使用堆来表示,堆用一个单数组加上堆的最末结点的下标表示。
散列表(hash table)是实现了字典操作(INSERT,SEARCH,DELETE)的一种有效数据结构。在一些合理的假设下,在散列表中查找一个元素的平均时间是 O(1) 。
在直接寻址方式下,具有关键字k的元素被放在槽k中。
为表示动态集合,我们用一个称为 直接寻址表(direct-address table)的数组,记为 T[0..m-1]。数组中的位置称为 槽(slot),每个槽对应全域U中的一个关键字。如果该集合中没有关键字为k的元素,则 T[k]=NIL。
几个字典操作:
DIRECT-ADDRESS-SEARCH(T, k)
return T[k]
DIRECT-ADDRESS-INSEART(T, x)
T[x.key] = x
DIRECT-ADDRESS-DELETE(T, x)
T[x.key]=NIL
在散列方式下,具有关键字k的元素方舟子槽 h(k) 中。即利用 散列函数(hash function)h,由关键字 k 计算出槽的位置。函数h将关键字的全域U映射到 散列表(hash table)T的槽位上:
h: U -> {0,1,…,m-1}
CHAINED-HASH-INSERT(T, x)
insert x at the head of list T[h(x.key)]
CHAINED-HASH-SEARCH(T, k)
search for an element with key k in list T[h(k)]
CHAINED-HASH-DELETE(T, x)
delete x from the list T[h(x.key)]
在简单均匀三列的假设下,对于用链接法解决冲突的散列表,一次不成功查找的平均时间为 Θ(1+α) 。
对于一次不成功的查找,首先计算槽位置 h(k),时间为 Θ(1) ;然后遍历该槽上链表中的所有元素,平均个数为 α 。故一次不成功查找的平均时间为 Θ(1+α) 。
在简单均匀三列的假设下,对于用链接法解决冲突的散列表,一次成功查找的平均时间为 Θ(1+α) 。
同上,但是遍历槽上链表中的元素时,平均遍历个数为 α/2 ,故一次成功查找的平均时间为 Θ(1+α/2)=Θ(1+α) 。
好的散列函数的特点
h(k) = k mod m
当应用除法散列法时,要避免选择m的某些值(例如远离2的幂次)。
假设 m=2p−1 ,k 为按基数 2p 表示的字符串,则很容易证明,散列值只与字符串各字符ASCII值的和有关。
h(k)=⌊m(kAmod 1)⌋ , 0<A<1
任何一个特定的散列函数都可通过选择特定的关键字,使得n个关键字全部散列到同一个槽中,此时平均检索时间为 Θ(n) 。为了避免这种情况,可以随机地选择散列函数,使之独立于要存储的关键字。这种方法称为 全域散列(universal hashing)
在 开放寻址法(open addressing)中,所有的元素都存放在散列表里,每个表项包含动态集合中的一个元素,或者NIL。
此时,装载因子永远不会超过1。
为了插入一个元素,需要连续地检查散列表,称为 探查(probe)。
需要将散列函数加以扩充,将探查号作为第二个参数。对于每个关键字 k,产生 0~m-1 的探查序列(同样,m为槽数,n为元素数)。
HASH-INSERT(T, k)
i=0
repeat
j = h(k,i) //j为探查序列的第i项的存储位置
if T[j] == NIL
T[j]=k
return j
else i=i+1
until i==m
error "hash table overflow"
HASH-SEARCH(T, k)
i=0
repeat
j=h(k,i)
if T[j] == k
return j
i = i+1
until T[j] == NIL or i==m
return NIL
删除操作比较困难。可以将删除的元素赋值为DELETED而不是NIL,使得在此仍可以插入元素,而SEARCH则会跳过该槽。
此时,查找时间不再依赖于装载因子了。为此,在必须删除关键字的应用中,更常见的做法是采用链接法来解决冲突。
在 线性探查(linear probing)中,采用散列函数:
h(k,i)=(h′(k)+i)mod m, i=0,1,...,m−1
随着连续被占用的槽不断增加,平均查找时间随之增加。称为 一次群集(primary clustering)。
在 二次探查(quadratic probing)中,采用散列函数:
h(k,i)=(h′(k)+c1i+c2i2)mod m
在二次探查中,如果两关键字的初始探查位置相同,在他们的探查序列也是相同的。称为 二次群集(secondary clustering)。
双重散列(double hashing)是用于开放寻址法的最好方法之一。采用如下散列函数
h(k,i)=(h1(k)+ih2(k))mod m
给定一个装载因子为 α 的开放寻址散列表,并假设均匀散列,则对于一次不成功的查找,期望的探查次数至多为 1/(1−α) 。
对于不成功的查找,第j次查找相当于在 m-(j-1) 个未探查的槽中,查找 n-(j-1) 个元素中的任一个。
给定一个装载因子为 α 的开放寻址散列表,平均情况下,向一个装载因子为 α 的开放寻址散列表中插入一个元素至多需要做 1/(1−α) 次探查。
对于一个装载因子为 α<1 的开放寻址散列表,一次成功查找中的探查期望数至多为 1αln11−α 。
完全散列(perfect hashing)进行查找时,能在最坏情况下用 O(1) 次访存完成。
采用两级的散列方法设计完全散列方案。
二叉搜索树:对任何结点x,其左子树中的关键字最大不超过x.key,其右子树中的关键字最小不低于x.key。
二叉搜索树不一定是平衡的,其操作时间为 O(h)。当其非常不平衡时,O(h) 将远远超过 O(lg n)。
中序遍历(inorder tree walk):输出的子树根的关键字位于其左子树的关键字值和右子树的关键字值之间。
类似, 先序遍历(preorder tree walk)中输出根的关键字在其子树的关键字之前; 后序遍历(postorder tree walk)输出的根的关键字在其子树的关键字之后。
INORDER-TREE-WALK(x)
if x != NIL
INORDER-TREE-WALK(x.left)
print x.key
INORDER-TREE-WALK(x.left)
输入一个指向树根的指针x和关键字k,如果这个结点存在,TREE-SEARCH返回一个指向关键字为k的结点的指针;否则返回NIL。
TREE-SEARCH
if x==NIL or k==x.key
return x
if k < x.key
return TREE-SEARCH(x.left, k)
else return TREE-SEARCH(x.right, k)
或者采用非递归方式
在大多数计算机,接待版本的效率要高得多
ITERATIVE-TREE-SEARCH(x, k)
while x!=NIL and k!=x.key
if k<x.key
x = x.left
else x = x.right
return x
TREE-MINIMUM(x)
while x.left != NIL
x = x.left
return x
TREE-MAXIMUM(x)
while x.right != NIL
x = x.right
return x
TREE-SUCCESSOR(x)
if x.right != NIL //右子树存在:返回右子树最小值
return TREE-MINIMUM(x, right)
y = x.p
while y != NIL and x == y.right //右子树不存在:返回第一个在右侧的祖父结点
x = y
y = y.p
return y
在一棵高度为h的二叉搜索树上,动态集合上的操作 SEARCH、MINIMUM、MAXIMUM、SUCCESSOR和PREDECESSOR可以在O(h)时间内完成。
TREE-INSERT(T, z)
y = NIL
x = T.root
while x != NIL
y = x
if z.key < x.key
x = x.left
else x = x.right
z.p = y
if y == NIL
T.root = z
elseif z.key < y.key
y.left = z
else y.right = z
从二叉搜索树T中删除结点x分为以下4中情况:
左子树(如果有的话)应放在右子树的MINIMUM处。
在一棵高度为h的二叉搜索树上,动态集合上的操作 TREE-INSERT、TREE-DELETE可以在O(h)时间内完成。
随机构建二叉搜索树(randomly built binary search tree)为按随机次序插入这些关键字到一棵初始的空树而生成的树,这里输入关键字的 n! 个排列中的每个都是等可能地出现。
红黑树(red-black tree)是许多“平衡”搜索树中的一种,可以保证在最坏情况下基本动态集合操作的时间复杂度为 O(lg n)。
一棵红黑树是满足下面 红黑性质的二叉搜索树:
从某个结点x出发到达一个叶结点的任意一条简单路径上的黑色结点个数称为该结点的 黑高(black-height),记为 bh(x)。定义 红黑树的黑高为其根节点的黑高。
一棵有n个内部结点的红黑树的高度至多为 2lg(n+1) 。
通过存储额外信息的方法来扩张一种标准的数据结构,然后对这种数据结构,编写新的操作来支持所需要的应用。
修改红黑树,使得可以在 O(lg n) 时间内确定任何顺序统计量。给每个结点x添加一个属性size保存以x为根的子树的结点个数。
过程OS-SELECT(x, i)返回一个指针,指向以x为根的子树中包含第i小关键字的结点。
OS-SELECT(x, i)
r = x.left.size + 1
if i == r
return x
elseif i< r
return OS-SELECT(x.left, i)
else return OS-SELECT(x.right, i-r)
过程OS-RANK返回对T中序遍历对应的线性序列中x的位置。
OS-RANK(T, x)
r = x.left.size +1
y = x
while y != T.root
if y == y.p.right
r = r + y.p.left.size +1
y = y.p
return r
扩张一种数据结构可以分为四个步骤
设f是n个结点的红黑树T扩张的属性,且假设对任一结点x,f的值仅依赖于结点x、x.left、x.right的信息,还可能包括x.left.f和x.right.f。那么我们可以在插入和删除操作期间对T的所有节点的f值进行维护,并且不影响这两个操作 O(lg n) 的渐近时间性能。
通过扩张红黑树来支持由区间构成的动态集合上的一些操作:
区间三分率(interval trichotomy):1. i 与 i’ 重叠;2. i 在 i’ 的右边;3. i 在 i’ 的左边
高端点(high endpoint):i.hight
低端点(low endpoint):i.low
重叠(overlap):i.low <= i’.high 且 i’.low <= i.high
附加信息:在结点x中添加属性 max,它是以x为根的子树中所有区间端点的最大值。
新的操作:INTERVAL-SEARCH(T, i),用来查找树T中与区间i重叠的那个结点,若不存在返回哨兵 T.nil 的指针。
INTERVAL-SEARCH(T, i)
x = T.root
while x!=T.nil and i does not overlap x.int
if x.left != T.nil and x.left.max >= i.low
x = x.left
else
x = x.right
return x
动态规划方法通常用来求解 最优化问题(optimization problem),通常有四个步骤:
动态规划(dynamic programming)与分治方法相似,都是通过组合子问题的解来求解原问题。区别在于分治法的子问题互不相交,而动态规划应用于子问题重叠的情况。
问题定义:给定一段长度为n的钢条和价格表 pi(i=1,2,...,n) ,求切割钢条方案,使得销售收益 rn 最大。
钢条切割问题满足 最优子结构(optimal substructure)性质:问题的最优解由相关子问题的最优解组合而成,这些子问题可以独立求解。
CUT-ROD(p, n)
if n==0
return 0
q = -MAX
for i=1 to n
q = max(q,p[i]+CUT-ROD(p,n-i))
return q
该算法的运行时间为 T(n)=2n 。
MEMOIZED-CUT-ROD(p,n)
let r[0..n] be a new array
for i=0 to n
r[i] = -MAX
return MEROIZED-CUT-AUX(p,n,r)
MEMOIZED-CUT-ROD-AUX(p,n,r)
if r[n] >= 0
return r[n]
if n == 0
q = 0
else q = -MAX
for i=1 to n
q = max(q,p[i]+MEMOIZED-CUT-ROD-AUX(p,n-i,r))
r[n] = q
return q
该算法的渐近运行时间为 Θ(n2) 。
BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] be a new array
r[0] = 0
for j = 1 to n
q = -MAX
for i=1 to j
q = max(q,p[i]+r[j-i])
r[j] = q
return r[n]
该算法与自顶向下法有相同的渐近运行时间。
扩展 BOTTOM-UP-CUT-ROD 算法,计算最大收益 rj 同时,记录最优解对应的第一段钢条的切割长度 sj 。
EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] be a new array
r[0] = 0
for j = 1 to n
q = -MAX
for i=1 to j
if q< p[i]+r[j-i]
q = p[i]+r[j-i]
s[j] = i
r[j] = q
return r and s
输出长度为n的钢条的完整的最优切割方案
PRINT-CUT-ROD-SOLUTION(p,n)
(r,s) = EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
while n>0
print s[n]
n=n-s[n]
矩阵链乘法(matrix-chain multiplication problem)可描述如下:给定n个矩阵的链
MATRIX-CHAIN-ORDER(p)
n = p.length -1
let m[1..n,1..n] and s[1..n-1,2..n] be new tables
for i = 1 to n
m[i,i] = 0
for l = 2 to n
for i = 1 to n-l+1
j = i+l-1
m[i,j] = MAX
for k = i to j-1
q = m[i,k]+m[k+1,j] + p(i-1)p(k)p(j)
if q< m[i,j]
m[i,j] = q
s[i,j] = k
return m and s
构造最优解
PRINT-OPTIMAL-PARENS(s, i, j)
if i==j
print "Ai"
else print "("
PRINT-OPTIMAL-PARENS(s, i, s[i,j])
PRINT-OPTIMAL-PARENS(s, s[i,j]+1, j)
print ")"
适合应用动态规划方法求解的最优化问题应该具备两个要素:最优子结构和子问题重叠。
给定一个有向图 G=(V,E) 和两个顶点 u, v∈V 。
无权最短路径:找到一条从 u 到 v 的边数最少的路径。这条路径必然是简单路径。
无权最长路径:找到一条从 u 到 v 的边数最多的路径,这条路径要求是简单路径。
无权最短路径具有最优子结构性质,而无权最长路径没有该性质。原因在于,虽然最长路径问题和最短路径问题的解都用到了两个子问题,但两个最长简单路径子问题是相关的,而两个最短路径子问题是 无关的(independent)。
如果递归算法反复求解相同的子问题,我们就称最优化问题具有 重叠子问题(overlapping subproblems)性质。与之相反,适合用分治方法求解的问题通常在递归的每一步都生成全新的问题。直接的递归算法无疑会重复计算每个子问题,而带备忘的递归算法可以达到与带备忘自顶向下的动态规划算法相似的效率。
给定一个序列X[1..m],另一个序列Z[1..k]满足如下条件时称为X的子序列:存在一个严格递增的X的下标序列 i[1..k] ,对所有的 1<=j<=k,满足 x[i[j]] = z[j]。
c[i,j]表示X[i]和Y[j]的 最长公共子序列(longest commen subsequence,LCS),根据 LCS 的最优子结构性质,可得到如下公式
c[i,j]=⎧⎩⎨⎪⎪⎪⎪0, if i=0 or j=0c[i−1,j−1]+1, if i,j>0 and xi=yjmax(c[i,j−1],c[i−1,j]), if i,j>0 and xi≠yj
通过动态规划的方法,可以先求解表c,再根据表c构造LCS。
另外,对于LCS算法,每个c[i,j]只依赖于c[i-1,j], c[i,j-1], c[i-1,j-1]和x,y的关系,用这些可以在常数时间内计算c[i,j],因此完全可以去掉表c,只需要常量的存储。
最优二叉搜索树(optimal binary search tree):给定一个n个不同关键字的已排序的序列K,希望构造一棵二叉搜索树。每个关键字都有一个概率表示其搜索频率,我们希望该二叉搜索树的期望搜索代价最小。
最优子结构:如果一棵最优二叉搜索树T有一棵子树T‘,那么T’必然是其包含的关键字构成的子问题的最优解。递归式为:
e[i,j]={qi−1, if j=i−1mini≤r≤j{e[i,r−1]+e[r+1,j]+w(i,j)}, if i≤j
可以通过类似矩阵链乘的算法进行求解,时间复杂度也是 Θ(n3) 。
问题描述:假定有n个 活动(activity)的集合S,这些活动使用同一资源(即同一时刻只供一个活动使用)。每个活动有一个 开始时间(s[i])和 结束时间(f[i]),在 活动选择问题中,我们希望选出一个最大兼容活动集。假定活动已按结束时间递增排序。
Sij 表示结束时间在活动 i 结束后 j 开始前的活动集合,c[i,j] 表示 Sij 的最优解的大小,则
c[i,j]=⎧⎩⎨⎪⎪0, ifSij=∅max{c[i,k]+c[k,j]+1}, ifSij≠∅
可以通过带备忘的递归算法,或者自底向上法填写表项。
加入无需求解所有子问题就可以选择一个活动加入最优解,将省去上式中考察所有选择的过程,即 贪心选择。 Sk=ai∈S:si≥fk 为在 ak 结束后开始的任务集合。
递归贪心算法 RECURSIVE-ACTIVITY-SELECTOR 的输入为两个数组 s 和 f,表示活动的开始和结束时间,下标 k 指出要求解的子问题 Sk ,以及问题规模 n。返回 Sk 的一个最大兼容活动集。求解原问题可以调用 RECURSIVE-ACTIVITY-SELECTOR(s,f,0,n)。
RECURSIVE-ACTIVITY-SELECTOR(s,f,k,n)
m = k+1
while m<=n and s[m]<f[k]
m++
if m<=n
return {a[m]} U RECURSIVE-ACTIVITY-SELECTOR(s,f,m,n)
else return null
可以转换为迭代贪心算法
GREEDY-ACTIVITY-SELECTOR(s,f)
n = s.length
A={a[1]}
k=1
for m=2 to n
if s[m] >= f[k]
A = A U {a[m]}
k = m
return A
如果一个问题的最优解包含其子问题的最优解,则称此问题具有 最优子结构星坠,此性质是能否应用动态规划和贪心方法的关键要素。两者的差别在于 贪心选择性质(greedy-choice property):我们可以通过做出局部最优(贪心)选择来构造全局最优解。
0-1 背包问题(0-1 knapsack problem):正在抢劫商店的小偷发现n个商品,第i个商品价值v[i]美元,重w[i]磅。小偷希望拿走尽量多的物品,而他的背包最多容纳W磅的物品。对于每个商品小偷要么完整拿走,要么把它留下,不能部分拿走或拿走多次。
分数背包问题(fractional knapsack problem)中,设定于 0-1背包问题一样,但低于每一个商品,小偷可以拿走其一部分。
两个背包问题都具有最优子结构性质。但我们可以使用贪心策略求解分数背包问题,而不能求解0-1背包问题。原因是小偷无法装满背包时,空闲空间降低了方案的有效每磅价值。当我们考虑是否装入某商品时,必须比较包含此商品的子问题的解与不包含它的子问题的解,然后才能做出选择。这将产生大量的重叠子问题,即需要使用动态规划算法。
我们考虑一种 二进制字符编码(或简称 编码)的方法,每个字符用唯一二进制串表示,称为 码字。 变长编码(variable-length code)可以达到比 定长编码好得多的压缩率,其思想是赋予高频字符短码字,赋予低频字符长码字。
在 前缀码(prefix code)中,没有任何码字是其他码字的前缀。此时编码文件的开始码字是无歧义的。我们可以简单地识别出开始码字,将其转换回原字符,然后对编码文件剩余部分重复这种解码过程。解码过程可以用二叉树方便地表示。
给定编码树 T,定义 dT(c) 为字母表 C 中字符 c 的叶结点的深度,即 c 的码字长度。定义 T 的 代价:
B(T)=∑c∈Cc.freq⋅dT(c)
赫夫曼编码(Huffman code)是一种使用贪心算法构造的最优前缀码。C是n个字符的集合,C中的每个字符c有一个属性c.freq给出其出现频率。Q为以freq为关键字的最小优先队列。
HUFFMAN(C)
n = |C|
Q = C
for i = 1 to n-1
allocate a new code z
z.left = x = EXTRACT-MIN(Q)
z.right = y = EXTRACT-MIN(Q)
z.freq = x.freq + y.freq
INSERT(Q,z)
return EXTRACT-MIN(Q) //return the root of the tree
假定 Q 使用最小二叉堆实现,则堆操作时间为 O(n),HUFFMAN的运行时间为 O(n lg n),如果将最小二叉堆换为 van Emde Boas 树,可以将运行时间将为 O(n lg lg n)。
引理1:令C为一个字母表,其中的每个字符c有一个频率c.freq。x和y为频率最低的两个字符。那么存在C的一个最优前缀码,x和y有相同的码字长度,且只有最有一个二进制位不同。
叶结点都会成对出现。因为如果出现单独的叶结点,用该结点替换其父结点,可以得到更优的树。
不会有任何结点深于频率最低的x和y。因为假如存在这样一个z,那么调换x与z,可以得到更优的树。
所以x和y可以位于最低的那两个叶结点上。
引理2:令C为一个字母表,其中的每个字符c有一个频率c.freq。x和y为频率最低的两个字符。令 C’ 为 C 去掉 x 和 y,加入 z(z.freq = x.freq+y.freq) 得到的字母表。T‘ 为 C’ 的一个最优前缀码对应的编码树。则将T’ 的z替换为一个有x和y子节点的内部结点得到的树T,表示C的一个最优前缀码。
因为 B(T) = B(T’) + x.freq + y.freq,这样的替换将产生确定的代价差额。故T’是最优的可以得到T是最优的(由引理1可以得到总是存在这样的替换)。
我们假定,任何与 关键字相联系的 卫星数据将与关键字一样存放在同一结点中,并随着关键字一起移动。一棵B树T是具有以下性质的有根树(根为T.root):
B+树是B树的变种。
如果 n>=1,那么对任意一棵包含n个关键字、高度为h、最小度数t>=2 的B树T,有
h≤logtn+12
B-TREE-SEARCH(x,k)
i=1
while i<=x.n and k>x.key[i]
i++
if i<=x.n and k==x.key[i]
return (x,i)
elseif x.leaf
return NIL
else DISK-READ(x,c[i])
return B-TREE-SEARCH(x.c[i],k)
循环所用时间为 O(t),访问磁盘页数为 O(logtn) ,总的CPU时间为 O(tlogtn) 。
B-TREE-CREATE(T)
x = ALLOCATE-NODE()
x.leaf = TRUE
x.n = 00
DISK-WRITE(x)
T.root = x
CPU时间为 O(1)。
分裂 B 树中的结点
B-TREE-SPLIT-CHILD(x,i)
输入:非满的内部结点 x,它的一个满的孩子x.c[i]的下标 i。
输出:将该子结点分裂为2个,并在 x 中添加关键字分开这两个孩子
沿树下行插入关键字
B-TREE-INSERT-NONFULL(x,k)
输入:非满的树x,要插入的关键字k
构造非满根结点并插入关键字k
B-TREE-INSERT(T,k)
当删除内部结点的关键字时,需要重新安排这个结点的孩子。当要删除的关键字的路径上的结点有最少的关键字树时还可能需要向上回溯。删除操作有以下几种情况:
图 G=(V,E) 可以用两种标准表示方法表示。
权重图:直接将边 (u,v) 的权重值 w(u,v) 存放在 u 的邻接链表里。
邻接链表表示 稀疏图(边的条数|E|远小于 |V|2 )时非常紧凑。
BFS(G,s)
for each vertex u in G.V ={s}
u.color = WHITE
u.d = MAX
u.pi = NIL
s.color = GRAY
s.d = 0
s.pi = NIL
Q = NULL
ENQUEUE(Q,s)
while Q != NULL
u = DEQUEUE(Q)
for each v in G.Adj[u]
if v.color == WHITE
v.color = GRAY
v.d = u.d + 1
v.pi = u
ENQUEUE(Q,v)
u.color = BLACK
扫描邻接链表的总时间为 O(E),初始化成本为 O(V),故BFS的总运行时间为 O(V+E)。
PRINT-PATH(G,s,v)
if v == s
print s
elseif v.pi == NIL
print "no path from" s "to" v "exists"
else PRINT-PATH(G,s,v.pi)
print v
DFS(G)
for each vertex u in G.V
u.color = WHITE
u.pi = NIL
time = 0
for each vertex u in G.V
if u.color == WHITE
DFS-VISIT(G,u)
DFS-VISIT(G,u)
time++
u.d = time
u.color = GRAY
for each v in G:Ajd[u]
if v.color == WHITE
v.pi = u
DFS-VISIT(G,v)
u.color = BLACK
time++
u.f = time
初始化时间为
Θ(V) ,遍历邻接链表时间为
Θ(E) ,故算法运行时间为
Θ(V+E) 。
推论 在深度优先森林中,v是u的真后代当且仅当 u.d
第一次探索边 (u,v) 时,结点v的颜色会反应边的信息:
定理 对无向图G进行DFS时,每条边要么是树边,要么是后向边
有向图中的横向边在无向图中成为树边或后向边。
拓扑排序是G中所有结点的一种线性排序,满足:如果G包含边(u,v),则u在拓扑排序中处于结点v的前面。
如下算法完成对有向无环图的拓扑排序:
TOPOLOGICAL-SORT(G)
call DFS(G) to compute finishing times v.f for each vertex v
as each vertex is finished, insert it onto the front of a linked list
return the linked list of vertex
引理 有向图G是无环的当且仅当对其DFS不产生后向边。
有向图G=(V,E)的 强连通分量为一个最大结点集合 C⊂V ,对于该集合中任意两点可以互相到达。
定义图G=(V,E)的转置为 GT=(V,ET) ,其中 ET={(u,v):(v,u)∈E} 。下面的线性时间( Θ(V+E) )算法使用两次DFS计算G的强连通分量。分别运行在G和 GT 上。
STRONGLY-CONNECTED-COMPONENTS(G)
call DFS(G) to compute finishing times u.f for each vertex u
compute G^T
call DFS(G^T), but in the main loop of DFS, consider the vertices in order of decreasing u.f
output the vertices of each tree in the DFS forest formed in line 3 as a separate strongly connected component
对G的DFS建立了深度优先森林,计算 GT 将该森林中所有边反转,对 GT 的DFS选择从上述森林的根结点出发,尝试到达原来的叶结点,能走通的结点加入到强连通分量。
引理 C和C’为G的两个不同的强连通分量, u,v∈C , u‘,v′∈C‘ 。如果G包含u到u’的路径,则不可能包含 v’ 到 u’ 的路径。
引理 C和C’为G的两个不同的强连通分量,如果存在边 (u,v)∈E , u∈C , v∈C′ ,则 f(C)>f(C’)。
定义d(U)和f(U)为U中所有结点最早和最晚发现时间。
推论 C和C’为G的两个不同的强连通分量,如果存在边 (u,v)∈ET , u∈C , v∈C′ ,则 f(C)
对于连同无向图G=(V,E),我们希望找到一个五环子集 T\subsetE ,既能将所有结点连接起来,又具有最小的权重( w(T)=∑(u,v)∈Tw(u,v) ),由于T无环,T必然是一棵树,称为图G的 生成树,求取该生成树的问题为 最小生成树问题。
在每一时刻生长最小生成树的一条边,并维护如下循环不变式:
在每次循环之前,边集合A是某棵最小生成树的一个子集。
这样不破坏循环不变式的的边(u,v)称为集合A的 安全边。
GENERIC-MST(G,w)
A=NULL
while A does not form a spanning tree
find an edge(u,v) that is safe for A
A = A U {(u,v)}
return A
无向图G=(V,E)的一个 切割(S,V-S)是集合V的一个划分。如果一条边 (u,v)∈E 的一个端点位于S,另一个端点位于V-S,则称该边 横跨该切割。如果集合A中不存在横跨该切割的边,则称该切割 尊重集合A。在横跨一个切割的所有边中,权重最小的边称为 轻量级边。
定理 设G=(V,E)是一个在边E上定义了实数权重函w的连通无向图。A为E的子集,且在G的某棵最小生成树中。(S,V-S)为尊重集合A的任意一个切割。(u,v)是横跨该切割的一条轻量级边。则边(u,v)对于集合A是安全的。
假设(u.v)不在最小生成树T中,因u v必然在树中相连,故(u,v)与树中两者的连线构成环。至少有两边横跨该切割,一边为(u,v),设另一边为(x,y)。考虑新的一棵生成树:T’=T-{(x,y)}+{(u,v)},因(u,v)是轻量级边,故w(T’)不大于w(T),即T’也是最小生成树。显然(x,y)不在A中,于是A与(u,v)都在T’中,即(u,v)对于集合A是安全的。
推论 设G=(V,E)是一个在边E上定义了实数权重函w的连通无向图。A为E的子集,且在G的某棵最小生成树中。设 C=(VC,EC) 为森林 GA=(V,A) 中的一棵树。如果边(u,v)是连接C 和 GA 中其他树的一条轻量级边,则该边对于A是安全的。
在所有连接森林中两棵不同树的边里面,找到权重最小的加入最小生成树。Kruskal算法属于贪心算法。
FIND-SET(u)用来返回包含u的集合的代表元素,UNION过程对两棵树进行合并,判断FIND-SET(u)==FIND-SET(v)可知两点是否在同一集合。
MST-KRUSKAL(G,w)
A=NULL
for each vertex v in G.V
MAKE-SET(v) //each tree contains one vertex
sort the edges of G.E into nondecreasing order by weight w
for each edge(v,u) in G.E, taken in nondecreasing order by weight w
if FIND-SET(u) != FIND-SET(v)
A = A U {(u,v)}
UNION(u,v)
return A
共有O(E)个FIND-SET和UNION操作,|V|个MAKE-SET操作,故总运行时间为 O(E lgV + V lgV) = O(E lgE)(对于连通图: E≥V−1 )。注意到 |E|<|V|2 ,运行时间为O(E lgV)。
集合A中的边总是构成一棵树,每次选择一条轻量级边加入A。Prim算法属于贪心算法。
所有不在A中的结点存放于以key为权值的最小优先队列Q中。对每一个结点v,v.key保存连接v和树中结点的所有边中最小边的权重。
MST-PRIM(G,w,r) //对于任意指定的根结点r,都可生成拥有同样边集合的树
for each u in G.V
u.key = MAX
u.pi = NIL
r.key = 0
Q = G.V
while Q!=NULL
u = EXTRACT-MIN(Q)
for each v in G.Adj[u]
if v in Q and w(u,v) < v.key
v.pi = u
v.key = w(u,v)
每次循环结束后,保证了下一次循环中EXTRACT-MIN得到的u都是最小生成树中的结点(因为本次循环中(u,v)为轻量级边)。
建造堆的时间为 O(V);EXTRACT-MIN的时间为 O(lg V),遍历结点循环次数为 |V|;修改key用到的DECREASE-KEY在二叉最小堆的时间为 O(lg V),在斐波那契堆的时间为 O(1),遍历边循环次数为|E|。故算法MST-PRIM的运行时间为 O(V + V lgV + E lgV)=O(E lgV)(最小二叉堆实现)或者 O(E + V lgV)(斐波那契堆实现)。
在 最短路径问题中,给定一个带权重的有向图G=(V,E)和权重函数 ω:E→\bmR→ ,该函数将每条边映射到实数值的权重。
图中一条路径p的 权重 w(p) 是构成该路径的所有边的权重之和: ω(p)=∑ki=1ω(vi−1,vi) 。
从结点u到结点 v的 最短路径权重 \delta(u,v) = \begin{cases}\min\{\omega(p):u\to v\},\quad if~there~is~a~path~from~u~to~v}\\\\ \infty,\quad other\end{cases}
最短路径的最优子结构性质:两个结点之间的一条最短路径包含着其他的最短路径。
最短路径问题的几个变体
引理(最短路径的子路径也是最短路径)给定带权重的有向图G=(V,E)和权重函数 ω:E→\bmR→ 。设 p=<v0,v1,..,vk> 为从结点 v0 到结点 vk 的一条最短路径,并且对于任意 i 和 j, 0≤i≤j≤k ,设 pij=<vi,vi+1,...,vj> 为路径p中从结点 vi 到结点 vj 的子路径。那么 pij 是从结点 vi 到结点 vj 的一条最短路径。
负权重的边
如果图G不包含从源点s可到达的权重为负的环路,则对所有结点,最短路径权重都有精确定义;如果从结点s到结点v的某条路经上存在权重为负的环路,我们定义 δ(s,v)=−∞ 。
环路
最短路径不能包含权重为正值的环路。
最短路径表示
前驱子图 Gπ=(Vπ,Eπ) ,其中 Vπ={v∈V:v.π≠NIL}∪{s} , Vπ={(v.π,V)∈E:v∈Vπ−{s}} 。
算法终止时, Gπ 是一棵“最短路径树”:有根结点的树,包括了从源结点 s 到每个可以从 s 到达的结点的一条最短路径。
松弛操作
对每个结点维护一个属性 v.d,记录从源结点 s 到结点 v 的最短路径权重的上界。称为 最短路径估计。
使用 Θ(V) 运行时间的算法对最短路径估计和前驱结点初始化:
INITIALIZE-SINGLE-SOURCE(G,s)
for each vertex v in G.V
v.d = MAX
v.pi = NIL
s.d = 0
对一条边(u,v)的 松弛操作:
RELAX(u,v,w)
if v.d > u.d+w(u,v)
v.d = u.d+w(u,v)
v.pi = u
Bellman-Ford算法解决的是一般情况下的单源最短路径问题。该算法返回TRUE当且仅当输入图不包含可以从源结点到达的权重为负值的环路。
BELLMAN-FORD(G,w,s)
INITIALIZE-SINGLE-SOURCE(G,s)
for i=1 to |G.V|-1
for each edge(u,v) in G.E
RELAX(u,v,w)
for each edge(u,v) in G.E
if v.d>u.d+w(u.v)
return FALSE
return TRUE
总运行时间为 O(VE)。
推论 设G=(V,E)为一个带权重的源结点为s的有向图,其权重函数为 ω:E→\bmR→ 。图G不包含从 s 可以到达的权重为负值的环路,则对于所有结点 v,存在一条从 s 到 v 的路径当且仅当 BELLMAN-FRD 算法终止时有 v.d<∞ 。
定理(Bellman-Ford算法的正确性)设BELLMAN-FORD算法运行在一带权重的源结点为 s 的有向图 G=(V,E) 上,该图的权重函数为 ω:E→\bmR→ 。如果图G不包含从 s 可以到达的权重为负值的环路,则算法返回 TRUE,且对于所有结点 v,前驱子图 Gπ 是一棵根为 s 的最短路径树。如果G包含一条从 s 可以到达的权重为负值的环路,则算法返回FALSE。
根据结点的拓扑排序次序来对带权重的有向无环图 G=(V,E) 进行边的松弛操作,便可以在 Θ(V+E) 时间内计算出从单个源结点到所有结点之间的最短路径。每次对一个结点进行处理时,我们队从该结点发出的所有边进行松弛操作。
DAG-SHORTEST-PATHS(G,w,s)
topologically sort the vertices of G
INITIALIZE-SINGLE-SOURCE(G,s)
for each vertex u, taken in topologically sorted order
for each vertex v in G.Adj[u]
RELAX(u,v,w)
算法的总运行时间为 Θ(V+E) 。
定理 如果带权重无环路的有向图G=(V,E)有一个源结点s,则在算法DAG-SHORTEST-PATHS终止时,对于所有结点v,我们有 v.d=δ(s,v) ,且前驱子图 Gπ 是一棵最短路径树。
Dijkstra算法解决的是带权重的有向图上单源最短路径问题,该算法要求所有边的权重都为非负值。Dijkstra 算法在运行过程中维持的关键信息是一组结点集合S:从源结点 s 到该集合中每个结点之间的最短路径已经被找到。
DIJKSTRA(G,w,s
INITIALIZE-SINGLE-SOURCE(G,s)
S=NULL
Q=G.V
while Q!=NULL
u = EXTRACT-MIN(Q)
S=S U {u}
for each vertex v in G.Adj[u]
RELAX(u,v,w)
定理(Dijkstra算法的正确性)Dijkstra算法运行在带权重的有向图G=(V,E)时,如果所有权重为非负值,则在算法终止时,对于所有结点 u,有 u.d=δ(s,u) 。
可通过循环不变式证明:4~8行的while语句每次循环开始前,对于每个结点 v∈S ,有 v.d=δ(s,v) 。
Q中最小结点所有连接到S的路径已被探测过,且pi已经标记为最短路径上的前驱结点。
推论 如果在带权重的有向图G=(V,E)上运行Dijkstra算法,其中的权重皆为非负值,源点为s,则在算法终止时,前驱子图 Gπ 是一棵根结点为 s 的最短路径树。
Dijkstra算法的时间复杂度同最短路径的 Prim 算法,依赖于最小优先队列的实现:
寻找一个 n 维向量 x,使得在由 Ax≤b (A为 m×n 矩阵,b为m维向量)给定的m个约束条件下优化目标函数 ∑ni=1cixi (c为n维向量,“优化”通常是指取值最大)。
有时我们并不关注目标函数,而是仅仅希望找到一个 可行解。
在一个 差分约束系统中,线性规划矩阵A的每一行只包括一个1和一个-1,其他项为0。因此 Ax≤b 所给出的约束条件变为 m 个涉及 n 个变量的 差额限制条件。其中每个条件可以表示为: xj−xi≤bk 。这里 1≤i,j≤n, i≠j, 1≤k≤m 。
引理 设向量 x=(x1,x2,...,xn) 为差分约束系统 Ax≤b 的一个可行解,设 d 为任意常数,则 x+d 也睡该差分约束系统的一个解。
给定差分约束系统 Ax≤b ,其对应的 约束图是一个带权重的有向图 G=(V,E),其中:
V={v0,v1,...,vn}
E={(vi,vj):xj−xi≤bk is a constraint}∪{(v0,v1),(v0,v2),...,(v0,vn)} 。
定理 给定差分约束系统 Ax≤b ,设G=(V,E)是该系统对应的约束图,如果G不包含权重为负的环路,则
x=(δ(v0,v1),δ(v0,v2),...,δ(v0,vn))
为该系统的一个可行解。如果图G包含权重为负值的环路,该系统没有可行解。
对任意一条边(vi,vj),根据三角不等式, δ(v0,jj)≤δ(v0,vi)+ω(vi,vj) ,即 δ(v0,vj)−δ(v0,vi)≤w(vi,vj) ,即 xj−xi≤bk 。
引理(三角不等式)设G=(V,E)为一个带权重的有向图,其权重函数为 ω:E→\bmR→ ,源点为s。则对于所有边 (u,v)∈E ,我们有:
δ(s,v)≤δ(s,u)+ω(u,v)
引理(上界性质)设G=(V,E)为一个带权重的有向图,其权重函数为 ω:E→\bmR→ ,源点为s。该图由算法 INITIALIZE-SINGLE-SOURCE(G,s)执行初始化。那么对于所有结点 v∈V,v.d≥δ(s,v) ,并且该不变式在对图G的边进行任何次序的松弛过程中保持成立。而且一旦v.d取得其下界将不再变化。
推论(非路径性质)设G=(V,E)为一个带权重的有向图,其权重函数为 ω:E→\bmR→ ,假定从源结点 s 到给定结点 v 之间不存在路径,则在该图由算法 INITIALIZE-SINGLE-SOURCE(G,s)进行初始化后,我们有 v.d=δ(s,v)=∞ ,并且该等式一直维持到G的所有松弛操作结束。
引理 设G=(V,E)为一个带权重的有向图,其权重函数为 ω:E→\bmR→ 。那么对边 (u,v)∈E 进行 RELAX(u,v,w)后,有 v.d\dequ.d+ω(u,v) 。
这即是松弛操作所做的工作。
引理(收敛性质)设G=(V,E)为一个带权重的有向图,其权重函数为 ω:E→\bmR→ ,s为某个源点, s→u→v 为G中的一条最短路径。假定G由INITIALIZE-SINGLE-SOURCE(G,s)初始化,并在这之后做了一系列松弛操作,其中包括对边(u,v)的松弛操作 RELAX(u,v,w)。如果在对边(u,v)进行松弛操作前的任意时刻有 u.d=δ(s,u) ,则在该松弛操作之后的所有时刻有 v.d=δ(s,v) 。
引理(路径松弛性质)设G=(V,E)为一个带权重的有向图,其权重函数为 ω:E→\bmR→ ,s为某个源点,考虑从s到vk的任意一条最短路径 p=<v0,v1,...,vk> 。如果G由INITIALIZE-SINGLE-SOURCE(G,s)进行初始化,并在这之后进行了一系列的松弛操作,包括对 (v0,v1),(v1,v2),...,(vk−1,vk) 按照所列次序而进行的松弛操作,则在这些操作后我们有 vk.d=δ(s,vk) ,并且该等式一直保持成立。该性质的成立与其他边的松弛操作及次序无关。
使用归纳法证明。
引理 设G=(V,E)为一个带权重的有向图,其权重函数为 ω:E→\bmR→ ,s为某个源点,假定图中不包含从s可以到达的权重为负值的环路,则在图G由INITIALIZE-SINGLE-SOURCE(G,s)进行初始化之后,前驱子图 Gπ 形成根为s的有根树,并且对任何对G的边进行的任意松弛操作都将维持该性质不变。
引理(前驱子图性质)设G=(V,E)为一个带权重的有向图,其权重函数为 ω:E→\bmR→ ,s为某个源点,假定图中不包含从s可以到达的权重为负值的环路,由INITIALIZE-SINGLE-SOURCE(G,s)对G进行初始化,然后对G的边进行任意次序的松弛操作。该松弛操作序列将针对所有结点生成 v.d=δ(s,v) ,则前驱子图 Gπ 形成根为s的最短路径树。
可用 cut & paste 证明。
前驱结点矩阵 Π=(πij) ,其中 πij 在 i=j 或 i到j不存在路径时为 NIL,其他情况为 i 到 j 最短路径上j的前驱结点。对每个结点 i,定义图G对于结点 i 的 前驱子图为 Gπ,i=(Vπ,i,Eπ,i) ,其中
Vπ,i={j∈V:πi,j≠NIL}∪{i},Eπ,i={(πij,j):j∈Vπ,i−{i}}
如果 Gπ,i 是一棵最短路径树,如下算法将打印 i 到 j 的一条最短路径。
PRINT-ALL-PAIRS-SHORTEST-PATH(PI, i, j)
if i==j
print i
elseif PI[i,j] == NIL
print "no path from" i "to" j "exists"
else PRINT-ALL-PAIRS-SHORTEST-PATH(PI, i, PI[i,j])
print j j
定义 l(m)ij 为 i 到 j 的至多包含 m 条边的所有路径中最小的权重,则:
l(0)ij={0if i=j∞if i≠j
l(m)ij=min1≤k≤n{l(m−1