数据结构——树

逻辑结构

树是n(n≥0)个结点的有限集合,一种递归的数据结构,同时也是一种分层结构。

n=0时,称为空树,任意一棵非空树应满足:

  • 有且仅有一个特定的称为的结点,除根结点外的所有结点有且只有一个前驱,树中所有结点可以有零个或多个后继
  • n>1时,其余结点可分为m(m>0)个互不相交的有限集合 T 1 , T 2 , . . . , T m T_1,T_2,...,T_m T1,T2,...,Tm,其中每个集合都是一颗树,并且称为根结点的子树

n个结点的树有n-1条边

术语:

  • 祖先:根A到结点K的唯一路径上的任意结点(结点A、结点B、结点E),称为结点K的祖先
  • 子孙:结点K,称为根A到结点K的唯一路径上的任意结点(结点A、结点B、结点E)的子孙
  • 双亲:路径上自上而下最接近结点K的结点E称为K的双亲,根A是树中唯一没有双亲的结点
  • 孩子:路径上自下而上最接近结点E的结点(结点K、结点L)称为E的孩子,叶子结点没有孩子
  • 兄弟:有相同双亲的结点称为兄弟,如结点K和结点L有相同的双亲E,即K和L互为兄弟
  • 堂兄弟:结点的层次从树根开始定义,根结点为第1层。双亲在同一层的结点互为堂兄弟,结点G与E,F,H,I,J互为堂兄弟
  • 结点的度:结点的孩子个数称为该结点的度,如结点B的度为2,结点D的度为3
  • 树的度:树中结点的最大度数称为树的度,树的度为3
  • 分支结点:度大于0(有后继结点)的结点称为分支结点(又称非终端结点),对于分支结点,结点的分支数就是该结点的度
  • 叶子结点:度为0(没有后继结点)的结点称为叶子结点(又称终端结点)
  • 结点的深度:结点的深度是从根结点开始自顶向下逐层累加的,从1开始
  • 结点的高度:结点的高度是从叶结点开始自底向上逐层累加的,从1开始
  • 树的高度:树的高度(或深度)是树中结点的最大层数,树的高度为4
  • 有序树和无序树:树中结点的各子树从左到右是有次序的,不能互换,称该树为有序树,否则称为无序树
  • 路径和路径长度:树中两个结点之间的路径由这两个结点从上往下所经过的结点序列构成,路径长度是路径上所经过的边的个数
  • 森林 :森林是m(m≥0)棵互不相交的树的集合,m为0时称为空森林
  • m叉树:所有结点的度都<=m的树

注意:由于树中的分支是有向的,即从双亲指向孩子,所以树中的路径是从上向下的,同一双亲的两个孩子之间不存在路径

树的基本性质:

1、树中的结点数 = 所有结点的度数之和 + 1

2、度为m的树、m叉树的第i层至多有 m i − 1 m^{i-1} mi−1 个结点(i≥1),高度为hm叉树至多有 ( m h − 1 ) / ( m − 1 ) (m^h-1)/(m-1) (mh−1)/(m−1) 个结点

3、具有n个结点的m叉树的最小高度为 ⌈ log ⁡ m ( n ( m --- 1 ) + 1 ) ⌉ \left\lceil\log_m(n(m---1)+1) \right\rceil ⌈logm(n(m---1)+1)⌉,所有结点都有m个孩子

前 h − 1 层最多结点树 m h − 1 − 1 m − 1 < n ≤ 前 h 层最多结点数 m h − 1 m − 1 前h-1层最多结点树\frac{m^{h-1}-1}{m-1} < n \le 前h层最多结点数\frac{m^h-1}{m-1} 前h−1层最多结点树m−1mh−1−1<n≤前h层最多结点数m−1mh−1

4、高度为hm叉树至少有h个结点,高度为h、度为m的树至少有h+m-1个结点

5、度为m的树、m叉树的区别

  • 度为m的树,一定是非空树,任意结点的度<=m,至少有m+1个结点,至少有一个结点的度为m
  • m叉树,可以是空树,任意结点的度<=m,允许所有结点的度都<m

6、设非空二叉树中度为0、12的结点个数分别为 n 0 、 n 1 n_0、n_1 n0、n1 和 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1(叶子结点比二分支结点多一个)

基本操作

遍历

树的遍历是指用某种方式访问树中的每个结点,且仅访问一次。主要有以下方式:

  • 先根遍历:若树非空,先访问根结点,再依次遍历根结点的每棵子树,遍历子树时仍遵循先根后子树的规则
  • 后根遍历:若树非空,先依次遍历根结点的每棵子树,再访问根结点,遍历子树时仍遵循先子树后根的规则
  • 层次遍历:若树非空,则根节点入队;若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队,重复执行直到队列为空

树的先根遍历序列为ABEFCDG,后根遍历序列为EFBCGDA,层次遍历序列为ABCDEFG

