【数据结构Note5】- 树和二叉树(知识点超细大全-涵盖常见算法 排序二叉树 线索二叉树 平衡二叉树 哈夫曼树)

文章目录

5.1 树和二叉树引入

5.1.1 树的概念

树是一种由n个结点构成的非线性的数据结构。逻辑结构类似倒挂的树。除根节点没有前趋,叶子结点没有后继。其余结点有且仅有一个前趋和多个后继。

n等于0为空树,而后继结点个数不超过2的树就引申为二叉树(二叉树和树是两个大类,后面会提)

**每一棵树都能看成根节点加子树!**所以树的定义包括算法大多都是递归定义

树用在操作系统中表示文件目录的组织结构,用在编译系统中表示源程序的语法结构,用在数据库系统中组织信息,可以说树的应用很大!

5.1.2 树的表示

image-20221107150602591
  1. 树的链式表示

  2. 树的嵌套集合表示:外圈即为根结点–A,圈内即为子树–B、C、D。(子树内部也有根)

  3. 树的广义表表示:由子树森林组成的表的名字写在表的左边

  4. 树的凹入表示法

5.1.3 树中基本术语

image-20220731233030653

节点:树种独立单位,包含数据元素和指向其子树的分支

节点的度:节点拥有子树的个数称为节点的度。如上图:A的度为6

树的度:树内各结点的度的最大值 如上图:树的度为6

叶节点或终端节点:度为0的节点称为叶节点。 如上图:B、C、H、I…等节点为叶节点

分支节点或非终端节点:度不为0的节点; 如上图:D、E、F、G…等节点为分支节点

双亲和孩子:结点的子树的根称为该结点的孩子,相应的,该结点称为孩子的双亲。

兄弟:同一个双亲的孩子之间互称为兄弟节点; 如上图:B、C是兄弟节点

祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先

子孙:以某节点为根的子树中的任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙 森林:由m(m>0)棵互不相交的树的集合称为森林;

节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;

树的高度或深度:树中节点的最大层次; 如上图:树的高度为4 (还有一种说法认为第一层是零)

堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点

有序树和无序树:如果将树中结点的各子树子树看成从左至右是有次序的(不能互换),则称该树为,否则为无序树。有序树中最左边的子树称为第一个孩子,最右边的树称为最后一个孩子

森林:m棵互不相交的树的集合

5.2 二叉树

5.2.1 概念

节点的度不超过2的树称为二叉树。

树和二叉树的区别:二叉树是有序树,任意子树都分左右,分别为左子树和右子树

注意:对于任意的二叉树都是由以下几种情况复合而成的

image-20220801181328696

5.2.2 二叉树的性质

  1. 二叉树的第i层上最多有**2i-1**个结点。(递归证明)

  2. n个结点的二叉链表中由n+1个空指针域(2n-(n-1))

  3. 深度为h的二叉树的最大结点数是2h-1个。(等比求和)

  4. 对任何一棵二叉树, 如果度为0其叶结点个数为 n0, 度为2的分支结点个数为 n2 ,则有 n0 = n2 +1。 也就是度为零的节点数比度为二的节点数多一个。

    证明:设度为012的结点个数分别为n0、n1、n2。分别从上向下和从下向上看:结点间树枝个数=n0+n1+n2-1(根节点向上没有树枝)(从下向上)

    结点间树枝个数=n1+2*n2(根节点向上没有树枝)(从上向下)

    联立消去n1得:n0 = n2 +1

  5. 性质4具有n个节点的完全二叉树的深度为⌈log2(n+1)⌉或==⌊log2n⌋+1==。(⌈ x ⌉表示不小于x的最小整数,⌊ x ⌋表示不大于x的最大整数)
    image-20221121192105640

  6. 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= log2n+1

  7. (完全二叉树双亲结点和孩子结点的关系)对于具有n个结点的完全二叉树,按照层序编号(从1到[log2n]+ 1层)每层从左到右,则对于序号为i的结点有:

    • 如果i=1,则节点是二叉树的根,无双亲;如果i>1,则其双亲PARENT(i)是节点[i/2]。
    • 如果2i>n,则节点i无左孩子(节点i为叶子节点);否则其左孩子LCHILD(i)是节点2i
    • 如果2i+1>n,则节点i无右孩子;否则其右孩子RCHILD(i)是节点2i+1。(递归证明,假设部分成立,推出其余成立,期间有用到分类讨论把握二叉树最多两分支的特点)
    image-20221107164831868

5.2.3 特殊的二叉树

  1. 满二叉树:每一个层的节点数都达到最大值得二叉树就称为满二叉树。如果一个满二叉树的层数为K,且结点总数是2k-1(等比求和或用二进制来理解20+21+22+…+2h-1=2h-1=N,h=log2N+1)则它就是满二叉树。

    满二叉树特点:每一层结点树均为最大值

    对满二叉树结点进行连续编号(从左到右,从上到下)

  2. 完全二叉树:二叉树每一个节点都与深度为K的满二叉树中编号从1至n的节点一一对应时,则称该二叉树之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。(前k-1层都是满的,最后一层不满,但是最后一层从左向右都是连续的。完全二叉树度为一的接结点数只有可能是0或1)

    完全二叉树特点:叶子节点只可能在层次最大的两层上出现。其次对于任意节点左分支最大层次必定比右分支最大层次大1或相等。

image-20220805161054024

5.2.4 二叉树的顺序存储

顺序结构存储就是使用数组来存储按编号顺序存储

所以对于完全二叉树,直接从根起按层序存储即可,依次自上向下自左向右存储结点元素,将完全二叉树中编号为i的结点存储在数组中下标为i-1的分量中。而对于一般的二叉树,由于也是按照编号存储,二叉树的顺序存储中可能会出现大量未用分量,十分浪费空间所以对于一般二叉树,不适合用顺序存储,

由此可见二叉树的顺序存储只适合完全二叉树的存储。对于一般二叉树,更适合二叉树的链式存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。

image-20220826223454311

接下来着重讲解二叉树的链式存储包括算法实现,对于二叉树的顺序存储,算法思路也是一致的。二叉树链式存储使用指针访问左右子树,二叉树的顺序存储使用二叉树性质(完全二叉树中根节点和双亲结点,左右子树根节点的编号存在等式!)而对于数组值为特定的值,如0,则视为空。

5.2.5 二叉树的链式存储

二叉树由根节点和左右子树构成(如a图)。所以二叉树的结点由数据域,左右指针域,三个部分构成(如图b)。为了便于寻找结点的双亲,我们可以增加一个指向双亲结点的指针域。(如图c)

image-20221107175738774

利用这两种结点,所得到的二叉树的存储结构分别称为二叉链表和三叉链表

image-20221107175912725

结点定义如下:

typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
	struct BinTreeNode* _pLeft; // 指向当前节点左孩子
	struct BinTreeNode* _pRight; // 指向当前节点右孩子
	BTDataType _data; // 当前节点值域
}

// 三叉链
struct BinaryTreeNode
{
    struct BinTreeNode* _pParent; // 指向当前节点的双亲
    struct BinTreeNode* _pLeft; // 指向当前节点左孩子
    struct BinTreeNode* _pRight; // 指向当前节点右孩子
    BTDataType _data; // 当前节点值域
}

5.2.6 二叉树的深度优先遍历(递归)

这里考虑的是二叉链,不考虑三叉链。分别用LDR表示二叉树的根节点,左子树,右子树。我们就有A33=6种种遍历顺序,若规定先左后右,就只有三种:DLR、LDR、LRD,分别对应先序遍历、中序遍历、后续遍历。

分而治之作为二叉树核心思想,在二叉树的算法种其至关重要的作用。因为二叉树是由根节点和左右子树构成,而左右子树又由根结点和子树的左右子树构成,所以二叉树的问题往往可以转化为左右子树的问题,越分越小直至为空。而这个分而治之的过程就是递归。(递归的过程其实就是树的形!在栈那章有说)

