排序算法在面试中是面试官考察候选人的基础知识点,重要性不言而喻,所以,今天就把常见的排序算法都好好温习一遍吧!
排序算法的过程会以动图形式展现出来,并且给出伪代码和两种语言(Java
、Python
)的实现代码,重点在:快速排序、堆排序、归并排序
排序算法总览图
排序算法分类
排序算法可以分为两大类:比较类排序,非比较类排序
比较类排序:比较元素之间的相对次序进行排序,算法的时间复杂度最优是O(NlogN),因此也称为非线性时间比较类排序。
非比较类排序:不通过比较来决定元素的相对次序,最优时间复杂度是O(N),因此也称为线性时间非比较类排序。
冒泡排序
核心思想:每一轮都将最大的数移动至未排序数组的末端。
冒泡排序成了面试官考察候选人数据结构与算法的必备基础题(可能大厂甚至都不问,默认已掌握)。
关键点:采用布尔标记进行算法加速,如果某一轮没有进行元素移动,证明未排序数组已经有序,不用继续执行排序逻辑。
上面的动图可以得到元素的交换条件:前面的数 > 后面的数
于是,我们可以写出伪代码:
def bubble_sort(arr):
从 i = 0 开始遍历所有元素
从 j = 0 开始遍历到 arr.length - i - 1
标记 = false
如果 前一个数 大于 后一个数:
交换两个数的位置
标记 = true
如果 标记 为 false:
break
由于每一趟排序都需要遍历6
个元素,每趟排序只确定1
个元素的位置,所以排序6
个元素,算法时间复杂度就是 6 * 6 = 36
算法的时空复杂度:O(N²) 和 O(1)
Java 实现
public void bubbleSort(int[] arr) {
int len = arr.length;
if (len < 2)
return ;
for (int i = 0; i < len; i++) {
boolean flag = false;
for (int j = 0; j < len - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
flag = true;
}
}
if (!flag)
break;
}
}
Python 实现
def bubble_sort(arr):
arr_length = len(arr)
if arr_length < 2:
return
for i in range(0, arr_length, 1):
for j in range(0, arr_length - i - 1, 1):
flag = False
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
flag = True
if not flag:
break
选择排序
核心思想:每一轮从未排序数组中选出最小值放入已排序数组的尾部。
选择排序应该是最直观的排序算法了,因为其核心逻辑非常简单易懂,没有什么复杂的判断条件和边界。
于是,我们可以写出伪代码:
def select_sort(arr):
从 sorted_index = 0 开始遍历所有元素
最小值下标 = sorted_index
从 index = sorted_index 开始遍历到倒数第二个元素
如果 当前数 < 后一个数:
最小值下标 = index
如果 最小值下标 != sorted_index:
交换(arr, 最小值下标, sorted_index)
该排序的时空复杂度分析与冒泡排序相同,所以也是:O(N²),O(1)
Java 实现
public void selectSort(int[] arr) {
int len = arr.length;
if (len < 2)
return ;
for (int sortedIndex = 0; sortedIndex < len; sortedIndex++) {
int min = sortedIndex;
for (int index = sortedIndex; index < len; index++) {
if (arr[min] > arr[index])
min = index;
}
if (min != sortedIndex) {
swap(arr, min, sortedIndex);
}
}
}
Python3 实现
def select_sort(arr):
arr_length = len(arr)
if arr_length < 2:
return
for sorted_index in range(0, arr_length):
min = sorted_index
for index in range(sorted_index, arr_length):
min = index if arr[min] > arr[index] else min
if min != sorted_index:
arr[min], arr[sorted_index] = arr[sorted_index], arr[min]
插入排序
核心思想:每次从未排序数组的头部选定元素,插入到已排序输入对应的位置。
插入排序和选择排序很类似,但两者在逻辑的执行过程中有本质的区别:
- 插入排序:每次选择元素后立即寻找插入位置
- 选择排序:遍历完未排序数组后选择元素寻找插入位置
根据动图,我们可以抓住关键逻辑:选定下标为idx
的元素后,需要遍历[0,idx
-1]的元素寻找插入位置
于是,我们可以写出伪代码:
def insert_sort(arr):
遍历从 idx = 0 到最后一个元素:
遍历从 i = idx 到 1:
如果 arr[i] <= arr[idx]:
break
否则:
arr[i] = arr[i-1]
arr[i-1] = arr[idx]
插入排序的时间复杂度是O(N²),在每次选取一个元素后,都需要往前遍历m(0≤m≤N)
个元素,总共需要取N
个元素寻找插入位置。
Java 实现
public void insertSort(int[] arr) {
int len = arr.length;
if (len < 2)
return ;
for (int idx = 0; idx < len; idx++) {
for (int i = idx; i > 0; i--) {
if (arr[i - 1] < arr[i]) {
break;
} else {
swap(arr, i - 1, i);
}
}
}
}
Python3 实现
def insert_sort(arr):
arr_length = len(arr)
if arr_length < 2:
return
for idx in range(0, arr_length):
for i in range(idx, 0, -1):
if arr[i - 1] < arr[i]:
break
else:
arr[i], arr[i - 1] = arr[i - 1], arr[i]
快速排序
快速排序面试必问,而且可能还需要手撕,它是非常经典的排序算法,涵盖了分治思想,且在所有排序算法中比较常见和实用,但准确地实现代码并不简单。
核心思想:每一轮选定一个基准值pivot
,将小于pivot
的所有数移动到其左边,大于pivot
的数移动到右边,等于pivot
的数没有硬性规定,完成对pivot
的排序后,继续在pivot
的左右两个子区间进行快速排序。
主要步骤:
- 挑选基准值:选定基准值
pivot
。 - 分割:对区间内的所有元素重新排列,完成对
pivot
的排序。 - 递归:递归排列小于
pivot
的子序列和大于pivot
的子序列。
根据执行步骤,可以写出伪代码:
def quick_sort(arr, start, end):
if start >= end:
return
pivot_idx = partition(arr, start, end)
quick_sort(arr, start, pivot_idx - 1)
quick_sort(arr, pivot_idx + 1, end)
def partition(arr, start, end):
选定pivot,默认以start下标元素为pivot
遍历数组,下标为i,将小于pivot的元素放在左边,大于pivot的元素放在右边
将pivot与位置i的元素进行对换
返回枢轴的位置i
要写对快速排序,只有多写,多去理解其中的过程,才能逐渐熟练起来。
Java 实现
public void quickSort(int[] arr, int start, int end) {
if (start >= end) {
return ;
}
int pivotIdx = partition(arr, start, end);
quickSort(arr, start, pivotIdx - 1);
quickSort(arr, pivotIdx + 1, end);
}
public int partition(int[] arr, int start, int end) {
// counter 记录当前大于pivot的第一个元素下标
int pivotIdx = start, counter = start + 1;
for (int i = start + 1; i <= end; i++) {
if (arr[i] < arr[pivotIdx]) {
swap(arr, i, counter);
counter++;
}
}
swap(arr, pivotIdx, counter - 1);
return counter - 1;
}
public void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
python3 实现
def quick_sort(arr, start, end):
if start >= end:
return
pivot = partition(arr, start, end)
quick_sort(arr, start, pivot - 1)
quick_sort(arr, pivot + 1, end)
def partition(arr, start, end):
pivotIdx, counter = start, start + 1
for i in range(start, end, 1):
if arr[i] < arr[pivotIdx]:
arr[i], arr[counter] = arr[counter], arr[i]
counter += 1
arr[pivotIdx], arr[counter - 1] = arr[counter - 1], arr[pivotIdx]
return counter - 1
快速排序还可以有优化的地方:基准值的选取策略。
在代码中和动图演示中,采用了以区间第一个值为基准值,这种做法其实会有弊端,我们都背过快速排序的最优时间复杂度是O(NlogN),但最坏时间复杂度是O(N²),这个最坏时间复杂度的出现的罪魁祸首就是选取策略。
如果选取区间第一个值为基准值,对数组进行升序排序时,如果数组已经有序,就会退化成 O(N²) 的时间复杂度。
快速排序基准值的选取方法:
- 区间第一个/最后一个值
- 随机选取区间内的一个值
- 三数取中
其余两种方法可以对基准值的选取位置进行权衡,避免在数组有序的情况下使算法的性能退化,具体的性能对比我还没来得及去比较,只在网上查阅了一些资料后放在这里给大家参考,还有更多的优化技巧感兴趣的读者可以自行去深入学习!
归并排序
归并排序算法的核心在于分治(divide-and-conquer)。
分治法的思想:
- 分:是将一个问题分解成很多不同的小问题进行递归求解。
- 治:是将各个小问题的解重新凑合在一起组成最终的答案。
归并排序的主要步骤:
- 申请大小为
N
的temp
数组空间存放合并后的序列 - 设定
leftPtr
和rightPtr
指针,指向两个已排序序列的首部 - 左右指针中较小的元素值放入
temp
中,指针移动一位 - 重复第3步,直到左右指针都到达各自序列尾部
- 将
temp
中的已合并序列复制到原数组
归并排序与快速排序具有相似性,但是操作步骤是相反的:
- 快速排序:先确定左右子数组,然后再对左右子数组分别进行排序
- 归并排序:先排序左右子数组,然后再合并两个子数组
Java 实现
public void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left >= right) return ;
int mid = left + ((right - left) >> 1);
mergeSort(arr, left, mid, temp);
mergeSort(arr, mid + 1, right, temp);
merge(arr, left, mid, right, temp);
}
public void merge(int[] arr, int left, int mid, int right, int[] temp) {
int leftPtr = left, rightPtr = mid + 1, index = left;
while (leftPtr <= mid && rightPtr <= right) {
if (arr[leftPtr] < arr[rightPtr]) {
temp[index] = arr[leftPtr];
leftPtr++;
} else {
temp[index] = arr[rightPtr];
rightPtr++;
}
index++;
}
while (leftPtr <= mid) {
temp[index++] = arr[leftPtr++];
}
while (rightPtr <= right) {
temp[index++] = arr[rightPtr++];
}
// 将temp[left..right]合并到arr[left..right]
while (left <= right) {
arr[left] = temp[left];
left++;
}
}
Python 实现
def merge_sort(arr, left, right, temp):
if left >= right:
return
mid = left + ((right - left) >> 1)
merge_sort(arr, left, mid, temp)
merge_sort(arr, mid + 1, right, temp)
merge(arr, left, mid, right, temp)
def merge(arr, left, mid, right, temp):
leftPtr, rightPtr = left, mid + 1
while leftPtr <= mid and rightPtr <= right:
if arr[leftPtr] < arr[rightPtr]:
temp.append(arr[leftPtr])
leftPtr += 1
else:
temp.append(arr[rightPtr])
rightPtr += 1
while leftPtr <= mid:
temp.append(arr[leftPtr])
leftPtr += 1
while rightPtr <= right:
temp.append(arr[rightPtr])
rightPtr += 1
# 将temp[left..right]合并回arr[left..right]
while left <= right:
arr[left] = temp.pop(0)
left += 1
归并排序的时空复杂度:O(NlogN)、O(N+logN)=O(N)
给出的代码中,需要注意的地方是:左区间的末尾和右区间的起始位置。
如果写成下面这种形式,会发生什么事情:
int mid = left + ((right - left) >> 1);
mergeSort(arr, left, mid - 1, temp);
mergeSort(arr, mid, right, temp);
假设待排序的数组是[1, 2]
,则mid = 0 + 1 / 2 = 0
,代入代码后,可以看到右区间是{0, 1}
,左区间是{0, -1}
:
mergeSort(arr, 0, -1, temp);
mergeSort(arr, 0, 1, temp); // 问题出在这一行
我们会发现从函数外调用mergeSort
函数的left
和right
是0
和1
,经过一次归并排序后,还是原来的值,所以:没有进行区间分割,递归无法终止,造成调用栈溢出。
发生这种现象的原因在于求区间分割mid
的时候,语言限定了正数的除法是向下取整,所以下面求区间中值的代码是向下取整过的整数。
// 0 + (1 - 0) / 2 = 0 + 0 = 0(向下取整)
int mid = left + ((right - left) >> 1);
所以,为避免这种情况:保证:左区间的元素等于右区间的元素或者比右区间可以多一个元素
这样无论在任何情况下,都不会发生上述情况。
还有另外一种解决办法:将得到的mid更改为向上取整。
向上取整后,就可以使用新的一套写法了:
int mid = left + ((right - left + 1) >> 1); // 注意这一行
mergeSort(arr, left, mid - 1, temp); // 左区间是[left, mid- 1]
mergeSort(arr, mid, right, temp); // 右区间是[mid, right]
merge(arr, left, mid - 1, right, temp);
不过这种写法实际上是和向下取整的原理是等价的:右区间的元素个数与左区间相等或多一个。
这里给出了两种不同的写法,目的是:把除法的向下取整重视起来,在很多边界很有可能会因为处理不当而出现Bug。
归并排序的应用
归并排序可以应用于对大于系统内存的超大文件进行排序(外部排序)
假设有1T的大文件,无法一次性装入内存中,如何对这个文件内部的数据进行排序?
要回答这个问题,最简单的版本就是:使用两两归并排序(就是简单的归并排序),对文件中的多个数据进行分片段排序,保存到小文件中,然后再两两地对小文件进行合并排序,最终保存回大文件中。(当然,解释的过程顺便也把时空复杂度分析一遍)
更进阶的版本就是:多路归并排序。
关于外部排序,我在网上找到一篇比较好的文章,分享给你们:外部排序算法总结
堆排序
堆排序是基于二叉堆的一种排序算法,首先要清楚堆这种数据结构是一种特殊的二叉树,可以分为:大顶堆和小顶堆。
大顶堆:根节点元素值比左右子树的元素值都要大。
小顶堆:根接点元素值比左右子树的元素值都要小。
如果要将数组进行升序排序,就要建立大顶堆;如果要降序排序,需要建立小顶堆,之后我会以升序排序为例解释原因。
堆排序的本质也是对数组排序,关键点是:将数组看作是一棵二叉树,对数组进行堆排序。
以下标为i(0≤i≤N)
的元素为根节点:
- 左孩子:下标为
2*i+1
的元素 - 右孩子:下标为
2*i+2
的元素
上面的大顶堆和小顶堆的数组分别是:
- 大顶堆:[21, 12, 18, 5, 7, 10, 13]
- 小顶堆:[5, 7, 12, 18, 10, 13, 20]
建立大顶堆的步骤:
- 从最后一个非叶子节点开始,检查根节点和左右孩子的值,在三者中选取最大的值对换位置到根节点
- 寻找前一个非叶子节点,重复第一个步骤,直到根节点
堆排序的步骤:
- 对数组进行煎堆操作。
- 将堆顶元素与未排序的堆尾元素进行对换
- 调整未排序的二叉堆
- 重复第2、3个步骤
Q:为什么动图中的堆顶元素直接消失而不是与堆尾元素对换?
这里的动图演示与我描述的堆排序的第二个步骤好像并不相符,动图中堆顶元素直接消失,其实是没有冲突的,我以取出第一个元素21
为例子画了一幅图,看了以后应该就会比较明朗。
当元素21
完成排序后,在动图上看见该元素直接消失了,实际上等价于与元素13
对换了位置,这是非常关键的一点!
所以,未排序的二叉堆减少了一个元素。
弄清楚这个以后,证明上述的堆排序步骤是正确的,我们就可以开始写代码了。
Java 实现
public void heapSort(int[] arr, int start, int end) {
if (start >= end) return ;
// 进行堆的初始化操作
for (int i = arr.length / 2 - 1; i >= 0; i--) {
heapify(arr, i, end);
}
// 进行堆排序
for (int i = arr.length - 1; i >= 0; i--) {
int temp = arr[i];
arr[i] = arr[0];
arr[0] = temp;
// 每次对换堆顶和堆尾元素后,调整未排序部分使其满足堆结构
heapify(arr, 0, i);
}
}
public void heapify(int[] arr, int i, int j) {
if (i >= j) return ;
// 初始化最大值的节点下标是根节点下标
int largest = i;
int left = i * 2 + 1, right = i * 2 + 2;
if (left < j && arr[left] > arr[largest]) {
largest = left;
}
if (right < j && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
int temp = arr[largest];
arr[largest] = arr[i];
arr[i] = temp;
// 还需要对交换的节点所在的子树进行堆化
heapify(arr, largest, j);
}
}
Python 实现
def heap_sort(arr, start, end):
if start >= end:
return
for i in range(int(end / 2) - 1, start - 1, -1):
heapify(arr, i, end)
for j in range(end, start, -1):
arr[0], arr[j] = arr[j], arr[0]
heapify(arr, 0, j)
def heapify(arr, start, end):
if start >= end:
return
# 记录最大值下标
largest = start
leftChildPtr, rightChildPtr = start * 2 + 1, start * 2 + 2
if leftChildPtr < end and arr[leftChildPtr] > arr[start]:
largest = leftChildPtr
if rightChildPtr < end and arr[rightChildPtr] > arr[start]:
largest = rightChildPtr
if largest != start:
arr[largest], arr[start] = arr[start], arr[largest]
# 对换位置后,子树的堆结构也要检查
heapify(arr, largest, end)
堆排序的代码比较不好写,因为对数组下标操作的要求较高,如果要一次写对,必须要对边界条件以及数组下标处理得细腻一些。
堆排序的时空复杂度是O(NlogN),O(1)。
我在腾讯一面时被问:建堆的时间复杂度是多少呢?
当时没答出来,就凉了,现在就试着分析一下吧。
假设现在是具有一棵深度为2
的完全二叉树,对其进行Floyd建堆操作(从下往上顺序建堆)。
我们可以归纳出:如果深度为k
,每一层的节点数是2^k
,每一层的每个节点可被交换次数和深度恰好相反。
所以,可以得到下面这条总的交换次数公式:
S ( k ) = 2 0 ∗ k + 2 1 ∗ ( k − 1 ) + 2 2 ∗ ( k − 2 ) + ⋅ ⋅ ⋅ + 2 k − 1 ∗ 1 + 2 k ∗ 0 S(k) = 2^0*k+2^1*(k-1)+2^2*(k-2)+···+2^{k-1}*1+2^k*0 S(k)=20∗k+21∗(k−1)+22∗(k−2)+⋅⋅⋅+2k−1∗1+2k∗0
这条公式是一条等比+等差数列求和公式,利用错位相减法,可以解出这条总交换次数公式的一般表达式:
2 S ( k ) = 2 1 ∗ k + 2 2 ∗ ( k − 1 ) + 2 3 ∗ ( k − 2 ) + ⋅ ⋅ ⋅ + 2 k − 1 ∗ 2 + 2 k ∗ 1 + 2 k + 1 ∗ 0 2S(k) = 2^1*k+2^2*(k-1)+2^3*(k-2)+···+2^{k-1}*2+2^{k}*1+2^{k+1}*0 2S(k)=21∗k+22∗(k−1)+23∗(k−2)+⋅⋅⋅+2k−1∗2+2k∗1+2k+1∗0
两式相减,得到下面这个形式:
S ( k ) = − 2 0 ∗ k + ( 2 1 + 2 2 + 2 3 + ⋅ ⋅ ⋅ + 2 k − 1 + 2 k ) S(k) = -2^0*k+(2^1+2^2+2^3+···+2^{k-1}+2^k) S(k)=−20∗k+(21+22+23+⋅⋅⋅+2k−1+2k)
还记得我们高中学的等比数列求和公式吧:
S ( n ) = a 1 × ( 1 − q n ) 1 − q S(n) = \frac{a_1×(1-q^n)}{1-q} S(n)=1−qa1×(1−qn)
代入后,得到下面这条化简好的式子:
S ( k ) = 2 k + 1 − ( k + 2 ) S(k) = 2^{k+1}-(k+2) S(k)=2k+1−(k+2)
我们知道k
是树的深度,而节点的个数n
和k
的关系如下:
n = 2 k + 1 − 1 = > k = l o g 2 ( n + 1 ) − 1 n = 2^{k+1}-1 => k = log_2(n+1)-1 n=2k+1−1=>k=log2(n+1)−1
将其代入到化简好的求和公式,得到:
S ( n ) = ( n − 3 ) − l o g 2 ( n + 1 ) S(n) = (n-3)-log_2(n+1) S(n)=(n−3)−log2(n+1)
由于时间复杂度只看n
的最高次幂,所以:建堆的时间复杂度是O(N)。
到这里为止,比较类的常见排序算法就告一段落了,什么?没有希尔排序?那是因为希尔排序是直接插入排序的一个优化版本,我觉得掌握了直接插入排序,在其之上增加一个step
,理解希尔排序应该问题不大,下面三个排序,在面试中可能不会要求写,但是它们的应用场景我倒是被问到过,所以代码就不贴出来了,着重讲一讲它们的应用场景。(说这么多就是懒)
计数排序
计数排序要求输入的数据必须是有确定范围的整数。
核心思想:将输入的数据值转化为键存储在额外开辟的数组空间中(数组的下标就是确定范围的整数);然后依次把计数大于1
的填充回原数组。
计数排序的时空复杂度都是O(N+K)。N
是原数组的数组元素个数,K
是额外存储元素出现次数的数组元素个数。
应用场景:根据分数快速得到自己处于排行榜中的第几名。
我们在玩各种游戏的时候,或多或少会有全服全区排名的情况出现,如果每个用户在请求获取自己排名时都对所有数据排序一次再找自己分数的位置,那消耗会很大,所以可以利用计数排序,它会记录下每个分数的总人数。
假如我的分数是2
,只需要获取分数是10~3
的所有用户总人数sum
,然后再在分数是2
的所有用户中线性查找自己的位置k
,就能得到自己的排名是sum+k
,查找一次的时间复杂度就是O(k)
,而k
小于n
。
但是计数排序对于数值范围非常广的数据非常不友好,例如0~10000
,要创建长度为10001
的数组,内存消耗非常大,数据范围在0-100的数据最适合采用计数排序。
桶排序
桶排序是计数排序的扩展版本。
桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)。
应用场景:将全国1071万考生的高考分数进行排序。
我们知道全国高考总分数是750分(我这辈子都得不到的分数)
我们可以将区间每隔50分作为一个桶,这样就可以划分出15个桶(实际上可以划分更多的桶,减少每个桶的排序压力)。
桶排序和计数排序各有各的优势,并没有说哪个排序算法更优,需要根据不同场景来权衡。
应用场景:快速查出自己高考成绩的排名。
假如我的高考成绩是345分,我处于 [300, 350) 的区间,此时,我只要知道 [0, 300) 的所有人数,再加上我自己在 [300, 350) 的桶的内部位置,两者一相加,就可以快速得到自己的排名啦。
基数排序
基数排序是桶排序的一个变种。
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。
有时候元素的某些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。
基数排序更多的是一种思想:由低位开始排序,再按高位继续排序。
拓展一下这个思想,我们可以认为:元素每种属性都有一定的优先级,我们按照某种顺序对每个元素进行优先级排序,最终实现所有元素的排序。
这样的话,无论在面对什么情况,只要能够把属性映射到对应的桶上,我们就可以对任意元素进行相应的排序啦。
总结
这篇文章篇幅比较长,看到这里之后,总结一些我觉得比较重要的知识点:
- 冒泡排序可以利用
flag
提前结束排序逻辑 - 快速排序可以优化枢轴的选取策略,以防算法退化
- 分治排序的分治思想可以利用在外部排序,甚至是大数据的
Map-Reduce
- 堆排序的建堆、排序过程比较繁琐,需要仔细体会,建议Debug
- 所有排序算法都有自己的优势,掌握其核心思想,了解应用场景,可以对算法的理解更加深刻
- 最好可以学习常见算法的时空复杂度分析
相关链接
- 快速排序的优化-枢轴:https://blog.csdn.net/qq_33504135/article/details/88650827
- 算法可视化VisualGo:https://visualgo.net/zh/
我还给你准备了两个有意思的算法排序演示视频,感兴趣的可以去看看,一定要打开声音体会排序的乐趣:
- https://www.bilibili.com/video/av25136272
- https://www.bilibili.com/video/av63851336