数据结构基础——树

是本人对浙大数据结构课程的学习过程笔记,非常口语化不专业。

二分查找

前提必须是元素的关键字有序,且连续存放的数据(数组)。

数据有序才能在二分之后判断是在前还是在后。

假设一数组按从小到大排列:

在这里插入图片描述

二分查找算法的时间复杂度O(logN),log以2为底。

之所以可以比顺序更快,就是因为多了事先排序,这就是树的意义。

一个有序(左支小右支大)二分查找判定树:

在这里插入图片描述

启示:可以通过树的形式存储数据,不依赖数组也能达到二分法logN的效率,以此解决动态查找问题。

树的定义

树是一种非线性的层级结构的数据结构,由结点组成。每个结点有且仅有一个父结点,根结点没有,故一颗N个结点的树有N-1条边,树也是保证结点联通的最小连接方式之一,所有子树互不相交。

度:结点的子树个数。

二叉树(Binary Tree)

一个子树有左右之分的度为二的树,一个可为空的有穷的结点集合。

如下列一种链表表示的二叉树例子,每个结点有2个指针,第一个(左子树)指向自己的第一个儿子,第二个(右子树)指向自己的第一个弟弟,没有则指向NULL。

在这里插入图片描述

特殊的二叉树(完全二叉树)

在这里插入图片描述

完全二叉树的每个结点的父结点序号正好是自己序号i/2取整,反过来求子结点也是如此,序号2i为左子结点,2i+1为右子结点,若结果>n则没有子结点。基于这一索引关系和紧凑的结构,完全二叉树非常适合用数组实现,紧密的顺序没有空洞,简单高效又不会存在空间浪费。

注意这里序号从1开始才行,很多以树为基础的结构会把0处用作哨兵不算做树里的结构,如果有的树算上0了,则左孩子下标为2i+1,右孩子下标为2i+2。

满二叉树每一层的结点个数等于,已知总结点数为N,可以求出与树高H的关系为也就是树高为H的完全二叉树的最大结点数,显然依此还可以推理出其左右子树的结点数,注意树高层数是从上往下数,最下面一层的层数最高。

求完全二叉树的左子树结点数

树高为H+1的完全二叉树由为H层的满二叉树和最后一层的X个子结点组成,有即H=,其中X的范围是[0, ]。显然只有当X=0时(满二叉树)H才为整数,故要向下取整,所以计算H时X可以直接忽略,即有H=,得到这个H就可以用把X反解出来了。然后再利用,即可求出左子树的结点数L,注意这里的X要取刚刚计算出的X和的最小值。

在这里插入图片描述

很多数据结构都将基于完全二叉树,因为其紧凑有序,索引关系简单明确。

二叉树性质

第i层最大结点数为,等比数列求和可知深度为k的二叉树最大结点数为,满树时即为最大数。

二叉树每个结点的度可能为2、1、0(叶结点),设叶结点数为n0,度为2的结点数为n2,则有n0=n2+1。因为每个度为2的结点是多了一个子树,也就一定会多一个叶结点,而度为1的结点是不影响n0和n2的,再加上根结点。

二叉树的存储结构

顺序存储(数组)

按完全二叉树的序号把结点依次存入一个数组,可以发现完全二叉树的每个结点的父结点序号正好是自己序号i/2取整,反过来求子结点也是如此,序号2i为左子结点,2i+1为右子结点,若结果>n则没有子结点。

对于一般二叉树可以补齐完全再顺序存储,但会造成空间浪费,以空间换时间,故一般二叉树会用链表表示。

链表存储

在这里插入图片描述

二叉树的遍历

在这里插入图片描述

二叉树遍历的本质:二维结构的线性化(一维化)

遍历就一定要为原本的某种数据结构产生一个对其结点的访问序列,不同的遍历方法就会产生不同的一维序列。

递归算法

先序遍历

注意对每个子树的遍历也是同样的先序遍历,故需要使用递归或迭代的方法实现。

在这里插入图片描述

中序遍历

把先序遍历的黄色代码移动到左右两次递归的中间(箭头处),即把①移到②后面——先中序遍历其左子树,再访问根结点,最后中序遍历其右子树。即每次先遍历到当前还未遍历的最左边的子树。具体顺序为(D B E F) A (G H C I)。

