二叉树

逻辑结构

是一种结点度数均<=2的树形结构。
二叉树的子树有左右之分,是有序树

递归定义

  1. 或者为空二叉树
  2. 或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树

二叉树与度为2的有序树的区别:

  1. 度为2的树至少有3个结点,而二叉树可以为空。
  2. 度为2的有序树的孩子的左右顺序是相对于另一个孩子而言的,若某个结点只有一个孩子则无需区分左子树还是右子树;但二叉树无论其孩子数是否为2,均需要区分。

性质

  • 非空二叉树上的叶子结点数等于度为2的结点数+1,即n0=n2+1
  • 非空二叉树上第k层上至多有2k-1个结点
  • 高度为h的二叉树至多有2h-1个结点
  • 具有n个结点的m叉树的最小高度为logm(n(m-1)+1)

特殊的二叉树

  • 满二叉树。高度为h,含有2h-1个结点的二叉树。所有叶子结点都在最后一层。

  • 完全二叉树。高度为h,有n个结点,并且每个结点都与高度为h的满二叉树中的结点编号一一对应。

完全二叉树的性质

  1. 叶子结点只可能在层次最大的两层上出现。并且在最大层次上从左到右排列。
  2. 若有度为1的结点,则只可能有1个,且只可能有左孩子。
  3. 若结点i为叶子结点或只有左孩子,则结点j(j>i)均为叶子结点。
  4. 若n为奇数,则每个结点都有左右孩子;若n为偶数,则编号最大的分支结点(编号 n/2)只有左孩子。

将完全二叉树按从左到右、从上到下顺序依次编号:

  • 当i>1时
    • 结点i的双亲为i/2(i为偶数),它是双亲的左孩子。
    • 结点i的双亲为(i-1)/2(i为奇数),它是双亲的右孩子。
  • 结点i的左孩子编号为2i,否则无左孩子(2i<n)。
  • 结点i的右孩子编号为2i+1,否则无右孩子(2i+1<=n)。
  • 结点i所在的层次(深度)为 [log2i(向下取整)]+1
  • 具有n个结点的完全二叉树高度为log2(n+1)或log2n+1
  • 若i<(n/2),则结点i是分支结点,否则为叶子结点。
  • 二叉排序树。左子树上的所有结点的关键字均小于根结点的关键字;右子树上所有结点的关键字均大于根结点的关键字;左子树和右子树又分别是一棵二叉排序树。
  • 平衡排序树。树上任一结点的左子树和右子树的深度之差不超过1。

物理结构

顺序存储结构

用一组连续的地址空间,将完全二叉树上编号为i的结点元素存储在数组下标为i(或数组下标为i-1)的分量中。
对于完全二叉树和满二叉树,使用顺序存储比较合适,下标序号可以反映结点之间的逻辑关系,可以节省空间。
对于一般的二叉树,为了让下标序号反映结点关系,只能添加许多空结点,让其与完全二叉树对照。最坏情况下,一棵高度为h、结点有h个的二叉树,却需要占用近2h-1个分量。

注意:
建议从数组下标1开始存储完全二叉树,因为这样才对应上述完全二叉树的序号性质。
如果从数组下标0开始存储完全二叉树,则需要对上述序号对应关系做一些改动。

链式存储空间

二叉链表

用链表存储二叉树,结点包括:数据域data、左指针域lchild,右指针域rchild。
在有n个结点的二叉链表中,含有n+1空链域。

二叉树的遍历

“序” 指的是根结点在何时被访问,例子:

typedef struct BiNode{
    int data;
    struct BiNode *lchild, *rchild;
}BiNode, *BiTree;

先序遍历

/*
递归算法:
1. 访问根结点
2. 先序遍历左子树
3. 先序遍历右子树
*/
void PreOrder(BiTree root){
	if(root != NULL){
        visit(root);
        PreOrder(root->lchild);
        PreOrder(root->rchild);
    }
}

