数据结构常见面试问题
一、时间复杂度与空间复杂度
时间复杂度是用于算法执行效率的度量,而空间复杂度是用于算法所需存储空间的度量。时间复杂度好理解但是不好说,就是对算法执行所需时间开销的一种度量,而如何度量算法执行时间呢?其实最简单的方法就是跑一遍代码看运行时间,但是这种方法受到硬件、编译程序等影响,因此就引入了问题规模n,也就是说时间复杂度是关于问题规模n的函数,也可以理解为算法的执行时间与问题规模n直接相关,因此我们通过n来计算代码执行次数并取得最高数量级从而得到时间复杂度。但是值得注意的是并不是真实代表算法的执行时间的,它是用来表示代码执行时间的增长变化趋势的。
时间复杂度就是度量算法执行大概需要多大的时间开销,其实最简单的方法就是跑一遍代码看运行时间,但是这种方法受到硬件、编译程序等影响,所以呢就想能否从算法中选出一些语句中的原操作,就比如说加减乘除、数组的读取等,他们的执行次数与算法的执行时间成正比,这样我们知道他们的执行次数就大致知道了运行时间,其实呢,执行次数是关于问题规模n的函数,所以计算出执行次数并算出其关于n的阶就能准确知道程序执行时间关于n的变化趋势,也就是时间复杂度。
空间复杂度:度量算法执行时候大致需要多大存储空间,如果输入数据占的空间是由特定问题决定的与算法是没有关系的,那只分析除了//输入数据还有程序所占空间外//额外临时存储空间即可,就比如说BFS算法中需要队列这一额外存储空间。
1.1 提高效率的算法:
以时间换空间: 常见的内存替换算法(OS内容需要好好复习 这个还没复习)
以空间换时间: 从栈中找到最小的元素直接方法需要遍历整个栈太麻烦,因此引入一个辅助栈 更快速找到min值 这就是以一部分辅助空间为代价 换取时间。题解请看 求栈min。其次散列查找也是。
索引!
1.2 递归、迭代、循环、遍历
- 循环(loop),指的是在满足条件的情况下,重复执行同一段代码。比如,while语句。
- 迭代(iterate),指的是按照某种顺序逐个访问列表中的每一项。比如,for语句。
- 遍历(traversal),指的是按照一定的规则访问树形结构中的每个节点,而且每个节点都只访问一次。
- 递归(recursion),指的是一个函数不断调用自身的行为。比如,以编程方式输出著名的斐波纳契数列。
递归: 递归就是实现函数自身调用,或者是相互调用的过程,所以他的代码可以非常简单,其实就是利用系统堆栈找到上一层信息,从而把大规模问题拆分成小规模问题,分而治之。其优点:代码简单。其缺点:可能会多一些重复计算、或者递归深度太深使得栈溢出。Stack leak
循环:指的是在满足条件的情况下,重复执行同一段代码。其缺点就是不容易理解,编写复杂问题时困难。(比方说遍历整个树用循环就很难做,但是递归就很容易理解)优点是效率高。运行时间只因循环次数增加而增加,没什么额外开销。空间上没有什么增加。
递归和循环谁效率高还要具体问题具体看,但是我们要明白两个方法的时间开销一个来自于递归的重复计算一个是循环的次数过多。
详解递归
1.3 九大数据结构
- 数组
- 链表
- 跳表
- 栈
- 队列
- 树
- 图
- 散列
- 堆(不太理解)
了解完二叉树,再来理解堆就不是什么难事了。堆通常是一个可以被看做一棵树的数组对象,即顺序存储的完全二叉树;堆的具体实现一般不通过指针域,而是通过构建一个一维数组与二叉树的父子结点进行对应,因此堆总是一颗完全二叉树。用数组存储二叉树的节点,对于空节点也需要占用空间,所以堆总是对应一颗完全二叉树。
对于任意一个父节点的序号n来说(这里n从0算),它的子节点的序号一定是2n+1,2n+2,因此可以直接用数组来表示一个堆。
不仅如此,堆还有一个性质:堆中某个节点的值总是不大于或不小于其父节点的值。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆
常见数据结构
二、顺序表与链表的区别?
顺序表是线性表的顺序存储,也就是说用连续的一段内存区域来存储一个一个的数据,所以逻辑上相邻的数据元素在实际存储的物理位置也是相邻的。所以呢我们只要知道首地址和每个数据元素占多大的地址空间就能够算出各个数据元素的具体地址,就可以进行随机访问指定数据元素。但是正因为各个元素在存储时是相邻的,所以删除一个元素那么有后续元素的话都要往前移动,插入也是同样的道理。所以移动大量元素造成效率下降。按值查找也可结合相关折半查找等方法时间复杂度0(N)。
链表是线性表的链式存储,也就是说用一块块离散的存储单元来存储数据元素,无需大块连续存储空间,并且利用指针来指向下一个数据元素的地址,从而实现各个数据元素之间的线性关系,从宏观上来看就是通过一个个指针把各个离散的数据元素链接在一块所以也形象的称为链表。正因为引入了指针所以只需要修改指针的指向就能实现插入删除等操作,而无需像顺序表那样移动大量元素,但是其缺点也体现在存储指针也需要一定存储开销,查找元素也必须从表头开始遍历。
面试答题的时候可以结合王道4中分类来有条理的答题。
先解释何为链表与顺序表,所以呢,第一个区别就是存储方式,顺序表逻辑上相邻物理位置也相邻,而链式存储则不是;第二个区别就是存取方式,正因为顺序表存储的特点,我们可以很容易算出各个元素的具体地址,就可以进行随机访问指定数据元素。而链式存储只能一个一个读。第三个区别呢就是增删改查的难度,顺序表查找是优势,而修改需要移动大量元素。而链表对指定位置的修改很容易,修改指针即可。
综上所述呢,两种线性表各有优势,结合他们的优势有不同的使适用领域。
经常需要查找操作时最好使用顺序存储的方式,如果经常需要插入、删除等操作最好使用链式存储。如果两者都需要的话,可以将两者结合起来,各取优势。就比如散列存储+拉链法。那么HashMap就是结合了两者的特点,既有数组查找效率快的优势,又有链表插入和删除元素速度快的特点,并且有动态扩展机制。
三 、哈希表(线性结构)
哈希表也是一种线性结构,与数组一样,但是数组是通过下标来进行随机存取,而哈希表是利用数据的关键字通过哈希函数计算出该数据所存放的地址,因此哈希表对数据的读取也比较方便。
可以想象,要处理的数据是相当多的,一个索引地址不可能只对应一个数据,否则那将需要极大的连续存储空间 会产生大量的内存碎片(这就跟OS联系在一起了 这点要复习)那这时候就会有矛盾产生。所以我们有必要构造哈希函数来使一组关键字的哈希地址尽量均匀分布在整个地址区间中,从而减少冲突。
减少冲突的方法最常见的就是拉链法(HashMap就是这种方法 这点也需要复习),Linux内核中的Hash表处理冲突也是这样的方法。不过他用的是双链表,存储多余节点。
四、栈和队列
栈和队列在逻辑上也是一种线性结构,但和一般线性表的区别就是操作受限。
栈:栈就像一个盒子,先放入盒子里的数据,往往后取出来,而且只能在盒子顶部放东西和取东西。这就是先入后出 以及只能在栈顶操作。
栈的实现: 顺序存储实现(用数组)、链式存储用链表都可以。 详细实现可看代码。带头结点的单链表就能实现,只需要在链头进行插入或删除操作即可。数组实现,出栈先判断栈是不是空,空栈时栈顶为-1,不空则出栈数组下标减一。进栈,判断栈顶是否到达最大值,若没有则数组下标加一入栈。
栈的应用: 递归工作栈:递归的过程中用栈来存每一层函数调用的信息,就比方说执行到哪一句了,参数数据等等。
队列: 队列从字面意思出发就是相当于排队,先排的人先出队,但只能从队尾排队,从队头出队。这就是先入先出,以及队尾入队,队头出队的操作。
队列的实现: 顺序存储实现(用数组、循环数组都可以)、链式存储用链表都可以。 详细实现可看代码。链尾插入元素,链头删除元素,带尾指针的循环单链表也很好实现。队头等于队尾则为空。数组实现,队头等于队尾为空,队尾+1%maxsize==队头则队满;入队:队尾下标位置放入元素,队尾+1取模;出队:判断队空,取出队头下标元素,队头+1取模。
队列的应用: BFS中作为临时存储空间 存贮各个节点。 在资源调度算法中,如先来先服务的调度算法,需要用到队列。
一、介绍一下深度优先搜索和广度优先搜索是如何实现的?
广度优先遍历算法 p215
图的遍历比树遍历复杂,因为图中可能存在环路,所以遍历的过程中要标记已经访问过的节点。
BFS:BFS的思想其实跟树的层序遍历类似,因为树也是图的一种,他是从起始节点出发,依次访问直接与起始节点相连的其他节点,并将这些节点入队;然后,依次出队,再依次访问出队节点所没被访问过的邻接节点,并且入队,重复这个过程直到所有点都被访问过。当然要遍历完图往往也需要多次BFS。总结来说呢,其本质就是分层找点,所以需要队列这一辅助空间,记录每一层顶点。
( 每次BFS相当于遍历图的一个连通分量,如果一次bfs后还有节点没被访问,那么代表必定多个子图之间是没连在一起的,故需要多次BFS来遍历。 )
BFS性能分析: 无论是邻接链表还是邻接矩阵存储树,BFS都需要辅助队列,n个顶点都要入队,最坏是0(V); 邻接表存储:访问所有点与边复杂度就是0(V+E);邻接矩阵:0(V2);
BFS解决单源最短路径: 对BFS的过程做一个小修改即可,比方说从起始节点1开始,每访问一个节点的邻接节点时,将其距离也就是起始节点到他父亲节点的距离加一即可。相当于走一步距离加一。
BFS解决树的深度: BFS本质就是一层一层找节点,那么我们在进行BFS遍历的同时,每遍历完一层就+1,这样不就算出来树的深度了吗。注意队列元素个数是当前每一层的所有节点个数。
深度优先遍历算法 p217
DFS: 同理他的思想跟树的先序遍历类似,就是从起始节点开始一直尽可能深得往下遍历节点。从起始节点出发,访问他邻接的节点w2,再访问w2邻接的节点w3,按这个过程重复访问,直到某个节点w5没邻接的节点了,那么回退w4再重复上面的过程。总结来说,就是尽可能深的访问各个节点,各个节点操作是一样的,看有没有邻接点,有就访问,没有就跳到上一个点。所以呢,往往借助递归工作栈来实现回跳。
DFS算法效率分析: 递归工作栈空间大小应该是图的最大深度。O(V) 0(V+E) 0(V2)
二、最小生成树 prim与kruscal算法
三、最短路径问题
1.BFS求最短路径
其不适于求解带权图的最短路径问题。
2.Dijkstra 算法求单源最短路径问题
第一轮:
1.循环遍历所有节点,找到还没确定最短路径且其dist最小的顶点vi,令其final[i]=true;
时间复杂度:0(N) 来自访问数组的时间。
2.检查所有邻接自vi的顶点,若其final值为false则更新dist和path信息。
时间复杂度:
若以邻接矩阵存储则访问邻接的各个顶点,复杂度为0(n),若为邻接矩阵则复杂度较小。
总和的时间复杂度为:0(2n),除去第一个节点,一共需要进行n-1伦,故总时间复杂度为0(n^2);
与prim算法有类似的流程,都是遍历dist找到最小的节点,然后prim算法计算新加入节点到各个节点距离,与原来dist相比取最小更新dist。Dijkstal算法是计算每个点到原点的距离取最小加入dist。只不过算的距离不一样,但是本质很类似,都是贪心的策略。
3.Floyd算法求各顶点之间最短路径问题
4. 拓扑排序
AOV DAG
5. 关键路径
AOE
五、查找
5.1 顺序查找
基本思想:顺序查找也称为线形查找,属于无序查找算法。从数据结构线形表的一端开始,顺序扫描,依次将扫描到的结点关键字与给定值k相比较,若相等则表示查找成功;若扫描结束仍没有找到关键字等于k的结点,表示查找失败。
说明:顺序查找适合于存储结构为顺序存储或链接存储的线性表。
复杂度分析:
查找成功时的平均查找长度为:(假设每个数据元素的概率相等) ASL = 1/n(1+2+3+…+n) = (n+1)/2 ;当查找不成功时,需要n+1次比较,时间复杂度为O(n);所以,顺序查找的时间复杂度为O(n)。
5.2 有序表的顺序查找
基本思想:在查找之前就已知关键字是有序的,假如要找key值,那么遍历过程中若第i个元素小于Key,但第i+1个元素大于Key,则表示查找失败。
说明:顺序查找适合于存储结构为顺序存储或链接存储的线性表。
复杂度分析:ASL=1/n+1(1+2+3+…+n+n)=n/2+。。。;
比一般的顺序查找算法好一点;
5.3 折半查找
说明:元素必须是有序的,如果是无序的则要先进行排序操作。折半查找适用于存储结构为顺序存储的线性表,因为他要定位二分的位置并与之比较,从而缩小查找区域。所以需要随机存取的特点。
基本思想:也称为是折半查找,属于有序查找算法。用给定值k先与中间结点的关键字比较,中间结点把线形表分成两个子表,若相等则查找成功;若不相等,再根据k与该中间结点关键字的比较结果确定下一步查找哪个子表,这样递归进行,直到查找到或查找结束发现表中没有这样的结点。
复杂度分析:最坏情况下,关键词比较次数为log2(n+1),且期望时间复杂度为O(log2n);
注:折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,折半查找能得到不错的效率。但对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,那就不建议使用。——《大话数据结构》
5.5 插值查找
基本思想: 二分查找mid值一直选定的是中间位置,可以说是傻瓜式找,所以我们也可以引入一个自适应的mid选取的算法,根据关键字在整个有序表中所处的位置,让mid值的变化更靠近关键字key,这样也就间接地减少了比较次数,算法整体思想与二分查找一致,只不过mid值选取不同。
折半查找这种查找方式,不是自适应的(也就是说是傻瓜式的)。二分查找中查找点计算如下:
mid=(low+high)/2, 即mid=low+1/2*(high-low);
通过类比,我们可以将查找的点改进为如下:
mid=low+(key-a[low])/(a[high]-a[low])*(high-low),
注:对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好的多。反之,数组中如果分布非常不均匀,那么插值查找未必是很合适的选择。
复杂度分析:查找成功或者失败的时间复杂度均为O(log2(log2n))。
5.5 分块查找
分块查找又称索引顺序查找,它是顺序查找的一种改进方法。
算法思想:将n个数据元素"按块有序"划分为m块(m ≤ n)。每一块中的结点不必有序,但块与块之间必须"按块有序";即第1块中任一元素的关键字都必须小于第2块中任一元素的关键字;而第2块中任一元素又都必须小于第3块中的任一元素,……
算法流程:
step1 先选取各块中的最大关键字构成一个索引表;
step2 查找分两个部分:先对索引表进行二分查找或顺序查找,以确定待查记录在哪一块中;然后,在已确定的块中用顺序法进行查找。
算法复杂度分析: 其平均查找长度是索引查找和块内查找的平均长度之和。索引查找使用的二分法或者顺序查找,块内用的是顺序找,如此一来就好算了。
5.6 树表查找算法-二叉树查找算法
基本思想:二叉查找树是先对待查找的数据进行生成树,但要确保树是一棵二叉查找树,其左子树节点的值小于根节点的值,根节点值小于右分支的节点值,查找的时候就与父节点比较大小,根据大小情况来选择合适的分支,查找最适合的范围。 这个算法的查找效率很高,但是如果使用这种查找方法要首先创建树。
二叉查找树(BinarySearch Tree,也叫二叉搜索树,或称二叉排序树Binary Sort Tree)或者是一棵空树,或者是具有下列性质的二叉树:
1)若任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
2)若任意节点的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
3)任意节点的左、右子树也分别为二叉查找树。
二叉查找树性质:对二叉查找树进行中序遍历,即可得到有序的数列。
复杂度分析:它和二分查找一样,插入和查找的时间复杂度均为O(log2n),但是在最坏的情况下仍然会有O(n)的时间复杂度。原因在于插入和删除元素的时候,树没有保持平衡(就比方有序的一组元素所形成的二叉搜索树)。我们追求的是在最坏的情况下仍然有较好的时间复杂度,这就是平衡查找树设计的初衷。
5.7 B树和B+树(难点)
B树的删除与查找:
散列表:
开放定址法:一旦发生冲突,就去寻找下一个空的散列地址,也就是说每个地址都是开放的都可以存入其他关键字 只要散列表足够大,空的散列地址总能找到,并将记录存入。
①线性探测再散列:f(key)=(f(key)+di) mod m(di=1,2,3…m-1)。当发生冲突时,向下逐个查找,直到找到一个空地址。缺点:关键字堆积严重,效率较低。关键字就从发生冲突的位置开始顺序找空位置,这导致关键字堆积严重效率较低。
②二次探测再散列:f(key)=(f(key)+di) mod m(di=1²,-1²,2²,-2²,…,q²,-q²,q<=m/2)。发生冲突时,向两侧双向查找,可避免关键字聚集在某一块区域。
③随机探测再散列:f(key)=(f(key)+di) mod m(di是一个随机数列)。di是伪随机数,通过随即种子产生。
https://www.cnblogs.com/hanganglin/articles/3737506.html
Java中的HashMap:使用拉链法防止冲突
https://www.cnblogs.com/chengxiao/p/6059914.html
六、排序
6.1 插入排序
基本思想:每次将一个待排记录插入到已排序好的有序表中,从而得到一个新的记录数增1的有序表,即:先将序列的第1个记录看成是一个有序的子序列,然后从第2个记录逐个进行插入,直至整个序列有序为止。
算法复杂度:最好的情况下表中元素有序,每插入一个元素,只需比对一次,一共n-1个元素,所以时间复杂度为0(N);最坏的情况表中元素逆序,比较次数为Σ i,移动次数为Σ i+1,所以时间复杂度达到0(N^2);
实现操作:
- 从前面有序子表中找出待插元素可以插入的位置;
- 移动其他元素为其腾出位置,将其插入。
6.2 折半插入排序
基本思想: 在直接插入排序的基础上,将比较元素和移动元素操作分开,先利用折半查找找出待插入元素的位置,然后再统一移动插入位置后面的元素。
算法复杂度分析: 折半查找仅减少了比对的次数,每运行依次折半查找时间复杂度为0(log2n),需要运行n-1次,所以查找所需时间复杂度为O(nlog2n);但是移动次数与直接插入排序一致,依旧是0(n^2);所以总体上来说还是 O(N2);
6.3 希尔排序(shell s sort)
基本思想: 直接插入排序在待排序列为有序的情况下,时间复杂度降到O(N),所以希尔排序采用了这种思想,先对子表进行插入排序,当整个表基本有序的时候,再整体进行一次插排序。
具体实现就是:先将要排序的一组记录按某个增量d 分成若干组子序列,每组中记录的下标相差d,对每组中全部元素进行直接插入排序,然后再用一个较小的增量(d/2)对它进行分组,在每组中再进行直接插入排序。继续不断缩小增量直至为1,最后整个表已经基本有序了再使用直接插入排序完成排序。
算法复杂度分析:与d有关,但具体也不知道。
说明: 只能用顺序表,因为要根据d步长找对应元素。
6.4 选择排序
基本思想: 在要排序的一组数中,选出最小(或者最大)的一个数与第1个位置的数交换;然后在剩下的数当中再找最小(或者最大)的与第2个位置的数交换,依次类推,直到第n-1个元素(倒数第二个数)和第n个元素(最后一个数)比较为止。
//直接选择排序
void StraightSelectionSort(int a[], int n)
{
int i, j, minIndex;
for(i=0; i<n; i++)
{
minIndex=i;
for(j=i+1; j<n; j++)
if(a[j]<a[minIndex])
minIndex=j;
Swap(a[i], a[minIndex]);
}
PrintDataArray(a, n);
}
O(n^2)不用多说了
6.5 堆排序 (也是一种选择排序)
基本思想: 初始时把要排序的n个数的序列看作是一棵顺序存储的二叉树(一维数组存储二叉树),调整它们的存储序,使之成为一个堆,将堆顶元素输出,得到n 个元素中最小(或最大)的元素,这时堆的根节点的数最小(或者最大)。然后对剩下的(n-1)个元素重新调整使之成为堆,输出堆顶元素,得到n 个元素中次小(或次大)的元素。依此类推,直到只有两个节点的堆,并对它们作交换,最后得到有n个节点的有序序列。称这个过程为堆排序。
算法复杂度分析:
一个节点最多log2n层,每层比对2次,所以是O(log2n),N个元素就是Nlog2n
时间复杂度O(Nlog2N) 空间复杂度:O(1);
在这里多说几句堆排序的强大之处,堆排序可以看成是一种算法,也可以看成是一种数据结构。它可以分为小顶堆和大顶堆。
2*(h-1)+2^12(h-2)+ 2的2次方*2(h-3) <4N
所以建立堆的时候就是O(N)复杂度。
另外因为每次都是把堆顶元素与堆底元素交换,所以每次对堆顶元素操作时候,需要2log2n次比较,需要n次就是nlog2n。
6.6 冒泡排序
基本思想:在要排序的一组数中,对当前还未排好序的范围内的全部数,自左到右对相邻的两个数依次进行比较和调整,让较大的数往下沉,较小的往上冒。即:每当两相邻的数比较后发现它们的排序与排序要求相反时,就将它们互换。每一趟排序后的效果都是将没有沉下去的元素给沉下去。
//冒泡排序
void BubbleSort(int a[], int n)
{
int i, j;
for(i=0; i<n; i++)
//j的起始位置为1,终止位置为n-i
for(j=1; j<n-i; j++)
if(a[j]<a[j-1])
Swap(a[j-1], a[j]);
PrintDataArray(a, n);
}
算法复杂度: O(n^2)没啥好说的
6.7 快速排序
基本思想:快速排序算法的基本思想为分治思想。跟算二叉树的深度思想类似(找左右子树深度比较大小即可)
1)先从数列中取出一个数作为基准数;
2)根据基准数将数列进行分区,小于基准数的放左边,大于基准数的放右边;
3)再对已经分好的区域,重复分区操作,直到各区间只有一个数为止。
代码实现:
算法复杂度分析:
每一层快排只需要处理剩余的待排序元素,时间复杂度不超过O(N);所以总的时间复杂度为递归的层数* n;
如何分析递归的层数:每一次快排之后都将剩下元素分为两部分,所以逐层划分之后就相当于一棵二叉树,树的深度就是递归调用的层数。
时间复杂度:O(N*递归深度); 空间复杂度:O(递归深度);
所以时间复杂度:最好O(Nlog2N) 最坏O(N2) ; 空间复杂度 O(N) 最好O(log2N)
6.8 归并排序
基本思想: 排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
算法复杂度分析:
十大排序图解:
https://blog.csdn.net/admans/article/details/122151753