后序遍历

把先序遍历的黄色代码移动到左右两次递归的后面,即把①移到③后面——先后序遍历其左子树,再后序遍历其右子树,最后访问根结点。具体顺序为(D E F B) (H G I C) A。

先/中/后序路线一样,区别在于何时访问(打印)数据。**在该路线下,每个结点都经过了3次,在第一/二/三次访问即时先/中/后序遍历

非递归算法

大家族遍历的根本问题,如二维数据结构,一个结点会和两个结点相关联,若访问其中一个,会失去另一个的联系,故需要一个存储结构保存暂时不访问的结点,上文的先/中/后序的递归算法底层都使用了栈来保存当前结点。

递归算法通常效率低,由于递归操作本质要用堆栈实现,故理论上可以手动用堆栈实现该算法:先用栈保存走过的路径,在访问完子树后,退栈回到当前结点的父结点,进行下一步操作。

中序遍历

在这里插入图片描述

手动创建堆栈S,前文提到经过结点时访问的时刻决定了先中后序遍历,这段代码中Push(S,T)为第一个经过结点,T=Pop(S)为第二次经过结点,故把访问代码移到Push(S,T)之后即可实现先序遍历。

后序遍历是先访问左、右子树,再访问根结点,而在非递归算法中,利用栈回退到时,无法得知是从左子树还是从右子树回退到根结点的,故需要在压栈时添加信息,以便在退栈时判断。

层序遍历

除了利用堆栈保存当前结点,也可手动用队列来保存另一个未访问结点,按队列处理,实现层序遍历。

队列实现:

在这里插入图片描述

在这里插入图片描述

最终会按层序,从上到下,从左至右依次输出结点。

遍历应用

求二叉树的子结点

仅需在递归遍历代码中加个判断左右子树都为空的条件即可,先中后序都可

在这里插入图片描述

求二叉树的高度

由于需要提前知道两个子树的高度,故用后序的递归遍历的方法

在这里插入图片描述

二元运算表达式树遍历

在这里插入图片描述

生活中用的就是中缀表达式,故要有括号。

三种遍历已知中序和任意另一种遍历序列,即可唯一确定一颗二叉树,因为先后序一定程度上有对称相关(个人理解),无法确定左右树,信息不够。

例如用先序和中序遍历序列来确定一颗二叉树:

1.先根据先序遍历第一个结点确定根结点;

2.再利用根结点在中序遍历序列中分割出左右两个子树序列;

3.对左右子树分别递归使用此方法继续分解。

在这里插入图片描述

树的同构判别

当一颗树可以通过若干次左右孩子的互换变成另一个树,则称两棵树是同构的。

二叉树的表示

用静态链表来表示二叉树

在这里插入图片描述

定义一个结构体,并未使用指针,Tree是int型,即不用申请堆空间,由于下标有0,为区分指针代表0的NULL,以-1表示该结点指向的是空结点。

同时以此构建的结构数组表示不唯一,元素的顺序无所谓,可以通过遍历Left、Right未出现的下标判断根结点,即没有其他结点指向它。

静态链表构建二叉树

以下为部分关键代码

Tree BuildTree(struct TreeNode T[]){//输入结构数组构建二叉树
    int Root;//设置根结点序号变量
    scanf("%d\n", &N);//输入总结点数
    int check[N];//创建数组作为标志位,用来遍历找到根结点

    if (N) {
        for (size_t i = 0; i < N; i++)check[i] = 0;//初始化check
        for (size_t i = 0; i < N; i++) {//循环输入N次结点数据
            scanf("%c %c %c\n", &T[i].Element, &cl, &cr);//依次输入结点元素,左子树序号,右子树序号
            if (cl != '-') {
                T[i].Left = cl - '0';//由于以char型输入序号,故减去0转化成int
                check[T[i].Left] = 1;//将对应check位改为1
            }
            else T[i].Left = Null;//无左子树,设为-1
            if (cr != '-') {
                T[i].Right = cr - '0';
                check[T[i].Right] = 1;
            }
            else T[i].Right = Null;
        }
        for (size_t i = 0; i < N; i++)//通过查找check数组中的0元素序号找到根结点并返回
            if (!check[i])break;
        Root = i;
    }
    return Root;
}

