10个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树;
10个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态 规划、字符串匹配算法。算法
【复杂度分析】
1、什么是复杂度分析?
1.数据结构和算法解决是“如何让计算机更快时间、更省空间的解决问题”。
2.所以需从执行时间和占用空间两个维度来评估数据结构和算法的性能。
3.分别用时间复杂度和空间复杂度两个概念来描述性能问题,两者统称为复杂度。
4.复杂度描述的是算法执行时间(或占用空间)与数据规模的增加关系。数据库
2、为何要进行复杂度分析?
1.和性能测试相比,复杂度分析有不依赖执行环境、成本低、效率高、易操做、指导性强的特色。
2.掌握复杂度分析,将能编写出性能更优的代码,有利于下降系统开发和维护成本。编程
3、如何进行复杂度分析?
1.大O表示法
(1)来源
算法的执行时间与每行代码的执行次数成正比,用T(n) = O(f(n))表示,其中T(n)表示算法执
行总时间,f(n)表示每行代码执行总次数,而n每每表示数据的规模。
(2)特色
以时间复杂度为例,因为时间复杂度描述的是算法执行时间与数据规模的增加变化趋势,所
以常量阶、低阶以及系数实际上对这种增加趋势不产决定性影响,因此在作时间复杂度分析
时忽略这些项。
2.复杂度分析法则
1)单段代码看高频:好比循环。
2)多段代码取最大:好比一段代码中有单循环和多重循环,那么取多重循环的复杂度。
3)嵌套代码求乘积:好比递归、多重循环等
4)多个规模求加法:好比方法有两个参数控制两个循环的次数,那么这时就取两者复杂度相加。数组
4、经常使用的复杂度级别?
多项式阶:随着数据规模的增加,算法的执行时间和空间占用,按照多项式的比例增加。包括:
O(1)(常数阶)、O(logn)(对数阶)、O(n)(线性阶)、O(nlogn)(线性对数阶)、O(n^2)(平方阶)、O(n^3)(立方阶)
非多项式阶:随着数据规模的增加,算法的执行时间和空间占用暴增,这类算法性能极差。包括:
O(2^n)(指数阶)、O(n!)(阶乘阶)浏览器
5、复杂度分析的4个概念
1.最坏状况时间复杂度:代码在最理想状况下执行的时间复杂度。
2.最好状况时间复杂度:代码在最坏状况下执行的时间复杂度。
3.平均时间复杂度:用代码在全部状况下执行的次数的加权平均值表示。
4.均摊时间复杂度:在代码执行的全部复杂度状况中绝大部分是低级别的复杂度,个别状况是高级别复杂度且发生具备时序关系时,能够将个别高级别复杂度均摊到低级别复杂度上。基
本上均摊结果就等于低级别复杂度。缓存
6、为何要引入这4个概念?
1.同一段代码在不一样状况下时间复杂度会出现量级差别,为了更全面,更准确的描述代码的时间复杂度,因此引入这4个概念。
2.代码复杂度在不一样状况下出现量级差异时才须要区别这四种复杂度。大多数状况下,是不须要区别分析它们的。安全
7、如何分析平均、均摊时间复杂度?
1.平均时间复杂度
代码在不一样状况下复杂度出现量级差异,则用代码全部可能状况下执行次数的加权平均值表示。
2.均摊时间复杂度
两个条件知足时使用:1)代码在绝大多数状况下是低级别复杂度,只有极少数状况是高级别
复杂度;2)低级别和高级别复杂度出现具备时序规律。均摊结果通常都等于低级别复杂度。性能优化
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具备相同类型的数据。
数组、链表、队列、栈等都是线性表结构。
与它相对立的概念是非线性表,好比二叉树、堆、图等。之因此叫非线性,是由于,在非线性表中,数据之间并非简单的先后关系。服务器
1.线性表
线性表就是数据排成像一条线同样的结构。常见的线性表结构:数组,链表、队列、栈等。数据结构
2. 连续的内存空间和相同类型的数据
优势:两限制使得具备随机访问的特性
缺点:删除,插入数据效率低(为什么数组插入和删除低效?)
【插入】
如有一元素想往int[n]的第k个位置插入数据,须要在k-n的位置日后移。
最好状况时间复杂度 O(1),最坏状况复杂度为O(n),平均负责度为O(n)
若是数组中的数据不是有序的,也就是无规律的状况下,能够直接把第k个位置上的数据移到
最后,而后将插入的数据直接放在第k个位置上。
这样时间复杂度就将为 O(1)了。
【删除】
与插入相似,为了保持内存的连续性。
最好状况时间复杂度 O(1),最坏状况复杂度为O(n),平均复杂度为O(n)
提升效率:将屡次删除操做中集中在一块儿执行,能够先记录已经删除的数据,可是不进行数据迁移,而仅仅是记录,当发现没有更多空间存储时,再执行真正的删除操做。
这也是 JVM标记清除垃圾回收算法的核心思想。
用数组仍是容器?
数组先指定了空间大小,容器如ArrayList能够动态扩容。
1.但愿存储基本类型数据,能够用数组
2.事先知道数据大小,而且操做简单,能够用数组
3.直观表示多维,能够用数组
4.业务开发,使用容器足够,开发框架,追求性能,首先数组。
为何数组要从 0 开始编号?
因为数组是经过寻址公式,计算出该元素存储的内存地址:a[i]_address = base_address + i * data_type_size
若是数组是从 1 开始计数,那么就会变成:a[i]_address = base_address + (i-1)* data_type_size
对于CPU来讲,多了一次减法的指令。固然,还有必定的历史缘由。
缓存 是一种提升数据读取性能的技术,在硬件设计、软件开发中都有着很是普遍的应用,好比常见的 CPU 缓存、数据库缓存、浏览器缓存等等。
缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就须要缓存淘汰策略来决定。
常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。
缓存实际上就是利用了空间换时间的设计思想。
对于执行较慢的程序,能够经过消耗更多的内存(空间换时间)来进行优化;
而消耗过多内存的程序,能够经过消耗更多的时间(时间换空间)来下降内存的消耗。
如何用链表来实现 LRU 缓存淘汰策略呢?
三种最多见的链表结构,它们分别是:单链表、双向链表、循环链表、双向循环链表。
1.单链表
(1)每一个节点只包含一个指针,即后继指针。
(2)单链表有两个特殊的节点,即首节点和尾节点。为何特殊?用首节点地址表示整条链表,尾节点的后继指针指向空地址null。
(3)性能特色:插入和删除节点的时间复杂度为O(1),查找的时间复杂度为O(n)。
2.循环链表
(1)除了尾节点的后继指针指向首节点的地址外均与单链表一致。
(2)适用于存储有循环特色的数据,好比约瑟夫问题。
3.双向链表
(1)节点除了存储数据外,还有两个指针分别指向前一个节点地址(前驱指针prev)和下一个节点地址(后继指针next)。
(2)首节点的前驱指针prev和尾节点的后继指针均指向空地址。
与数组同样,链表也支持数据的查找、插入和删除操做。
数组和链表是两种大相径庭的内存组织方式。正是由于内存存储的区别,它们插入、删除、随机访问操做的时间复杂度正好相反。
选择数组仍是链表?
1.插入、删除和随机访问的时间复杂度
数组:插入、删除的时间复杂度是O(n),随机访问的时间复杂度是O(1)。
链表:插入、删除的时间复杂度是O(1),随机访问的时间复杂端是O(n)。
2.数组缺点
(1)若申请内存空间很大,好比100M,但若内存空间没有100M的连续空间时,则会申请失败,尽管内存可用空间超过100M。
(2)大小固定,若存储空间不足,需进行扩容,一旦扩容就要进行数据复制,而这时很是费时的。
3.链表缺点
(1)内存空间消耗更大,由于须要额外的空间存储指针信息。
(2)对链表进行频繁的插入和删除操做,会致使频繁的内存申请和释放,容易形成内存碎片,若是是Java语言,还可能会形成频繁的GC(自动垃圾回收器)操做。
4.如何选择?
数组简单易用,在实现上使用连续的内存空间,能够借助CPU的缓冲机制预读数组中的数据,因此访问效率更高,而链表在内存中并非连续存储,因此对CPU缓存不友好,没办法预读。若是代码对内存的使用很是苛刻,那数组就更适合。
1.对于指针(或者引用)的理解:
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来讲,指针中存储了这个变量的内存地址,指向了这个变量,经过指针就能找到这个变量。
2.咱们插入结点时,必定要注意操做的顺序;删除链表结点时,也必定要记得手动释放内存空间,不然,也会出现内存泄漏的问题。
3. 利用哨兵简化难度
链表的插入、删除操做,须要对插入第一个结点和删除最后一个节点作特殊处理。利用哨兵对象能够不用边界判断,链表的哨兵对象是只存指针不存数据的头结点。
4. 重点留意边界条件处理
操做链表时要考虑链表为空、一个结点、两个结点、头结点、尾结点的状况。学习数据结构和算法主要是掌握一系列思想,能在其它的编码中也养成考虑边界的习惯。
常常用来检查链表代码是否正确的边界条件有这样几个:
若是链表为空时,代码是否能正常工做?
若是链表只包含一个结点时,代码是否能正常工做?
若是链表只包含两个结点时,代码是否能正常工做?
代码逻辑在处理头结点和尾结点的时候,是否能正常工做
经典链表操做案例:
* 单链表反转
* 链表中环的检测
* 两个有序的链表合并
* 删除链表倒数第 n 个结点
* 求链表的中间结点
【栈】
后进先出,先进后出,这就是典型的“栈”结构。
任何数据结构都是对特定应用场景的抽象,数组和链表虽然使用起来更加灵活,但却暴露了几乎全部的操做,不免会引起错误操做的风险。
当某个数据集合只涉及在一端插入和删除数据,而且知足后进先出、先进后出的特性,咱们就应该首选“栈”这种数据结构。
栈主要包含两个操做,入栈和出栈。
实际上,栈既能够用数组来实现,也能够用链表来实现。用数组实现的栈,咱们叫做顺序栈,用链表实现的栈,咱们叫做链式栈。
对于出栈操做来讲,咱们不会涉及内存的从新申请和数据的搬移,因此出栈的时间复杂度仍然是O(1)。可是,对于入栈操做来讲,状况就不同了。当栈中有空闲空间时,入栈操做的时间复杂度为 O(1)。但当空间不够时,就须要从新申请内存和数据搬移,因此时间复杂度就变成了O(n)。
【队列】
先进者先出,这就是典型的“队列”。
最基本的两个操做:入队enqueue(),放一个数据到队列尾部;出队dequeue(),从队列头部取一个元素。队列能够用数组或者链表实现,用数组实现的队列叫做顺序队列,用链表实现的队列叫做链式队列。
队列须要两个指针:一个是 head 指针,指向队头;一个是 tail 指针,指向队尾。
在数组实现队列的时候,会有数据搬移操做,要想解决数据搬移的问题,咱们就须要像环同样的循环队列。
阻塞队列就是在队列为空的时候,从队头取数据会被阻塞,由于此时尚未数据可取,直到队列中有了数据才能返回;若是队列已经满了,那么插入数据的操做就会被阻塞,直到队列中有空闲位置后再插入数据,而后在返回。
在多线程的状况下,会有多个线程同时操做队列,这时就会存在线程安全问题。可以有效解决线程安全问题的队列就称为并发队列。
基于链表的实现方式,能够实现一个支持无限排队的无界队列(unbounded queue),可是可能会致使过多的请求排队等待,请求处理的响应时间过长。因此,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。
而基于数组实现的有界队列(bounded queue),队列的大小有限,因此线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来讲,就相对更加合理。不过,设置一个合理的队列大小,也是很是有讲究的。队列太大致使等待的请求太多,队列过小会致使没法充分利用系统资源、发挥最大性能。
实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上均可以经过“队列”这种数据结构来实现请求排队。
【递归】
递归须要知足的三个条件:
1. 一个问题的解能够分解为几个子问题的解
2. 这个问题与分解以后的子问题,除了数据规模不一样,求解思路彻底同样
3. 存在递归终止条件
写递归代码的关键就是找到如何将大问题分解为小问题的规律,而且基于此写出递推公式,而后再推敲终止条件,最后将递推公式和终止条件翻译成代码。递归代码虽然简洁高效,可是,递归代码也有不少弊端。好比,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,因此,在编写递归代码的时候,必定要控制好这些反作用。
递归的优缺点?
1.优势:代码的表达力很强,写起来简洁。
2.缺点:空间复杂度高、有堆栈溢出风险、存在重复计算、过多的函数调用会耗时较多等问题。
递归常见问题及解决方案
1.警戒堆栈溢出:能够声明一个全局变量来控制递归的深度,从而避免堆栈溢出。
2.警戒重复计算:经过某种数据结构来保存已经求解过的值,从而避免重复计算。
几种最经典、最经常使用的排序方法:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。
对于排序算法执行效率的分析,咱们通常会从这几个方面来衡量:
1. 最好状况、最坏状况、平均状况时间复杂度
2. 时间复杂度的系数、常数 、低阶
3. 比较次数和交换(或移动)次数
排序算法的稳定性:若是待排序的序列中存在值相等的元素,通过排序以后,相等元素之间原有的前后顺序不变。
【冒泡排序(Bubble Sort)】
冒泡排序只会操做相邻的两个数据。每次冒泡操做都会对相邻的两个元素进行比较,看是否知足大小关系要求。
若是不知足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工做。
* Q:第一,冒泡排序是原地排序算法吗?
A:冒泡的过程只涉及相邻数据的交换操做,只须要常量级的临时空间,因此它的空间复杂度为O(1),是一个原地排序算法。
* Q:第二,冒泡排序是稳定的排序算法吗?
A:在冒泡排序中,只有交换才能够改变两个元素的先后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,咱们不作交换,相同大小的数据在排序先后不会改变顺序,因此冒泡排序是稳定的排序算法。
* Q:第三,冒泡排序的时间复杂度是多少?
最好状况下,要排序的数据已是有序的了,咱们只须要进行一次冒泡操做,就能够结束了,因此最好状况时间复杂度是 O(n)。而最坏的状况是,要排序的数据恰好是倒序排列的,咱们须要进行 n 次冒泡操做,因此最坏状况时间复杂度为 O(n²)。
【插入排序(Insertion Sort)】
咱们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。
插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。
重复这个过程,直到未排序区间中元素为空,算法结束。
* 空间复杂度:插入排序是原地排序算法。
* 时间复杂度:1. 最好状况:O(n)。2. 最坏状况:O(n^2)。3. 平均状况:O(n^2)
* 稳定性:插入排序是稳定的排序算法。
【选择排序(Selection Sort)】
选择排序算法的实现思路有点相似插入排序,也分已排序区间和未排序区间。可是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
* 选择排序空间复杂度为 O(1),是一种原地排序算法。
* 选择排序的最好状况时间复杂度、最坏状况和平均状况时间复杂度都为O(n²)。
* 选择排序是一种不稳定的排序算法。
冒泡排序和插入排序的时间复杂度都是 O(n²),都是原地排序算法,为何插入排序要比冒泡排序更受欢迎呢?
从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序须要3 个赋值操做,而插入排序只须要 1 个。
如何在 O(n) 的时间复杂度内查找一个无序数组中的第 K 大元素?须要用到两种时间复杂度为 O(nlogn) 的排序算法:归并排序和快速排序。这两种排序算法适合大规模的数据排序。
【归并排序(Merge Sort)】
若是要排序一个数组,咱们先把数组从中间分红先后两部分,而后对先后两部分分别排序,再将排好序的两部分合并在一块儿,这样整个数组就都有序了。
归并排序使用的就是分治思想。分治算法通常都是用递归来实现的。(分治是一种解决问题的处理思想,递归是一种编程技巧)
* 归并排序是一个稳定的排序算法。
* 归并排序的时间复杂度是很是稳定的,无论是最好状况、最坏状况,仍是平均状况,时间复杂度都是 O(nlogn)。
* 可是,归并排序不是原地排序算法,归并排序的空间复杂度是 O(n)。(由于归并排序的合并函数,在合并两个有序数组为一个有序数组时,须要借助额外的存储空间)
【快速排序(Quicksort)】
快排的思想是这样的:若是要排序数组中下标从 p 到 r 之间的一组数据,咱们选择 p 到 r 之间的任意一个数据做为 pivot(分区点)。
咱们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot放到中间。
通过这一步骤以后,数组 p 到 r 之间的数据就被分红了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
根据分治、递归的处理思想,咱们能够用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到r 之间的数据,直到区间缩小为 1,就说明全部的数据都有序了。
* 快排是一种原地、不稳定的排序算法。
* 快排的时间复杂度也是 O(nlogn)
归并排序和快速排序是两种稍微复杂的排序算法,它们用的都是分治的思想,代码都经过递归来实现,过程很是类似。
归并排序算法是一种在任何状况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正由于此,它也没有快排应用普遍。
快速排序算法虽然最坏状况下的时间复杂度是 O(n ),可是平均状况下时间复杂度都是O(nlogn)。
不只如此,快速排序算法时间复杂度退化到 O(n ) 的几率很是小,咱们能够经过合理地选择 pivot 来避免这种状况。
三种时间复杂度是 O(n) 的排序算法:桶排序、计数排序、基数排序。由于这些排序算法的时间复杂度是线性的,因此咱们把这类排序算法叫做线性排序(Linear sort)。
【桶排序(Bucket sort)】
将要排序的数据分到几个有序的桶里,每一个桶里的数据再单独进行排序。桶内排完序以后,再把每一个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序对要排序数据的要求是很是苛刻的。首先,要排序的数据须要很容易就能划分红 m 个桶,而且,桶与桶之间有着自然的大小顺序。这样每一个桶内的数据都排序完以后,桶与桶之间的数据不须要再进行排序。其次,数据在各个桶之间的分布是比较均匀的。若是数据通过桶的划分以后,有些桶里的数据很是多,有些很是少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端状况下,若是数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,没法将数据所有加载到内存中。
【计数排序(Counting sort)】—— 实际上是桶排序的一种特殊状况
当要排序的 n 个数据,所处的范围并不大的时候,好比最大值是 k,咱们就能够把数据划分红 k 个桶。每一个桶内的数据值都是相同的,省掉了桶内排序的时间
计数排序只能用在数据范围不大的场景中,若是数据范围 k 比要排序的数据 n 大不少,就不适合用计数排序了。并且,计数排序只能给非负整数排序,若是要排序的数据是其余类型的,要将其在不改变相对大小的状况下,转化为非负整数。
问题:如何根据年龄给100万用户数据排序?
咱们假设年龄的范围最小 1 岁,最大不超过 120 岁。咱们能够遍历这 100 万用户,根据年龄将其划分到这 120个桶里,而后依次顺序遍历这 120 个桶中的元素。这样就获得了按照年龄排序的 100 万用户数据。
【基数排序(Radix sort)】
假设有 10 万个手机号码,但愿将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?
有这样的规律:假设要比较两个手机号码 a,b 的大小,若是在前面几位中,a手机号码已经比 b 手机号码大了,那后面的几位就不用看了。
基数排序对要排序的数据是有要求的,须要能够分割出独立的“位”来比较,并且位之间有递进的关系,若是 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此以外,每一位的数据范围不能太大,要能够用线性排序算法来排序,不然,基数排序的时间复杂度就没法作到 O(n) 了。
如何实现一个通用的、高性能的排序函数?
快速排序比较适合来实现排序函数,如何优化快速排序?最理想的分区点是:被分区点分开的两个分区中,数据的数量差很少。为了提升排序算法的性能,要尽量地让每次分区都比较平均。
* 1. 三数取中法
①从区间的首、中、尾分别取一个数,而后比较大小,取中间值做为分区点。
②若是要排序的数组比较大,那“三数取中”可能就不够用了,可能要“5数取中”或者“10数取中”。
* 2.随机法:每次从要排序的区间中,随机选择一个元素做为分区点。
* 3.警戒快排的递归发生堆栈溢出,有2种解决方法,以下:
①限制递归深度,一旦递归超过了设置的阈值就中止递归。
②在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈过程,这样就没有系统栈大小的限制。
通用排序函数实现技巧
1.数据量不大时,能够采起用时间换空间的思路
2.数据量大时,优化快排分区点的选择
3.防止堆栈溢出,能够选择在堆上手动模拟调用栈解决
4.在排序区间中,当元素个数小于某个常数是,能够考虑使用O(n^2)级别的插入排序
5.用哨兵简化代码,每次排序都减小一次判断,尽量把性能优化到极致
【二分查找】
二分查找针对的是一个有序的数据集合,查找思想有点相似分治思想。每次都经过跟区间的中间元素对比,将待查找的区间缩小为以前的一半,直到找到要查找的元素,或者区间被缩小为 0。
二分查找是一种很是高效的查找算法,时间复杂度是 O(logn)。O(logn) 这种对数时间复杂度,是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级O(1) 的算法还要高效。二分查找更适合处理静态数据,也就是没有频繁的数据插入、删除操做。
使用循环和递归均可以实现二分查找。
二分查找应用场景的局限性:
* 二分查找依赖的是顺序表结构,简单点说就是数组。(链表不能够)
* 二分查找针对的是有序数据。(若是数据没有序,咱们须要先排序。)
* 数据量太大不适合二分查找。
四种常见的二分查找变形问题
1.查找第一个值等于给定值的元素
2.查找最后一个值等于给定值的元素
3.查找第一个大于等于给定值的元素
4.查找最后一个小于等于给定值的元素
适用性分析
1.凡事能用二分查找解决的,绝大部分咱们更倾向于用散列表或者二叉查找树,即使二分查找在内存上更节省,可是毕竟内存如此紧缺的状况并很少。
2.求“值等于给定值”的二分查找确实不怎么用到,二分查找更适合用在”近似“查找问题上。好比上面讲几种变体。
【跳表】
跳表是一种动态数据结构,能够支持快速的插入、删除、查找操做,写起来也不复杂,甚至能够替代红黑树(Red-black tree)。Redis 中的有序集合(Sorted Set)就是用跳表来实现的。
链表加多级索引的结构,就是跳表。
在一个单链表中查询某个数据的时间复杂度是 O(n)。那在一个具备多级索引的跳表中查询任意数据的时间复杂度是 O(logn)。这
个查找的时间复杂度跟二分查找是同样的。换句话说,咱们实际上是基于单链表实现了二分查找。(这种查询效率的提高,前提是创建了不少级索引,也就是空间换时间的设计思路。)
跳表的空间复杂度是O(n)。也就是说,若是将包含 n 个结点的单链表构形成跳表,咱们须要额外再用接近 n 个结点的存储空间。
在实际的软件开发中,原始链表中存储的有多是很大的对象,而索引结点只须要存储关键值和几个指针,并不须要存储对象,因此当对象比索引结点大不少时,那索引占用的额外空间就能够忽略了。
跳表这个动态数据结构,不只支持查找操做,还支持动态的插入、删除操做,并且插入、删除操做的时间复杂度也是 O(logn)。
做为一种动态数据结构,咱们须要某种手段来维护索引与原始链表大小之间的平衡,也就是说,若是链表中结点多了,索引结点就相应地增长一些,避免复杂度退化,以及查找、插入、删除操做性能降低。
跳表是经过随机函数来维护“平衡性”,当咱们往跳表中插入数据的时候,咱们能够选择同时将这个数据插入到部分索引层中。
为何 Redis 要用跳表来实现有序集合,而不是红黑树?
Redis 中的有序集合支持的核心操做主要有下面这几个:
* 插入一个数据;
* 删除一个数据;
* 查找一个数据;
* 按照区间查找数据(好比查找值在 [100, 356] 之间的数据);
* 迭代输出有序序列。
对于按照区间查找数据这个操做,跳表能够作到 O(logn) 的时间复杂度定位区间的起点,而后在原始链表中顺序日后遍历就能够了。这样作很是高效。
【散列表】
用的是数组支持按照下标随机访问数据的特性,因此散列表其实就是数组的一种扩展,由数组演化而来。能够说,若是没有数组,就没有散列表。
散列函数,能够把它定义成hash(key),其中 key 表示元素的键值,hash(key) 的值表示通过散列函数计算获得的散列值。
散列函数设计的基本要求:
1. 散列函数计算获得的散列值是一个非负整数;
2. 若是 key1 = key2,那 hash(key1) == hash(key2);
3. 若是 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
散列冲突
再好的散列函数也没法避免散列冲突。经常使用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。
开放寻址法的核心思想是,若是出现了散列冲突,咱们就从新探测一个空闲位置,将其插入。三种探测方法是:线性探测(Linear Probing)、二次探测(Quadratic probing)、双重散列(Double hashing)。
哈希算法的定义:将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而经过原始数据映射以后获得的二进制值串就是哈希值。常见的例如:MD五、SHA。
设计一个优秀的哈希算法须要知足的几点要求:
* 从哈希值不能反向推导出原始数据(因此哈希算法也叫单向哈希算法);
* 对输入数据很是敏感,哪怕原始数据只修改了一个 Bit,最后获得的哈希值也大不相同;
* 散列冲突的几率要很小,对于不一样的原始数据,哈希值相同的几率很是小;
* 哈希算法的执行效率要尽可能高效,针对较长的文本,也能快速地计算出哈希值。
哈希算法的七个常见应用:
* 安全加密:MD五、SHA、DES、AES。很难根据哈希值反向推导出原始数据;散列冲突的几率要很小(由于没法作到零冲突)。
* 惟一标识:哈希算法能够对大数据作信息摘要,经过一个较短的二进制编码来表示很大的数据。
(1)海量的图库中,搜索一张图是否存在
* 数据校验:校验数据的完整性和正确性。
* 散列函数:对哈希算法的要求很是特别,更加看重的是散列的平均性和哈希算法的执行效率。
* 负载均衡:利用哈希算法替代映射表,能够实现一个会话粘滞的负载均衡策略。
(1)在同一个客户端上,在一次会话中的全部请求都路由到同一个服务器上。
* 数据分片:经过哈希算法对处理的海量数据进行分片,多机分布式处理,能够突破单机资源的限制。
(1)如何统计“搜索关键词”出现的次数?
(2)如何快速判断图片是否在图库中?
* 分布式存储:利用一致性哈希算法,能够解决缓存等分布式系统的扩容、缩容致使数据大量搬移的难题。
(1)如何决定将哪一个数据放到哪一个机器上?
(2)一致性哈希算法
以前说的栈和队列都是线性表结构,树是非线性表结构。
关于树的经常使用概念:根节点、叶子节点、父节点、子节点、兄弟节点,还有节点的高度、深度、层数,以及树的高度。
最经常使用的树就是二叉树(Binary Tree)。二叉树的每一个节点最多有两个子节点,分别是左子节点和右子节点。
二叉树中,有两种比较特殊的树,分别是满二叉树和彻底二叉树。满二叉树又是彻底二叉树的一种特殊状况。
二叉树的两种存储方式:
(1)用链式存储:
* 每一个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。
* 咱们只要拎住根节点,就能够经过左右子节点的指针,把整棵树都串起来。
* 这种存储方式咱们比较经常使用。大部分二叉树代码都是经过这种结构来实现的。
(2)用数组顺序存储:
* 若是节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。
* 反过来,下标为 i/2 的位置存储就是它的父节点。
* 经过这种方式,咱们只要知道根节点存储的位置(通常状况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就能够经过下标计算,把整棵树都串起来。
* 数组顺序存储的方式比较适合 彻底二叉树,其余类型的二叉树用数组存储会比较浪费存储空间。
若是某棵二叉树是一棵彻底二叉树,那用数组存储是最节省内存的一种方式。由于数组的存储方式并不须要像链式存储法那样,要存储额外的左右子节点的指针。(这也是为何彻底二叉树会单独拎出来的缘由,也是为何彻底二叉树要求最后一层的子节点都靠左的缘由。)堆 就是一种彻底二叉树,最经常使用的存储方式就是数组。
【二叉树的遍历】
二叉树里很是重要的操做就是前序遍历、中序遍历、后序遍历,用递归代码来实现遍历的时间复杂度是 O(n)。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的前后顺序。
(1)前序遍历是指,对于树中的任意节点来讲,先打印这个节点,而后再打印它的左子树,最后打印它的右子树。
* preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)
(2)中序遍历是指,对于树中的任意节点来讲,先打印它的左子树,而后再打印它自己,最后打印它的右子树。
* inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)
(3)后序遍历是指,对于树中的任意节点来讲,先打印它的左子树,而后再打印它的右子树,最后打印这个节点自己。
* postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r
【二叉查找树(Binary Search Tree)】
二叉查找树是为了实现快速查找而生的,它不只仅支持快速查找一个数据,还支持快速插入、删除一个数据。
二叉查找树要求,在树中的任意一个节点,其左子树中的每一个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
1. 二叉查找树的查找操做
先取根节点,若是它等于咱们要查找的数据,那就返回。若是要查找的数据比根节点的值小,那就在左子树中递归查找;若是要查找的数据比根节点的值大,那就在右子树中递归查找。(感受有点像 二分查找)
2. 二叉查找树的插入操做
二叉查找树的插入过程有点相似查找操做。新插入的数据通常都是在叶子节点上,因此咱们只须要从根节点开始,依次比较要插入的数据和节点的大小关系。若是要插入的数据比节点的数据大,而且节点的右子树为空,就将新数据直接插到右子节点的位置;若是不为空,就再递归遍历右子树,查找插入位置。同理,若是要插入的数据比节点数值小,而且节点的左子树为空,就将新数据插入到左子节点的位置;若是不为空,就再递归遍历左子树,查找插入位置。
3. 二叉查找树的删除操做
针对要删除节点的子节点个数的不一样,须要分三种状况来处理:
* 若是要删除的节点没有子节点,咱们只须要直接将父节点中,指向要删除节点的指针置为 null。
* 若是要删除的节点只有一个子节点(只有左子节点或者右子节点),咱们只须要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就能够了。
* 若是要删除的节点有两个子节点,须要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。而后再删除掉这个最小节点,由于最小节点确定没有左子节点(若是有左子结点,那就不是最小节点了),因此,咱们能够应用上面两条规则来删除这个最小节点。
4. 二叉查找树的其余操做
二叉查找树中还能够支持快速地查找最大节点和最小节点、前驱节点和后继节点。
二叉查找树还有一个重要的特性,就是中序遍历二叉查找树,能够输出有序的数据序列,时间复杂度是 O(n),很是高效。所以,二叉查找树也叫做二叉排序树。
支持重复数据的二叉查找树:若是存储的两个对象键值相同,有两种解决方法。
* 第一种方法:二叉查找树中每个节点不只会存储一个数据,所以咱们经过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
* 第二种方法:每一个节点仍然只存储一个数据。在查找插入位置的过程当中,若是碰到一个节点的值,与要插入数据的值相同,咱们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据看成大于这个节点的值来处理。当要查找数据的时候,遇到值相同的节点,咱们并不中止查找操做,而是继续在右子树中查找,直到遇到叶子节点,才中止。这样就能够把键值等于要查找值的全部节点都找出来。对于删除操做,咱们也须要先查找到每一个要删除的节点,而后再按前面讲的删除操做的方法,依次删除。
二叉查找树的时间复杂度分析:
彻底二叉树(或满二叉树),无论操做是插入、删除仍是查找,时间复杂度其实都跟树的高度成正比,也就是 O(height)。二叉查找树在比较平衡的状况下,插入、删除、查找操做时间复杂度是O(logn)。
* 有了高效的散列表(时间复杂度是 O(1)),为何还须要二叉查找树?1. 散列表中的数据是无序存储的,若是要输出有序的数据,须要先进行排序。而对于二叉查找树来讲,咱们只须要中序遍历,就能够在 O(n) 的时间复杂度内,输出有序的数据序列。2. 散列表扩容耗时不少,并且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,可是在工程中,咱们最经常使用的平衡二叉查找树的性能很是稳定,时间复杂度稳定在O(logn)。3. 笼统地来讲,尽管散列表的查找等操做的时间复杂度是常量级的,但由于哈希冲突的存在,这个常量不必定比 logn 小,因此实际的查找速度可能不必定比 O(logn) 快。加上哈希函数的耗时,也不必定就比平衡二叉查找树的效率高。4. 散列表的构造比二叉查找树要复杂,须要考虑的东西不少。好比散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只须要考虑平衡性这一个问题,并且这个问题的解决方案比较成熟、固定。5. 为了不过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,否则会浪费必定的存储空间。综合这几点,平衡二叉查找树在某些方面仍是优于散列表的,因此,这二者的存在并不冲突。