来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇

发布时间:2020-08-09 20:08:04 作者:yilian
来源:ITPUB博客 阅读:150

著名数据专家沃斯曾说:算法+数据结构=程序

今天我们就来讲讲数据结构

1. 数组

数组(Array)是一种 线性表数据结构。它用一组 连续的内存空间,来存储一组具有 相同类型的数据。具有的特性:

  1. 线性表
  2. 连续的内存空间
  3. 相同类型的数据
  4. 可以随机访问
  5. 数据操作比较低效,平均情况时间复杂度为 O(n)

数组为什么下标从0开始

  1. 由于数组是是一种线性表数据结构。它用一组 连续的内存空间,来存储一组具有 相同类型的数据。 所以:
  1. C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言。

容器能否完全替代数组?

例如Java的ArrayList,ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容。

那么,作为高级语言编程者,是不是数组就无用武之地了呢?当然不是,有些时候,用数组会更合适些,总的来说,对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

2. 链表 (Linked list)

不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image
几种常见的链表形式:
1\. 单链表
2\. 循环链表
3\. 双向链表 (空间换时间思想)
4\. 双向循环列表

与数组的对比:

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

不过,数组和链表的对比,并不能局限于时间复杂度。而且,在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。

写链表代码的几个技巧:
1\. 理解指针或引用的含义、警惕指针丢失和内存泄漏
2\. 利用哨兵简化实现难度
3\. 重点留意边界条件处理
4\. 举例画图、辅助思考
复制代码

写链表代码是最考验逻辑思维能力的。因为,链表代码到处都是指针的操作、边界条件的处理,稍有不慎就容易产生 Bug。链表代码写得好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密。所以,这也是很多面试官喜欢让人手写链表代码的原因。所以,这一节讲到的东西,你一定要自己写代码实现一下,才有效果。

  1. 单链表反转
  2. 链表中环的检测
  3. 两个有序的链表合并
  4. 删除链表倒数第 n 个结点
  5. 求链表的中间结点

3. 栈

应用:

4. 队列

特点:先进先出

队列拓展:

5. 跳表

我们知道,数组支持快速的随机访问,而链表不支持,这样的话,就不能用二分查找法来对链表进行快速查找。实际上,我们只需要对链表稍加改造,就可以支持类似“二分”的查找算法。我们把改造之后的数据结构叫作跳表(Skip list)。

跳表,其实就是对 有序链表建立多级“索引”,每两个(也可以是其他数量)结点提取一个结点到上一级,我们把抽出来的那一级叫作索引或索引层。你可以看我画的图。图中的 down 表示 down 指针,指向下一级结点。

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

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

我举的例子数据量不大,查找效率的提升也并不明显。为了让你能真切地感受索引提升查询效率。我画了一个包含 64 个结点的链表,按照前面讲的这种思路,建立了五级索引。

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

从图中我们可以看出,原来没有索引的时候,查找 62 需要遍历 62 个结点,现在只需要遍历 11 个结点,速度是不是提高了很多?所以,当链表的长度 n 比较大时,比如 1000、10000 的时候,在构建索引之后,查找效率的提升就会非常明显。

时间复杂度:

跳表查询某个数据的时间复杂度是多少呢?

按照我们刚才讲的,每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是 n/2,第二级索引的结点个数大约就是 n/4,第三级索引的结点个数大约就是 n/8,依次类推,也就是说, 第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那第 k级索引结点的个数就是 n/(2k)

假设索引有 h 级,最高级的索引有 2 个结点。通过上面的公式,我们可以得到 n/(2h)=2,从而求得 h=log2n-1。如果包含原始链表这一层,整个跳表的高度就是 log2n。我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个结点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)。

那这个 m 的值是多少呢?按照前面这种索引结构,我们每一级索引都最多只需要遍历 3 个结点,也就是说 m=3。

所以在跳表中查询任意数据的时间复杂度就是  O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,我们其实是基于单链表实现了二分查找,是不是很神奇?不过,天下没有免费的午餐,这种查询效率的提升,前提是建立了很多级索引,也就是我们在第 6 节讲过的空间换时间的设计思路。

空间复杂度:

跳表是不是很浪费内存?比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。那到底需要消耗多少额外的存储空间呢?我们来分析一下跳表的空间复杂度。

跳表的空间复杂度分析并不难,我在前面说了,假设原始链表大小为 n,那第一级索引大约有 n/2 个结点,第二级索引大约有 n/4 个结点,以此类推,每上升一级就减少一半,直到剩下 2 个结点。如果我们把每层索引的结点数写出来,就是一个等比数列。

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

这几级索引的结点总和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是 O(n)。也就是说,如果将包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。那我们有没有办法降低索引占用的内存空间呢?

我们前面都是每两个结点抽一个结点到上级索引,如果我们每三个结点或五个结点,抽一个结点到上级索引,是不是就不用那么多索引结点了呢?