同构判别

R1、R2为输入的两个根结点序号,即要判别的二叉树;T1、T2为构成R1、R2二叉树信息的结构数组。注意这里使用的是结构数组二叉树,就是在不断查找数组信息比对下标,全是int型,不要想复杂。

先判别各种特殊情况,主要有各种空子树,不断左右递归。

在这里插入图片描述

该片段最后一个if如果左边都为空,则递归判断右子树;

在这里插入图片描述

该片段若左子树均不空且左子树根结点相等,则开始递归判别T1、T2的左子树和右子树,否则再判别交换左右子树后是否同构。

二叉搜索树(Binary Search Tree)

一颗可为空的二叉树,满足以下性质:

1.非空左子树所有键值小于其根结点的键值;

2.非空右子树所有键值大于其根结点的键值;

3.左右子树也都是二叉搜索树。

即在构建树时就按某种顺序排序,这样就能利用前文二分查找的思想。

如以十二个月的英文缩写为键值构建的二叉搜索树,依次比较首字母

在这里插入图片描述

在查找时,若关键字小于根结点键值,则只需在左子树中继续查找,若 大于则反过来,若相等则搜索完成,返回指向当前结点的指针。

由于最坏情况下会退化成链的形式,故实际应用中不常用,但是基础。

二叉搜索树的查找

在这里插入图片描述

递归效率一般不高,尤其在数据量大时,通常可用循环(迭代)实现,但递归代码量少,可读性强。但这种在return时递归的叫“尾递归”,编译器会自动转换成循环实现。

在这里插入图片描述

可见该算法的复杂度理论上是O(logN),但与树的高度和结构有很大关系,当树往一边偏时效率变低,最坏情况会退化成链,故后续引出平衡二叉树。

由于二叉搜索树的性质,后续其他操作代码框架常与查找类似。

二叉搜索树的最值查找

由于二叉搜索树的性质,最小值一定在最左端点,最大值一定在最右端点,示例如下

在这里插入图片描述

只需判别到左子树为空的那个结点即为最小元素,最大同理。下图分别为递归和循环迭代函数示例。

在这里插入图片描述

二叉搜索树的插入

二叉搜索树的插入只会插入到空子树的位置,不会影响原有的结构,可采用与查找类似的方法。输入BST为结构体指针,二叉树根结点。

在这里插入图片描述

黄框if为插入部分,下面else为查找位置。当一直递归查找到某结点的对应子树为空,则执行黄框部分,将元素插入,作为该结点的对应子树。

插入代码其实把插入的步骤去掉,就和查找很像了,因为就是查找到位置之后插入,区别在于查找一般是已有值的,会结束在找到的那个元素结点。

二叉搜索树的插入时间复杂度与树的高度有关,理想情况下即平衡二叉树高度为O(log2N)。

二叉搜索树的删除

有以下三种情况:

1.删除的结点没有孩子结点,即叶结点:

直接删除,再把父结点的指针指向空。

2.要删除的结点只有一个子结点:

将其父结点的指针指向该子结点,再删除结点。

3.要删除的结点有左右两颗子树:

用右子树的最小元素或左子树的最大元素替代被删除结点,然后删除右子树的最小元素或左子树的最大元素,将问题转化成第一或第二种情况。

在这里插入图片描述—>在这里插入图片描述

为什么是右最小或左最大?

首先最大元素和最小元素一定没有两个子树,其次要删除的结点一定是比左大,比右小的,故要替换只能是左子树里最大或右子树里最小元素。
在这里插入图片描述

判断是否为同一颗二叉搜索树

前言:给定一个插入序列,可以唯一确定一颗二叉搜索树,反过来给定一颗二叉搜索树,其插入序列不唯一。

输入两个序列,判断两个序列构成的二叉树是否为同一颗

方法1:直接创建这两颗树,比较根结点、左子树、右子树是否一样,再递归;

方法2:不创建树,先比较序列第一个数(根结点)是否一样,若一样,再将其他数保持顺序地以根结点大小为界分成两个子序列(分出左右子树),再递归对每个子序列做如上操作;

方法3:将一个序列创建为初始树,判断其他序列是否与之一致。

