数据结构的基本概念及相关问题汇总
序言
本文是个人复习数据结构与算法总结,持续更新中,谢谢。
参考的是极客时间王铮的数据结构与算法专栏。
复杂度分析
什么是大O复杂度表示法?
\(T(n) = O(f(n))\)
T(n)表示代码执行的时间;n 表示数据规模的大小;f(n) 表示每行代码执行的次数总和。因为这是一个公式,所以用 f(n) 来表示。公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。
-
顺序执行:\(O(1)\)
-
循环:\(O(n)\)
-
双重循环:\(O(n^2)\)
-
循环中对循环次数条件进行乘法计算的:\(O(logn)\)
i=1; while (i <= n) { i = i * 2; } -
双重循环中,子循环对循环次数条件进行乘法计算的:\(O(nlogn)\)
归并排序、快速排序
什么是最好、最坏、平均、均摊时间复杂度?
在循环体内有判断条件,某些情况下执行,某些情况下不执行。
最好时间复杂度:执行次数最少的条件下的时间复杂度
最坏时间复杂度:执行次数最多的条件下的时间复杂度。
平均时间复杂度:将执行条件的每一种情况需要遍历的次数乘上这种情况下发生的概率,也叫加权平均时间复杂度。
均摊时间复杂度:使用摊还分析方法,将某一次的执行情况平摊到每一次的执行中,总体下来,得到算法的执行次数。比如某一特定情况下,执行情况是\(O(n)\),其他情况都是\(O(1)\),那么平摊下来,算法时间复杂度就是\(O(1)\)
数组
如何优化数组的插入和删除操作?
一般情况下,插入和删除会导致后续节点的移动,导致复杂度为\(O(n)\),如何将其优化为\(O(1)\)
-
插入操作
如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。
如果要将某个数据插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。

-
删除操作
数组 a[10]中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

什么时候使用数组,什么时候使用容器(ArrayList)?
- Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
- 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组.
- 当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:
ArrayList <ArrayList<object>> array。
对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
为什么大多数编程语言数组从0开始编号,而不是1?
程序访问数组中的元素,使用寻址访问,指针或引用指向的是数组中的首地址,通过计算寻址公式来得到第k个元素的地址。
如果从0编号:
a[k]_address = base_address + k * type_size
如果从1编号:
a[k]_address = base_address + (k-1) * type_size
- 从1编号,每次访问需要计算k-1,效率不如从0编号好
- C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,习惯如此
链表、队列和栈
如何实现LRU缓存淘汰算法?
缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)
维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
-
如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
-
如果此数据没有在缓存链表中,又可以分为两种情况:
- 如果此时缓存未满,则将此结点直接插入到链表的头部;
- 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
如何实现浏览器的前进和后退功能?
我们使用两个栈,X和Y,我们把首次浏览的页面依次压入栈X,当点击后退时,再依次从栈X中出栈,并将出栈的数据依次放入栈Y中。当我们点击前进按钮时,我们依次从栈Y中取出数据,放入栈X中。如果点击其它页面时,将栈Y清空。
如何实现表达式求值?
比如\(3+5*8-6\)
使用两个栈来实现,一个放数字,一个放运算符
我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较,如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶依次取两个操作数,第一个在运算符后,第二个在运算符前,然后进行计算,再把计算结果压入操作数栈,继续比较。

如何实现括号匹配?
假设表达式中只包含三种括号,圆括号 ()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢
用栈来保存未匹配的左括号,从左到右依次扫描字符串,如果遇到左括号,入栈,如果遇到有括号,从栈顶取出一个元素进行比较,如果能够匹配,继续扫描,直到结束。最后检查栈是否为空,如果为空,则全部匹配,否则异常。
为什么函数调用要使用“栈”来保存临时变量呢,用其它数据结构不可以吗?
函数调用中,变量的作用域很重要,先声明的作用域更大,后声明的更小,函数的调用结束,伴随着出栈操作,变量的使用就结束了。
函数中,调用函数的关系满足先进后出,后进先出原则,所以使用栈很合适。
循环队列的判断为满的条件是什么?
(tail + 1) % n == head