第一级索引需要大约 n/3 个结点,第二级索引需要大约 n/9 个结点。每往上一级,索引结点个数都除以 3。为了方便计算,我们假设最高一级的索引结点个数是 1。我们把每级索引的结点个数都写下来,也是一个等比数列。

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

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

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

跳表索引动态更新

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

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。

当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。如何选择加入哪些索引层呢?

我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

随机函数的选择很有讲究,从概率上来讲,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。

跳表特点:

  1. 前提是有序链表
  2. 动态数据结构
  3. 支持快速的查询、插入、删除操作,时间复杂度为O(logn)
  4. 表面上空间复杂度是O(n),但是因为索引只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。
  5. 和红黑树相比的优势:当需要按区间查找数据时,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。
  6. 代码实现比红黑树容易很多。

6. 散列表

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

特性:

  1. 基于数组可以根据下标快速查询的特点
  2. 利用散列函数,可以把key散列后得出正整数,也就是数组的下标,进行快速查找。
  3. 插入、查找、删除的时间复杂度都是O(1)

散列冲突:

  1. 散列值很大可能会重复,所以就有了散列冲突
  2. 解决散列冲突的两种方式:  开放寻址法:线性探测、二次探测、双重散列 优点: 散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。 缺点:1.删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据;2.装载因子的上限不能太大,这也导致这种方法比链表法更浪费内存空间。 总结:当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。  链表法 优点:1.内存的利用率比开放寻址法要高,需要用的时候再申请;2.对大装载因子的容忍度更高;3.可以用跳表、红黑树来代替普通的链表,这样的话即使是极端情况下,时间复杂度也只是O(logn) 总结:比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
  3. 用装载因子来表示空位的多少 装载因子 = 填入表中的元素个数/散列表的长度 装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

工业级水平的散列表:

最终要求:

  1. 支持快速的查询、插入、删除操作;
  2. 内存占用合理,不能浪费过多的内存空间;
  3. 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。

具体设计方向:

  1. 散列函数要求: 尽可能要设计得让散列值均匀分布 不能设计得太复杂计算时间太久
  2. 支持动态扩容 根据装载因子大小来进行动态扩容,当装载因子超过阈值时,进行扩展。 合理设置装载因子的阈值,如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。 装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1。
  3. 合理选择冲突解决方法

散列表和链表的组合应用

LRU 缓存淘汰算法

借助散列表和链表,我们可以把 LRU 缓存淘汰算法的时间复杂度降低为 O(1)。

来年加薪必备,2020年攻破数据结构与算法学习笔记-数据结构篇
image

利用散列表,可以让在链表里查找某个数据的时间复杂度为O(1),而链表本身的删除和插入操作时间复杂度为O(1)。

Redis 有序集合

举个例子,比如用户积分排行榜有这样一个功能:我们可以通过用户的 ID 来查找积分信息,也可以通过积分区间来查找用户 ID 或者姓名信息。这里包含 ID、姓名和积分的用户信息,就是成员对象,用户 ID 就是 key,积分就是 score。

所以,如果我们细化一下 Redis 有序集合的操作,那就是下面这样:

如果我们仅仅按照分值将成员对象组织成跳表的结构,那按照键值来删除、查询成员对象就会很慢,解决方法与 LRU 缓存淘汰算法的解决方法类似。我们可以再按照键值构建一个散列表,这样按照 key 来删除、查找一个成员对象的时间复杂度就变成了 O(1)。同时,借助跳表结构,其他操作也非常高效。

Java LinkedHashMap

如果你熟悉 Java,那你几乎天天会用到这个容器。我们之前讲过,HashMap 底层是通过散列表这种数据结构实现的。而 LinkedHashMap 前面比 HashMap 多了一个“Linked”,这里的“Linked”是不是说,LinkedHashMap 是一个通过链表法解决散列冲突的散列表呢?

实际上,LinkedHashMap 并没有这么简单,其中的“Linked”也并不仅仅代表它是通过链表法解决散列冲突的。

你可能已经猜到了,LinkedHashMap 也是通过散列表和链表组合在一起实现的。我们先看下面这段代码:

// 10是初始大小,0.75是装载因子,true是表示按照访问时间排序HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
m.put(3, 26);
m.get(5);for (Map.Entry e : m.entrySet()) {
 System.out.println(e.getKey());
}

这段代码打印的结果是 1,2,3,5。

其实,按照访问时间排序的 LinkedHashMap 本身就是一个支持 LRU 缓存淘汰策略的缓存系统?实际上,它们两个的实现原理也是一模一样的。

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

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

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

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

最后

如果需要看视频学习,可以看: https://zhuanlan.zhihu.com/p/96130186

推荐阅读:
  1. redis笔记-数据结构篇
  2. 数据结构与算法知识大纲

免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

2020年 加薪 必备

上一篇:Oracle归档文件丢失导致OGG不用启动

下一篇:好程序员Java培训分享Java学习到什么程度可以找到工作

相关阅读

您好,登录后才能下订单哦!

密码登录
登录注册
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》