数据结构与算法之美-学习笔记(二)

17|跳表:为什么Redis一定要用跳表来实现有序集合?

二分查找底层依赖的是数组随机访问的特性,所以只能用数组来实现如果数据存储在链表中,就真的没法用二分查找算法了吗?

对链表稍加改造,就可以得到跳表,支持快速的插入、删除、查找操作

为了提高有序链表的存储效率,在链表的基础上,提出一层索引层,对链表建立一级索引

在这里插入图片描述
如果我们现在要查找某个结点,比如16。我们可以先在索引层遍历,当遍历到索引层中值为13的结点时,我们发现下一个结点是17,那要查找的结点16肯定就在这两个结点之间。然后我们通过索引层结点的down指针,下降到原始链表这一层,继续遍历。这个时候,我们只需要再遍历2个结点,就可以找到值等于16的这个结点了。这样,原来如果要查找16,需要遍历10个结点,现在只需要遍历7个结点。

链表加多级索引的结构,就是跳表

用跳表查询到底有多快?O(logn)

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

在这里插入图片描述
这几级索引的结点总和就是n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是O(n)。也就是说,如果将包含n个结点的单链表构造成跳表,我们需要额外再用接近n个结点的存储空间
在这里插入图片描述

跳表是不是很浪费内存?
比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。

那我们有没有办法降低索引占用的内存空间呢?
如果我们每三个结点或五个结点,抽一个结点到上级索引,是不是就不用那么多索引结点了呢?
在这里插入图片描述
第一级索引需要大约n/3个结点,第二级索引需要大约n/9个结点。每往上一级,索引结点个数都除以3。为了方便计算,我们假设最高一级的索引结点个数是1。我们把每级索引的结点个数都写下来,也是一个等比数列。

在这里插入图片描述

通过等比数列求和公式,总的索引结点大约就是n/3+n/9+n/27+…+9+3+1=n/2。尽管空间复杂度还是O(n),但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间。
在这里插入图片描述

实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但是在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了

高效的动态插入和删除

跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是O(logn)

对于纯粹的单链表,需要遍历每个结点,来找到插入的位置。但是,对于跳表来说,我们讲过查找某个结点的的时间复杂度是O(logn),所以这里查找某个数据应该插入的位置,方法也是类似的,时间复杂度也是O(logn)

跳表索引动态更新
当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某2个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表
在这里插入图片描述

跳表是通过随机函数来维护前面提到的“平衡性”

为什么Redis要用跳表来实现有序集合,而不是红黑树?
在这里插入图片描述
跳表本质上就是链表,所以插入和删除操时间复杂度就为O(1),但在实际情况中,要插入或删除某个节点,需要先查找到指定位置,而这个查找操作比较费时,但在跳表中这个查找操作的时间复杂度是O(logn),所以,跳表的插入和删除操作的是时间复杂度也是O(logn)

18|散列表(上):Word文档中的单词拼写检查功能是如何实现的?

散列表的英文叫“Hash Table”,我们平时也叫它“哈希表”或者“Hash表”

把参赛编号转化为数组下标的映射方法就叫作散列函数(或“Hash函数”“哈希函数”),而散列函数计算得到的值就叫作散列值(或“Hash值”“哈希值”)。

我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。

散列冲突

1 开放寻址法

开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入那如何重新探测新的位置呢?

线性探测(Linear Probing)。当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止

当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。
对于开放寻址冲突解决方法,除了线性探测方法之外,还有另外两种比较经典的探测方法,二次探测(Quadratic probing)和双重散列(Double hashing)。

所谓二次探测,跟线性探测很像,线性探测每次探测的步长是1,那它探测的下标序列就是hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变
成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key)+0,hash(key)+12,hash(key)+22……

所谓双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少

在这里插入图片描述

在散列表中查找元素的过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。

散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。我们不能单纯地把要删除的元素设置为空。这是为什么呢?

还记得我们刚讲的查找操作吗?在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。这个问题如何解决呢?

我们可以将删除的元素,特殊标记为deleted。当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测。

2.链表法

在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
在这里插入图片描述

当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。
那查找或删除操作的时间复杂度是多少呢?
实际上,这两个操作的时间复杂度跟链表的长度k成正比,也就是O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中n表示散列中数据的个数,m表示散列表中“槽”的个数。

在这里插入图片描述

小结

散列表来源于数组,它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问元素的特性。散列表两个核心问题是散列函数设计和散列冲突解决。散列冲突有两种常用的解决方法,开放寻址法和链表法。散列函数设计的好坏决定了散列冲突的概率 ,也就决定散列表的性能。

课后思考题

  1. 假设我们有10万条URL访问日志,如何按照访问次数给URL排序?
  2. 有两个字符串数组,每个数组大约有10万条字符串,如何快速找出两个数组中相同的字符串?

在这里插入图片描述

哈希表比较经典的应用还有bitmap和布隆过滤器,其中布隆过滤器也可以用于文本判重,但是有一定的误判概率,可以根据场景使用。

19|散列表(中):如何打造一个工业级水平的散列表?