二叉树遍历用递归,将输出二叉树转化为输出左右子树(分治);二叉树求最大深度用递归,二叉树的最大深度转化为左子树和右子树深度较大者加一(分治);二叉树的结点计数、叶节点计数都用的递归。

分而治之(递归)实现先中后序遍历:

  1. 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。

    //先序,输出语句的位置决定了先中后序
    void prevOrder(BinaryTreeNode* root)
    {
    	if (root == NULL)
    	{
    		cout << "NULL->";
    		return;
    	}
    	cout << root->data << "->";
    	prevOrder(root->left);
    	prevOrder(root->right);
    }
    

    先序遍历的详细图解:

    image-20220824223634061

    二叉树的遍历用的是函数递归,函数递归底层具体实现就是树的结构!

    前序遍历就是先访问根,再访问左子树,再访问右子树。将左右子树视为新的二叉树递归,直到左右子树为空说明到头了return。具体如下:

    image-20220824225920679

    接着改变输出的节点数据的位置就衍生出了前中后序–

  2. 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中。

    //中序,输出语句的位置决定了先中后序
    void inOrder(BinaryTreeNode* root)
    {
    	if (root == NULL)
    	{
    		cout << "NULL->";
    		return;
    	}
    	inOrder(root->left);
    	cout << root->data << "->";
    	inOrder(root->right);
    }
    
  3. 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。

    //后序,输出语句的位置决定了先中后序
    void postOrder(BinaryTreeNode* root)
    {
    	if (root == NULL)
    	{
    		cout << "NULL->";
    		return;
    	}
    	postOrder(root->left);
    	postOrder(root->right);
    	cout << root->data << "->";
    }
    

可以看出遍历的框架是一样的:根节点空?就return。根节点非空就访问左右子树。而输出语句的位置决定这个遍历时先序还是中序还是后续。

将遍历中的输出语句换成其他操作,就能衍生出非常多其他功能的算法!如根据字符串递归建立链表

可以根据二叉树先序和中序,后续和中序遍历的结果还原二叉树,原理就是分治。

这里插一句·

5.2.7 二叉树的遍历(非递归)

递归在系统中本质就是系统栈的使用,所以递归能遍历二叉树,那么就能使用栈堆二叉树进行非递归遍历。

算法思路:

  1. 初始化空战,指针p指向根节点H

  2. 申请一个结点空间用来存放栈顶弹出去的元素

  3. 当p非空或栈非空,循环执行以下操作:

    如果p非空,则p进栈,p指向该节点的左孩子

    如果p为空,则弹出栈顶元素并访问根节点,将p指向该节点的有孩子

  4. 先序非递归遍历:

    指针指向根节点,建立栈。循环遍历:

    p非空则入栈,同时p指向左子树

    p空则出栈顶元素,p指向栈顶的右子树

    先序遍历就是在入栈的时候输出

    //前序遍历
    void PreOrderTraverse(BTNode* root){
        
    	if (root == NULL)
    		return;
    	BTNode* p = root;
    	stack<BTNode*> s;
    	while (!s.empty() || p)
    	{
    		if (p)
    		{
    			cout << setw(4) << p->data;
    			s.push(p);
    			p = p->lchild;
    		}
    		else
    		{
    			p = s.top();
    			s.pop();
    			p = p->rchild;
    		}
    	}
    	cout << endl;
    }
    
  5. 中序非递归遍历

    p非空则入栈,同时p指向左子树

    p空则出栈顶元素,p指向栈顶的右子树

    中序遍历就是在栈顶出元素的时候输出

    //非递归遍历
    void InOrderTraverse(BTreeNode* root){
        
        if (root == NULL)
    		return;
    	stack<BTreeNode*> s;
    	BTreeNode* temp;
    	BTreeNode* p = root;
    	while (p || !s.empty()){
    		if (p) {
    			s.push(p);
    			p = p->left;
    		}
    		else {
    			temp = s.top();
    			s.pop();
    			cout << temp->data << "->";
    			p = temp->right;
    		}
    	}
    }
    
  6. 后续非递归遍历

    后序遍历的难点在于:需要判断上次访问的节点是位于左子树,还是右子树。若是位于左子树,则需跳过根节点,先进入右子树,再回头访问根节点;若是位于右子树,则直接访问根节点。这里用到一个指针pLastVisit记录上次访问过的结点,节点能被访问的前提就是:无右子树或右子树已被访问过

    //后续非递归遍历
    void PostOrderTraverse(BTNode* root)
    {
    	if (root == NULL)
    		return;
    
    	BTNode* pcur = root;
    	BTNode* pLastVisit = NULL;
    	//pCur:当前访问节点,pLastVisit:上次访问节点
    	stack<BTNode*>s;
    
    	//先把pCur移动到左子树最下边
    	while (pcur) {
    		s.push(pcur);
    		pcur = pcur->left;
    	}
    
    	while (!s.empty()) {
    
    		//走到这里,pCur都是空,并已经遍历到左子树底端
    		BTNode* pcur = s.top();
    		s.pop();
    
    		//一个根节点被访问的前提是:无右子树或右子树已被访问过
    		if (pcur->right == NULL || pLastVisit == pcur->right) {
    			cout << pcur->data << "->";
    			pLastVisit = pcur;
    		}
    		else {
    			//走到这根结点不配出栈,重新入栈
    			s.push(pcur);
    
    			//进入右子树,且可肯定右子树一定不为空
    			pcur = pcur->right;
    
    			//访问一个新结点,就遍历到左子树左下底
    			while(pcur) {
    				s.push(pcur);
    				pcur=pcur->left;
    			}
    		}
    	}
    }
    

5.2.8 二叉树的广度优先遍历(层序遍历)

所谓层序遍历,就是自上而下,自左向右逐层访问的过程

image-20220826223635745

层序遍历核心思路就是上一层带下一层,调用队列预先写好的函数辅助实现层序遍历。具体来说就是将结点自上到下逐层放入队列中,每次队列出一个节点,直到最后队列为空,遍历完成。代码如下:

void LevelOrder(BinaryTreeNode* root)
{
	Queue BTree;//层序遍历的队列
	QueueInit(&BTree);//队列初始化
	if (root)QueuePush(&BTree,root);//入队
	while (!QueueEmpty(&BTree))//队列空,循环终止
	{
		BinaryTreeNode* temp = QueueFront(&BTree);
		QueuePop(&BTree);
		cout << temp->data << " -> ";
		if (temp->left)QueuePush(&BTree, temp->left);//上一层带下一层
		if (temp->right)QueuePush(&BTree, temp->right);//上一层带下一层
	}
	cout << endl;
	QueueDestory(&BTree);//销毁队列
}

5.2.9 输出二叉树中从每个叶子结点到根节点的路径

该算法是基于二叉树的递归遍历算法扩展实现。(具体实现代码在本文最下方,这里带读者一步步理解算法实现思路)

首先二叉树递归遍历在底层会用到一个栈,记录当前状态。我们的灵感便是来自于此。如果我们在递归函数,传入一个栈,(不要传引用!)每次递归,若当前结点非空那么就将该节点入栈,那么每一层递归都会对应一个栈,这个栈表示当前结点到根结点的路径。如下代码:

void printLeaf(BinaryTreeNode* root,stack<char>path) {
	if (root == NULL)return;
    
    path.push(root->data);//非空就入栈
    printLeaf(root->left, path);
    printLeaf(root->right, path);
}

可以发现每一次递归都将当前栈传给下一层递归(传的是拷贝),所以每一层递归都记录这当前结点的路径,而这正是我们需要的!所以在递归中加入判断,如果当前接结点时叶子结点,就输出整一个栈!而输出整一个栈需要一个函数封装,具体代码如下:

//输出栈中元素
void printStack(stack<char>path) {
	while (!path.empty()) {
		char c = path.top();
		cout << c << "-";
		path.pop();
	}
	cout << endl;
}
//递归主体
void printLeaf(BinaryTreeNode* root, stack<char>path) {
	if (root == NULL)return;

	path.push(root->data);//非空就入栈

	if (root->left == NULL && root->right == NULL)printStack(path);//若为叶子结点就输出栈

	printLeaf(root->left, path);
	printLeaf(root->right, path);
}

5.2.10 二叉树的构建(根据递归序列)

  1. 先序序列构建二叉树

    //根据先序遍历序列创建二叉树
    //根据数值和传入指针构建结点
    void newNode(BinaryTreeNode*& newp,char data) {
    	newp = (BinaryTreeNode*)malloc(sizeof(BinaryTreeNode));
    	newp->data = data;
    	newp->left = NULL;
    	newp->right = NULL;
    }
    
    //构建二叉树,传进来的字符串:AB^DF^^^CEG^^H^^^,index初始值为0
    void createTree(BinaryTreeNode*& root,string prevStr,int& index) {
    	if (prevStr[index] != '^'){
    		newNode(root, prevStr[index]);
    		index++;
    		createTree(root->left, prevStr, index);
    		createTree(root->right, prevStr, index);
    	}
    	else {
    		index++;
    		return;
    	}
    }
    
  2. 后续序列构建二叉树

    思想将后序序列倒过来,就是根-右节点-左结点,先构建右子树,再构建左子树,string下标也是倒着来

    //根据数值和传入指针构建结点
    void newNode(BinaryTreeNode*& newp,char data) {
    	newp = (BinaryTreeNode*)malloc(sizeof(BinaryTreeNode));
    	newp->data = data;
    	newp->left = NULL;
    	newp->right = NULL;
    }
    //构建二叉树,传进来的字符串:AB^DF^^^CEG^^H^^^,index初始值为postStr最大下标
    void createTree2(BinaryTreeNode*& root, string postStr, int& index) {
    	if (postStr[index] != '^') {
    		newNode(root, postStr[index]);
    		index--;
    		createTree2(root->right, postStr, index);//先构建右子树
    		createTree2(root->left, postStr, index);
    	}
    	else {
    		index--;
    		return;
    	}
    }
    
    image-20221119150718227

5.2.11 二叉树其他常见算法

  1. 复制二叉树

    //复制二叉树
    void copyBTree(BTNode* root, BTNode*& demo) {
    	if (root == NULL) {
    		demo = NULL;
    		return;
    	}
    	else {
    		demo = new BTNode;
    		demo->data= root->data;
    
    		copyBTree(root->left,demo->left);//递归复制左二叉树
    		copyBTree(root->right,demo->right);//递归复制右二叉树
    	}
    }
    
  2. 计算二叉树深度

    //二叉树的深度,分而治之。二叉树的最大深度等于左子树和右子树的最大深度加一。方法就是递归
    int BTreeLength(BTNode* root) {
    	if (!root)return 0;
    	//else return BTreeLength(root->left) > BTreeLength(root->right) ? BTreeLength(root->left)+1 : BTreeLength(root->right)+1;//这种写法算了两遍的深度!
    	else 
    	{
    		int leftLength = BTreeLength(root->left);
    		int rightLength = BTreeLength(root->right);
    		return leftLength > rightLength ? leftLength + 1 : rightLength + 1;
    	}
    }
    
  3. 二叉树节点数

    //方法一:遍历
    void TreeSize(BinaryTreeNode* root,int* num)
        //参数用引用或地址!传值参数在递归中数值不变
    {
        if (root != NULL)(*num)++;
        else return;
        TreeSize(root->left,num);
        TreeSize(root->right,num);
    }
    
    //方法二分治,节点数等于根节点加上左右子树的节点数
    int BinaryTreeSize(BinaryTreeNode* root)//分治-
    {
        return root == NULL ? 0: BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
    }
    
  4. 二叉树叶子结点数

    //二叉树的叶子结点等于左子树的叶子结点加上右子树叶子节点(分而治之)
    int TreeLeafSize(BinaryTreeNode* root)
    {
    	//转化为左子树的叶子加右边的叶子,不断分割,有叶子返回1,没叶子返回0
    	if (root == NULL)return 0;
    	if (root->left == NULL && root->right == NULL)return 1;
    	return TreeLeafSize(root->left) + TreeLeafSize(root->right);
    }
    
  5. 二叉树销毁

    //思想:将每一个结点记录压入栈中,从后往前销毁,后续销毁
    void DestoryBinaryTree(BinaryTreeNode* root)
    {
    	if (root == NULL)return;
    	DestoryBinaryTree(root->left);
    	DestoryBinaryTree(root->right);
    	free(root);
    	root == NULL;//实际不起作用,传的是拷贝
    }
    //解决方法——使用引用.cpp
    /*void DestoryBinaryTree(BinaryTreeNode*& root)
    {
    	if (root == NULL)return;
    	DestoryBinaryTree(root->left);
    	DestoryBinaryTree(root->right);
    	free(root);
    	root == NULL;
    }*/
    
  6. 递归创建二叉树

    //根据输入创建二叉树
    void CreateBiTree(BiTree* T) {
        int num;
        cin>>num;
        //如果输入的值为 0,表示无此结点
        if (num == 0) {
            *T = NULL;
        }
        else
        {
            //创建新结点
            *T = (BiTree)malloc(sizeof(BiTNode));
            (*T)->data = num;
            CreateBiTree(&((*T)->lchild));//创建该结点的左孩子
            CreateBiTree(&((*T)->rchild));//创建该结点的右孩子
        }
    }
    

5.3线索二叉树

5.3.1 线索二叉树基本概念:

  • 如果想直接看构造线索二叉树的友友们,直接跳到第二部分

在谈线索二叉树前,我们先谈谈什么时线索二叉树

所谓线索二叉树就是将二叉链表中的空指针改为指向前趋和后继的线索。

对于n个结点的指针,就相应的有n+1的空指针域,线索二叉树就是将这些空指针域利用起来!

我们知道二叉树遍历次序有先序遍历,中序遍历,后序遍历,这三种遍历次序对应的输出结果分别对应先序序列,中序序列,后序序列。而线索二叉树中的前趋和后继就是指的序列中位置的前趋和后继!举例:对于如下二叉树

image-20221109150827232

其中序输出序列为:CBEGDFA,所以对于E结点其前趋为B,后继为G。

理解的前趋后继,下面谈谈如何将二叉树修改成线索二叉树!

如果结点左指针为空,就将该指针指向结点在序列中的前驱。如果结点右指针为空,就将该指针指向结点在序列中的后继。

为了弄清指针指向的时左右子树还是前驱后继,我们在结点中增加两个标志域LTagRLag

image-20221109121553569
  • 这种结点结构的构成的二叉树叫线索二叉树
  • 对应的存储结构叫做线索链表
  • 结点中指向前驱后继的指针叫做线索。

了解完线索二叉树的基本概念,来看个例子,看看其指针指向:如图(a)所示为中序线索二叉树,与其对应的中序线索链表如图(b)所示。

其中实线为指针(指向左、右子树),虚线为线索(指向前驱和后继)。

image-20221109123141903

可以看出:线索二叉树仿照线性表的存储结构

在二叉树的线索链表上也添加一个头节点,并令其lchild域的指针指向二叉树的根节点,令其rchild域的指针指向中序遍历时访问的最后一个节点;

同时,令二叉树中序序列中第一个节点的lchild域指针和最后一个节点的rchild域指针均指向头节点。(中序序列第一个结点就是二叉树最左边的结点和最右边的结点)

这好比为二叉树建立了一个双向线索链表,既可从第一个节点起顺后继进行遍历,也可从最后一个节点起顺前驱进行遍历。

5.3.2 构造线索二叉树