树的先根遍历和后根遍历属于深度优先遍历,而层次遍历属于广度优先遍历

森林

按照森林和树相互递归的定义,可得到森林的两种遍历方法:

  • 先序遍历:若森林为非空,访问森林中第一棵树的根结点,先序遍历第一棵树中根结点的子树森林,先序遍历除去第一棵树之后剩余的树构成的森林
  • 中序遍历:森林为非空时,中序遍历森林中第一棵树的根结点的子树森林,访问第一棵树的根结点,中序遍历除去第一棵树之后剩余的树构成的森林

森林的先序遍历序列为ABCDEFGHI,中序遍历序列为BCDAFEHIG

当森林转换成二叉树时,其第一棵树的子树森林转换成左子树,剩余树的森林转换成右子树,可知森林的先序和中序遍历即为其对应二叉树的先序和中序遍历

森林的中根遍历称为后根遍历,称中根遍历是相对其二叉树而言的,称后根遍历是因为根确实是最后才访问的

代码:

c++ 复制代码
//树-先根遍历
void preOrder(PTree &T, int v) {
    visit(T.nodes[v]);                              //访问当前结点
    int nextNode = firstChild(T, v);                //下一个访问的结点
    while (nextNode != -1) {
        preOrder(T, nextNode);                   //递归遍历
        nextNode = nextSibling(T, v, nextNode);  //下一个堂兄弟结点
    }
}

//树-后根遍历
void postOrder(PTree &T, int v) {
    int nextNode = firstChild(T, v);                //下一个访问的结点
    while (nextNode != -1) {
        postOrder(T, nextNode);
        nextNode = nextSibling(T, v, nextNode);
    }
    visit(T.nodes[v]);                              //访问当前结点
}

//树-层次遍历
void levelOrder(PTree &T, int v) {
    Queue < PTNode * > queue;
    queue.push(T.nodes[v]);         //将根节点放入队列
    while (!queue.empty()) {        //当队列不为空
        int front = queue.pop();    //弹出队首元素
        visit(T.nodes[front]);    //访问

        int nextNode = firstChild(T, front);    //压入孩子节点
        while (nextNode != -1) {
            queue.push(T.nodes[nextNode]);
            nextNode = nextSibling(T, front, nextNode);
        }
    }
}

//树-遍历
void traversePTree(PTree &T) {
    for (int i = 0; i < T.n; i++) {                       //搜索根结点
        if (T.nodes[i].parent == -1) {
            preOrder(T, i);                         //先根遍历
            //levelOrder(T, i);                            //层次遍历
            //postOrder(T, i);                             //后根遍历
        }
    }
}

基于双亲表示法实现

树的深度

c++ 复制代码
int treeDepth(BiTree T) {
    if (T == NULL) {
        return 0;
    } else {
        int l = treeDepth(T->lchild);
        int r = treeDepth(T->rchild);
        return l > r ? l + 1 : r + 1;   //树的深度=Max(左子树深度,右子树深度)+1
    }
}

存储结构

重点:孩子兄弟表示法、森林与二叉树的转换

树的存储方式要能唯一地反映树中各结点之间的逻辑关系,常见的存储方式有:双亲表示法、孩子表示法、孩子兄弟表示法

双亲表示法(顺序存储)

双亲表示法采用一组连续空间存储树的结点,同时在每个结点中附设一个指示其双亲结点位置的指针域,根结点下标为0,指针域为-1

特点:

查找指定结点的双亲很方便,但查找指定结点的孩子只能从头遍历

代码:

c++ 复制代码
#define MAX_TREE_SIZE 100   //树中最多结点数
typedef int ElemType;
typedef struct {            //数组结点
    ElemType data;          //数据元素
    int parent;             //双亲位置域
} PTNode;
typedef struct {                    //树结构
    PTNode nodes[MAX_TREE_SIZE];    //结点数组
    int n;                          //n是结点数
} PTree;

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

孩子表示法采用顺序存储方式存储每个结点,同时每个结点都附设一个指向其所有孩子结点链接形成的单链表的指针域,n个结点有n个孩子链表(叶子结点的孩子链表为空表)

特点:

查找指定结点的孩子很方便,但查找指定结点的双亲只能从头遍历

代码:

c++ 复制代码
#define MAX_TREE_SIZE 100
typedef int ElemType;
typedef struct {               //数组结点
    ElemType data;
    struct CTNode *firstChild; //指向第一个孩子
} CTBox;
struct CTNode {                //链表结点
    int child;                 //孩子结点索引下标
    struct CTNode *next;       //指向下一个孩子
};
typedef struct {               //树结构
    CTBox nodes[MAX_TREE_SIZE];//结点数组
    int r, n;                  //r是根位置,n是结点数
} CTree;

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

孩子兄弟表示法又称二叉树表示法,以二叉链表作为树的存储结构,物理上呈现出二叉树形式。每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针以及指向结点下一个兄弟结点的指针