散列表的查询效率并不能笼统地说成是O(1)。它跟散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能导致散列冲突发生的概率升高,查询效率下降

如何设计散列函数?

散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接的影响到散列表的性能。其次,散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况

实际上,散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等

装载因子过大了怎么办?

装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。

针对散列表,当装载因子过大时,我们也可以进行动态扩容重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了0.4。

针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置

如果散列表当前大小为1GB,要想扩容为原来的两倍大小,那就需要对1GB的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,听起来就很耗时,是不是?

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。当有新数据要插入时 ,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。
在这里插入图片描述
通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是O(1)。

如何选择冲突解决方法?
Java中LinkedHashMap就采用了链表法解决冲突,ThreadLocalMap是通过线性探测的开放寻址法来解决冲突。那你知道,这两种冲突解决方法各有什么优势和劣势,又各自适用哪些场景

1.开放寻址法
开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用CPU缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易

用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。

当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是Java中的ThreadLocalMap使用开放寻址法解决散列冲突的原因

2.链表法

链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好

链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于1的情况。接近1时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。

链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对CPU缓存是不友好的,这方面对于执行效率也有一定的影响。

如果我们存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4个字节或者8个字节),那链表中指针的内存消耗在大对象面前就可以忽略了

实际上,我们对链表法稍加改造,可以实现一个更加高效的散列表。那就是,我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是O(logn)

基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表

工业级散列表举例分析
HashMap默认的初始大小是16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高HashMap的性能。

最大装载因子默认是0.75,当HashMap中元素个数超过0.75*capacity(capacity表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。

HashMap底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。

在JDK1.8版本中,为了对HashMap做进一步优化,我们引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高HashMap的性能。当红黑树结点个数少于6个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。

在这里插入图片描述

关于散列函数的设计,我们要尽可能让散列后的值随机且均匀分布,这样会尽可能地减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,太复杂就会太耗时间,也会影响散列表的性能

在这里插入图片描述

20|散列表(下):为什么散列表和链表经常会一起使用?

用链表来实现LRU缓存淘汰算法,但是链表实现的LRU缓存淘汰算法的时间复杂度是O(n),当时我也提到了,通过散列表可以将这个时间复杂度降低到O(1)

LinkedHashMap这样一个常用的容器,也用到了散列表和链表两种数据结构

LRU缓存淘汰算法

我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们就直接将链表头部的结点删除。

当要缓存某个数据的时候,先在链表中查找这个数据。如果没有找到,则直接将数据放到链表的尾部;如果找到了,我们就把它移动到链表的尾部。因为查找数据需要遍历链表,所以单纯用链表实现的LRU缓存淘汰算法的时间复杂很高,是O(n)

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

我们使用双向链表存储数据,链表中的每个结点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增了一个特殊的字段hnext。这个hnext有什么作用呢?
因为我们的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的双向链表,另一个链是散列表中的拉链。前驱和后继指针是为了将结点串在双向链表中,hnext指针是为了将结点串在散列表的拉链中。

前面讲到的缓存的插入、删除、查询三个操作,是如何做到时间复杂度是O(1)的?

首先,我们来看如何查找一个数据。我们前面讲过,散列表中查找数据的时间复杂度接近O(1),所以通过散列表,我们可以很快地在缓存中找到一个数据。当找到数据之后,我们还需要将它移动到双向链表的尾部

其次,我们来看如何删除一个数据。我们需要找到数据所在的结点,然后将结点删除。借助散列表,我们可以在O(1)时间复杂度里找到要删除的结点。因为我们的链表是双向链表,双向链表可以通过前驱指针O(1)时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要O(1)的时间复杂度。

最后,我们来看如何添加一个数据。添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。

这整个过程涉及的查找操作都可以通过散列表来完成。其他的操作,比如删除头结点、链表尾部插入数据等,都可以在O(1)的时间复杂度内完成。所以,这三个操作的时间复杂度都是O(1)。至此,我们就通过散列表和双向链表的组合使用,实现了一个高效的、支持LRU缓存淘汰算法的缓存系统原型。

LinkedHashMap

LinkedHashMap也是通过散列表和链表组合在一起实现的。实际上,它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据

在这里插入图片描述

每次调用put()函数,往LinkedHashMap中添加数据的时候,都会将数据添加到链表的尾部

LinkedHashMap是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突

为什么散列表和链表经常一块使用?

散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。

因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。

在这里插入图片描述

21|哈希算法(上):如何防止数据库中的用户信息被脱库?

如果你是CSDN的一名工程师,你会如何存储用户密码这么重要的数据吗?仅仅MD5加密一下存储就够了吗? 要想搞清楚这个问题,就要先弄明白哈希算法

什么是哈希算法?
将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。

从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法);
对输入数据非常敏感,哪怕原始数据只修改了一个Bit,最后得到的哈希值也大不相同;
散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。

(MD5的哈希值是128位的Bit长度,为了方便表示,我把它们转化成了16进制编码)
在这里插入图片描述
应用一:安全加密

最常用于加密的哈希算法是MD5(MD5 Message-Digest Algorithm,MD5消息摘要算法)和SHA(Secure Hash Algorithm,安全散列算法)。