构造线索二叉树的过程就是将二叉链表中的空指针改为指向前趋和后继的线索的过程。也就是说二叉树线索化就是修改空指针的过程。

由于前趋和后继只有在遍历的时候才能获得,所以按照不同的遍历次序对二叉树线索化,可以得到分别得到先序线索二叉树,中序线索二叉树,后续线索二叉树。

在讲解构造线索二叉树前,相信二叉树的遍历大家都很清楚了,访问结点的先后顺序时不变的(框架不变)。所谓先中后遍历二叉树就是将输出语句放在不同的位置,如下:

void prevOrder(BinaryTreeNode* root)
{
	if (root == NULL)
	{
		cout << "NULL->";
		return;
	}
	//cout << root->data << "->";//放在此处就是先序遍历
	prevOrder(root->left);
	//cout << root->data << "->";//放在此处就是中序遍历
	prevOrder(root->right);
	//cout << root->data << "->";//放在此处就是后序遍历
}

这里先对构建线索二叉树进行总结,友友们没看懂没关系,可以向下看,看完三种线索二叉树的构建再回来读这部分一定会有不一样的收获!

将修改空指针的操作和记录前趋节点的操作放在放在二叉树遍历的不同位置,就衍生出先序线索二叉树,中序线索二叉树,后续线索二叉树。

为了记下遍历过程中访问结点的先后关系,便于当前节点的线索化,左指针指向前前趋,右指针指向后继。设置一个指针pre始终指向刚刚访问过的结点,而p指针指向当前结点

这里友友们就右疑问了?当前结点要改变左指针,也要改变右指针。不应该设置三个指针,prev指向前趋,next指向后继,p指向当前节点嘛?答案是不用的。我们每次修改空指针,都改变当前节点的左指针,改变前趋(也就是prev)的右指针。这样再遍历过后除了,除了序列的最后一个节点的后继没有修改外,其余节点均被完全线索化了。来看看个图就明白了:

image-20221109161606056

记住:当前节点的左指针,改变前趋(也就是prev)的右指针

对于当前节点C而言,修改左指针,遍历一次后C成为前趋,修改前趋的右指针(C的后继),这样C的左右指针都做了修改!

5.3.2.1 中序线索二叉树的构建

在修改空指针前我们需要记下当前结点的前驱后继!所以设置一个指针prev始终指向刚刚访问过的结点,而p指针指向当前结点,在构造中序线索二叉树前,我们先理解pre指针和p指针时如何记录当前结点和前趋的,这样能更有利于我们理解中序二叉树的构建:

//将以p为根的二叉树线索化的部分代码
void InThreading(BTNode* p,BTNode*& prev) {
	//if (!root)return;//可以写但很多余

	//prev是全局定义好的变量,用于指向刚刚访问过的结点
	if (p) {
		InThreading(p->left,prev);
		prev = p;
		InThreading(p->right,prev);
	}
}

可以看到,和遍历输出二叉链表的代码实现很像!p非空?那就递归左子树线索化,记录根结点prev = p;,再右子树线索化!

prev的初始值指向线索二叉树的头节点Thrt,对于中序第一个结点,它的前趋就是二叉搜索树的头节点

上面代码,每一层递归都记录下的当前结点和上一个结点:每一层递归中当前结点为p,当前结点的前趋为prev。下一步接着加入修改空指针的操作:

//将以p为根的二叉树线索化
void InThreading(BTNode* p, BTNode*& prev) {
	//if (!root)return;//可以写但很多余

	//prev是全局定义好的变量,用于指向刚刚访问过的结点
	if (p) {

		InThreading(p->left, prev);

		if (!p->left) {
			p->LTag = 1;//标记p->left为左线索
			p->left = prev;//指向前趋
		}
		else p->LTag = 0;//标记p->left指向左子树

		if (!prev->right) {
			prev->RTag = 1;//标记p->right为右线索
			prev->right = p;//指向后继
		}
		else prev->RTag = 0;//标记p->right指向右子树

		prev = p;

		InThreading(p->right, prev);
	}
}

上面说过,我们每次修改空指针,都改变当前节点的左指针,改变前趋(也就是prev)的右指针。这样再遍历过后除了,除了序列的最后一个节点的后继没有修改外,其余节点均被完全线索化了。所以真正的代码实现我们还需要对序列最后一个节点的后继线索化!,下面是完整的线索化二叉树实现:

//将以p为根的二叉树线索化
void InThreading(BTNode* p, BTNode*& prev) {
	//if (!root)return;//可以写但很多余

	//prev是全局定义好的变量,用于指向刚刚访问过的结点
	if (p) {

		InThreading(p->left, prev);

		if (!p->left) {
			p->LTag = 1;//标记p->left为左线索
			p->left = prev;//指向前趋
		}
		else p->LTag = 0;//标记p->left指向左子树

		if (!prev->right) {
			prev->RTag = 1;//标记p->right为右线索
			prev->right = p;//指向后继
		}
		else prev->RTag = 0;//标记p->right指向右子树

		prev = p;

		InThreading(p->right, prev);
	}
}

//中序遍历二叉树T,并将线索化,Thrt指向头节点(线索二叉树的头节点)
void InOrderThreading(BTNode*& Thrt, BTNode* root) {

	Thrt = new BTNode;//建立头节点
	Thrt->LTag = 0;//头节点有左孩子,若树非空,左孩子为树根。若树空,左孩子指向其自己。
	Thrt->RTag = 1;//头节点右孩子指向序列最后一个节点
	Thrt->right = Thrt;//右孩子初始化指向自己

	if (!root)Thrt->left = Thrt;
	else {
		Thrt->left = root;
		BTNode* prev = Thrt;//前趋节点,初始化指向头节点
		InThreading(root, prev);

		prev->right = Thrt;//递归出来prev指向序列最后一个节点,对最后一个节点序列化
		prev->RTag = 1;

		Thrt->right = prev;
	}
}

理解完了中序遍历,相信大家也能大概懂得了先序后续线索二叉树的方法咯,就是改变记录前趋的位置,修改空指针的位置

5.3.2.2 先序线索二叉树的构建

还是一样,在修改空指针前我们需要记下当前结点的前驱后继!所以设置一个指针prev始终指向刚刚访问过的结点,而p指针指向当前结点,在构造先序线索二叉树前,我们先理解pre指针和p指针时如何记录当前结点和前趋的,这样能更有利于我们理解先二叉树的构建:

//将以p为根的二叉树线索化
void InThreading(BTNode* p, BTNode*& prev) {
	//if (!root)return;//可以写但很多余

	//prev是全局定义好的变量,用于指向刚刚访问过的结点
	if (p) {
		prev = p;

		InThreading(pleft, prev);
		InThreading(p->right, prev);
	}
}

可以发现和中序相比就是将prev=p移动的了位置

下面就是修改空指针了,此处有点小修改,因为先序线索二叉树可能需要再访问左右子树前,修改左右指针,这直接影响我们原本遍历二叉树所以我们,先将左指针存储起来,再修改左指针:

//将以p为根的二叉树线索化
void InThreading(BTNode* p, BTNode*& prev) {
	//if (!root)return;//可以写但很多余

	//prev是全局定义好的变量,用于指向刚刚访问过的结点
	if (p) {
		BTNode* pleft = p->left;//先存储左指针,防止下面修改左指针映像二叉树遍历!

		if (!p->left) {
			p->LTag = 1;//标记p->left为左线索
			p->left = prev;//指向前趋
		}
		else p->LTag = 0;//标记p->left指向左子树

		if (!prev->right) {
			prev->RTag = 1;//标记p->right为右线索
			prev->right = p;//指向后继
		}
		else prev->RTag = 0;//标记p->right指向右子树

		prev = p;

		InThreading(pleft, prev);
		InThreading(p->right, prev);
	}
}