方法3判断方法:初始树从根结点开始和其他序列的元素依次比对,如果位置不符则不是同一棵树,如何判断位置不符?

在初始树中按其他序列中的顺序搜索每个元素,如果每个元素在搜索时当前结点与自身不一致,且前面元素搜索时未出现过,则树不一致。详解见C++项目。

平衡二叉树 AVL(Balance Binary Tree)

平衡二叉树的定义

平衡二叉树是一种特殊的二叉搜索树。

由于创建/插入搜索树的结点的次序不同,会导致不同的深度和平均查找长度ASL。在同样条件下,树的高度越低,左右越平衡。

平衡因子BF(T)=,hl和hr分别为T的左右子树高度,任一结点左右子树高度差不超过1则称为平衡二叉树,也叫AVL树。注意是任一,即每一个结点子树都要平衡,而非只看根结点一棵。

这里高度的定义:每层中间的楼梯(通道),只有一个结点时高度为0。

n个结点的完全二叉树高度h=,即设高度为h的平衡二叉树的最小结点数为,可以发现以下规律:当前高度h的树的为左右子树的。

(仅限这里便于后续的公式推导,正常一般按每层结点算高度,即和现实楼层一样,一个结点时高度为1,应当有,反正在代码中多做个+1/-1即可)

在这里插入图片描述

即在平衡二叉树的递归条件下,尽量高且尽量少。尽量高:每个左右子树都有查且是1;尽量少:差的那层每个都只多一个。差的那层只有一个不代表左右子树结点数差就是1,只有高度为1的左右子树差是1,每多1层差都要多1,即满足最小结点数的左右子树的结点数差就等于左右子树更高的那个树的高度。

在这里插入图片描述

该式子与斐波那契数列很像,经过数学推导可以得出以下结论:

在这里插入图片描述
同样结点数n的平衡二叉树的最大高度h和完全二叉树同一个量级,树的相关算法的时间复杂度通常与树的高度直接相关。

平衡二叉树的调整

我们给1到12创建一颗平衡二叉树,先随便选取一个作为起始根结点,如3(mar),之后按搜索树的要求,以任意序列插入新结点,显然可能会改变每个结点的平衡因子,进而让树不平衡,故需要并不断调整树的结构来保持树的平衡。

在这里插入图片描述

也就是把右子树提上来变成新根结点,原根结点降下去变成左子树,则右子树的右子树成了新根结点的右子树,再把右子树的左子树变成原根结点的右子树(因为搜索树的性质,右子树的左子树还是在根结点的右边,一定比根结点大)。

这里的AL,BL,BR都是树,不是单个结点,RR插入是指 麻烦结点所在的子树是发现者的右子树的右子树,插入在BR里的麻烦结点本身左右都可。

LL,LR,RL旋转的定义同理,分别是在左子树的左子树里,左子树的右子树里,右子树的左子树里。

注意,这种旋转发现者不一定就是根结点,每个含有子树的结点都有可能;当一个麻烦结点导致多个不平衡的发现者时,调整最近的那个通常就全部平衡了,因为同一个麻烦结点的远处不平衡是近处不平衡导致的。

本质就是,麻烦结点插在发现者的子树的子树上,即构成了最近的发现者a->子树结点b->子树结点的子树结点c,要解决这种不平衡就是要找出三者中间值,用其替代不平衡点(成为新的相对根结点),会有这四种情况:RR插入,LL插入 ,RL插入,LR插入,要做对应的旋转。又分为两类:RR/LL和RL/LR——RR/LL就是顺序同向,子树结点b就是中间值;RL/LR就是顺序反向,子树结点的子树结点c就是中间值。

LR插入:麻烦结点是Jan,发现者是May,子树结点是Aug,子树结点的子树结点是Mar,有May->Aug->Mar,LR旋转调整成Aug<-Mar->May,如下图

在这里插入图片描述

RL插入:麻烦结点是Feb,最近的发现者是Aug,子树结点是Jan,子树结点的子树结点是Dec,有Aug->Jan->Dec,RL旋转调整成Aug<-Dec->Jan,如下图

在这里插入图片描述

堆(heap)

优先队列(Priority Queue)

