排序算法
排序算法
排序算法,就是将一个无序数组内的全部元素,按照递增或递减的顺序重新排列起来的程序实现方法。在结构体中,还有针对结构体数组排序的排序算法。对于多种属性数据对象集合,按照其中的某一个或某一些属性的大小进行排序。例如:对一个公司的员工信息排序时,可以分别按工号、姓名或年龄等属性进行排序。
基础排序算法包括冒泡排序、选择排序和插入排序三种,高级排序包括快速排序、归并排序、堆排序和希尔排序等等。七种常见的、经典的基于"比较"的排序算法:
- 冒泡排序
- 选择排序
- 插入排序
- 希尔排序
- 快速排序
- 归并排序
- 堆排序
这些排序算法各有各的特点、适用场景,没有一种算法总是最优解。但相对而言比较重要、更常用的是:快速排序、归并排序、堆排序以及插入排序。
如何评估一个排序算法?
从三个维度去分析一个排序算法:时间复杂度、空间复杂度和稳定性。
下面这张图展示了算法随着数据规模的增大,指令数量和额外内存空间占用,在不同级别下的增加趋势:

其中,常数级别O(1)和对数级别O(log n)被认为是特别优秀的,稳定且高效。
而从O(n2)开始,就表示算法随着数据规模的增大而迅速变得低效,在实际应用中要尽量避免选择这样的算法去进行大数据集的处理。
冒泡排序
算法思想
- 第一轮冒泡排序:从数组的第一个元素开始,比较相邻的元素。如果第一个元素比第二个元素大,则交换它们的位置。然后,移动到下一对相邻元素,重复这个过程,直到比较最后一对元素。每一轮冒泡排序都会使当前比较序列的最大值到达数组末尾,随后第二轮排序过程中,需要比较的元素就减1。(将尾部最大的元素减去不再排序)
- 第二轮冒泡排序:重复第一轮的过程,但这次只比较和交换直到倒数第二个元素(因为最后一个元素已经是最大的了)。在这一轮结束时,倒数第二大的元素会被“冒泡”到倒数第二的位置。
- ...
- 结束条件:
- 在不设置任何额外结束条件的前提下,冒泡排序每一轮都会将未排序序列的最大值"冒泡"到末尾。冒泡排序需要进行固定的(n - 1)轮!
- 但实际上在这(n - 1)轮冒泡排序的过程中,只要某一轮完全不存在元素的交换,就说明数组已经完全有序了,排序就可以结束了。
- 所以我们可以设定一个布尔值来标记此轮冒泡排序是否存在元素交换,如果没有元素交换,直接结束整个排序。这种做法可以优化冒泡排序的性能,尤其是当原数组已基本有序时。
代码实现
冒泡排序算法C语言实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdbool.h>
#define ARR_LEN(arr) (sizeof(arr) / sizeof(arr[0]))
#define SWAP(arr, i, j ) { \
int tmp = arr[i]; \
arr[i] = arr[j]; \
arr[j] = tmp; \
}
void print_arr(int arr[], int len) {
for (int i = 0; i < len; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
// 冒泡排序
void bubble_sort(int arr[], int len) {
for (size_t i = 0; i < len-1; i++)
{
// 标记在这一次冒泡排序中有没有交换,false表示没有交换
bool swapped = false;
for (size_t j = 0; j < len - i- 1; j++) {
if (arr[j]>arr[j+1])
{
SWAP(arr, j, j+1);
// 发生了交换改变标记
swapped = true;
}
}
// 在一轮冒泡排序中没有任何交换,则排序已经完成,终止循环
if (!swapped) {
break;
}
// 打印一轮冒泡排序后数组的元素排列
print_arr(arr, len);
}
}
int main(void) {
// 测试冒泡排序
int arr[] = { 1,10,2,5,3,4,5,6,3,2 };
int len = ARR_LEN(arr);
bubble_sort(arr, len);
system("pause");
return 0;
}
性能分析
时间复杂度分析:
最佳情况:由于使用了"swapped"标记,所以在最佳情况下,即输入的数组已经是有序的情况下,算法仅需要一次数组遍历就可以确定排序完成。
即:
比较次数: (n - 1)
交换次数: 0
所以最佳情况下的时间复杂度是 O(n)。
最坏情况:
在最坏的情况下(当数组完全逆序时),冒泡排序第一轮需要执行( n - 1) 次比较和交换,第二次执行( n - 2 )次,依此类推,直到最后一次执行 1 次。所以,总的执行次数是等差数列求和:n * ( n - 1) / 2。所以最坏情况下的时间复杂度是
平均情况:在平均情况下,时间复杂度也是
空间复杂度分析:
冒泡排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)
稳定性分析:
冒泡排序显然是一种稳定的排序算法,因为交换的过程中不会交换任何两个相同的元素。
选择排序
算法思想
①先假设第一个数是最小的,然后拿这个数去和后面的n个数依次进行比较,
②如果发现有比最小值还小的数 则通过中间变量记录下下标和值,
③然后继续拿这个最小值往后进行比较,如果又发现比最小值还小的值 就执行第②步,直到比完最后一个元素
④最后把存在中间变量的最小值和最小值的下标 和假设的那个最小值进行交换
代码实现
选择排序算法C语言实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#define ARR_LEN(arr) (sizeof(arr) / sizeof(arr[0]))
#define SWAP(arr, i, j ) { \
int tmp = arr[i]; \
arr[i] = arr[j]; \
arr[j] = tmp; \
}
void print_arr(int arr[], int len) {
for (int i = 0; i < len; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
// 选择排序
void selection_sort(int arr[], int len) {
/*
* i表示未排序序列的开头元素
* 最后一轮选择排序时, 未排序序列的开头元素是数组倒数第二个元素
* i的每个取值都表示一轮选择排序
* 也就是选择排序一共执行9趟
*/
for (int i = 0; i < len - 1; i++) {
// 不妨直接假设未排序序列的开头i位置元素就是最小值
int min_index = i;
// 遍历未排序数组序列,找出真正的最小值下标,此时应遍历最后一个元素
for (int j = i + 1; j < len; j++) {
if (arr[j] < arr[min_index]) {
min_index = j; // 记录较小值的下标
}
} // for循环结束时,未排序序列的最小值下标就是min_index
// 交换min_index和下标i的元素
SWAP(arr, min_index, i);
// 选择排序一趟打印一次数组
print_arr(arr, len);
}
}
int main(void) {
// 测试选择排序
int arr[] = { 1,10,2,5,3,4,5,6,3,2 };
int len = ARR_LEN(arr);
selection_sort(arr, len);
system("pause");
return 0;
}
性能分析
时间复杂度分析:
在分析以比较为核心的排序算法的时间复杂度时,重点关注两点:
- 比较次数
- 交换元素次数
在选择排序当中,无论什么情况下,比较次数和交换元素次数都是一样的:
- 比较次数:
- 每一轮的比较次数会随着未排序序列减少递减
- 即一共比较(n - 1) + (n - 2) + ... + 1 = n * (n - 1) / 2 次(等差数列求和)
- 交换元素次数:数组一共有n个元素,除了末尾元素不需要交换,其它元素都需要交换,固定交换(n - 1)次。
将两者加起来就是总的指令执行数量。
除此之外,我们发现选择排序,在数组已经有序、逆序还是完全无序的各种情况下,这些比较和交换都不会减少。
所以用大O表示法,选择排序的时间复杂度是
空间复杂度分析:
选择排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)
稳定性分析:
选择排序稳定吗?答:不稳定!
选择排序在每轮中选择最小元素,并与未排序部分的第一个元素交换。如果存在相等的元素,选择排序可能会改变它们的相对顺序。所以选择排序不是稳定的排序算法。
这样的例子随手可举,比如对于一个数组:
[3, 5, 3', 2, 8]
两个3是相同的元素,相对位置是3在前,3'在后。在选择排序后,得出结果是:
[2, 3', 3 , 5, 8]
变成3'在前,3在后。
插入排序
算法思想
- 以数组的首元素为初始状态:这个初始状态相当于抓到的第一张牌,它默认就是有序的。
- 从数组的第二个元素开始遍历:相当于抓一张牌,然后从小到大整理手牌。
- 比较与交换:将新插入的元素和前面的元素逐一比较,如果新插入元素较小,则交换两个元素,直到完全不可交换,则完成一轮排序。
- 重复步骤2和3,直到步骤2遍历到最后一个元素。
代码实现
插入排序算法C语言实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#define ARR_LEN(arr) (sizeof(arr) / sizeof(arr[0]))
#define SWAP(arr, i, j ) { \
int tmp = arr[i]; \
arr[i] = arr[j]; \
arr[j] = tmp; \
}
void print_arr(int arr[], int len) {
for (int i = 0; i < len; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
// 插入排序
void insert_sort(int arr[], int len) {
for (int i = 1; i < len; i++)
{
for (int j = i - 1; j >= 0; j--) {
if (arr[j] > arr[j + 1])
{
SWAP(arr, j, j + 1);
}
else
{
break;
}
}
// 打印一轮插入排序后数组的元素排列
print_arr(arr, len);
}
}
// 插入排序 优化: 用向后移动腾出插入位置,然后插入实现插入排序
void insertion_sort2(int arr[], int len) {
// 现在第一个元素就是第一张手牌,从第二个元素开始就是每一次要摸的牌
// 外层for循环代表每一轮摸到的新手牌, 也就是每一轮插入排序
for (int i = 1; i < len; i++) {
// 先记录一下新手牌的值, 便于后续的插入操作
int tmp = arr[i];
int j = i - 1;
for (; j >= 0; j--) {
if (arr[j] > tmp) { // 注意:不能加=,加了就不是稳定排序算法了
arr[j + 1] = arr[j]; // 将旧手牌中大于新手牌的所有牌都向后移
}
else
{
break; // 只要发现一张旧手牌更小或相等, 就说明已经找到新手牌的插入位置了
}
}
/*
现在还有一件事情没做:新手牌要插入,需要确定插入位置
分析: for循环什么时候结束?
两种情况:
1.j=-1时,循环结束,说明新手牌是最小的,所以插入到0这个位置,也就是j+1
2.arr[j] <= tmp 也就是旧手牌更小或相等,此时新手牌放在j+1的位置
*/
arr[j + 1] = tmp;
print_arr(arr, len); // 每一轮摸牌后查看排序后的数组
}
}
int main(void) {
// 测试插入排序
int arr[] = { 1,10,2,5,3,4,5,6,3,2 };
int len = ARR_LEN(arr);
//insert_sort(arr, len);
insert_sort2(arr, len);
system("pause");
return 0;
}
性能分析
以版本2为选择排序的实现,假设n为数组的长度,分析以下复杂度:
时间复杂度分析:
最佳情况:
- 当输入的数组已经是排序好的,此时不需要移动任何元素,但比较还是需要的。
- 比较次数(外层for循环次数):(n - 1)
- 移动次数:0
所以最佳情况下的时间复杂度是 O(n)。
最坏情况:
- 在最坏的情况下,即数组完全逆序时。
- 比较和交换的次数是相同的,第一轮需要比较和移动1次,第二轮需要比较和移动2次....最后一个元素需要比较和移动 (n - 1)次
- 等比数列求和:需要比较和移动各n(n-1)/2次,加起来就是
所以最坏情况下的时间复杂度是
平均情况:
- 在平均情况下,可以粗略的认为移动次数和比较次数是最坏情况除以2
- 所以在平均情况下,时间复杂度也是
。
空间复杂度分析:
插入排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)
稳定性分析:
插入排序显然也是一种稳定的排序算法,因为对于两个相同的元素,我们始终都不会交换它们的相对位置。
注意:如果判断的条件改成arr[j] >= arr[j + 1]
,即在前后元素相等时也交换/移动元素,算法就会变成不稳定的。
基础排序算法总结
选择排序、冒泡排序和插入排序都是基础的排序算法,同样的基于比较而实现,同样的简单直观,同样的原地算法。
这里对它们做一个总结分析:
- 选择排序在任何情况下时间复杂度都是
,且它是一个不稳定的排序算法。比起作为算法去使用,它更具有教学意义,一般不推荐去使用它。 - 冒泡排序和插入排序看起来很类似,它们都有最坏和平均情况下
的时间复杂度,都有最优情况下 的时间复杂度。但在小数据集或者数据集基本有序的情况下,仍然更推荐使用插入排序。
这是因为:
- 插入排序的移动操作比冒泡排序的交换操作效率更高。在冒泡排序中,每轮冒泡都可能涉及大量交换元素操作,而插入排序用移动操作替代了交换。从指令执行数量上来说,移动操作效率更高。
- 在数据基本有序的情况下,插入排序往往表现要更好。这是因为在数据基本有序的情况下,插入排序往往只需要挪动元素很少的次数,而冒泡排序可能还需要,慢慢的一步步的两两交换多次。这里同样存在移动操作和交换操作指令数量上的差异!
希尔排序
算法思想
希尔排序(Shell Sort)是美国计算机科学家,唐纳德·希尔在1959年提出的排序算法。
希尔排序是插入排序的一种改进和扩展,它基于插入排序的两个特点来进行改进:
- 插入排序的缺点就是当较小元素处于末尾时,需要移动元素的次数过多,希尔排序通过比较和交换不相邻的元素来改进插入排序,可以一次性将后面的小元素向前面移动多个位置,从而提高效率。
- 插入排序在排序基本有序的序列时,效率很高。可以将希尔排序理解成——先通过几次分组的、较小的组间插入排序将原数组变得有序,最后再进行一次序列基本有序的完整插入排序。
整个希尔排序的过程,可以看成多个人轮流抓牌 --> 整理手牌 --> 抓牌,然后再把手牌合并变成更少的人抓牌,直到回归一个人抓牌(直到变成插入排序)。
代码实现
希尔排序算法C语言实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#define ARR_LEN(arr) (sizeof(arr) / sizeof(arr[0]))
#define SWAP(arr, i, j ) { \
int tmp = arr[i]; \
arr[i] = arr[j]; \
arr[j] = tmp; \
}
void print_arr(int arr[], int len) {
for (int i = 0; i < len; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
// 希尔排序: 缩小增量排序, 其实就是多人摸牌, 逐渐减少摸牌人数
// 希尔排序中, 增量序列的设计非常重要,这里采取简单的gap序列: 长度减半..一直减半,直到为1
// gap为1时就是一个在数组元素基本有序情况下的,插入排序
void shell_sort(int arr[], int len) {
// 第一个元素是第一个人的初始手牌,一直到第gap个元素都是初始手牌
int gap = len >> 1;
while (gap > 0) {
// 外层for的i仍然代表新摸到的手牌的下标,i从gap开始,直到摸完整个数组元素
for (int i = gap; i < len; i++) {
// 先记录一下新手牌的值, 便于后续的插入操作
int tmp = arr[i];
int j = i - gap; // 代表每一个人旧手牌的最后一张牌
for (; j >= 0; j -= gap) {
// 内层for代表 每个人每摸到一张新手牌,都会和原本的旧手牌比较,但由于gap存在,所以需要减去gap
if (arr[j] > tmp) { // 注意:不能加=,加了就不稳定了
arr[j + gap] = arr[j]; // 将旧手牌中大于新手牌的所有牌都向后移
}
else
{
break; // 只要发现一张旧手牌更小或相等, 就说明已经找到新手牌的插入位置了
}
}
arr[j + gap] = tmp;
}
print_arr(arr, len); // 每一轮希尔排序后查看数组排序结果
gap >>= 1; // 每一轮希尔排序,增量都减半
}
}
int main(void) {
// 测试希尔排序
int arr[] = { 16, 1, 45, 23, 99, 2, 18, 67, 42, 10 };
int len = ARR_LEN(arr);
shell_sort(arr, len);
system("pause");
return 0;
}
性能分析
时间复杂度:
希尔排序的时间复杂度,和选择的增量序列有密切的关联:
若使用希尔本人提出的减半序列,时间复杂度通常会小于
空间复杂度分析:
希尔排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)。
稳定性分析:
希尔排序显然不是一种稳定的排序算法,因为它先分组再插入排序的方式,使得相同元素可能会由于分组不同改变位置。
比如:
假设我们有一个数组 [4a, 3, 4b, 2, 1],选择的增量是3...1
那么第一轮希尔排序后的结果就是:[2, 1, 4b, 4a, 3],很明显这不是稳定的排序算法。
希尔排序的地位如何?
希尔排序的实现比起插入排序更加复杂,思路更加不直观,并且还牺牲了算法的稳定性,从而换取了一定的性能提升。
但总得来说,希尔排序处在一共尴尬的境地:在小数据集的情况下,插入排序的性能和希尔排序相当甚至会更优。
在大数据集的情况下,希尔排序的性能又不及归并、快排等高级排序算法。希尔排序更像是一个介于简单排序算法和高级排序算法之间的一个桥梁,不需要作为重点去学习。
归并排序
算法思想
归并排序的分治策略思路大体上如下:
- 分解大问题:将一个大数组分解成两个或更多的子数组,直到每个子数组足够小,通常是直到每个子数组只包含一个元素或者是空数组。
- 解决子问题:数组小到只有一个元素或者没有元素,那自然是"有序数组",所以这些子问题可以视为被解决了。
- 合并:归并排序的核心在于合并步骤,也可以叫"归并"操作,它会将两个有序的子数组合并成一个大的有序数组。这个过程通常需要反复比较元素,比较复杂。
分解过程:
可以采用递归作为手段来实现分治的过程。首先存在一个递归分解的过程,递归的思路参考如下:

那么这个递归的递归体和递归的出口是什么呢?
很明显,递归分解的过程会不停地将大数组分解成两个小的子数组,这个分解的过程会根据大数组的左右界限求一个中间索引,然后将大数组尽量等分为两份。所以,递归分解的函数,至少需要三个参数:
- 递归分解的数组arr
- 数组分解的左下标left
- 数组分解的右下标right
此递归分解的函数会将arr数组的[left, right]区间分解成两个小数组。
于是递归的出口就很明显了是:left >= right,这表示子数组缩小到只包含一个元素或没有元素时,递归将停止。
在计算中索引时,我们将采用一种优化的方案:
- 一般情况下,可以直接使用 "(left + right) >> 1" 来直接求中间索引。
- 但C语言中int类型可能只占2个字节,此时int类型取值范围较小,上面的表达式可能会出现数据溢出失真。为避免这种情况发生,我们可以采用表达式"left + (right - left >> 1)"去求中间索引。
合并过程:
合并操作的思路是:
- 从左到右轮流比较待合并子数组中的元素,把比较过程中的较小元素存入临时数组中,直到某个子数组元素为空。
- 然后再将存在剩余元素的子数组中的所有元素,轮流放入临时数组中。
- 最后把临时数组中的元素,复制回原数组。
注:临时数组的长度和原数组是一致的,且合并过程共有同一套下标索引。
代码实现
归并排序算法C语言实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#define ARR_LEN(arr) (sizeof(arr) / sizeof(arr[0]))
// 打印数组的函数
void print_arr(int arr[], int left, int right) {
for (int i = left; i <= right; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
/*
* 合并的思路:
* 1.把左右子数组中元素按照顺序合并到临时数组中,过程类似"穿针引线"
* 2.将排好序的临时数组元素按照下标赋值给原数组
* 注:临时数组和原数组共有一套下标
* 传入函数逻辑上的左右子数组是有序的,相当于合并两个有序的左右子数组
*/
static void merge(int arr[], int left, int mid, int right, int* temp) {
/*
* tmp_idx: 用于存放合并结果的临时数组的开始下标
* left_idx: 左子数组的开始下标
* right_idx: 右子数组的开始下标
*/
int temp_idx = left, left_idx = left, right_idx = mid + 1;
// 只要左右子数组同时还有元素
while (left_idx <= mid && right_idx <= right) {
// 逐对比较左右子数组的元素, 按照从小到大放入临时数组
// <=判断不会改变相同元素的相对位置,是稳定算法。反之则不是稳定算法
if (arr[left_idx]<=arr[right_idx])
{
temp[temp_idx++] = arr[left_idx++];
}
else
{
temp[temp_idx++] = arr[right_idx++];
}
}
// while结束时,左右子数组必然有一个没有元素了,此时另一个数组必然还有元素
// 也就是说只会有一个数组是空的
// 但我们无法确定是哪个数组没有元素了
// 所以我们都判断一下将左右子数组还剩余的元素取出来
while (left_idx <= mid) {
temp[temp_idx++] = arr[left_idx++];
}
while (right_idx <= right) {
temp[temp_idx++] = arr[right_idx++];
}
// 将临时数组中已排序好的元素复制到原始数组中
for (int i = left; i <= right; i++)
{
arr[i] = temp[i];
}
// 打印此一轮归并排序的元素
print_arr(arr, left, right);
}
/*
* 辅助函数,实现对[left, right]范围内的数组递归分解合并
* left表示递归分解的区间起点,right表示递归分解区间的终点,是一个闭区间
* 递归分解的思路是:
* 对[left, right]区间元素的排序,可以分解成:
* [left, mid]区间,和[mid + 1, right]区间的排序合并
* 递归的出口是:
* 如果区间仅有一个元素或没有元素,递归结束
*/
static void divide_merge(int arr[], int left, int right, int* temp) {
// 递归的出口
if (left>=right)
{
return;
}
// 递归体
// 计算中间索引
int mid = left + (right - left >> 1);
divide_merge(arr, left, mid, temp);
divide_merge(arr, mid + 1, right, temp);
/*
* 归并,归并排序的核心操作
* 需要一个临时数组完成此操作
* 这个临时数组至少要和原先的数组一般大
* 有两种方案:
* 1.用全局变量数组或局部变量,该方式简洁易实现,无需考虑内存回收
* 但缺点是
* a.必须编译时期确定数组长度,无法运行时期动态分配
* b.栈区和数据段都无法创建长数组,在大数据集下容易产生溢出错误
* 为了解决这两个缺点,我们可以在堆上动态分配数组
* 但同样也有缺点:
* a.内存管理风险
* b.动态分配数组会带来额外性能开销
*/
merge(arr,left,mid,right,temp);
}
// 归并排序
void merge_sort(int arr[], int len) {
int* temp = calloc(len, sizeof(int));
if (temp==NULL)
{
printf("Error: malloc failed in merge_sort.\n");
exit(1);
}
// 将整个数组进行递归分解合并,即完成归并排序
divide_merge(arr, 0, len - 1, temp);
// 不要忘记free释放资源
free(temp);
}
int main(void) {
// 测试归并排序
int arr[] = { 8, 3, 2, 6, 9, 7, 1, 0, 4, 5 };
int len = ARR_LEN(arr);
merge_sort(arr, len);
system("pause");
return 0;
}
性能分析
时间复杂度:
无论原始数组处在什么状态,归并排序都会按照既定步骤分解、合并。所以在最好,最坏,平均情况下,归并排序的时间复杂度都是一样的,都是
归并排序的时间复杂度分析需要考虑它的两个主要操作,分解和合并:
- 分解过程也就是递归调用的过程,这个过程大概分解了
次(每次都将数组折半,也就是递归的深度) - 在合并的过程中,需要遍历并比较子数组的元素,然后将它们按顺序复制回原数组。每次合并操作的时间复杂度都是O(n),因为它涉及到整个数组的遍历。合并的次数和分解的次数是一样的,都是log2n次,所以对于合并操作,总的时间复杂度是
。
综合起来,时间复杂度就是
空间复杂度:
归并排序显然不是一个原地算法。它需要额外的内存空间:
- 需要一个与原始数组大小相同的,长度是n的辅助数组来进行合并操作。
- 递归调用,占用额外的栈空间。因为每次递归调用都会将数组分为两个大致相等的部分,所以每次都将问题的规模减半。递归深度大致是
。
所以空间复杂度是O(n)。
稳定性:
归并排序是稳定的排序算法。这是因为如果两个元素相等,归并排序不会改变它们的相对顺序。
快速排序
快速排序(Quick Sort)是一种建立在分区(partition)操作上的,非常高效的排序算法,由托尼·霍尔在1960年代提出。快速排序同样是分治思想的典型应用,由于其优秀的性能和较为简单的实现,它通常是实际应用中,大数据集下首选的排序算法。
通过分区,快速排序实现了对数组的分解,大体思路是:
- 分解大问题:挑选一个基准值(pivot),然后通过一轮遍历将数组分成两个子数组:一个包含所有小于等于基准的元素,另一个包含所有大于基准的元素。这个过程就是分区操作,该过程结束后,基准值一定位于其排序最终位置上。
- 解决子问题:递归地对两个子数组进行快速排序。由于这两个子数组都比原始数组小,这就是解决子问题的过程。如果子数组足够小,可以直接认定它就是有序的。
- 合并:快速排序的操作都是在原数组上直接进行的,所以快速排序的合并步骤实际上并不需要做任何操作,只有解决子问题,原数组就是直接排好序的。
在具体到实现时,快速排序根据分区操作的不同,可以分为两种:
- 单向分区
- 双向分区
其中双向分区泛用性最强,也最常用。
单向分区
所谓单向分区,指的是快速排序算法在分区的过程中,元素比较和交换的操作是单向进行的,也就是从数组的一端进行到另外一端。
单向分区快速排序算法,具体而言,它的思路是:
- 选择一个基准值(pivot),可以是随机选择,也可以是直接选首尾元素。选定基准值后,一般会将pivot交换到数组末尾,这样做可以简化分区操作。
- 设置一个索引(比如叫idx)来追踪小于基准值的元素应该插入的位置,一开始idx索引指向数组的首元素。
- 遍历数组进行分区操作:
- 从数组首元素开始遍历整个数组
- 如果元素小于基准值,则将该元素与idx位置的元素交换,idx索引加1。
- 如果元素大于或等于基准值,则不做任何操作,继续遍历下一个元素。
- 当遍历到最后一个元素,也就是pivot时,遍历结束:
- 最后将pivot元素和此时idx索引元素进行交换,完成这一轮分区操作。
- 此时pivot左侧的元素一定都是小于基准值的。
- pivot右侧的元素一定都是大于等于基准值的。
- 对基准值左右两边的子数组递归地执行以上步骤,直到每个子数组的大小减少到1或0,此时数组就被完全排序了。
双向分区
比起单向分区,双向分区是更常用的快排分区策略,一般而言当提起快速排序,指的都是双向分区策略的快速排序。
所谓双向分区,指的是在分区过程中,元素比较和交换操作的方向是,同时从数组的两端向中间逼近的。
双向分区快速排序算法,具体而言,它的思路是:
-
选择基准值pivot,基准值可以是一个随机元素,也可以选择一个固定元素。然后将基准值元素和首元素交换,这样做的目的是为了将交换元素操作优化成一个赋值操作。并且要将基准值存储起来。
-
设置两个索引 low 和 high :
-
索引 low 一开始指向数组首元素,它的含义是指示小于基准值的元素应该置于的位置。
-
索引 high 一开始指向数组尾元素,它的含义是指示大于等于基准值的元素应该置于的位置。
-
-
率先移动索引high,它从尾元素开始向左移动,目标是找到第一个小于基准值的元素:
-
找到该元素后,直接将该元素赋值给low索引位置,也就是覆盖掉基准值。
-
赋值结束后,low索引和high索引都不需要移动。
-
-
然后向右移动索引 low,找到第一个大于等于基准值的元素:
-
找到该元素后,直接将该元素赋值给high索引位置
-
赋值结束后,low索引和high索引都不需要移动。
-
-
重复过程3和4,直到索引high和low相遇。最后将基准值放入它们相遇的位置。
-
于是分区就结束了,基准值到达了排序的最终位置,基准值左边都是小于基准值的元素,右边都是大于等于基准值的元素。
-
对基准值左右两边的子数组递归地执行以上步骤,直到每个子数组的大小减少到1或0,此时数组就被完全排序了。
代码实现
快速排序算法单向分区C语言实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))
#define SWAP(arr, i, j) { \
int tmp = arr[i]; \
arr[i] = arr[j]; \
arr[j] = tmp; \
}
// 打印数组的函数
void print_arr(int arr[], int left, int right) {
for (int i = left; i <= right; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
// 分区核心操作实现,返回一轮快排选择的pivot的下标
int partition(int arr[], int left, int right) {
// 1.随机选择一个基准值,然后把它先放到数组末尾
int pivot_idx = left + rand() % (right - left + 1); // 得到一个[left, right]范围内的随机索引
int pivot = arr[pivot_idx];
SWAP(arr, pivot_idx, right);
// 2.设置一个partition_idx索引,指示小于pivot的元素应该插入的位置
// 同时该索引最终表示分区的界限索引,所以命名为partition_idx
int partition_idx = left;
// 3.遍历整个数组,当元素小于pivot时,将它和partition_idx位置元素交换,partition_idx加1
// 希望遍历结束时,i指向数组末尾的pivot,所以i < right
for (int i = left; i < right; i++) {
if (arr[i] < pivot) {
SWAP(arr, i, partition_idx);
partition_idx++;
}
}
// 4.遍历结束后,将pivot元素(最后一个元素)交换到partition_idx位置
SWAP(arr, right, partition_idx);
printf("此一轮分区操作,选择的pivot是: %d\n分区结束后的数组是: ", pivot);
print_arr(arr, left, right);
// 5.返回基准值的位置索引
return partition_idx;
}
/*
* 辅助函数
* 用于对对[left, right]区间中的元素进行递归分区操作
*/
void partition_recursion(int arr[], int left, int right) {
// 递归出口
if (left >= right) {
return;
}
// 递归体
int idx = partition(arr, left, right); // 分区操作,找到pivot元素的下标位置
partition_recursion(arr, left, idx - 1);
partition_recursion(arr, idx + 1, right);
}
void quick_sort_one_way(int arr[], int len) {
// 初始化随机数生成器,time(NULL)获取当前时间戳
// 用于生成随机索引
srand(time(NULL));
// 调用辅助函数进行递归分解
partition_recursion(arr, 0, len - 1);
}
int main(void) {
// 测试单向分区快速排序
int arr[] = { 8,3,2,6,9,5 };
int len = ARR_SIZE(arr);
quick_sort_one_way(arr, len);
system("pause");
return 0;
}
快速排序算法双向分区C语言实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))
// 打印数组的函数
void print_arr(int arr[], int left, int right) {
for (int i = left; i <= right; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
// 快速排序的核心操作: 双向分区, 也就是确定pivot的最终位置
// 挑选一个基准值,通过双向分区操作,决定最终的位置,最终位置就是基准值排好序的位置
static int partition(int arr[], int left, int right) {
// 1.为了简化实现,直接挑选首元素为基准值(因为基准值要交换到开头,所以直接挑选首元素作为基准值,可以减少一步交换)
int pivot = arr[left];
// 2.初始化两个索引low和high,分别指向数组两端
int low = left, high = right;
// 3.循环遍历这个数组区间
while (low < high) { // 两个索引没有相遇就继续循环
// 在两个索引没有相遇的情况下,high索引用于寻找比基准值小的元素
while (low < high && arr[high] >= pivot) {
high--;
} // while循环结束时,要么两个索引相遇了,要么high索引已经找到了一个比基准值小的元素
arr[low] = arr[high]; // 将这个比基准值小的元素覆盖到low位置
//low++; 该行语句不能加,因为若此时两个索引相遇结束while,low++将导致相遇的索引不再相遇
//
// 在两个索引没有相遇的情况下,low索引用于寻找比基准值大和相等的元素
while (low < high && arr[low] < pivot) {
low++;
} // while循环结束时,要么两个索引相遇了,要么low索引已经找到了一个比基准值大或相等的元素
arr[high] = arr[low]; // 将这个比基准值大或相等的元素覆盖到high位置
//high--; 该行语句不能加,因为若此时两个索引相遇结束while,high--将导致相遇的索引不再相遇
//
} // while循环结束时,说明low和high索引相遇,此时该位置就是pivot应该放置的位置
arr[low] = pivot;
printf("此一轮分区操作选择的pivot = %d\n", pivot);
print_arr(arr, left, right);
return low;
}
// 对[left, right]区间进行递归分区操作
void partition_recursion(int arr[], int left, int right) {
// 递归出口
if (left >= right) {
return;
}
// 递归体
int idx = partition(arr, left, right); // 分区操作,找到pivot下标位置
partition_recursion(arr, left, idx - 1);
partition_recursion(arr, idx + 1, right);
}
void quick_sort_two_way(int arr[], int len) {
partition_recursion(arr, 0, len - 1);
}
int main(void) {
int arr[] = { 8,3,2,6,9,5 };
int len = ARR_SIZE(arr);
// 测试双向分区-快速排序
quick_sort_two_way(arr, len);
system("pause");
return 0;
}
性能分析
时间复杂度:
一般提起快速排序算法,不管采取单双向哪一种分区策略,都只会说它的时间复杂度是O(nlogn)级别。
空间复杂度:
快速排序,无论什么分区策略,都不需要额外的数组空间来辅助排序,但考虑到需要使用递归来实现算法。
在最佳和平均情况下,递归深度大约是
但如果是在最坏情况下,递归深度接近n,此时空间复杂度为
稳定性:
快速排序是一种不稳定的排序算法,无论选取什么分区方案,快速排序都有可能改变两个相同元素的相对位置。
堆排序
堆排序(HeapSort)是一种高效的排序算法,它利用了堆这种数据结构来实现排序。
堆是一种特殊的完全二叉树,它满足以下性质:
- 大顶堆(最大堆):每个父节点的值都大于或等于其子节点的值(递归定义)。这意味着树的根节点(堆顶)是所有节点中的最大值。
- 小顶堆(最小堆):每个父节点的值都小于或等于其子节点的值(递归定义)。这意味着树的根节点(堆顶)是所有节点中的最小值。
在实现堆排序时,一般选择使用大顶堆。
算法思想
堆排序的过程可以分为两个主要步骤:
- 构建一个大顶堆
- 堆排序过程
下面分别解释一下这两个过程:
步骤一:构建一个大顶堆
首先,堆排序基于一个大顶堆来实现排序并不意味着需要借助额外的数据结构,而是只需要把原数组在逻辑上看成是一个大顶堆即可。
在这个过程中,数组首元素(下标为0的元素)就是完全二叉树的根结点,而一个下标为i的元素,它的两个子结点是:
- 2i + 1
- 2i + 2
比如:根结点元素下标为0,它的两个子结点就是下标为1和2的元素。而下标为1的元素,它的两个子结点就是下标为3和4的元素。
以数组[4, 10, 3, 5, 1]为例,这个数组可以构建下面一棵完全二叉树(还不是大顶堆,因为结点数据大小关系不对):

构建好这样一棵完全二叉树后,就需要调整元素的位置,使得它成为一个大顶堆。具体来说需要这么做:
从数组尾元素开始,找到第一个非叶子结点(因为叶子结点可以视为已经是大顶堆了)。遍历这个非叶子结点的左右子树,比较元素的大小关系,以决定是否需要调整元素位置。这个过程会自下而上的将一棵完全二叉树调整成为一个大顶堆,这个过程就是堆化(heapify)
注意:在堆化的过程中,一旦进行了子树结点和父节点的交换操作以后,就需要再判断以该子树结点为父节点的二叉树是否为大顶堆,从而决定是否再次进行调整!!!
那么哪一个元素是这棵树的最后一个非叶子结点(从尾元素开始的第一个非叶子结点)?
我们可以假设最后一个非叶子结点的下标是i
,它一定有左子树,且左子树的下标是2i + 1
,这个下标不能超出数组的索引界限,假如数组长度是len
,下标最大是len-1
计算过程如下:
2i + 1 <= len - 1 i <= (len - 2) / 2
最后一个非叶子结点就是取等号的位置,也就是(len - 2 ) / 2
索引位置就是数组这棵树的最后一个结点。
比如在本案例中:
- 第一个非叶子结点是下标为1的元素10,遍历它的左右子树即下标为3和4的结点,发现符合大顶堆的定义。此父结点不需要调整。
- 第二个非叶子结点就是根结点,下标为0的元素4,遍历它的左右子树即下标为1和2的结点,发现左子树的10比4大,于是交换它们。这样交换后的数组就变成了:[10, 4, 3, 5, 1]。
- 但这样不算完,下标为0的父节点和下标为1的左子树交换了元素,所以就需要再次判断下标为1的左子树是否还符合大顶堆定义,此时发现已经不符合了。所以再次调整,交换元素4和5。此时被交换元素的结点已经是叶子结点了,没有左右子树,这样整个数组才完全构建成了一个大顶堆。数组堆化结束最终为:[10, 5, 3, 4, 1]
经过上述调整,此时该数组就可以在逻辑上看成一个大顶堆。如下图所示:

这样第一步,构建大顶堆就完成了。
步骤二:堆排序过程
基于一个大顶堆,知道此时数组的首元素(也就是堆顶元素),一定是此数组的最大值,那这就好办了。
所谓堆排序的过程,也就是通过不断将堆顶元素移动到数组末尾,再重构大顶堆(重新堆化),再移动堆顶元素....重复的过程。
具体来说步骤就是:
- 将堆顶元素和堆末尾的元素交换,第一次也就是数组的首尾元素进行交换,这样数组的最大值就到了数组的末尾。于是数组就变成了[1, 5, 3, 4, 10]
- 将堆的逻辑大小减少1,数组就变成[1, 5, 3, 4]
- 重构大顶堆(重新堆化)。
- 在上述过程中,数组末尾元素被去掉了,首元素是新赋值的。所以如果新数组逻辑上不是一个大顶堆,只可能是首元素违反了规则。
- 此时只需要比较首元素和左右子树结点的大小,然后进行调整就可以重新堆化。
- 不要忘记上述过程如果出现了元素交换,那么就需要去判断被交换的左右子树是否还符合大顶堆的规则。
- 在本案例中,首先需要将元素5和元素1交换,将1交换到左子树后发现左子树不再是一个大顶堆,于是就继续交换元素1和4。最终数组重新堆化,变成了[5, 4, 3, 1]
- 重复过程1~3,直到堆的大小减少为1,堆排序结束。
这一过程,可以用下图来描述:

于是第二步堆排序过程就完成了,最终就在原地得到了一个排序好的数组。
以上两个步骤合起来,就是一个完整的堆排序过程,下面就可以基于这个思路来实现堆排序。
实现思路
上述堆排序算法在具体实现时,要分为两个步骤:
- 将待排序的原始数组,在逻辑上进行第一次堆化的操作
- 将大顶堆的根结点元素移到数组末尾,交换首尾元素,逻辑上堆大小减1,以新的根结点进行堆化操作。
这两个步骤中的核心操作逻辑都是——堆化。
于是我们把堆化这个操作,提取出一个函数,那么这个函数应该给什么参数呢?怎么写呢?
堆化的过程,其实就是自父结点开始,向下检查左右子树和这个父结点大小关系的过程:
- 如果左子树大于父结点,那么交换左子树和父结点
- 如果右子树大于父结点,那么交换右子树和父结点
- 如果出现了交换,那么被交换的左子树或右子树就要重新进行堆化操作。
- 如果根结点已经是最大值(相等的最大值也算),没有交换,那么堆化结束。
为什么这么设计堆化函数呢?为了让堆排序两个过程,都能复用这个函数:
- 在第一个步骤中,我们只需要从原数组的最后一个非叶子结点开始,将每一个非叶子结点作为父结点,逐一堆化。最终将根结点作为父结点传入,完成整个堆化过程。
- 在第二个步骤中,会将已生成的大顶堆的首尾元素交换,并将尾元素(最大值)移除,堆结点数量减1。此时如果这个堆不是大顶堆了,只可能是首元素(原本的尾元素)影响了大顶堆的性质。于是我们就以这个首元素为父结点,重新进行堆化。
那么这个堆化函数的声明就应该如下所示:
static void heapify(int arr[], int heap_len, int idx);
解释一下:
- arr参数:待堆化的原数组
- heap_len参数:逻辑上堆结点的数量。
- 一开始它就是数组的长度
- 每一轮堆排序会去掉一个最大值,逐渐减少
- 直到堆的逻辑大小减少为1,堆排序就结束了,原数组排序完成。
- idx参数:可能违反大顶堆规则的父结点的下标。
把这个核心逻辑函数分析清楚了,代码实现就很容易了。
代码实现
堆排序算法C语言实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))
#define SWAP_ELEMENT(arr, i, j){ \
int tmp = arr[i]; \
arr[i] = arr[j]; \
arr[j] = tmp; \
}
void print_arr(int arr[], int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
// 该函数会把以root_idx索引元素为根结点的
// 逻辑长度是tree_len的一棵完全二叉树arr,构建成一个大顶堆
static void heapify(int arr[], int tree_len, int root_idx) {
/*
堆化操作必然是需要循环来完成的
如果对于某个循环,既不清楚循环的次数,循环结束的条件也不太好找到
那么可以先写一个死循环, 然后具体到代码中再用break,return等结束循环
*/
while (1) {
// 根据根节点的下标,先计算出左右子树的下标
int lchild_idx = (root_idx << 1) + 1;
int rchild_idx = (root_idx << 1) + 2;
int max_idx = root_idx; // 先假设根节点就是最大值
if (lchild_idx < tree_len && arr[lchild_idx] > arr[max_idx]) {
// 如果左子树存在且左子树值比假设的最大值要大,那么左子树下标就是新的最大值下标
max_idx = lchild_idx;
}
if (rchild_idx < tree_len && arr[rchild_idx] > arr[max_idx]) {
// 如果右子树存在且右子树值比假设的最大值要大,那么右子树下标就是新的最大值下标
max_idx = rchild_idx;
}
if (max_idx != root_idx) {
// 交换左右子树较大者和根节点的值
SWAP_ELEMENT(arr, max_idx, root_idx);
// 此时max_idx结点的值就是以前根节点的值,此时由于数据发生了改变,max_idx结点的树就不一定是大顶堆了
// 所以接下来要以max_idx为根节点,继续构建大顶堆
root_idx = max_idx;
}
else {
// 不需要交换了,说明以root_idx为根节点的树已经是大顶堆了
break;
}
}
}
// 第一次将数组构建成大顶堆,自下而上将每一个非叶子结点构建大顶堆
static void first_build_heap(int arr[], int len) {
int last_idx = len - 2 >> 1; //最后一个非叶子结点的下标
for (int i = last_idx; i >= 0; i--) {
heapify(arr, len, i);
}
printf("第一次堆化后数组为: \n");
print_arr(arr, len);
}
void heap_sort(int arr[], int len) {
// 1.将原arr数组构建成大顶堆,第一次构建大顶堆
first_build_heap(arr, len);
// 2.反复移除根结点元素,然后再重建大顶堆
int heap_len = len; // 堆逻辑上的长度,一开始就是数组长度,随着反复移除重建大顶堆,这个长度会一直减少1
while (heap_len > 1) { // 只要堆还有两个元素就需要继续构建移除
SWAP_ELEMENT(arr, 0, heap_len - 1);
heap_len--;
/*
堆排序的核心操作:
重新构建大顶堆
*/
heapify(arr, heap_len, 0); // 堆排序核心操作:堆化
printf("重新构建大顶堆后: \n");
print_arr(arr, heap_len);
}
}
int main(void) {
int arr[] = { 4, 10, 3, 5, 1 };
int len = ARR_SIZE(arr);
heap_sort(arr, len);
system("pause");
return 0;
}
性能分析
时间复杂度:
堆排序在任何情况下,时间复杂度都是
空间复杂度:
堆排序显然是一个原地算法,不需要任何额外内存空间,空间复杂度是O(1)
稳定性:
堆排序是一种不稳定的排序算法,在堆化的过程需要交换父节点和左右子树结点,这个过程非常容易出现改变相同元素位置的情况。
排序算法总结
选择排序 | 冒泡排序 | 插入排序 | 希尔排序 | 归并排序 | 快速排序 | 堆排序 | ||
---|---|---|---|---|---|---|---|---|
时间复杂度 | 最佳情况 | O(n^2) | O(n) | O(n) | 小于O(n^2) | O(nlogn) | O(nlogn) | O(nlogn) |
平均情况 | O(n^2) | O(n^2) | O(n^2) | 小于O(n^2) | O(nlogn) | O(nlogn) | O(nlogn) | |
最坏情况 | O(n^2) | O(n^2) | O(n^2) | 接近O(n^2) | O(nlogn) | O(n^2) | O(nlogn) | |
空间复杂度 | O(1) | O(1) | O(1) | O(1) | O(n) | O(logn)~O(n) | O(1) | |
稳定性 | 不稳定 | 稳定 | 稳定 | 不稳定 | 稳定 | 不稳定 | 不稳定 |
- 选择排序:建议任何情况都不用。
- 冒泡排序:建议任何情况都不用。
- 插入排序:适合小数据集,尤其当数据已基本有序时非常好用。
- 希尔排序:一般不使用。
- 归并排序:大数据集的场景下,需要稳定排序算法时使用。
- 快速排序:大数据集的场景下,通用的排序算法,效率高,但不稳定。
- 堆排序:大数据集的场景下,性能均衡的不稳定排序算法,优点是不占用额外内存空间。
除此之外,我们还可以总结记忆排序算法的稳定和不稳定性的特点:
只要排序算法涉及到大范围的、远距离的元素交换,那么该算法肯定不是稳定算法,因为远距离的数据交换没办法控制元素的相对位置不变。
举例:
- 选择排序:选择最小(或最大)元素放在排序数组的起始位置,涉及远距离交换。
- 希尔排序:通过使用间隔序列,它在远距离进行元素比较和交换。
- 快速排序:通过分区操作,导致可能出现远距离的元素交换。
- 堆排序:可能出现将根结点元素一直交换到叶子结点,这是远距离交换元素。
这些排序算法,都是因为存在不相邻元素的远距离交换,所以是不稳定的
而冒泡排序、插入排序以及归并排序,由于总是交换相邻元素,它们就是稳定的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· AI Agent开发,如何调用三方的API Function,是通过提示词来发起调用的吗