public class CircularQueue {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head表示队头下标,tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 队列满了
if ((tail + 1) % n == head) return false;
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
什么是阻塞队列,什么是并发队列?
-
阻塞队列:在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。
我们可以使用阻塞队列,轻松实现一个“生产者 - 消费者模型”!!!
-
并发队列:线程安全的队列叫做并发队列。最简单直接的实现方式是直接在 enqueue()、dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。
实际上,基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。
什么场景下会使用队列?
-
线程池的池结构使用队列来排队请求。
-
数据库连接池,使用队列连接数据库操作。
-
消息队列使用队列来处理消息的发送和消费。
如何实现无锁的并发队列?
使用CAS实现无锁队列,在入队前,获取tail位置,入队时比较tail是否发生变化,如果否,则允许入队,反之,本次入队失败。出队则是获取head位置,进行cas。
递归
使用递归算法的条件是什么?
- 一个A问题可以被分解为不确定的子问题B、C、D等
- 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
- 存在递归终止条件
使用递归算法可能会产生什么问题?
- 堆栈溢出
函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。
-
重复值计算
在处理\(f(n) = f(n-1)+f(n-2)\) 的递归情况时,会出现以下情况

从图中,我们可以直观地看到,想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3),因此,f(3) 就被计算了很多次,这就是重复计算问题。为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。
排序
我们用下面三个指标来衡量排序算法
-
执行效率
- 最好情况、最坏情况、平均时间复杂度
- 时间复杂度的系数、常数、低阶
- 比较次数和交换(或移动)次数
-
内存消耗
一般指空间复杂度,如果空间复杂度为\(O(1)\) ,则是原地排序算法
-
稳定性
-
稳定的排序算法
如果待排序的的序列存在值相等的元素,经过排序后,相等元素之间的先后顺序不变。
-
不稳定的排序算法
如上,先后顺序发生变化。
-
冒泡排序(Bubble sort)
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让他两互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。
经过一次冒泡的过程分解如下:

冒泡排序是原地排序算法,稳定的排序算法
最好时间复杂度是\(O(n)\)
最坏时间复杂度是\(O(n^2)\)
平均时间复杂度是\(O(n^2)\)
插入排序(Insertion sort)
将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一致有序。重复这个过程,直到未排序区间中元素为空。

插入排序是原地排序算法,稳定的排序算法
最好时间复杂度是\(O(n)\)
最坏时间复杂度是\(O(n^2)\)
平均时间复杂度是\(O(n^2)\)
选择排序(Selection sort)
选择排序算法的实现思路有点类似插入排序,也分为已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其翻到已排序区间的末尾。

选择排序是原地排排序算法,不稳定的的排序算法(每次都要找剩余未排序中最小值,并和前面的元素交换位置,如果有两个相同的值的元素,一样会被交换,破坏了稳定性)
最好时间复杂度为\(O(n^2)\)
最坏时间复杂度是\(O(n^2)\)
平均时间复杂度是\(O(n^2)\)
小结,以上是平均时间复杂度为\(O(n^2)\) 的三种排序算法,使用最广泛的是插入排序。
选择排序很简单,最好时间复杂度都是\(O(n^2)\) ,处理顺序比较顺的数据性能比较差。
插入排序和冒泡排序两者从时间复杂度,空间复杂度,稳定性看都是一样的。为什么插入排序更有优势呢。
冒泡排序中数据的交换操作:
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true;
}
插入排序中数据的移动操作:
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
可见,在总共同样的次数的移动操作,冒泡排序有三次赋值,插入排序只有一次。
所以插入排序更快。
这三种排序算法,对于小规模数据的排序,用起来非常高效,但是在大规模的数据排序的时候,时间复杂度还是有些高,相对于时间复杂度为\(O(nlogn)\) 的快速排序和归并排序还是有一些劣势。
归并排序(Merge sort)
先把数组中从中间分成前后两部分,然后对前后两部分分别排序,再讲排好序的两部分合并在一起,这样整个数组就都有序了。

