数据结构和算法

本文是王争的《数据结构与算法之美》的学习笔记。


从广义上讲,数据结构就是指一组数据的存储结构。算法就是操作数据的一组方法。从狭义上讲,指某些著名的数据结构和算法,比如队列、栈、堆、二分查找、动态规划等。数据结构是为算法服务的,算法要作用在特定的数据结构之上。

在实际的软件开发中,业务纷繁复杂,功能千变万化,但是,万变不离其宗。如果抛开这些业务和功能的外壳,其实它们的本质都可以抽象为“对数据的存储和计算”。对应到数据结构和算法中,那“存储”需要的就是数据结构,“计算”需要的就是算法。

  1、复杂度分析

数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。

1.1、大O复杂度表示法

假设每行代码执行时间都相同,

  • 例1:那么一个3行代码执行时间是3(每行执行1次),
  • 例2:一个3行for循环n次的代码执行时间是3n(每行执行n次),
  • 例3:一个for循环n次内嵌套的3行for循环n次代码执行时间是3n²(每行执行n²次)。

得出所有代码的执行时间T(n)和每行代码的执行次数f(n)成正比。得出公式:T(n) = O(f(n))。

大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

当n非常大时,公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。所以前面例2和例3可以记为T(n) = O(n); T(n) = O(n²)。

1.2、三个时间复杂度分析方法

  1. 方法一,只关注循环次数最多的一段代码:我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。
  2. 方法二,加法法则:总复杂度等于量级最大的那段代码的复杂度。抽象成公式T1(n)=O(f(n)),T2(n)=O(g(n));那么T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).
  3. 方法三:乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积。公式T1(n)=O(f(n)),T2(n)=O(g(n));那么T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n)). 也就是说,假设T1(n) = O(n),T2(n) = O(n2),则T1(n) * T2(n) = O(n3)。落实到具体的代码上,我们可以把乘法法则看成是嵌套循环。

1.3、几种常见时间复杂度分析

主要来看几种常见的多项式时间复杂度、非多项式不看(当数据规模n越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长

  • 多项式量级: 常量阶O(1)、对数阶O(logn)、线性阶O(n)、线性对数阶O(nlogn)、平方阶O(n²)(以及立方阶O(n³)、k次方阶等)
  • 非多项式量级: 指数阶O(2ⁿ)、阶乘阶O(n!)
  1. O(1):一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)。代码的执行时间不随n的增大而增长。
  2. O(logn)、O(nlogn):例如执行i*2,只要i<=n就循环执行,那么最终执行了k次。2的k次方等于n,则k=log₂n,时间复杂度就是O(log₂n)。O(log₂n)  等于O(log₃n)。在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为O(logn)。一段代码的时间复杂度是O(logn),我们循环执行n遍,时间复杂度就是O(nlogn)了。比如,归并排序、快速排序的时间复杂度都是O(nlogn)。
  3. O(m+n)、O(m*n):例如代码中一段循环m次一段循环n次,或者m次循环嵌套了n次循环,无法评估m和n的量级谁大,那么代码的复杂度由两个数据的规模来决定。

从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n² )

1.4、最好、最坏、平均、均摊时间复杂度

有些代码在不同的情况下时间复杂度是不一样的,存在最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度。均摊时间复杂度就是一种特殊的平均时间复杂度,而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。

1.5、空间复杂度分析

空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。我们常见的空间复杂度就是O(1)、O(n)、O(n²),像O(logn)、O(nlogn)这样的对数阶复杂度平时都用不到。

2、数组

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

  • 线性表:只有前后两个方向的结构。数组、链表、队列、栈等是线性表结构。二叉树、堆、图等就是非线性表结构。
  • 连续的内存空间和相同类型的数据:因为这两个限制,数组可“随机访问”,根据下标随机访问的时间复杂度为O(1)。但也使删除和插入变得非常低效,平均情况时间复杂度为O(n)。
  • 为什么下标都从0开始而不是1:因为“下标”最确切的定义应该是“偏移(offset)”。a[0]就是偏移为0的位置,也就是首地址,a[k]就表示偏移k个位置

