数据结构与算法之美笔记(七)树

首先说下我对树的理解:树是每个结点只能对应一个父节点,但是可以有0个或多个子结点的数据结构,所以可以表示一对多的关系
然后贴个术语的图
在这里插入图片描述
在这里插入图片描述

前驱节点:对一棵二叉树进行中序遍历,按照遍历后的顺序,当前节点的前一个节点为该节点的前驱节点。

后继节点:对一棵二叉树进行中序遍历,按照遍历后的顺序,当前节点的后一个节点为该节点的后继节点


二叉树
每个节点最多有两个子节点,分别是左子节点和右子节点。
满二叉树:叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点
完全二叉树:叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大

在这里插入图片描述


二叉树基于数组的存储法
如下图的例子,就是根结点的索引为i,左子结点的索引为2 * i,2 * i+1

在这里插入图片描述

易知这种方法很适合完全二叉树,毕竟如下图这种,就会有很多子结点对应的数组空间被浪费
在这里插入图片描述
所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。

堆其实就是一种完全二叉树,最常用的存储方式就是数组。


二叉树的遍历
每个节点最多会被访问两次,所以遍历操作的时间复杂度,跟节点的个数n成正比,也就是说二叉树遍历的时间复杂度是O(n)


挖坑:卡特兰数:因为课后题是这道:给定一组数据,比如1,3,5,6,9,10。你来算算,可以构建出多少种不同的二叉树?


二叉查找树
就是让左子树的结点都比自己小(大),让右子树的结点都比当前节点大(小)
麻烦的是删除操作:如果被删除结点左右两个子结点都不为空,则删除他的后继结点(我前文特地给出了定义,即右子树的最左下方的结点)
注意二叉查找树的一个性质:他的中序遍历序列是有序的(所以也叫作二叉排序树),所以查找前驱和后继结点就容易起来了


支持重复数据的二叉查找树:
如果插入多个键值相同的K-V对象,有两种方法:

  1. 在该结点上用链表或数组存储这多个对象
  2. 在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。
    在这里插入图片描述当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。
    在这里插入图片描述
    对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
    在这里插入图片描述

这种一般的二叉查找树,不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比,也就是O(height)。但是极端情况下会出现以下这种退化情况
在这里插入图片描述


平衡二叉查找树

平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些

平衡二叉查找树很多,如AVL树、Splay(伸展树)、Treap(树堆)以及作者讲解的重点:红黑树


RBtree

红黑树的要求:

  1. 根节点为黑色
  2. 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  3. 任两个红色结点都不能是父子关系
  4. 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点

在这里插入图片描述

在实际实现中,那个NIL的叶节点可以共用一个黑色的、空的叶子节点。
在这里插入图片描述


为什么说红黑树是“近似平衡”的?
分析:根据二叉平衡树的结论,我们只需要分析出红黑树的高度比较稳定地趋近log2n。
一步步来:

  1. 如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少呢?
    注意这里将没有了父结点的结点直接令他的父节点为祖父结点,得到的就是一棵2-3-4树(即子结点个数可以为2、3、4的多叉树)
    在这里插入图片描述
  2. 前面红黑树的定义里有这么一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。
    于是我们从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小, 即高度也不会超过log2n
  3. 然后在重新加入红结点时,由于红结点不能为父子关系,又只包含黑色结点的最长路径也就log2n,所以添加红色结点后最长路径(也即高度)也不会超过2log2n

所以红黑树在平衡性这上面,是符合平衡树的要求的


Splay、Treap在极端条件下也会退化,即使概率不高但是也并不适用
AVL树为了维护他的高度平衡,插入删除时进行了大量的调整操作,所以不适合有频繁插入删除操作的业务环境
相比而言,红黑树各方面都较为稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。(不过,它实现起来比较复杂,如果自己写代码实现,难度会有些高,这个时候,我们其实更倾向用跳表来替代它。)


即便你将左右旋背得滚瓜烂熟,我保证你过不几天就忘光了。因为,学习红黑树的代码实现,对于你平时做项目开发没有太大帮助。
这里我之前看了点acwing的y总的讲解,虽然操作有点记不清,但是领悟了一个事:我们的调整是基于维护红黑树性质的初衷的,我们要从性质出发,能只通过变色调整好就变色,不然就旋转,反正哪条性质没满足,就在不破坏其他已达成的性质的基础上进行结点操作
(所以这段先跳了)


