【PAT】3.树

【PAT】3.树

树与二叉树

满足连通、边数等于顶点数减1的结构一定是一棵树。

二叉树的递归定义:

  1. 要么二叉树没有根结点,是一棵空树
  2. 要么二叉树由根结点、左子树、右子树组成,且左子树和右子树都是二叉树

二叉树与度为2的树的区别:左右子树严格区分

完全二叉树的存储结构

对完全二叉树当中的任何一个结点(设编号为x),其左孩子的编号一定是2x,而右孩子的编号一定是2x+1。即完全二叉树可以通过建立一个大小为 2 k 2^k 2k的数组来存放所有结点的信息,其中k为完全二叉树的高度,且1号为存放的必须是根结点。

除此之外,该数组中元素存放的顺序恰好为该完全二叉树的层序遍历序列。判断某个结点是否为叶结点的标志为:该结点(记下标为root)的左子结点的编号root*2大于结点总个数n;判断某个结点是否为空结点的标志为:该结点下标root大于结点总个数n。

二叉树的遍历

  • 无论是先序、中序、后序遍历的哪一种,左子树一定先于右子树遍历,“先中后”是指根节点root在遍历中的位置
  • 先序遍历序列的第一个一定是根节点,后序遍历序列的最后一个一定是根节点,中序遍历总是把根节点放在左子树和右子树中间
  • 中序序列可以与先序序列、后序序列、层次序列中的任意一个来构建唯一的二叉树,而后三者两两搭配或是三个一起上都无法构建唯一的二叉树
  • 先序序列与中序序列还原二叉树:当前先序序列区间为[preL,preR],中序序列区间为[inL,inR],在中序序列中找到结点k,使pre[preL]=in[k],那么左子树的结点个数为numLeft = k - inL,左子树的先序序列区间就是[preL+1,preL+numLeft],左子树的中序序列区间就是[inL,k-1],右子树的先序序列区间是[preL+numLeft+1,preR],右子树的中序序列区间是[k+1,inR]。递归边界是先序序列的长度小于等于0。
  • 反转二叉树的操作只需要进行后序遍历,在后序遍历访问根结点时交换lchild和rchild

二叉树的的静态实现

静态的二叉链表,结点的左右指针域使用int型代替,用来表示左右子树的根节点在数组中的下标。建立一个大小为结点上限个数的node型数组,所有动态生成的结点都直接使用数组中的结点,所以对指针的操作都改为对数组下标的访问。

如果题目直接给的是结点编号的关系,使用二叉树的静态写法会比较方便。

struct Node{
    int data;
    int lchild;
    int rchild;
} node[maxn];

树的遍历

这里的“树”是一般意义上的树,即子节点个数不限且子节点没有先后次序的树

树的静态写法

struct Node{
    typename data; //数据域
    vector child; //指针域,存放所有子结点的下标
}node[maxn]; //结点数组,maxn为结点上线个数

如果题目不涉及结点的数据域,只需要树的结构,上面结构体简化成 vector<int> child[maxn]

当需要新建一个结点时,按顺序从数组中取出一个下标

int index = 0;
int newNode(int v){
    node[index].data = v; //数据域为v
    node[index].child.clear(); //清空子结点
    return index++; //返回结点下标,并令index自增
}

树的先根遍历:先访问根结点,再去访问所有子树

void preOrder(int root){
    printf("%d ", node[root].data);//访问当前结点
    for(int i = 0; i < node[root].child.size(); i++){
        preOrder(node[root].child[i]);//递归访问结点root的所有子结点
    }
}

树的层序遍历(记录层号)

struct Node{
    int layer;
    int data;
    vector<int> child;
} node[maxn];

void LayerOrder(int root){
    queue<int> q;
    q.push(root);
    node[root].layer = 0;//记根结点的层号为0
    while(!q.empty()){
        int front = q.front();
        printf("%d", node[front].data);//当前结点的数据域
        q.pop();//队首元素出队
        for (int i = 0; i < node[front].child.size(); i++){
            int child = node[front].child[i];//当前结点的第i个子结点的编号
            node[child].layer = node[front].layer + 1;//子结点层号为当前层号加1
            q.push(child); //将当前结点的所有子结点入队
        }
    }
}
  • 对所有合法的DFS求解过程,都可以把它画成树的形式,此时死胡同等价于树中的叶子结点,而岔道口等价于树中的非叶子结点,并且对这棵树的DFS遍历过程就是树的先根遍历过程
  • 对所有合法的BFS求解过程,都可以像DFS那样画出一棵树,并且将广度优先搜索问题转换为树的层序遍历的问题