3、链表

3.1、链表的分类

通过指针将一组零散的内存块串联在一起的结构叫链表。其中,我们把内存块称为链表的“结点”。

  1. 单链表:每个结点存储数据data,还有后继指针next记录下一个结点的地址。头结点记录链表基地址,尾结点指针指向空地址null。
  2. 循环链表:尾结点指针指向头结点。
  3. 双向链表:每个结点还有一个前驱指针prev指向前面的结点(所以更占内存空间)。
  4. 双向循环链表:循环和双向合在一起的链表。

3.2、链表和数组的区别

  • 数组需要一块连续的内存空间,链表通过“指针”将一组零散的内存块串联起来使用
  • 数组随机访问时间复杂度是O(1)(因为是连续存储,根据首地址和下标就可以计算出地址),链表机访问时间复杂度是O(n)(因为需要根据指针一个个结点依次遍历,直到找到结点)。
  • 数组插入删除时间复杂度是O(n)(因为保持数据连续性,删除插入后需要做大量数据搬移),链表插入删除时间复杂度是O(1)(因为只需要考虑相邻结点的指针改变)。

3.3、空间时间设计思想

对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。

4、栈

  • 栈是一种“操作受限”的线性表,只允许在一端插入和删除数据。先进后出,后进先出。
  • 用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈
  • 空间复杂度和入栈出栈的时间复杂度都是O(1)
  • 栈的常见应用场景有括号匹配、算数表达式等。

5、队列

和栈一样,队列也是一种操作受限的线性表数据结构

5.1、栈和队列的区别

  • 栈是先进后出,操作有入栈push()出栈pop(),只允许一端进出。
  • 队列是先进先出,操作有入队enqueue()出队dequeue(),一端进一端出。

5.2、队列的分类

用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列

首尾相连的是循环队列。循环队列会浪费一个数组的存储空间(tail指向位置无数据)。

  • 非循环队列的队空条件head == tail,队满条件tail == n。
  • 循环队列的队空条件head == tail,队满条件(tail+1)%n=head

其他还有阻塞队列并发队列

6、递归

适用递归算法的模型满足三个条件:

  1. 一个大问题可以分解成多个子问题。(n的阶乘=n乘以n-1的阶乘)
  2. 大问题和子问题的求解思路完全一样。(n-1的阶乘=n-1乘以n-2的阶乘)
  3. 存在递归终止的条件。(0的阶乘=1)

递归代码的核心就是写出递推公式,找到终止条件。只要满足最终结果和它前一个结果相关,并且可以抽象出一个递推公式,并且第一个结果是知道的,就可以使用递推,将复杂的计算交给计算机。

递归要警惕重复计算和堆栈溢出。另外,所有递归代码都可以改写成迭代循环的非递归代码。

递归很容易出现重复计算导致超时的问题,所以可以使用记忆化递归,即添加一个参数保存已经计算过的结果,每次先检查是否已经算过直接取出。

7、排序

7.1、如何分析一个排序算法

  • 排序算法的执行效率
    • 最好、最坏、平均情况时间复杂度
    • 时间复杂度的系数、常数、低阶
    • 比较次数和交换(移动)次数
  • 排序算法的内存消耗
    • 原地排序:特指空间复杂度是O(1)的排序算法
  • 排序算法的稳定性
    • 稳定的排序算法:经过排序过程排序,相同的两个元素前后顺序没变就是稳定的排序算法。

7.2、冒泡、插入、选择排序

这三种排序方法时间复杂度都是O(n²),都基于比较。

  1. 冒泡:比较相邻的两个元素,不满足大小关系要求就把它们互换。
  2. 插入:将数组中的数据分为两个区间,已排序区间未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
  3. 选择:选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾(和未排序区间的第一个做交换)。