特殊的“队列“,取出元素的顺序先考虑元素的优先权(关键字)大小,同优先权才按进入队列的时间先后。

各种实现优先队列的数据结构的插入/查找/删除操作复杂度:

在这里插入图片描述

用链表和数组的数据结构有舍有得。

如果用树呢?比如二叉搜索树:

增/删/查——与二叉搜索树的高度正相关,如果树的结构比较平衡,最好情况就是平衡二叉树的O(logN),log以2为底。

但多次做插入/删除操作还是很可能导致树不再平衡,比如优先队列要删除最值,只会删除最左侧或最右侧的结点,必然导致树越来越歪,高度不再是O(logN)。

可以发现删除最值很容易影响树的平衡,插入还好,所以引出最大堆/最小堆。

最大堆(MaxHeap)/最小堆(MinHeap)

为了避免这些结构实现的优先队列的缺点,需要构建一个稳定高效的易于操作最值的数据结构。

首先在结构上使用高效的完全二叉树,其次在性质上使每个结点都是其树的最值,即不小于或不大于其子树上的所有元素值,这样删除最值就是拿掉当前根结点。

满足以上条件的数据结构就是堆,如果根结点是最大值则称为最大堆或大顶堆,是最小值则称为最小堆或小顶堆。本节均以最大堆为例。

从根结点开始到任意叶结点都是有序的,一路变大或一路变小,插入时将沿着这一有序轨迹操作,但只是局部有序,如兄弟结点之间没有限制,故同一序列可以构成的堆是不唯一的,但如果插入算法也是确定的,如用堆的插入一节Insert()函数,则结果是唯一的。

注意,堆的结构擅长需要排最值的数据结构,这里的最值判断并非只局限于数据域,比如下文哈夫曼树里是排权值。

堆的创建

在这里插入图片描述

开辟空间时多留一个位置,下标从1开始,0用作哨兵,下面具体操作时再介绍,这里只是开辟了堆的空间,只有头结点。

个人理解,其实后面堆的一系列操作,都是操作结点上的元素,或者说堆结构上的元素,尤其是对于数组,构建堆就是将数组抽象成一个二叉堆,堆的结构一开始创建都定义好了,因为最大容量是定好的,排列顺序是Z字形也是确定的,所以操作集就是在挪动结点上的元素,增删就再改下堆的size大小值即可。

堆的插入

先按完全二叉树的顺序插入新结点,然后再通过判断从新结点到根结点是否仍为有序序列去调整位置:沿着这一自下而上的轨迹,若当前结点的父结点值小于新结点,就把父结点的值赋值给当前结点,直到当前结点的父结点值大于新结点的停下,并将当前结点的值赋值为要插入的数据。

在这里插入图片描述

如果不加哨兵,那么在for处还需要判断一下i>1,否则如果传入了一个比当前树根结点还大的值,就会放进0处,不属于该堆的范围。故可以设置一个足够大的哨兵(或者每次设成要插入的那个值,结束后再设成根结点的值),省去判断i>1的过程,还可以在插入前以此先判断下是否在合法范围内。

由于堆特殊的结构性质,如果简单地这样使用插入操作来创建堆效率很低,可以看到示例里创建的堆实际上是一条单向链表,该方法只适合向已有堆插入元素。堆的具体结构如何构建,见堆的建立一节。

堆的删除

由于堆的目的和结构,堆的删除特指删除最(大)值。

为了不破坏堆的结构,把根结点删除后,利用最后一个结点进行调整:取出最后一个结点的值并删除最后一个结点的位置(H->size–即可),然后从空的根结点依次往下找合适的位置,先找出当前结点的左右两个子结点中的较大值,再和最后一个结点的值比较,将这其中的较大值放在当前结点,若较大值不是最后一个结点的值,而是左右子结点中的一个,则沿该子结点继续往下,直到最后一个结点找到合适的位置,可知最差情况是比之前进一位。

在这里插入图片描述

temp为最后一个结点的值,取出后再将堆的当前大小减一,Parent为从根结点开始的当前结点下标,Child为当前结点的子结点,默认为左子结点,在红框处代码为找出左右子结点的较大值,若为右结点,则将Child++,且if中第一个条件用来判断当前的左子结点是否为最后一个结点,这样就没有右子结点要比较了。for循环以减一后的堆的当前大小为界,最差是减少后堆的最后一位。