/*
非递归算法:
1. 沿着根的左孩子依次访问并入栈,直至左孩子为空。
2. 栈顶元素出栈,若其右孩子为空,则继续执行2;若其右孩子非空,则将其右孩子作为根结点执行1。
*/
void PreOrder(BiTree root){
    InitStack(S);			//初始化栈
    BiNode* p = root;		//遍历指针
    while(p || !Empty(S)){
        if(p){
            visit(p);
            Pust(S,p);		//入栈
            p = p->lchild;	//左孩子不空,则一直向左走
        }
        else{				//左孩子为空,转向出栈结点的右子树
            p = Pop(S);		//栈顶元素出栈
            p = p->rchild;		
        }					//返回循环,继续先序遍历左子树
    }
    
}

下面是每次循环后栈内的元素(加粗表示此次循环有出栈操作,下划线表示此次循环有访问操作):

  1. 1 (p = 2)
  2. 1 2 (p = NULL)
  3. 1 (p = 4)
  4. 1 4 (p = 6)
  5. 1 4 6 (p = NULL)
  6. 1 4 (p = NULL)
  7. 1 (p = NULL)
  8. - (p = 3)
  9. 3 (p = NULL)
  10. - (p = 5)
  11. 5 (p = NULL)
  12. - (p = NULL)

先序访问序列为 1 2 4 6 3 5

中序遍历

/*
递归算法:
1. 中序遍历左子树
2. 访问根结点
3. 中序遍历右子树
*/
void InOrder(BiTree root){
    if(root != NULL){
        InOrder(root->lchild);
        visit(root);
        InOrder(root->rchild);
    }
}

/*
非递归算法:
1. 沿着根的左孩子依次入栈,直至左孩子为空。(说明已经找到可以输出的结点)
2. 栈顶元素出栈并访问,若其右孩子为空,继续执行2;若其右孩子非空,则将其右孩子作为根结点执行1。
*/
void InOrder(BiTree root){
    InitStack(S);			//初始化栈
    BiNode* p = root;		//遍历指针
    while(p || !Empty(S)){
        if(p){
            Push(S,p);		//入栈
            p = p->lchlid;	//左孩子不空,则一直向左走
        }
        else{				//左孩子为空,访问出栈结点,然后转向出栈结点的右子树
            p = Pop(S);		//栈顶元素出栈
            visit(p);		//访问出栈元素
            p = p->rchild;	
        }					//返回循环,继续中序遍历右子树
    }
}

下面是每次循环后的栈内元素(加粗表示此次循环有出栈操作,下划线表示此次循环有访问操作):

  1. 1 (p = 2)
  2. 1 2 (p = NULL)
  3. 1 (p = 4)
  4. 1 4 (p = 6)
  5. 1 4 6 (p = NULL)
  6. 1 4 (p = NULL)
  7. 1 (p = NULL)
  8. - (p = 3)
  9. 3 (p = NULL)
  10. - (p = 5)
  11. 5 (p = NULL)
  12. - (p = NULL)

中序访问序列为2 6 4 1 3 5

后序遍历

/*
递归算法:
1. 后序遍历左子树
2. 后序遍历右子树
3. 访问根结点
*/
void PostOrder(BiTree root){
    if(root != NULL){
        PostOrder(root->lchild);
        PostOrder(root->rchild);
        visit(root);
    }
}

/*
非递归算法:
1. 沿着根的左孩子依次入栈,直至左孩子为空。(但此时不能出栈并访问)
2. 查看栈顶元素(但不输出),若其右孩子为空,则栈顶元素出栈并访问,并继续执行2;
	若其右孩子不为空并且未被访问过,则将其右孩子作为根结点执行1。

栈顶元素若想出栈,要么右子树为空,要么右子树已经被访问过。
运行过程中,访问到结点p时,栈中的结点恰好是结点p的所有祖先。
从栈底到栈顶结点再加上结点p,刚好可以构成从根结点到结点p的一条路径。
*/
void PostOrder(BiTree root){
    InitStack(S);
    BiNode* p = root;
    BiNode* r;				//为了分清是从左子树返回,还是从右子树返回的,需要设置一个辅助指针
    while(p || !Empty(S)){
        if(p){
            Push(S,p);		//入栈
            p = p->lchild;	//左孩子不空,则一直向左走
        }
        else{				//左孩子为空,转向出栈结点的右子树
            p = GetTop(S);	//读取栈顶结点(不弹出)		
            if(p->rchild && p->rchild != r){//如果右子树不为空,并且未被访问过
                p = p->rchild;	//向右走
            }	
            else{							//否则,弹出栈顶结点并访问
                p = Pop(S);
                visit(p);
                r = p;			//记录最近访问的结点
                p = NULL;		//结点访问完后,重置p指针(每次出栈访问完一个结点就相当于遍历完以该结点为根的子树,需要将p置空,以使得下一次循环从取出下一个栈顶元素开始)
            }
        }
    }
}