递归树
首先说下我以前就开始有的且没错的概念:递归完全可以看做一棵树,对于多个子情况的"探索",可以看做根结点遍历各个子结点一样
分析快排的时间复杂度
下图中定每次都将数组分为了1/10和1/9两个部分
在这里插入图片描述
快速排序的过程中,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是 n n n。我们现在只要求出递归树的高度 h h h,这个快排过程遍历的数据个数就是 h ∗ n h * n hn ,也就是说,时间复杂度就是 O ( h ∗ n ) O(h * n) O(hn)

因为每次分区并不是均匀地一分为二,所以递归树并不是满二叉树。这样一个递归树的高度是多少呢?

我们知道,快速排序结束的条件就是待排序的小区间,大小为 1 1 1,也就是说叶子节点里的数据规模是 1 1 1。从根节点 n n n到叶子节点 1 1 1,递归树中最短的一个路径每次都乘以 1 10 \frac{1}{10} 101,最长的一个路径每次都乘以 9 10 \frac{9}{10} 109。通过计算,我们可以得到,从根节点到叶子节点的最短路径是 log ⁡ 10 n \log_{10}n log10n,最长的路径是 log ⁡ 10 9 n \log_{\frac{10}{9}}n log910n
可以看下下图
在这里插入图片描述

于是对于高度h,根据复杂度的大O表示法,我们统一写成 log ⁡ n \log n logn,快速排序的时间复杂度仍然是 O ( n log ⁡ n ) O(n\log n) O(nlogn)

也就是说,对于 k k k等于 9 9 9 99 99 99,甚至是 999 999 999 9999 9999 9999……,只要 k k k的值不随 n n n变化,是一个事先确定的常量,那快排的时间复杂度就是 O ( n log ⁡ n ) O(n\log n) O(nlogn)。所以,从概率论的角度来说,快排的平均时间复杂度就是 O ( n log ⁡ n ) O(n\log n) O(nlogn)


分析斐波那契数列的时间复杂度

画递归树,就是下面这个样子:

在这里插入图片描述

这棵递归树的高度是多少呢?

f ( n ) f(n) f(n)分解为 f ( n − 1 ) f(n-1) f(n1) f ( n − 2 ) f(n-2) f(n2),每次数据规模都是 − 1 -1 1或者 − 2 -2 2,叶子节点的数据规模是 1 1 1或者 2 2 2。所以,从根节点走到叶子节点,每条路径是长短不一的。如果每次都是 − 1 -1 1,那最长路径大约就是 n n n;如果每次都是 − 2 -2 2,那最短路径大约就是 n 2 \frac{n}{2} 2n

每次分解之后的合并操作只需要一次加法运算,我们把这次加法运算的时间消耗记作 1 1 1。所以,从上往下,第一层的总时间消耗是 1 1 1,第二层的总时间消耗是 2 2 2,第三层的总时间消耗就是 2 2 2^{2} 22。依次类推,第 k k k层的时间消耗就是 2 k − 1 2^{k-1} 2k1,那整个算法的总的时间消耗就是每一层时间消耗之和。

如果路径长度都为 n n n,那这个总和就是 2 n − 1 2^{n}-1 2n1
在这里插入图片描述

如果路径长度都是 n 2 \frac{n}{2} 2n ,那整个算法的总的时间消耗就是 2 n 2 − 1 2^{\frac{n}{2}}-1 22n1
在这里插入图片描述

所以,这个算法的时间复杂度就介于 O ( 2 n ) O(2^{n}) O(2n) O ( 2 n 2 ) O(2^{\frac{n}{2}}) O(22n)之间。虽然这样得到的结果还不够精确,只是一个范围,但是我们也基本上知道了上面算法的时间复杂度是指数级的,非常高。


分析全排列的时间复杂度
全排列就是第一位n种情况,第二位n-1种情况。。。。可以画出下面这个树(多叉树省略了多叉了)

在这里插入图片描述
第一层分解有 n n n次交换操作,第二层有 n n n个节点,每个节点分解需要 n − 1 n-1 n1次交换,所以第二层总的交换次数是 n ∗ ( n − 1 ) n*(n-1) n(n1)。第三层有 n ∗ ( n − 1 ) n*(n-1) n(n1)个节点,每个节点分解需要 n − 2 n-2 n2次交换,所以第三层总的交换次数是 n ∗ ( n − 1 ) ∗ ( n − 2 ) n*(n-1)*(n-2) n(n1)(n2)