特点:

孩子兄弟表示法比较灵活,可以方便地实现树与二叉树的转换,易于查找结点的孩子等,但缺点是从当前结点查找其双亲结点比较麻烦

代码:

c++ 复制代码
typedef int ElemType;
typedef struct CSNode {
    ElemType data;
    struct CSNode *firstchild, *nextsibling;       //第一个孩子、右兄弟指针
} CSNode, *CSTree;

二叉树

重点,完全二叉树是选择题中的高频考点

二叉树是n(n≥0)个结点的有限集合,是一种树形结构(有序树),每个结点至多只有两个互不相交的两棵子树(左子树、右子树),并且二叉树的子树有左右之分,其次序不能颠倒

n=0时,称为空二叉树

性质:

1、设非空二叉树中度为0、12的结点个数分别为 n 0 、 n 1 n_0、n_1 n0、n1 和 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1(叶子结点比二分支结点多一个)

n = n 0 + n 1 + n 2 n=n_0+n_1+n_2 n=n0+n1+n2 与 n = n 1 + 2 n 2 + 1 n=n_1+2n_2+1 n=n1+2n2+1(树的结点数 = 总度数+1)

存储结构

顺序存储

二叉树的顺序存储是指用一组地址连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素,即将完全二叉树上编号为i的结点元素存储在一维数组下标为i-1的分量中。

从数组下标1开始存储树中的结点

代码:

c++ 复制代码
#define MaxSize 100
typedef int ElemType;
typedef struct TreeNode {
    ElemType value;                    //结点数据
    bool isEmpty;
};
TreeNode t[MaxSize];

完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映结点之间的逻辑关系,既能最大可能地节省存储空间,又能利用数组元素的下标值确定结点在二叉树中的位置,以及结点之间的关系。

但对于一般的二叉树,为了让数组下标能反映二叉树中结点之间的逻辑关系,只能添加一些并不存在的空结点,让其每个结点与完全二叉树上的结点相对照,再存储到一维数组的相应分量中。最坏情况下,高度为h且只有h个结点的单支树,却需要占据近 2 h − 1 2^h-1 2h−1 个存储单元

因此实际应用中很少用顺序存储结构来存储一颗二叉树

链式存储

由于顺序存储的空间利用率较低,二叉树一般采用链式存储结构,用链表结点来存储二叉树中的每个结点。在二叉树中,结点结构通常包括若干数据域和若干指针域,二叉链表至少包含3个域:数据域data、左指针域lchild和右指针域rchild

代码:

c++ 复制代码
typedef int ElemType;
typedef struct BiTNode {
    ElemType data;					   //数据域
    struct BiTNode *lchild, *rchild;	//左右孩子指针
} BiTNode, *BiTree;

在含有n个结点的二叉链表中,含有n+1个空链域(重要结论,经常出现在选择题中)

方便寻找指定结点p的左/右孩子,但只能从根开始遍历寻找指定结点p的父结点

主要考察

实际上在不同的应用中,还可以增加某些指针域,如增加指向父结点的指针后,变为三叉链表的存储结构。

c++ 复制代码
typedef int ElemType;
typedef struct BiTNode {
    ElemType data;					   //数据域
    struct BiTNode *lchild, *rchild;	//左右孩子指针
    struct BiTNode *parent;             //父节点指针
} BiTNode, *BiTree;

基本操作

转换

由于二叉树、森林和树都可以用二叉链表作为存储结构,以二叉链表作为媒介可以导出树与二叉树、森林的一个对应关系,即给定一棵树或森林,可以找到唯一的一棵二叉树与之对应。

本质是用孩子兄弟法存储树、森林

规则:每个结点左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟(左孩子右兄弟)

树与二叉树

画法:①在兄弟结点之间加一连线;②对每个结点,只保留它与第一个孩子的连线,而与其他孩子的连线全部抹掉;③以树根为轴心,顺时针旋转45°

森林和二叉树

将森林转换为二叉树的规则与树类似。先将森林中的每棵树转换为二叉树,将前一棵树对应的二叉树当作后一棵二叉树根的右子树,以此类推,即可转换为二叉树

画法:①将森林中的每棵树转换成相应的二叉树;②每棵树的根也可视为兄弟关系,在每棵树的根之间加一根连线;③以第一棵树的根为轴心顺时针旋转45°

遍历

二叉树的遍历是指按某条搜索路径访问树中每个结点,使得每个结点均被访问一次,而且仅被访问一次。基于树的不同特性可分为:

  • 层次特性:层次遍历
  • 递归特性:先序遍历(NLR)、中序遍历(LNR)和后序遍历(LRN)
递归特性

确定对根结点N、左子树L和右子树R的访问顺序,常见遍历次序有先序(NLR)、中序(LNR)和后序(LRN)