和中序一样需要对序列最后一个节点序列化,完整实现如下:

//将以p为根的二叉树线索化
void InThreading(BTNode* p, BTNode*& prev) {
	//if (!root)return;//可以写但很多余

	//prev是全局定义好的变量,用于指向刚刚访问过的结点
	if (p) {
		BTNode* pleft = p->left;//用于临时存储当前节点的left,以放NULL修改后死循环!

		if (!p->left) {
			p->LTag = 1;//标记p->left为左线索
			p->left = prev;//指向前趋
		}
		else p->LTag = 0;//标记p->left指向左子树

		if (!prev->right) {
			prev->RTag = 1;//标记p->right为右线索
			prev->right = p;//指向后继
		}
		else prev->RTag = 0;//标记p->right指向右子树

		prev = p;

		InThreading(pleft, prev);
		InThreading(p->right, prev);
	}
}

//中序遍历二叉树T,并将线索化,Thrt指向头节点(线索二叉树的头节点)
void InOrderThreading(BTNode*& Thrt, BTNode* root) {

	Thrt = new BTNode;//建立头节点
	Thrt->LTag = 0;//头节点有左孩子,若树非空,左孩子为树根。若树空,左孩子指向其自己。
	Thrt->RTag = 1;//头节点右孩子指向序列最后一个节点
	Thrt->right = Thrt;//右孩子初始化指向自己

	if (!root)Thrt->left = Thrt;
	else {
		Thrt->left = root;
		BTNode* prev = Thrt;//前趋节点,初始化指向头节点
		InThreading(root, prev);

		prev->right = Thrt;//递归出来prev指向序列最后一个节点,对最后一个节点序列化
		prev->RTag = 1;

		Thrt->right = prev;
	}
}

5.3.2.3 后续线索二叉树的构建

经过上面两个讲解,大家坑定也知道了,后续线索二叉树就是将修改空指针和记录位置下移,但不同的是后续序列最后一个节点是不一定要序列化的因此还要判断,再序列化,代码实现:

//将以p为根的二叉树线索化
void InThreading(BTNode* p, BTNode*& prev) {
	//if (!root)return;//可以写但很多余

	//prev是全局定义好的变量,用于指向刚刚访问过的结点
	if (p) {

		InThreading(p->left, prev);

		InThreading(p->right, prev);

		if (!p->left) {
			p->LTag = 1;//标记p->left为左线索
			p->left = prev;//指向前趋
		}
		else p->LTag = 0;//标记p->left指向左子树

		if (!prev->right) {
			prev->RTag = 1;//标记p->right为右线索
			prev->right = p;//指向后继
		}
		else prev->RTag = 0;//标记p->right指向右子树

		prev = p;
	}
}

//中序遍历二叉树T,并将线索化,Thrt指向头节点(线索二叉树的头节点)
void InOrderThreading(BTNode*& Thrt, BTNode* root) {

	Thrt = new BTNode;//建立头节点
	Thrt->LTag = 0;//头节点有左孩子,若树非空,左孩子为树根。若树空,左孩子指向其自己。
	Thrt->RTag = 1;//头节点右孩子指向序列最后一个节点
	Thrt->right = Thrt;//右孩子初始化指向自己

	if (!root)Thrt->left = Thrt;
	else {
		Thrt->left = root;
		BTNode* prev = Thrt;//前趋节点,初始化指向头节点
		InThreading(root, prev);

		if (!prev->right) {
			prev->right = Thrt;//递归出来prev指向序列最后一个节点,对最后一个节点序列化
			prev->RTag = 1;
		}
        else {
			prev->RTag = 0;
		}

		Thrt->right = prev;
	}
}

5.3.3 遍历线索二叉树

5.3.3.1 找指定节点的前驱后继

由于有了节点的前驱和后继信息,线索二叉树的遍历和在指定次序下查找节点的前驱和后继算法都变得简单了。因此,若需经常查找节点在所遍历线性序列中的前驱和后继,则采用线索链表作为存储结构。

下面分3种情况讨论在线索二叉树中如何查找节点的前驱和后继。

  1. 在中序线索二叉树中查找
  • 查找p指针所指节点的前驱:
    p- > LTag为1,则p的左链指示其前驱;

    p->LTag为0,则说明p有左子树,节点的前驱是遍历左子树时最后访问的一个节点(左子树中最右下的节点)。

  • 查找p指针所指节点的后继:
    p->RTag为1,则p的右链指示其后继

    p->RTag为0,则说明p有右子树。根据中序遍历的规律可知,节点的后继应是遍历其右子树时访问的第一个节点,即右子树中最左下的节点。

  1. 在先序线索二叉树中查找
  • 查找p指针所指节点的前驱:
    p- > LTag为1,则p的左链指示其前驱;

    p- > LTag为0,则说明p有左子树。此时p的前驱有两种情况:若*p是其双亲的左孩则其前驱为其双亲节点;否则应是其双亲的左子树上先序遍历最后访问到的节点。

  • 查找p指针所指节点的后继:
    p- >RTag为1,则p的右链指示其后继;

    p- >RTag为0,则说明p有右子树。按先序遍历的规则可知,*p的后继必为其左子树根(若存在)或右子树根。

  1. 在后序线索二叉树中查找
  • 查找p指针所指节点的前驱:
    p->LTag为1,则p的左链指示其前驱;

    p->LTag为0,当p- > RTag也为0时,则p的右链指示其前驱;若p- >LTag为0,而p->RTag为1时,则p的左链指示其前驱。

  • 查找p指针所指节点的后继情况比较复杂,分以下情况讨论:
    *p是二叉树的根,则其后继为空;
    *p是其双亲的右孩子,则其后继为双亲节点;
    *p是其双亲的左孩子,且*p没有右兄弟,则其后继为双亲节点;
    *p是其双亲的左孩子,且*p有右兄弟,则其后继为双亲的右子树上按后序遍历列出的第一个节点(右子树中最左下的叶节点)。

5.3.3.2 遍历二叉树

你当然可以直接递归遍历,根据标记判断当前节点是否有左右子树。但我们知道递归遍历二叉树时间复杂度和空间复杂度都是O(N)。但这样丝毫没有发挥线索二叉树的优势,线索二叉树的非递归遍历才是一大亮点!

这里以中序线索二叉树为例,在理解中序线索二叉树非递归遍历前,先要清楚线索二叉树是如何非递归遍历的算法思路:

  1. 指针p指向根节点。(也可以是子树的根)

  2. p为非空树或遍历未结束时,循环执行以下操作:

    • 沿左孩子向下,到达最左下节点*p,它是该子树中序遍历的第一个节点;访问*p;
    • 沿右线索反复查找当前节点*p的后继节点并访问后继节点,直至右
      线索为0或者遍历结束;
    • 转向p的右子树。

下面是完整的线索二叉树,非递归遍历顺序,为了便于理解,这里简述其中过程,

  1. 开始p指针指向A
  2. 然后循环找到当前子树最左下角的节点M,并输出!
  3. M通过右索引访问输出N(此时循环结束,N没有右索引)
  4. 循环结束就将指针指向该节点的右孩子(也就是新的根节点了)

下图红线是中序线索二叉树遍历输出过程,每一个红线下方的节点都是当前子树的最左下节点。把握住子树的最左下节是中序遍历的头,右线索是直接后继

image-20221109193911617

下面是代码实现:

void InOrderTraverse(BTNode* T)
{
	BTNode* p = T->left;//线索二叉树头节点的左指针指向实际二叉树的头
	while (p != T)
	{
        //1. 循环找到当前子树的左下角,开始以四字节单元访问!
		while (p->LTag == 0)
			p = p->left;
		cout << p->data;
        
        //2. 通过右索引访问直接后继
		while (p->RTag == 1 && p->right != T)
		{
			p = p->right;
			cout << p->data;
		}
        
        //3. 四字节单元访问完,指向右孩子,寻找下一个四字节!
		p = p->right;
	}

}