二叉查找树

二叉查找树的删除

把以二叉查找树中比结点权值小的最大结点称为该结点的前驱,而把以结点权值大的最小结点称为该结点的后继,显然。结点但的前驱是该结点左子树中的最右结点,后继是该结点右子树中的最左节点

因此删除操作的基本思路为:

  1. 如果当前结点root为空,说明不存在给定权值的结点,直接返回
  2. 如果当前结点root的权值为给定的权值x,说明找到了想要删除的结点,进入删除处理
    1. 如果当前结点root不存在左右孩子,说明是叶子结点,直接删除
    2. 如果当前结点root存在左孩子,那么在左子树中寻找结点前驱pre,然后让pre的数据覆盖root,接着在左子树删除pre
    3. 如果当前结点root存在右孩子,那么在右子树中寻找结点后继next,然后让next的数据覆盖root,接着在右子树删除next
  3. 如果当前结点root的权值大于给定的权值x,则在左子树中递归删除权值为x的结点
  4. 如果当前结点root的权值小于给定的权值x,则在右子树中递归删除权值为x的结点

优化:可以在找到欲删除结点root的后继结点next后,不进行递归,而是通过这样的手段直接删除该后继:假设结点next的父亲结点是结点S,显然结点next是S的左孩子,那么由于结点next一定没有左子树,便可以直接把结点S的右子树代替结点next成为S的左子树,这样就删去了结点next。前驱同理。这个优化需要在结点定义中额外记录每个结点的父亲结点地址。

但是总是优先删除前驱(后继)容易导致树的左右子树高度极度不平衡,使得二叉查找树退化成一条链。解决方法有两种:每次交替删除前驱或后继、记录子树高度,总是优先在高度较高的一棵子树里删除结点。

二叉查找树的性质

  • 即使是一组相同的数字,如果插入它们的顺序不同,最后生成的二叉查找树也可能不同
  • 对二叉查找树进行中序遍历,遍历的结果是有序的

平衡二叉树

AVL树仍然是一棵二叉查找树,其左子树和右子树的高度之差的绝对值不超过1,左子树与右子树的高度之差称为该结点的平衡因子。可以保证树的高度在每次插入元素后仍能保持 O ( l o g n ) O(logn) O(logn)的级别。

需要在树的结构中加入一个变量height,来记录以当前结点为根结点的子树的高度。结点root所在子树的height等于其左子树的height与右子树的height的较大值加1。

struct Node{
    int data, height;//data为结点权值,height为当前子树高度
    Node *lchild, *rchild;//左右孩子结点地址
};

AVL的插入操作

先考虑局部的旋转操作:结点B本来是根结点A的右子树,现在将B想自己当根结点,假设指针root指向结点A,指针temp指向结点B,左旋调整过程分为三个步骤:(左旋与右旋的对称本质——它们互为逆操作)

  1. 让B的左子树成为A的右子树
  2. 让A成为B的左子树
  3. 将根结点设定结点B

接下来讨论插入操作:往AVL树中插入一个结点时,一定会有结点的平衡因子发生变化,此时可能会有结点的平衡因子的绝对值大于1,这样以该结点为根结点的子树就是失衡的,需要调整。显然只有在从根结点到该插入结点的路径上的结点才可能发生平衡因子变化。可以证明,只要把最靠近插入结点的失衡结点调整到正常,路径上的所有结点就都会平衡。假设最靠近插入结点的失衡结点是A,显然它的平衡因子只可能是2或者是-2,假设是2,则左子树比右子树大2,以结点A为根结点的子树一定是LL和LR型之一,当结点A的左孩子的平衡因子是1时为LL型,是-1时为LR型。

并查集

并查集支持下面两个操作

  1. 合并:合并两个集合
  2. 查找:判断两个元素是否在同一个集合中

并查集就是用一个数组father[N]实现的,其中father[i]表示元素i的父亲节点,而父亲结点本身也是这个集合内的元素。如果father[i] = i,说明元素i是该集合的根结点,对同一集合来说只存在一个根结点,且将其作为集合的标识。

性质:并查集产生的每一个集合都是一棵树

并查集的初始化

一开始每个元素都是独立的一个集合,因此需要令所有father[i]等于i

并查集的查找

由于一个集合只存在一个根结点,因此只需要寻找给定结点的根结点即可,即反复寻找父亲结点,直到找到根结点。

优化查询操作(路径压缩):把当前查询结点的路径上的所有结点的父亲都指向根结点,查找的时候就不需要一直回溯区找父亲了,这样查找函数均摊效率为 O ( 1 ) O(1) O(1)

