目录
3.1 第一梯队:简单排序(易于理解,但效率较低,O(n²))
3.2 第二梯队:高效排序(基于分治思想,O(nlogn))
1. 引言:为什么排序至关重要?
想象一下,如何从成千上万的商品中快速找到最便宜的?如何在一秒内看到最新的新闻?这背后都离不开排序。排序是计算机科学中的基石算法,是数据库查询、搜索引擎、数据分析等领域的基础。
本文旨在用通俗易懂的方式,系统讲解各类经典排序算法的思想、实现,并对比其优劣,帮助读者建立清晰的知识体系。
2. 基础知识准备
时间复杂度:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。 一个算法所花费的时间与其中语句的执行次数成正比例,使用大O渐进表示法。
空间复杂度:空间复杂度是对一个算法在运行过程中临时额外占用存储空间大小的量度。 空间复杂度算的是变量的个数,使用大O渐进表示法。
稳定性:相等的值在排序前后的相对位置保持不变,则稳定,反之,则不稳定。
3. 经典排序算法详解
3.1 第一梯队:简单排序(易于理解,但效率较低,O(n²))
3.1.1 冒泡排序
基本思想:像气泡一样,两两比较,将最大/最小的元素“浮”到顶端。

代码实现:
void Swap(int* a, int* b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
void BubbleSort(int* a, int n) {
for (int i = 0; i < n - 1; i++) {
int f = 1;
for (int j = 0; j < n - i - 1; j++) {
if (a[j] > a[j + 1]) {
Swap(&a[j], &a[j + 1]);
f = 0;
}
}
if (f) break;
//f==1说明内层没有发生交换,数组已经有序,直接跳出循环
}
}
时间复杂度: O(N^2)
空间复杂度: O(1)
稳定性:稳定
3.1.2 选择排序
基本思想:每次从未排序部分“选择”最小(或最大)的元素,放到已排序序列的末尾。
改进:每次从未排序部分同时“选择”最小和最大的元素,放到已排序序列的开头和末尾。

代码实现:
void SelectSort(int* a, int n) {
int begin = 0;
int end = n - 1;
while (begin < end) {
int max = begin; int min = begin;
for (int i = begin+1; i <= end; i++) {
if (a[max] < a[i]) {
max = i;
}
if (a[min] > a[i]) {
min = i;
}
}
Swap(&a[begin], &a[min]);
if (begin == max) max =min;
//如果begin和max恰好相等,swap就会把begin位置的值和min位置的值交换,此时要令max=min
Swap(&a[end], &a[max]);
begin++; end--;
}
}
时间复杂度: O(N^2)
空间复杂度: O(1)
稳定性:不稳定
3.1.3 插入排序
基本思想:像打扑克牌理牌一样,将每个新元素插入到已排序序列的合适位置。

代码实现:
void InsertSort(int* a, int n) {
//直接插入排序
for (int i = 0; i < n - 1; i++) {
int end=i;
int tmp = a[end + 1];
while (end>=0) {
if (tmp < a[end]) {
a[end + 1] = a[end];
end--;
}
else break;
}
a[end + 1] = tmp;
}
}
时间复杂度: O(N^2)
空间复杂度: O(1)
稳定性:稳定
3.2 第二梯队:高效排序(基于分治思想,O(nlogn))
3.2.1 快速排序(重点)
3.2.1.1 递归方法
基本思想: 选取“基准”,将数组分为“小于基准”和“大于基准”的两部分,递归处理。
最被广泛使用的排序方法,C/C++中的qsort()/sort()函数也是基于快排思路实现的。
思路一:hoare版本
算法思路 :
1)创建左右指针,确定基准值
2)从右向左找出⽐基准值⼩的数据,从左向右找出⽐基准值⼤的数据,左右指针数据交换,进⼊下次循环
3)左右指针相遇后,相遇位置放入基准值,此时基准值有序,以基准值为中心,划分左右区间,对左右两个区间重复以上步骤。
图示:






代码实现:(这里重点理解代码中怎么保证左右指针相遇的位置一定小于基准值)
int _QuickSort1(int* a, int left, int right)// hoare版本
{
int keyi = left;
int begin = left;
int end = right;
while (begin < end) {
//左边作key,右边end先走,可以保证相遇位置比key小
//右边找大
while (begin < end && a[end] >= a[keyi]) {
end--;
}
//左边找小
while (begin < end && a[begin] <= a[keyi]) {
begin++;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[begin], &a[keyi]);
return begin;
}
void QuickSort(int* a, int left, int right) {//主框架
if (left >= right) return;
int keyi = _QuickSort1(a,left,right);//这里选择快排的实现方法
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
思路二:挖坑法
算法思路:创建左右指针。⾸先从右向左找出⽐基准⼩的数据,找到后⽴即放⼊左边坑中,当前位置变为新的"坑",然后从左向右找出⽐基准⼤的数据,找到后⽴即放⼊右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放⼊当前的"坑"中,返回当前"坑"下标(即分界值下标)

代码实现:(挖坑法不用考虑相遇位置一定要比基准值小,相对来说代码不那么容易犯错)
int _QuickSort2(int* a, int left, int right) {//挖坑法
int hole = left;
int begin = left;
int end = right;
while (begin< end) {
while (begin < end && a[end] >= a[hole]) {
end--;
}
Swap(&a[end], &a[hole]);
hole = end;
while (begin < end && a[begin] <=a[hole]) {
begin++;
}
Swap(&a[begin], &a[hole]);
hole = begin;
}
return hole;
}
void QuickSort(int* a, int left, int right) {//主框架
if (left >= right) return;
int keyi = _QuickSort2(a,left,right);//这里选择快排的实现方法
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
思路三:lomuto前后指针
算法思路:创建前后指针,从左往右找⽐基准值⼩的进⾏交换,使得⼩的都排在基准值的左边。

代码实现:(代码最简洁,但也最不好理解,可以参考上图例子,走读代码帮助理解)
int _QuickSort3(int* a, int left, int right) {//前后指针法
int prev = left, cur = left + 1;
int key = left;
while (cur <= right)
{
if (a[cur] < a[key] && ++prev != cur)
{
Swap(&a[cur], &a[prev]);
}
++cur;
}
Swap(&a[key], &a[prev]);
return prev;
}
void QuickSort(int* a, int left, int right) {//主框架
if (left >= right) return;
int keyi = _QuickSort3(a,left,right);//这里选择快排的实现方法
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
思考:上述的几种思路基准值都是直接取当前区间范围最左边的值,试想当前有一个降序数组,例如[9,8,7,6,5,4,3,2,1],我们要将其排成升序数组,那么快排的效率会非常低,时间复杂度会降到O(N^2),

为了使递归深度尽可能接近log n,每次取的基准值要尽可能不大也不小,靠近中间才行,由此引申出了两种解决办法
1,随机取key
2,三数取中
第一种方法很好理解,就是每次在数组中随机取一个基准值。
本文重点介绍第二种方法:三数取中
思路:数组最左最右最中间,返回中间大的那个值的下标
代码实现:
int GetMid(int* a, int left, int right) {//三数取中
int mid = left + (right - left) / 2;
if (a[left] < a[mid]) {
if (a[mid] < a[right]) return mid;
else if (a[right] < a[left]) return left;
else return right;
}
else {
if (a[right] < a[mid]) return mid;
else if (a[right] > a[left]) return left;
else return right;
}
}
以挖坑法为例:(交换a[mid]和a[left],hole依旧等于left,不影响已经写好的代码)
int _QuickSort2(int* a, int left, int right) {//挖坑法
int midi = GetMid(a, left, right);
Swap(&a[midi], &a[left]);
int hole = left;
int begin = left;
int end = right;
while (begin< end) {
while (begin < end && a[end] >= a[hole]) {
end--;
}
Swap(&a[end], &a[hole]);
hole = end;
while (begin < end && a[begin] <=a[hole]) {
begin++;
}
Swap(&a[begin], &a[hole]);
hole = begin;
}
return hole;
}
依旧存在的缺陷:递归要创建函数栈帧(不了解栈帧的同学可以简单理解成调用函数所占用的一段内存空间),递归深度太深,可能导致栈溢出(内存不够了),为了降低递归深度,引申出了一种解决办法:
小区间优化
思路:我们设定如果数组待排序区间长度小于10(这个值不要太小也不要太大),调用其他排序算法进行排序。
代码实现:
void QuickSort(int* a, int left, int right) {//主框架
if (left >= right) return;
//小区间优化,不再递归分割,减少递归次数,可以大幅节省函数
//栈帧占用空间,也可以提升一点运行效率
if ((right - left + 1) < 10) {
InsertSort(a + left, right - left + 1);
//这里调用的是插入排序,当数据量小的时候,插入排序是比较简单快速的
}
else {
int keyi = _QuickSort3(a,left,right);//这里选择快排的实现方法
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
3.2.1.2 非递归方法
思路:借助栈,利用后进先出的特性,基准值有序后,先将右区间下标入栈,再将左区间下标入栈,下次循环先出栈两次,取得区间范围,进行排序,再重复以上过程(类似于二叉树层次遍历的过程)
代码实现:(因为是c语言,所以用了自己手撕的栈,建议学过栈的再看)
void QuickSortNonR(int* a, int left, int right) {//非递归实现快排,借助栈
ST s1;
STInit(&s1);//栈初始化
STPush(&s1, right);//入栈
STPush(&s1, left);
while (!STEmpty(&s1)) {//判断栈不为空
int begin = STTop(&s1);//取栈顶元素
STPop(&s1);//出栈
int end = STTop(&s1);
STPop(&s1);
int keyi = _QuickSort3(a, begin, end);
if (keyi + 1 < end) {
STPush(&s1, end);
STPush(&s1, keyi+1);
}
if (keyi - 1 > begin) {
STPush(&s1, keyi-1);
STPush(&s1, begin);
}
}
STDestroy(&s1);//销毁栈
}
时间复杂度: O(nlogn)
空间复杂度: O(logn)
稳定性:不稳定
那么第一期的内容就到这里了,觉得有收获的同学们可以给个点赞、关注、收藏哦,谢谢大家。