三个方法对比:

  • 冒泡排序:是原地排序,是稳定的排序算法,最好时间复杂度O(n),最坏时间复杂度O(n²),平均时间复杂度O(n²)。
  • 插入排序:是原地排序,是稳定的排序算法,最好时间复杂度O(n),最坏时间复杂度O(n²),平均时间复杂度O(n²)。
  • 选择排序:是原地排序,是不稳定的排序算法,最好时间复杂度O(n²),最坏时间复杂度O(n²),平均时间复杂度O(n²)。

7.3、归并排序、快速排序

这两种排序方法都用到了分治思想,时间复杂度都是O(nlogn),都基于比较。分治思想就是将一个大问题分解成小的子问题来解决。分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧。

  • 归并:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
  • 快速:选任意一个数据作为pivot(分区点)。小于它的放到左边区间,大于它的放到右边区间。然后根据递归思想,再对左右区间内的数继续选一个pivot(分区点)再执行这个方法,直到区间缩小为1。

两种方法对比:

  • 归并排序:不是原地排序(空间复杂度是O(n)),是稳定排序。最好、最坏、平均时间复杂度是O(nlogn)。归并排序处理过程由下到上的,先处理子问题,然后再合并。
  • 快速排序:是原地排序,是不稳定的排序算法。时间复杂度是O(nlogn),极端情况下是O(n²)。快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。

7.4、线性排序:桶排序、计数排序、基数排序

这三种方法时间复杂度都是O(n),都不是基于比较的。重点是掌握适用场景。都是稳定排序,都不是原地排序。

  • 桶排序:将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
  • 计数排序:计数排序其实是桶排序的一种特殊情况。当要排序的n个数据,所处的范围并不大的时候,比如最大值是k,我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
  • 基数排序:按照每一位来排序。比如手机号,每一位排一次,排序11次后就完成了。如果位数不一样长,我们可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”,因为根据ASCII值,所有字母都大于“0”,所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了。

三种方法适用场景:

  • 桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
  • 计数排序只能用在数据范围不大的场景中,如果数据范围k比要排序的数据n大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
  • 基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果a数据的高位比b数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到O(n)了。

8、二分查找

方法思想:二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0。

时间复杂度为O(logn)。可以用循环实现,也可以用递归实现

局限性:

  • 二分查找依赖的是顺序表结构,简单点说就是数组
  • 二分查找针对的是有序数据
  • 数据量太小不适合二分查找(也许顺序查找可以更快)
  • 数据量太大也不适合二分查找(二分查找依赖数组,而要求内存空间连续,对内存的要求比较苛刻)

四种常见变形:

  1. 查找第一个值等于给定值
  2. 查找最后一个值等于给定值
  3. 查找第一个大于等于给定值的元素
  4. 查找最后一个小于等于给定值的元素

9、跳表

背景:

  • 因为二分查找依赖数组随机访问的特性,所以只能用数组来实现,不能用链表。而跳表就是对链表改造后可以支持类似“二分”的查找算法的数据结构。

跳表结构:

  • 对于一个单链表,我们要查找某个数据只能从头到尾遍历。但是给链表建立一级索引层,例如每两个结点提取一个结点到上一级,我们把抽出来的那一级叫做索引索引层。加了一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。而且还可以加多层索引。

跳表特性:

  • 跳表查询的时间复杂度O(logn),但是跳表的空间复杂度O(n)。属于空间换时间。
  • 跳表不仅支持查找,还支持动态的插入删除,而且时间复杂度也是O(logn)。
  • 索引可以根据实际情况动态更新,随时调整避免复杂度退化。

10、散列表(哈希表、Hash表)

散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。

散列表结构:

  • 键key(或者关键字),通过散列函数(或“Hash函数”“哈希函数”)计算得到散列值(或“Hash值”“哈希值”)。

三点散列函数设计的基本要求:

  • 散列函数计算得到的散列值是一个非负整数;
  • 如果key1 = key2,那hash(key1) == hash(key2);
  • 如果key1 ≠ key2,那hash(key1) ≠ hash(key2)。(但这几乎不可能,即使著名的MD5SHACRC等哈希算法无法完全避免散列冲突)

解决散列冲突常用两种方法:

  • 开放寻址法(open addressing):线性探测、二次探测、双重散列。
    • (当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是Java中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。)
  • 链表法(chaining)
    • (基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。)

