数据结构与算法期末复习总结三
前言
最近期末考试,我整理了这个学期数据结构与算法所学到的代码与知识点,供自己复习,也供大家参考。文中所有代码均使用C++编写。 作者水平有限,若各位发现代码有误或者有可以改进的地方,欢迎在评论区留言告诉我,谢谢! 文中所有代码已经同步上传到我的GitHub上,大家也可以去这里看:
供大家参考
第四章 树
一、树的基本概念
1、树的定义
树是n(n>=0)个结点的有限集。当n = 0时,称为空树。在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点。
- 当n>1时,其余节点可分为m(m>0)个互不相交的有限集T1,T2,…,Tm,其中每个集合本身又是一棵树,并且称为根的子树。
显然,树的定义是递归的,即在树的定义中又用到了自身,树是一种递归的数据结构。树作为一种逻辑结构,同时也是一种分层结构,具有以下两个特点:
- 树的根结点没有前驱,除根结点外的所有结点有且只有一个前驱。
- 树中所有结点可以有零个或多个后继。
因此n个结点的树中有n-1条边。
2、基本术语
二、二叉树
1、二叉树的定义
二叉树是应用最广泛的树形结构,其特点是每个结点至多只有两棵子树( 即二叉树中不存在度大于2的结点),并且二叉树的子树有左右之分,其次序不能任意颠倒。
二叉树是有序树,若将其左、右子树颠倒,则成为另一棵不同的二叉树。即使树中结点只有一棵子树,也要区分它是左子树还是右子树。二叉树的5种基本形态如图所示。
2、几个特殊的二叉树
(1)斜树
所有的结点都只有左子树的二叉树叫左斜树。所有结点都是只有右子树的二叉树叫右斜树。这两者统称为斜树。
(2)满二叉树
由度为0的叶结点和度为2的中间结点构成的二叉树,树中没有度为1的结点
(3)完全二叉树
完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树
(4)完美二叉树
一个深度为k(>=1)且有
2
(
k
−
1
)
−
1
2^{(k-1)}- 1
2(k−1)−1 个结点的二叉树称为完美二叉树。即k-1层所有节点的度都是2
3、二叉树的性质
-
任意二叉树第i层最大结点数为 2 i − 1 。 ( i ≥ 1 ) 2^{i-1}。(i\ge 1) 2i−1。(i≥1)
归纳法证明。
-
深度为k的二叉树最大结点总数为 2 k − 1 。 ( k ≥ 1 ) 2^k-1。(k\ge 1) 2k−1。(k≥1)
证明: ∑ i = 1 k 2 i − 1 = 2 k − 1 \sum^k_{i=1}2^{i-1}=2^k-1 ∑i=1k2i−1=2k−1
-
对于任意二叉树,用 n 0 , n 1 , n 2 n_0,n_1,n_2 n0,n1,n2分别表示叶子结点,度为1的结点,度为2的结点的个数,则有关系式 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1
证明:总结点个数 n = n 0 + n 1 + n 2 n=n_0+n_1+n_2 n=n0+n1+n2;总结点中除根结点外,其余各结点都有一个分支进入,设m为分支总数,则有 n = m + 1 n=m+1 n=m+1,又因为这些分支都是由度为1或2的结点射出的,所以有 m = n 1 + 2 n 2 m=n_1+2n_2 m=n1+2n2,于是有 n = n 1 + 2 n 2 + 1 n=n_1+2n_2+1 n=n1+2n2+1;最后将关于n的两个关系式化简得证。
-
(满二叉树定理)非空满二叉树中叶节点等于中间结点数加1
证明:满二叉树没有度为1的结点,故由性质3可直接得证
-
n个结点( n ≥ 1 n\ge 1 n≥1)完全二叉树深度为 ⌊ l o g 2 ( n + 1 ) ⌋ + 1 \lfloor log_2(n+1)\rfloor +1 ⌊log2(n+1)⌋+1
证明:设深度k,则有 2 k − 1 ≤ n < 2 k ⇒ k − 1 ≤ l o g 2 n < k ⇒ = ⌊ l o g 2 n ⌋ + 1 2^{k-1}\le n<2^k\Rightarrow k-1\le log_2n<k\Rightarrow=\lfloor log_2n\rfloor +1 2k−1≤n<2k⇒k−1≤log2n<k⇒=⌊log2n⌋+1
-
这个性质其实描述的是完全二叉树中父子结点间的逻辑对应关系。 假如对一棵完全二叉树的所有顶点按层序遍历的顺序从1开始编号,对于编号后的结点 i i i :
(1)i=1时表示i是根结点;
(2)i>1时:①i的根结点为 i 2 \frac{i}2 2i。②若$2 i > n $,结点i 无左孩子,且为叶子结点。③若 2 i + 1 > n 2 i + 1 > n 2i+1>n ,结点i无右孩子,可能为叶子结点。
当然如果完全二叉树的顶点从0开始编号,那么上述关系就要相应修改一下。
4、二叉树的顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
5、二叉树的链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链。
6、二叉树的遍历
(1)先序遍历
先序遍历可以想象为,一个小人从一棵二叉树根节点为起点,沿着二叉树外沿,逆时针走一圈回到根节点,路上遇到的元素顺序,就是先序遍历的结果
(2)中序遍历
中序遍历可以看成,二叉树每个节点,垂直方向投影下来(可以理解为每个节点从最左边开始垂直掉到地上),然后从左往右数,得出的结果便是中序遍历的结果
(3)后序遍历
后序遍历就像是剪葡萄,我们要把一串葡萄剪成一颗一颗的。
代码如下:
使用递归
//使用递归的先序、中序、后序
void PreOrderRecur(TreeNode* head){
if(head==nullptr){
return;
}
cout<<head->val<<" ";
PreOrderRecur(head->left);
PreOrderRecur(head->right);
}
void InOrderRecur(TreeNode* head){
if(head==nullptr){
return;
}
InOrderRecur(head->left);
cout<<head->val<<" ";
InOrderRecur(head->right);
}
void PosOrderRecur(TreeNode* head){
if(head==nullptr){
return;
}
PosOrderRecur(head->left);
PosOrderRecur(head->right);
cout<<head->val<<" ";
}
不使用递归,自己压栈
//使用非递归,自己压栈
void PreOrderUnRecur(TreeNode* head){
if(head==nullptr){
return;
}
stack<TreeNode*> stk;
stk.push(head);
while (!stk.empty())
{
head=stk.top();
stk.pop();
cout<<head->val<<" ";
if(head->right!=nullptr){ //先压右孩子,后出
stk.push(head->right);
}
if(head->left!=nullptr){ //后压左孩子,先出
stk.push(head->left);
}
}
}
void InOrderUnRecur(TreeNode* head){
if(head==nullptr){
return;
}
stack<TreeNode*> stk;
while (!stk.empty()||head!=nullptr)
{
if(head!=nullptr){ //一直找左孩子,直到为空
stk.push(head);
head=head->left;
}
else{ //找右孩子
head=stk.top();
stk.pop();
cout<<head->val<<" ";
head=head->right;
}
}
}
void PosOrderUnRecur(TreeNode* head){
if(head==nullptr){
return;
}
// 两个栈,一个栈用来先序遍历,一个栈用来倒序
stack<TreeNode*> stk1;
stack<TreeNode*> stk2;
stk1.push(head);
//先先序遍历
while (!stk1.empty())
{
head=stk1.top();
stk1.pop();
stk2.push(head);
if(head->left!=nullptr){ //先根右左先序遍历,然后通过栈倒序
stk1.push(head->left);
}
if(head->right!=nullptr){
stk1.push(head->right);
}
}
//再通过栈倒序
while (!stk2.empty()){
cout<<stk2.top()->val<<" ";
stk2.pop();
}
}
(4)层序遍历
下图为二叉树的层次遍历,即按照箭头所指方向,按照1,2,3, 4的层次顺序,对二叉树中的各个结点进行访问。
要进行层次遍历,需要借助一个队列。先将二叉树根结点入队,然后出队,访问出队结点,若它有左子树,则将左子树根结点入队;若它有右子树,则将右子树根结点入队。然后出队,访问出队结…如此反复,直至队列为空。
二叉树的层次遍历算法如下:
// 宽度优先遍历,使用队列
void widthTraversal(TreeNode* head){
if(head==nullptr){
return;
}
queue<TreeNode*> q;
q.push(head);
while (!q.empty())
{
TreeNode* cur=q.front(); //一层一层弹出
q.pop();
cout<<cur->val<<" ";
if(cur->left!=nullptr){ //先进左结点,先出
q.push(cur->left);
}
if(cur->right!=nullptr){//先进右结点,后出
q.push(cur->right);
}
}
}
7、二叉树遍历的应用
(1)奇偶数Ⅰ
法一:层序遍历——利用队列
从上至下依层遍历所有结点,同时记录结点所在层数
(1)结点队列:记录结点
(2)层数队列:记录结点所在层
然后对出队结点判断其元素值及层数的奇偶性是否一致
法二:前序遍历
把层数作为参数传递
代码如下:
bool isParityTree(TreeNode* head){
queue<TreeNode*>node_queue; //结点队列
queue<int>level_queue; //结点所在层数队列
node_queue.push(head);
level_queue.push(1);
while(!node_queue.empty()){
TreeNode* node_ptr=node_queue.front();
node_queue.pop();
int level=level_queue.front();
level_queue.pop();
if(node_ptr!=nullptr){
if(node_ptr->val%2!=level%2){// 判断奇偶性是否相同
return false;
}
node_queue.push(node_ptr->left); //压入左孩子
level_queue.push(level+1); //记录左孩子的层数
node_queue.push(node_ptr->right);//压入右孩子
level_queue.push(level+1); //记录右孩子的层数
}
}
return true;
}
(2)奇偶树Ⅱ
代码如下:
//利用层序遍历
bool isParityTree1(TreeNode* head){
queue<TreeNode*>node_queue; //结点队列
queue<int>level_queue; //结点所在层数队列
node_queue.push(head);
level_queue.push(1);
TreeNode* pre_node=nullptr; //记录前一个出队的非空结点
int pre_level=0;//记录前一个出队的非空结点层数,初始值为0
while(!node_queue.empty()){
TreeNode* node_ptr=node_queue.front();
node_queue.pop();
int level=level_queue.front();
level_queue.pop();
if(node_ptr!=nullptr){
if(node_ptr->val%2!=level%2){// 判断奇偶性是否相同
return false;
}
else if(pre_level==level&&pre_node->val>=node_ptr->val){//判断是否递增
return false;
}
pre_level=level;
pre_node=node_ptr;
node_queue.push(node_ptr->left); //压入左孩子
level_queue.push(level+1); //记录左孩子的层数
node_queue.push(node_ptr->right);//压入右孩子
level_queue.push(level+1); //记录右孩子的层数
}
}
return true;
}
//利用前序遍历
vector<TreeNode*> pre_nodes(20);
bool isParityTree2(TreeNode* head,int level){
if(head==nullptr){
return true;
}
if(head->val%2!=level%2){
return false;
}
if(pre_nodes[level]!=nullptr&&pre_nodes[level]->val>=head->val){
return false;
}
pre_nodes[level]=head;//记录一层最先遇到的结点
return isParityTree2(head->left,level+1)&&isParityTree2(head->right,level+1);
}
(3)二叉树的序列化与反序列化
代码如下:
//前序序列化
void PreOrderSerialize(TreeNode* head){
if(head==nullptr){
cout<<"#";
}
else{
cout<<head->val;
PreOrderSerialize(head->left);
PreOrderSerialize(head->right);
}
}
//中序序列化
void InOrderSerialize(TreeNode* head){
if(head==nullptr){
cout<<"#";
}
else{
PreOrderSerialize(head->left);
cout<<head->val;
PreOrderSerialize(head->right);
}
}
//后序序列化
void PosOrderSerialize(TreeNode* head){
if(head==nullptr){
cout<<"#";
}
else{
PreOrderSerialize(head->left);
PreOrderSerialize(head->right);
cout<<head->val;
}
}
//前序反序列化
TreeNode* PreOrderDeSerialize(string& s)
{
if (s.empty())
return nullptr;
if (s[0] == '#')
{
s = s.substr(1);
return nullptr;
}
TreeNode* node = new TreeNode(s[0]-'0');
s = s.substr(1);
node->left = PreOrderDeSerialize(s);
node->right = PreOrderDeSerialize(s);
return node;
}
//后序反序列化
TreeNode* PosOrderDeSerialize(string &s){
s.reserve();
if (s.empty())
return nullptr;
if (s[0] == '#')
{
s = s.substr(1);
return nullptr;
}
TreeNode* node = new TreeNode(s[0]-'0');
s = s.substr(1);
node->left = PreOrderDeSerialize(s);
node->right = PreOrderDeSerialize(s);
return node;
}
8、哈夫曼树
在许多应用中,树中结点常常被赋予一个表示某种意义的数值,称为该结点的权。从树的根到任意结点的路径长度(经过的边数)与该结点上权值的乘积,称为该结点的带权路径长度。树中所有叶结点的带权路径长度之和称为该树的带权路径长度,记为
W
P
L
=
∑
i
=
1
n
w
i
l
i
WPL=\sum^n_{i=1}w_il_i
WPL=i=1∑nwili
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
(1)哈夫曼树的性质
-
哈夫曼树是满二叉树
-
哈夫曼树中,如果两个叶节点的权重值不同,则权重小的叶结点在树中的层数大于等于权重值大的叶结点
-
给定一组叶结点权重,存在最优二叉树,权重最小和次小的叶结点在树的最下层并且互为兄弟结点。
(2)哈夫曼算法
一种至下而上构建最优二叉树的方法,通过不断合并两个带权二叉树,最终生成最优二叉树
代码如下:
//重载堆的比较器,最小堆
struct cmp
{
bool operator()(TreeNode* a,TreeNode* b){
return a->weight>=b->weight;
}
};
TreeNode* CreateHuffmanTree(vector<int> w){
int n=w.size();
priority_queue<TreeNode*,vector<TreeNode*>,cmp> treeSet;
//遍历数据集w并建立结点放入小根堆
for(int i=0;i<n;i++){
TreeNode* node=new TreeNode();
node->weight=w[i];
treeSet.emplace(node);
}
for(int i=0;i<n-1;i++){
TreeNode* node=new TreeNode();
node->left=treeSet.top();
treeSet.pop();
node->right=treeSet.top();
treeSet.pop();
node->weight=node->left->weight+node->right->weight;
treeSet.emplace(node);
}
return treeSet.top();
}
(3)哈夫曼编码
赫夫曼当前研究这种最优树的目的是为了解决当年远距离通信(主要是电报)的数据传输的最优化问题。
哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码。
比如我们有一段文字内容为“ BADCADFEED”要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法。我们现在这段文字只有六个字母ABCDEF,那么我们可以用相应的二进制数据表示,如下表所示:
这样按照固定长度编码编码后就是“001000011010000011101100100011”,对方接收时可以按照3位一分来译码。如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的。
假设六个字母的频率为A 27,B 8,C 15,D 15,E 30,F 5,合起来正好是
100%。那就意味着,我们完全可以重新按照赫夫曼树来规划它们。
下图左图为构造赫夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的赫夫曼树。
这棵哈夫曼树的WPL为:
W
P
L
=
2
∗
(
15
+
27
+
30
)
+
3
∗
15
+
4
∗
(
5
+
8
)
=
241
WPL=2∗(15+27+30)+3∗15+4∗(5+8)=241
WPL=2∗(15+27+30)+3∗15+4∗(5+8)=241
此时,我们对这六个字母用其从树根到叶子所经过路径的0或1来编码,可以得到如下表所示这样的定义。
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码。
我们将文字内容为“ BADCADFEED”再次编码,对比可以看到结果串变小了。
- 原编码二进制串: 000011000011101100100011 (共 30个字符)
- 新编码二进制串: 10100101010111100(共25个字符)
也就是说,我们的数据被压缩了,节约了大约17%的存储或传输成本。
注意:
0和1究竟是表示左子树还是右子树没有明确规定。左、右孩子结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但各哈夫曼树的带权路径长度WPL相同且为最优。此外,如有若干权值相同的结点,则构造出的哈夫曼树更可能不同,但WPL必然相同且是最优的。