目录
万字解析各大排序,带你领略你未曾了解的细节。
排序
-
排序:排序就是使一串记录按照其中某个或者某些关键字的大小,递增或者递减排序起来的操作。
稳定性 :假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。(相同元素的相对顺序不发生改变)
- 内部排序 :数据元素全部放在内存中的排序。
- 外部排序 :数据元素太多不能同时放在内存中,根据排序过程的要求不断地在内外存之间移动数据的排序。
常见排序

常见排序算法的实现
教学意义的排序
冒泡排序
冒泡排序(Bubble Sort)是一种简单的排序算法,它重复地遍历要排序的数列,以升序为例,一次比较两个元素,如果它们的顺序错误(前一个元素大于后一个元素)就把它们交换过来。遍历数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。
这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。
cpp
//冒泡排序
void BubbleSort(int* arr, int n)
{
for (int j = 0; j < n; j++)
{
// 提前退出冒泡循环的标志位
bool flag = true;
for (int i = 0; i < n - 1 - j; i++)
{
if (arr[i] > arr[i + 1])
{
Swap(&arr[i], &arr[i + 1]);
flag = false;
}
}
// flag为真,说明在这一轮排序中没有交换过,说明数组已经有序,可以提前退出
if (flag) break;
}
}
冒泡排序是一种稳定的算法。
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
选择排序
基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序数据元素排完 。

每次只选出一个最小元素效率有点低,所以一般都是一趟遍历同时选出该范围内最小或者最大的元素。
cpp
//选择排序
void SelectSort(int* arr, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin + 1; i <= end; i++)
{
//找小
if (arr[i] < arr[mini])
mini = i;
//找大
if (arr[i] > arr[maxi])
maxi = i;
}
//交换
Swap(&arr[begin], &arr[mini]);
//如果begin == maxi,记得更新下标,不然排序会错乱
if (begin == maxi)
maxi = mini;
Swap(&arr[end], &arr[maxi]);
begin++;
end--;
}
}
这里要说明下begin == maxi的情况,其实就是begin位置的元素就是循环范围内最大的元素。

Swap(&arr[begin], &arr[mini])后,maxi对应的元素被交换到mini的位置,所以必须更新maxi,不然排序就出现错误了。
选择排序是一种不稳定的算法。

Swap(&arr[begin], &arr[mini])后,元素5的相对顺序被破坏了。
- 时间复杂度:最好最坏都是O(n^2)
- 空间复杂度:O(1)
重要排序
插入排序
基本思想:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为 止,得到一个新的有序序列 。

插入排序就类似于我们打牌时整理扑克牌的操作