装载因子:

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

应用技巧:

  • 设计散列函数:不能太复杂,生成的值要尽可能随机并且均匀分布
  • 装载因子过大:可设置一个阈值,超过该阈值时,就把散列表动态扩容。阈值要选择的合理,如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。
  • 如何避免低效扩容:将扩容操作穿插在插入操作的过程中,分批完成。这样插入一个数据的时间复杂度都是O(1)

为什么散列表经常和链表一起使用:

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

11、哈希算法

哈希定义:

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

哈希算法要求:

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

11.1、哈希算法的应用场景

  1. 安全加密(如MD5、SHA、DES、AES等)
  2. 唯一标识
  3. 数据校验
  4. 散列函数
  5. 负载均衡:我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。(我们可以通过哈希算法,对客户端IP地址或者会话ID计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。)
  6. 数据分片:(例如一台机器内存有限,就可以把数据分片然后多台机器共同处理,然后哈希值相同的就被分到同一个机器上)
  7. 分布式存储:面对现在互联网的海量数据,一般采用分布式存储方式,将数据分布在多台机器上。通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。为解决扩容后搬移的问题,可使用一致性哈希算法

12、二叉树

12.1、树的概念

  • 如果A是B、C、D的父节点
  • 那么B、C、D就是A的子节点
  • B、C、D互为兄弟节点
  • 没有父节点的(最顶端)是根节点
  • 没有子节点的(最底端)是叶子节点

节点的高度、深度、层:

  • 高度:该节点到叶子节点的最长路径,即边个数。(从下到上0,1,2,3....)
  • 深度:根节点到该节点所经历的边个数。(从上到下0,1,2,3....)
  • :节点的深度+1。(从上到下1,2,3,4....)
  • 树的高度=根节点的高度

12.2、二叉树

  1. 二叉树:每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点右子
  2. 满二叉树:叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点。(即满足每层都是满的1,2,4,8,16......)
  3. 完全二叉树:叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树。(即一个满二叉树下面再加一层,而加的这一层节点都在左边,不在右边。左边没有空洞,右边没有节点)

完全二叉树有什么特别,为什么值得单独讲,这与表示(存储)二叉树的方法有关:

  • 链式存储法:每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。(缺配图)
  • 顺序存储法:层数从上往下,每一层内从左往右,依次把每个节点存储到1,2,3,4......,如果有空的,就把对应的存储位置空出来。(那么根节点i = 1,第二层的2个节点是2和3,第三层就是4,5,6,7......。规律每个节点的左子节点2 * i ,右子节点存储在2 * i + 1。)(缺配图)

所以完全二叉树的特别之处就在于,在顺序存储法中,完全二叉树的节点存储位置是连续的,不会浪费数组存储空间。例如有9个节点的完全二叉树,他所有节点的存储空间一定是1,2,3,4,5,6,7,8,9

12.3、二叉树的遍历

二叉树遍历的时间复杂度是O(n),有三种遍历方式:

  • 前序遍历:对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。(父--左--右)
  • 中序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。(左--父--右)
  • 后序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。(左--右--父)
  • 简单记忆:左子节点一定在右子节点前面,前序后序中序分别指父节点插入子节点的前、中、后。(缺配图)

实际上,二叉树的前、中、后序遍历就是一个递归的过程

12.4、二叉查找树

二叉查找树:在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是O(n)。各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是O(logn)。

  • 查找:先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。(缺配图)
  • 插入:新插入的数据一般都在叶子节点上。如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。(缺配图)
  • 删除:三种情况(缺配图)
    • 第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为null。比如图中的删除节点55。
    • 第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点13。
    • 第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点18。
    • 取巧的删除方法:就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。

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

  • 方法一:通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
  • 方法二:在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,把这个新插入的数据当作大于这个节点的值来处理。相应的,查找和删除的动作就不能找到后就停下,还需要继续往下找,因为下面可能还存在相同的数值。

13、红黑树

