7161 字
36 分钟
【算法设计与分析】期末复习专题

前言#

学习了一个学习的算法设计与分析,在这里做一个期末复习专题来回顾一下后半学期的知识。

PS:本篇题目与分篇中大部分不太相同,可能是其他的例子(也可能不是),为了更好的理解这些问题。

PS:这里不包括分治法及其之前的内容,具体请见各自章节。

https://hoyue.fun/categories/学习笔记/算法设计与分析

这一篇复习涉及到的内容:

  • 散列表——开放定址法(双散列)
  • 动态规划——最优二叉搜索树
  • 贪心算法——哈夫曼编码
  • 基本图论——BFS、DFS
  • 单源最短路——Bellman-Ford 算法, Dijkstra算法
  • 堆排序
  • 线性时间排序

散列表——开放定址法(双散列)#

本章原文:

https://hoyue.fun/algorithm_6.html#%E5%BC%80%E6%94%BE%E5%AF%BB%E5%9D%80%E6%B3%95

我们一般使用双散列比较多,下面是一个使用双散列的例子:

Consider inserting the keys 10, 22, 31, 4, 15, 28, 17, 88, 59 into a hash table of length m = 11 using open addressing. Illustrate the result of inserting these keys using double hashing with h1(k) = k mod m and h2(k) = 1 + (k mod (m - 1)).

我们整合一下上面的两个函数,可以得到函数h(k,i),k表示关键字, i从0开始 ,表示初始位置的基础上进行偏移的度:

h(k, i) = (h₁(k) + i*h₂(k)) mod m = (k mod m + i(1 + (k mod (m - 1)))) mod m

我们的操作如下:

  1. 对于键值对10:h1(10) = 10 mod 11 = 1,插入键值对10到哈希表的第10个位置。
  2. 接下来是键值对22:h1(22) = 22 mod 11 = 0,插入键值对22到哈希表的第0个位置。
  3. 现在是键值对31:h1(31) = 31 mod 11 = 9,插入键值对31到哈希表的第9个位置。
  4. 接下来是键值对4:h1(4) = 4 mod 11 = 4,插入键值对4到哈希表的第4个位置。
  5. 现在是键值对15:h1(15) = 15 mod 11 = 4,4这个位置冲突了,继续计算h2(15) = 1 + (15 mod (11 - 1)) = 1 + 5 = 6,(4 + 6) mod 11= 10这个位置还是冲突,继续计算(4 + 2*6) mod 11 = 16 mod 11 = 5,插入键值对15到哈希表的第5个位置。
  6. 接下来是键值对28:h1(28) = 28 mod 11 = 6,插入键值对28到哈希表的第6个位置。
  7. 现在是键值对17:h1(17) = 17 mod 11 = 6,这个位置冲突了,计算h2(17) = 1 + (17 mod (11 - 1)) = 1 + 7 = 8,(6 + 8) mod 11 = 3,插入键值对17到哈希表的第3个位置。
  8. 接下来是键值对88:h1(88) = 88 mod 11 = 0,位置冲突继续计算,h2(88) = 1 + (88 mod (11 - 1)) = 1 + 8 = 9,(0+9) mod 11 = 9冲突,继续计算(0+2*9) mod 11 = 7,插入键值对88到哈希表的第7个位置。
  9. 最后是键值对59:h1(59) = 59 mod 11 = 4,位置冲突继续计算,h2(59) = 1 + (59 mod (11 - 1)) = 1 + 9 = 10,(4+10) mod 11 = 3冲突,(4+2*10) mod 11 = 2,插入键值对59到哈希表的第2个位置。

最后可以得到如下表格:

012345678910
22591741528883110

动态规划——最优二叉搜索树#

本章原文:

https://hoyue.fun/algorithm_8.html

在上述章节中,对过程描述得非常详细,原理部分将不再展示,下面将会修改一些数据,用另外的例子来回顾这章内容。同理,我们先列出表定义:

e[i, j] 为在包含关键字 ki,,kjk_i, \ldots, k_j 的最优二叉搜索树中进行一次关键字搜索的 期望代价

包含关键字 ki,,kjk_i, \ldots, k_j 的子树,定义子树中所有关键字和虚关键字的 概率和w(i, j)