当然还有很多其他加密算法,比如DES(Data Encryption Standard,数据加密标准)AES(Advanced Encryption Standard,高级加密标准)。

为什么哈希算法无法做到零冲突?

一般情况下,哈希值越长的哈希算法,散列冲突的概率越低。
在这里插入图片描述

应用二:唯一标识
如果要在海量的图库中,搜索一张图是否存在,我们不能单纯地用图片的元信息(比如图片名称)来比对,因为有可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。那我们该如何搜索呢?

任何文件在计算中都可以表示成二进制码串
在这里插入图片描述如果不存在,那就说明这个图片不在图库中;如果存在,我们再通过散列表中存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的比对,看是否完全一样。如果一样,就说明已经存在;如果不一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片。

应用三:数据校验
我们通过哈希算法,对100个文件块分别取哈希值,并且保存在种子文件中。我们在前面讲过,哈希算法有一个特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。

应用四:散列函数

散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过,相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决

在这里插入图片描述
加salt,也可理解为为密码加点佐料后再进行hash运算。比如原密码是123456,不加盐的情况加密后假设是是xyz。 黑客拿到脱机的数据后,通过彩虹表匹配可以轻松破解常用密码。如果加盐,密码123456加盐后可能是12ng34qq56zz,再对加盐后的密码进行hash后值就与原密码hash后的值完全不同了。
而且加盐的方式有很多种,可以是在头部加,可以在尾部加,还可在内容中间加,甚至加的盐还可以是随机的。这样即使用户使用的是最常用的密码黑客拿到密文后破解的难度也很高

①常用于加密的哈希算法:
MD5:MD5 Message-Digest Algorithm,MD5消息摘要算法
SHA:Secure Hash Algorithm,安全散列算法
DES:Data Encryption Standard,数据加密标准
AES:Advanced Encryption Standard,高级加密标准

哈希算法的特点有一条:从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)

22|哈希算法(下):哈希算法在分布式系统中有哪些应用?

应用五:负载均衡

负载均衡算法有很多,比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。

在这里插入图片描述

应用六:数据分片

假如我们有1T的日志文件,这里面记录了用户的搜索关键词,我们想要快速统计出每个关键词被搜索的次数,该怎么做呢?

在这里插入图片描述
假设现在我们的图库中有1亿张图片,很显然,在单台机器上构建散列表是行不通的。因为单台机器的内存有限,而1亿张图片构建散列表显然远远超过了单台机器的内存上限

在这里插入图片描述

应用七:分布式存储

现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。我们有海量的数据需要缓存,所以一个缓存机器肯定是不够的。于是,我们就需要将数据分布在多台机器上

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

哈希算法还有很多其他的应用,比如网络协议中的CRC校验、Git commit id等等

23|二叉树基础(上):什么样的二叉树适合用数组来存储?

二叉树有哪几种存储方式?什么样的二叉树适合用数组来存储?

在这里插入图片描述
在这里插入图片描述
高度”这个概念,其实就是从下往上度量,比如我们要度量第10层楼的高度、第13层楼的高度,起点都是地面。所以,树这种数据结构的高度也是一样,从最底层开始计数,并且计数的起点是0