三种遍历算法中,递归遍历左、右子树的顺序都是固定的,只是访问根结点的顺序不同,每个结点都访问一次且仅访问一次,时间复杂度都是O(n)。递归工作栈的栈深恰好为树的深度,在最坏情况下,二叉树是有n个结点且深度为n的单支树,空间复杂度为O(n)

每个结点都会被经过3次

先序遍历

先序遍历(PreOrder),第一次经过结点时访问结点,操作过程:

  • 若二叉树为空,则什么也不做
  • 若二叉树非空:
    1. 访问根结点
    2. 先序遍历左子树
    3. 先序遍历右子树
c++ 复制代码
void PreOrder(BiTree T) {
    if (T != NULL) {
        Visit(T);
        PreOrder(T->lchild);
        PreOrder(T->rchild);
    }
}

先序非递归遍历

c++ 复制代码
void PreOrder(BiTree T) {
    Initstack(S);
    BiTree p = T;
    while (p || !IsEmpty(S)) {//初始化栈 S;p 是遍历指针//栈不空或p不空时循环
        if (p) {//一路向左
            visit(p);
            Push(S, p); //访问当前结点,并入栈
            p = p->lchild; //左孩子不空,一直向左走
        } else {//出栈,并转向出栈结点的右子树
            Pop(S, p);//栈顶元素出栈
            p = p->rchild;//向右子树走,p赋值为当前结点的右孩子
        }
    }
}
中序遍历

中序遍历(InOrder),第二次经过结点时访问结点,操作过程:

  • 若二叉树为空,则什么也不做
  • 若二叉树非空
    1. 中序遍历左子树
    2. 访问根结点
    3. 中序遍历右子树
c++ 复制代码
void InOrder(BiTree T) {
    if (T != NULL) {
        InOrder(T->lchild);
        Visit(T);
        InOrder(T->rchild);
    }
}

中序非递归遍历

c++ 复制代码
void Inorder(BiTree T) {
    InitStack(S); //初始化栈S;p是遍历指针
    BiTree p = T;
    while (p || !IsEmpty(S)) { //栈不空或p不空时循环
        if (p) {
            //一路向左
            Push(S, p);//当前结点入栈
            p = p->lchild;//左孩子不空,一直向左走
        } else {//出栈,并转向出栈结点的右子树
            Pop(S, p);
            visit(p);//栈顶元素出栈,访问出栈结点
            p = p->rchild;//向右子树走,p赋值为当前结点的右孩子
            // 返回while循环继续进入if - else语句
        }
    }
}
后序遍历

后序遍历(Postorder),第三次经过结点时访问结点,操作过程:

  • 若二叉树为空,则什么也不做
  • 若二叉树非空
    1. 后序遍历左子树
    2. 后序遍历右子树
    3. 访问根结点
c++ 复制代码
void PostOrder(BiTree T) {
    if (T != NULL) {
        PostOrder(T->lchild);
        PostOrder(T->rchild);
        Visit(T);
    }
}

后序非递归遍历二叉树是先访问左子树,再访问右子树,最后访问根结点。

算法过程:

①沿着根的左孩子,依次入栈,直到左孩子为空

②读栈顶元素:若其右孩子不空且未被访问过,将右子树转执行①;否则,栈顶元素出栈并访问

为了分清返回时是从左子树返回的还是从右子树返回的,需要设定一个辅助指针r,指向最近访问过的结点。也可在结点中增加一个标志域,记录是否已被访问。

c++ 复制代码
void Postorder(BiTree T) {
    Initstack(S);
    p = T;
    r = NULL;
    while (p || !IsEmpty(S)) {
        if (p) {//走到最左边
            push(S, p);
            p = p->lchild;
        } else {//向右
            GetTop(S, p);//读栈顶结点(非出栈)
            if (p->rchild && p->rchild != r)//若右子树存在,且未被访问过
                p = p->rchild;//转向右
            else {//否则,弹出结点并访问
                pop(S, p);//将结点弹出
                visit(p->data);//访问该结点
                r = p;//记录最近访问过的结点
                p = NULL;//结点访问完后,重置p指针
            }
        }
    }
}
层次特性
层次遍历

借助一个队列,先将二叉树根结点入队;出队头结点,访问出队结点,若它有左子树,则将左子树根结点入队;若它有右子树,则将右子树根结点入队;如此反复,直至队列为空

算法思想:

  • 初始化一个辅助队列
  • 根结点入队
  • 若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话),重复直至队列为空
c++ 复制代码
void Levelorder(BiTree T) {
    LinkQueue Q;
    InitQueue(Q);//初始化辅助队列
    BiTree p;
    EnQueue(Q, T);//将根结点入队
    while (!IsEmpty(Q)) {//队列不空则循环
        DeQueue(Q, p);//队头结点出队
        visit(p);//访问出队结点
        if (p->lchild != NULL)
            EnQueue(Q, p->lchild);//左子树不空,则左子树根结点入队
        if (p->rchild != NULL)
            EnQueue(Q, p->rchild);//右子树不空,则右子树根结点入队
    }
}
补充
对应关系
分支结点逐层展开法
由遍历序列构造二叉树