并查集的合并

先判断两个元素是否属于同一个集合,只有当两个元素属于不同集合时才合并,而合并的过程是把其中一个集合的根结点的父亲指向另一个集合的根结点。

堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子结点的值。如果父亲结点的值大于或等于孩子结点的值,称这样的堆为大顶堆。堆一般用于优先队列的实现,默认情况下使用的是大顶堆。

堆的基本操作

给定一个初始序列,如何将它建成一个堆?

用数组来存储完全二叉树,第一个结点存储于数组的1号位,数组i号位表示的结点的左孩子就是2i号位,而右孩子是(2i+1)号位。

向下调整是把结点从上往下的调整:总是将当前结点V与它的左右孩子比较,假如孩子中存在权值比结点V的权值更大的,就将其中权值最大的那个孩子结点与结点V交换;交换完毕后继续让结点V和孩子比较,直到结点V的孩子的权值都比结点V的权值小或者结点V不存在孩子结点。向下调整的时间复杂度是 O ( l o g n ) O(logn) O(logn)

那么建堆的过程如下:假设序列中元素的个数为n,由于完全二叉树的叶子结点个数为 ⌈ n 2 ⌉ \left \lceil \frac{n}{2} \right \rceil 2n,因此数组下标在 [ 1 , ⌊ n 2 ⌋ ] [1,\left \lfloor \frac{n}{2} \right \rfloor] [1,2n]范围内的结点都是非叶子结点。于是可以从 ⌊ n 2 ⌋ \left \lfloor \frac{n}{2} \right \rfloor 2n号位开始倒着枚举结点,对每个遍历到的结点i进行[i,n]范围的调整。倒着调整是因为每次调整完一个结点后,当前子树中权值最大的结点就会出在根结点的位置,这样当遍历到其父亲结点时,就可以直接使用这个结果。也就是说保证了每个结点都是以其为根结点的子树的权值最大的结点。建堆的时间复杂度为 O ( n ) O(n) O(n)

删除堆中最大元素(堆顶元素):用最后一个元素覆盖堆顶元素,然后对根结点进行向下调整。时间复杂度为 O ( l o g n ) O(logn) O(logn)

往堆里添加一个元素:把待添加的元素放在数组的最后(完全二叉树的最后一个结点的后面),然后进行向上调整操作。向上调整总是把欲调整结点与父亲结点比较,如果权值比父亲结点大,就交换其与父亲结点,这样反复比较,直到到达堆顶或是父亲结点的权值较大为止。时间复杂度为 O ( n ) O(n) O(n)

堆排序

堆排序是指使用堆结构对一个序列进行排序,这里讨论递增排序的情况。在建堆完毕后,重复下面步骤:取出堆顶元素,然后将堆的最后一个元素替换至堆顶,再进行一次针对堆顶元素的向下调整。直到堆中只有一个元素为止。

具体实现时为了节省空间,可以倒着遍历数组,假设当前访问到i号位,那么将堆顶元素与i号位的元素交换,接着在[1,i-1]范围内对堆顶元素进行一次向下调整即可。

哈夫曼树

叶子结点的路径长度是指从根结点出发到达该结点所经过的边数,把叶子结点的权值乘以其路径长度的结果称为这个叶子结点的带权路径长度树的带权路径长度(Weighted Path of Tree, WPL) 等于它所有叶子结点的带权路径长度之和。

哈夫曼树构建思想:反复选择两个最小的元素,合并,直到只剩下一个元素。对同一组叶子节点来说,哈夫曼树可以不是唯一的,但是最小带权路径长度一定是唯一的。对哈夫曼树不存在度为1的结点,并且权值越高的结点相对来说更加接近根结点。

哈夫曼编码

前缀编码:任何一个字符的编码都不是另一个字符编码的前缀的编码方式,它的存在意义在于不产生混肴,让解码可以正常进行。

对哈夫曼树上的所有分支进行编号,将所有左分支标记为0,右分支标记为1,那么对树上的任意一个结点,都可以根据从根结点出发到达它的分支顺序得到一个编号。并且对于任何一个叶子结点,其编号一定不会成为其他任何一个结点编号的前缀。

如果把叶子结点A、B、C、D的出现次数(频数)作为各自叶子结点的权值,那么字符串编码成01串后的长度实际上就是这棵树的带权路径长度,显然哈夫曼编码是能使给定字符串编码成01串后长度最短的前缀编码

哈夫曼编码使针对确定的字符串来讲的。只有对确定的字符串,才能根据其中字符的出现次数建立哈夫曼树,于是才有对应的哈夫曼编码。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值