堆的建立(Heapify法)

堆常用于堆排序,要建立最大堆,即将给定序列的N个元素按最大堆的要求存放在一个一维数组中(数组可以利用完全二叉树的索引关系),如果利用插入操作插入到一个初始为空的堆,时间复杂度为O(NlogN),简单也可行,但效率低。

用自下而上的堆化(Heapify)可以把复杂度降到线性O(N),也叫下滤PerDown:

先按序列原本的顺序直接把N个元素存入,满足完全二叉树的结构特性,再调整各结点位置,满足最大堆的有序特性。

具体调整的思路和删除很像,这也是为什么堆的建立要在后面介绍。

删除操作中核心是已知被删除的根结点左右子树都是堆,此时在空的根结点处放了一个新的结点(最后一个结点)如何调整,方法是自上而下的堆化。而对于一个乱的完全二叉树,自上而下显然是不可能的,故要从最后一个结点和其父结点构成的堆开始自下而上、从后往前的堆化,这里不是只调整自下而上一条轨迹,是要按完全二叉树的方向,反向的Z型调整,以一个小堆为单位。先将最后一个结点和其父结点、兄弟结点(如果有的话)的结构调整成堆,然后逆序把每个结点及其子树作为小堆来调整。

在这里插入图片描述

如图调整顺序应为(87及其子树)—>(30及其子树)—>(83及其子树)—>(43及其子树)—>(66及其子树)—>(79及其子树)。其实是从最后一个结点9开始的,但是因为叶结点必然是堆,所以9到38全部跳过。

删除的核心就是一次根结点的下滤操作——即不需要删除并抛出根结点再用最后一个结点代替,而是直接用该子树的根结点作为temp,其他基本一样。

而堆的建立则是从堆尾开始逆向不断执行下滤操作,PerDown()的参数p是要下滤的根结点下标,改成0(或1)即是删除的下滤过程,这里则是通过BuildHeap()进行一个反向的循环下滤。

/* 下滤操作:将H中以H->Data[p]为根的子堆调整为最大堆 */
void PercDown(MaxHeap H, int p)
{
    int Parent, Child;
    ElementType X;

    X = H->Data[p]; /* 取出根结点存放的值 */
    for (Parent = p; Parent * 2 <= H->Size; Parent = Child)
    {
        Child = Parent * 2;
        if ((Child != H->Size) && (H->Data[Child] < H->Data[Child + 1]))
            Child++; /* Child指向左右子结点的较大者 */
        if (X >= H->Data[Child])
            break; /* 找到了合适位置 */
        /* 下滤X */
        H->Data[Parent] = H->Data[Child];
    }
    H->Data[Parent] = X;
}

/* 将一个无序的数组调整为最大堆  */
void BuildHeap(MaxHeap H)
{
    /* 这里假设所有H->Size个元素已经存在H->Data[]中 */
    int i;

    /* 从最后一个结点的父节点开始,到根结点1 */
    for (i = H->Size / 2; i > 0; i--)
        PercDown(H, i);
}

该示例代码的Maxheap是个指针类型,且BuildHeap里是for(i=size/2;i>0;i–),根结点从1开始。

调用时先初始化一个堆Maxheap heap;xxxx; 然后heap->Data[i] = array[i - 1];将数组array拷贝到heap->Data中,之后调用BuildHeap(heap)即可。

下面是封装成最大堆类的示例,创建了向量heap用于存储元素,函数maxHeapify()即为下滤操作,有参构造MaxHeap()用初始化列表的方式使用参数array来初始化类的成员变量heap,然后自下而上循环调用maxHeapify(),使向量heap堆化,函数getHeap()可以获取构建好的最大堆heap。

class MaxHeap {
private:
    vector<int> heap;
    void maxHeapify(int i, int n) {
        int largest = i, left = 2 * i + 1, right = 2 * i + 2;
        if (left < n && heap[left] > heap[largest]) largest = left;
        if (right < n && heap[right] > heap[largest]) largest = right;
        if (largest != i) {
            swap(heap[i], heap[largest]);
            maxHeapify(largest, n);
        }
    }

public:
    MaxHeap(vector<int>& array) : heap(array) {
        int n = heap.size();
        for (int i = n / 2 - 1; i >= 0; --i) maxHeapify(i, n);
    }