由二叉树的先序序列(序列中第一个结点为根)、后序序列(序列中最后一个结点为根)、层次序列(先出现的结点为根)可以得知二叉树的根结点,在中序遍历中,根结点将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,后一个子序列是根结点的右子树的中序序列

过程:

找到树的根节点,并根据中序序列划分左右子树,再找到左右子树根节点,以此类推

唯一地确定一棵二叉树:

  • 先序序列+中序序列
  • 后序序列+中序序列
  • 层次序列+中序序列

只给出一种序列无法唯一确定一颗二叉树,前序、后序、层序序列的两两组合无法唯一确定一颗二叉树

例如:求先序序列(ABCDEFGHI)和中序序列(BCAEDGHFI)所确定的二叉树

首先,由先序序列可知A为二叉树的根结点。中序序列中A之前的BC为左子树的中序序列,EDGHFI为右子树的中序序列。然后由先序序列可知B是左子树的根结点,D是右子树的根结点。以此类推,就能将剩下的结点继续分解下去,最后得到二叉树

特殊二叉树

满二叉树

一棵高度为h,且含有 2 h − 1 2^h-1 2h−1 个结点的二叉树称为满二叉树,即树中的每层都含有最多的结点,满二叉树的叶子结点都集中在二叉树的最下面一层,并且除叶子结点之外的每个结点度数均为2

对满二叉树按层序编号:约定编号从根结点(根结点编号为1)起,自上而下,自左向右,每个结点对应一个编号,对于编号为i的结点,若有双亲,则其双亲为 ⌊ i / 2 ⌋ \left\lfloor i/2 \right\rfloor ⌊i/2⌋,若有左孩子,则左孩子为2i;若有右孩子,则右孩子为2i+1

完全二叉树

高度为h、有n个结点的二叉树,当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树

特点:

  • 若 i ≤ ⌊ n / 2 ⌋ i≤ \left\lfloor n/2 \right\rfloor i≤⌊n/2⌋,则结点i为分支结点,否则为叶子结点
  • 叶子结点只可能在层次最大的两层上出现,对于最大层次中的叶子结点,都依次排列在该层最左边的位置上
  • 若有度为1的结点,则只可能有一个,且该结点只有左孩子而无右孩子
  • 按层序编号后,一旦出现某结点(编号为i)为叶子结点或只有左孩子,则编号大于i的结点均为叶子结点
  • n为奇数,则每个分支结点都有左孩子和右孩子;若n为偶数,则编号最大的分支结点(编号为n/2)只有左孩子,没有右孩子,其余分支结点左、右孩子都有
  • 从根结点(根结点编号为1)起,自上而下,自左向右,每个结点对应一个编号,对于编号为i的结点,若有双亲,则其双亲为 ⌊ i / 2 ⌋ \left\lfloor i/2 \right\rfloor ⌊i/2⌋,若有左孩子,则左孩子为2i;若有右孩子,则右孩子为2i+1

性质:

1、具有n(n>0)个结点的完全二叉树的高度为 ⌈ l o n g 2 ( n + 1 ) ⌉ \left\lceil long_2(n+1) \right\rceil ⌈long2(n+1)⌉ 或 ⌊ l o n g 2 n ⌋ + 1 \left\lfloor long_2n \right\rfloor+1 ⌊long2n⌋+1

h的满二叉树共有 2 h − 1 2^h-1 2h−1个结点,高h-1的满二叉树共有 2 h − 1 − 1 2^{h-1}-1 2h−1−1个结点, 2 h − 1 − 1 < n ≤ 2 h − 1 2^{h-1}-1 < n \le 2^h-1 2h−1−1<n≤2h−1, h = ⌈ l o n g 2 ( n + 1 ) ⌉ h=\left\lceil long_2(n+1) \right\rceil h=⌈long2(n+1)⌉

h的完全二叉树至少 2 h − 1 2^{h-1} 2h−1个结点,至多 2 h − 1 2^{h}-1 2h−1个结点, 2 h − 1 ≤ n < 2 h 2^{h-1} \le n < 2^h 2h−1≤n<2h, h = ⌊ l o n g 2 n ⌋ + 1 h=\left\lfloor long_2n \right\rfloor+1 h=⌊long2n⌋+1

2、对于完全二叉树,可由结点总数n推出度为0、1、2的结点个数为 n 0 、 n 1 、 n 2 n_0、n_1、n_2 n0、n1、n2

