数据结构
数组、链表的优缺点
数组的优缺点
数组的优点在于:
- 构建非常简单
- 能在 O(1) 的时间里根据数组的下标(index)查询某个元素
而数组的缺点在于:
- 构建时必须分配一段连续的空间
- 查询某个元素是否存在时需要遍历整个数组,耗费 O(n) 的时间(其中,n 是元素的个数)
- 删除和添加某个元素时,同样需要耗费 O(n) 的时间
链表的优缺点
链表的优点如下:
- 链表能灵活地分配内存空间;
- 能在 O(1) 时间内删除或者添加元素,前提是该元素的前一个元素已知,当然也取决于是单链表还是双链表,在双链表中,如果已知该元素的后一个元素,同样可以在 O(1) 时间内删除或者添加该元素。
链表的缺点是:
- 不像数组能通过下标迅速读取元素,每次都要从链表头开始一个一个读取;
- 查询第 k 个元素需要 O(k) 时间。
应用场景:如果要解决的问题里面需要很多快速查询,链表可能并不适合;如果遇到的问题中,数据的元素个数不确定,而且需要经常进行数据的添加和删除,那么链表会比较合适。而如果数据元素大小确定,删除插入的操作并不多,那么数组可能更适合。
栈和队列
栈
特点:栈的最大特点就是后进先出(LIFO)。对于栈中的数据来说,所有操作都是在栈的顶部完成的,只可以查看栈顶部的元素,只能够向栈的顶部压⼊数据,也只能从栈的顶部弹出数据。
实现:利用一个单链表来实现栈的数据结构。而且,因为我们都只针对栈顶元素进行操作,所以借用单链表的头就能让所有栈的操作在 O(1) 的时间内完成。
应用场景:在解决某个问题的时候,只要求关心最近一次的操作,并且在操作完成了之后,需要向前查找到更前一次的操作。如果打算用一个数组外加一个指针来实现相似的效果,那么,一旦数组的长度发生了改变,哪怕只是在最后添加一个新的元素,时间复杂度都不再是 O(1),而且,空间复杂度也得不到优化。
队列
特点:和栈不同,队列的最大特点是先进先出(FIFO),就好像按顺序排队一样。对于队列的数据来说,我们只允许在队尾查看和添加数据,在队头查看和删除数据。
实现:可以借助双链表来实现队列。双链表的头指针允许在队头查看和删除数据,而双链表的尾指针允许我们在队尾查看和添加数据。
应用场景:直观来看,当我们需要按照一定的顺序来处理数据,而该数据的数据量在不断地变化的时候,则需要队列来帮助解题。在算法面试题当中,广度优先搜索(Breadth-First Search)是运用队列最多的地方。
双端队列
特点:双端队列和普通队列最大的不同在于,它允许我们在队列的头尾两端都能在 O(1) 的时间内进行数据的查看、添加和删除。
实现:与队列相似,我们可以利用一个双链表实现双端队列。
应用场景:双端队列最常用的地方就是实现一个长度动态变化的窗口或者连续区间,而动态窗口这种数据结构在很多题目里都有运用。
优先队列( Priority Queue)
特点:能保证每次取出的元素都是队列中优先级别最高的。优先级别可以是自定义的,例如,数据的数值越大,优先级越高;或者数据的数值越小,优先级越高。优先级别甚至可以通过各种复杂的计算得到。
应用场景:从一堆杂乱无章的数据当中按照定的顺序(或者优先级)逐步地筛选出部分乃至全部的数据。
实现:优先队列的本质是一个二叉堆结构。堆在英文里叫 Binary Heap,它是利用一个数组结构来实现的完全二叉树。换句话说,优先队列的本质是一个数组,数组里的每个元素既有可能是其他元素的父节点,也有可能是其他元素的子节点,而且,每个父节点只能有两个子节点,很像一棵二叉树的结构。
牢记三个重要的性质:
- 数组里的第一个元素 arrays[0]拥有最高的优先级别
- 给定一个下标i,那么对于元素array[i]而言:
- 它的父节点所对应的元素下标是(i-1)/2
- 它的左孩子所对应的元素下标是2*i+1
- 它的右孩子所对应的元素下标是2*i+2
- 数组里每个元素的优先级别都要高于它两个孩子的优先级别
优先队列最基本的操作有两个
向上筛选
- 当有新的数据加入到优先队列中,新的数据首先被放置在二叉堆的底部。
- 不断进行向上筛选的操作,即如果发现该数据的优先级别比父节点的优先级别还要高,那么就和父节点的元素相互交换,再接着往上进行比较,直到无法再继续交换为止。
时间复杂度:由于二叉堆是一棵完全二叉树,并假设堆的大小为k,因此整个过程其实就是沿着树的高度往上爬,所以只需要O(log k)的时间。
向下筛选
- 当堆顶的元素被取出时,要更新堆顶的元素来作为下一次按照优先级顺序被取出的对象,需要将堆底部的元素放置到堆顶,然后不断地对它执行向下筛选的操作。
- 将该元素和它的两个孩子节点对比优先级,如果优先级最高的是其中一个孩子,就将该元素和那个孩子进行交换,然后反复进行下去,直到无法继续交换为止。
时间复杂度:整个过程就是沿着树的高度往下爬,所以时间复杂度也是O(log k)
因此,无论是添加新的数据还是取出堆顶的元素,都需要O(log k)的时间。
初始化
优先队列的初始化是一个最重要的时间复杂度,是分析运用优先队列性能时必不可少的,也是经常容易弄错的地方。
误区:每当把—个数据加入到堆里,都要对其执行向上筛选的操作,这样一来就是O(n log n)。
解法:在创建这个堆的过程中,二叉树的大小是从1逐渐增长到n的,所以整个算法的复杂度经过推导,最终的结果是O(n)。
注意:算法面试中是不要求推导的,你只需要记住,初始化大小为n的堆,所需要的时间是O(n)即可。
树
树的结构十分直观,而树的很多概念定义都有一个相同的特点:递归,也就是说,一棵树要满足某种性质,往往要求每个节点都必须满足。例如,在定义一棵二叉搜索树时,每个节点也都必须是一棵二叉搜索树。
正因为树有这样的性质,大部分关于树的面试题都与递归有关,换句话说,面试官希望通过一道关于树的问题来考察对于递归算法掌握的熟练程度。
树的形状
在面试中常考的树的形状有:普通二叉树、平衡二叉树、完全二叉树、二叉搜索树、四叉树(Quadtree)、多叉树(N-ary Tree)。
对于一些特殊的树,例如红黑树(Red-Black Tree)、自平衡二叉搜索树(AVL Tree),一般在面试中不会被问到,除非你所涉及的研究领域跟它们相关或者你十分感兴趣,否则不需要特别着重准备。
关于树的考题,无非就是要考查树的遍历以及序列化(serialization)。
树的遍历
前序遍历(Preorder Traversal)
方法:先访问根节点,然后访问左子树,最后访问右子树。在访问左、右子树的时候,同样,先访问子树的根节点,再访问子树根节点的左子树和右子树,这是一个不断递归的过程。
应用场景:运用最多的场合包括在树里进行搜索以及创建一棵新的树。
中序遍历(Inorder Traversal)
方法:先访问左子树,然后访问根节点,最后访问右子树,在访问左、右子树的时候,同样,先访问子树的左边,再访问子树的根节点,最后再访问子树的右边。
应用场景:最常见的是二叉搜素树,由于二叉搜索树的性质就是左孩子小于根节点,根节点小于右孩子,对二叉搜索树进行中序遍历的时候,被访问到的节点大小是按顺序进行的。
后序遍历(Postorder Traversal)
方法:先访问左子树,然后访问右子树,最后访问根节点。
应用场景:在对某个节点进行分析的时候,需要来自左子树和右子树的信息。收集信息的操作是从树的底部不断地往上进行,好比你在修剪一棵树的叶子,修剪的方法是从外面不断地往根部将叶子一片片地修剪掉。
注意:
掌握好这三种遍历的递归写法和非递归写法是非常重要的,懂得分析各种写法的时间复杂度和空间复杂度同样重要。
无论是前端工程师,还是后端工程师,在准备面试的时候,树这个数据结构都是最应该花时间学习的,既能证明你对递归有很好的认识,又能帮助你学习图论。树的许多性质都是面试的热门考点,尤其是二叉搜索树(BST)(二叉搜索树的性质:对于每个节点来说,该节点的值比左孩子大,比右孩子小,而且一般来说,二叉搜索树里不出现重复的值)
。
图
基本知识点
图可以说是所有数据结构里面知识点最丰富的一个,最基本的知识点如下:
阶、出度、入度
树、森林、环
有向图、无向图、完全有向图、完全无向图
连通图、连通分量
存储和表达方式:邻接矩阵、邻接链表
根据长期的经验总结,以下的知识点是必须充分掌握并反复练习:
基本存储方式
邻接链表(Adjacency List)
无向图 graph 表示
有向图 digraph 表示
邻接矩阵( Adjacency Matrix)
无向图 graph 表示
有向图 digraph 表示
若采用邻接矩阵表示,则需要申请空间大小为 的二维数组,在二位数组中保存每两个顶点之间的连通关系,则无论有向图或无向图,邻接矩阵方式的存储空间复杂度皆为 。若只记录图中顶点是否连通,不记录权值大小,则可以使用一个二进制位来表示二维数组的每个元素,并且根据无向图的特点可知,无向图的邻接矩阵沿对角线对称,所以可以选择记录一半邻接矩阵的形式来节省空间开销。
两种存储结构对比
根据邻接表和邻接矩阵的结构特性可知,当图为稀疏图、顶点较多,即图结构比较大时,更适宜选择邻接表作为存储结构。当图为稠密图、顶点较少时,或者不需要记录图中边的权值时,使用邻接矩阵作为存储结构较为合适。
图的遍历
深度优先搜索(Depth-First Search / DFS)
深度优先搜索,从起点出发,从规定的方向中选择其中一个不断地向前走,直到无法继续为止,然后尝试另外一种方向,直到最后走到终点。就像走迷宫一样,尽量往深处走。
DFS 解决的是连通性的问题,即给定两个点,一个是起始点,一个是终点,判断是不是有一条路径能从起点连接到终点。起点和终点,也可以指的是某种起始状态和最终的状态。问题的要求并不在乎路径是长还是短,只在乎有还是没有。有时候题目也会要求把找到的路径完整的打印出来。
DFS 遍历
对这个图进行深度优先的遍历:
解题思路
必须依赖栈(Stack),特点是后进先出(LIFO)。
第一步,选择一个起始顶点,例如从顶点 A 开始。把 A 压入栈,标记它为访问过(用红色标记),并输出到结果中。
第二步,寻找与 A 相连并且还没有被访问过的顶点,顶点 A 与 B、D、G 相连,而且它们都还没有被访问过,我们按照字母顺序处理,所以将 B 压入栈,标记它为访问过,并输出到结果中。
第三步,现在我们在顶点 B 上,重复上面的操作,由于 B 与 A、E、F 相连,如果按照字母顺序处理的话,A 应该是要被访问的,但是 A 已经被访问了,所以我们访问顶点 E,将 E 压入栈,标记它为访问过,并输出到结果中。
第四步,从 E 开始,E 与 B、G 相连,但是B刚刚被访问过了,所以下一个被访问的将是G,把G压入栈,标记它为访问过,并输出到结果中。
第五步,现在我们在顶点 G 的位置,由于与 G 相连的顶点都被访问过了,类似于我们走到了一个死胡同,必须尝试其他的路口了。所以我们这里要做的就是简单地将 G 从栈里弹出,表示我们从 G 这里已经无法继续走下去了,看看能不能从前一个路口找到出路。
可以看到,每次我们在考虑下一个要被访问的点是什么的时候,如果发现周围的顶点都被访问了,就把当前的顶点弹出。
第六步,现在栈的顶部记录的是顶点 E,我们来看看与 E 相连的顶点中有没有还没被访问到的,发现它们都被访问了,所以把 E 也弹出去。
第七步,当前栈的顶点是 B,看看它周围有没有还没被访问的顶点,有,是顶点 F,于是把 F 压入栈,标记它为访问过,并输出到结果中。
第八步,当前顶点是 F,与 F 相连并且还未被访问到的点是 C 和 D,按照字母顺序来,下一个被访问的点是 C,将 C 压入栈,标记为访问过,输出到结果中。
第九步,当前顶点为 C,与 C 相连并尚未被访问到的顶点是 H,将 H 压入栈,标记为访问过,输出到结果中。
第十步,当前顶点是 H,由于和它相连的点都被访问过了,将它弹出栈。
第十一步,当前顶点是 C,与 C 相连的点都被访问过了,将 C 弹出栈。
第十二步,当前顶点是 F,与 F 相连的并且尚未访问的点是 D,将 D 压入栈,输出到结果中,并标记为访问过。
第十三步,当前顶点是 D,与它相连的点都被访问过了,将它弹出栈。以此类推,顶点 F,B,A 的邻居都被访问过了,将它们依次弹出栈就好了。最后,当栈里已经没有顶点需要处理了,我们的整个遍历结束。
算法分析
DFS 是图论里的算法,分析利用 DFS 解题的复杂度时,应当借用图论的思想。图有两种表示方式:邻接表、邻接矩阵。假设图里有 V 个顶点,E 条边。
时间复杂度:
邻接表
访问所有顶点的时间为 O(V),而查找所有顶点的邻居一共需要 O(E) 的时间,所以总的时间复杂度是 O(V + E)。
邻接矩阵
查找每个顶点的邻居需要 O(V) 的时间,所以查找整个矩阵的时候需要 O(V²) 的时间。
空间复杂度:
DFS 需要堆栈来辅助,在最坏情况下,得把所有顶点都压入堆栈里,所以它的空间复杂度是 O(V)。
广度优先搜索(Breadth-First Search / BFS)
广度优先搜索,一般用来解决最短路径的问题。和深度优先搜索不同,广度优先的搜索是从起始点出发,一层一层地进行,每层当中的点距离起始点的步数都是相同的,当找到了目的地之后就可以立即结束。
广度优先的搜索可以同时从起始点和终点开始进行,称之为双端 BFS。这种算法往往可以大大地提高搜索的效率。
BFS 遍历
对这个图进行广度优先的遍历:
解题思路
依赖队列(Queue),先进先出(FIFO)。一层一层地把与某个点相连的点放入队列中,处理节点的时候正好按照它们进入队列的顺序进行。
第一步,选择一个起始顶点,让我们从顶点 A 开始。把 A 压入队列,标记它为访问过(用红色标记)。
第二步,从队列的头取出顶点 A,打印输出到结果中,同时将与它相连的尚未被访问过的点按照字母大小顺序压入队列,同时把它们都标记为访问过,防止它们被重复地添加到队列中。
第三步,从队列的头取出顶点 B,打印输出它,同时将与它相连的尚未被访问过的点(也就是 E 和 F)压入队列,同时把它们都标记为访问过。
第四步,继续从队列的头取出顶点 D,打印输出它,此时我们发现,与 D 相连的顶点 A 和 F 都被标记访问过了,所以就不要把它们压入队列里。
第五步,接下来,队列的头是顶点 G,打印输出它,同样的,G 周围的点都被标记访问过了。我们不做任何处理。
第六步,队列的头是 E,打印输出它,它周围的点也都被标记为访问过了,我们不做任何处理。
第七步,接下来轮到顶点 F,打印输出它,将 C 压入队列,并标记 C 为访问过。
第八步,将 C 从队列中移出,打印输出它,与它相连的 H 还没被访问到,将 H 压入队列,将它标记为访问过。
第九步,队列里只剩下 H 了,将它移出,打印输出它,发现它的邻居都被访问过了,不做任何事情。
第十步,队列为空,表示所有的点都被处理完毕了,程序结束。
算法分析
同样借助图论的分析方法,假设有 V 个顶点,E 条边。
时间复杂度:
邻接表
每个顶点都需要被访问一次,时间复杂度是 O(V);相连的顶点(也就是每条边)也都要被访问一次,加起来就是 O(E)。因此整体时间复杂度就是 O(V+E)。
邻接矩阵
V 个顶点,每次都要检查每个顶点与其他顶点是否有联系,因此时间复杂度是 O(V²)。
空间复杂度:
需要借助一个队列,所有顶点都要进入队列一次,从队列弹出一次。在最坏的情况下,空间复杂度是 O(V)。