本系列文章将简要介绍数据结构课程入门知识,文章将结合我们学校(吉大)数据结构课程内容进行讲述。文中算法大部分来自朱允刚老师上课的讲解,朱老师是我遇到最认真负责的老师,很有幸能成为朱老师的学生。
本节较为重要就没有将其合并到树和二叉树中。
数据压缩
➢ 数据压缩是计算机科学中的重要技术。
➢ 数据压缩过程称为编码,即将文件中的每个字符均转换为一个唯一的二进制位串。
➢ 数据解压过程称为解码,即将二进制位串转换为对应的字符。
压缩的关键在于编码的方法,哈夫曼编码是一种最常用无损压缩编码方法。
文件压缩策略采用不等长二进制码,要求:
前缀码:字符集中任何字符的编码都不是其它字符的编码的前缀,满足这个条件的编码被称为前缀码
问题:怎样的前缀码才能使文件的总编码长度最短?
设组成文件的字符集A={a1,a2,…,an},其中,ai的编码长度为li;ai出现的次数为ci 。
设计一个前缀码方案,最小化文件的总编码长度:
m i n ∑ i = 1 n c i l i min \sum_{i=1}^n c_il_i mini=1∑ncili
解决方法:1952年,哈夫曼(Huffman)算法被提出。
在开始介绍哈夫曼算法之前,我们先了解一下扩充二叉树
定义
在二叉树中出现空子树的每个地方,都增加特殊的结点(空叶结点),使图(a)变成图(b),由此生成的二叉树(图(b))被称为扩充二叉树。
内部路径长度定义为从根到每个内结点的路径长度之和。
外部路径长度定义为从根到每个外结点的路径长度之和。
扩充二叉树的n个外结点各赋一个实数,称为该结点的权。
树的加权外部路径长度定义为WPL:
W P L = ∑ i = 1 n w i L i WPL=\sum_{i=1}^nw_iL_i WPL=i=1∑nwiLi
其中, n表示外结点的个数,wi和Li分别表示外结点ki的权值和深度。
为求得最优二叉树,哈夫曼巧妙的设计了哈夫曼算法,通过哈夫曼算法可以建立一棵哈夫曼树,进而为压缩文本文件建立哈夫曼编码。
根据给定的n个权值w1, w2, … ,wn构成n棵二叉树的森林F={T1,T2, …,Tn},其中每棵二叉树Ti都只有一个权值为wi的根结点,其左、右子树均空;
在森林F中选出权值最小的两个根结点合并成一棵二叉树:生成一个新结点作为这两个结点的父结点,新结点的权值为其两个子结点的权值之和;现在森林中减少了一棵二叉树。
重复第②步,直到F中只含有一棵二叉树为止,此树便是哈夫曼树。
假设给定n个实数(权值)所在结点的地址存于一维数组H[1:n]中,该数组按每个结点的Weight域已经排序,即Weight(H[1])≤ … ≤Weight(H[n])
算法思想:
预处理:对H数组排序
每次取出权值最小的两个结点
将合并得到的新子树插入H数组,并保持有序
哈夫曼树中每个结点的结构为:
template <class T>
class node{
public:
T Info;//信息域
int Weight;//权值
node*Llink;//链接域
node*Rlink;
};
//这里的代码仅仅是为了更好的展示算法思想,并不一定可以直接成功运行
void Huffman(node* H[],int n){//这里H数组已经按权值递增排好序
for(int i=0;i<n;++i)
H[i]->Llink=H[i]->Rlink=NULL;
for(int i=0;i<n-1;++i){
node* t=new node;
t->Weight=H[i]->Weight+H[i+1]->Weight;
t->Llink=H[i];
t->Rlink=H[i+1];
//将新结点的地址t插入H中
int j=i+2;
while(j<=n && H[j]->Weight < t->Weight){
H[j-1]=H[j];
++j;
}
H[j-1]=t;
}
}
哈夫曼编码是否是前缀码?
字符对应叶结点,每个叶结点对应的编码不可能是其他叶结点对应的编码的前缀,故哈夫曼编码是前缀码。
哈夫曼编码是否唯一?
哈夫曼树不包含度为1的结点。哈夫曼树外结点个数为n,则内结点个数为n-1,总结点个数为2n-1。
解码过程:依次读入文件的二进制码,从哈夫曼树的根结点出发,若当前读入0,则走向其左孩子,否则走向其右孩子,到达某一叶结点时,便可以译出相应的字符。
根据后缀表达式构造表达式二叉树(仅考虑二元运算)
从左向右扫描后缀表达式中符号,建立二叉树:
通过后根遍历一个表达式对应的二叉树,可以计算表达式的值:
一些应用问题涉及将n个不同的元素分成一组不相交的集合。
经常需要进行两种操作:①查询某个元素所属的集合, ②合并两个集合。
将维护该不相交集合的数据结构称为并查集。
选择集合中的某个元素代表整个集合,称为集合的代表元。
设x、y表示集合中的元素,并查集的三个操作。
设x、y表示集合中的元素,并查集的三个操作。
并查集的这种实现方式只需要树的向上访问能力,只需存储每个结点的父结点信息。故可采取Father数组的方法。
void Make_Set(int x){
// 实现并查集的MAKE_SET操作,为元素x生成一棵单结点树
Father[x]=0;
}
int Find(int x){
// 实现并查集的FIND操作,查找x所在树的根结点
if(Father[x]==0)return x;
else return Find(Father[x]);
}
void Union(int x,int y){
// 实现并查集的UNION操作,合并x和y的树,y所在树的根结点指向x所在树根结点
Father[Find(y)]=Find[x];
}
特点:
struct Node{
int L,R;
int sum;
};
Node tree[N*4];
//建树
void build(int root, int L, int R){
tree[root].L = L;
tree[root].R = R;
if (L == R) { //叶结点,区间长度为1
tree[root].sum = a[L];
return;
}
int mid = (L + R)/2;
build(2*root, L, mid);
build(2*root+1, mid+1, R);
tree[root].sum=
tree[2*root].sum+tree[2*root+1].sum;
}
//单点更新
void update(int root, int i, int x){ //a[i]加上x
if (tree[root].L==tree[root].R){
tree[root].sum += x;
return;
}
int mid=(tree[root].L+tree[root].R)/2;
if (i<=mid) update(2*root, i, x);
else update(2*root+1, i, x);
tree[root].sum=tree[2*root].sum+tree[2*root+1].sum;
}
//区间查询
int query(int root, int L, int R){
if (L==tree[root].L && R==tree[root].R)
return tree[root].sum;
int mid=(tree[root].L+tree[root].R)/2;
if (R<=mid)
return query(2*root, L, R);
else if (L>mid)
return query(2*root+1, L, R);
else
return query(2*root, L, mid)+query(2*root+1, mid+1, R);
}
x的二进制表示形式留下最右边的1,其他位都变成0
int lowbit(int x) {
return x & -x;
}
(1)区间查询
查询a[1]+…+a[i]:从c[i]开始沿父结点往上走,将沿途结点累加,即将c[i]及其祖先结点相加。
int query(int i) { //查询a[1]+…+a[i]
for (int sum=0; i>0; i-=lowbit(i))
sum+=c[i];
return sum;
}
(2)单点更新
更新a[i] :从c[i]开始沿父结点往上走,将沿途结点更新,即将c[i]及其祖先结点更新。
void update(int i, int x) { //a[i]+=x
for(; i<=n; i+=lowbit(i))
c[i]+=x;
}
(3)构建树状数组
c[i]=a[i-lowbit(i)+1]+a[i-lowbit(i)+2]+…+a[i]
void build(int a[], int n){
sum[0]=0;
for(int i=1; i<=n; i++){
sum[i]=sum[i-1]+a[i];
c[i]=sum[i]-sum[i-lowbit(i)];
}
}