完全二叉树最多只有一个度为1的结点,即 n 1 = 0 或 1 n_1=0或1 n1=0或1, n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1,即 n 0 + n 2 n_0+n_2 n0+n2一定是奇数

  • 若完全二叉树有2k个结点,则必有 n 1 = 1 、 n 0 = k 、 n 2 = k − 1 n_1=1、n_0=k、n_2=k-1 n1=1、n0=k、n2=k−1
  • 若完全二叉树有2k-1个结点,则必有 n 1 = 0 、 n 0 = k 、 n 2 = k − 1 n_1=0、n_0=k、n_2=k-1 n1=0、n0=k、n2=k−1
二叉排序树

左子树上所有结点的关键字均小于根结点的关键字、右子树上的所有结点的关键字均大于根结点的关键字的二叉树称为二叉排序树,左子树和右子树又各是一棵二叉排序树

便于查找

平衡二叉树

树上任一结点的左子树和右子树的深度之差不超过1 的二叉树称为平衡二叉树

相比二叉排序树,有更高的搜索效率

线索二叉树

利用二叉树本身存在的n+1个空链域来存储线索,指示前驱后继

比较重要的考点

为了加快查找结点前驱、后继的速度,在二叉链表中增加两个标志域,规定若无左子树,令lchild指向其前驱结点,标志域ltag=1;若无右子树,令rchild指向其后继结点,标志域rtag=1。指向结点前驱和后继的指针称为线索,构成的链表称为线索链表,即线索二叉树

标志域的含义如下:

  • ltag=0,lchild域指示结点的左孩子
  • ltag=1,lchild域指示结点的前驱
  • rtag=0,rchild域指示结点的右孩子
  • rtag=1,rchild域指示结点的后继
c++ 复制代码
typedef struct ThreadNode {
    ElemType data;//数据元素
    struct ThreadNode *lchild, *rchild; //左、右孩子指针
    int ltag, rtag;//左、右线索标志
} ThreadNode, *ThreadTree;

为了方便,可以在二叉树的线索链表上添加一个头结点,令其lchild域的指针指向二叉树的根结点,其rchild域的指针指向中序遍历时访问的最后一个结点;令二叉树中序序列中的 第一个结点的lchild域指针和最后一个结点的rchild域指针均指向头结点,建立了一个双向线索链表,方便从前往后或从后往前对线索二叉树进行遍历

分类

二叉树的线索化是将二叉链表中的空指针改为指向前驱或后继的线索,按照遍历的顺序不同可分为:

  • 先序线索二叉树:线索指向先序前驱、先序后继
  • 中序线索二叉树:线索指向中序前驱、中序后继
  • 后序线索二叉树:线索指向后序前驱、后序后继
线索化
中序线索化

指针pre指向上一个被访问的结点,指针p指向当前访问的结点。在中序遍历的过程中,检查p的左指针是否为空,若为空就将它指向pre;检查pre的右指针是否为空,若为空就将它指向p

c++ 复制代码
void InThread(ThreadTree &p, ThreadTree &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 T) {
    ThreadTree pre = NULL;			//指向当前访问结点的前驱
    if (T != NULL) {                //非空二叉树,线索化
        InThread(T, pre);    		//线索化二叉树
        if (pre->rchild == NULL)         //处理遍历的最后一个结点
            pre->rtag = 1;
    }
}

由于中序遍历的最后一个结点右孩子指针必为空,因此无须进行检查并设置,可以省略

初步建成的树,ltag、rtag=0

最后还要检查pre的rchild是否为NULL, 如果是,则令rtag=1

先序线索化
c++ 复制代码
void PreThread(ThreadTree &p, ThreadTree &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 T) {
    ThreadTree pre = NULL;  //指向当前访问结点的前驱
    if (T != NULL) {                //非空二叉树,线索化
        PreThread(T, pre);    //线索化二叉树
        if (pre->rchild == NULL)         //处理遍历的最后一个结点
            pre->rtag = 1;
    }
}

需要判断左子树是否已经线索化

