07 树 | 数据结构与算法
1. 树
1. 树的定义
-
树 (\(Tree\)):树是\(n\)个结点的有限集合,若\(n = 0\)时称为 空树,否则:
- 有且只有一个特殊的称为树的 根(\(Root\))结点
- 若\(n>1\)时,其余的结点被分为 \(m(m>0)\) 个 互不相交 的子集 \(T1, T2, T3,\dots, Tm\),其中每个子集本身又是一棵树,称其为根的子树(\(Subtree\))
-
层次关系:一对多
-
树的具体概念
- 结点的度:结点的子树个数
- 树的度:树中结点的度的 最大值
- 叶结点:度为零的结点
- 双亲结点:某个结点的子树的双亲
- 兄弟结点:具有同一双亲结点的所有结点
- 路径和路径长度:如果树的结点序列\(n_1,n_2,\dots,n_k\)有如下关系:结点 \(n_i\)是结点 \(n_{i+1}\) 的双亲结点,则把 \(n_1,n_2,\dots,n_k\)称为一条从\(n_1\)到\(n_k\)的路径,路径上经过的边的个数称为路径长度
- 祖先和子孙:在树中,如果有一条路径从
x
到y
,那么x
就是y
的祖先,y
是x
的子孙 - 结点的层数:根节点的层数为1,对于任意结点,若结点在第k层,则它的孩子结点在k+1层
- 树的深度:树中所有结点的最大层数
-
树的分类
- 有序树:子树的次序不能交换
- 无序树:子树的次序可以交换
- 森林:互不相交的树的集合
2. 树的基本操作
- 求根节点
- 求指定节点的双亲结点
- 求指定节点的某一个孩子节点
- 插入子树或者结点
- 删除子树或者结点
- 树的遍历:根据某种规则,按照一定的顺序访问树中的每一个结点,使得每个结点被访问且仅被访问一次
- 前序遍历:访问根结点,再依次前序遍历树的各子树
/* // definition for a tree node struct node{ int val; vector<node* > children; node(val):val(val), children(vector<node* >()){} node(val, children):val(val), children(children){} } */ void helper(node* root, vector<int> & res) { if (root == nullptr) return ; res.push_back(root->val); //push back the root for(auto& child: root->children) helper(child, res); //then push children } vector<int> preorder(node* root) { vector<int> res; helper(root, res); return res; }
- 后序遍历:依次后序遍历树的各子树,再访问根结点
void helper(node* root, vector<int> & res) { if (root == nullptr) return ; for(auto& child: root->children){ helper(child, res); //push children } res.push_back(root->val); //then push back the root } vector<int> postorder(node* root) { vector<int> res; helper(root, res); return res; }
- 前序遍历一棵树等价于前序遍历该树对应的二叉树,后序遍历一棵树等价于中序遍历该树对应的二叉树
- 前序遍历:访问根结点,再依次前序遍历树的各子树
3. 存储设计
-
双亲表示法:主要描述的是结点与双亲的关系
-
孩子表示法:孩子表示法主要描述的是节点与孩子之间的关系,由于每个结点的孩子的数目不确定,因此利用链式存储会更加方便
-
孩子兄弟表示法:通过描述每一个结点的一个孩子和兄弟的信息来反映节点之间的层次关系
2. 二叉树
1. 二叉树的定义
- 二叉树:二叉树是一个是\(n(n\ge 0)\)个结点的有限集合,该集合或者为空(称为空二叉树);或者是由一个根结点和两棵互不相交的、分别称为左子树和右子树的二叉树组成。
- 结构特点
- 每个节点最多只有两颗子树,即结点的都不超过2
- 子树有左右之分,顺序不能颠倒
- 即使只有一棵子树,也有 左右之分
- 满二叉树
- 定义:高度为\(K\)且有\(2^{K}-1\)个结点的二叉树
- 结构特点
- 分支节点都有两棵二叉树
- 叶子节点都在最后一层
- 在所有二叉树中,满二叉树的结点数量,分支节点数量和叶子节点数量是 最多的
- 完全二叉树
- 定义:高度为 \(K\) 的二叉树中所有的叶子都出现在第 \(K\) 层或者 \(K-1\) 层,并且第 \(K\) 层的叶子都出现在该层 最左边
2. 二叉树的性质
- 若二叉树的层次从1开始,那么在二叉树的第\(i\)层最多有\(2^{i - 1}\) 个结点
- 高度为\(k\)的二叉树最多有\(2^k-1\)个节点
- 对任何一棵二叉树, 如果其叶结点个数为\(n_0\), 度为2的非叶结点个数为\(n_2\), 则有\(n_0=n_2+1\)
- 具有\(n\)个结点的完全二叉树的高度为 \(\lfloor \log_2 n \rfloor + 1\)
- 完全二叉树的连续存储设计的下标特点:如果将一棵有\(n\)个结点的完全二叉树自顶向下,同一层自左向右连续给结点编号\(1, 2, \dots, n-1,n\),然后按此结点编号将树中各结点顺序地存放于一个一维数组中, 并简称编号为\(i\)的结点为结点\(i\)
- 若\(i == 1\), 则 \(i\) 无双亲
- 若\(i > 1\), 则 \(i\) 的双亲为\(\lfloor i/2 \rfloor\)
- 若\(2*i \le n\), 则 \(i\) 的左子女为 \(2*i\);否则,\(i\)无左子女,必定是叶结点,二叉树中\(i>\lfloor n/2\rfloor\)的结点必定是叶结点
- 若\(2*i+1 \le n\), 则 \(i\) 的右子女为\(2*i+1\),否则,\(i\)无右子女
- 若 \(i\) 为奇数, 且\(i\ne 1\),则其左兄弟为\(i-1\),否则无左兄弟
- 若 \(i\) 为偶数, 且\(i < n\),则其右兄弟为\(i+1\),否则无右兄弟
- \(i\) 所在层次为 \(\lfloor \log_2i\rfloor + 1\)
- 二叉树的存储设计
- 连续设计存储
-
类似树的顺序存储表示:双亲数组表示
-
直接用数组进行二叉树的存储(只适用于完全二叉树)
index
0 1 2 3 4 5 value
A B C D E F
-
- 链接存储设计
//二叉链表 typedef struct BinaryTreeNode{ int val; BinaryTreeNode *left, *right; }node; //三叉链表 typedef struct BinaryTreeNode{ int val; BinaryTreeNode *parent, *left, *right; }node;
- 连续设计存储
4. 二叉树的遍历
- 前序遍历
- 递归
void preorder(node *root){ if(root == nullptr) return ; else { visit(root->val); preorder(root->left); preorder(root->right); } }
- 迭代
void preorder(node *root){ if(root == nullptr) return ; stack<node*> stk; stk.push(root); node* ptr = root; while(!stk.empty()){ ptr = stk.top(); stk.pop(); if(ptr != nullptr) { visit(ptr->val); //先将右结点放进栈,左边访问完之后到右结点 stk.push(ptr->right); stk.push(ptr->left); } } }
- 递归
- 中序遍历
- 递归
void inorder(node *root){ if(root == nullptr) return ; else { inorder(root->left); visit(root->val); inorder(root->right); } }
- 迭代
void inorder(node *root){ if(root == nullptr) return ; stack<node*> stk; node *ptr = root; while(ptr != nullptr or !stk.empty()){ while(ptr != nullptr){ stk.push(ptr); //存下根节点 ptr = ptr->left; } ptr = stk.top(); visit(ptr->val); //访问根节点 stk.pop(); ptr = ptr->right; //到右子树 } }
- 递归
- 后序遍历
- 递归
void postorder(node *root){ if(root == nullptr) return ; else { postorder(root->left); postorder(root->right); visit(root->val); } }
- 迭代
void postorder(node *root){ if(root == nullptr) return ; stack<node*> stk; node *prev = nullptr, *ptr = root; while (ptr != nullptr or !stk.empty()) { while (ptr != nullptr) { stk.push(ptr); ptr = ptr->left; } ptr = stk.top(); stk.pop(); /*左右子树都已经访问完了*/ if (ptr->right == nullptr or ptr->right == prev) { visit(ptr->val); prev = ptr; ptr = nullptr; } /*左右子树还没访问完*/ else { stk.push(ptr); ptr = ptr->right; } } }
- 递归
- 层序遍历
void levelorder(node *root){ if(root == nullptr) return ; node *ptr = root; queue<node*> q; q.push(ptr); while(!q.empty()){ int size = q.size(); /*访问完这一层size个结点*/ for(int i = 0; i < size; ++i){ ptr = q.front(); q.pop(); visit(ptr->val); if(ptr->left != nullptr) q.push(ptr->left); if(ptr->right != nullptr) q.push(ptr->right); } } }
5. 二叉树的操作
- 统计叶子节点数:任意遍历的时候将访问改为计数器加一即可
- 求二叉树的高度
int height(node *root){ if(root == nullptr) return 0; else return max(height(root->left), height(root->right)) + 1; }
- 由先序遍历和中序遍历可 唯一 确定一棵二叉树
HashTable<int, int> hash; // <val , pos> node* recur(vector<int>& preorder , vector<int>& inorder , int pre_left , int pre_right , int in_left , int in_right){ if(pre_left > pre_right) return nullptr; //找到根节点:先序遍历的最左边即为根节点 int pre_root_index = pre_left; int in_root_index = hash[preorder[pre_left]]; int size = in_root_index - in_left; //建立根节点 node* root = new node(preorder[pre_root_index]); //建立左子树:中序遍历的根节点的左部分即为左子树的中序遍历 root->left = recur(preorder, inorder, pre_left + 1, pre_left + size , in_left , in_root_index - 1); //建立右子树:中序遍历的根节点的右部分即为右子树的中序遍历 root->right = recur(preorder, inorder, pre_left + size + 1, pre_right, in_root_index + 1, in_right); return root; } node* buildTree(vector<int>& preorder, vector<int>& inorder) { int n = preorder.size(); for(int i = 0 ; i < n ; ++i) { //记录中序遍历每个值的下标 hash[inorder[i]] = i; } return recur(preorder , inorder , 0 , n - 1 , 0 , n - 1); }
3. 线索树
1. 线索树
- 目的:遍历二叉树是按一定的规则将树中的结点排列成一个线性序列,即是对非线性结构的线性化操作。线索树可以存储遍历过程中动态得到的每个结点的直接前驱和直接后继
- 原理:空闲指针的利用:设一棵二叉树有
n
个结点,则有n-1
条边(指针连线) ,而n
个结点共有2n
个指针域(*left
和*right
) ,显然有n+1
个空闲指针域未用。则可以利用这些空闲的指针域来存放结点的直接前驱和直接后继信息 - 线索树定义
- 如果结点有左孩子,那么
*left
指向左孩子,否则指向直接前驱 - 如果结点有右孩子,那么
*right
指向右孩子,否则指向直接后继 - 为了避免混淆,增加2个标志域
leftTag
,rightTag
,等于0的时候和指示孩子,等于1的时候指示前驱后继typedef struct BinaryTreeNode{ int val; BinaryTreeNode *left, *right; int leftTag, rightTag; }node;
- 如果结点有左孩子,那么
- 线索化二叉树
-
线索树:遍历二叉树是按一定的规则将树中的结点排列成一个 线性序列,即是对非线性结构的线性化操作。在树中直接存储遍历过程中动态得到的每个结点的直接前驱和直接后继(第一个和最后一个除外)的信息就是线索化二叉树;设一棵二叉树有\(n\)个结点,则有\(n-1\)条边(指针连线) ,而\(n\)个结点共有\(2n\)个指针域(Lchild和Rchild) ,显然有\(n+1\)个空闲指针域未用。则可以利用这些空闲的指针域来存放结点的直接前驱和直接后继信息
-
线索树的指针域:
- 如果节点有左孩子,则
left
指向左孩子,否则指向其直接前驱 - 如果节点有右孩子,则
right
指向右孩子,否则指向其直接后继 - 加以改进:在指针域之中添加两个标志域
leftTag
和rightTag
leftTag == 1: *leftChild; leftTag == 0: *prev
rightTag == 1: *rightChild; rightTag == 0: *next
- 如果节点有左孩子,则
-
中序遍历的线索二叉树
- 寻找前驱:在当前结点的左子树的根的最右侧节点
if(ptr->leftTag == 1) prev = ptr->leftChild; else { prev = ptr->leftChild; while(prev != nullptr && prev->rightTag == 0) { prev = prev->rightChild; } }
- 寻找后继:在当前结点的右子树的根的最左侧节点
if(ptr->rightTag == 1) next = ptr->leftChild; else { next = ptr->rightChild; while(next != nullptr && next->leftTag == 0) { next = next->leftChild; } }
- 寻找前驱:在当前结点的左子树的根的最右侧节点
-
4. 森林与二叉树的转换
1. 树转换为二叉树
-
连线:在树的每层按从“左至右”的顺序在兄弟结点之间加虚线相连
-
删线:除最左的第一个子结点外,父结点与所有其它子结点的连线都去掉
-
旋转
-
性质
- 二叉树的根结点没有右子树,只有左子树
- 左子结点仍然是原来树中相应结点的左子结点,而所有沿右链往下的右子结点均是原来树中该结点的兄弟结点
2. 森林转换为二叉树
- 若森林不空,则对应二叉树的根
root
是Forest
中第一棵树T1
的根root
(T1);其左子树为T1
的子树;其右子树为T2
,T3
, ...,Tn
,其中,T2
,T3
, ...,Tn
是除T1
外其它树构成的森林 - 转换步骤
- 将
Forest = {T1, T2, ⋯, Tn}
中的每棵树转换成 二叉树 - 按给出的森林中树的次序,从最后一棵二叉树开始,每棵二叉树作为前一棵二叉树的根结点的右子树,依次类推,则第一棵树的根结点就是转换后生成的二叉树的根结点
- 将
3. 二叉树还原为森林
- 若二叉树不空,则
Forest
中第一棵树T1
的根为root
;T1
的根的子树森林{T11, T12, ..., T1m}
是由root
的左子树转换而来,Forest
中除了T1
之外其余的树组成的森林{ T2, T3, ..., Tn}
是由root
的右子树转换而成的森林 - 转换步骤
- 去连线。将二叉树的根结点与其右子结点以及沿右子结点链方向的所有右子结点的连线全部去掉,得到若干棵孤立的二叉树,每一棵就是原来森林F中的树依次对应的二叉树
- 二叉树的还原。将各棵孤立的二叉树按二叉树还原为树的方法还原成一般的树
5. 哈夫曼树 \((HuffmanTree)\)
1. 哈夫曼树
- 叶子节点的权值:对叶子节点赋予一个有意义的数值
- 二叉树的带权路径长度:设二叉树具有\(n\)个带权值的叶子节点,二叉树的带权路径长度是从根节点到叶子节点的 路径长度 与相应叶子节点权值的乘积之和,记为\(WPL = \sum_{k = 1}^nw_k\times l_k\),其中\(w_k\)是第\(k\)片叶子的权值,\(l_k\)是从根节点到第\(k\)个叶子节点的路径长度
- 哈夫曼树:带权路径长度 最短 的二叉树
- 哈夫曼树的特点
- 树中没有分支为一的节点
- \(m\)片叶子的哈夫曼树具有\(2m-1\)个节点
- 哈夫曼树的形态 不唯一
- 权值较大的节点离根较近,权值较小的结点离根较远
2. 构造哈夫曼树
-
步骤
- 将给定的\(n\)个权值
{w1, w2, ..., wn}
作为\(n\)个根结点的权值构造一个具有\(n\)棵二叉树的森林{T1, T2, ..., Tn}
,其中每棵二叉树只有一个根结点 - 在森林中选取两棵根结点权值 最小 的二叉树作为左右子树构造一棵新二叉树,新二叉树的根结点权值为这两棵树根的 权值之和
- 在森林中,将上面选择的这两棵根权值最小的二叉树从森林中删除,并将刚刚新构造的二叉树加入到森林中
- 重复上面(2)和(3),直到森林中只有一棵二叉树为止。这棵二叉树就是哈夫曼树
- 将给定的\(n\)个权值
-
演示
-
算法
- 构建哈夫曼树
struct TreeNode { char val; TreeNode *left; TreeNode *right; TreeNode() : val(0), left(nullptr), right(nullptr) {} TreeNode(char x) : val(x), left(nullptr), right(nullptr) {} TreeNode(char x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} }; struct cmp{ bool operator()(pair<int, TreeNode*> x, pair<int, TreeNode*> y) { return x.first > y.first; } }; TreeNode* BuildHuffmanTree(char vals[], int weights[], int n){ priority_queue<pair<int, TreeNode*>, vector<pair<int, TreeNode*>>, cmp> minheap; for (int i = 0; i < n; ++i) { TreeNode *root = new TreeNode(vals[i]); minheap.push({weights[i], root}); } while (minheap.size() != 1) { const pair<int, TreeNode*> node1 = minheap.top(); minheap.pop(); const pair<int, TreeNode*> node2 = minheap.top(); minheap.pop(); // 两个节点合并的顺序,权重小的放右边,权重大的放左边,权重一样字符小的右边,字符大的左边 // 权重和字符都相同,则先进入队列的放左边 int weight1 = node1.first, weight2 = node2.first; char ltr1 = (node1.second)->val, ltr2 = (node2.second)->val; TreeNode *newNode = nullptr; if (weight1 != weight2) { newNode = new TreeNode('#', node2.second, node1.second); } // '#' 表示非叶子节点的字符值 else if (ltr1 != ltr2) { if (ltr1 < ltr2) newNode = new TreeNode('#', node2.second, node1.second); else newNode = new TreeNode('#', node1.second, node2.second); } else newNode = new TreeNode('#', node1.second, node2.second); minheap.push({weight1 + weight2, newNode}); } TreeNode *root = minheap.top().second; minheap.pop(); return root; }
- 计算\(WPL\)
int HuffmanTreeWPL(char vals[], int weights[], int n) { priority_queue<int, vector<int>, greater<int>> minheap; int WPL = 0, temp, first, second; for(const int& weight: weights) { minheap.push(weight); } while(minheap.size() != 1) { first = minheap.top(); minheap.pop(); second = minheap.top(); minheap.pop(); WPL += first + second; minheap.push(first + second); } minheap.pop(); return WPL; }
3. 哈夫曼编码
-
背景:在电报收发等数据通讯中,常需要将传送的文字转换成由二进制字符0、1组成的字符串来传输。为了使收发的速度提高,就要求电文编码要尽可能地短
-
前缀码:如果一组编码中任意一个编码 都不是 其他任何一个编码的前缀,则称这种编码具有前缀性,简称 前缀码
- 等长编码是前缀码
- 变长编码为了使得译码不具有二义性,需要具备前缀性
-
平均编码长度:设每个对象\(c_j\)出现的频率为\(p_j\),其二进制位串长度为\(l_j\),则平均编码长度为\(\sum_{j = 1}^np_j\times l_j\)
-
最优前缀码:使得平均编码长度 最短 的前缀编码为最优前缀码
-
哈夫曼编码:利用哈夫曼树,左支为0,右支为1,从 根到叶子的01序列就是 哈夫曼编码
- 一定具有 前缀性
- 最小冗余码
- 出现概率大的对象对于码长 短
- 编码不唯一
-
例子
char
M A N G O code
00 01 100 101 11 -
哈夫曼译码算法
struct TreeNode { char val; TreeNode *left, *right; }; string HuffmanEncoder(TreeNode* root, string code) { TreeNode *ptr = root; string decode; for (int i = 0; i < code.length(); ++i) { if (code[i] == 0) ptr = ptr -> left; else ptr = ptr -> right; if (ptr -> val != '#') { decode += ptr -> val; ptr = root; } } return encode; }
6. 二叉查找树 \(BST\)
1. 二叉查找树
-
二叉查找树:二叉查找树(二叉排序树) 或者是一棵空树,或者是具有下列性质的二叉树
- 每个结点都有一个作为查找依据的关键字(
key
),所有结点的关键字互不相同 - 左子树(若非空)上所有结点的关键字都 小于 根结点的关键字
- 右子树(若非空)上所有结点的关键字都 大于 根结点的关键字
- 左子树和右子树也是 二叉查找树
- 每个结点都有一个作为查找依据的关键字(
-
二叉查找树的特点
- 任意一个节点的关键字都 大于 其左子树的任意节点,小于 其右子树的任意节点
- 按 中序遍历 二叉查找树所得到的中序序列是一个 递增的有序序列
- 同一数据集的二叉查找树 不唯一,但是中序序列唯一
2. 二叉查找树的操作
- 查找:在树中查找关键字
key
key == node.val
查找成功key < node.val
,则递归地在node
的左子树查找key
key > node.val
,则递归地在node
的右子树查找key
struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode() : val(0), left(nullptr), right(nullptr) {} TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} }; TreeNode* BST_search(TreeNode* root, int key) { if (root == nullptr) return nullptr; else if (key == root->val) return root; else if (key < root->val) return BST_search(root->left, key); else return BST_search(root->right, key); }
- 插入:若二叉排序树为空树,则新插入的结点为根结点;否则,新插入的结点必为一个新的叶结点
void BST_insert(TreeNode* root, int key) { TreeNode* prev = root, ptr; TreeNode* newNode = new TreeNode(key); if (root == nullptr) root = newNode; else { ptr = root; while (ptr != nullptr) { prev = ptr; if (key < ptr->val) ptr = ptr -> left; else ptr = ptr -> right; } if (key < prev -> val) prev -> left = newNode; else prev -> right = newNode; } }
- 删除
-
删除二叉查找树的叶子节点:直接删除
-
删除二叉查找树的根节点
- 根节点有左右子树的情况下,选择根结点的左子树中的最大结点为新的根结点;或者是右子树中的最小结点为新的根结点
- 如果根结点没有左子树,则以右子树的根结点作为新的根结点
- 如果根结点没有右子树,则以左子树的根结点作为新的根结点
-
算法
bool del(TreeNode* root, TreeNode* node, TreeNode* parent) { TreeNode* son; bool tag = false; if (node -> left == nullptr) son = node -> right; else if (node -> right == nullptr) son = node -> left; else { TreeNode* ptr = node; son = node -> left; // find node with max value of the left tree while (son -> right != nullptr) { ptr = son; son = son -> right; } node -> val = son -> val; if (ptr == node) ptr -> left = son -> left; else ptr -> right = son -> left; delete son; tag = true; } if (tag == false) { if (parent == nullptr) root->left = son; else if (parent -> left == node) parent -> left = son; else parent -> right = son; } return true; } bool BST_delete(TreeNode* root, int key) { TreeNode* ptr = root, prev; while (ptr != nullptr) { if (ptr -> val == key) return del(root, ptr, prev); else if (ptr -> val > key) { prev = ptr; ptr = ptr -> left; } else { prev = ptr; ptr = ptr -> right; } } return false; // not found }
-
3. 平衡二叉查找树 \(AVL\)
-
平衡二叉查找树:一棵\(AVL\)树或者是空树,或者是具有这个性质的二叉查找树:它的左子树和右子树都是\(AVL\)树,且左子树和右子树的高度之差的绝对值不超过1
-
\(n\)个节点的二叉查找树最大高度为\(n-1\),最小高度为\(\lfloor\log n\rfloor\)
-
结点的平衡因子
- 每个结点附加一个数字,给出该结点右子树的高度减去左子树的高度所得的高度差。这个数字即为结点的平衡因子
balance
- 根据\(AVL\)树的定义,任一结点的平衡因子只能取 -1,0和 1
- 如果一个结点的平衡因子的绝对值大于1,则这棵二叉查找树就失去了平衡,不再是\(AVL\)树
- 如果一棵二叉查找树是高度平衡的,它就成为\(AVL\)树。如果它有\(n\)个结点,其高度可保持在\(O(\log n)\),平均查找长度也可保持在\(O(\log n)\)
- 每个结点附加一个数字,给出该结点右子树的高度减去左子树的高度所得的高度差。这个数字即为结点的平衡因子
-
\(AVL\)树的插入
- 在向一棵本来是高度平衡的AVL树中插入一个新结点时,如果树中某个结点的平衡因子的绝对值
|balance|
> 1,则出现了不平衡,需要做平衡化处理 - 算法从一棵空树开始,通过输入一系列对象的关键字,逐步建立\(AVL\)树。在插入新结点时使用了前面所给的算法进行 平衡旋转
- 平衡旋转
-
LL:插入或删除一个结点后,根节点的左子树的左子树还有非空结点,导致根的左子树的高度比根的右子树的高度大2,\(AVL\)树因此失去平衡
struct TreeNode { int key; int height; TreeNode *left, *right; TreeNode(int key, TreeNode *left, TreeNode *right):key(key), left(left), right(right) {} }; int height(TreeNode* node) { if (node != nullptr) return node->height; else return 0; } TreeNode* leftleftRotation(TreeNode* T) { TreeNode* L = T -> left; T -> left = L -> right; L -> right = T; T -> height = max(height(T -> left), height(T -> right)) + 1; L -> height = max(height(L -> left), T -> height) + 1; return L; }
-
RR:插入或删除一个结点后,根节点的右子树的右子树还有非空结点,导致根的右子树的高度比根的左子树的高度大2,\(AVL\)树因此失去平衡
TreeNode* rightrightRotation(TreeNode* T) { TreeNode* R = T -> right; T -> right = R -> lwdt; R -> left = T; T -> height = max(height(T -> left), height(T -> right)) + 1; R -> height = max(height(R -> right), T -> height) + 1; return R; }
-
LR:插入或删除一个结点后,根节点的左子树的右子树还有非空结点,导致根的左子树的高度比根的右子树的高度大2,\(AVL\)树因此失去平衡
TreeNode* leftrightRotation(TreeNode* T) { T -> left = rightrightRotation(T -> left); return leftleftRotation(T); }
-
RL:插入或删除一个结点后,根节点的右子树的左子树还有非空结点,导致根的右子树的高度比根的左子树的高度大2,\(AVL\)树因此失去平衡
TreeNode* rightleftRotation(TreeNode* T) { T -> right = leftleftRotation(T -> right); return rightrightRotation(T); }
-
插入
TreeNode* insert(TreeNode* root, int key) { if(root == nullptr) { root = new TreeNode(key, nullptr, nullptr); if (root == nullptr) { cout << "ERROR: create tree node failed" << endl; return nullptr; } } else if (key < root->key) { root->left = insert(root->left, key); if (height(root->left) - height(root->right) == 2) { if (key < root->left->key) root = leftleftRotation(root); else root = leftrightRotaion(root); } } else { root->right = insert(root->right, key); if (height(root->right) - height(root->left) == 2) { if (key > root->right->key) root = rightrightRotation(root); else root = rightleftRotaion(root); } } root->height = max(height(root->left), height(root->right)) + 1; return root; }
-
- 在向一棵本来是高度平衡的AVL树中插入一个新结点时,如果树中某个结点的平衡因子的绝对值
7. \(B-Tree\) 和 \(B^+Tree\)
1. 动态查找结构\(B-Tree\)
-
背景:当查找表上的大小超过内存容量的时候,由于必须从磁盘等辅助存储设备中读取这些查找树结构的节点,每次只能根据实际需要读取一个节点,因此,\(AVL\)树的性能不是很高
-
\(m\)-阶查找树:一棵 \(m\) 阶\(B\)-树是一棵 \(m\) 路查找树,它或者是空树,或者是满足下列性质的树
- 树中每个结点至多有\(m\)棵子树
- 根结点至少有 \(2\) 棵子树
- 除根结点以外的所有非终端结点至少有\(\lceil m / 2 \rceil\)棵子树
- 所有非终端结点中包含下列信息数据
(n, A0, K1, A1, K2, A2, ..., Kn, An)
,其中:Ki (i=1,...,n)
为关键字,且Ki
<Ki+1
,Ai (i=0,...,n)
为指向子树根结点的指针,n
为关键字的个数 - 所有的叶子结点(失败结点)都位于同一层。事实上,每个结点中还应包含指向每个关键字的记录的指针
-
\(B\)-树的查找
- \(B\)-树的查找过程是一个顺指针查找结点和在结点的关键字进行查找交叉进行的过程。因此,\(B\)-树的查找时间与\(B\)-树的阶数\(m\)和\(B\)-树的高度h直接有关,必须加以权衡
- 在\(B\)-树上进行查找,查找成功所需的时间取决于关键字所在的层次,查找不成功所需的时间取决于树的高度。如果我们定义\(B\)-树的高度\(h\)为失败结点所在的层次,需要了解树的高度\(h\)与树中的关键字个数\(N\)之间的关系
- \(m\)的选择:如果提高\(B\)-树的阶数 \(m\),可以减少树的高度,从而减少读入结点的次数,因而可减少读磁盘的次数,事实上,\(m\) 受到内存可使用空间的限制。当 \(m\)很大超出内存工作区容量时,结点不能一次读入到内存,增加了读盘次数,也增加了结点内查找的难度
-
\(B\)-树的 插入
- 基本原理:B-树是从空树起,逐个插入关键字而生成的。在\(B\)-树,每个非失败结点的关键字个数都在\([\lceil m/2\rceil - 1, m-1]\)之间;插入是在最底层的某个非失败结点添加一个关键字。如果在关键码插入后结点中的关键字个数超出了上界\(m-1\)则结点需要 分裂,否则可以直接插入
- 分裂:设结点 \(A\) 中已经有 \(n-1\)个关键字,当再插入一个关键字后结点中的状态为\((n, A_0, K_1, A_1, K_2, A_2, \dots, K_m, A_m)\),这时必须把结点 \(p\)分裂成两个结点 \(p\)和 \(q\),它们包含的信息分别为
-
\(p:(\lceil m/2 \rceil - 1, A_0, K_1, A_1, \dots, K_{\lceil m/2 \rceil - 1}, A_{\lceil m/2 \rceil - 1})\)
-
\(q:(m - \lceil m/2\rceil, A_{\lceil m/2\rceil}, K_{\lceil m/2\rceil + 1}, \dots, K_m, A_m)\)
-
位于中间的关键字 \(K\lceil m/2\rceil\) 与指向新结点 \(q\) 的指针形成一个二元组 \(( K\lceil m/2\rceil, q )\),插入到这两个结点的双亲结点中去
-
在插入新关键字时,需要自底向上分裂结点,最坏情况下从被插关键字所在结点到根的路径上的所有结点都要分裂
-
-
\(B\)-树的 删除
- 基本思想:在\(B\)-树上删除一个关键字时,首先需要找到这个关键字所在的结点,从中删去这个关键字。
- 若删除结点非最后一层:且被删关键字为 \(K_i\),则在删去该关键字之后,应以该结点\(A_i\)所指示子树中的最小关键字\(x\)来代替被删关键字\(K_i\)所在的位置,然后在\(x\)所在的叶结点中删除\(x\)
- 若在最后一层上的删除(橙色为待删除结点)
-
被删关键字所在结点的关键字数目 大于等于 \(\lceil m/2\rceil\),则直接在该结点处删除关键字\(K_i\)以及对应指针\(A_i\)
-
被删关键字所在结点的关键字数目 等于 \(\lceil m/2\rceil - 1\),而与该结点相邻的右兄弟结点(或者左兄弟)结点中的关键字数目大于\(\lceil m/2\rceil - 1\),只需将该右兄弟结点中的最小(或者左兄弟 最大)的关键字上移到双亲结点中,然后将双亲结点中小于(或者大于)且紧靠该上移关键字的关键字 移动到 被删关键字所在的结点中
-
被删除关键字所在的结点如果和其相邻的兄弟结点中的关键字数目都正好等于\(\lceil m/2\rceil - 1\),假设其有右兄弟结点,且其右兄弟结点是由双亲结点中的指针 \(A_i\) 所指,则需要在删除该关键字的同时,将剩余的关键字和指针连同双亲结点中的 \(K_i\) 一起合并到右兄弟结点中;在合并的同时,由于从双亲结点中删除一个关键字,若导致双亲结点中关键字数目小于\(\lceil m/2\rceil - 1\),则继续按照该规律进行合并
-
2. \(B^+Tree\)
-
背景:\(B\)树之有利于单个关键字的查找,但是在数据库等应用中往往需要范围查找,所以改进\(B\)树为\(B^+\)树
-
与\(B\)树的区别:\(B^+\)树的元素 只存放在叶子节点,中间节点的关键字起到引导查找的作用
-
\(B^+\)树:一棵\(m\)阶\(B^+\)树可以定义如下
- 树中每个非叶结点 最多 有 \(m\) 棵子树
- 根结点 (非叶结点) 至少有 \(2\) 棵子树。除根结点外,其它的非叶结点 至少 有 \(\lceil m/2\rceil\) 棵子树;有 \(n\) 棵子树的非叶结点有 \(n-1\) 个关键字
- 所有的叶结点都处于同一层次上,包含了全部关键字及指向相应数据对象存放地址的指针,且叶结点本身按关键字 从小到大 顺序链接
- 每个叶结点中的子树棵数 \(n\) 可以多于 \(m\),可以少于\(m\),视关键字字节数及对象地址指针字节数而定。若设结点可容纳最大关键字数为 \(m\),则指向对象的地址指针也有 \(m\) 个。结点中的子树棵数 \(n\) 应满足 \(n \in [\lceil m/2\rceil, m]\)
- 若根结点同时又是叶结点,则结点格式同叶结点
- 所有的非叶结点可以看成是索引部分,结点中关键码 \(K_i\) 与指向子树的指针 \(A_i\) 构成对子树 (即下一层索引块) 的索引项 \(( K_i, A_i )\),\(K_i\)是子树中最小的关键字。特别地,子树指针 \(A_0\)所指子树上所有关键字均小于 \(K_1\),结点格式同\(B\)树
- 在\(B^+\)树中有两个头指针:一个指向\(B^+\)树的根结点,一个指向关键字最小的叶结点。可对\(B^+\)树进行两种查找运算:一种是循叶结点链顺序查找,另一种是从根结点开始,进行自顶向下,直至叶结点的随机查找
-
\(B^+\)树的 查找
- 由于与记录有关的信息存放在叶节点,查找时如果已经在上层找到了待查的关键码并不会停止,而是一直沿着指针向下查找直到叶子节点层
- \(B^+\)树的所有叶子节点形成一个有序链表,因此可以按照关键码排序的次序遍历全部记录。结合起来使得\(B^+\)树适合 范围查找
-
\(B^+\)树的 插入
- \(B^+\) 树的插入仅在叶结点上进行。每插入一个关键字-指针索引项后都要判断结点中的子树棵数是否超出范围。当插入后结点中的子树棵数\(n > m\) 时,需要将叶结点分裂为两个结点,它们的关键字个数分别为\(\lfloor(m+1)/2\rfloor\) 和 \(\lceil(m + 1)/2\rceil\)。分裂后,需要将第\(\lceil(m + 1)/2\rceil\)的关键字上移到父结点。如果这时候父结点中包含的关键字个数小于\(m\),则插入操作完成;如果父结点中包含的关键字个数等于\(m\),则继续分裂父结点
- 演示
-
\(B^+\)树的 删除
-
\(B^+\) 树的删除仅在叶结点上进行。当在叶结点上删除一个关键字-指针索引项(非该结点的最小关键码),结点中的子树棵数仍然不少于\(\lceil m/2\rceil\),则直接删除
-
如果删除的关键字是该结点的最小关键码,且结点中的子树棵数仍然不少于\(\lceil m/2\rceil\)上溯找到对应关键码进行修改
-
如果在叶结点中删除一个关键字-指针索引项后,该结点中的子树棵数 \(n\) 小于结点子树棵数的下限\(\lceil m / 2 \rceil\),必须做结点的调整或合并工作
-
其兄弟结点有多余的关键字,则从其兄弟结点借用关键字,同时上溯修改关键码(注意演示为5阶\(B^+\)树)
-
其兄弟结点没有多余的关键字,则与兄弟结点合并
-
-