以此类推,第 k k k层总的交换次数就是 n ∗ ( n − 1 ) ∗ ( n − 2 ) ∗ … ∗ ( n − k + 1 ) n * (n-1) * (n-2) * … * (n-k+1) n(n1)(n2)(nk+1)。最后一层的交换次数就是 n ∗ ( n − 1 ) ∗ ( n − 2 ) ∗ … ∗ 2 ∗ 1 n * (n-1) * (n-2) * … * 2 * 1 n(n1)(n2)21。每一层的交换次数之和就是总的交换次数。

n + n*(n-1) + n*(n-1)(n-2) +… + n(n-1)(n-2)21

这个公式的求和比较复杂,我们看最后一个数, n ∗ ( n − 1 ) ∗ ( n − 2 ) ∗ … ∗ 2 ∗ 1 n * (n-1) * (n-2) * … * 2 * 1 n(n1)(n2)21等于 n ! n! n!,而前面的 n − 1 n-1 n1个数都小于最后一个数,所以,总和肯定小于 n ∗ n ! n * n! nn!,也就是说,全排列的递归算法的时间复杂度大于 O ( n ! ) O(n!) O(n!),小于 O ( n ∗ n ! ) O(n * n!) O(nn!),虽然我们没法知道非常精确的时间复杂度,但是这样一个范围已经让我们知道,全排列的时间复杂度是非常高的。


堆的性质:

  1. 堆是一个完全二叉树
  2. 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值

对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作“小顶堆”。

下面这两个是大顶堆
在这里插入图片描述
下面这个是小顶堆
在这里插入图片描述
关于堆的存储,用数组来存储完全二叉树是非常节省存储空间的。因为我们不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。
在这里插入图片描述
堆的插入一个元素
下图是从下往上的调整,即先把插入的结点放在数组二叉树结尾(即数组最后一个不为空的位置的下一个位置)
然后不断和父节点比较与交换
在这里插入图片描述
堆顶元素的删除
注意是删除堆顶!
先看看下图这种有问题的删除方式,因为这种做法结束后,这个二叉树就不能称为堆了,因为他不是完全二叉树了
在这里插入图片描述

所以我们改用下面这种方法:先直接用最后一个结点替代堆顶,然后向下比较和交换(这里和二叉查找树的过程是一样的)
在这里插入图片描述
易知插入删除时间复杂度都是 O ( n log ⁡ n ) O(n\log n) O(nlogn)


堆排序

  1. 建堆
    从第一个非叶结点开始,从数组后往前对每个非叶结点向下调整
    在这里插入图片描述
    在这里插入图片描述
    对于完全二叉树来说,下标从 n 2 + 1 \frac{n}{2}+1 2n+1 n n n的节点都是叶子节点。

每个节点堆化的时间复杂度是 O ( log ⁡ n ) O(\log n) O(logn),那 n 2 + 1 \frac{n}{2}+1 2n+1个节点堆化的总时间复杂度是不是就是 O ( n log ⁡ n ) O(n\log n) O(nlogn)呢?这个答案虽然也没错,但是这个值还是不够精确。实际上,堆排序的建堆过程的时间复杂度是 O ( n ) O(n) O(n)
推导过程
下图是各层结点的节点个数
每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点的高度 k k k成正比。
在这里插入图片描述

所以对于结点个数与当前高度的乘积求和就能得到下面公式
在这里插入图片描述
2 S 1 − S 1 2S_1-S_1 2S1S1易得
在这里插入图片描述
因为 h = log ⁡ 2 n h=\log_{2}n h=log2n,代入公式 S S S,就能得到 S = O ( n ) S=O(n) S=O(n),所以,建堆的时间复杂度就是 O ( n ) O(n) O(n)

  1. 排序
    像下面这个图,看起来是删除,其实是缩小了堆的范围,从1~5 => 1~4 => 1~3 以此类推
    然后这里是其实是把有序的部分放到当前这个堆的最后面,然后把堆的最大索引减少,让有序部分似乎被删除了
    然后把有序部分放到后面的操作是,把当前堆顶和堆的最后一个元素交换,再对新的堆顶
    对于所有结点都进行这个操作之后,数组就已经有序了
    在这里插入图片描述
    复杂度分析:
    首先堆排序是原地排序,而且因为这个交换顶底结点的操作使得堆排序不具有稳定性。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O ( n ) O(n) O(n),排序过程的时间复杂度是 O ( n log ⁡ n ) O(n\log n) O(nlogn),所以,堆排序整体的时间复杂度是 O ( n log ⁡ n ) O(n\log n) O(nlogn)

在实际开发中,快速排序要比堆排序性能好
原因:

  1. 堆排序数据访问的方式没有快速排序友好。对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。,这样对CPU缓存是不友好的。
  2. 对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。