归并排序是稳定排序,不是原地排序(这个原因会导致内存占用比其它排序更大)空间复杂度为\(O(n)\)
最好时间复杂度为\(O(nlogn)\)
最坏时间复杂度为\(O(nlogn)\)
平均时间复杂度为\(O(nlogn)\)
快速排序(QuickSort)
快速排序和归并排序一样利用分治思想。首先选择p到r之间任意一个数据为pivot(分区点),从p至r遍历,小于分区点的在一遍,大于分区点的在另一边。然后在新产生的两个分区递归执行以上操作,直至区间缩小为1。

下面说下分区点,如何选择一个分区点,如果分区点选的不好,极端情况下会使算法的时间复杂度降低到\(O(n^2)\) ,听起来很可怕,但是实际上取最后一个下标的值为分区点,从概率上说,不会一直导致两边一边没有,一边全是。
另外代码中,如何获取这个分区点,并分区,是个技术活。
简单的通过遍历生成两个数组,一边大于分区点,一边小于分区点,然后在把这些数组赋值过来。但是这样空间复杂度就和归并排序一样\(O(n)\),不是原地排序算法了
下图是分区函数的图解,这样互相交换,就是原地排序算法了。

快速排序是原地排序算法,不稳定的排序算法(分区函数决定的)。
最好时间复杂度,\(O(nlogn)\)
平均时间复杂度,\(O(nlogn)\)
最坏时间复杂度,\(O(n^2)\)
如何得到第K大的元素,\(O(n)\) 时间复杂度
可以利用快速排序的分区方法,以末尾的值e作为分区点的值,然后进行两边分类,左边小,右边大。这么一次就得到比e小的有多少个,比e大的有多少个,也就是得到e是第多少大的元素,如果e小于k,在右边的元素中重复上述步骤,反之在左边的元素中重复上述步骤,直到e==k。
桶排序(BucketSort)
对于大数据量,并且数据可以按照范围比较均匀的分布在所划定的范围内,可以使用桶排序。
先遍历数据,获取到最小和最大的值,然后分成m个桶,第二次遍历将其中的数据放到对应范围的桶中,对于某一桶中,如果数据量过大,可以颗粒再细点,继续分桶。然后对于每个桶,使用快速排序,最后从小到大依次将所有桶中的数据输出到一个大文件中,得到排好序的文件。
桶排序的时间复杂度为,\(O(n)\)
计数排序(Counting sort)
对于考生分数排序这样的问题,如果是数据量特别大,则比较适用于计数排序。
首先将考生的分数0-x 生成一个记人数的数组,c[x],下标表示的是分数,对应的值是这个分数对应的人数。遍历一遍所有人的分数就可以得到。然后从后往前遍历c[x],得到求和后的c[x],求和的方式是c[x] = c[x] +c[x-1]+c[x-2]+...+ c[0]。新生成一个数组R[],大小是所有人数。从后往前遍历所有人的分数,得到对应c[x]下标对应的值a,那么数组R[a-1] = x,然后c[x]-1。遍历完就得到排好序的数组R。
步骤如下

由上述步骤可以看出,计数排序只涉及到对人数的遍历,以及分数数组的遍历求和。所以时间复杂度为\(O(n)\)
计数排序是稳定的,非原地排序。
时间复杂度为\(O(n)\)
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数
基数排序(Radix sort)
对应手机号排序,或者英文字典的排序,适用于基数排序。先把待排序的元素按位来分割,一个对其使用稳定的排序算法,所有的位都排序一遍,那么整个数据就变成有序的了。

基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
如何实现一个通用高效的排序函数

C 语言的 qsort()
如何数据量较小(1kb-10kb),使用归并排序,当数据量比较大,使用快速排序,当快速排序递归的过程数据量小于等于4个,使用插入排序,停止递归。

浙公网安备 33010602011771号