平衡二叉树:二叉树中任意一个节点的左右子树的高度相差不能大于1。(但实际上经常不这么严格)。(其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。)

平衡二叉查找树:既“平衡”又“查找”。例如AVL树、Splay Tree(伸展树)、Treap(树堆)、红黑树。“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化得太严重

红黑树:

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

AVL树是一种高度平衡的二叉树,所以查找的效率非常高,但每次插入、删除都要做调整,就比较复杂、耗时。红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比AVL树要低。

递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题。如果我们把这个一层一层的分解过程画成图,它就是递归树

这部分很难又用的不多,先了解下概念:

  • 左旋(rotate left)全称其实是叫围绕某个节点的左旋右旋(rotate right)就叫围绕某个节点的右旋
  • 插入操作的平衡调整技巧
  • 删除操作的平衡调整技巧
  • 针对关注节点进行二次调整技巧

14、堆和堆排序

堆(Heap):堆是一种特殊的树

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

堆上的操作有:往堆中插入一个元素,删除堆顶元素。

堆常见的应用有:优先级队列、利用堆求TOP K、利用堆求中位数。

堆排序是一种原地的、时间复杂度为O(nlogn)的排序算法。堆排序的过程大致分解成两个大的步骤,建堆排序

15、图

(Graph)是一种非线性表数据结构。由顶点(vertex)和(edge)组成。边没有方向的图叫“无向图”,边有方向的图叫“有向图”。每条边都有一个权重(weight)的图叫带权图(weighted graph)

无向图中一个顶点连接了几条边就叫顶点的(degree)。有向图中的度分为入度(In-degree)和出度(Out-degree)。

基于图这种数据结构,有广度优先搜索(BFS)深度优先搜索(DFS)两种算法。广度优先即层层推进;深度优先就像走迷宫,你随意选择一个岔路口来走,走着走着发现走不通的时候,你就回退到上一个岔路口,重新选择一条路继续走,直到最终找到出口。

16、字符串匹配

在字符串A中查找字符串B,那字符串A就是主串,字符串B就是模式串。主串长度n,模式串长度m,n>m

16.1、BF算法(Brute Force)

即暴力匹配,我们在主串中,检查起始位置分别是0、1、2....n-m且长度为m的n-m+1个子串,看有没有跟模式串匹配的。最坏情况时间复杂度是O(n*m)

16.2、RK算法(Rabin-Karp)

其实就是刚刚讲的BF算法的升级版。通过哈希算法对主串中的n-m+1个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了。(因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了)。RK算法整体的时间复杂度就是O(n)

16.3、BM算法(Boyer-Moore)

我们把模式串和主串的匹配过程,看作模式串在主串中不停地往后滑动。当遇到不匹配的字符时,BF算法和RK算法的做法是,模式串往后滑动一位,然后从模式串的第一个字符开始重新匹配。而BM算法就是,当遇到不匹配的字符时,找固定的规律,将模式串往后多滑动几位。

BM算法包含两部分,分别是坏字符规则(bad character rule)和好后缀规则(good suffix shift)。

16.4、KMP算法(Knuth Morris Pratt)

KMP算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?KMP算法的时间复杂度就是O(m+n)

17、Trie树和AC自动机(Aho-Corasick)

17.1、Trie树

Trie树,也叫“字典树”。是一个多叉树。Trie树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。最常见的应用就是搜索引擎的搜索关键词提示功能。

Trie树主要有两个操作,一个是将字符串集合构造成Trie树。这个过程分解开来的话,就是一个将字符串插入到Trie树的过程。另一个是在Trie树中查询一个字符串

构建好Trie树后,在其中查找字符串的时间复杂度是O(k),k表示要查找的字符串的长度。

17.2、AC自动机(Aho-Corasick)

AC自动机实际上就是在Trie树之上,加了类似KMP的next数组,只不过此处的next数组是构建在树上罢了

AC自动机的构建,包含两个操作:

  • 将多个模式串构建成Trie树;
  • 在Trie树上构建失败指针(相当于KMP中的失效函数next数组)