堆的应用

优先队列

  1. 合并有序小文件.
    假设我们有100个小文件,每个文件的大小是100MB,每个文件中存储的都是有序的字符串。我们希望将这些100个小文件合并成一个有序的大文件。这里就会用到优先级队列。原理其实就是用堆进行 O ( l o g n ) O(logn) O(logn)的插入和删除,这样就避免了 O ( n ) O(n) O(n)的时间复杂度。
  2. 高性能定时器
    在这里插入图片描述

对于一个维护了多个定时任务的定时器,如果按照最简单粗暴的办法,就是用1s的轮询去检查所有定时任务是否时间到了,但显然这样效率很低。我们可以将任务按结束时间升序放进一个小顶堆中,然后也不需要轮询堆顶,而是先求一个当前时间与堆顶任务的结束时间的时间间隔等过完那个时间间隔才进行该任务,然后移除堆顶,重新计算当前时间与堆顶任务的结束时间的时间间隔,重新等待。
这样,定时器既不用间隔1秒就轮询一次,也不用遍历整个任务列表,性能也就提高了。


利用堆顶求TOP K
这里的TOPk是降序的前K个数
根据以前的学习,这种题目是可以用快排做的,通过快排的二分,不断排除不符合的那一部分,最后子数组元素只有一个时就是,得到了结果。与完全排序不同,因为他是二分,且中间耗费的时间在遍历上,即 O ( T ) = O ( N ) + O ( N / 2 ) + O ( N / 4 ) . . . . . . . O(T)=O(N)+O(N/2)+O(N/4)....... O(T)=O(N)+O(N/2)+O(N/4).......易得时间复杂度为 O ( N ) O(N) O(N)
但是这种方法一开始是需要把数据全部读入内存中的,海量数据下是不行的
所以这里用到了堆
我们先把下标为0~k-1的k个数组元素放进一个大小为k的小顶堆中(用小顶堆是因为要筛掉降序的后N-k个数,用当前数和堆顶的数比较就行)
这样就得到了前k个数的最小值,然后遍历后面N-k个数,把当前遍历到的和堆顶作比较,小于就跳过(比堆顶这个大小为K的堆内的最小元素还小,肯定不在前K大的数中),大于就删除堆顶,然后向堆中插入当前数。遍历结束后得到的就是TOP K


利用堆求中位数
在这里插入图片描述
如果数据是动态的,此时用排序已经不太好使了
先说下静态数据的情况
我们维护一个大顶堆和一个小顶堆,假设数据量为N,小顶堆放N/2个数据,大顶堆放N-N/2个数据
我们先把数据插入大顶堆,
当大顶堆大小足够时,直接将数据插入大顶堆。
当大小不足时,先比较当前元素和大顶堆元素,把大者放进小顶堆,小者放进大顶堆
这样子就能保证小顶堆元素都大于大顶堆元素
结束操作后就会发现 N 为 奇 数 N为奇数 N时大顶堆元素为中位数,当N为偶数是大顶堆元素和小顶堆元素为中位数
在这里插入图片描述

静态操作用排序也能实现,所以我们要着重考虑动态操作
其实插入操作和之前静态的是一样的,但是因为动态会打破之前定的大顶堆和小顶堆的大小与N的大小关系,所以我们需要再判断一下大小并进行调整(就是堆顶数据的移动)

在这里插入图片描述
99%响应时间:
如果有100个接口访问请求,每个接口请求的响应时间都不同,比如55毫秒、100毫秒、23毫秒等,我们把这100个接口的响应时间按照从小到大排列,排在第99的那个数据就是99%响应时间,也叫99百分位响应时间。
求这个的方法和之前求中位数的方法是一样的,就是修改下大顶堆和小顶堆的大小关系
假设当前总数据的个数是n,大顶堆中保存n * 99%个数据,小顶堆中保存n * 1%个数据。大顶堆堆顶的数据就是我们要找的99%响应时间。
在这里插入图片描述
问题解答:假设现在我们有一个包含10亿个搜索关键词的日志文件,且我们将处理的场景限定为单机,可以使用的内存为1GB,如何快速获取到Top 10最热门的搜索关键词呢?
答:
基本操作:

  • 散列表统计关键词出现的频率
  • 维护大小为10的小顶堆获取TOP 10

要多想想才知道的操作:

  • 数据量太大, 不能直接全部读下来,应该用对hashcod取模的方式将该日志文件分片到多个文件内,然后再分别对各个文件取TOP 10
  • 再对这多个top10,再取top10,即目标关键词
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值