“深度`”这个概念在生活中是从上往下度量的,比如水中鱼的深度,是从水平面开始度量的。所以,树这种数据结构的深度也是类似的,从根结点开始度量,并且计数起点也是0

二叉树(Binary Tree)

二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。

在这里插入图片描述

编号2的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树

编号3的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树

要理解完全二叉树定义的由来,我们需要先了解,如何表示(或者存储)一棵二叉树?

想要存储一棵二叉树,我们有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。

链式存储法
在这里插入图片描述

基于数组的顺序存储法
我们把根节点存储在下标i = 1的位置,那左子节点存储在下标2 * i = 2的位置,右子节点存储在2 * i + 1 = 3的位置。以此类推,B节点的左子节点存储在2 * i = 2 * 2 = 4的位置,右子节点存储在2 * i + 1 = 2 * 2 + 1 = 5的位置。

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

所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因

二叉树的遍历
如何将所有节点都遍历打印出来呢?经典的方法有三种,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。

前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身

实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。

void preOrder(Node* root) {
 if (root == null) return;
 print root // 此处为伪代码,表示打印root节点
 preOrder(root->left);
 preOrder(root->right);
}
void inOrder(Node* root) {
 if (root == null) return;
 inOrder(root->left);
 print root // 此处为伪代码,表示打印root节点
 inOrder(root->right);
}
void postOrder(Node* root) {
 if (root == null) return;
 postOrder(root->left);
 postOrder(root->right);
 print root // 此处为伪代码,表示打印root节点
}

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

我们平时最常用的树就是二叉树。二叉树的每个节点最多有两个子节点,分别是左子节点和右子节点。二叉树中,有两种比较特殊的树,分别是满二叉树和完全二叉树。满二叉树又是完全二叉树的一种特殊情况

二叉树既可以用链式存储,也可以用数组顺序存储。数组顺序存储的方式比较适合完全二叉树,其他类型的二叉树用数组存储会比较浪费存储空间。除此之外,二叉树里非常重要的操作就是前、中、后序遍历操作,遍历的时间复杂度是O(n)

课后思考

  1. 给定一组数据,比如1,3,5,6,9,10。你来算算,可以构建出多少种不同的二叉树?
  2. 我们讲了三种二叉树的遍历方式,前、中、后序。实际上,还有另外一种遍历方式,也就是按层遍历,你知道如何实现吗?

关于问题1,如果是完全二叉树,老师说过可以放在数组里面,那么问题是否 可以简化为数组内的元素有多少种组合方式,这样的话,就是 n!,不知是否可以这样理解 ?

在这里插入图片描述

第二题:
层序遍历,借用队列辅助即可,根节点先入队列,然后循环从队列中pop节点,将pop出来的节点的左子节点先入队列,右节点后入队列,依次循环,直到队列为空,遍历结束

24|二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?

二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。

二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。不过,它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。它是怎么做到这些的呢?

1.二叉查找树的查找操作
我们看如何在二叉查找树中查找一个节点。我们先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。

public class BinarySearchTree {
 private Node tree;
 public Node find(int data) {
 Node p = tree;
 while (p != null) {
 if (data < p.data) p = p.left;
 else if (data > p.data) p = p.right;
 else return p;
 }
 return null;
 }

2.二叉查找树的插入操作

二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。

如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。

同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。

public void insert(int data) {
 if (tree == null) {
 tree = new Node(data);
 return;
 }
 Node p = tree;
 while (p != null) {
 if (data > p.data) {
 if (p.right == null) {
 p.right = new Node(data);
 return;
 }
p = p.right;
 } else { // data < p.data
 if (p.left == null) {
 p.left = new Node(data);
 return;
 }
 p = p.left;
 }
 }
}

3.二叉查找树的删除操作
在这里插入图片描述在这里插入图片描述

public void delete(int data) {
 Node p = tree; // p指向要删除的节点,初始化指向根节点
 Node pp = null; // pp记录的是p的父节点
 while (p != null && p.data != data) {
 pp = p;
 if (data > p.data) p = p.right;
 else p = p.left;
 }
 if (p == null) return; // 没有找到
 // 要删除的节点有两个子节点
 if (p.left != null && p.right != null) { // 查找右子树中最小节点
 Node minP = p.right;
 Node minPP = p; // minPP表示minP的父节点
 while (minP.left != null) {
 minPP = minP;
 minP = minP.left;
 }
 p.data = minP.data; // 将minP的数据替换到p中
 p = minP; // 下面就变成了删除minP了
 pp = minPP;
 }
 // 删除节点是叶子节点或者仅有一个子节点
 Node child; // p的子节点
 if (p.left != null) child = p.left;
 else if (p.right != null) child = p.right;
 else child = null;
 if (pp == null) tree = child; // 删除的是根节点
 else if (pp.left == p) pp.left = child;
 else pp.right = child;
}

二叉查找树除了支持上面几个操作之外,还有一个重要的特性,就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是O(n),非常高效。因此,二叉查找树也叫作二叉排序树。

支持重复数据的二叉查找树

第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。

第二种方法比较不好理解,不过更加优雅。
每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。

当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。

二叉查找树的时间复杂度分析

当二叉树退化成了链表,所以查找的时间复杂度就变成了O(n)

不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比,也就是O(height)

如何求一棵包含n个节点的完全二叉树的高度?

树的高度就等于最大层数减一

包含n个节点的完全二叉树中,第一层包含1个节点,第二层包含2个节点,第三层包含4个节点,依次类推,下面一层节点个数是上一层的2倍,第K层包含的节点个数就是2(K-1)。包含n个节点的完全二叉树中,第一层包含1个节点,第二层包含2个节点,第三层包含4个节点,依次类推,下面一层节点个数是上一层的2倍,第K层包含的节点个数就是2(K-1)。
在这里插入图片描述
显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这就是我们下一节课要详细讲的,一种特殊的二叉查找树,平衡二叉查找树。平衡二叉查找树的高度接近logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是O(logn)。

为什么还要用二叉查找树呢?

在这里插入图片描述

小结

在这里插入图片描述

课后思考
如何通过编程,求出一棵给定二叉树的确切高度呢
leetcode 104 题,可以使用递归法

在这里插入图片描述

25|红黑树(上):为什么工程中都用红黑树这种二叉树?

二叉查找树是最常用的一种二叉树,它支持快速插入、删除、查找操作,各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是O(logn)。

不过,二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于log2n的情况,从而导致各个操作的效率下降。极端情况下,二叉树会退化为链表,时间复杂度会退化到O(n)。

什么是“平衡二叉查找树”?

二叉树中任意一个节点的左右子树的高度相差不能大于1
满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。

在这里插入图片描述

发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题

红黑树的英文是“Red-Black Tree”,简称R-B Tree。它是一种不严格的平衡二叉查找树

红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求

根节点是黑色的;
每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;

AVL树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用AVL树的代价就有点高了。

红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比AVL树要低
所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳
定的平衡二叉查找树。

动态数据结构是支持动态的更新操作,里面存储的数据是时刻在变化的,通俗一点讲,它不仅仅支持查询,还支持删除、插入数据。而且,这些操作都非常高效。如果不高效,也就算不上是有效的动态数据结构了。所以,这里的红黑树算一个,支持动态的插入、删除、查找,而且效率都很高。链表、队列、栈实际上算不上,因为操作非常有限,查询效率不高。

散列表:插入删除查找都是O(1), 是最常用的,但其缺点是不能顺序遍历以及扩容缩容的性能损耗。适用于那些不需要顺序遍历,数据更新不那么频繁的。
跳表:插入删除查找都是O(logn), 并且能顺序遍历。缺点是空间复杂度O(n)。适用于不那么在意内存空间的,其顺序遍历和区间查找非常方便。
红黑树:插入删除查找都是O(logn), 中序遍历即是顺序遍历,稳定。缺点是难以实现,去查找不方便。其实跳表更佳,但红黑树已经用于很多地方了。

26|红黑树(下):掌握这些技巧,你也可以实现一个红黑树

平衡调整中,会一直用到这两个操作,左旋(rotate left)、右旋(rotate right)
红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上

先跳过红黑树

27|递归树:如何借助树来求解递归算法的时间复杂度?

递归树与时间复杂度分析

递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止

归并排序算法你还记得吧?它的递归实现代码非常简洁。现在我们就借助归并排序来看看,如何用递归树,来分析递归代码的时间复杂度

在这里插入图片描述

因为每次分解都是一分为二,所以代价很低,我们把时间上的消耗记作常量 1 1 1。归并算法中比较耗时的是归并操作,也就是把两个子数组合并为大数组。从图中我们可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关。我们把每一层归并操作消耗的时间记作 n n n

从归并排序的原理和递归树,可以看出来,归并排序递归树是一棵满二叉树。我们前两节中讲到,满二叉树的高度大约是 log ⁡ 2 n \log_{2}n log2n,所以,归并排序递归实现
的时间复杂度就是 O ( n log ⁡ n ) O(n\log n) O(nlogn)。我

实战一:分析快速排序的时间复杂度

在用递归树推导之前,我们先来回忆一下用递推公式的分析方法。你可以回想一下,当时,我们为什么说用递推公式来求解平均时间复杂度非常复杂?

快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式 T ( n ) = 2 T ( n 2 ) + n T(n)=2T(\frac{n}{2})+n T(n)=2T(2n)+n,很容易就能推导出时间复杂度是 O ( n log ⁡ n ) O(n\log n) O(nlogn)。但是,我们并不可能每次分区都这么幸运,正好一分为二。
我们假设平均情况下,每次分区之后,两个分区的大小比例为 1 : k 1:k 1:k。当 k = 9 k=9 k=9时,如果用递推公式的方法来求解时间复杂度的话,递推公式就写成 T ( n ) = T ( n 10 ) + T ( 9 n 10 ) + n T(n)=T(\frac{n}{10})+T(\frac{9n}{10})+n T(n)=T(10n)+T(109n)+n

这个公式可以推导出时间复杂度,但是推导过程非常复杂。那我们来看看,用递归树来分析快速排序的平均情况时间复杂度,是不是比较简单呢?
我们还是取 k k k等于 9 9 9,也就是说,每次分区都很不平均,一个分区是另一个分区的 9 9 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
在这里插入图片描述
这里插入一点自己的理解哈,假设原来有n个元素,那么第一次切分成 1 10 n \frac{1}{10}n 101n 9 10 n \frac{9}{10}n 109n,下一次 1 10 n \frac{1}{10}n 101n又被按照1:9来切分,那么最小的一个区间变成 1 100 n \frac{1}{100}n 1001n,知道最后这个最小区间变成1,即为递归树的最小高度,从n变成1,每次除以10,需要除多少次?那不就是 l o g 10 n log_{10}n log10n

理想情况下是每次都是切分成一半,那么就是n变成1,每次都除以2,递归树的高度是 log ⁡ n \log n logn

所以,遍历数据的个数总和就介于 n log ⁡ 10 n n\log_{10}n nlog10n n log ⁡ 10 9 n n\log_{\frac{10}{9}}n nlog910n之间。根据复杂度的大O表示法,对数复杂度的底数不管是多少,我们统一写成 log ⁡ n \log n logn

所以,当分区大小比例是 1 : 9 1:9 1:9时,快速排序的时间复杂度仍然是 O ( n log ⁡ n ) O(n\log n) O(nlogn)
刚刚我们假设 k = 9 k=9 k=9,那如果 k = 99 k=99 k=99,也就是说,每次分区极其不平均,两个区间大小是 1 : 99 1:99 1:99,这个时候的时间复杂度是多少呢?
我们可以类比上面 k = 9 k=9 k=9的分析过程。当 k = 99 k=99 k=99的时候,树的最短路径就是 log ⁡ 100 n \log_{100}n log100n,最长路径是 log ⁡ 100 99 n \log_{\frac{100}{99}}n log99100n,所以总遍历数据个数介于 n log ⁡ 100 n n\log_{100}n nlog100n n log ⁡ 100 99 n n\log_{\frac{100}{99}}n nlog99100n之间。尽管底数变了,但是时间复杂度也仍然是 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)

实战二:分析斐波那契数列的时间复杂度

int f(int n) {
 if (n == 1) return 1;
 if (n == 2) return 2;
 return f(n-1) + f(n-2);
}

在这里插入图片描述
这棵递归树的高度是多少呢?
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 − 1 n-1 n1个数据的排列问题。而最后一位数据可以是 n n n个数据中的任意一个,因此它的取值就有 n n n种情况。所
以,“ n n n个数据的排列”问题,就可以分解成 n n n个“ n − 1 n-1 n1个数据的排列”的子问题。

// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);
// k表示要处理的子数组的数据个数
public void printPermutations(int[] data, int n, int k) {
 if (k == 1) {
 for (int i = 0; i < n; ++i) {
 System.out.print(data[i] + " ");
 }
 System.out.println();
 }
 for (int i = 0; i < k; ++i) {
 int tmp = data[i];
 data[i] = data[k-1];
 data[k-1] = tmp;
 printPermutations(data, n, k - 1);
 tmp = data[i];
 data[i] = data[k-1];
 data[k-1] = tmp;
 }
}

在这里插入图片描述

第一层分解有 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 1 1个细胞的生命周期是 3 3 3小时, 1 1 1小时分裂一次。求 n n n小时后,容器内有多少细胞?请你用已经学过的递归时间复杂度的分析方法,分析一下这个递归问题的时间复杂度

假设细胞到了第三个小时是先分裂完再死亡,那么递推公式就应该是:f(n) = f(n-1)*2 - f(n-3)一次乘法和一次减法一起看作一次基本操作消耗,那么情况和斐波那契数列很像。

最高的树应该有n层, 最短的是n/3层,每层操作数都是指数增长。那么时间复杂度应该是在O(2^n)量级的。

天啦
在这里插入图片描述

28|堆和堆排序:为什么说堆排序没有快速排序快?

堆排序是一种原地的、时间复杂度为KaTeX parse error: Undefined control sequence: \logn at position 4: O(n\̲l̲o̲g̲n̲)的排序算法。

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

完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。(堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值)

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

如何实现一个堆?

在这里插入图片描述

数组中下标为 i i i的节点的左子节点,就是下标为 i ∗ 2 i*2 i2的节点,右子节点就是下标为 i ∗ 2 + 1 i*2+1 i2+1的节点,父节点就是下标为 i 2 \frac{i}{2} 2i的节点。

1.往堆中插入一个元素

2.删除堆顶元素
可以把最后一个节点放在头结点上,然后调整堆

一个包含 n n n个节点的完全二叉树,树的高度不会超过 log ⁡ 2 n \log_{2}n log2n。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O ( log ⁡ n ) O(\log n) O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O ( log ⁡ n ) O(\log n) O(logn)

如何基于堆实现排序?

堆排序。这种排序方法的时间复杂度非常稳定,是 O ( n log ⁡ n ) O(n\log n) O(nlogn),并且它还是原地排序算法

我们对下标从 n 2 \frac{n}{2} 2n 开始到 1 1 1的数据进行堆化,下标是 n 2 + 1 \frac{n}{2}+1 2n+1 n n n的节点是叶子节点,我们不需要堆化。实际上,对于完全二叉树来说,下标从 n 2 + 1 \frac{n}{2}+1 2n+1 n n n的节点都是叶子节点

排序

建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n n n的位置

这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为 n n n的元素放到堆顶,然后再通过堆化的方法,将剩下的 n − 1 n-1 n1个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是 n − 1 n-1 n1的位置,一直重复这个过程,直到最后堆中只剩下标为 1 1 1的一个元素,排序工作就完成了。

整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 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 , 2 , 4 , 8 1,2,4,8 1248的元素,而不是像快速排序那样,局部顺序访问,所以,这样对CPU缓存是不友好的

对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序

堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。

在这里插入图片描述

课后思考

  1. 在讲堆排序建堆的时候,我说到,对于完全二叉树来说,下标从 n 2 + 1 \frac{n}{2}+1 2n+1 n n n的都是叶子节点,这个结论是怎么推导出来的呢?
  2. 我们今天讲了堆的一种经典应用,堆排序。关于堆,你还能想到它的其他应用吗?

应用:
1.topK
2.流里面的中值
3.流里面的中位数

在这里插入图片描述

29|堆的应用:如何快速获取到Top10最热门的搜索关键词?

搜索引擎每天会接收大量的用户搜索请求,它会把这些用户输入的搜索关键词记录下来,然后再离线地统计分析,得到最热门的Top 10搜索关键词。

假设现在我们有一个包含10亿个搜索关键词的日志文件,如何能快速获取到热门榜Top 10的搜索关键词呢?

堆的应用一:优先级队列

优先级队列,顾名思义,它首先应该是一个队列。我们前面讲过,队列最大的特性就是先进先出。不过,在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队

如何实现一个优先级队列呢?方法有很多,但是用堆来实现是最直接、最高效的。这是因为,堆和优先级队列非常相似。一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。

1.合并有序小文件
假设我们有100个小文件,每个文件的大小是100MB,每个文件中存储的都是有序的字符串。我们希望将这些100个小文件合并成一个有序的大文件。这里就会用到优先级队列

我们将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。我们将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将100个小文件中的数据依次放入到大文件中。

删除堆顶数据和往堆中插入数据的时间复杂度都是O(logn),n表示堆中的数据个数,这里就是100。

2.高性能定时器

堆的应用二:利用堆求Top K

针对静态数据,如何在一个包含n个数据的数组中,查找前K大数据呢?我们可以维护一个大小为K的小顶堆,顺序遍历数组,从数组中取出取数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前K大数据了。

堆的应用三:利用堆求中位数

假设现在我们有一个包含10亿个搜索关键词的日志文件,如何快速获取到Top10最热门的搜索关键词呢?

因为用户搜索的关键词,有很多可能都是重复的,所以我们首先要统计每个搜索关键词出现的频率。我们可以通过散列表、平衡二叉查找树或者其他一些支持快速查找、插入的数据结构,来记录关键词及其出现的次数。

假设我们选用散列表,等遍历完这10亿个搜索关键词之后,散列表中就存储了不重复的搜索关键词以及出现的次数。

我们再根据前面讲的用堆求Top K的方法,建立一个大小为10的小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,然后与堆顶的搜索关键词对比。如果出现次数比堆顶搜索关键词的次数多,那就删除堆顶的关键词,将这个出现次数更多的关键词加入到堆中

在这里插入图片描述

具体可以这样做:我们创建10个空文件00,01,02,……,09。我们遍历这10亿个关键词,并且通过某个哈希算法对其求哈希值,然后哈希值同10取模,得到的结果就是这个搜索关键词应该被分到的文件编号。

对这10亿个关键词分片之后,每个文件都只有1亿的关键词,去除掉重复的,可能就只有1000万个,每个关键词平均50个字节,所以总的大小就是500MB。1GB的内存完全可以放得下。

我们针对每个包含1亿条搜索关键词的文件,利用散列表和堆,分别求出Top 10,然后把这个10个Top 10放在一块,然后取这100个关键词中,出现次数最多的10个关键词,这就是这10亿数据中的Top 10最频繁的搜索关键词了。

优先级队列是一种特殊的队列,优先级高的数据先出队,而不再像普通的队列那样,先进先出。实际上,堆就可以看作优先级队列,只是称谓不一样罢了。求TopK问题又可以分为针对静态数据和针对动态数据,只需要利用一个堆,就可以做到非常高效率的查询Top K的数据。求中位数实际上还有很多变形,比如求99百分位数据、90百分位数据等,处理的思路都是一样的,即利用两个堆,一个大顶堆,一个小顶堆,随着数据的动态添加,动态调整两个堆中的数据,最后大顶堆的堆顶元素就是要求的数据。

有一个访问量非常大的新闻网站,我们希望将点击量排名Top 10的新闻摘要,滚动显示在网站首页banner上,并且每隔1小时更新一次。如果你是负责开发这个功能的工程师,你会如何来实现呢?

在这里插入图片描述

30|图的表示:如何存储微博、微信等社交网络中的好友关系?

实际上,涉及图的算法有很多,也非常复杂,比如图的搜索、最短路径、最小生成树、二分图等等
我们前面讲过了树这种非线性表数据结构,今天我们要讲另一种非线性表数据结构,图(Graph)。和树比起来,这是一种更加复杂的非线性表结构。

树中的元素我们称为节点,图中的元素我们就叫作顶点(vertex)
图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫作边(edge)

在这里插入图片描述

我们就拿微信举例子吧。我们可以把每个用户看作一个顶点。如果两个用户之间互加好友,那就在两者之间建立一条边。所以,整个微信的好友关系就可以用一张图来表示。其中,每个用户有多少个好友,对应到图中,就叫作顶点的度(degree),就是跟顶点相连接的边的条数。

如果用户A关注了用户B,我们就在图中画一条从A到B的带箭头的边,来表示边的方向。如果用户A和用户B互相关注了,那我们就画一条从A指向B的边,再画一条从B指向A的边。我们把这种边有方向的图叫作“有向图”。以此类推,我们把边没有方向的图就叫作“无向图”。

在这里插入图片描述

无向图中有“度”这个概念,表示一个顶点有多少条边。在有向图中,我们把度分为入度(In-degree)和出度(Out-degree)。

顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。对应到微博的例子,入度就表示有多少粉丝,出度就表示关注了多少人

QQ中的社交关系要更复杂的一点。不知道你有没有留意过QQ亲密度这样一个功能。QQ不仅记录了用户之间的好友关系,还记录了两个用户之间的亲密度,如果两个用户经常往来,那亲密度就比较高;如果不经常往来,亲密度就比较低。如何在图中记录这种好友关系的亲密度呢?

在带权图中,每条边都有一个权重(weight),我们可以通过这个权重来表示QQ好友间的亲密度

如何在内存中存储图这种数据结构呢?

邻接矩阵存储方法
邻接矩阵的底层依赖一个二维数组。对于无向图来说,如果顶点i与顶点j之间有边,我们就将A[i][j]和A[j][i]标记为1;对于有向图来说,如果顶点i到顶点j之间,有一条箭头从顶点i指向顶点j的边,那我们就将A[i][j]标记为1。同理,如果有一条箭头从顶点j指向顶点i的边,我们就将A[j][i]标记为1。对于带权图,数组中就存储相应的权重

在这里插入图片描述
用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间。为什么这么说呢?

对于无向图来说,如果A[i][j]等于1,那A[j][i]也肯定等于1。实际上,我们只需要存储一个就可以了。也就是说,无向图的二维数组中,如果我们将其用对角线划分为上下两部分,那我们只需要利用上面或者下面这样一半的空间就足够了,另外一半白白浪费掉了。

还有,如果我们存储的是稀疏图(Sparse Matrix),也就是说,顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间了。比如微信有好几亿的用户,对应到图上就是好几亿的顶点。但是每个用户的好友并不会很多,一般也就三五百个而已。如果我们用邻接矩阵来存储,那绝大部分的存储空间都被浪费了

用邻接矩阵的方式存储图,可以将很多图的运算转换成矩阵之间的运算。比如求解最短路径
题时会提到一个Floyd-Warshall算法,就是利用矩阵循环相乘若干次得到结果

邻接表存储方法

每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点
图中画的是一个有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。对于无向图来说,也是类似的,不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点
在这里插入图片描述

邻接矩阵存储起来比较浪费空间,但是使用起来比较节省时间。相反,邻接表存储起来比较节省空
间,但是使用起来就比较耗时间

就像图中的例子,如果我们要确定,是否存在一条从顶点2到顶点4的边,那我们就要遍历顶点2对应的那条链表,看链表中是否存在顶点4。而且,我们前面也讲过,链表的存储方式对缓存不友好。所以,比起邻接矩阵的存储方式,在邻接表中查询两个顶点之间的关系就没那么高效了

我们可以将邻接表中的链表改成平衡二叉查找树。实际开发中,我们可以选择用红黑树。这样,我们就可以更加快速地查找两个顶点之间是否存在边了。当然,这里的二叉查找树可以换成其他动态数据结构,比如跳表、散列表等。除此之外,我们还可以将链表改成有序动态数组,可以通过二分查找的方法来快速定位两个顶点之间否是存在边。

在这里插入图片描述JAVA动态数组实现(顺序表)

在这里插入图片描述

因为我们需要按照用户名称的首字母排序,分页来获取用户的粉丝列表或者关注列表,用跳表这种结构再合适不过了。这是因为,跳表插入、删除、查找都非常高效,时间复杂度是O(logn),空间复杂度上稍高,是O(n)。最重要的一点,跳表中存储的数据本来就是有序的了,分页获取粉丝列表或关注列表,就非常高效。

如果对于小规模的数据,比如社交网络中只有几万、几十万个用户,我们可以将整个社交关系存储在内存中,上面的解决思路是没有问题的。但是如果像微博那样有上亿的用户,数据规模太大,我们就无法全部存储在内存中了。这个时候该怎么办呢?

我们可以通过哈希算法等数据分片方式,将邻接表存储在不同的机器上。你可以看下面这幅图,我们在机器1上存储顶点1,2,3的邻接表,在机器2上,存储顶点4,5的邻接表。逆邻接表的处理方式也一样。当要查询顶点与顶点关系的时候,我们就利用同样的哈希算法,先定位顶点所在的机器,然后再在相应的机器上查找。
在这里插入图片描述

31|深度和广度优先搜索:如何找出社交网络中的三度好友关系?

在社交网络中,我们往往通过用户之间的连接关系,来实现推荐“可能认识的人”这么一个功能。今天的开篇问题就是,给你一个用户,如何找出这个用户的所有三度(其中包含一度、二度和三度)好友关系?

广度优先搜索(BFS)

广度优先搜索(Breadth-First-Search),我们平常都把简称为BFS。直观地讲,它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。理解起来并不难,所以我画了一张示意图,你可以看下。

在这里插入图片描述

queue是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的,也就是说,我们只有把第k层的顶点都访问完成之后,才能访问第k+1层的顶点。当我们访问到第k层的顶点的时候,我们需要把第k层的顶点记录下来,稍后才能通过第k层的顶点来找第k+1层的顶点。所以,我们用这个队列来实现记录的功能。

深度优先搜索
实际上,深度优先搜索用的是一种比较著名的算法思想,回溯思想。这种思想解决问题的过程,非常适合用递归来实现

深度:借助一个栈
广度:借助一个队列

32|字符串匹配基础(上):如何借助哈希算法实现高效字符串匹配?

BF算法
我们在字符串A中查找字符串B,那字符串A就是主串,字符串B就是模式串。我们把主串的长度记作n,模式串的长度记作m。因为我们是在主串中查找模式串,所以n>m

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值