以上学习的几种字符串匹配算法:

  • 有BF算法、RK算法、BM算法、KMP算法----单模式串匹配算法
  • Trie树、AC自动机----多模式串匹配算法

18、基础算法思想

贪心算法、分治算法、回溯算法、动态规划。更加确切地说,它们应该是算法思想,并不是具体的算法,常用来指导我们设计具体的算法和编码等。

18.1、贪心算法

贪心的思想就是在某限制条件下,尽量使我们的期望值达到最大。因此,当我们看到这类问题的时候,首先要联想到贪心算法:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。

举例:一个背包只能装100kg,有5种不同价值的物品,怎么样装能让背包总价值最大?根据贪心思想,一定是先装最贵的物品直到该物品装完或背包没有空间为止,然后再装第二贵的物品......在这个例子中,限制值就是重量不能超过100kg,期望值就是物品的总价值。这组数据就是5种物品。我们从中选出一部分,满足重量不超过100kg,并且总价值最大。

18.2、分治算法

分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。

和递归的区别:分治算法是一种处理问题的思想,递归是一种编程技巧。分治算法一般都比较适合用递归来实现。

18.3、回溯算法

回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。回溯算法非常适合用递归代码实现。

19、动态规划

什么样的问题适合用动态规划来解决呢?  ----一个模型,三个特征

  • 一个模型:多阶段决策最优解模型
  • 特征1:最优子结构。问题的最优解包含子问题的最优解。后面阶段的状态可以通过前面阶段的状态推导出来。
  • 特征2:无后效性。第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。
  • 特征3:重复子问题。不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。

解决动态规划问题,一般有两种思路:

  • 状态转移表法:回溯算法实现-定义状态-画递归树-找重复子问题-画状态转移表-根据递推关系填表-将填表过程翻译成代码
  • 状态转移方程法:找最优子结构-写状态转移方程-将状态转移方程翻译成代码

20、各部分学习指导

算法难易度重点程度掌握程度简述
复杂度分析Medium10能分析大部分数据结构和算法的时间、空间复杂度非常重要,牢牢掌握
数组、栈、队列Easy8能自己实现动态数组、栈、队列较简单,是最基础的数据结构,要掌握

链表

Medium9能轻松写出经典链表题目代码非常重要,操作复杂
递归Hard10轻松写出二叉树遍历、八皇后、背包问题、DFS的递归代码很难很重要
排序、二分查找Easy7能自己把各种排序算法、二分查找及其变体代码写一遍较简单
跳表Medium6初学者可以先跳过不必非得掌握
散列表Medium8能代码实现一个拉链法解决冲突的散列表即可并不很难,应用广泛,要牢固掌握
哈希算法Easy3可以暂时不看可以不看
二叉树Medium9能代码实现二叉树的三种遍历算法、按层遍历、求高度等经典二叉树题目很重要,重点掌握
红黑树Hard3初学者不用管很难,但不用看
B+树Medium5可看可不看面试偶尔会问,可看可不看
堆与堆排序Medium8能代码实现堆、堆排序,并且掌握堆的三种应用(优先级队列、Top k、中位数)不难,要掌握
图的表示Easy8理解图的三种表示方法(邻接矩阵、邻接表、逆邻接表),能自己代码实现只用掌握最基本图的概念、表示方法
深度广度优先搜索Hard8能代码实现广度优先、深度优先搜索算法图上最基础的遍历或者说是搜索算法,要掌握
拓扑排序、最短路径、A*算法Hard5有时间再看,暂时可以不看稍高级,不是重点
字符串匹配(BF、RK)Easy7能实践BF算法,能看懂RK算法都不难,最好掌握
字符串匹配(BM、KMP、AC自动机)Hard3初学者不用管能看懂就行
字符串匹配(Trie)Medium7能看懂,知道特点、应用场景即可,不要求代码实现能看懂,不需要掌握代码实现
位图Easy6看懂即可不是重点,有余力再掌握
四种算法思想Hard10可以放到最后,但是一定要掌握!做到能实现Leetcode上Medium难度的题目重点+难点。需大量刷题
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值