系列文章目录:
树 树就是一种特殊的图,是一个无向图,是一种连通且无环的无向图。
含有以下内容:
树与二叉树的定义
二叉树的实现与遍历
树与森林
哈夫曼树与哈夫曼编码
树的定义:树是一个或者多个结点的有限集合,存在一个称为根的特定结点,其余结点分为若干个互不相交的集合,每个集合本身又是一棵树,这些树称为根的子树。如下图:
F
/ \
D J
/ \ / \
B E G K
/ \ \
A C L
/ \ \ / / \
I H M N O P
一些重要名词
结点: 树中的每一个独立单元。
结点的度: 结点拥有的子树个数。
树的度: 树中所有结点度的最大值。
叶子: 度为0的结点或者终端结点。
分支结点: 度不为0的结点。
非终端结点: 除了根结点以外的所有结点。
双亲与孩子: 结点的子树的根称为该结点的还在,相应地,该结点称为孩子的双亲。(有的书上把其根也叫儿子,同时其结点叫父亲)。
层次: 结点的层次从根开始定义起,根为第一层,根的孩子为第二层,以此类推。
高度: 树中结点的最大层次。(也可以叫深度)
中心: 使得最大距离最小的结点,中心的个数为1或2。
重心: 使得所有子树结点数都不超过总结点数一半的结点。
森林: 若干棵独立的树组成的集合,不限制每棵树的高度。
树的基本性质
证明如下:
设该树为无向图 ,其中 $V$ 为结点集个数为 $n$ ( $ |V|=n $ ),$E$ 为边集边数为 $e$ ( $ |E|=e $ )。
我们知道对于任意无向图,有如下恒等式:
对于树,满足
带入得:
证明如下:
我们先举几个例子,然后用数学归纳法:
第一层: 最多有 $m^0=1$ 个结点
第二层: 最多有 $m^1$ 个结点
第三层: 最多有 $m^2$ 个结点
第四层: 最多有 $m^3$ 个结点显然地,第 $i$ 层最多有 $m^{i-1}$ 个结点。(严格的数学归纳读者不难自证)
那么对于一共高度为 $h$ 的树,最多有:
二叉树 二叉树(Binary Tree ) 是n个结点所构成的集合,它或为空树(n=0),或为非空树,对于非空树T;
有且仅有一个根结点。
除根结点以外的其余结点分为两个互不相交的子集 $T_i$ 和 $T_2$ ,分别称为T的左子树和右子树,且 $T_i$ 和 $T_2$ 本身也是二叉树。
二叉树每个结点至多只有两颗子树,二叉树中结点的子树有左右之分,次序不能颠倒。
F
/ \
D J
/ \ / \
B E G K
/ \ \
A C L
二叉树的性质
在二叉树的第 $i$ 层上至多有 $2^{i-1}$ 个结点。
深度为 $k$ 的二叉树至多有 $2^k-1$ 个结点。
对于满二叉树中,叶子数为度为2的结点数+1 。
对于任何非空的二叉树,如果叶子的个数为 $n_0$ ,度为2的结点个数为 $n_2$ ,则 $n_0=n_2+1$ 。
特殊的二叉树
满二叉树(Full Binary Tree): 满二叉树是指每一个非叶子结点都有两个孩子结点,并且所有叶子结点都在同一层的二叉树。
第 $i$ 层最多有 $2^{i-1}$ 个结点。(根为第1层)
总共有 $2^h-1$ 个结点。
完全二叉树(Complete Binary Tree): 除了最后一层外,每一层的结点数都达到最大值,且最后一层的结点都集中在左部连续位置上。没有左子树,不能有右子树,上一层没铺满,不能有下一层
可以以用数组从索引1开始存储。
任意结点的编号为 $i$
左儿子结点的编号为 $2i$ ,右儿子结点的编号为 $2i+1$
父亲结点的编号为 $\left \lfloor i/2 \right \rfloor $
具有 $n$ 个结点的完全二叉树,深度为 $\left \lfloor \log_2 n \right \rfloor +1$ 。
二叉搜索树(Binary Search Tree, BST): 满足左子树的所有结点值均<根结点值,右子树的所有结点值均>根结点值,且左右子树也是二叉搜索树。 也叫二叉排序树(Bianry Sort Tree )。
中序遍历可以得到一个严格递增 序列。
可支持动态查找、插入、删除操作,平均时间复杂度 $O(\log n)$。
平衡二叉树(AVL Tree): 又称为AVL树,它是一棵特殊的二叉搜索树,且每个结点的左右子树的高度差不超过1。
高度平衡性保证了最坏时间复杂度为 $O(\log n)$
插入与删除后可能需要旋转操作 (LL,RR,LR,RR )
斜树: 所有结点都只有左子树或者只有右子树的二叉树,极端情况下,二叉树退化成一条链表。
二叉树的存储结构 根据我们之前学的知识,存储结构有:顺序存储和链式存储。那么对于二叉树同理。
顺序存储 二叉树的顺序存储结构就是用一组连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素,即将完全二叉树按层序遍历的顺序存储到一维数组中。
注意: 除了满二叉树和完全二叉树外,一般的二叉树不推荐使用顺序存储——结点数不足的部分会空出位置或标记符号,造成存储空间的浪费
对于一般的二叉树,我们构造一个结构体,如下:
1 2 struct P {int val,L,R;}; vector<P> BTree;
由于其存储的便捷性,我们常常在竞赛中使用。并且在竞赛中,我们一般构造二叉树是用类似邻链表的方式构造。例如知道某个节点的左右儿子和值,那么就将其push_back
进向量数组。
值得注意的是,对于满二叉树,我们可以直接用数组存储:
计层数为i,那么该层的下标(0开始)从左到右依次为:$2^i-1$ ~ $2^{i+1}-2$。
对于下标为i的节点,其左儿子下标为 $2i+1$ ,右儿子下标为 $2i+2$ 。
链式存储 二叉树由于是一种递归定义的数据结构,因此比较自然的存储方式就是链式存储。
1 2 3 4 5 6 7 struct TreeNode { ElemType _data; TreeNode *_left; TreeNode *_right; TreeNode (int x) : _data(x), _left(nullptr ), _right(nullptr ) {} };
遍历方式 前序遍历 前序遍历的顺序为:根结点->左子树->右子树
1 2 3 4 5 6 7 8 9 void preOrder (TreeNode* root) { if (root==nullptr ) return ; cout<<root->_data<<" " ; preOrder (root->_left); preOrder (root->_right); return ; }
同样地,对于直接用向量数组存储的二叉树,前序遍历为:
1 2 3 4 5 6 7 void preOrder (int x) { cout<< BTree[x].val <<" " ; if (BTree[x].L)preOrder (BTree[x].L); if (BTree[x].R)preOrder (BTree[x].R); return ; }
我们用这个例子来演示:
1
/ \
2 3
/ \ /
4 5 6
/ \ /
7 8 9
/ / \
10 11 12
每个结点上的数字也是这个结点的值,对于这个二叉树,我们用数组存储,如下:
Index
Value
Left
Right
1
1
2
3
2
2
4
5
3
3
6
0
4
4
7
8
$\vdots$
$\vdots$
$\vdots$
$\vdots$
10
10
0
0
那么对于这个二叉树,我们进行前序遍历,结果为:1 2 4 7 10 8 11 12 5 3 6 9
不用函数嵌套的方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 void preOrder (TreeNode* root) { if (!root) return ; stack<TreeNode*> stk; stk.push (root); while (!stk.empty ()) { TreeNode* node = stk.top (); stk.pop (); cout << node->_data << " " ; if (node->_right) stk.push (node->_right); if (node->_left) stk.push (node->_left); } }
可见,这个不用嵌套的方法,类似 bfs 而使用嵌套的方法类似 dfs
中序遍历 中序遍历的顺序为:左子树->根结点->右子树
1 2 3 4 5 6 7 8 9 void inOrder (TreeNode* root) { if (root==nullptr ) return ; inOrder (root->_left); cout<<root->_data<<" " ; inOrder (root->_right); return ; }
同样地,对于直接用向量数组存储的二叉树,中序遍历为:
1 2 3 4 5 6 7 void inOrder (int x) { if (BTree[x].L)inOrder (BTree[x].L); cout<< BTree[x].val <<" " ; if (BTree[x].R)inOrder (BTree[x].R); return ; }
对于刚刚二叉树,我们进行中序遍历,结果为:10 7 4 11 8 12 2 5 1 9 6 3
后序遍历 后序遍历的顺序为:左子树->右子树->根结点
1 2 3 4 5 6 7 8 9 void postOrder (TreeNode* root) { if (root==nullptr ) return ; postOrder (root->_left); postOrder (root->_right); cout<<root->_data<<" " ; return ; }
同样地,对于直接用向量数组存储的二叉树,后序遍历为:
1 2 3 4 5 6 7 void postOrder (int x) { if (BTree[x].L)postOrder (BTree[x].L); if (BTree[x].R)postOrder (BTree[x].R); cout<< BTree[x].val <<" " ; return ; }
1
/ \
2 3
/ \ /
4 5 6
/ \ /
7 8 9
/ / \
10 11 12
对于刚刚二叉树,我们进行后序遍历,结果为:10 7 11 12 8 4 5 2 9 6 3 1
层序遍历 层序遍历的顺序为:从根结点开始,依次访问每一层的结点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void levelOrder (TreeNode* root) { queue<TreeNode*> q; q.push (root); while (!q.empty ()) { TreeNode* front = q.front (); q.pop (); cout<<front->_data<<" " ; if (front->_left) q.push (front->_left); if (front->_right) q.push (front->_right); } return ; }
同样地,对于直接用向量数组存储的二叉树,层序遍历为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void levelOrder (int x) { queue<int > q; q.push (x); while (!q.empty ()) { int front = q.front (); q.pop (); cout<< BTree[front].val <<" " ; if (BTree[front].L)q.push (BTree[front].L); if (BTree[front].R)q.push (BTree[front].R); } return ; }
对于刚刚二叉树,我们进行层序遍历,结果为:1 2 3 4 5 6 7 8 9 10 11 12
构造二叉树 反过来造树 以下是我自己的思考,用来锻炼自己的思维能力。
前序 输入前序遍历的结果(这里修改下输出的函数,如果该结点的值为空输出#
,并且值为非正整数),输出二叉树。
1
/ \
2 3
/ \ /
4 5 6
/ \ /
7 8 9
/ / \
10 11 12
那么对于这个二叉树的前序遍历结果为:1 2 4 7 10 # # # 8 11 # # 12 # # 5 # # 3 6 9 # # # #
那么我们如何构造这棵树呢?我们注意到当树的某一分支遍历到底时,输出#
,那么我们可以利用这个特性,
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 #include <bits/stdc++.h> using namespace std;#define ll long long #define ull unsigned long long #define ld long double struct P { int val, L, R; };vector<P> BTree; int p=0 ;int build (const vector<string> &a) { if (p>=(int )a.size () || a[p]=="#" ){p++;return -1 ;}; int val=stoi (a[p++]); int l=build (a); int r=build (a); BTree.push_back ({val, l ,r}); return BTree.size ()-1 ; } int main () { ios::sync_with_stdio (0 ); cin.tie (0 ); cout.tie (0 ); string x; vector<string> a; while (cin>>x) a.push_back (x); int root=build (a); }
这里用一个简单的例子说明下,为什么这个 build
函数可以成功构造一个树。
1
/ \
2 3
/
4
我们以上树为例,其前序遍历含空 #
的结果为:1 2 4 # # # 3 # #
那么从左到右依次读取有:
读取 1
, 有val=a[0]=1, p=1
进入左右子树:
左子树:读取 2
,有 val=2, p=2
进入左右子树:
左子树:读取4
,有 val=4, p=3
进入左右子树:
左子树:读取 #
,有 p=4
,直接 返回-1
右子树:读取 #
,有 p=5
,直接 返回-1
插入 {4, -1, -1}
,返回 BTree.size()-1=0
右子树:读取 #
,有 p=6
,返回-1
插入 {2, 0, -1}
,返回 BTree.size()-1=1
右子树:读取 3
,有 val=3, p=7
进入左右子树:
左子树:读取 #
,有 p=8
,返回-1
右子树:读取 #
,有 p=9
,返回-1
插入 {3, -1, -1}
,返回 BTree.size()-1=2
插入 {1, 1, 2}
,返回 BTree.size()-1=3
这样我们就成功构造了这棵树。
如果我们用指针的链式结构,那么可以写成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 struct TreeNode { int val; TreeNode *left, *right; TreeNode (int x) : val (x), left (nullptr ), right (nullptr ) {} }; int p=0 ;TreeNode* build (const vector<string> &a) { if (p>=(int )a.size () || a[p]=="#" ){p++;return nullptr ;} TreeNode* node = new TreeNode (stoi (a[p++])); node->left = build (a); node->right = build (a); return node; }
后序 后序遍历的方式为:左子树->右子树->根结点
显然那么我们应该先找到根结点,然后递归找到右节点和左节点,这样输入构建应该是倒着读取 a.reverse()
,那么 build
函数应为:
1 2 3 4 5 6 7 8 9 int build (const vector<string> &a) { if (p>=(int )a.size () || a[p]=="#" ){p++;return -1 ;}; int val=stoi (a[p++]); int r=build (a); int l=build (a); BTree.push_back ({val, l ,r}); return BTree.size ()-1 ; }
中序 中序遍历的方式为:左子树->根结点->右子树
经过前面两个例子,我们发现只有确定根节点的位置才能构造树,显然对于中序遍历,把根节点放到中间,是不能直接用中序遍历输出,举个例子:
1
/
2
\
3
其中序遍历结果是:# 2 # 3 # 1 #
,但是对于这个树:
1
/
2
/
3
其中序遍历结果也是: # 2 # 3 # 1 #
,显然我们无法只通过中序遍历确定根节点。
那么是否能够通过前序+中序 或者后序+中序 构造树呢?
前序+中序 对于构造一个二叉树,关键点在于如何确定根节点,那么对于前序+中序,我们可以通过前序遍历的首个元素确定整棵树的根节点,然后在中序遍历中找到该根节点,其左边部分是左子树,右边部分是右子树,对左右子树递归构造即可。
1
/ \
2 5
/ \ \
3 4 6
例如这个二叉树的前序遍历结果为: 1 2 3 4 5 6
; 中序遍历结果为: 3 2 4 1 5 6
首先我们可以确定根节点的值为1
,那么由中序遍历得,左子树为3 2 4
,右子树为 5 6
接下来对于左子树 3 2 4
,由前序遍历中的 2 3 4
可知,根节点值为 2
, 左子树为 3
,右子树为 4
同理对于右子树 5 6
,由前序遍历中的 5 6
可知,根节点值为 5
,左子树为空,右子树为 6
以此我们可以递归构造这棵树。
1 2 3 4 5 6 7 8 9 10 11 12 13 int build (const vector<int >& a, const vector<int >& b, int al, int ar, int bl, int br) { if (al>=ar || bl>=br) return -1 ; int val=a[al]; int idx=tree.size (); tree.push_back ({val, -1 , -1 }); int k=bl; while (b[k]!=val)k++; int ls=k-bl; tree[idx].L=build (a, b, al+1 , al+1 +ls, bl, k); tree[idx].R=build (a, b, al+1 +ls, ar, k+1 , br); return idx; }
如果我们用指针的链式结构,那么可以写成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 struct TreeNode { int val; TreeNode *left, *right; TreeNode (int x) : val (x), left (nullptr ), right (nullptr ) {} }; int p=0 ;TreeNode* build (const vector<int >& a, const vector<int >& b, int al, int ar, int bl, int br) { if (al>=ar || bl>=br)return nullptr ; int val=a[al]; TreeNode* root = new TreeNode (val); int k=bl; while (b[k]!=val)k++; int ls=k-bl; root->left = build (a, b, al+1 , al+1 +ls, bl, k); root->right = build (a, b, al+1 +ls, ar, k+1 , br); return root; }
后序+中序 对于后序+中序,我们可以通过后序遍历的最后一个元素确定整棵树的根节点,然后在中序遍历中找到该根节点,其左边部分是左子树,右边部分是右子树,对左右子树递归构造即可。注意 : 这里还是要 a.reverse()
。
1 2 3 4 5 6 7 8 9 10 11 12 13 int build (const vector<int >& a, const vector<int >& b, int al, int ar, int bl, int br) { if (al>ar) return -1 ; int val=a[al]; int k=bl; while (b[k]!=val)k++; int rs=br-k; int idx=tree.size (); tree.push_back ({val,-1 ,-1 }); tree[idx].R=build (a, b, al+1 , al+ls, k+1 , br); tree[idx].L=build (a, b, al+rs+1 , ar, bl, k-1 ); return idx; }
线索二叉树 在使用遍历二叉树时,我们总可以得到一个线性的序列,但是,这个序列是通过递归实现的,我们无法直接像链表 一样得到这个序列,即知道遍历结果的某一个元素,无法直接找到它的前驱和后继。
而线索二叉树正是这样的一种结构,利用叶子空余 的空间,来记录前驱和后继,从而实现直接 找到前驱和后继。
构造(中序)
我们先来观察一下二叉树的中序遍历结果: D B G E A C H F
然后我们让每个节点空余的空间指向它的前驱和后继:
接下来,让我们来分析一下如何构造这样的二叉树:
我们需要找到节点的空指针,然后让这个空指针指向它的前驱(左)或者后继(右),并且为了区分孩子指针,我们再加入 bool pred=false, succ=false
作为左右指针是否为其前驱或者后继。
那么此时线索二叉树的节点结构为:
1 2 3 4 5 6 7 struct ThreadNode { ElemType _data; ThreadNode *_left, *_right; bool _pred, _succ; ThreadNode (ElemType x) : _data(x), _left(nullptr ), _right(nullptr ), _pred(false ), _succ(false ) {} }
值得注意的是,这样构造的二叉树是永远存在的,即节点的空指针总干好够用。
因为对于一个含有 $n$ 个节点的二叉树,有 $2n$ 个指针域、$n-1$ 个非空指针域,那么空指针域就有 $n+1$ 个。
对于中序遍历,有 $n-1$ 个节点的连接关系,即需要 $2(n-1)$ 个指针来存储前驱/后继关系。
当需要前驱/后继指针小于等于空指针数量时,即
所以对于于含有 $n\ge 3$ 个节点的二叉树,都可以构造出中序线索二叉树。
但是,这样构造的二叉树,在遍历到最后一个节点或者第一个节点是时,必须要判断是否为空指针,否则会空指针异常。我们为了统一操作,以及为了方便遍历,我们在二叉树前加入一个头节点,这个头节点满足以下:
头节点的 *_left
指向树的根节点
头节点的 *_right
指向中序遍历的最后一个节点
中序遍历的第一个节点的 _left
指向头节点
中序遍历的最后一个节点的 _right
指向头节点
具体实现 下面,我们来写这个构造中序线索二叉树的函数,我们先进行中序遍历,如果这个节点的左指针为空,那么让它指向它的前驱,右指针为空,那么让它指向它的后继。显然地,我们在中序遍历的时候,还必须要要记录前驱节点,那么我们可以用 &*pre
来记录前驱节点。
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 void inThread (ThreadNode *node, ThreadNode *&pre) { if (node==nullptr ) return ; inThread (node->_left, pre); if (!node->_left) { node->_left = pre; node->_pred = true ; } else if (pre && !pre->_right) { pre->_right = node; pre->_succ = true ; } pre=node; inThread (node->_right, pre); } ThreadNode *buildThreadTree (ThreadNode *root) { ThreadNode *head = new ThreadNode (-1 ); if (!root) { head->_left = head; head->_right = head; return head; } head->_left = root; ThreadNode* pre = head; inThread (root, pre); pre->_right = head; pre->_succ = true ; head->_succ=true ; head->_right = pre; ThreadNode *p=root; while (p && !p->_pred) p=p->_left; p->_left = head; p->_pred = true ; return head; }
那么其中序遍历的方式就是直接用头节点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void inOrder (ThreadNode *head) { ThreadNode *p = head->_right; while (p && !p->_pred) p=p->_left; while (p!=head) { cout<<p->_data<<" " ; if (p->_succ) p=p->_right; else { p=p->_right; while (p && !p->_pred) p=p->_left; } } }
竞赛中的方法 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 struct P { int val,L,R; bool pred=false , succ=false ; }; vector<P> BTree; int pre = -1 ; void buildThread (int cur) { if (cur == -1 ) return ; if (!BTree[cur].pred) buildThread (BTree[cur].L); if (BTree[cur].L == -1 ) { BTree[cur].L = pre; BTree[cur].pred = true ; } if (pre != -1 && BTree[pre].R == -1 ) { BTree[pre].R = cur; BTree[pre].succ = true ; } pre = cur; if (!BTree[cur].succ) buildThread (BTree[cur].R); }
这个不使用指针的方法直戳构造的逻辑,写起来较为便捷。
构造(前序) 类似地,对于前序线索二叉树,我们只需要在构造使用前序遍历即可。
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 void preThread (ThreadNode *node, ThreadNode *&pre) { if (node==nullptr ) return ; if (!node->_left) { node->_left = pre; node->_pred = true ; } else if (pre && !pre->_right) { pre->_right = node; pre->_succ = true ; } pre=node; if (!node->_pred)preThread (node->_left, pre); if (!node->_succ)preThread (node->_right, pre); } ThreadNode *buildThreadTree (ThreadNode *root) { ThreadNode *head = new ThreadNode (-1 ); if (!root) { head->_left = head; head->_right = head; return head; } head->_left = root; ThreadNode* pre = head; preThread (root, pre); pre->_right = head; pre->_succ = true ; head->_succ=true ; head->_right = pre; root->_left = head; root->_pred = true ; return head; }
在前序遍历中,这个判断 if(!node->_pred)
和 if(!node->_succ)
是必须要写的!!!
因为前序遍历的构造方式,会在“构造当前节点的线索”之后,马上访问它的左、右孩子。 而这些孩子可能——已经被线索占用了!
构造(后序) 后序遍历的线索二叉树的构造就很复杂了,因为在前节点处理时,左右子树已经线索化,递归处理复杂
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 void postThread (ThreadNode *node, ThreadNode *&pre) { if (node==nullptr ) return ; if (!node->_pred)postThread (node->_left, pre); if (!node->_succ)postThread (node->_right, pre); if (!node->_left) { node->_left = pre; node->_pred = true ; } else if (pre && !pre->_right) { pre->_right = node; pre->_succ = true ; } pre=node; } ThreadNode *buildThreadTree (ThreadNode *root) { ThreadNode *head = new ThreadNode (-1 ); if (!root) { head->_left = head; head->_right = head; return head; } head->_left = root; head->_succ = true ; ThreadNode* pre = head; postThread (root, pre); pre->_right = head; pre->_succ = true ; head->_right = pre; ThreadNode* p = root; while (true ) { if (!p->_pred) p = p->_left; else if (!p->_succ) p = p->_right; else break ; } p->_left = head; p->_pred = true ; return head; }
应用 虽然线索二叉树有这三种,但常用的也就一种,即中序线索二叉树 。
这里举例一个详细的竞赛例子——二叉查找树(BST):
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 struct P { int val; int L=-1 , R=-1 ; bool pred=false , succ=false ; }; vector<P> BSTree; int root=-1 ; int pre=-1 ;int insert (int root, int val) { if (root == -1 ) { BSTree.push_back ({val, -1 , -1 , false , false }); return (int )BSTree.size () - 1 ; } if (val<BSTree[root].val) BSTree[root].L=insert (BSTree[root].L, val); else BSTree[root].R = insert (BSTree[root].R, val); return root; } void buildThread (int cur) { if (cur==-1 ) return ; if (!BSTree[cur].pred) buildThread (BSTree[cur].L); if (BSTree[cur].L==-1 ) { BSTree[cur].L=pre; BSTree[cur].pred=true ; } if (pre!=-1 && BSTree[pre].R==-1 ) { BSTree[pre].R=cur; BSTree[pre].succ=true ; } pre=cur; if (!BSTree[cur].succ) buildThread (BSTree[cur].R); }
这个例子的打印函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void inorder_threaded (int root) { int cur=root; while (!BSTree[cur].pred)cur=BSTree[cur].L; while (cur != -1 ) { cout << BSTree[cur].val << " " ; if (BSTree[cur].succ) cur=BSTree[cur].R; else { cur=BSTree[cur].R; while (cur!=-1 && !BSTree[cur].pred) cur=BSTree[cur].L; } } }
中序线索二叉树的其他应用,包括但不限于:
编译器构建抽象语法树(AST)后需要遍历树来生成代码或优化
树大且深,递归开销大,且遍历顺序很重要
使用中序线索二叉树可节省递归栈空间,且方便快速定位前驱后继节点,方便代码生成和优化
数据库索引的区间查询优化
频繁递归或栈访问影响性能,据库使用二叉排序树或B树做索引,快速区间查询需要有序访问节点
实时系统与嵌入式系统的资源受限环境
动态维护一棵二叉树,频繁插入和删除节点
插入删除时需要更新前驱和后继指针,普通树结构操作复杂,使用线索二叉树的前驱后继指针明确,便于快速定位插入位置和删除影响范围,提高动态更新效率
哈夫曼树 补充几个概念:
路径 :一个节点到另一个节点的通路,一般有很多条。
路径长度 :路径上经过的边的数量。
权 :叶子上存储的数据,一般是一个数值。
带权路径长度 :从根节点到叶子的路径长度(一般是层数)与该叶子上的权值乘积。
树的带权路径长度(Weighted Path Length of Tree, WPL) :树中所有叶子节点的带权路径长度之和。
例如下面这棵树:
R
/ \
A B
/ \ \
C D 4
/ \ \
2 3 5
基本概念 对于一棵带权的二叉树,如果它的带权路径长度最小,那么这棵树就叫做哈夫曼树 (Huffman Tree)。
对于于哈夫曼树来说,距离根节点越远的叶子节点权值越大,反之越小。
构造 我们考虑叶子的权值集合为 $\mathbf W={w_1, w_2, \cdots, w_n}$,在经过某种构造方法后:
我们以叶子所在层数划分,对于第 $i$ 层有 $ci$ 个叶子, 其中 $i,c_i \in \mathbf N$ ,有叶子的权集合 $\mathbf W_i ={w {i1}, w{i2}, \cdots, w {ic_i}} \subseteq \mathbf W$,我们按照层开始求,那么有:
我们不难发现,欲WPL越小,那么要使得每层的带权路径长度最小,则尽量把大的权值放到低层,小的权值放到高层。
考虑到有 $n$ 个叶子的二叉树树,至少有 $2n-1$ 个节点(当且仅当满二叉树时),除去根节点即至少有 $2(n-1)$ 个节点,并且此时二叉树的层数也最小 。
不难发现当每次取出权值最小的两个节点,构造一个新节点,然后依次递归,最后得到的必定是满二叉树,且权值大的节点在底层,权值小的节点在高层。
这里给出为什么$n$ 个叶子的二叉树树,至少有 $2n-1$ 个节点的证明:
我们知道对于一颗二叉树,节点下的孩子节点的数量可能为 ${0,1,2}$ 我们令:
叶子数 $L=n$
恰好有 1 个子节点的内部节点数 $c_1$
恰好有 2 个子节点的内部节点数 $c_2$
那么内部总节点数 $I=c_1+c_2$ ,总节点数(+叶子) $N=L+I$ ,有:
因此总节点数 $N$ :
当 $N$ 最小时,$c_1=0$ 也最小,因此 $N$ 最小为 $2L-1$ ,即 $2n-1$ 。
故当且仅当 $n$ 个叶子构成满二叉树 $c_1=0$ 时,$N$ 最小。
代码实现 于是我们可以按照上述思路,构造哈夫曼树:
先将叶子节点存入一个小根堆中(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 struct TreeNode { int _weight=0 ; TreeNode* _left,*_right; TreeNode (int weight=0 ) : _weight(weight), _left(nullptr ), _right(nullptr ) {}; }; struct Compare { bool operator () (TreeNode* a, TreeNode* b) { return a->_weight > b->_weight; } }; priority_queue<TreeNode*, vector<TreeNode*>, Compare> pq; void FindLeaves (TreeNode* root) { if (!root) return ; queue<TreeNode*> q; q.push (root); while (!q.empty ()) { TreeNode* cur = q.front (); q.pop (); if (!cur->_left && !cur->_right) { if (cur->_weight != 0 ) pq.push (cur); } else { if (cur->_left) q.push (cur->_left); if (cur->_right) q.push (cur->_right); } } return ; } TreeNode* BuildHuffman () { while (pq.size () > 1 ) { TreeNode* left = pq.top (); pq.pop (); TreeNode* right = pq.top (); pq.pop (); TreeNode* parent = new TreeNode (left->_weight + right->_weight); parent->_left = left; parent->_right = right; pq.push (parent); } return pq.empty () ? nullptr : pq.top (); }
用个例子测试一下:
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 int WPL (TreeNode* root) { int res = 0 ; if (!root) return 0 ; queue<pair<TreeNode*,int >> q; q.push ({root,0 }); while (!q.empty ()) { auto [cur, depth] = q.front (); q.pop (); if (!cur->_left && !cur->_right && cur->_weight != 0 ) res += cur->_weight*depth; else { if (cur->_left) q.push ({cur->_left, depth+1 }); if (cur->_right) q.push ({cur->_right, depth+1 }); } } return res; } int main () { TreeNode* root = new TreeNode (); root->_right = new TreeNode (); root->_left = new TreeNode (); root->_right->_left = new TreeNode (2 ); root->_right->_right = new TreeNode (3 ); root->_left->_left = new TreeNode (12 ); root->_left->_right = new TreeNode (); root->_left->_right->_left = new TreeNode (1 ); root->_left->_right->_right = new TreeNode (8 ); FindLeaves (root); cout << WPL (BuildHuffman ()) << endl; system ("pause" ); }
竞赛中的写法 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 #define pii pair<int,int> struct node { int W=0 ; int L=-1 ,R=-1 ; }; int buildHuffman (vector<node> &tree) { priority_queue<pii, vector<pii>, greater<pii>> pq; for (int i=0 ;i<tree.size ();i++) if (tree[i].W!=0 )pq.push ({tree[i].W,i}); queue<pii> q; while (pq.size ()>1 ) { auto [lw,ldx]=pq.top (); pq.pop (); auto [rw,rdx]=pq.top (); pq.pop (); node pt; pt.W=lw+rw; pt.L=ldx; pt.R=rdx; int idx=tree.size (); tree.push_back (pt); pq.push ({pt.W,idx}); } return pq.top ().second; }
哈夫曼编码 Huffman编码是一种无损压缩算法 ,用来根据字符出现频率生成最优前缀码(Prefix Code)
出现频率高的字符用更短的编码表示
出现频率低的字符用更长的编码表示
保证任何一个符号的编码都不是其他符号编码的前缀(前缀码性质),这样解码时不会产生歧义
编码步骤
统计每个字符出现的频率
每次取出权重最小的两个节点,构造一个新节点,权值为两个节点的权值之和,然后将其插入堆中
重复上述过程,直到堆中只剩下一个节点,即为哈夫曼树的根节点
从根开始:
向左走,编码加 0
向右走,编码加 1
直到走到叶子节点,该路径上的编码即为该字符的编码
按照编码表对原字符进行编码,得到编码后的编码串
代码实现 别看!自己先写! 这里直接按照上述思路实现即可:
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 using pii=pair<string,int >;using tpe=tuple<int ,char ,int >;struct Node {char C;int W, L=-1 , R=-1 ;};map<char ,int > freqm (string s) { map<char ,int > res; for (char c: s)res[c]++; return res; } map<char ,string> HuffmanCodeList (map<char , int > &freq) { priority_queue<tpe, vector<tpe>, greater<tpe>> pq; vector<Node> Htree; for (auto [ch,f]: freq) { Htree.push_back ({ch,f}); pq.push ({f,ch,Htree.size ()-1 }); } while (pq.size ()>1 ) { auto [f1, ch1, i1] = pq.top ();pq.pop (); auto [f2, ch2, i2] = pq.top ();pq.pop (); Htree.push_back ({'\0' ,f1+f2,i1,i2}); pq.push ({f1+f2,'\0' ,Htree.size ()-1 }); } int root = get <2 >(pq.top ()); map<char ,string> res; queue<pii> q; q.push ({"" ,root}); while (!q.empty ()) { auto [code, i] = q.front ();q.pop (); if (Htree[i].C!='\0' ) res[Htree[i].C] = code; else { q.push ({code+'0' ,Htree[i].L}); q.push ({code+'1' ,Htree[i].R}); } } return res; } string HuffmanCode (string s) { string res; auto freq = freqm (s); auto code = HuffmanCodeList (freq); for (char c: s) res+=code[c]; return res; }
测试代码:1 2 3 4 5 6 7 8 9 10 11 12 13 int main () { string s="Hello World!" ; auto freq = freqm (s); auto code = HuffmanCodeList (freq); for (auto [c,w]: freq) cout<<c<<" : " <<w<<endl; cout <<endl; for (auto [c,cde]: code) cout<<c<<" : " <<cde<<endl; cout <<endl; cout<<HuffmanCode (s)<<endl; system ("pause" ); return 0 ; }
哈夫曼解码 如果我们知道一个编码串和对应的哈夫曼编码表,那么我们应该如何快速地解码呢?
一个很自然的想法就是直接遍历编码字符串,维护一个 cur
字符串,把编码字符依次加入 cur
中,如果 cur
在编码表中,那么就输出对应的字符,并清空 cur
,否则继续遍历。但是这样时间复杂度太高。
我们可以直接利用哈夫曼树进行解码,先用编码表构造一棵树,再遍历编码串,每次向左走或向右走,直到走到叶子节点,输出叶子节点对应的字符,然后回到根节点,继续遍历。
代码实现 别看!自己先写! 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 struct Node {char C; int L=-1 ,R=-1 ;};string decode (string HuffmanCode, map<char ,string> HuffmanList) { int root=0 ; vector<Node> HuffmanTree; string decodeStr; HuffmanTree.push_back ({'\0' ,-1 ,-1 }); for (auto [c,code]: HuffmanList) { int cur=root; for (int i=0 ; i<code.size (); i++) { if (code[i]=='0' ) { if (HuffmanTree[cur].L==-1 ) { HuffmanTree.push_back ({'\0' ,-1 ,-1 }); HuffmanTree[cur].L=HuffmanTree.size ()-1 ; } cur=HuffmanTree[cur].L; } else { if (HuffmanTree[cur].R==-1 ) { HuffmanTree.push_back ({'\0' ,-1 ,-1 }); HuffmanTree[cur].R=HuffmanTree.size ()-1 ; } cur=HuffmanTree[cur].R; } } HuffmanTree[cur].C=c; } int cur=root; for (char c: HuffmanCode) { cur = (c=='0' )? HuffmanTree[cur].L: HuffmanTree[cur].R; if (HuffmanTree[cur].C!='\0' ) { decodeStr+=HuffmanTree[cur].C; cur=root; } } return decodeStr; }
这玩意再搞个进制转换和简单混淆,就可以实现简单密码的功能了。
树的补充 树的存储 由于树的孩子不止有两个,所以不能像二叉树那样用明确左右孩子的连接关系来存储,这里介绍几种不同的方式:
多叉树的指针存储 存储多叉树最自然的方式就是用一个数组存储节点的所有孩子指针
1 2 3 4 5 struct TreeNode { ElmeType data; vector<TreeNode*> children; }
为了方便回溯,我们还可以加入TreeNode* parent
其父节点的指针
父节点表示法 由于每个孩子都有且仅有一个父节点,于是直接使用一个数组来存储每个父节点的索引,这样就可以得到节点(该数组索引)->父节点位置(存储的值)的关系:
1 2 val: 1 -1 4 4 2 5 5 1 2 4 //-1表示为根节点 idx: 0 1 2 3 4 5 6 7 8 9
这样存储的好处是找父节点方便 $O(1)$ ,但是找子节点需要遍历整个数组 $O(n)$
子节点表示法 类似邻接表(Adjacency List)的方式来存储树的连接关系:
1 2 vector<vector<int >> children (n); children (x).push_back ({...});
这样存储的好处就是能够快速找到子节点,适合DFS/BFS,但是找父节点需要遍历整个数组 $O(m)$ 或者再开一个数组来记录。
如果直接使用邻接表 ,那么还需要把父节点的编号push进该节点的数组中 ,如此构造就方便使用图的算法、LCA、最短路等
邻接矩阵 (Adjacency Matrix) 由于这时候的树已经很像图了,不妨直接用图的邻接矩阵来存储:
1 2 3 4 vector<vector<bool >> g (n, vector <bool >(n, false )); g[1 ][2 ]=true ; g[2 ][1 ]=true ; ...
这样对于两个节点的连接查询很方便,但是空间复杂度太高了,树很稀疏时很浪费,而且寻找父节点的子节点时间复杂度是 $O(n)$
兄弟表示法 (Left-Child Right-Sibling, LCRS) 把一棵多叉树变成一棵二叉树,方便用二叉树的算法和结构处理。
每个节点都只保留两个指针:
leftChild
指向第一个孩子
rightSibling
指向同一父节点的下一个兄弟节点
这个在后续的树转化为二叉树会讲到。
树的遍历 树不像二叉树那样有左右子节点之分,按照搜索方式不同,树的遍历可以分为:先根遍历(dfs)、后根遍历(dfs)、层序遍历(bfs)。
这里我们都采取邻接表的存储方式进行遍历。
先根遍历 先根遍历顾名思义就是先访问根节点,再访问子节点,至于子节点的访问顺序?一般没有规定其顺序,可以直接遍历。
1 2 3 4 5 6 7 8 vector<int > data (n) ={...};vector<bool > vis (n, false ) ;void preOrder (int u) { vis[u]=true ; cout << data[u] << " " ; for (int v : adj[u])if (!vis[v]) preOrder (v); }
如果使用孩子表示法来存储树,那么这个遍历就不需要vis
来避免访问到已经访问过的节点了。
这里补充一个使用邻接表避免多开一个vis
数组的方法:
1 2 3 4 5 void preOrder (int u,int parent) { cout << data[u] << " " ; for (int v : adj[u])if (v!=parent) preOrder (v,u) }
后根遍历 后根遍历顾名思义就是先访问子节点,再访问根节点。
1 2 3 4 5 6 7 8 vector<int > data (n) ={...};vector<bool > vis (n, false ) ;void postOrder (int u) { vis[u]=true ; for (int v : adj[u])if (!vis[v]) postOrder (v); cout << data[u] << " " ; }
同样地,后序遍历也可以避免使用vis
数组,读者自己完成。
层序遍历 这个遍历方式和之前的二叉树层序遍历别无二致,使用队列即可。这里使用邻接表的方式举例,这时一定要多开一个vis
数组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 vector<int > data (n) ={...};void levelOrder (int u) { vector<bool > vis (n, false ) ; queue<int > q; q.push (u); vis[u]=true ; while (!q.empty ()) { int cur=q.front (); q.pop (); cout << data[cur] << " " ; for (int v : adj[cur])if (!vis[v]) { vis[cur]=true ; q.push (v); } } }
树的转换 多叉树<——>二叉树 对于某个节点:
多叉树——>二叉树
左连孩子:将多叉树中某节点的第一个子节点作为该节点的左子节点。
右连兄弟:将多叉树中某节点的其他子节点依次作为其左子节点的右子节点。
二叉树——>多叉树
左孩子右兄弟:将二叉树中某节点的左子节点作为该节点的第一个子节点,右子节点作为该节点的下一个兄弟节点。
下面用bfs的方式展示(虽然一般用dfs更简洁直观🤫但动画都做好了🤗):
具体的代码
多叉树——>二叉树
DFS
写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void treeToBtreeDFS (vector<vector<int > > &children,vector<node> &Btree,int u) { Btree.assign (children.size (),{}); if (u>=(int )children.size ())return ; int prev=-1 ; for (int i=0 ; i<(int )children[u].size (); i++) { int v=children[u][i]; if (i==0 )Btree[u].L=v; else Btree[prev].R=v; prev=v; treeToBtreeBFS (children,Btree,v); } }
BFS
写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void treeToBtreeBFS (vector<vector<int > > &children,vector<node> &Btree,int root) { Btree.assign (children.size (),{}); queue<int > q; q.push (root); while (!q.empty ()) { int u=q.front (); q.pop (); const auto & kids=children[u]; if (kids.empty ())continue ; Btree[u].L=kids[0 ]; q.push (kids[0 ]); for (int i=1 ; i<(int )kids.size (); i++) { Btree[kids[i-1 ]].R=kids[i]; q.push (kids[i]); } } }
二叉树——>多叉树
DFS
写法:
1 2 3 4 5 6 7 8 9 10 11 void BtreeToTreeDFS (vector<vector<int > > &children,vector<node> &Btree,int u) { children.resize (Btree.size ()); int child=Btree[u].L; while (child!=-1 ) { children[u].push_back (child); BtreeToTreeDFS (children,Btree,child); child=Btree[child].R; } }
BFS
写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void BtreeToTreeBFS (const vector<node> &Btree, vector<vector<int >> &children, int root) { children.resize (Btree.size ()); queue<int > q; q.push (root); while (!q.empty ()) { int u=q.front (); q.pop (); int child=Btree[u].L; while (child!=-1 ) { children[u].push_back (child); q.push (child); child=Btree[child].R; } } }
以上仅为示例,并不代表实际情况一定要这么写。
如果要多次使用并存到同一个容器,记得先clear()
。
二叉树<——>森林 对于某个节点:
二叉树——>森林
从根节点开始,如果右子节点存在,那么将该节点拆分,右子节点作为新树的根节点
继续向右移动,拆分,直到右子节点不存在
将所得的二叉树树转化为多叉树
森林——>二叉树
将森林中的每棵树层数对齐,最左的为根节点,向右依次连接为右子树
将连接所得的多叉树转化为二叉树
由于这里森林的表示方式有很多,包括但不限于:
邻接表 + 根节点数组
1 2 vector<int > children[N]; vector<int > roots;
左孩子–右兄弟(LCRS)二叉树表示
1 2 struct node {int L=-1 ,R=-1 ;};vector<node> tree;
由于森林可以转化为二叉树,而LCRS表示方法可以直接表示二叉树、多叉树、森林,这样只用以上结构就可以表示不同的树,但就是不直观
虚拟根法
创建一个新的虚拟根 VR,所有森林的根挂到它下面,之后森林就是一颗树,做遍历就很方便
1 2 int VR = 0 ;for (auto r : roots) children[VR].push_back (r);
使用父节点数组
用 parent[u]
存父节点编号,很省空间,但是对于查找遍历不方便,除非预处理或者加个 children
数组双查找。
具体的代码 下面只给出邻接表+根节点数组
的示例代码:
二叉树——>森林
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 struct node {int L=-1 ,R=-1 ;};void dfs (const vector<node> &Btree, vector<vector<int >> &children, int u) { for (int c=Btree[u].L; c!=-1 ; c=Btree[c].R) { children[u].push_back (c); dfs (Btree, children, c); } } void btreeToForest (const vector<node> &Btree,vector<vector<int >> &children,vector<int > &roots,int root) { for (int cur=root; cur!=-1 ; cur=Btree[cur].R) { roots.push_back (cur); dfs (Btree, children, cur); } }
或者用BFS
写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void btreeToForestBFS (const vector<node> &Btree,vector<vector<int >> &children,vector<int > &roots,int root) { for (int cur=root; cur!=-1 ; cur=Btree[cur].R) { roots.push_back (cur); queue<int > q; q.push (cur); while (!q.empty ()) { int u=q.front (); q.pop (); for (int c = Btree[u].L; c != -1 ; c = Btree[c].R) { children[u].push_back (c); q.push (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 void forestToBtree (const vector<node> &Btree,vector<vector<int >> &children,vector<int > &roots,int root) { for (int i=0 ; i<(int )roots.size (); i++) { if (i+1 <(int )roots.size ()) Btree[roots[i]].R=roots[i+1 ]; else Btree[roots[i]].R=-1 ; } int n=(int )Btree.size (); for (int u=0 ; u<n; u++) { if (children[u].empty ()) Btree[u].L=-1 ; else { Btree[u].L=children[u][0 ]; int m=(int )children[u].size (); for (int i=0 ; i<m-1 ; i++) Btree[children[u][i]].R=children[u][i+1 ]; Btree[children[u][m-1 ]].R=-1 ; } } }