5.4 树和森林

树的基本概念在文章顶部已有介绍

5.4.1 双亲表示法(顺序存储)

每个节点中保存指向双亲的“指针”。并使用一片连续的物理空间存储。其中根节点固定存储再数组0号下标位置,其指向双亲的指针为-1,表示没有双亲。

参考结构体:

#define MAX_TREE_SIZE 20//树中最多的节点数
typedef char TreeDataType;//树中数据类型

struct TreeNode{
	TreeDataType data;
	int parent;//双亲在数组中的位置
};

struct Tree{
	TreeNode nodes[MAX_TREE_SIZE];
	int n;//节点数
};
image-20221113000309166
  • 增加元素只需要在数组末尾加上节点,节点数据与存储元素数据,指针域标识好其双亲的下标位置。所以可以看出双亲表示法中数组元素先后关系并不重要,内部通过双亲链接起整个树。
  • 删除节点,存在删除叶子节点和删除子树的情况,首先通过遍历数组查找目标删除 节点是否有孩子节点,如果有,则需要一起删除。具体如何删除,将数组尾部节点移动到要删除的节点的位置(掩盖),然后数组节点减一。

不同于二叉树,二叉树的顺序存储中每一个节点都是有编号的,节点在数组中存储的位置由编号决定。而树的双亲表示法除了根节点严格要求在数组0号下标,其余节点在数组中位置没有严格要求。

这种存储结构,寻找双亲或者寻找根节点方便,但是求孩子结点很不方便。

5.4.2 孩子表示法(顺序+链式)

**顺序存储树各个节点。结点包含数据域data和指向孩子链表的指针域。**该链表是当前结点的孩子构成的单链表。该单链表中结点的数据域存储的是孩子对应在数组中的下标。

参考结构体定义:

#define MAX_TREE_SIZE 20//树中最多的节点数
typedef char TreeDataType;//树中数据类型

struct Node {
	int index;//存储的是数组中对应的下标
	Node* next;//指向下一个孩子
};//单链表结点

struct TreeNode {
	TreeDataType data;//存储节点的数据
	Node* child;//指向当前节点的孩子链表
};//数组单元

struct Tree {
	TreeNode nodes[MAX_TREE_SIZE];
	int n;//节点在数组中的位置
	int m;//结点数
};
image-20221113001448028
  • 增加元素:数组尾部加上结点,同时找到该节点的双亲,修改孩子链表
  • 删除元素:用数组末尾节点覆盖,并同时修改其双亲的孩子链表

5.4.3 孩子兄弟表示法(链式存储)

孩子兄弟表示法也叫二叉树表示法,二叉链表示法,也可以叫左孩子右兄弟表示法。孩子兄弟表示法可以实现树,二叉树,森林的相互转换。

链表中节点包括三个部分,当前节点的数据域,指向第一个孩子节点和下一个兄弟节点的两个指针域。

参考结构体定义:

typedef int TreeNodeData;
struct TreeNode{
    TreeNodeData data;
    TreeNode* firstchild,*nextsibling;
}
image-20221112151828327

孩子兄弟表示法实现了==树到二叉树的转换==。树每个结点都对应有两个指针域。

使用孩子兄弟表示法存储的树,其根节点的右指针必然为空。若把森林中每棵树根节点视为兄弟,森林中不同的树通过根结点右指针(兄弟)链接在一起,从而实现==森林到二叉树的转换==

image-20221112152904130

想要寻找当前节点的第i个孩子,只需要通过firstchild寻找当前节点的第1个孩子,然后通过nextsibling寻找其兄弟节点即可找到第i个孩子,如果增设双亲指针也能方便的额查找双亲。

查看孩子兄弟表示法存储的树或者森林技巧:斜着看,沿着右指针的节点都是平级关系,都是兄弟。

此外通过孩子兄弟表示法存储的树,形似二叉树,所以可以用二叉树的算法解决树的问题~

5.4.4 树的遍历

先根遍历、后根遍历、层序遍历

5.4.4.1 树的先根遍历

先根遍历:若树非空,先访问根节点,再依次对每个子树进行先根遍历

如何写树的先根遍历结果?按部就班:根 - 子树 - 子树根-----

image-20221113022850386

下面我们将树转化为对应的二叉树(所孩子右兄弟),可以发现树的先根遍历就是与之对应的二叉树的先根遍历!

image-20221112151828327

例如,基于二叉树算法对树的先根遍历得:

  • 先根遍历实现

    void prevOrder(TreeNode* root)
    {
    	if (root == NULL) {
    		return;
    	}
    	cout << root->data<<" ";
    	prevOrder(root->firstchild);
    	prevOrder(root->nextsibling);
    }
    

    其树的先根序列为:RADEBCFGHK

5.4.4.2 树的后根遍历

后根遍历:若树非空,先依次对子树进行后根遍历,最后访问根节点

如何写出树的后根遍历?按部就班:子树-根:

image-20221113023705359

下面我们将树转化为对应的二叉树(所孩子右兄弟),可以发现树的后根遍历就是与之对应的二叉树的中根遍历!

image-20221112151828327
  • 基于二叉树算法对数的后根遍历实现:

    void postOrder(TreeNode* root)
    {
    	if (root == NULL) {
    		return;
    	}
    	postOrder(root->firstchild);
    	cout << root->data<<" ";
    	postOrder(root->nextsibling);
    }
    

    其树的后根序列为:DEABGHKFCR

5.4.4.3 数的层序遍历(广度优先遍历 )

  1. 若树非空,则根节点入队
  2. 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
  3. 重复2直到队列为空
image-20221113024333300

5.4.5 森林的遍历

树去掉根节点,各个子树又组成森林。并且森林的存储就是树的左孩子右兄弟存储引申出来的,所以,森林的遍历和树的遍历类似

森林的先根后根遍历是基于左孩子有兄弟存储的森林下,将森林视作二叉树其根节点和子树的访问次序决定的。因为森林多个树有多个根,可以认为左孩子右兄弟存储下的树的根即为森林的根。所以森林的先根遍历算法和中跟遍历算法就是森林对应二叉树的先根遍历和后根遍历!

5.4.5.1 先根遍历森林

image-20221113031258088

若森林非空,则可按下述规则遍历:

  1. 访问森林中第一棵树的根节点;
  2. 先序遍历第一棵树的根节点的子树森林;
  3. 先序遍历除去第一棵树之后剩余的树构成的森林。

这是书面定义,上面说过,森林的先根后根遍历是基于左孩子有兄弟存储的森林下,将森林视作二叉树其根节点和子树的访问次序决定的。所以森林的先根遍历算法和中跟遍历算法就是森林对应二叉树的先根遍历和后根遍历!

如何写出森林先根遍历序列?

image-20221113031105915

对森林的先根遍历就是依次对各个子树的先根遍历!而对树的先根遍历就是对树对应的二叉树先根遍历

而森林的存储就是先用左孩子有兄弟法存储树,然后将各个树根节点作为右兄弟链接在一起,所以森林存储结构和树并无差别。树的根序遍历就是对应二叉树的先根遍历,所以森林的先根遍历任然是二叉树的先根遍历。

基于二叉树算法对森林先根遍历:

void prevOrder(TreeNode* root)
{
	if (root == NULL) {
		return;
	}
	cout << root->data<<" ";
	prevOrder(root->firstchild);
	prevOrder(root->nextsibling);
}

5.4.5.2 中根遍历森林

若森林非空,则可按下述规则遍历:

  1. 中序遍历森林中第一棵树的根节点的子树森林;
  2. 访问第一棵树的根节点;
  3. 中序遍历除去第一棵树之后剩余的树构成的森林。