算法描述:
- 从第一个元素开始,假设其有序。(默认升序)
- 取出下一个元素为新元素,与已经有序的元素序列从后往前一一比较。
- 如果有序元素序列的元素大于新元素,将该元素(有序)移到下一个位置。
- 重复步骤3,直到有序元素小于或者等于新元素。
- 将新元素插入到有序元素的下一位置。
cpp
//插入排序
void InsertSort(int* arr, int n)
{
for (int i = 0; i < n - 1; i++)
{
//[0,end]有序 end+1位置的值插入[0,end],保持有序
int end = i;
int tep = arr[end + 1];
while (end >= 0)
{
if (tep < arr[end])
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
arr[end + 1] = tep;
}
}
元素集合越接近有序,直接插入排序算法的时间效率越高。
插入排序是一种稳定的算法。

tep < arr[end] 这里不能是<=,如果带了=,新元素等于有序元素时,有序元素会被挪到下一个位置,相对顺序就被破坏了。
- 时间复杂度:最好是O(n),最坏是O(n^2)
- 空间复杂度:O(1)
希尔排序
希尔排序(Shell Sort)是插入排序的一种高效版本,由 Donald Shell 在 1959 年提出。它通过引入一个"增量"的概念来改进插入排序的性能,特别是对于大范围的元素排序。
基本思想:
-
增量序列:选择一个增量序列,这个序列的元素逐渐减小到 1。常见的增量序列有 n/2、n/4、... 直到 1,其中 n 是数组的长度。
-
分组处理:按照当前增量值,将数组分成若干组。
-
对每组进行插入排序:在每组中,对元素进行插入排序,使得同一组内的元素有序。
-
缩小增量:将增量减小(通常是减半),并重复步骤 2 和 3,直到增量值为 1。
-
完成排序:当增量为 1 时,整个数组只包含一个组,此时对整个数组进行一次插入排序,完成排序过程。
所以简单来说,希尔排序是通过该增量对元素序列进行一定程度的预排序,让该元素序列变得接近有序,当增量为1时,就是进行一趟简单的插入排序;希尔排序相较于普通的插入排序,元素间的比较和移动更加分散,减少了整个数组的比较次数,从而提高了排序效率。
cpp
//希尔排序
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)
{
// +1保证最后一个gap一定是1
// gap > 1时是预排序
// gap == 1时是插入排序
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tep = arr[end + gap];
while (end >= 0)
{
if (tep < arr[end])
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tep;
}
}
}
希尔排序的时间复杂度却决于增量序列的选择,好的情况接近O(nlogn),坏的情况为O(n^2)。
希尔排序是一个不稳定的算法,原因在于预排序可能造成相同元素的相对位置改变。
- 时间复杂度:大约在O(n^1.3)
- 空间复杂度:O(1)
堆排序
堆排序利用了特殊的二叉树结构--堆(Heap)来实现。
基本思想:
- 建堆,升序建大堆,降序建小堆。
- 以升序建大堆为例,堆顶元素与最后一个元素交换,缩小堆的范围(删除最后一个元素),然后调整剩余元素使其还是一个大堆。
- 重复步骤2,直到所有元素排列完毕。
调整算法一般使用向上调整或者向下调整,这两个算法都要求根节点的左右子树都是有序的(大堆或者小堆)
cpp
//向下调整
void AdjustDown(int* arr, int size, int parent)
{
int child = 2 * parent + 1;
while (child < size)
{
if (child + 1 < size && arr[child] < arr[child + 1])
{
child++;
}
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
//堆排序
void HeapSort(int* arr, int size)
{
//向下建堆
//升序 建大堆
//降序 建小堆
for (int i = (size - 2) / 2; i >= 0; i--)
{
AdjustDown(arr, size, i);//大堆
}
int end = size - 1;
while (end > 0)
{
Swap(&arr[end], &arr[0]);
AdjustDown(arr, end, 0);
end--;
}
}
堆排序是一个不稳定的算法,原因在于向下建堆可能会破坏相同元素的相对位置
堆排序的总时间复杂度是 O(n)(构建堆)+ O(nlogn)(堆排序过程),结果是 O(nlogn)。
- 时间复杂度:最好最坏都是O(nlogn)
- 空间复杂度:O(1)
快速排序
快速排序(Quick Sort)是一种高效的排序算法,由Hoare 在 1960 年提出。它的基本思想是通过分治法(Divide and Conquer)对数据集进行排序。
基本思想:
-
选择基准值:从数列中选择一个元素作为基准值(key),通常选择首元素、末元素、中间元素或随机元素。
-
分区操作 :重新排列数列,所有比基准值小的元素放在基准前面,所有比基准值大的元素放在基准后面。在这个分区退出之后,基准值就处于数列的中间位置,称为分区点。
-
递归排序:递归地将小于基准值的子数列和大于基准值的子数列进行同样的排序操作。
-
完成排序:重复步骤2和3,直到所有子数列的长度为零或一,这时数列已经完全排序。

右边找小于基准的元素,左边找大于基准值的元素。
实现快排有几种不同的方法。
hoare法
实现步骤:
- 定义两指针L和R,分别指向元素序列的开头和结尾。
- R先出发,寻找比基准值(key)小的值。
- L后出发,寻找比基准值(key)大的值。
- 交换L和R对应的元素。
- 相遇后,将基准值与相遇位置的元素交换。

这里就有个问题,为什么相遇位置的值一定比基准值小?
左边做key,让右边先走,那相遇位置就会比key小。原因如下:
1.如果R遇上L,R走时在找比基准值小的元素,找不到,此时已经遇上了L,L位置的元素此时已经比基准值小(由于上一轮的交换)。
2.如果是L遇上R,注意,R先于L出发并且已经停住了(R找到比基准值小的元素就会停下),相遇位置的元素就比基准值小。
综上,L和R的相遇位置的元素一定比基准值小。
cpp
//快速排序 hoare
int PartSort1(int* arr, int left, int right)
{
int keyi = left;
int begin = left, end = right;
while (begin < end)
{
//右边找小
while (begin < end && arr[end] >= arr[keyi])
{
end--;
}
//左边找大
while (begin < end && arr[begin] <= arr[keyi])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
Swap(&arr[keyi], &arr[begin]);
return begin;
}
挖坑法
挖坑法就是Hoare法的优化版本,优势在于不用考虑相遇位置元素与基准值的大小。
基本思路:
- 先将基准值(key)保存,将其位置设置成一个坑位(hole)。
- R先出发,寻找比基准值小的值,将其保存在坑位,当前位置设置成新坑位。
- L后出发,寻找比基准值大的值,将其保存在坑位,当前位置设置成新坑位。
- 相遇后将关键值(key)保存在坑位即可。

cpp
//快速排序 挖坑法
int PartSort3(int* arr, int left, int right)
{
int hole = left;//坑位下标
int key = arr[hole];
int begin = left, end = right;
while (begin < end)
{
//右边找小
while (begin < end && arr[end] >= key)
{
end--;
}
arr[hole] = arr[end];
hole = end;
//左边找大
while (begin < end && arr[begin] <= key)
{
begin++;
}
arr[hole] = arr[begin];
hole = begin;
}
arr[hole] = key;
return begin;
}
由于,L先走的时候,R必定是坑位,所以L遇到R,相遇位置必定是坑位。(反之同理)
所以相遇位置必定是坑位,即left == hole == right,最后返回基准值下标hole(begin)。
前后指针法
基本思路:
- cur从左边出发,prev从cur + 1的位置出发
- cur先出发寻找比key小的值,找到后++prev,cur和prev位置的值进行交换。
- cur找到比key大的值,++cur。
解释:prev要么跟着cur,也就是prev下一个就是cur;或者prev和cur间隔一段比基准值大的区间。这样就是达到prev这个位置的元素比基准值大并交换的目的,又排除了prev元素比基准值小的情况(不会影响想要的目的)

cpp
//快速排序 前后指针
int PartSort2(int* arr, int left, int right)
{
int keyi = left;
int prev = left, cur = prev + 1;
while (cur <= right)
{
if (arr[cur] < arr[keyi] && ++prev != cur)
{
Swap(&arr[prev], &arr[cur]);
}
cur++;
}
Swap(&arr[keyi], &arr[prev]);
return prev;
}
整体实现
cpp
//快速排序 hoare
int PartSort1(int* arr, int left, int right)
{
int midi = GetMidi(arr, left, right);
Swap(&arr[left], &arr[midi]);
int keyi = left;
int begin = left, end = right;
while (begin < end)
{
//右边找小
while (begin < end && arr[end] >= arr[keyi])
{
end--;
}
//左边找大
while (begin < end && arr[begin] <= arr[keyi])
{
begin++;
}
Swap(&arr[begin], &arr[end]);
}
Swap(&arr[keyi], &arr[begin]);
return begin;
}
//快速排序 挖坑法
int PartSort3(int* arr, int left, int right)
{
int midi = GetMidi(arr, left, right);
Swap(&arr[left], &arr[midi]);
int hole = left;//坑位下标
int key = arr[hole];
int begin = left, end = right;
while (begin < end)
{
//右边找小
while (begin < end && arr[end] >= key)
{
end--;
}
arr[hole] = arr[end];
hole = end;
//左边找大
while (begin < end && arr[begin] <= key)
{
begin++;
}
arr[hole] = arr[begin];
hole = begin;
}
arr[hole] = key;
return begin;
}
//快速排序 前后指针
int PartSort2(int* arr, int left, int right)
{
int midi = GetMidi(arr, left, right);
Swap(&arr[left], &arr[midi]);
int keyi = left;
int prev = left, cur = prev + 1;
while (cur <= right)
{
if (arr[cur] < arr[keyi] && ++prev != cur)
{
Swap(&arr[prev], &arr[cur]);
}
cur++;
}
Swap(&arr[keyi], &arr[prev]);
return prev;
}
//快速排序
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
if ((right - left + 1) < 10)
{
InsertSort(arr + left, right - left + 1);
}
else
{
// [left, keyi-1] keyi [keyi+1, right]
int keyi = PartSort1(arr, left, right);
//PartSort2
//PartSort3
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi + 1, right);
}
}
快排优化
快排在平均情况下具有很好的性能,在某些情况下,性能会退化。
-
基准值选择不佳:如果总是选择极端值(如数组的第一个元素或最后一个元素)作为基准值,而数组已经有序或接近有序,这可能导致每次分区都非常不平衡。
-
数组有序或接近有序:快速排序在数组已经排序或逆序的情况下性能最差,因为每次分区只能将一个元素放到正确的位置,导致递归树的深度为 O(n),从而使时间复杂度退化到 O(n^2)。
造成快排性能退化的原因主要是传递的(left、right)区间不合理导致了递归深度过深,从而是递归的时间复杂度变大(由O(logn)退化到O(n)),最后是整体的性能退化。所以我们的优化都是优化递归的区间。
优化方法:
1.三数取中
让基准值选的不那么极端,从而导致区间分配不平衡。
cpp
//三数取中
int GetMidi(int* arr, int left, int right)
{
int midi = (left + right) / 2;
if (arr[left] < arr[midi])
{
if (arr[midi] < arr[right])
return midi;
//arr[midi] >= arr[right]
if (arr[left] > arr[right])
return left;
else
return right;
}
else//arr[left] >= arr[midi]
{
if (arr[midi] > arr[right])
return midi;
//arr[midi] <= arr[right]
if (arr[left] > arr[right])
return right;
else
return left;
}
}
2.小区间优化
理想情况下的递归可以想象成一颗满二叉树,最后一次的递归占据总递归次数的一半,所以当区间少于阈值时就直接插入排序。

非递归实现
递归实现快排还是不可避免的会遇到很多问题,如效率问题、递归深度过深造成的栈溢出问题。那我们就可以尝试就递归改成非递归(迭代)。
一般我们使用栈来实现。
cpp
//快速排序 非递归
void QuickSortNonR(int* arr, int left, int right)
{
ST st;
STInit(&st);
STPush(&st, right);
STPush(&st, left);
while (!STEmpty(&st))
{
//入栈其实相当于调用一次函数
int begin = STTop(&st);//left
STPop(&st);
int end = STTop(&st);//right
STPop(&st);
int keyi = PartSort1(arr, begin, end);
//分割区间
// [begin, keyi-1] keyi [keyi+1, end]
//先入右边再入左边
//等下先取到左边
if (keyi + 1 < end)
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
}
STDestroy(&st);
}
快排总结
快速排序是一个不稳定的算法。
- 时间复杂度:平均情况下O(nlogn),最坏的情况下会退化成O(n^2)。
- 空间复杂度:却决于递归深度,一般O(logn),坏的情况O(n)。
归并排序
基本思想:
-
分解:将原始数组分成更小的数组,直到每个小数组只有一个元素。这是通过递归实现的,每次将数组从中间分成两半。
-
定义基准情况:递归的基准情况是数组的大小为 1,此时数组已经排序,因为只有一个元素。
-
合并:将分解得到的有序小数组两两合并成更大的有序数组。这是通过比较两个数组中元素的大小,并按照顺序将它们放入新数组来实现的。
-
递归合并:重复合并步骤,直到所有的元素合并成一个有序的大数组。
-
完成排序:当所有元素都合并到一个数组中时,整个数组就完成了排序。

递归实现
基本步骤:
1.建立一个等长的临时数组,方便后序操作。
2.递归拆解区间,每次一分为二,直到区间大小为1.
3.分解得到的小数组比较合并成有序的数组,然后拷贝回去。

cpp
//子归并
void _MergeSort(int* arr, int* tmp, int begin, int end)
{
if (begin >= end)
{
return;
}
int mid = (begin + end) / 2;
// 如果[begin, mid][mid+1, end]有序就可以进行归并了
_MergeSort(arr, tmp, begin, mid);
_MergeSort(arr, tmp, mid + 1, end);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] <= arr[begin2])
{
tmp[i++] = arr[begin1++];
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
memcpy(arr + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
//归并排序
void MergeSort(int* arr, int n)
{
int* tep = (int*)malloc(sizeof(int) * n);
if(tep == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(arr, tep, 0, n - 1);
free(tep);
tep = NULL;
}
这里有个值得注意的点,分割区间是**不能以[begin, mid-1][mid, end]**这样分割的。

以[begin, mid-1][mid, end]这样分割区间 就会导致某些情况下,分割出的左区间不存在,右区间跟原来一样 ,从而导致不断递归然后栈溢出。
所以分割区间必须以**[begin, mid][mid+1, end]这样分割。**
非递归实现
不能栈模拟的原因:
这里的非递归实现就不太适合用栈去模拟实现了,因为首先需要对区间进行分割,直到区间大小为1,然后进行归并,归并这个过程是需要倒回去对各个区间(原始区间、分割出来的区间)进行处理的,用栈模拟这个过程分割区间是不可逆的 ,你没有办法获取当前区间的父区间进行归并(没有办法确定当前区间是哪个区间分割出来的,因为除法会丢数据)。

所以我们直接用迭代进行模拟实现就可以了。
使用一个gap代表归并每组的数据个数。
cpp
void MergeSortNonR(int* arr, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
// gap 每组归并数据的数据个数
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
// 1.第一组没越界,第二组越界不存在,不需要归并
// 2.第一组end1越界了,那么第二组肯定越界,不需要归并
//上面两种情况合二为一,就只需要判断第二组越界就可以了。
if (begin2 >= n)
break;
// 第二的组begin2没越界,end2越界了,需要修正一下,继续归并
if (end2 >= n)
end2 = n - 1;
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] <= arr[begin2])
{
tmp[j++] = arr[begin1++];
}
else
{
tmp[j++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
memcpy(arr + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
归并排序总结
归并排序是一个稳定的算法。

- 时间复杂度:最好最坏的都是O(nlogn)
- 空间复杂度:O(n)
非比较排序
非比较排序算法是基于数据的其他属性或次序标准 进行排序的算法,而不是基于元素之间的比较,这种排序在特定场景会很高效。
非比较排序包括:
-
计数排序(Counting Sort):
适用于整数且整数的范围不是很大。通过统计每个元素出现的次数,然后按顺序构造最终的排序结果。
-
基数排序(Radix Sort):
可以处理整数、浮点数或字符串。按照低位到高位的顺序,逐位进行排序。
-
桶排序(Bucket Sort):
适用于均匀分布的数据。将数据分配到有限数量的"桶"中,每个桶内的数据使用其他排序算法进行排序,然后按顺序合并桶中的数据。
-
位图排序:
使用位图来表示数据项的存在或不存在,然后对位图进行处理,得到排序结果。
-
排序网络(Sorting Networks):
一种硬件排序结构,通过比较-交换网络实现排序,适用于并行处理。
-
指数排序(Exponential Sort):
基于指数函数,适用于部分数据已知排序的情况。
-
鸽巢排序(Nest Sort):
一种使用"鸽巢原理"的排序方法,适用于小规模数据。
计数排序
这里我们实现个比较简单的计数排序。
计数排序的基本思想是通过键值计数来对元素进行排序,这种方法特别适用于元素值范围较小的情况。
步骤:
1.遍历原数组,确定最小值和最大值,通过最大值 - 最小值 + 1确定计数数组的大小。
2.创建计数数组,遍历原数组,统计原数组个元素的出现次数。(这是一个相对映射,假如数组中的元素是i,则count[i - min] ++,不走直接映射是为了防止空间浪费)
3.遍历计数数组,通过计数数组覆盖原数组。
cpp
//计数排序
//时间复杂度(N + Range)
//空间复杂度(range)
//适合整数,范围集中的
void CountSort(int* arr, int n)
{
//找最小值和最大值
int max, min;
max = min = arr[0];
for (int i = 0; i < n; i++)
{
if (arr[i] > max)
{
max = arr[i];
}
if (arr[i] < min)
{
min = arr[i];
}
}
int range = max - min + 1;
int* count = (int*)calloc(range,sizeof(int));
if (count == NULL)
{
perror("calloc fail");
return;
}
//统计次数
for (int i = 0; i < n; i++)
{
count[arr[i] - min]++;
}
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
arr[j++] = i + min;
}
}
free(count);
}
总结:
计数排序适用于小范围整数排序,能在O(n + range)时间内完成排序,range是整数的范围。
计数排序不适用于非整数,也不适用于range很大的数据,因为需要开辟额外的空间。
计数排序是个稳定的算法,因为统计频率是按顺序计数,按顺序覆盖原数组。
- 时间复杂度:O(n + range)
- 空间复杂度:O(range)
总结各大排序


拜拜,下期再见😏
摸鱼ing😴✨🎞
