k 2 k 1 k 4 k 3 k 5 d 0 d 1 d 2 d 3 d 4 d 5 通过 BST 搜索某个结点x x x 时,需要访问的结点数就是d T ( x ) + 1 d_T(x)+1 d T ( x ) + 1 ,我们就将访问结点数作为一个关键字的搜索代价。(其中,d T ( x ) d_T(x) d T ( x ) 表示字符x x x 在二叉搜索树T T T 中的深度)
那么与W P L WPL W P L 类似,一个最优二叉检索树的期望代价 :
E [ Search cost in T ] = ∑ i = 1 n [ d T ( k i ) + 1 ] × k i . p + ∑ i = 0 n [ d T ( d i ) + 1 ] × d i . p = 1 + ∑ i = 1 n d T ( k i ) × k i . p + ∑ i = 0 n d T ( d i ) × d i . p \begin{aligned} E[\text{Search cost in }T]&=\sum_{i=1}^n[d_T(k_i)+1]\times k_i.p+\sum_{i=0}^n[d_T(d_i)+1]\times d_i.p\\ &=1+\sum_{i=1}^nd_T(k_i)\times k_i.p+\sum_{i=0}^nd_T(d_i)\times d_i.p \end{aligned} E [ Search cost in T ] = i = 1 ∑ n [ d T ( k i ) + 1 ] × k i . p + i = 0 ∑ n [ d T ( d i ) + 1 ] × d i . p = 1 + i = 1 ∑ n d T ( k i ) × k i . p + i = 0 ∑ n d T ( d i ) × d i . p
求解最优二叉检索树就是找到这样一棵二叉搜索树T ∗ T^* T ∗ ,使得E ( T ∗ ) E(T^*) E ( T ∗ ) 最小.
T ∗ = arg max T E ( T ) T^*=\arg\max_{T}E(T) T ∗ = arg T max E ( T )
问题分析 构造一棵任意的 BST 是简单的。我们只需对K K K 进行全排列,以排列后的序列第一个元素作为根结点,依照 BST 的构建方法逐步添加结点,直到所有的k i k_i k i 加入树中,然后再填补空隙d i d_i d i 作为叶子结点即可。这样能得到所有可能的 BST(含重复),即便如此,搜索所有可能的解空间需要消耗的时间也十分巨大。
《算法导论》一书中指出,n n n 个结点的二叉树(虽然并不是BST)数量在Ω ( 4 n / n 3 / 2 ) \Omega(4^n/n^{3/2}) Ω ( 4 n / n 3/2 ) ,所以暴力穷举求解是十分困难的,存在指数爆炸问题 。因此下面将介绍一种使用动态规划 求解 OBST 的算法。
状态转移方程 现在我们从后往前 思考,对于一棵以k r k_r k r 为根结点的 BST,其搜索代价必然包含 k r k_r k r 本身的代价,以及由k r k_r k r 所划分的左右子树T 1 , T 2 T_1,\;T_2 T 1 , T 2 的搜索代价.
其中,T 1 T_1 T 1 是由全体小于k r k_r k r 的关键字子序列K 1 : = K < k 1 . . k r − 1 > K_1:=K<k_1..k_{r-1}> K 1 := K < k 1 .. k r − 1 > 构成,当然还包括伪关键字(空隙)的子序列D 1 = D < d 0 . . d r − 1 > D_1=D<d_0..d_{r-1}> D 1 = D < d 0 .. d r − 1 > ;T 2 T_2 T 2 是由全体大于k r k_r k r 的关键字子序列K 2 : = K < k 1 . . k r − 1 > K_2:=K<k_1..k_{r-1}> K 2 := K < k 1 .. k r − 1 > 及其对应的空隙子序列D 2 D_2 D 2 构成。
k r K1: <k1,k2,...,kr-1> K2: <kr+1,kr+2,...,kn> T T1 T2 并且,以k r k_r k r 作为根结点时的二叉树T T T 比之其左右子树T 2 , T 2 T_2,\;T_2 T 2 , T 2 深度多了正好一个单位 。 从而,有:
E ( T ) = [ E ( T 1 ) + w ( 1 , k − 1 ) ] + [ E ( T 2 ) + w ( k + 1 , n ) ] + k r . p = E ( T 1 ) + E ( T 2 ) + [ w ( 1 , k − 1 ) + w ( k + 1 , n ) + k r . p ] = E ( T 1 ) + E ( T 2 ) + w ( 1 , n ) \begin{aligned} E(T)&=[E(T_1)+w(1,k-1)]+[E(T_2)+w(k+1,n)]+k_r.p\\ &=E(T_1)+E(T_2)+[w(1,k-1)+w(k+1,n)+k_r.p]\\ &=E(T_1)+E(T_2)+w(1,n) \end{aligned} E ( T ) = [ E ( T 1 ) + w ( 1 , k − 1 )] + [ E ( T 2 ) + w ( k + 1 , n )] + k r . p = E ( T 1 ) + E ( T 2 ) + [ w ( 1 , k − 1 ) + w ( k + 1 , n ) + k r . p ] = E ( T 1 ) + E ( T 2 ) + w ( 1 , n )
其中,w ( 1 , k − 1 ) w(1,k-1) w ( 1 , k − 1 ) 是左子树深度增加一位之后,比起E ( T 1 ) E(T_1) E ( T 1 ) 来多出来的搜索代价 ,右子树同理。不难给出对于任意新增代价 w ( i , j ) w(i,j) w ( i , j ) 的表达式:
w ( i , j ) = ∑ l = i j k l . p + ∑ l = i − 1 j d l . p w(i,j)=\sum_{l=i}^jk_l.p+\sum_{l=i-1}^jd_l.p w ( i , j ) = l = i ∑ j k l . p + l = i − 1 ∑ j d l . p
事实上,T 1 , T 2 T_1,\;T_2 T 1 , T 2 正是由我们需要解决的问题中划分出来的更小规模的子问题 。我们只需要搜索 从K K K 中,以哪一个结点作为根结点 可以使得E ( T ) E(T) E ( T ) 最大,那么问题就可以解决。
更一般化地,如果需要从子序列< k i , k i + 1 , . . . , k j > <k_i,k_{i+1},...,k_j> < k i , k i + 1 , ... , k j > 中求最小期望代价,那么可以将序列的起始点和终止点作为 状态 ,在该状态下的最优值记作d p ( i , j ) dp(i,j) d p ( i , j ) 则有:
d p ( i , j ) = { min i ≤ k ≤ j { d p ( i , k − 1 ) + d p ( k + 1 , j ) + w ( i , j ) } , j ≥ i d i − 1 . p , j = i − 1 dp(i,j)=\begin{cases} \min\limits_{i\leq k\leq j}\{dp(i,k-1)+dp(k+1,j)+w(i,j)\},&j\geq i\\ d_{i-1}.p,&j=i-1 \end{cases} d p ( i , j ) = ⎩ ⎨ ⎧ i ≤ k ≤ j min { d p ( i , k − 1 ) + d p ( k + 1 , j ) + w ( i , j )} , d i − 1 . p , j ≥ i j = i − 1
该状态方程指出,当划分出空子树 时,即j = i − 1 j=i-1 j = i − 1 时,其空隙的频率作为叶子结点出现,该频率就是唯一的搜索代价。 而j ≥ i j\geq i j ≥ i 时,就从i → j i\to j i → j 中搜索出以哪个结点k k k 为根结点时的代价最小。
值得注意的是,我们的转移方程虽然建立起来了,但是我们还需关注自底向上 的数据是如何建立的。
例如,想要计算d p [ 1 , 5 ] dp[1,5] d p [ 1 , 5 ] ,根据转移方程: 左边需要遍历d p [ 1 , 0 ] , d p [ 1 , 1 ] , d [ 1 , 2 ] , d p [ 1 , 3 ] , d p [ 1 , 4 ] dp[1,0],dp[1,1],d[1,2],dp[1,3],dp[1,4] d p [ 1 , 0 ] , d p [ 1 , 1 ] , d [ 1 , 2 ] , d p [ 1 , 3 ] , d p [ 1 , 4 ] ; 右边需要遍历d p [ 2 , 5 ] , d p [ 3 , 5 ] , d p [ 4 , 5 ] , d p [ 5 , 5 ] , d p [ 6 , 5 ] dp[2,5],dp[3,5],dp[4,5],dp[5,5],dp[6,5] d p [ 2 , 5 ] , d p [ 3 , 5 ] , d p [ 4 , 5 ] , d p [ 5 , 5 ] , d p [ 6 , 5 ] .
而这些都需要我们一开始计算并保存过。下面给出将动态规划表旋转后的结果,该旋转使得数组的对角线元素在水平线上。
6 5 4 3 2 1 5 4 3 2 1 4 3 2 1 3 2 1 2 1 1 i j dp Table 0 5 1 6 图中的数字并不是实际结果,而是一个标记。可见只有在先算完图中标记为1 1 1 的数据之后,才能去计算标记为2 2 2 的数据,以此类推。 如果记l l l 为上表中的 标记值-1
,则:
l = 0 l=0 l = 0 时,i = 1 , 2 , 3 , 4 , 5 , 6. j = 0 , 1 , 2 , 3 , 4 , 5 i=1,2,3,4,5,6.\;j=0,1,2,3,4,5 i = 1 , 2 , 3 , 4 , 5 , 6. j = 0 , 1 , 2 , 3 , 4 , 5 (此时j = i − 1 j=i-1 j = i − 1 ,所以应提前处理)l = 1 l=1 l = 1 时,i = 1 , 2 , 3 , 4 , 5. j = 1 , 2 , 3 , 4 , 5 i=1,2,3,4,5.\;j=1,2,3,4,5 i = 1 , 2 , 3 , 4 , 5. j = 1 , 2 , 3 , 4 , 5 l = 2 l=2 l = 2 时,i = 1 , 2 , 3 , 4. j = 2 , 3 , 4 , 5 i=1,2,3,4.\;j=2,3,4,5 i = 1 , 2 , 3 , 4. j = 2 , 3 , 4 , 5 l = 3 l=3 l = 3 时,i = 1 , 2 , 3. j = 3 , 4 , 5 i=1,2,3.\;j=3,4,5 i = 1 , 2 , 3. j = 3 , 4 , 5 l = 4 l=4 l = 4 时,i = 1 , 2. j = 4 , 5 i=1,2.\;j=4,5 i = 1 , 2. j = 4 , 5 l = 5 l=5 l = 5 时,i = 1. j = 5 i=1.\;j=5 i = 1. j = 5 (d p [ 1 , 5 ] dp[1,5] d p [ 1 , 5 ] 即为所求)根据以上自底向上的计算顺序,可总结出算法的循环体中应该有:i : 1 → n − l + 1 , j : i − l + 1 i:1\to n-l+1,\;j:i-l+1 i : 1 → n − l + 1 , j : i − l + 1
伪代码与复杂度 在算法中,为了简单起见,避免k i . p , d i . p k_i.p,\;d_i.p k i . p , d i . p 这种多余的符号,我们规定输入关键字序列K K K 的个数n n n ,其频率p [ 1.. n ] p[1..n] p [ 1.. n ] ,空隙出现的频率q [ 1.. n + 1 ] q[1..n+1] q [ 1.. n + 1 ] 。
利用二维数组d p [ 1.. n + 1 , 0.. n ] dp[1..n+1,0..n] d p [ 1.. n + 1 , 0.. n ] 作为动态规划表(即备忘录)。根据转移方程可知,只在j ≥ i − 1 j\geq i-1 j ≥ i − 1 时,该数组的内容才会被用到。
d p [ . . ] dp[..] d p [ .. ] 的行列数均是n + 1 n+1 n + 1 是因为存在伪关键字单独作为结点的情况,如d p [ n + 1 , n ] dp[n+1,n] d p [ n + 1 , n ] 表示以k n k_n k n 为根结点时,其右子树为空树,但补上空隙d n d_n d n 作为子结点。同理,d p [ 1 , 0 ] dp[1,0] d p [ 1 , 0 ] 表示k 1 k_1 k 1 为根结点时,左子树为空树时,补上d 0 d_0 d 0 。
与d p [ . . ] dp[..] d p [ .. ] 类似,我们利用二维数组w [ 1.. n + 1 , 0.. n ] w[1..n+1,0..n] w [ 1.. n + 1 , 0.. n ] 存储新增代价,避免后续的重复计算。新增代价的迭代更新也很简单:
w [ i , j ] = { w [ i , j − 1 ] + p [ j ] + q [ j ] , j ≥ i d i − 1 . p , j = i − 1 w[i,j]=\begin{cases} w[i,j-1]+p[j]+q[j],&j\geq i\\ d_{i-1}.p,&j=i-1 \end{cases} w [ i , j ] = { w [ i , j − 1 ] + p [ j ] + q [ j ] , d i − 1 . p , j ≥ i j = i − 1
为记录结果(追溯解 ),我们利用二维数组r o o t [ 1.. n , 1.. n ] root[1..n,1..n] roo t [ 1.. n , 1.. n ] 存储在下标子域r o o t [ i , j ] root[i,j] roo t [ i , j ] 时使得代价最小时选取的根结点r r r . 显然,只有在1 ≤ i ≤ j ≤ n 1\leq i\leq j\leq n 1 ≤ i ≤ j ≤ n 时,该数组的内容才有意义。
输入:关键字序列K K K 的个数n n n ,其频率p [ 1.. n ] p[1..n] p [ 1.. n ] ,空隙出现的频率q [ 1.. n + 1 ] q[1..n+1] q [ 1.. n + 1 ] 输出:OBST的最小代价,根结点的选取表r o o t [ 1.. n , 1.. n ] root[1..n,1..n] roo t [ 1.. n , 1.. n ] Algorithm: Optimal-BST ( n , p , q ) 1. 初始化二维数组 d p , w , r o o t 2. f o r i = 1 t o n + 1 d o 3. d p [ i , i − 1 ] ← q [ i − 1 ] 4. w [ i , i − 1 ] ← q [ i − 1 ] 5. f o r l = 1 t o n d o 6. f o r i = 1 t o n − l + 1 d o 7. j ← i + l − 1 8. d p [ i , j ] ← + ∞ 9. w [ i , j ] ← w [ i , j − 1 ] + p [ j ] + q [ j ] 10. f o r r = i t o j d o 11. t m p ← d p [ i , r − 1 ] + d p [ r + 1 , j ] + w [ i , j ] 12. i f d p [ i , j ] > t m p t h e n 13. d p [ i , j ] ← t m p 14. r o o t [ i , j ] ← r 15. r e t u r n d p [ 1 , n ] a n d r o o t \begin{aligned} &\text{Algorithm: }\;\text{Optimal-BST}(n,p,q)\\\\ 1.&\;\text{初始化二维数组 }dp,\;w,\;root\\ 2.&\;\mathbf{for}\;i\;=1\;\mathbf{to}\;n+1\;\mathbf{do}\\ 3.&\;\qquad dp[i,i-1]\leftarrow q[i-1]\\ 4.&\;\qquad w[i,i-1]\leftarrow q[i-1]\\ 5.&\;\mathbf{for}\;l\;=1\;\mathbf{to}\;n\;\mathbf{do}\\ 6.&\;\qquad\mathbf{for}\;i\;=1\;\mathbf{to}\;n-l+1\;\mathbf{do}\\ 7.&\;\qquad\qquad j\leftarrow i+l-1\\ 8.&\;\qquad\qquad dp[i,j]\leftarrow +\infty\\ 9.&\;\qquad\qquad w[i,j]\leftarrow w[i,j-1]+p[j]+q[j]\\ 10.&\;\qquad\qquad\mathbf{for}\;r\;=i\;\mathbf{to}\;j\;\mathbf{do}\\ 11.&\;\qquad\qquad\qquad tmp\leftarrow dp[i,r-1]+dp[r+1,j]+w[i,j]\\ 12.&\;\qquad\qquad\qquad\mathbf{if}\;dp[i,j]\gt tmp\;\mathbf{then}\\ 13.&\;\qquad\qquad\qquad\qquad dp[i,j]\leftarrow tmp\\ 14.&\;\qquad\qquad\qquad\qquad root[i,j]\leftarrow r\\ 15.&\;\mathbf{return}\;dp[1,n]\;\mathbf{and}\;root \end{aligned} 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. Algorithm: Optimal-BST ( n , p , q ) 初始化二维数组 d p , w , roo t for i = 1 to n + 1 do d p [ i , i − 1 ] ← q [ i − 1 ] w [ i , i − 1 ] ← q [ i − 1 ] for l = 1 to n do for i = 1 to n − l + 1 do j ← i + l − 1 d p [ i , j ] ← + ∞ w [ i , j ] ← w [ i , j − 1 ] + p [ j ] + q [ j ] for r = i to j do t m p ← d p [ i , r − 1 ] + d p [ r + 1 , j ] + w [ i , j ] if d p [ i , j ] > t m p then d p [ i , j ] ← t m p roo t [ i , j ] ← r return d p [ 1 , n ] and roo t
显然,算法嵌套了三层循环,每层执行的操作在O ( 1 ) O(1) O ( 1 ) ,因此该算法的时间复杂度为Θ ( n 3 ) \Theta(n^3) Θ ( n 3 ) ,而利用了3个近似为n × n n\times n n × n 的的二维数组作为备忘和结果,所以空间复杂度为O ( n 2 ) O(n^2) O ( n 2 ) .
实例与编程 假设给定概率分布 如下:
i i i 0 1 2 3 4 5 p [ i ] p[i] p [ i ] 0.15 0.10 0.05 0.10 0.20 q [ i ] q[i] q [ i ] 0.05 0.10 0.05 0.05 0.05 0.10
下给出 c
系列的编程实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 #include <iostream> #include <algorithm> #include <string.h> #include <vector> #include <queue> #include <cstdlib> using namespace std;#define M 100 double dp[M][M];double w[M][M];int root[M][M];double Optimal_BST (double p[], double q[], int n) { memset (dp,0 ,sizeof (dp)); memset (root,0 ,sizeof (root)); memset (w,0 ,sizeof (w)); for (int i = 1 ; i <= n+1 ; i++){ dp[i][i-1 ] = q[i-1 ]; w[i][i-1 ] = q[i-1 ]; } for (int l = 1 ; l <= n; l++){ for (int i = 1 ; i <= n-l+1 ; i++){ int j = i+l-1 ; dp[i][j] = INT32_MAX; w[i][j] = w[i][j-1 ]+p[j]+q[j]; for (int r = i; r <= j; r++){ double tmp = dp[i][r-1 ]+dp[r+1 ][j]+w[i][j]; if (dp[i][j] > tmp){ dp[i][j] = tmp; root[i][j] = r; } } } } return dp[1 ][n]; } int main (void ) { double p[6 ] = {0 , 0.15 , 0.10 , 0.05 , 0.10 , 0.20 }; double q[6 ] = {0.05 , 0.10 , 0.05 , 0.05 , 0.05 , 0.10 }; cout << "min E = " << Optimal_BST (p, q, 5 ) << endl << endl; for (int l = 0 ; l <= 5 ; l++){ for (int i = 1 ; i <= 5 -l+1 ; i++){ int j = i+l-1 ; cout << "(" << i << "," << j << "): dp = " ; printf ("%.2f\t" ,dp[i][j]); cout << "w = " ; printf ("%.2f\t" ,w[i][j]); cout << "root = " << root[i][j] << endl; } } return 0 ; }
得到如下旋转过后的各数组结果:
从而得到对于该问题的最小代价为2.75 2.75 2.75 .
最小生成树|Minimum Spanning Tree,MST 对于一个 无向带权连通图 G = ( V , E , W ) G=(V,E,W) G = ( V , E , W ) ,我们需要寻找G G G 的一个无环子集 T T T 使得T ⊆ G T\subseteq G T ⊆ G 且T T T 包含G G G 的所有顶点的同时又具有最小权重,即w ( T ) = ∑ ( u , v ) ∈ T w ( u , v ) w(T)=\sum\limits_{(u,v)\in T}w(u,v) w ( T ) = ( u , v ) ∈ T ∑ w ( u , v ) 的值最小。
由T T T 无环且连通所有顶点可知,T T T 必然是一棵树,我们就称T T T 是 图G G G 的最小生成树 Minimum Spanning Tree,MST。
生成树的性质 生成树具有如下性质 设G ( V , E , W ) G(V,E,W) G ( V , E , W ) 是n n n 阶连通图,即∣ V ∣ = n |V|=n ∣ V ∣ = n ,那么:
T T T 是G G G 的生成树 当且仅当T T T 无环且有n − 1 n-1 n − 1 条边;若某条边e ∈ E ( G ) e\in E(G) e ∈ E ( G ) ,且G G G 的一个生成树T T T 中不含有e e e ,即e ∉ E ( T ) e\notin E(T) e ∈ / E ( T ) ,那么E ( T ) ∪ { e } E(T)\cup \{e\} E ( T ) ∪ { e } 必含有一个回路,记为C C C ;(注:用E ( X ) E(X) E ( X ) 表示某个图X X X 的边集 ) 紧接第二点,如果去掉回路C C C 中任意一条边e ′ , e ′ ≠ e e',\quad e'\neq e e ′ , e ′ = e ,最终得到的T ′ T' T ′ 仍是生成树。 如下图所示:
根据该性质,我们可以初步得出一种找到 MST 的策略: 首先得到一棵生成树T T T ,然后对其加一条非树边e e e 以形成回路C C C . 如果能在C C C 中去掉一条边e ′ e' e ′ ,其中有w ( e ′ ) < w ( e ) w(e')\lt w(e) w ( e ′ ) < w ( e ) ,那么得到的新树T ′ T' T ′ 就有:w ( T ′ ) − w ( T ) = w ( e ′ ) − w ( e ) < 0 w(T')-w(T)=w(e')-w(e)\lt0 w ( T ′ ) − w ( T ) = w ( e ′ ) − w ( e ) < 0 ,即是w ( T ′ ) < w ( T ) w(T')\lt w(T) w ( T ′ ) < w ( T ) .
从而可以根据该策略不断改进,最终得到 MST.
于是,求出一棵生成树的问题也就可以按照如下的通用算法进行描述:
Algorithm: Generic-MST ( G , E , w ) 1. A ← ∅ 2. w h i l e A does not from a spanning tree d o 3. find an edge ( u , v ) that is safe for A 4. A ← A ∪ { ( u , v ) } 5. r e t u r n A \begin{aligned} &\text{Algorithm: }\;\text{Generic-MST}(G,E,w)\\\\ 1.&\;A\leftarrow\varnothing\\ 2.&\;\mathbf{while}\;A\text{ does not from a spanning tree}\;\mathbf{do}\\ 3.&\;\qquad \text{find an edge }(u,v)\text{ that is safe for }A\\ 4.&\;\qquad A\leftarrow A\cup\{(u,v)\}\\ 5.&\;\mathbf{return}\;A \end{aligned} 1. 2. 3. 4. 5. Algorithm: Generic-MST ( G , E , w ) A ← ∅ while A does not from a spanning tree do find an edge ( u , v ) that is safe for A A ← A ∪ {( u , v )} return A
此处的A A A 即是 MST 的边集. 此处的s a f e safe s a f e 表示的是这样一条边( u , v ) (u,v) ( u , v ) 使得A ∪ { ( u , v ) } A\cup\{(u,v)\} A ∪ {( u , v )} 仍然是 MST 边的子集,把这样的边称为 安全边 .
下面将具体介绍两种算法用于求解最小生成树,并且二者均是利用了贪心策略 的思想,但具体如何找到安全边的方法则不一样。
Prim算法 Prim 算法的核心思想是将图的顶点V V V 划分为两个子集S , V − S S,V-S S , V − S . 如果边e = ( u , v ) e=(u,v) e = ( u , v ) 中,u ∈ S , v ∈ V − S u\in S,v\in V-S u ∈ S , v ∈ V − S 则称e e e 横跨 V V V 的一个切割 ( S , V − S ) (S,V-S) ( S , V − S ) .
而 Prim算法 要求不断找到横跨某个切割的最小权重边,这样的边称为 轻量级边 。可以证明这样的轻量级边就是一条安全边 。
Prim算法 将当前的轻量级边划入子集A A A ,对应的端点加入S S S 中,从而构造出下一个切割,再寻找下一条轻量级边,直到所有结点都划入S S S ,最终的结果即为 MST.
说人话就是,初始化最终 MST 的点集和边集为空。 先选定一个点,找与该点距离最近的边 ,然后将此边的连接的另一个端点加入点集,此边加入边集。 接下来,又继续找距离当前这个点集 最近的边,依次加入,直到所有顶点都加入点集。
算法伪码 输入:连通图G G G ,边权w w w ,最小生成树的根结点r r r 输出:最小生成树的边集A A A 其他:v . k e y v.key v . k ey 表示树中与v v v 相连的边权重 ,若不存在则赋为∞ \infty ∞ v . π v.\pi v . π 表示v v v 在树中的父结点,有:边 e = ( v , v . π ) e=(v,v.\pi) e = ( v , v . π ) 的权值是v . k e y v.key v . k ey G . V G.V G . V 表示图G G G 的点集(数学中表示为V ( G ) V(G) V ( G ) )G . A d j [ v ] G.Adj[v] G . A d j [ v ] 表示图G G G 中与结点v v v 相邻的结点的集合(数学中表示为N G ( v ) N_G(v) N G ( v ) ) Algorithm: MST-Prim ( G , w , r ) / / r 是起始点,最终作为树的根结点 1. f o r each u ∈ G . V d o 2. u . k e y ← + ∞ 3. u . π ← N I L 4. r . k e y ← 0 5. Q ← G . V / / Q 为按权重 V . k e y 递增的优先队列 6. A ← ∅ 7. w h i l e Q ≠ ∅ d o 8. u ← Extract-min ( Q ) 9. A ← A ∪ { ( u , u . π ) } 10. f o r each v ∈ G . A d j [ u ] d o 11. i f v ∈ Q and w ( u , v ) < v . k e y t h e n 12. v . π ← u 13. v . k e y ← w ( u , v ) 14. r e t u r n A \begin{aligned} &\text{Algorithm: }\;\text{MST-Prim}(G,w,r)\\ &//r\text{是起始点,最终作为树的根结点}\\\\ 1.&\;\mathbf{for}\;\text{each }u\in G.V\;\mathbf{do}\\ 2.&\;\qquad\;u.key\leftarrow+\infty\\ 3.&\;\qquad\;u.\pi\leftarrow NIL\\ 4.&\;r.key\leftarrow0\\ 5.&\;Q\leftarrow G.V\;//Q\text{为按权重 }V.key\text{ 递增的优先队列}\\ 6.&\;A\leftarrow\varnothing\\ 7.&\;\mathbf{while}\;Q\neq\varnothing\;\mathbf{do}\\ 8.&\;\qquad u\leftarrow\text{Extract-min}(Q)\\ 9.&\;\qquad A\leftarrow A\cup\{(u,u.\pi)\}\\ 10.&\;\qquad\mathbf{for}\;\text{each }v\in G.Adj[u]\;\mathbf{do}\\ 11.&\;\qquad\qquad\mathbf{if}\;v\in Q\text{ and }w(u,v)\lt v.key\;\mathbf{then}\\ 12.&\;\qquad\qquad\qquad v.\pi\leftarrow u\\ 13.&\;\qquad\qquad\qquad v.key\leftarrow w(u,v)\\ 14.&\;\mathbf{return}\;A \end{aligned} 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. Algorithm: MST-Prim ( G , w , r ) // r 是起始点,最终作为树的根结点 for each u ∈ G . V do u . k ey ← + ∞ u . π ← N I L r . k ey ← 0 Q ← G . V // Q 为按权重 V . k ey 递增的优先队列 A ← ∅ while Q = ∅ do u ← Extract-min ( Q ) A ← A ∪ {( u , u . π )} for each v ∈ G . A d j [ u ] do if v ∈ Q and w ( u , v ) < v . k ey then v . π ← u v . k ey ← w ( u , v ) return A
其中,第10到13行是对队列中的结点的属性进行更新 ,使得每次循环都能保证队列中的结点v v v 到MST子集A A A 的权重v . k e y v.key v . k ey 最小,此时保存好在A A A 中与v v v 相连的结点,即是其父结点v . π v.\pi v . π 。
如果优先队列Q Q Q 采用 最小二叉堆 实现,则每一次循环取出当前最小元的过程(利用最小二叉堆实现时)需要花费O ( log ∣ V ∣ ) O(\log |V|) O ( log ∣ V ∣ ) ,while
循环次数O ( ∣ V ∣ ) O(|V|) O ( ∣ V ∣ ) ,for
循环总次数O ( ∣ E ∣ ) O(|E|) O ( ∣ E ∣ ) ,事先装载队列需要O ( ∣ V ∣ ) O(|V|) O ( ∣ V ∣ ) .
综合来看,该算法的时间复杂度为O ( ∣ V ∣ log ∣ V ∣ + ∣ E ∣ log ∣ V ∣ ) = O ( ∣ E ∣ log ∣ V ∣ ) O(|V|\log |V|+|E|\log |V|)=O(|E|\log |V|) O ( ∣ V ∣ log ∣ V ∣ + ∣ E ∣ log ∣ V ∣ ) = O ( ∣ E ∣ log ∣ V ∣ ) ,空间复杂度O ( ∣ V ∣ ) O(|V|) O ( ∣ V ∣ ) .
如果改用斐波那契堆,则时间复杂度还可以进一步优化为O ( ∣ E ∣ + ∣ V ∣ log ∣ V ∣ ) O(|E|+|V|\log|V|) O ( ∣ E ∣ + ∣ V ∣ log ∣ V ∣ ) .
正确性证明 提出命题 ∀ k < n = ∣ V ∣ \forall k\lt n=|V| ∀ k < n = ∣ V ∣ ,存在一棵 MST 包含有算法前k k k 步选择边而纳入的子集A A A 。
当k = 1 k=1 k = 1 时,假设最小生成树T ∗ T^* T ∗ 不包含由选择的根结点r r r 组成的某条轻量级边( r , i ) (r,i) ( r , i ) ,即( r , i ) ∉ T ∗ (r,i)\notin T^* ( r , i ) ∈ / T ∗ . 设T = ( T ∗ − { ( r , j ) } ) ∪ { ( r , i ) } T=(T^*-\{(r,j)\})\cup\{(r,i)\} T = ( T ∗ − {( r , j )}) ∪ {( r , i )} . 因为( r , i ) (r,i) ( r , i ) 是一条轻量级边,即w ( r , i ) = min ( r , t ) ∈ E w ( r , t ) ≤ w ( r , j ) w(r,i)=\min\limits_{(r,t)\in E}w(r,t)\leq w(r,j) w ( r , i ) = ( r , t ) ∈ E min w ( r , t ) ≤ w ( r , j ) ,所以替换后得到的生成树仍然是最优解
现 假设命题成立 ,证明命题对k + 1 k+1 k + 1 的情况也成立。
设算法当前得到的子集A A A 中的顶点集为S S S ,那么第k + 1 k+1 k + 1 步将会从集合V − S V-S V − S 中选取下一个点i k + 1 i_{k+1} i k + 1 纳入S S S ,且保证i k + 1 i_{k+1} i k + 1 到S S S 有最小权边。如图所示:
假设该最小权边记为e = ( i k + 1 , i l ) e=(i_{k+1},i_l) e = ( i k + 1 , i l ) ,则有:e ∈ T e\in T e ∈ T ,若不然,则将e e e 加入到T ∗ T^* T ∗ 中形成回路,这条回路中一定包含有横跨( S , V − S ) (S,V-S) ( S , V − S ) 的另一条边e ′ e' e ′ ,将其从回路中删除得到新的生成树:
T = ( T ∗ − { e ′ } ) ∪ { e } T=(T^*-\{e'\})\cup\{e\} T = ( T ∗ − { e ′ }) ∪ { e }
因为e e e 是最小权边,所以w ( e ) ≤ w ( e ′ ) , w ( T ) ≤ w ( T ∗ ) w(e)\leq w(e'),w(T)\leq w(T^*) w ( e ) ≤ w ( e ′ ) , w ( T ) ≤ w ( T ∗ ) ,所以T T T 仍然是最小生成树,即算法的第k + 1 k+1 k + 1 步仍然能够得到MST的子集。
当算法执行完毕后,结果A = T A=T A = T 是图G G G 的一个最小生成树。
编程实现 采用 C++
进行编程实现.
首先创建给出结果的边集结构体(Edge
),以及由 Node
和 Edge
数组组合而成的图(Graph
).
此外还有为Prim算法设计的优先队列,因为C++中没有特定的Decrease-key()
方法,因此直接采用 vector
+遍历的方法模拟优先队列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 typedef struct NODE { int tag; int key; int pi; }Node; typedef struct EDGE { int u,v; }Edge; typedef struct GRAPH { int num; vector<int > nodes; vector<Edge> edges; }Graph;
然后是具体的Prim算法实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 Node PopMinKey (vector<Node> &Q) { int min = INT32_MAX; Node u; int index; for (int i = 0 ; i < Q.size (); i++){ if (min > Q[i].key){ min = Q[i].key; u = Q[i]; index = i; } } Q.erase (Q.begin ()+index); return u; } void UpdateQueue (vector<Node> &Q, int target, int sourse, int weight) { for (int i = 0 ; i < Q.size (); i++){ if (Q[i].tag == target){ Q[i].key = weight; Q[i].pi = sourse; break ; } } } vector<Edge> MST_Prim (Graph G, int w[], int r = 0 ) { vector<Node> Q; for (int i = 0 ; i < G.num; i++){ Q.push_back ({G.nodes[i], INT32_MAX, -1 }); } Q[0 ].key = 0 ; Q[0 ].pi = 1 ; vector<Edge> A; while (!Q.empty ()){ Node u = PopMinKey (Q); A.push_back ({u.tag, u.pi}); for (int i = 0 ; i < G.edges.size (); i++){ if (G.edges[i].u == u.tag){ UpdateQueue (Q, G.edges[i].v, u.tag, w[i]); } if (G.edges[i].v == u.tag){ UpdateQueue (Q, G.edges[i].u, u.tag, w[i]); } } } return A; }
实例演示 考虑如下图所示的实例,其正确答案已给出。
3 1 2 4 5 6 6 3 6 5 6 4 2 1 5 5 3 1 2 4 5 6 6 3 6 5 6 4 2 1 5 5 3 1 2 4 5 6 6 3 6 5 6 4 2 1 5 5 3 1 2 4 5 6 6 3 6 5 6 4 2 1 3 1 2 4 5 6 3 6 5 6 4 2 1 3 1 2 4 5 6 3 5 4 2 1 编写主函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int main (void ) { Graph G; int u,v; G.num = 6 ; for (int i = 0 ; i < 6 ; i++){ G.nodes.push_back (i+1 ); } for (int i = 0 ; i < 10 ; i++){ cin >> u >> v; G.edges.push_back ({u,v}); } int w[10 ]= {6 ,1 ,5 ,5 ,3 ,5 ,6 ,4 ,2 ,6 }; vector<Edge> MST = MST_Prim (G,w); for (int i = 1 ; i < MST.size (); i++){ cout << "(" << MST[i].u << ", " << MST[i].v << ")" << endl; } while (1 ); }
结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 输入(边集): 1 2 1 3 1 4 2 3 2 5 3 4 3 5 3 6 4 6 5 6 输出(边集): (3, 1) (6, 3) (4, 6) (2, 3) (5, 2)
Prefect!
Kruskal算法 Kruskal 算法的核心是与 Prim算法 不同。 Kruskal算法并不要求每一步所选最小权边 都是相互连通的。换言之,Kruskal算法中第k k k 步产生的子集A A A 不一定是一棵子树,而有可能是森林。当然,即使是森林也不可能出现环。
不难发现,Prim算法因为每一步仅选取一个点及其轻量级边纳入A A A ,所以可以保证A A A 中的边一定不构成回路,且是一棵树,而Kruskal算法则需要有一个判断是否构成回路的策略 。这个策略可以利用并查集 这种数据结构解决。
综上,Kruskal算法的思想就是,先对边集进行升序排列 ,然后考察当前最小边纳入A A A 之后是否构成回路,如果构成回路则跳过并寻找下一条边。
算法伪码 输入:连通图G G G ,边权w w w 输出:最小生成树的边集A A A Algorithm: MST-Kruskal ( G , w ) 1. f o r each u ∈ G . V d o 2. Make-Set ( u ) 3. Q ← G . E / / Q 为按权重 w ( e ) 递增的优先队列 4. A ← ∅ 5. r e p e a t 6. ( u , v ) ← Extract-min ( Q ) 7. i f Find-Set ( u ) ≠ Find-Set ( u ) t h e n 8. A ← A ∪ { ( u , u . π ) } 9. Union ( u , v ) 10. u n t i l ∣ A ∣ = ∣ V ∣ − 1 11. r e t u r n A \begin{aligned} &\text{Algorithm: }\;\text{MST-Kruskal}(G,w)\\\\ 1.&\;\mathbf{for}\;\text{each }u\in G.V\;\mathbf{do}\\ 2.&\;\qquad\;\text{Make-Set}(u)\\ 3.&\;Q\leftarrow G.E\;//Q\text{为按权重 }w(e)\text{ 递增的优先队列}\\ 4.&\;A\leftarrow\varnothing\\ 5.&\;\mathbf{repeat}\\ 6.&\;\qquad (u,v)\leftarrow\text{Extract-min}(Q)\\ 7.&\;\qquad\mathbf{if}\;\text{Find-Set}(u)\neq\text{Find-Set}(u)\;\mathbf{then}\\ 8.&\;\qquad\qquad A\leftarrow A\cup\{(u,u.\pi)\}\\ 9.&\;\qquad\qquad\text{Union}(u,v)\\ 10.&\;\mathbf{until}\;|A|=|V|-1\\ 11.&\;\mathbf{return}\;A \end{aligned} 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. Algorithm: MST-Kruskal ( G , w ) for each u ∈ G . V do Make-Set ( u ) Q ← G . E // Q 为按权重 w ( e ) 递增的优先队列 A ← ∅ repeat ( u , v ) ← Extract-min ( Q ) if Find-Set ( u ) = Find-Set ( u ) then A ← A ∪ {( u , u . π )} Union ( u , v ) until ∣ A ∣ = ∣ V ∣ − 1 return A
其中:
Make-Set ( u ) \text{Make-Set}(u) Make-Set ( u ) 用于创建集合,第 1到2 行用于为每一个结点单独创建一个集合(树)Find-Set ( u ) \text{Find-Set}(u) Find-Set ( u ) 返回用于表示元素u u u 所在的集合的元素,第 7 行 用于判断加入( u , v ) (u,v) ( u , v ) 后是否构成环,即合并两颗树后是否仍是树Union ( u , v ) \text{Union}(u,v) Union ( u , v ) 用于将u , v u,v u , v 所在集合进行合并分析得知,一共循环O ( ∣ E ∣ ) O(|E|) O ( ∣ E ∣ ) 次,每个循环下,生成集合、取出最小元、合并集合都在O ( log ∣ E ∣ ) O(\log |E|) O ( log ∣ E ∣ ) 下完成。因此该算法的时间复杂度为O ( ∣ E ∣ log ∣ E ∣ ) O(|E|\log |E|) O ( ∣ E ∣ log ∣ E ∣ ) ,如果注意到∣ E ∣ ≤ ∣ V ∣ 2 |E|\leq|V|^2 ∣ E ∣ ≤ ∣ V ∣ 2 ,则还可以表示为O ( ∣ E ∣ log ∣ V ∣ ) O(|E|\log|V|) O ( ∣ E ∣ log ∣ V ∣ ) 。 空间复杂度为O ( ∣ V ∣ ) O(|V|) O ( ∣ V ∣ ) 。
正确性证明 利用数学归纳法 . 证明 对于任意结点数为∣ V ∣ = n |V|=n ∣ V ∣ = n 的无向图G G G ,贪心算法都能得到一棵最小生成树.
n = 2 n=2 n = 2 时,算法能够直接从连接图G G G 的两个端点中找到一条最小权边 ,显然能够得到最小生成树;假设 对于任意n n n 阶图,算法都能得到MST,下面说明对于n + 1 n+1 n + 1 阶图也能得出.对于任意n + 1 n+1 n + 1 阶图G G G ,将其最小权边e = ( i , j ) e=(i,j) e = ( i , j ) 短接 ,如下图所示,将图化为n n n 阶图G ′ G' G ′ 。根据归纳假设,算法能够给出G ′ G' G ′ 的一棵 MST 记为T ′ T' T ′ 。
1 3 2 4 5 6 短接 1 3 2 5 4-6 G G' 1 3 2 5 4-6 T' 1 3 2 4 5 6 恢复 T MST 则T = T ′ ∪ { e } T=T'\cup\{e\} T = T ′ ∪ { e } 就是G G G 的一棵 MST,若不然,∃ T ∗ ∋ e \exists T^*\ni e ∃ T ∗ ∋ e 为G G G 的 MST.
注:就算e ∉ T ∗ e\notin T^* e ∈ / T ∗ 也可以通过加边再减边的方式得到包含e e e 的 MST. (这部分更详细的解释已在Prim算法的正确性证明时给出)
此时,有w ( T ∗ ) < w ( T ) ⇒ w ( T ∗ − { e } ) < w ( T − { e } ) = w ( T ′ ) w(T*)\lt w(T)\Rightarrow w(T^*-\{e\})\lt w(T-\{e\})=w(T') w ( T ∗ ) < w ( T ) ⇒ w ( T ∗ − { e }) < w ( T − { e }) = w ( T ′ ) 这与T ′ T' T ′ 是G ′ G' G ′ 的最小生成树矛盾.
综上所述,Kruskal 算法能够给出任意n n n 阶图的MST.
编程实现 采用 C++
进行编程实现.
首先声明必要的结构体以及以边权大小排序的优先队列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 typedef struct EDGE { int u,v; int w; }Edge; typedef struct GRAPH { int num; vector<int > nodes vector<Edge> edges; }Graph; struct cmp { bool operator () (Edge a, Edge b) { return a.w > b.w; } }; typedef priority_queue<Edge,vector<Edge>,cmp> EdgeQue;
利用 vector
自建并查集及其相关的合并查找函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 typedef struct SET { int root; int value; }Set; vector<Set> MakeSet (vector<int > a) { vector<Set> set (a.size()) ; for (int i = 0 ; i < a.size (); i++){ set[i].root = a[i]; set[i].value = a[i]; } return set; } void SetUnion (vector<Set> &set, int a, int b) { int i = max (set[a].root, set[b].root); int j = min (set[a].root, set[b].root); for (int k = 0 ; k < set.size (); k++){ if (set[k].root == i){ set[k].root = j; } } }
最后是 Kruskal算法的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 vector<Edge> MST_Kruskal (Graph G) { vector<Edge> A; vector<Set> set = MakeSet (G.nodes); EdgeQue Q; for (int i = 0 ; i < G.edges.size (); i++){ Q.push ({G.edges[i].u, G.edges[i].v, G.edges[i].w}); } do { Edge e = Q.top (); Q.pop (); if (set[e.u].root != set[e.v].root){ A.push_back ({e.u, e.v}); SetUnion (set, e.u, e.v); } } while (A.size () < G.num-1 ); return A; }
利用和 Prim进行演示时相同的实例,编写主函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int main (void ) { Graph G; int u,v; G.num = 6 ; int w[10 ]= {6 ,1 ,5 ,5 ,3 ,5 ,6 ,4 ,2 ,6 }; for (int i = 0 ; i < 6 ; i++){ G.nodes.push_back (i+1 ); } for (int i = 0 ; i < 10 ; i++){ cin >> u >> v; G.edges.push_back ({u,v,w[i]}); } vector<Edge> MST = MST_Kruskal (G); for (int i = 0 ; i < MST.size (); i++){ cout << "(" << MST[i].u << ", " << MST[i].v << ")" << endl; } while (1 ); }
结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 输入(边集): 1 2 1 3 1 4 2 3 2 5 3 4 3 5 3 6 4 6 5 6 输出(边集): (1, 3) (4, 6) (2, 5) (3, 6) (2, 3)
Prefect!
参考 《算法导论 (原书第三版)》 算法设计与分析|中国大学MOOC 哈夫曼树(赫夫曼树、最优树)详解 C++优先队列自定义排序总结|CSDN Huffman、Optimal-BST & MST| 算法之于树