二叉树
在计算机科学中,二叉树是每个节点最多有两个子树的树结构。在图论中,二叉树是一个连通的无环图,并且每一个顶点的度不大于3。
一. 旋转(Rotation): 从果园转换成二叉树
(1) 重画orchard,使得每个节点的正下方都是其第一个子节点,而不是所有节点的中间。
(2) 垂直连接节点及其第一个子节点,水平连接每个节点与其相邻的兄弟节点,删除原有的边(不包含上述的垂直边及水平边)。
(3) 顺时针旋转45°,则垂直连接成为二叉树的左连接,水平连接成为二叉树的右连接。
二. 实现方式
顺序存储(sequential storage)
使用数组array存储一个二叉树,则array[0]为根,存储在array[k]的节点的左孩子和右孩子分别位于array[2k+1]和array[2k+2]。如图所示,^表示空。下标 0 1 2 3 4 5 6 7 8 9 数据 A B C ^ E ^ G ^ ^ J 对于一个高度为k的树需要2^k的空间来存储,对于满二叉树比较合适,但是对于其他普通的二叉树,显然这个存储结构不够高效。
- 链式实现(linked implementation)
用链表实现树型是一种比较自然的实现方法。一个节点结构包含两个指针,分别指向其左右子树。
注意:链式实现存在两种表示——是否带头节点指针。
三. 二叉树的性质
前提:根节点位于第0层
(1) 在二叉树的第i层上至多有
2i
个结点(i≥0)。
(2) 深度为k的二叉树至多有
2k+1−1
个结点(k≥0)。
(3) 对任何一棵二叉树,如果其终端结点数为
n0
,度为2的结点数为
n2
,则
n0=n2+1
。
(4) 一棵深度为k且有
2k+1−1
个结点的二叉树称为满二叉树。对于深度为k的,有n个结点的二叉树,当且仅当其每一个结点都与深度为k的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树,如图所示。
(5) 具有n个结点的完全二叉树的深度为不大于
log2n
的最大整数。
(6) 如果对一棵有n个结点的完全二叉树的结点按层序编号(从第0层到最后一层,每层从左到右),则对任一结点i(1≤i≤n),有
a. 如果i=1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点x(其中x是不大于i/2的最大整数)。
b. 如果2i>n,则结点i无左孩子(结点i为叶子结点);否则其左孩子是结点2i。
c. 如果2i+1>n,则结点i无右孩子;否则其右孩子是结点2i+1。
四. 树的遍历
- 中序遍历(in-order traversal)
访问顺序为:左节点->根节点->右节点
typedef struct Node{
int data;
Node* left;
Node* right;
}Node;
// 递归实现
void in_order_traversal1(Node* node){
if(node->left != NULL)
in_order_traversal1(node->left);
cout << node->data << " ";
if(node->right != NULL)
in_order_traversal1(node->right);
}
// 非递归实现
void in_order_traversal2(Node* node){
Node* curNode = node;
stack<Node*> s;
while(curNode!=NULL || !s.empty()){
while(curNode != NULL){
s.push(curNode);
curNode = curNode->left;
}
curNode = s.top();
s.pop();
cout << curNode->data << " ";
curNode = curNode->right;
}
}
- 前序遍历(pre-order traversal)
访问顺序为:根节点->左节点->右节点
typedef struct Node{
int data;
Node* left;
Node* right;
}Node;
// 递归实现
void pre_order_traversal1(Node* node){
cout << node->data << " ";
if(node->left != NULL)
pre_order_traversal1(node->left);
if(node->right != NULL)
pre_order_traversal1(node->right);
}
// 非递归实现
void pre_order_traversal2(Node* node){
Node* curNode = node;
stack<Node*> s;
while(curNode!=NULL || !s.empty()){
while(curNode!=NULL){
cout << curNode->data << " ";
s.push(curNode);
curNode = curNode->left;
}
curNode = s.top();
s.pop();
curNode = curNode->right;
}
}
- 后序遍历(post-order traversal)
访问顺序为:左节点->右节点->根节点
typedef struct Node{
int data;
Node* left;
Node* right;
}Node;
// 递归实现
void post_order_traversal(Node* node){
if(node->left != NULL)
post_order_traversal1(node->left);
if(node->right != NULL)
post_order_traversal1(node->right);
cout << node->data << " ";
}
// 非递归实现
void post_order_traversal2(Node* node){
if(node == NULL) return;
Node* curNode = node;
Node* preNode = NULL;
stack<Node*> s;
s.push(curNode);
while(!s.empty()){
curNode = s.top();
// 遇到叶节点或者节点的左右子树都已访问
if(curNode->left==NULL && curNode->right==NULL
|| preNode!=NULL && (preNode==curNode->left || preNode==curNode->right)){
cout << curNode->data << " ";
s.pop();
preNode = curNode;
}
else{
if(curNode->right!=NULL){
s.push(curNode->right);
}
if(curNode->left!=NULL){
s.push(curNode->left);
}
}
}
}
- 层次遍历(level traversal)
访问顺序为:level 0->level 1-> …
typedef struct Node{
int data;
Node* left;
Node* right;
}Node;
void level_traversal(Node* node){
Node* curNode = node;
queue<Node*> q;
if(curNode != NULL) q.push(curNode);
while(!q.empty()){
curNode = q.front();
q.pop();
cout << curNode->data << " ";
if(curNode->left != NULL) q.push(curNode->left);
if(curNode->right != NULL) q.push(curNode->right);
}
}
- 根据不同遍历序列得到重构二叉树
a. 前序遍历+中序遍历
前序:ABCDEF
中序:CBAEDF
① 根据前序遍历序列,二叉树首先遍历根节点,再遍历左子树,最后遍历右子树。可以确定的是第一个数据A为根节点。再根据中序遍历序列,二叉树首先遍历左子树再遍历根节点,最后遍历右子树,可以确定左子树为CB和右子树EDF。
② 对左子树和右子树分别进行步骤①,直到遍历到叶节点。
b. 后序遍历+中序遍历
后序:CBEFDA
中序:CBAEDF
① 根据后序遍历序列,二叉树首先遍历遍历左子树,再遍历右子树,最后遍历根节点。可以确定的是最后一个数据A为根节点。再根据中序遍历序列,二叉树首先遍历左子树再遍历根节点,最后遍历右子树,可以确定左子树为CB和右子树EDF。
② 对左子树和右子树分别进行步骤①,直到遍历到叶节点。
c. 前序遍历+后序遍历
根据前序遍历和中序遍历得到的二叉树结构可能不唯一。
五. 二叉搜索树(Binary search tree)
二叉搜索树中的节点满足以下条件:
1. 假如节点存在左孩子,则左孩子小于其父节点
2. 假如节点存在右孩子,则右孩子大于其父节点
3. 根节点的左子树和右子树也是二叉搜索树。
注意:二叉搜索树要求不存在相同的键值。
- 目标值检索:为了搜索一个目标值,通常会借用一个辅助函数:首先比较目标值与树的根节点的大小,假如目标值相同,则搜索结束;假如目标值小于根节点,则进入左子树;否则进入右子树。在子树中重复上述操作,知道找到目标值或者到达一个空子树。
// 递归实现
Node* search_for_node1(Node* sub_root, const int target){
if(sub_root==NULL || sub_root->data == target) return sub_root;
if(sub_root->data > target) search_for_node1(sub_root->left, target);
if(sub_root->data < target) search_for_node1(sub_root->right, target);
}
// 非递归实现
Node* search_for_node2(Node* sub_root, const int target){
if(sub_root==NULL || sub_root->data == target) return sub_root;
while(sub_root!=NULL && sub_root->data != target){
if(sub_root->data > target)
sub_root = sub_root->left;
else if(sub_root->data < target)
sub_root = sub_root->right;
}
return sub_root;
}
时间复杂度分析:
对于最好情况,则二叉搜索树是一个几乎完全平衡的结构,那么拥有n个节点的树的比较次数复杂度为O(log n)。对于最坏情况,则二叉树为一个链式结构,那么搜索的复杂度与顺序搜索相同,为O(n)。假如二叉搜索树的构建是随机的(则不一定平衡),那么二叉树搜索的效率近似于二分检索。二叉搜索树插入节点:
类似于目标值查找,找到第一个空子树的位置,就将节点插入二叉树中。
void search_and_insert(Node* &sub_root, const int value){
if(sub_root==NULL){
sub_root = new Node();
sub_root->left = NULL;
sub_root->right = NULL;
sub_root->data = value;
return;
}
if(sub_root->data > value) search_and_insert(sub_root->left, value);
if(sub_root->data < value) search_and_insert(sub_root->right, value);
}
- 二叉搜索树删除节点:
如图所示共有三种情况:
void remove_node(Node* &sub_root){
if(sub_root == NULL) cout << "No target!" << endl;
else if(sub_root->left == NULL) sub_root = sub_root->right; // 情况1&2
else if(sub_root->right == NULL) sub_root = sub_root->left; // 情况2
else{ // 情况3
Node* parent = sub_root;
Node* preNode = sub_root->left;
while(preNode->right != NULL){
parent = preNode;
preNode = preNode->right;
}
sub_root->data = preNode->data;
if(parent == sub_root) sub_root->left = preNode->left;
else parent->right = preNode->left;
delete preNode;
}
}
void search_and_destroy(Node* sub_root, const int target){
if(sub_root==NULL || sub_root->data == target){
remove_node(sub_root);
}
else if(sub_root->data > target) search_and_destroy(sub_root->left, target);
else if(sub_root->data < target) search_and_destroy(sub_root->right, target);
}
- 树排序(treesort)
注意并不是堆排序,首先构造二叉搜索树,然后中序遍历树可以得到一个有序序列。treesort与quicksort十分相似,
首先第一个数据作为根节点;
第二个数据想要插入二叉搜索树时,首先与根节点比较,类似地,在quicksort中,首先与pivot比较。根据比较结果,将第二个数据插入,作为左/右子树的根;
接下来的数据假如与第二个数据位于同一个子树,则需要与第二个数据相比较,同样类似于quitsort。由此,我们可以知道,treesort所需要的key值比较次数与quitsort相同。
相比于quitsort,treesort的优点是①不要求所有数据在一开始就是可获取的,因为treesort是对数据逐个插入;②treesort支持后续的插入与删除。缺点是对于已经有序或者接近有序的数据,treesort效率极低,生成的二叉搜索树是一条链。
六. 平衡二叉树(AVL tree)
AVL树是一种自平衡二叉查找树,在AVL树中任何节点的两个子树的高度最大差别为一,所以它也被称为高度平衡树。
平衡因子(balance factor)=左子树高度 - 右子树高度
1. 平衡旋转(Rotation)
当AVL树插入一个新节点,就有可能造成失衡,此时必须重新调整树的结构。(此处图片截自zzz老师PPT)
RR型:单向左旋平衡处理
LL型:单向右旋平衡处理
RL型:双向旋转,先右后左
LR型:双向旋转,先左后右
一个简单例子如下:
2. AVL树最坏情况
即求问带有N个节点的AVL树的最大高度是多少?
Fh
:高度为h的AVL树
|Fh|
:该AVL树的节点数
则为了使用最少的节点得到最高的树,则可以使每个节点的平衡因子都为-1或1,得到Fibonacci树:
其中 |F0|=1 , |F1|=2
通过计算Fibonacci数得到高度为
即最稀疏的带有n个节点的AVL树的高度为 1.44lgn 。
3. 伸展树(Splay tree)
伸展树:使得最近被访问或者频繁被访问的记录放到离根节点更近的地方。
在每一次插入或者检索节点时,都会将检索到的节点/插入的节点作为被修改的树的根节点。splay操作不单是把访问的记录搬移到了树根,而且还把查找路径上的每个节点的深度都大致减掉了一半。伸展树的旋转方式与AVL树相似,它的优势在于不需要记录用于平衡树的冗余信息。具体实现及分析可以参考[2]。