    vector<int> getHeap() { return heap; }
};

这里建堆的是for(i=size/2-1;i>=0;–i),故根结点是从0开始。

//调用:
vector array = {4, 1, 3, 2, 5};
MaxHeap maxHeap(array);
array = maxHeap.getHeap();
//或者直接用匿名对象
array = MaxHeap(array).getHeap();
//范围for循环输出:
for (int num : maxHeap.getHeap()) cout << num << " ";

下滤操作的时间复杂度是logN,因为下滤操作是从根结点遍历比较到一个叶结点的过程,即等于(完全)二叉树高logN。而我们需要遍历N/2次,虽然看起来这种创建堆的方法时间复杂度是O(NlogN),但实际上可由递推公式得到为O(N)。

哈夫曼树(最优二叉树)

带权路径长度(WPL):设二叉树有n个叶子结点,每个叶子结点带有权值,从根结点到每个叶子结点的长度为,则每个叶子结点的带权路径长度之和就是: W P L = ∑ k = 1 n ( w k ∗ l k ) WPL = ∑^n_{k=1}(w_k * l_k) WPL=k=1n(wklk)

哈夫曼树或最优二叉树就是指WPL最小的二叉树,所有字符(包括符号,回车等)都在叶结点,不存在出现在父结点。

哈夫曼树的构造

每次把权值最小的两颗二叉树合并,每个根结点的数据为两个子树权重之和。

如何找出最小的两颗树?就是利用(最小)堆。

先按权重创建一个最小堆H,然后依次从最小堆取出两个根结点,合并成一个新的小二叉树,根结点为两个根结点权重之和,然后再插入回最小堆H,循环N-1次,N为最小堆的大小。最后形成 的新最小堆即变成了哈夫曼树,此时结点数已不同,因为合并出了很多只含权重的父结点,只有叶结点是原来堆的结点。

可以发现,哈夫曼树的特点就是没有度为1的结点,每个结点必然有两个或没有子结点,故根据二叉树的性质可知n个叶子结点的哈夫曼树共用2n-1个结点。

左右子结点虽然大小顺序是紧挨着的,但并未规定他们两者的左右顺序,故哈夫曼树任意父结点左右子树交换后还是哈夫曼树;当给定数据出现或在过程中出现权值相同的情况,会存在不同构的哈夫曼树,但WPL都一样的。以上情况都会使得下文的哈夫曼编码不唯一。

在这里插入图片描述

整体时间复杂度为O(NlogN),DeletMin()见堆的删除一节。

哈夫曼编码

在编码中为节省空间和提高效率,可以考虑根据权重分配编码位数,权重高的编码位数少,权重低的往多排,但是这样位数不一致会导致二义性,比如长编码的前缀正好就是短编码,为了避免二义性,可使用前缀码:数据只存在叶结点上。

从根结点开始,往左走为一位0,往右走为一位1,叶结点都在最后同一层,所有字符编码位数等长,不会出现二义性,如下:

假设四个字符的频率为 a:4,u:1,x:2,z:1

在这里插入图片描述

利用哈夫曼树来编码,权重越大的分支越长,则可以做到编码位数按权重分配,节省编码空间,如下:

在这里插入图片描述

可见WPL更低。

注意,哈夫曼编码不唯一,且虽然哈夫曼算法得到的一定是最优编码,但反过来最优编码不一定由哈夫曼编码得到,比如确定一个哈夫曼编码后交换子节点依然能得到等长的最短的WPL。

集合

交,并,补,差,查

并查集:是一种数据结构,用于表示集合的分组和合并,包括了合并集合、查询某元素属于什么集合两个主要操作。

集合的存储表示

可以用树的结构来存储集合,树的每个结点代表该集合的一个元素,用存储在根结点的元素来代表集合。原始集合仅一层结构,若要合并,则把一个根指向另一个根(终点根看情况可以指向空或自己,指向自己时可用来自相等判断,数组集合可以指向-1),出现多个集合合并嵌套时,层数可能会变多,而查询某元素属于哪个集合只需一直往上找根结点即可。