下面是每次循环后的栈内元素(加粗表示此次循环有出栈操作,下划线表示此次循环有访问操作):

  1. 1 (p = 2,r = NULL)
  2. 1 2 (p = NULL,r = NULL)
  3. 1 2 (p = 4,r = NULL)
  4. 1 2 4 (p = 6,r = NULL)
  5. 1 2 4 6 (p = NULL,r = NULL)
  6. 1 2 4 (p = NULL,r = 6)
  7. 1 2 (p = NULL,r = 4)
  8. 1 (p = NULL,r = 2)
  9. 1 (p = 3,r = 2)
  10. 1 3 (p = NULL,r = 2)
  11. 1 3 (p = 5,r = 2)
  12. 1 3 5 (p = NULL,r = 2)
  13. 1 3 (p = NULL,r = 5)
  14. 1 (p = NULL,r = 3)
  15. - (p = NULL,r = 1)

后序访问序列为6 4 2 5 3 1

层次遍历

需要借助一个队列。

  1. 先将根结点入队,然后出队。
  2. 访问出队结点,若它有左子树,则将左子树根结点入队;若它有右子树,则将右子树根结点入队。然后出队,访问出队结点。重复2。
void LevelOrder(BiTree root){
    InitQueue(Q);	//初始化队列
    BiNode* p;
    EnQueue(p);		//将根结点入队
    while(!Empty(Q)){
        p = DeQueue(Q);		//队头结点出队
        visit(p);
        if(p->lchild != NULL)
            EnQueue(Q, p->lchild);	//左孩子不空,则入队
        if(p->rchild != NULL)
            EnQueue(Q, p->rchild);	//右孩子不空,则入队
    }
}

线索二叉树

传统的二叉链表存储仅能体现一种父子关系,不能直接得到结点在遍历中的前驱或后继。
n个结点的二叉链表中,有n+1个空指针。

在含有n个结点的二叉树中,每个叶子结点有两个空指针,每个度为1的结点有1个空指针,所以空指针总数为2n0+n1,又因为非空二叉树上的叶子结点数等于度为2的结点数+1,即n0=n2+1,所以空指针总数为n0+n2+n1+1 = n+1.

利用这n+1个空指针来存放前驱和后继,就叫做线索二叉树。
规定:

  • 若无左子树,令lchild指向其前驱结点
  • 若无右子树,令rchild指向其后继结点

因此还需要两个标志域表示指针域是指向左(右)孩子还是前驱(后继)。


线索二叉树的存储结构描述如下:

typedef struct ThreadNode{
    int data;
    struct ThreadNode *lchild, *rchild;
    int ltag, rtag;
}ThreadNode, *ThreadTree;

所构成的二叉链表称为线索链表。其中指向前驱和后继的指针称为线索。加上线索的二叉树称为线索二叉树。

先序线索二叉树的构造

/*
在遍历的过程中:
检查p的左指针是否为空,如果为空,将它指向pre;
检查p的右指针是否为空,如果为空,将它指向p;
*/
void PreThread(ThreadNode* p,ThreadNode* pre){
	if(p != null){
        if(p->lchild == NULL){		//左子树为空,建立前驱线索
            p->lchild = pre;
            p->ltag = 1;
        }
        if(pre != NULL && pre->rchild == NULL){
            pre->rchild = p;		//建立前驱结点的后继线索
            pre->rtag = 1;
        }
        pre = p;					//标记当前结点成为刚刚访问过的结点

        if(p->ltag == 0)			//防止回环
        	PreThread(p->lchild, pre);	//递归线索化左子树
        PreThread(p->rchild, pre);	//递归线索化右子树
    }
}

void CreatePreThread(ThreadTree root){
	ThreadNode *pre = NULL;
    if(root != NULL){
        PreThread(root, pre);
        if(pre->rchild == NULL)
        	pre->rtag = 1;		//处理遍历的最后一个结点
    }
}