后序线索化
c++ 复制代码
void PostThread(ThreadTree &p, ThreadTree &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 CreateInThread(ThreadTree T) {
    ThreadTree pre = NULL;  //指向当前访问结点的前驱
    if (T != NULL) {                //非空二叉树,线索化
        PostThread(T, pre);    //线索化二叉树
        if (pre->rchild == NULL)         //处理遍历的最后一个结点
            pre->rtag = 1;
    }
}
线索二叉树找前驱后继

会理解分析,能推出来

中序线索二叉树方便寻找前驱后继,先序线索二叉树方便寻找后继,后序线索二叉树方便寻找前驱

中序线索二叉树

中序线索二叉树的结点中隐含了线索二叉树的前驱和后继信息。在对其进行遍历时,只要先找到序列中的第一个结点,然后依次找结点的后继,直至其后继为空。

前驱

在中序线索二叉树中找到指定结点*p的中序前驱 pre

  • 若 p->ltag=1,则 pre=p->lchild

  • 若 p->ltag=0,则 pre=p的左子树中最右下结点

c++ 复制代码
//找到以P为根的子树中,最后一个被中序遍历的结点
ThreadNode *Lastnode(ThreadNode *p) {
//循环找到最右下结点(不一定是叶结点
    while (p->rtag == 0) p = p->rchild;
    return p;
}

//在中序线索二叉树中找到结点p的前驱结点
ThreadNode *Prenode(ThreadNode *p) {
//左子树中最右下结点
    if (p->ltag == 0) return Lastnode(p->lchild);
    else return p->lchild;//ltag==1直接返回前驱线索 
}

//对中序线索二叉树进行逆向中序遍历
void RevInorder(ThreadNode *T) {
    for (ThreadNode *p = Lastnode(T); p != NULL; p = Prenode(p)) visit(p);
}
后继

在中序线索二叉树中找到指定结点*p 的中序后继 next

  • 若 p->rtag=1,则 next=p->rchild
  • 若 p->rtag=0,则 next=p的右子树中最左下结点
c++ 复制代码
//找到以P为根的子树中,第一个被中序遍历的结点
ThreadNode *Firstnode(ThreadNode *p) {
//循环找到最左下结点(不一定是叶结点)
    while (p->ltag == 0) p = p->lchild;
    return p;
}

//在中序线索二叉树中找到结点p的后继结点
ThreadNode *Nextnode(ThreadNode *p) {
//右子树中最左下结点
    if (p->rtag == 0) return Firstnode(p->rchild);
    else return p->rchild;//rtag=1直接返回后继线索 
}

//对中序线索二叉树进行中序遍历(利用线索实现的非递归算法)
void Inorder(ThreadNode *T) {
    for (ThreadNode *p = Firstnode(T); p != NULL; p = Nextnode(p)) visit(p);
}

遍历空间复杂度为O(1)

先序线索二叉树
前驱

在先序线索二叉树中找到指定结点*p的先序前驱 pre

  • 若p->ltag=1,则 next =p->lchild
  • 若p->ltag=0

改用三叉链表可以很方便找到父节点

先序遍历中,左右子树中的结点只可能是根的后继,不可能是前驱

后继

在先序线索二叉树中找到指定结点*p的先序后继 next

  • 若p->rtag=1,则 next=p->rchild

  • 若p->rtag=0

    • 若p有左孩子,则先序后继为左孩子

    • 若p没有左孩子,则先序后继为右孩子

后序线索二叉树
前驱

在后序线索二叉树中找到指定结点*p的后序前驱 pre

  • 若 p->ltag=1,则 pre=p->Ichild

  • 若 p->ltag=0

    • 若p有右孩子,则后序前驱为右孩子

    • 若p没有右孩子,则后序前驱为左孩子

后继

在后序线索二叉树中找到指定结点*p的后序后继 next

  • 若 p->rtag=1,则 next = p->rchild

  • 若 p->rtag=0

改用三叉链表可以很方便找到父节点

后序遍历中,左右子树中的结点只可能是根的前驱,不可能是后继

哈夫曼树

考研重点:构造哈夫曼树、哈夫曼编码

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

  • 结点的权:一个表示某种意义的数值
  • 结点的带权路径长度:从树的根到任意结点的路径长度(经过的边数)与该结点上权值的乘积
  • 树的带权路径长度 :树中所有叶结点的带权路径长度之和,记为 W P L = ∑ i = 1 n w i l i WPL= \sum\limits_{i=1}^nw_il_i WPL=i=1∑nwili

特点:

  • 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大
  • 初始结点n个,构造过程中共新建了n-1个结点(双分支结点),即哈夫曼树的结点总数为2n-1
  • 每次构造都选择2棵树作为新结点的孩子,即哈夫曼树中不存在度为1的结点
  • 哈夫曼树并不唯一,但WPL必然相同且为最优

哈夫曼树的构造

给定n个权值分别为 w 1 , w 2 , ⋅ ⋅ ⋅ , w n w_1,w_2,···,w_n w1,w2,⋅⋅⋅,wn的结点,构造哈夫曼树的算法描述:

  1. 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F

  2. 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和,从F中删除刚才选出的两棵树,同时将新得到的树加入F中

  3. 重复上述步骤,直至F中只剩下一棵树为止

哈夫曼树编码

哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码,属于可变长度编码、前缀编码

在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码,其特点是对频率高的字符赋以短编码,而对频率较低的字符则赋以较长一些的编码,从而可以使字符的平均编码长度减短,起到压缩数据的效果

若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码

构造:

字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,构造哈夫曼树,将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记0表示转向左孩子,标记1表示转向右孩子

哈夫曼树不唯一,因此哈夫曼编码不唯一

例题

并查集

并查集是一种简单的集合表示,将各个元素划分为若干个互不相交的子集,同一子集中的各个元素组成一棵树。所有表示子集的树构成表示全集的森林。

支持以下3种操作:

  • Initial(S):将集合S中的每个元素都初始化为只有一个单元素的子集合,每个子集合的数组值为-1
  • Union(S,Root1,Root2):把集合S中的子集合 Root2 并入子集合 Root1。要求 Root1和Root2互不相交,否则不执行合并
  • Find(S,x):查找集合S中单元素x所在的子集合,并返回该子集合的根结点

存储结构

采用双亲表示法表示并查集,通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲结点为负数,绝对值表示集合包含元素个数。

优点:便于实现并查操作

代码:

c++ 复制代码
#define SIZE 10
int UFSet[SIZE];

基本操作

初始化

将集合S中的每个元素都初始化为只有一个单元素的子集合,每个子集合的数组值为-1

c++ 复制代码
void Initial(int S[]) {
    for (int i = 0; i < SIZE; i++) {
        S[i] = -1;
    }
}

查找

查找一个元素所属集合,从指定元素出发,一路向北,找到根节点

c++ 复制代码
int Find(int S[], int x) {
    while (S[x] >= 0)            //寻找x的根
        x = S[x];                
    return x;                    
}

时间复杂度为O(n)

优化思路:压缩路径,尽可能让树变矮

每次Find操作,先找根,再压缩路径(将查找路径上所有结点都挂到根节点下),可使树的高度不超过 , α ( n ) α(n) α(n) 是一个增长很缓慢的函数,对于常见的n值,通常 α ( n ) α(n) α(n) ≤4,优化后并查集的Find、Union操作时间开销都很低

代码:

c++ 复制代码
int Find(int S[], int x) {
    int root = x;
    while (S[root] >= 0)root = S[root];	//循环找到根
    while (x != root) {	//压缩路径
        int mid = S[x];
        S[x] = root;
        x = mid;
    }
    return root;
}

Find操作优化后,构造的树高不超过 O ( α ( n ) ) O(α(n)) O(α(n)),Find操作最坏时间复杂度为 O ( α ( n ) ) O(α(n)) O(α(n)),Union操作最坏时间复杂度为 O ( α ( n ) ) O(α(n)) O(α(n))

合并

把两个集合并为一个集合,只需将其中一个子集合根结点的双亲指针指向另一个集合的根结点

c++ 复制代码
void Union(int S[], int x, int y) {
    int fx = Find(S, x), fy = Find(S, y);
    if (fx != fy)	//要求fx和fy是不同集合,通过根节点是否相同判断
        S[fy] = fx;
}

时间复杂度为O(n)

优化思路:在每次Union操作构建树的时候,尽可能让树不长高,让小树合并到大树

c++ 复制代码
void Union(int S[], int x, int y) {
    int fx = find(S, x), fy = find(S, y);
    if (fx == fy) return;  //如果 x和 y的代表元一致,说明他们共属同一集合,则不需要合并,直接返回;否则,执行下面的逻辑
    if (S[fx] < S[fy]) {
        S[fy] = fx;        //如果 x的高度大于 y,则令 y的上级为 x
    } else {
        if (S[fx] == S[fy]) S[fy]++;    //如果 x的高度和 y的高度相同,则令 y的高度加1
        S[fx] = fy;                    //让 x的上级为 y
    }
}

Union操作优化后,构造的树高不超过 ⌊ l o g 2 n ⌋ + 1 \left\lfloor log_2n\right\rfloor+1 ⌊log2n⌋+1,Find操作最坏时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n),Union操作最坏时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)