定义 root[i, j] 保存包含关键字 ki,,kjk_i, \ldots, k_j 的最优二叉树的 根结点 关键字k的 下标r(并非e[i, j])

以及新例子的概率表:

我们先从w表开始,它的公式为:

w[i,j]={qi1if j=i1w[i,j1]+pj+qjif ijw[i,j] = \begin{cases} q_{i-1} & \text{if } j = i-1 \\ w[i,j-1] + p_j + q_j & \text{if } i \leq j \end{cases}

填表有如下规律:

  • 首先先填对角线,填入 qiq_i 的所有内容。
  • 然后再填已填写位置右侧的一格(例如已填写w[1,0], 那么接下来就填w[1,1]),它的内容根据公式等于 左侧单元格内容 + 当前列号(j)对应的 pjp_jqjq_j 的概率和
  • 这是一个斜向右填表的过程,如果已经到达右边的边上,则不用填。每次要填写的数据都少一个(即第一次填6个,第二次就填5个,因为最后那个已经到达边上)。
i\j01234567
10.060.160.280.420.490.640.811.00
20.060.180.320.390.540.710.90
30.060.200.270.420.590.78
40.060.130.280.450.64
50.050.200.370.56
60.050.220.41
70.050.24
80.05

注意检查右上角的值一定为1!

接下来是e表和root表,e表的公式为:

e[i,j]={qi1if j=i1minirj{e[i,r1]+e[r+1,j]+w(i,j)}if ije[i,j] = \begin{cases} q_{i-1} & \text{if } j = i-1 \\ \min_{i \leq r \leq j} \{e[i,r-1] + e[r+1,j] + w(i,j)\} & \text{if } i \leq j \end{cases}

填表有如下规律:

  • 首先也是对角线的值为 qiq_i
  • 之后的值中, r 的取值有 j-i 种,需要每个讨论 j-i 次。
  • 每次讨论等价于对位相加。例如第1次讨论则是e表中这一行的第1个数 + e表中这一列的第1个数 + w表中该位置的值。即 对于e[i,j]的第k(k∈[i,j])次讨论,为该行(第i行)的第k个数 + 该列(第j列)的第k个数 + w[i,j]
  • 对于讨论中相同的值,选择讨论次数k最小的,因为替换最小值时比较为<而不是≤。
  • 这个过程也是斜向右填表,越往后的讨论次数越多。
  • 对于root表,它的行和列为1-n,也是从对角线开始填。右上角为整个树的根。
i\j01234567
10.060.280.621.021.341.832.443.12
20.060.300.680.931.411.962.61
30.060.320.571.041.482.13
40.060.240.571.011.55
50.050.300.721.20
60.050.320.78
70.050.34
80.05

root表:

i\j1234567
11222335
2233355
333455
44556
5566
667
77

最后根据这个表,生成一个树如图:


贪心算法——哈夫曼编码#

本章原文:

https://hoyue.fun/algorithm_9.html

哈夫曼编码的贪心设计就非常明显了,只需要使用优先队列去存,每次都选择最小的两个合并,直到全部插入树中即可,例如:

以下字符频率基于斐波那契数列前8个数字的字符集的哈夫曼编码是怎样的? a:1 b:1 c:2 d:3 e:5 f:8 g:13 h:21

下面使用z(a,b)表示频数为z的节点左边的是频数a或字符a,右边的是频数b或字符b。

  1. 把它们按照频数加入优先队列Q中,顺序刚好为: a:1 b:1 c:2 d:3 e:5 f:8 g:13 h:21
  2. 取出前两个,合并插入到优先队列中得到: c:2 2(a,b) d:3 e:5 f:8 g:13 h:21
  3. 依次类推继续合并插入得: d:3 4( c,2(a,b) ) e:5 f:8 g:13 h:21
  4. e:5 7( d,4( c,2(a,b) ) ) f:8 g:13 h:21
  5. f:8 12( e, 7( d,4( c,2(a,b) ) ) ) g:13 h:21
  6. g:13 20( f,12( e, 7( d,4( c,2(a,b) ) ) ) ) h:21
  7. h:21 33( g,20( f,12( e, 7( d,4( c,2(a,b) ) ) ) )
  8. 54( h,33( g,20( f,12( e, 7( d,4( c,2(a,b) ) ) ) ) )

最终得到这样的一棵树,图(a)为该题答案,图(b)为前n为推广:


基本图论#

本篇原文:

https://hoyue.fun/algorithm_10.html

BFS#

BFS的搜索过程是一层一层向外的,就犹如水波(wave)一样。简单来说,对于BFS的过程如下:

  1. 从s开始,一层一层地发现顶点。
  2. 首先访问距离s只有1条边的所有顶点(s的下一层)。
  3. 从那些顶点开始,访问距离下一层的所有顶点,以此类推。
  4. 直到它发现了从s出发可到达的每个顶点。

用下列的值表示:

  • v.d 表示从s到v的距离。
  • v.π 表示v的在广度优先树中的前驱,若v为源点或者未被发现,故v没有前驱,初始设v.π = NIL。

我们通过一个例子来进一步掌握:

对于如下的图,设从s出发,求以字典序的BFS中,每个顶点的d值表、π值表和队列出队顺序Q。

  1. 遍历与s相邻的节点,并把 s 出队,那些节点按照字典序入队,给它们的d赋值1,π赋值为s。此时队列S: r, u, v
  2. 接下来r出队,并把其相连的t和w入队,并给它们d = 2,π = r,S: u, v, t, w
  3. u出队,并把其相连的且没有被访问过的y入队,并给它d = 2,π = u,S: v, t, w, y
  4. v出队,与它相邻的均以入队,S: t, w, y
  5. t出队,与它相邻的均以入队或已经出队,S: w, y
  6. w出队,并把其相连的且没有被访问过的x和z入队,并给它们d = 3,π = w,S: y, x, z
  7. y出队,与它相邻的均以入队或已经出队,S: x, z
  8. x出队,与它相邻的均以入队或已经出队,S: z
  9. z出队,与它相邻的均以入队或已经出队,S: Empty

把这个过程整合成表格得:

rstuvwxyz
v.d102112323
v.πsNILrssrwuw

Q: s, r, u, v, t, w, y, x, z


DFS#

DFS则是先探索一条道路到头,再回溯到分叉口进行其他探索。

与BFS不同的是,DFS 必须访问所有的顶点 ,所以可能深度优先搜索有多个源点。还有广度优先搜索的前驱子图是一棵树,但是深度优先搜索的前驱子图可能包含若干棵树,这是由于搜索可能从多个源点进行。

除了之前BFS的d外,深度优先搜索还给每个顶点加时间戳(timepstamp)。

每一个顶点v有两个时间戳:第一个时间戳v.d记录了v第一次被发现的时间(discovery time),第二个时间戳v.f 记录了v被搜索结束的时间(finish time)。

例如下面的这个例子:

如图,以字典序进行DFS搜索,输出一个d表和f表。

  1. 以字典序的DFS,那么我们应该以q开始,因为它字典序最小。
  2. 按照字典序,先走s这条路径,一路走到:q->s->v->w,此时走到头了,一直回溯到分叉口q处。此时有: q: 1/? s:2/7 v:3/6 w:4/5. q的f为?是因为还有一个箭头指向它,可能之后还有访问到,f记录的是最后访问的时间戳。
  3. 接着沿着t这条路径走,根据字典序选择x这边:q->t->x->z,走到头了回溯到分叉口t,接着往y处走:t->y->q,整条路径为:q->t->x->z->z->x->t->y->y->t->q,于是有: q: 1/16 t: 8/15 x:9/12 z:10/11 y:13/14
  4. 左半部分遍历完了,接下来根据字典序,找到没有被遍历的最小字母r,走u这条路径,有: r:17/20 u:18/19

输出为表格得:

这整个过程的遍历图为:


单源最短路#

本篇原文:

https://hoyue.fun/algorithm_11.html

Bellman-Ford 算法#

Bellman-Ford算法解决的是一般情况下单源最短路径问题,边的权重可以为负值,给定源点为s的有向带权图 G =(V, E)和权重函数 w: E->R,Bellman-Ford算法返回一个布尔值,该布尔值表明是否存在一个从源点可达的负权重的环路。若存在这样一个环路(有负权重的环路),则算法提示不存在解决方案(False)。若不存在这样一个环路,则算法给出最短路径和它们的权重(True)。

算法的思路为:

  1. 初始化将所有顶点的d值设置为∞,π值设置为NIL。设置源点d = 0.
  2. 进行n - 1次遍历,每次遍历对所有边进行松弛操作,满足则将权值更新。
  3. 遍历都结束后,若再进行一次遍历,还能得到s到某些节点更短的路径的话,则说明存在负环路(FALSE)否则为TRUE。

例如:如果设 s 为源点,按照如下顺序 (t, x), (t, y), (t, z), (x, t), (y, x), (y, z), (z, x), (z, s), (s, t), (s, y) 松弛边的话,如下图,找出每个节点的d值(最短路长)和π值(前驱节点)的表格。

  1. 首先,通过 INITIALIZE-SINGLE-SOURCE 操作,将所有顶点的d值设置为∞,π值设置为NIL。设置源点s.d = 0.
  2. 第一次循环:从s出发,按照顺序遍历这些节点,此时根据 (s, t), (s, y) ,我们可以把t和y的值设置:t.d = 6, t.π = s; y.d = 7, y.π = s。
  3. 第二次循环:根据顺序有 (t, x), (t, y), (t, z), (x, t), (y, x), (y, z), (z, x), (z, s), (s, t), (s, y) ,通过松弛这些边得到:x.d = 11, x.π = t; z.d = 2, z.π = t; x.d = 4, x.π = y。
  4. 第三次循环中,在松弛边 (x, t) 时,t.d = 2, t.π = x。
  5. 在算法的最后一个步骤中,再进行一次遍历,在松弛边 (t, z) 时,z.d = -2, z.π = t;在松弛边 (z, x) 时,x.d = 2, z.π = z。
  6. 这个结果导致某些节点有更短的路径,则存在负权边,返回FALSE,验证:因为在松弛边 (t, z) 时,z.d = -2, z.π = t;在松弛边 (z, x) 时,x.d = 2, z.π = z。此时负权环为(t, z), (z, x), (x, t)边组成。

整合成表得:

d值表:

stxyz
0
067
06472
02472
0227-2

π表为:

stxyz
NILNILNILNILNIL
NILsNILsNIL
NILsyst
NILxyst
NILxzst

Dijkstra算法#

Dijkstra的算法解决了加权有向图上的单源最短路径问题,但它要求所有边的 权重是非负的

Dijkstra算法维持一个顶点集合 S,表示从s可达的最终最短距离已经确定的顶点的集合。Dijkstra算法不断从 V -S 中选择具有 最小路径 估计的顶点 u,然后将u加入 S,并松驰所有从u发出的边。即节点u出队时进入S中。

过程 Dijkstra 使用以顶点的 d 值为关键字的优先队列Q。

下面是一个例子:

如下图,若我们以z为源点,找到所有顶点在算法过程中的d值和π值,以及集合S表示最短路径。

  1. 首先,通过 INITIALIZE-SINGLE-SOURCE 操作,将所有顶点的d值设置为∞,π值设置为NIL,S和Q为Ø。设置源点z.d = 0,z入队.
  2. 接下来,z出队,s和x进入优先队列Q,更新s和x的值:s.d = 3, s.π = z;x.d = 7,x.π = z;此时最短路集合S = z。
  3. s出队,t和y进入优先队列Q,更新值为:t.d = 6, t.π = s;y.d = 8, y.π = s,此时最短路集合S = z, s。
  4. t出队(它的权比x小),无事发生,此时最短路集合S = z, s, t。
  5. x出队,无事发生,此时最短路集合S = z, s, t, x;
  6. y出队,无事发生,此时最短路集合S = z, s, t, x, y;

d值表:

stxyz
0
370
36780
36780
36780

π值表:

stxyz
NILNILNILNILNIL
zNILzNILNIL
zszsNIL
zszsNIL
zszsNIL

S = z, s, t, x, y


堆排序#

本篇原文:

https://hoyue.fun/algorithm_4.html

这一节又分为分析和代码部分,其中比较重要的是如何维护堆和建堆。下面的证明可能与之前原文有所不同,选择好理解的就行。

分析#

证明左子树最大规模为2n/3#

由于堆是对应一棵完全二叉树,设它有n个结点根结点。 根的左子树结点数≥根结点的右子树结点数

对于一个完全二叉树,它的最后一层可能不是完全填满的。为了估计最大规模,假设我们假设左子堆是一个高度为 h 的满二叉树,右子堆是一个高度为 h-1 的满二叉树。

那么左子树的结点数 nl = ∑i∈[0,h] 2^i = 2^(h+1) - 1 ;右子树的结点数 nr = ∑i∈[0,h-1] 2^i = 2^h - 1

总结点数 n = nl + nr + 1 = 2^(h+1) - 1 + 2^h - 1 + 1 = 3*2^h - 1

于是 左子树结点数/总结点数 = 2^(h+1) - 1 / 3*2^h - 1 = 2*2^h - 1 / 3*2^h - 1 < 2*2^h / 3*2^h = 2/3 故左子树最大规模为2n/3。

分析维护堆#

维护堆是一个向下调整(Percolate Down)的过程,如果为大根堆,则判断当前根结点是否满足大于它的左右结点,如不满足,则替换为左右结点中最大的,接着递归以被替换位置为根向下调整。

它的伪代码为:

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,故递归式为: T(n) = T(2n/3) + Θ(1)

根据Master Theorem Case 2, nlog2/31=1Θ(1)n^{\log_{2/3} 1} = 1 \in \Theta(1) ,故 T(n)=Θ(lgn)T(n) = \Theta(\lg n)

证明:对一个大小为n的堆,MAX-HEAPIFY的最坏情况运行时间为Ω(lgn)#

MAX-HEAPIFY的最坏情况下(是一个小根堆),每个根都需要运行一次MAX-HEAPIFY,此时一条路径上最长的为 h=lgnh = \lfloor\lg n\rfloor ,故最坏的运行时间为 Ω(lgn)\Omega(\lg n)

证明叶结点下标#

证明: 当用数组表示存储n个元素的堆时,叶结点的下标分别为 n/2+1,n/2+2,,n\lfloor n/2 \rfloor +1,\lfloor n/2 \rfloor +2, \ldots, n

这个很容易证明,我们想最后一个结点为n,它的父母结点p为 n/2\lfloor n/2 \rfloor 。此时有三种情况:

  1. 当父母结点p的左孩子为n的时候,且p不在这一层的末尾,那么p+1开始的结点到n都是叶子节点。 例如下图的堆:最后一位结点n的下标为8,值为1,它的父母结点的下标为 n/2=4\lfloor n/2 \rfloor = 4 ,那么从 n/2+1\lfloor n/2 \rfloor+1 到n的结点(4, 3, 2, 1)它们都是叶子节点。
8
/ \
7 6
/ \ / \
5 4 3 2
/
1
  1. 当父母结点p的右孩子为n的时候,且p不在这一层的末尾,那么p+1开始的结点到n都是叶子节点。 同理为右结点时,p的下一位一定也是叶子结点。例如:最后一位结点n的下标为9,值为2,它的父母结点的下标为 n/2=4\lfloor n/2 \rfloor = 4 ,那么从 n/2+1\lfloor n/2 \rfloor+1 到n的结点(4, 3, 2, 1, 2)它们都是叶子节点。
8
/ \
7 6
/ \ / \
5 4 3 2
/ \
1 2
  1. 当父母结点p的右孩子为n的时候,且p在这一层的末尾,那么p+1开始的结点到n都是叶子节点。 此时这种情况下像之前所知的无论最后一位是左右都无所谓,父母结点的下一个就在最后一位的这一层,它们都是叶子结点。例如:最后一位结点n的下标为7,值为2,它的父母结点的下标为 n/2=3\lfloor n/2 \rfloor = 3 ,那么从 n/2+1\lfloor n/2 \rfloor+1 到n的结点(5, 4, 3, 2)它们都在最后一层,是叶子节点。
8
/ \
7 6
/ \ / \
5 4 3 2

上述为理解证明,但实际上证明可以使用反证法来证明:

假设 n/2+1\lfloor n/2 \rfloor+1 不位于最后一层,那么它一定有左孩子,其左孩子结点下标小于等于n。

我们证明它的左孩子: LEFT(n/2+1)=2(n/2+1)\text{LEFT}(\lfloor n/2 \rfloor+1) = 2(\lfloor n/2 \rfloor+1) 因为 (n/21)n/2n/2(n/2-1) \leq \lfloor n/2 \rfloor \leq n/2 ,那么 2(n/2+1)2(n/21+1)=n2(\lfloor n/2 \rfloor+1) \geq 2(n/2-1+1) = n ,此时它的左孩子一定大于等于n,假设不成立得证。

证明在高h的层上至多的结点数#

首先,我们先回顾:从根到节点的简单路径的长度是树中节点的 深度 。树的 高度 是从节点到叶子的最长下行路径上的边数,树的高度是它的根的高度。这意味着,高度从下往上依次是0H,而深度则是从上往下依次是0H。

有了上一个证明,我们知道 n元堆的叶结点的下标分别为 n/2+1,n/2+2,,n\lfloor n/2 \rfloor +1,\lfloor n/2 \rfloor +2, \ldots, n

那么叶子结点的总数有: n(n/2+1)+1nn/2=n/2n - (\lfloor n/2 \rfloor +1) + 1 \geq \lceil n - n/2 \rceil = \lceil n/2 \rceil

设高度为h的层有 nhn_h 个结点,那么高度为0的层,它至多有 n/2\lceil n/2 \rceil 个叶子结点,因为当叶子结点全都在最后一层时成立。

那么使用数学归纳法:在高度为h的层有 nhn_h 个结点, nhn/2h+1n_h \leq \lceil n/2^{h+1} \rceil

则在高度为h+1的层的结点数为 nh+1nh/2=n/2h+1/2=n/2h+1/2=n/2h+1+1n_{h+1} \leq \lceil n_h/2 \rceil = \lceil n/2^{h+1} /2 \rceil = \lceil n/2^{h+1} /2 \rceil = \lceil n/2^{h+1 + 1} \rceil 得证。

在高h的层上至多的结点数为 n/2h+1\lceil n/2^{h+1} \rceil

建堆分析#

我们知道对于一颗完全二叉树, 根的左子树结点数≥根结点的右子树结点数 。那么当它满的时候,这棵树的高度为 lgn\lfloor\lg n\rfloor

下面是建堆的代码:

BUILD-MAX-HEAP(A, n)
A.heap-size = n
for i = floor(n/2) down to 1
MAX-HEAPIFY(A, i)

这个算法是从最后一个结点的父母结点开始,不断向上调整的过程。在每个 MAX-HEAPIFY(A, i) 中,它的时间复杂度看作是节点i到叶子节点的路径长度,这个路径长度等于这个节点的高度。因此,对于一个高度为h的节点,它的时间复杂度为O(h)。

在一个高度为 H=lgnH = \lfloor\lg n\rfloor 的完全二叉树中,高度为h的节点有 2Hh=2lgnh=2lgn/2h2^{H-h} = 2^{\lfloor\lg n\rfloor-h} = 2^{\lfloor\lg n\rfloor}/2^h 个。根据之前的证明可以得到 2lgn/2hn/2h+12^{\lfloor\lg n\rfloor}/2^h \leq \lceil n/2^{h+1} \rceil

设c为渐近符号中隐含的常数,故整个的时间复杂度为:

h=0H12Hh×O(h)×ch=0H1n/2h+1×h×c=cnh=0lgn1h/2h+1cnh=0h/2h+1\begin{align*} &\sum_{h=0}^{H-1} 2^{H-h} \times O(h) \times c \\ &\leq \sum_{h=0}^{H-1} \lceil n/2^{h+1} \rceil \times h \times c \\ &= cn \sum_{h=0}^{\lfloor\lg n\rfloor-1} h/2^{h+1} \leq cn \sum_{h=0}^{\infty} h/2^{h+1} \\ \end{align*}

When 0<x<1\text{When }0<x<1, n=0xn=1/(1x)\sum_{n=0}^{\infty} x^n = 1 / (1 - x)

对这个公式两边同时求导(对x),得到 n=0n×xn1=1/(1x)2\sum_{n=0}^{\infty} n \times x^{n-1} = 1 / (1 - x)^2 ,我们带入 x=1/2x = 1/2,得到 n=0n×(1/2)n1=1/(11/2)2=4\sum_{n=0}^{\infty} n \times (1/2)^{n-1} = 1 / (1 - 1/2)^2 = 4

将两边同时乘以 (1/2)2(1/2)^2 ,得到 n=0n×(1/2)n+1=1\sum_{n=0}^{\infty} n \times (1/2)^{n+1} = 1

回到原题中,这里的 h=0h/2h+1=h×(1/2)h+1=1\sum_{h=0}^{\infty} h/2^{h+1} = h \times (1/2)^{h+1} = 1 . 故有:

cnh=0h/2h+1=cn×1=cn=O(n)\begin{align*} &cn \sum_{h=0}^{\infty} h/2^{h+1} \\ &=cn \times 1 \\ &=cn \\ &=O(n) \end{align*}

于是乎建堆的过程,时间复杂度为 O(n)O(n)

分析堆排序#

HEAPSORT(A, n)
BUILD-MAX-HEAP(A, n)
for i = n downto 2
swap(A[1], A[i])
A.heap-size = A.heap-size - 1
MAX-HEAPIFY(A, 1)

对于堆排序,它的过程就非常简单了,因为有n-1次循环,每次MAX-HEAPIFY(A, 1)的时间复杂度为 O(lgn)O(\lg n) ,堆排序的时间复杂度为 O(nlgn)O(n \lg n)


代码#

编写MIN-HEAPIFY(A,i)#

维护堆是一个向下调整(Percolate Down)的过程,根据大根堆的思路,很容易知道小根堆的思路为找到i结点的左右结点,判断i结点是否为最小的,不是则替换,并递归被替换的位置。

于是它的伪代码:

MIN-HEAPIFY(A, i)
l = LEFT(i)
r = RIGHT(i)
if l ≤ A.heap-size and A[l] < A[i]
smallest = l
else smallest = i
if r ≤ A.heap-size and A[r] < A[smallest]
smallest = r
if smallest != i
exchange A[i] with A[smallest]
MIN-HEAPIFY(A, smallest)

使用循环重写MAX-HEAPIFY#

我们可以用循环来替换递归来提高效率。当最后的 largest == i 时,我们结束循环。 于是就有:

MAX-HEAPIFY(A, i)
while true
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
return
exchange A[i] with A[largest]
i = largest

线性时间排序#

本篇原文:

https://hoyue.fun/algorithm_5.html

分析#

证明比较排序的下界#

对于一棵每个排列都是一个可达的叶结点的决策树来说,树的高度完全可以被确定。考虑一棵高度为h的决策树,它对应一个对n个元素所做的比较排序。

对于n个变量的问题,这n个变量有n!种可能的顺序,它在决策树中就有n!个叶结点。根据图中情况可以知道,若为满二叉树,在h层有 2h2^h 个叶子结点,明显 n!2hn! \leq 2^h

两边取对数得: h >= lg(n!)

根据Sterling 近似公式,有 n! ≈ √(2πn)(n/e)^n => n! > (n/e)^n 带入可得, h ≥ lg(n/e)^n = n lg(n/e) = n lg n - n lg e = Ω(n lg n)

分析桶排序#

桶排序的步骤为:

  1. 根据A数组新建B链表数组,并初始化为0。
  2. 将A数组中的元素依次按照一定的规则插入到桶中。(有点像散列表)。
  3. 对每个桶内部进行插入排序,当插入排序规模很小的时候,趋向于 Θ(n)\Theta(n)
  4. 按顺序输出每个桶(桶设置时是有序的)。

它的伪代码:

BUCKET-SORT(A, n)
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[⌊n · A[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
return the concatenated lists

在最坏的情况下(所有的数据都插到同一个桶中),我们发现除了插入排序外,其他的均为 Θ(n)\Theta(n) 。因为插入排序的时间代价为平方阶的,那么整体的桶排序时间代价为: T(n)=Θ(n)+(0,n1)O(n2)T(n) = \Theta(n) + \sum_{(0,n-1)}O(n^2)

我们计算平均的运行时间,对这个式子取数学期望(mean):

E(T(n))=Θ(n)+E((0,n1)O(ni2))E( T(n) )= \Theta(n) + E (\sum_{(0,n-1)}O(n_i^2) ) =Θ(n)+(0,n1)O(E(ni2))= \Theta(n) + \sum_{(0,n-1)}O( E(n_i^2) )

一共有n个桶,这个数据落入这个桶的概率为1/n,数据落入桶这个过程它满足二项分布 niB(n,1/n)n_i \sim B(n,1/n)

E(T(n))=Θ(n)+(0,n1)O(E(ni2))E( T(n) )= \Theta(n) + \sum_{(0,n-1)}O( E(n_i^2) ) =Θ(n)+(0,n1)O(21/n)= \Theta(n) + \sum_{(0,n-1)}O( 2 - 1/n ) =Θ(n)+O(n)= \Theta(n) + O(n) =Θ(n)= \Theta(n)

因为在二项分布中:

  • E(x)=np=1;E(x) = np = 1;
  • Var(x)=np(1p)=11/n;Var(x) = np(1-p) = 1 - 1/n;
  • E(x2)=Var(x)+E2(x)=np(1p)+(np)2=11/n+1=21/n;E(x^2) = Var(x) + E^2(x) = np(1-p) + (np)^2 = 1 - 1/n + 1 = 2 - 1/n;

故平均情况下为 Θ(n)\Theta(n) 但最坏的情况为 Θ(n2)\Theta(n^2) ,因为插入排序最差情况为 Θ(n2)\Theta(n^2)


代码#

修改计数排序#

设由n个元素组成的输入序列每个元素都是区间[0: k]内的一个整数,且这些元素没有卫星数据,修改COUNTING-SORT,只用数组 A和C,将输出序列放在A中。

这样输入序列所有元素只能从 C 中复制,修改后COUNTING-SORT的伪代码如下:

COUNTING-SORT(A, n, k)
let C[0:k] be new arrays
for i = 0 to k
C[i] = 0
for j = 1 to n
C[A[j]] = C[A[j]] + 1 // C[i] now contains the number of elements equal to i.
p = 1
for i = 0 to k
while C[i] > 0
A[p] = i
p = p + 1
C[i] = C[i] - 1

基于CDF的桶排序#

设X为一个随机变量,设它的累计分布函数(CDF)为F(x) = P(X≤x),设 X1,X2,,XnX_1,X_2,\ldots, X_n 服从F(x),且在 O(1)O(1) 时间内可以计算得到(若给定 y 和 F(x)=y,能够在 O(1)O(1) 时间内计算出x ),设计一个算法,能在平均情况为线性时间内完成排序。

这样的算法我们可以使用桶排序,设置F(x) = i/n,有n个桶,它们就满足二项分布了,这个就和桶排序基本一致,于是我们的伪代码可以写为:

BUCKET-SORT(X, n)
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
if x_{i-1} ≤ X_i and X_i < x_i
insert X_i into list B[i-1]
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
return the concatenated lists

其中, xix_i 是满足 F(xi)=i/nF(x_i) = i/n 的值,可以在 O(1)O(1) 时间内计算得到。由于输入数据服从分布F(x),每个桶期望包含的元素数量为1,因此平均情况下算法的时间复杂度为 Θ(n)\Theta(n)


后记#

通过这次期末复习专题,我们系统回顾了算法设计与分析的核心内容,包括散列表的开放定址法、动态规划的最优二叉搜索树、贪心算法的哈夫曼编码、基本图论的BFS和DFS、单源最短路的Bellman-Ford和Dijkstra算法、堆排序以及线性时间排序算法。

这些算法各有特点和适用场景:

  • 散列表 通过双散列等开放定址法有效解决冲突问题
  • 动态规划 通过最优子结构和重叠子问题性质求解最优化问题
  • 贪心算法 在每步做出局部最优选择,在特定问题上能得到全局最优解
  • 图论算法 为后续复杂图算法奠定基础
  • 最短路算法 分别适用于有负权边和无负权边的情况
  • 堆排序 保证 O(nlgn)O(n \lg n) 的时间复杂度
  • 线性时间排序 在特定条件下突破比较排序的下界

掌握这些基础算法的原理、实现和分析方法,对于解决实际问题和进一步学习高级算法具有重要意义。算法设计与分析不仅是计算机科学的核心课程,更是培养逻辑思维和问题求解能力的重要途径。

【算法设计与分析】期末复习专题
https://hoyue.fun/algorithm_final.html
作者
Hoyue
发布于
2023-05-28
最后更新于
2023-05-29
许可协议
CC BY-NC-SA 4.0
评论