Screenshot from 2022-11-06 22-01-19.pngScreenshot from 2022-11-06 22-04-20.png

中序线索二叉树的构造

/*
在遍历的过程中:
检查p的左指针是否为空,如果为空,将它指向pre;
检查p的右指针是否为空,如果为空,将它指向p;
*/
void InThread(ThreadNode* p,ThreadNode* pre){
	if(p != null){
        InThread(p->lchild, pre);	//递归线索化左子树
        
        if(p->lchild == NULL){		//左子树为空,建立前驱线索
            p->lchild = pre;
            p->ltag = 1;
        }
        if(pre != NULL && pre->rchild == NULL){
            pre->rchild = p;		//建立前驱结点的后继线索
            pre->rtag = 1;
        }
        pre = p;					//标记当前结点成为刚刚访问过的结点
        
        InThread(p->rchild, pre);	//递归线索化右子树
    }
}

void CreateInThread(ThreadTree root){
	ThreadNode *pre = NULL;
    if(root != NULL){
        InThread(root, pre);
        pre->rchild = NULL;			//处理遍历的最后一个结点
        pre->rtag = 1;
    }
}

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

令二叉树的中序序列的第一个结点lchild指针和最后一个结点的rchild指针都指向头结点,好比为二叉树建立了一个双向线索链表,方便从前往后或从后往前对线索二叉树进行遍历。

Screenshot from 2022-11-06 21-54-42.pngScreenshot from 2022-11-06 21-58-29.png

后序线索二叉树的构造

/*
在遍历的过程中:
检查p的左指针是否为空,如果为空,将它指向pre;
检查p的右指针是否为空,如果为空,将它指向p;
*/
void PostThread(ThreadNode* p,ThreadNode* pre){
	if(p != null){
        PostThread(p->lchild, pre);	//递归线索化左子树
        PostThread(p->rchild, pre);	//递归线索化右子树
        
        if(p->lchild == NULL){		//左子树为空,建立前驱线索
            p->lchild = pre;
            p->ltag = 1;
        }
        if(pre != NULL && pre->rchild == NULL){
            pre->rchild = p;		//建立前驱结点的后继线索
            pre->rtag = 1;
        }
        pre = p;					//标记当前结点成为刚刚访问过的结点        
    }
}

void CreatePostThread(ThreadTree root){
	ThreadNode *pre = NULL;
    if(root != NULL){
        PostThread(root, pre);
        if(pre->rchild == NULL)
        	pre->rtag = 1;		//处理遍历的最后一个结点
    }
}

Screenshot from 2022-11-06 22-04-59.pngScreenshot from 2022-11-06 22-04-32.png

应用

哈夫曼树

从树的根结点到任意结点的路径长度(经过的边数)与该结点上权值的乘积,成为该结点的带权路径长度
树中所有结点的带权路径长度之和称为该树的带权路径长度。

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

构造方法

给定n个权值分别为w1,w2,w3......,wn的结点

  1. 将这n个结点分别作为树的根结点组成森林F
  2. 构造一个新结点,从F中取两棵根结点权值最小的树作为左右子树,并且将新结点的权值设置为左右子树根结点的权值之和
  3. 从F中删除刚才选出的两棵树,同时将新得到的树加入F中
  4. 重复2. 3. ,直到F中只剩下一棵树为止

特点

  1. 每个初始结点最终都成为了叶子结点,且权值越小的结点到根结点的路径长度越大
  2. 构造过程中共新建了n-1个结点(双分支结点),因此哈夫曼树的结点总数为2n-1
  3. 每次构造都选择了2棵树作为新结点的孩子,因此哈夫曼树中不存在度为1的结点

如果用哈夫曼树构建三叉树,则不存在度为1或2的结点。且需要谨记:初始节点最终都是叶节点,必要时需要补上权值为0的空结点,构成三叉树。

利用哈夫曼树编码

将每个出现的字符当做一个独立的结点,其权值为它出现的频率,构造出哈夫曼树。
所有字符结点都会出现在叶子节点上(保证了前缀编码),字符的编码即为从根结点至该字符结点的路径上的序列(通常左子树路径为0,右子树路径为1)
哈夫曼树的WPL可视为最终编码后的字符长度。

posted @ 2023-03-23 15:57  青子Aozaki  阅读(153)  评论(0)    收藏  举报