附:思维导图

相关推荐
花王江不语23 分钟前
设计模式学习笔记
笔记·学习·设计模式
和光同尘@28 分钟前
74. 搜索二维矩阵(LeetCode 热题 100)
数据结构·c++·线性代数·算法·leetcode·职场和发展·矩阵
前端熊猫40 分钟前
CSS Grid 布局学习笔记
css·笔记·学习·grid
zl_dfq1 小时前
数据结构之【顺序表简介】
数据结构·顺序表
code bean1 小时前
【C# 数据结构】队列 FIFO
开发语言·数据结构·c#
一个 00 后的码农1 小时前
25会计研究生复试面试问题汇总 会计专业知识问题很全! 会计复试全流程攻略 会计考研复试真题汇总
经验分享·考研·面试·面试问题·25考研·考研复试·会计复试
梅茜Mercy1 小时前
数据结构:栈和队列详解(下)
数据结构
Trouvaille ~2 小时前
【C++篇】树影摇曳,旋转无声:探寻AVL树的平衡之道
数据结构·c++·算法·蓝桥杯·计算机科学·平衡二叉树·avl
肥肠可耐的西西公主2 小时前
前端(AJAX)学习笔记(CLASS 2):图书管理案例以及图片上传
前端·笔记·学习
Long_poem2 小时前
【自学笔记】Spring Boot框架技术基础知识点总览-持续更新
spring boot·笔记·后端