这里要用的不是二叉树了,每个父结点可能有多个子结点,且指针方向相反,为子结点指向父结点。

在这里插入图片描述

通常可以用数组表示这种树的结构,数组中每个元素是一个结构体包含数据域Data和父结点指针(下标)Parent,组成了三个集合:

在这里插入图片描述

可以发现,对于有些数据可以直接用数组下标来表示元素,除了省下数据域,更可以极大提高效率,如该例是有顺序编号的数据,再做个映射,把1-10映射成0-9。但如果数据不连续,且不是有规则的映射的话,会浪费空间。

集合的查找

已知元素值为X,找出所在的集合(根结点),即先找到X的位置,再根据X的父结点指针一直往上找出根结点。

在这里插入图片描述

红框为查找X的循环,找到或遍历完退出,若找到则此时的i为x的下标,黄框为往上查找根结点的循环,该例为数组下标集合,结点值均>=0,根结点指向-1。

集合的合并

已知元素值为X1和X2,先找出各自所在的集合,若不同根,则将一个根结点的父结点指针设成另一个根结点的数组下标:

在这里插入图片描述

并查集的优化

如果不加策略的做合并,这个集合树可能会越来越高,甚至倾斜退化成单链表,这样会严重影响如Find()的查找效率。故尽量把小集合并到(指向)大集合,这样合并甚至有可能不会增加高度,所以就需要知道每个集合的元素个数,最好能直接从这个集合中显式地读取到,如果给元素结构体再加一个分量呢?显然很浪费空间,因为每个集合只需要一个计数空间即可。

按秩归并(优化Union)

如果不想额外开辟空间,我们可以利用根结点的指针域来表示,对于数组来说下标只会是0和正整数,根结点指向父结点的指针域只要是负数均可,我们可以把-1改成负的元素个数,这样就可以直接通过判断根结点的指针域大小来判断集合的大小,故要改写合并函数,在最后的合并代码前加上判断条件来动态调整归并方向。

理想情况应该是深度低的视为小集合,深度高的视为大集合,这样的小集合指向大集合后不会增加树的深度,只有相同时深度才会加一。实战中统一称为用树的秩来衡量,秩只需要是高度的相关值即可,比如刚刚按规模(元素个数)也可。

按秩归并就是把根结点的指针域设为负的秩(树高或规模),在初始化时可以赋为-1,因为每一个独立结点的秩就是1。若是按树高归并,则只有在秩相同时才要让新秩加一;若是按规模归并,则每次归并后都需要把两者的秩相加,重新赋给大的那个。按秩归并最坏情况的树高为O(logN)

虽然树高更加贴合本质,规模有时不够准确,但是按规模结合下文的路径压缩更好用,下图为以数组下标为元素时的按规模归并实现,这里默认传入的两个根结点,如果是实际函数,一般传入两个元素,要先用Find()找出其根节点。注意这里根结点中的秩都是负数,方向要反过来。如果是按树高,只要在相等时Root++,不用每次Root+=

在这里插入图片描述

路径压缩(优化Find)

同时也可以选择在查找函数上做优化,使用路径压缩来调整树的结构,在查找到根结点后,将在查找路径上的元素全都直接指向根结点,提高后续查找效率。

递归法,最终会返回根结点给路径上的每个结点,同时完成了查找和压缩:

在这里插入图片描述

如果最后的return没有用s[x]接收的话,就只是一个递归找根结点的函数,但每次递归加了s[x]=,就能在最后归的过程,把查找路径上的元素全都直接指向了返回的根结点。

不做任何优化的查找N次含有N个元素的集合的时间复杂度为O(N平方)。

路径压缩通常要结合按秩归并使用,即必须先有按秩归并,再做路径压缩,因为如果不做按秩归并,路径压缩并不会带来明显提升,仍然是近似线性的。而按秩归并后的集合结构可以先让一次查找的复杂度从O(N)变成O(logN),即查找N次为O(NlogN),再做路径压缩可以把logN变成不大于4的常数,那么此时查找N次含有N个元素的集合的复杂度就变成了O(4N),故要根据实际情况选择是否做路径压缩,如果N足够大,大约超过10^4这个数量级就要做了。

  • 28
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值