这是书面定义,上面说过,森林的先根后根遍历是基于左孩子有兄弟存储的森林下,将森林视作二叉树其根节点和子树的访问次序决定的。所以森林的先根遍历算法和中跟遍历算法就是森林对应二叉树的先根遍历和后根遍历!

理解这句话:对森林的中序遍历,就是对各个子树进行后根遍历,如果使用对应的二叉树存储就是二叉树的中序遍历!

image-20221112152904130

基于二叉树算法对森林中根遍历:

void InOrder(TreeNode* root)
{
	if (root == NULL) {
		return;
	}
	InOrder(root->firstchild);
    cout << root->data<<" ";
	InOrder(root->nextsibling);
}

总结:

image-20221113032736936

对树的先根遍历,就是对树对应二叉树的先根遍历

对数的后根遍历,就是对树对应二叉树的中根遍历

而森林的遍历和对应二叉树遍历一一对应!

5.5 二叉排序树BST

二叉排序树,又称二叉查找树(BST,Binary Search Tree)

二叉排序树是左子树节点值<根节点值<右子树节点值的二叉树

image-20221113132722225

所以对二叉排序树进行中序遍历会得到一个递增的序列(左子树-根-右子树)

5.5.1 二叉排序树的查找

若树非空,目标值与根结点的值比较

  1. 若相等,则查找成功;
  2. 若小于根结点,则在左子树上查找
  3. 若大于根节点在右子树上查找。

查找成功,返回结点指针;查找失败返回NULL,分为递归和非递归两种算法

排序二叉树非递归查找:最坏时间复杂度O(1)-nice

//在二叉排序树中查找值为key 的结点
BSTNode* BST_Search(BSTNode* T, BSTDataType key) {
	BSTNode* cur = T;
	while (cur != NULL) {//指针空则结束循环
		if (key == cur->data) return cur;
		else if (key < cur->data)cur = cur->left;//小于查找左子树
		else cur = cur->right;//大于查找右子树
	}
	return cur;//此时cur就是NULL 
}

排序二叉树递归查找:比根节点大,就到右子树查找。比根节点小,就到左子树查找。排序二叉树的递归查找最坏时间复杂度为O(n),最坏情况就是递归到排序二叉树的最大深度

//在二叉排序树中查找值为key 的结点
BSTNode* BST_Search(BSTNode* root, BSTDataType key) {
	if (!root)return NULL;
	else if (key == root->data)return root;
	else if (key < root->data) return BST_Search(root->left,key);
	else  return BST_Search(root->right, key);
}

5.5.2 二叉排序树的插入

若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树

可以发现插入的位置一定是叶子节点的下方!二叉排序树的插入就是二叉排序树不断向下延申的过程,不会出现中间插入的情况。同一组节点可以形成不同的二叉排序树的结构,但其中序遍历一定都是递增数列。

二叉排序树的插入算法有递归和非递归。

二叉排序树递归插入算法:最坏空间复杂度为O(n)

//插入节点
bool insert(BSTNode*& root, BSTDataType data) {
	if (root == NULL) {
		root = (BSTNode*)malloc(sizeof(BSTNode));
		root->data = data;
		root->left = NULL;
		root->right = NULL;
		return true;
	}
	else if (data == root->data) {
		return false;//树中存在下个相同关键字结点,插入失败 
	}
	else if (data < root->data) {
		insert(root->left, data);
	}
	else {
		insert(root->right, data);
	}
}

二叉排序树非递归插入算法:

//插入节点
bool insert(BSTNode*& root, BSTDataType data) {
	//空树直接填充
	if (!root) {
		root = (BSTNode*)malloc(sizeof(BSTNode));
		root->left = NULL;
		root->right = NULL;
		root->data = data;
		return true;
	}

    //非空二叉排序树需要找到,需要插入位置的根结点!
	BSTNode* cur = root;
	while (true) {
		if (data < cur->data) {
			if (cur->left == NULL) {
				BSTNode* temp = (BSTNode*)malloc(sizeof(BSTNode));
				temp->left = NULL;
				temp->right = NULL;
				temp->data = data;
				cur->left = temp;
				return true;
			}
			else cur = cur->left;
		}
		else if (data > cur->data) {
			if (cur->right == NULL) {
				BSTNode* temp = (BSTNode*)malloc(sizeof(BSTNode));
				temp->left = NULL;
				temp->right = NULL;
				temp->data = data;
				cur->right = temp;
				return true;
			}
			else cur = cur->right;
		}
		else {
			return false;//树中存在相等的结点,插入失败
		}
	}
}

5.5.3 二叉排序树的构造

实际上就是根据数值,不断进行二叉树插入操作的过程。所以这里需要引用上面二叉排序树的插入函数

参考代码如下:

void creatBSTree(BSTNode*& root,int* array,int arrayLength){
    root=NULL;
    for(int i=0;i<arrayLength;i++){
    	insert(root,array[i]);
    }
}

值得注意,不同的序列构建的二叉排序树可能一样,也可能不一样。但这些二叉排序树的中序遍历一定都是一样的。

image-20221113153128573

5.5.4 二叉排序树的删除

先搜索找到目标结点-(前面右谈过二叉排序树的查找函数)

插入的宗旨就是不会破坏二叉排序树的性质(左子树节点值<根节点值<右子树节点值)

  1. 若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。

  2. 若结点z只有左子树或只有右子树,则让z的子树成为z父结点的子树,替代z的位置。

    image-20221113154547625
  3. 若结点z有左、右两棵子树,则令结点z的直接后继(或直接前驱)替代结点z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。

    • 令结点z的直接后继(或直接前驱)替代z结点?实际上就是让右子树中最小的值(直接后继)覆盖结点z或者左子树中最大值(直接前驱)覆盖结点z任然满足二叉排序树的特性。
    • 因为结点z的直接后继或直接前驱,分别是右子树中最左下的元素,和左子树中最右下元素。不可能同时又左右子树,所以就能回归前两种情况了。
    image-20221113160128447

5.5.5 查找效率分析

查找长度――在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度

5.5.5.1 查找成功的平均查找长度ASL (Average Search Length)

每一个结点的查找长度之和除以结点总数=ASL

对于n个节点的二叉树,二叉树的最小高度是⌊log2n⌋+1,最大高度为n,当二叉排序树高度接近于⌊log2n⌋+1,该二叉排序树查找成功的查找效率最高。

5.5.5.2 查找失败的平均查找长度ASL (Average Search Length)

对于查找失败就是指针最后停留在了空指针域,计算停留在每一个空指针域的查找长度之和除以空指针域总数(3 * 7+4 * 2)/9=3.22:

image-20221113170034734

对于n个节点的二叉树,二叉树的最小高度是⌊log2n⌋+1,最大高度为n,当二叉排序树高度接近于⌊log2n⌋+1,该二叉排序树查找失败的查找效率最高。

高度接近⌊log2n⌋+1的二叉排序树查找成功和查找失败的查找效率都是最高的,也就是平衡二叉树

5.6 平衡二叉树

平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树)――树上任一结点的左子树和右子树的高度之差不超过1。(AVL是科学家命名)

结点的平衡因子=左子树高度-右子树高度。平衡二叉树的平衡因子值为0,-1,1。只要任意结点的平衡因子大于1,就不是平衡二叉树。

image-20221116092003538

参考代码:

struct AVLNode{
	int key;
	int balance;
	AVLNode *left,*right;
}AVLNode,*AVLTree;

当二叉排序树达到平衡时,查找效率最高。对于n个节点的排序二叉树其高度最小为⌊log2n⌋+1,所以对应的AVL二叉树的查找效率为log2n,那么二叉排序树插入新节点如何保持平衡?

5.6.1 二叉树的插入

image-20221116093317185

可以看到每插入一个新节点,查找路径上所有结点的平衡因子都可能受到影响。对此我们的策略是调整最小不平衡子树。所谓的最小不平衡子树就是从插入点往回找到第一个不平衡的结点,以该节点构成的子树就是最小不平衡子树。对上述二叉树的调整如下:

image-20221116093946118

可以发现调整完最小不平衡二叉树,其余所有节点都平衡

5.6.2 调整最小不平衡子树

只要将最小不平衡子树调整平衡,那么其他祖先结点都将恢复平衡。那么为什么?

对于一颗平衡二叉树,如果插入一个结点破坏了平衡。是因为最小平衡二叉树对比插入前高度增加了一!导致其祖先结点对应的子树全部增加一,使得平衡因子异常,我们所做的调整就是==回复最小不平衡子树的高度==,这样祖先结点相应子树高度也就回复了,排序树重新平衡!

我们先抽象出来最小平衡二叉树的模型:平衡二叉树的左右子树高度相差小于等于1,但对于高度差等于零平衡二叉树插入结点不会破坏平衡。我们考虑的是插入结点后能破坏平衡的模型,所以就得到了左右子树高度差相差1的最小平衡树,当我们进行LL的方式插入,可以看到平衡性收到破坏。

image-20221116141254385

我们要做的就是通过调整,让树恢复平衡并且保持排序二叉树的特点。

二叉排序树的特性:左子树结点值<根结点值<右子树结点值

对于可能导致平衡二叉树被破坏的插入操作有四种:

image-20221116094219460

下面我们来分别讨论这四种情况

5.6.2.1 LL左孩子的左孩子

LL平衡旋转(右单旋转)。由于在结点A的左孩子的左子树上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡。

调整方式为:左孩子右上旋。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。

image-20221116142839986

代码实现其实就是调整了三个指针,LL插入平衡调整如下:

实现右下旋操作,f指向根结点,p指向左孩子,gf指向根节点的双亲结点

修改三个指针,注意顺序:

f->lchild=p->rchild;
gf->lchild=p;//或者gf->rchild=p;
p->rchild=f;
image-20221116143848627

5.6.2.2 RR右孩子的右孩子

RR平衡旋转(左单旋转)。由于在结点A的右孩子的右子树上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡。

调整方式为:右孩子左上旋。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树

image-20221116142610558

代码实现其实就是调整了三个指针,RR插入平衡调整如下:

实现右下旋操作,f指向根结点,p指向右孩子,gf指向根节点的双亲结点

修改三个指针,注意顺序:

f->rchild=p->lchild;
p->lchild=f;
gf->lchild=p;//gf->rchild=p;
image-20221116145126270

5.6.2.3 LR左孩子的右孩子

image-20221116151147902

LR平衡旋转(先左后右双旋转)。由于在A的左孩子的右子树上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡

调整犯法如下:左孩子的右孩子,先左上旋后右上旋。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置。

image-20221116151507696

代码实现就是左旋和右旋,上面讲过了,可以封装一个方法,分别用于左旋右旋,参数传的是根结点的双亲。上面讲解的是插入左孩子的右子树的右子树,对于插入左孩子的右子树的左子树的处理方式也是一样的。就是下面的这种情况:

image-20221116152213704

5.6.2.4 RL右孩子的左孩子

image-20221116152424098

RL平衡旋转(先右后左双旋转)。由于在A的右孩子的左子树上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡.

调整的方式是:右孩子的左孩子,先右上旋转后左上旋转。先将A结点的右孩子B的左子树的根结点c向右上旋转提升到B结点的位置,然后再把该c结点向左上旋转提升到A结点的位置

image-20221116152707581

代码实现就是右孩子右上旋,自己左上旋。上面讲的是插入右孩子的左子树的左子树,至于插入右孩子左子树的右子树处理也是一样的,就是下面的这种情况:

image-20221116153006262

5.6.2.5 总结

只有左孩子才能右旋,只有右孩子才能左旋。而具体左旋右旋操作固定:

image-20221116162232284

对于调整策略

image-20221116162413561

5.6.2.6 案例

实际问题我们应该如何寻找最小不平衡子树呢?首先排序树插入征程插入就行,然后沿着二叉排序树查找路径寻找,最后一个平衡因子异常的就是对应的最小不平衡子树的根结点

例1

image-20221116164522373

RR型,就是右孩子的右孩子插入调整问题,方法就是右孩子左上旋(此类左孩子右孩子都是相对于最小不平衡二叉树的根节点来说的。孩子变爹,爹变孩子)

image-20221116164721872

例二

image-20221116165224292

RL型,右孩子的左孩子,先右上旋再左上,对应为:

image-20221116170620922

例三

image-20221116170657956

LR型,左孩子的右孩子,先左上旋再右上旋

image-20221116171009519

5.6.3 查找效率分析

深度为h的平衡二叉树中含有最少的结点数假设为nh表示

当h=0,n0=0;

当h=1,n1=1;

当h=2,n2=2;

递归:nh=nh-1+nh-2+1

(高度为h的平衡二叉树对应的最少结点数为根结点数加左子树节点数和右子树的节点数)

所以相应的n3=1+n2+n1=4,n4=1+n3+n2=7、、、、有点类似斐波那契数列~

对于高度的h的排序树,查找一个结点最多只需要查找h次。所以9个结点的平衡二叉树高度为4,所以查找长度最大为4

对于n个结点,最大平衡二叉树的数量级为log2n,所以节点数为n的平衡二叉树的查找效率为O(log2n)

5.7 哈夫曼树

image-20221116233751705

  1. 结点的权:有某种显示含义的数值(如:表示结点的重要性等)

  2. 结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该节点上权值的乘积。

  3. 数的带权路径长度:树种所有叶子结点的带权路径长度之 和(WPL,Weighted Path Length)
    W P L = ∑ i = 1 n w i l i WPL=\sum_{i=1}^{n}{w_il_i} WPL=i=1nwili

在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树

5.7.1 构造哈夫曼树

给定n个权值分别为w1, w2…wn的结点,构造哈夫曼树的算法描述如下:

  1. 首先将这n个结点分别视作n棵仅含一个结点的二叉树,构成森林F。
  2. 在森林中选取两棵==根结点权值最小的树==作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
  3. 重复选树的过程,知道森林只剩下一棵树

下面是构建哈夫曼树的过程:

image-20221116215321079

5.7.2 哈夫曼树的性质

  1. 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大

  2. 哈夫曼树的结点总数为2n -1(n个结点构建哈夫曼树,会创建n-1个新结点,所以一共有2n-1个结点)

  3. 哈夫曼树中不存在度为1的结点。

  4. 哈夫曼树并不唯一,但WPL必然相同且为最优(带权路径长度最小的树就是哈夫曼树,’最‘当然相等)

    上面那道题另外一种构建哈夫曼树的方法为:

    image-20221116220110563

    计算两棵哈夫曼树,会发现WPL的值一致

5.7.3 哈夫曼编码

  • 可变长度编码,对不同字符采用不等长的二进制位表示。
  • 前缀编码:没有一个编码时另一个编码的前缀

用哈夫曼树得到的编码方案叫哈夫曼编码,并且哈夫曼编码时前缀编码

具体:对不同的字符赋予权值,就得到了带权的结点,相应构建哈夫曼树。每一个字符对应哈夫曼树的叶子结点,规定查找路径向左为编码1,向右为编码0。该叶子的查找路径就对应了字符的唯一编码。

下面时哈夫曼编码的案例:可以看到同一权值的结点能构造的哈夫曼树不唯一,对应的哈夫曼编码也是不唯一的!

image-20221116233626629

对使用频率高的字符赋予较高权值,对应哈夫曼树种权值高的结点查找路径更短,相应的编码也更短,从而实现文件压缩!

提到这里有人想说为什么非得用哈夫曼树?结点作为中间结点出现,也能对应唯一编码呀?但这样的编码并非前缀编码,当对一大段字符进行解码时,由于不是前缀编码,会出现解码歧义!如下,同一编码有两种解释

image-20221116233011956
  • 5
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值