典型应用之:最优前缀码,最小生成树,单源最短路径
对于克鲁斯卡尔和普瑞姆算法的实现,等自己复习完数据结构图那一部分,会表达图再说。
最优前缀码
介绍
为了正确解码,要求任何代码不能作为其他代码的前缀,这样的码称为二元前缀码。
比如 a : 001 , b : 00 , c : 010 , d : 01 a:001,b:00,c:010,d:01 a:001,b:00,c:010,d:01。二元前缀码的存储通常采用二叉树结构。令每个字符作为树叶,对应这个字符的前缀码看作根到这片树叶的一条路径。规定每个结点通向左儿子的边记作0,通向右儿子的边记作1,得到这棵二叉树对应的前缀码。
解码的歧义,例如字符串 0100001
解码1: 01, 00, 001 d, b, a
解码2: 010, 00, 01 c, b, d
前缀码的二叉树表示法:
前缀码:
{00000, 00001, 0001, 001, 01,100,101,11}
构造树:
0 -左子树
1-右子树
码对应一片树叶
最大位数为树的深度
问题建模
问题:给定字符集 C = { x 1 , x 2 , . . . , x n } C=\{x1,x2,...,xn\} C={x1,x2,...,xn}和每个字符的频率 f ( x i ) f(x_i) f(xi),i=1,2,…,n. 求关于C的一个最优前缀码(平均传输位数最小):
设字符集C中xi的概率是f(xi)。d(xi)表示xi的二进制位数,也就是xi的码长。那么存储一个字符所使用的二进制位数的平均值是:
B = ∑ i = 1 n f ( x i ) d ( x i ) B=\displaystyle∑_{i=1}^nf(x_i)d(x_i) B=i=1∑nf(xi)d(xi)
由于一个二元前缀码对应一个二叉树,码字就是这棵二叉树的树叶,表示码字的二进制位数就是从根到这片树叶的路径长度,即树叶的深度。那么存储一个字符的平均二进制位数恰好就是这棵树在给给定频率下的平均深度,也称为这棵树的权。
不难看出,对应同一组频率可以构造出不同的二叉树,这些二叉树对应的前缀码的平均字符占用的位数也不一样。占用位数越少的压缩效率越高。压缩效率最高,即每个码字平均使用二进制位数最少的前缀码,称为最优前缀码。
给定频率下,对应于最优二元前缀码的二叉树是平均深度最小,即权最小的二叉树。
哈夫曼编码
这里对树的构造不熟悉,回头补数据结构树的部分
哈夫曼树与哈夫曼编码
伪码描述:
Queue HUffman(C);
//输入:C={x1,x2,...,xn}是字符集,每个字符频率f(x1)
//输出:Q (队列)
{
n = | C |
Q = C //按频率递增构成队列Q
for (i = 1; i <= n - 1;++i)
{
z=Allocate-Node();//生成结点z
z.left=Q中最小元 //取出Q中最小元作为z的左子
z.right=Q中最小元 //取出Q中最小元作为z的右儿子
f(z)=f(x)+f(y)
Insert(Q,z)//将Z插入Q
}
return Q;
}
该算法在递增排序概率需要 O ( n l o g n ) O(nlogn) O(nlogn)的时间,for循环执行 O ( n ) O(n) O(n)次,循环体内插入操作需要 O ( l o g n ) O(logn) O(logn)的时间,于是算法的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
文件归并问题
设 S = { f 1 , f 2 , . . . , f n } S=\{f_1,f_2,...,f_n\} S={f1,f2,...,fn}是一组不同长度的有序文件构成的集合。
最小生成树
背景:设无向连同带权图
G
=
<
V
,
E
,
W
>
G=<V,E,W>
G=<V,E,W>,其中
ω
(
e
)
∈
W
ω(e)∈W
ω(e)∈W是边E的权。G的一棵最小生成树是包含了G的所有顶点的树。树中的各边权之和称为树的权,具有最小权的树称为G的最小生成树。
应用:连接城市的最小费用。
Prim算法
-
设 G = < V , E , W > G=<V,E,W> G=<V,E,W>,其中V= { 1 , 2 , . . . , n } \{1,2,...,n\} {1,2,...,n}。这个算法的基本思想是将V划分成子集S和V-S。初始S={1}.算法每一步从连通S与V-S的边中挑选一条权最小的边,然后把这条边关联的顶点加入到S中去。这条边也就成了生成树T的边。至多经过n-1步,就能得到G的一棵最小生成树。
-
伪码:
Prim
//输入,连通图G=<V,E,W>
//输出,G的最小生成树T
S = {1};T=Ø
while(V-S)≠Ø
{
从V-S中选择j使得j到顶点S中顶点的边e的权最小
T=T∪{e}
S=S∪{j}
}
考虑时间复杂度
实现Prim算法需要两个数组near[1…n]和d[1…n],对于i∈V-S,near[i]是S中距离i最近的顶点,换句话说,它到i的边的权值最小,d[i]是这个最近结点到i的距离。
初始,对于结点i=2,3,…,n,令near[i]=1,如果e=(i,1)是G的边,则d[i]=ω(e);否则d[i]=∞。一旦结点i加入到S中,令d[i]=-1,以此来标记i∈S。
当算法需要选择连通S与V-S的最小边时,只需要检查每个i∈V-S,找到最小的d[i],就可以确定这条最小边,比如说
e
=
(
j
,
k
)
e=(j,k)
e=(j,k),j∈S,k∈V-S。然后把k加到S中,令d[k]=-1,这个检查工作需要
O
(
n
)
O(n)
O(n)的时间。剩下的工作就是修改V-S中所有结点i的near[i]和d[i]的值,因此只需要检查边(k,i)的权是否小于d[i],如果**(k,i)的权更小**,则将near[i]更新为k,且将d[i]的值更新为(k,i)的权。不难看出,所有V-S中结点的更新工作需要
O
(
n
)
O(n)
O(n)时间。因此算法每把1个新结点加到S中总计需要
O
(
n
)
O(n)
O(n)时间。由于需要加入n-1个顶点,于是算法的时间复杂度是
O
(
n
2
)
O(n^2)
O(n2)。
Kruskal算法
伪码描述
Krustal
//输入:连通图G=<V,E,W> 顶点数n,边数m
//输出:G的最小生成树
按照权从小到大顺排序G中的边,使得$E=\{e_1,e_2,...,e_n\}$
T=Ø
repeat
e⬅E中的最短边
if e的两个端点不在同一个连同分支
then T⬅T∪{e} //把e加入树中,合并连通分支
E⬅E-{e}
until T包含了n-1条边
时间复杂度的分析
关键在于第五步,怎样进行if
判断i
和j
是否在同一个连同分支中?
这里需要给出标记顶点所在连通分支的方法。不难看出,所有连通分支构成对顶点集V的一个划分,每个连通分支都是V的一个子集,每个顶点都在一个连通分支中,不同的连通分支彼此不相交。可以用连通分支中某个顶点的标号作为连通分支的名字。
比如G的顶点集:
V
=
{
1
,
2
,
.
.
.
,
8
}
V=\{1,2,...,8\}
V={1,2,...,8}。当前连通分支是{1,5,7,8},{2,3,6},{4}。那么这些连通分支可以依次记作1,2,4。假设需要判断边e1=(5,6)和e2=(1,8)的两个端点是否在同一个连通分支中。可以利用数组FIND。FIND[i]就是i的连通分支标记。对于e1=(5,6),因为FIND[5]=1,FIND[6]=2,两者不相等。于是不在同一个连通分支。
初始,对于i=1,2,…,令FIND[i]=i,设i和j所在子集分别是A和B,如果算法选择了边(i,j),就要将A和B合并成一个子集。需要更改一部分顶点的FIND函数值。为了减少更改标记的次数,在合并两个子集时取元素较多的子集名字作为合并后的子集名字。
建立和更改连通分支的时间复杂度为
O
(
n
l
o
n
g
n
)
O(nlongn)
O(nlongn)。每次合并规模加倍,对于单个结点logn次合并)。对于所有结点,至多nlongn次复杂度。
总的复杂度:
设G有n个顶点,m条边,下面考虑算法的工作量。对边排序需要
O
(
m
l
o
g
m
)
时
间
O(mlogm)时间
O(mlogm)时间。根据上式,建立和更新连通分支需要
O
(
n
l
o
g
n
时
间
)
O(nlogn时间)
O(nlogn时间)。循环
O
(
m
)
O(m)
O(m)次,循环体内除去更新连通分支的时间是
O
(
1
)
O(1)
O(1)。
因此算法的时间复杂度是:
O
(
m
l
o
g
m
+
n
l
o
g
n
+
m
)
O(mlogm+nlogn+m)
O(mlogm+nlogn+m)。因为简单的连通图边数m满足:n(n-1)/2≥m≥n-1。
因此有
n
l
o
g
n
=
O
(
m
l
o
g
m
)
nlogn=O(mlogm)
nlogn=O(mlogm)和
m
l
o
g
m
=
O
(
m
l
o
n
g
n
2
)
=
O
(
m
l
o
g
n
)
mlogm=O(mlongn^2)=O(mlogn)
mlogm=O(mlongn2)=O(mlogn)。
因此Kruskal算法的时间复杂度是
O
(
m
l
o
g
n
)
O(mlogn)
O(mlogn)。
单源最短路径/Dijkstra算法
- 数学模型
一个带权有向网络G=<V,E,W>中,每条边e=<i,j>的权ω(e)为非负实数,表示从i到j的距离。网络中有源点s∈V,求从s出发到大其他结点的最短路径。
求解这个问题的算法就是Dijkstra算法。它的设计思想是:将V划分成S与V-S。初始S={s}。算法的每一步都把1个结点加入S,直到S=V为止。
算法对每个结点i∈V-S,计算从s出发中间只经过S中结点且最终到达i的最短路径,称为从s到i的相对于S的最短路径,路径长度记为dist[i]
(注意,如果此刻s到i不可达,则令dist[i]=∞)。通过比较,从所有dist[i]
(i∈V-S)中选出最小值,比如dist[j]最小,那么结点j就是在这一步加入到S中的结点。先给出算法的伪码。
注意:这里是有向的图,所以,到i结点而不是从i结点出发,就不是所求的结果,这个距离应该为∞。
输入:带权有向图G=<V,E,W>,源点s∈V
输出:数组L,对所有j∈V-{s},L[j]表示s到j的最短路径上j前一个结点的标号
Dijkstra()
S⬅{s}
dist[s]⬅0
for i ∈V-{s} do
dist[i]⬅ω(s,i) //如果s到i没有边,ω(s,i)=∞
while V-S≠Ø do
从V-S中取出具有相对S的最短路径的结点j,k是该路径上连接j的结点
S⬅S∪{j};L[j]⬅k
for i∈V-S do
if dist[j]+ω(j,i)<dist[i]
then dist[i]⬅dist[j]+ω(j,i) //修改结点i相对S最短路径长度
时间复杂度
时间复杂度
O
(
n
m
)
O(nm)
O(nm)
算法进行n-1步
每步挑选1个具有最小dist函数值的结点进入S,需要
O
(
m
)
O(m)
O(m)时间
选用基于堆实现的优先队列的数据结构,可以将算法时间复杂度降低到
O
(
m
l
o
n
g
n
)
O(mlongn)
O(mlongn)。