跳到主要内容 常用排序算法详解:冒泡、快速、归并与计数 | 极客日志
C++ 算法
常用排序算法详解:冒泡、快速、归并与计数 四种常用排序算法:冒泡排序、快速排序、归并排序和计数排序。内容包括各算法的基本思想、详细步骤、递归与非递归实现代码、性能分析(时间复杂度、空间复杂度、稳定性)以及适用场景。重点讲解了快速排序的三数取中优化、非递归实现原理,以及计数排序的空间限制。旨在帮助读者深入理解排序算法的核心逻辑与工程实践。
云间运维 发布于 2026/3/30 更新于 2026/4/13 0 浏览2.5 冒泡排序
基本思路 :
冒泡排序的基本思路是通过重复遍历待排序的数列,比较每对相邻元素的值,若发现顺序错误则交换它们的位置。这个过程重复进行,直到没有需要交换的元素为止,此时数列就完成了排序。冒泡排序的名字是因为较小的元素会逐渐'冒泡'到数列的顶端,即前面,而较大的元素则会沉到数列的底部,即后面。
冒泡排序的基本步骤如下:
比较相邻的元素。如果第一个比第二个大,就交换它们两个。
对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
针对所有的元素重复以上的步骤,除了最后一个。
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
冒泡排序的时间复杂度在最坏情况下是 O(n^2),其中 n 是数列的长度。尽管冒泡排序不是最高效的排序算法,但它的实现简单,对于小数据量的排序问题还是相当实用的。
动图展示:
代码实现:
void BubbleSort (int * a, int n) {
for (int i = 1 ; i < n - 1 ; i++) {
int flag = 0 ;
for (int j = 0 ; j < n - i; j++) {
if (a[j] > a[j + 1 ]) {
Swap (&a[j], &a[j + 1 ]);
flag = 1 ;
}
}
if (flag == 0 ) {
break ;
}
}
}
程序中的 flag 变量用于优化算法效率。冒泡排序最坏时间复杂度是 O(N^2),但在数组相对有序的情况下,可以通过判断是否发生交换来提前结束循环。定义一个 flag 变量初始化为 0,如果发生交换则置为 1。若某次遍历后 flag 仍为 0,说明数组已有序,立即结束程序。这样在数组有序时,时间复杂度可降至 O(N)。
2.6 快速排序
快速排序是 Hoare 于 1962 年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后对左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
2.6.1 快速排序思路
快速排序(Quick Sort)是一种高效的排序算法,它使用分治策略来对一个数组进行排序。以下是快速排序的基本思路:
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown 转 HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
HTML 转 Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
选择基准 :从数组中选择一个元素作为基准(key)。这个基准可以是数组中的第一个元素、最后一个元素、中间元素,或者通过某种方式随机选择的元素。
分区 :重新排列数组,使得所有小于基准的元素都位于基准的左侧,所有大于基准的元素都位于基准的右侧。这个操作称为分区(partition)。
递归排序 :对基准左侧和右侧的子数组分别进行快速排序。这是一个递归的过程,直到子数组的大小为零或一(此时子数组已经是有序的)。
合并 :由于快速排序是原地排序,且通过递归已经保证了每个子数组的有序性,所以最终整个数组也会是有序的,不需要额外的合并步骤。
详细步骤
初始化 :设定数组的起始位置 left 和结束位置 right。
选择基准 :选择数组中的一个元素作为基准,通常选择 array[right](也可以选择其他元素)。
分区 :
初始化一个指针 i,使其指向数组的起始位置 low。
遍历数组,从 left 到 right-1:
如果 array[i] <= key,则继续向右移动 i。
如果 array[i] > key,则交换 array[i] 和 array[right-1](或者任何一个大于 key 的未处理元素的位置),并将 right-1 向左移动一位(因为该位置已经处理过)。
最后,将基准元素 key 放到正确的位置上,即 array[i] 和 array[right] 之间(通常是通过交换 array[i] 和 array[right] 来完成)。
递归排序 :对基准左侧的子数组(left 到 i-1)进行快速排序;对基准右侧的子数组(i+1 到 right)进行快速排序。
结束条件 :当子数组的大小为零或一时,递归结束,数组已经有序。
2.6.2 快速排序递归实现 到现在为止,我们已经了解了快速排序的大致思路,先来看递归实现的快速排序:
void Swap (int * p1, int * p2) {
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
int GetMiddle (int * a, int left, int right) {
int mid = (left + right) / 2 ;
if (a[left] < a[mid]) {
if (a[right] > a[mid]) {
return mid;
} else if (a[right] > a[left]) {
return right;
} else {
return left;
}
} else
{
if (a[right] > a[left]) {
return left;
} else if (a[right] > a[mid]) {
return right;
} else {
return mid;
}
}
}
void QuickSort (int * a, int left, int right) {
if (left >= right) {
return ;
}
if ((left + right + 1 ) < 10 ) {
InsertSort (a + left, left + right + 1 );
} else {
int mid = GetMiddle (a, left, right);
Swap (&a[left], &a[mid]);
int keyi = left;
int begin = left, end = right;
while (begin < end) {
while (begin < end && a[end] >= a[keyi]) {
end--;
}
while (begin < end && a[begin] <= a[keyi]) {
begin++;
}
Swap (&a[begin], &a[end]);
}
Swap (&a[keyi], &a[begin]);
keyi = begin;
QuickSort (a, left, keyi - 1 );
QuickSort (a, keyi + 1 , right);
}
}
int a[] = { 3 ,5 ,8 ,6 ,9 ,7 ,4 ,1 ,2 ,0 ,10 };
注意:此程序中的插入排序和三数取中算法都是为了优化快速排序,使它拥有更好的效率,快速排序的核心逻辑代码为:
void Swap (int * p1, int * p2) {
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void QuickSort (int * a, int left, int right) {
if (left >= right) {
return ;
}
int keyi = left;
int begin = left, end = right;
while (begin < end) {
while (begin < end && a[end] >= a[keyi]) {
end--;
}
while (begin < end && a[begin] <= a[keyi]) {
begin++;
}
Swap (&a[begin], &a[end]);
}
Swap (&a[keyi], &a[begin]);
keyi = begin;
QuickSort (a, left, keyi - 1 );
QuickSort (a, keyi + 1 , right);
}
2.6.3 快速排序非递归 既然可以使用递归实现快速排序,为什么还要使用非递归实现呢?
快排非递归的优势 快速排序非递归实现的重要性主要体现在以下几个方面:
避免递归调用开销 :递归实现虽然直观易懂,但在一些编程语言中,递归调用会引入额外的函数调用栈的使用和维护开销。非递归实现通过显式地使用栈来管理状态,可以有效避免这些开销,从而提高性能。
防止栈溢出 :在极端情况下,递归实现可能导致栈溢出,尤其是在处理大规模数据时。非递归实现使用显式的数据结构(如栈)来管理状态,不依赖于系统的调用栈,从而避免了栈溢出的风险。
降低空间复杂度 :递归实现可能需要更多的内存空间,因为每个递归调用都需要在内存中保留一些信息。非递归实现通常使用更少的内存,只需维护一些必要的状态信息,有助于减少内存使用。
提升性能 :在某些编程语言和环境中,递归调用的性能可能不如循环,因为每个递归调用都需要函数调用的开销。非递归实现可以更好地与一些编译器和优化器协同工作,从而提高性能。
便于优化 :非递归实现更容易进行一些优化,例如通过使用迭代而不是递归的方式来访问数组,以更好地利用 CPU 缓存。这种优化可以进一步提升算法的执行效率。
适应特定场景 :在一些对递归深度有限制的环境(如嵌入式系统)中,非递归实现是必须的,以确保算法能够正常运行。
非递归思路 快速排序的非递归思路主要是通过手动管理一个栈来模拟递归过程中的函数调用栈,从而实现对数组的排序。以下是快速排序非递归思路的详细步骤:
初始化栈 :创建一个空栈,用于保存接下来需要排序的子数组的边界。这个栈可以是任意类型的栈结构,但通常使用整型栈来保存子数组的起始索引(left)和结束索引(right)。
将整个数组的起始和结束索引入栈 :这一步相当于递归排序中的初始调用。将数组的起始索引(left)和结束索引(right)作为一对入栈,表示接下来需要处理整个数组。
循环处理栈中的子数组边界 :不断从栈中弹出子数组的边界索引(一对),然后对这个子数组进行快速排序的单趟排序。
单趟排序 :在单趟排序中,首先选择一个元素作为基准(pivot)。基准的选择可以是子数组的第一个元素,也可以通过其他策略(如三数取中法)来选择。进行分区操作,将子数组划分为比基准小的左侧部分和比基准大的右侧部分,并确定基准元素的最终位置。
处理分区后的子数组 :分区操作完成后,基准元素左侧的子数组(如果存在)和右侧的子数组(如果存在)可能还需要继续排序。如果左侧子数组有多个元素,则将其起始和结束索引作为一对入栈。如果右侧子数组有多个元素,也将其起始和结束索引作为一对入栈。
重复步骤 3 和步骤 5 :继续迭代该过程,直到栈为空。此时,所有的子数组都已经被正确排序,整个数组也就完成了排序。
注意事项
在单趟排序中,分区操作是关键步骤,它决定了基准元素的最终位置,并将数组划分为两个子数组。
栈的使用是非递归快速排序的核心,它模拟了递归调用栈的功能,允许我们手动管理子数组的排序顺序。
非递归快速排序的性能与递归快速排序相近,但在某些情况下(如递归深度过大导致栈溢出)可能更加稳定可靠。
总结:通过以上步骤,我们可以实现快速排序的非递归版本,从而在处理大规模数据时避免递归调用栈的限制。
由于 C 语言没有 STL,我们使用 C++ 来实现:
int GetMiddle (int * a, int left, int right) {
int mid = (left + right) / 2 ;
if (a[left] < a[mid]) {
if (a[right] > a[mid]) {
return mid;
} else if (a[right] > a[left]) {
return right;
} else {
return left;
}
} else
{
if (a[right] > a[left]) {
return left;
} else if (a[right] > a[mid]) {
return right;
} else {
return mid;
}
}
}
int PartSort1 (int * a, int left, int right) {
int midi = GetMiddle (a, left, right);
Swap (&a[left], &a[midi]);
int keyi = left;
int begin = left, end = right;
while (begin < end) {
while (begin < end && a[end] >= a[keyi]) {
--end;
}
while (begin < end && a[begin] <= a[keyi]) {
++begin;
}
Swap (&a[begin], &a[end]);
}
Swap (&a[keyi], &a[begin]);
return begin;
}
void QuickSortNonR (int * a, int left, int right) {
stack<int > st;
st.push (right);
st.push (left);
while (!st.empty ()) {
int begin = st.top ();
st.pop ();
int end = st.top ();
st.pop ();
int keyi = PartSort1 (a, begin, end);
if (keyi + 1 < end) {
st.push (end);
st.push (keyi + 1 );
}
if (begin < keyi - 1 ) {
st.push (keyi - 1 );
st.push (begin);
}
}
}
快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序。
时间复杂度:O(N*logN)。
总结 :快速排序是一种非常高效的排序算法,尤其适用于大部分元素已经有序的情况。通过合理选择基准和优化递归过程,可以进一步提高其性能。
2.7 归并排序
基本思想: 归并排序(Merge Sort)是一种分治策略的排序算法,其基本思想是将一个序列分为两个较小的子序列,直到子序列的大小为 1,然后将已排序的子序列合并成一个大的有序序列。以下是归并排序的完整思路:
一、算法步骤
分割 :
将待排序的数组分成两半,如果数组长度为奇数,则其中一部分会比另一部分多一个元素。
对这两部分数组继续递归地进行分割,直到子数组的长度为 1,此时可以认为每个子数组都是有序的(因为只有一个元素)。
治理(解决) :
这一步在归并排序中实际上是通过分割步骤隐含完成的。当子数组的长度为 1 时,它们自然就是有序的,无需进一步处理。
合并 :
将相邻的有序子数组合并成一个有序数组,直到合并为 1 个完整的数组。
合并过程中,通过比较两个子数组的元素,按大小顺序依次放入新的数组中,从而实现排序。
二、具体实现
申请空间 :创建一个临时数组,用于存放合并后的子数组。
设定指针 :在两个待合并的子数组上分别设置指针,初始时都指向子数组的起始位置。
比较合并 :
比较两个指针所指向的元素,将较小的元素放入临时数组,并移动该指针。
重复此过程,直到某个子数组的所有元素都被复制到临时数组中。
将另一个子数组中剩余的元素(如果有的话)直接复制到临时数组的末尾。
复制回原数组 :将临时数组中的元素复制回原数组,以替换原来的子数组。
归并排序递归实现: void _MergeSort(int * a, int * tmp, int begin, int end) {
if (begin >= end) {
return ;
}
int mid = (begin + end) / 2 ;
_MergeSort(a, tmp, begin, mid);
_MergeSort(a, tmp, mid + 1 , end);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1 , end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[i++] = a[begin1++];
} else {
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1) {
tmp[i++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[i++] = a[begin2++];
}
memcpy (a + begin, tmp + begin, (end - begin + 1 ) * sizeof (int ));
}
void MergeSort (int * a, int n) {
int * tmp = (int *)malloc (sizeof (int ) * n);
if (tmp == NULL ) {
perror ("malloc fail" );
}
_MergeSort(a, tmp, 0 , n - 1 );
free (tmp);
tmp = NULL ;
}
此程序涉及在堆上申请资源,所以我们分两个接口实现。
归并排序非递归实现:
void MergeSortNonR (int * a, int n) {
int * tmp = (int *)malloc (sizeof (int ) * (n + 1 ));
if (tmp == NULL ) {
perror ("malloc fail" );
return ;
}
int gap = 1 ;
while (gap <= n) {
for (int j = 0 ; j <= n; j += gap * 2 ) {
int begin1 = j, end1 = j + gap - 1 ;
int begin2 = j + gap, end2 = j + gap * 2 - 1 ;
if (begin2 > n) {
break ;
}
if (end2 > n) {
end2 = n;
}
int i = j;
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[i++] = a[begin1++];
} else {
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1) {
tmp[i++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[i++] = a[begin2++];
}
memcpy (a + j, tmp + j, sizeof (int ) * (end2 - j + 1 ));
}
gap *= 2 ;
}
free (tmp);
tmp = NULL ;
}
int a[] = { 3 ,5 ,7 ,1 ,2 ,0 ,9 ,10 ,4 ,8 ,11 };
三、性能分析
时间复杂度 :归并排序的时间复杂度为 O(n log n),其中 n 是数组的长度。在每一层递归中,合并操作的时间复杂度为 O(n),而递归的层数为 log n,因此总的时间复杂度为 O(n log n)。
空间复杂度 :归并排序的空间复杂度也是 O(n),因为需要额外的空间来创建临时数组。
稳定性 :归并排序是稳定的排序算法,即相等的元素在排序后的顺序与它们在原数组中的顺序相同。
总结 :归并排序因其稳定的排序特性和较好的平均性能,在实际应用中非常广泛。特别是在数据量较大时,归并排序能够展现出其高效的排序能力。
2.8 计数排序
计数排序概念: 计数排序(Counting Sort)是一种非基于比较的排序算法,由 Harold H. Seward 在 1954 年提出。它适用于待排序元素为整数且范围较小的情况。计数排序的基本思想是统计每个元素的出现次数,然后利用这些信息将原始序列重新组合成有序序列。
一、计数排序的原理与步骤
统计元素出现次数 :
遍历待排序的数组,统计每个元素出现的次数。这通常通过创建一个辅助数组(计数数组)来实现,该数组的长度等于待排序数组中元素的最大值加 1(如果元素是非负整数)。
前缀和操作 :
对计数数组进行前缀和操作,使得每个元素的值变为小于等于该元素的值的元素总数。这样,计数数组的每个位置就对应了原数组中元素在排序后数组中的位置。
重建有序数组 :
遍历待排序数组,根据计数数组的信息,将元素放回其在有序数组中的正确位置。同时,更新计数数组中对应元素的值,以确保相同元素的相对顺序不变。
二、计数排序的示例 假设我们有一个待排序的数组 arr = [4, 2, 2, 8, 3, 3, 1],我们可以按照计数排序的步骤对其进行排序:
统计元素出现次数 :
遍历数组 arr,得到计数数组 count = [0, 1, 2, 2, 2, 0, 1, 1](假设数组中的元素都是非负整数,且最大值不超过 7)。
前缀和操作 :
对计数数组进行前缀和操作,得到 count = [0, 1, 3, 5, 7, 7, 7, 8]。
重建有序数组 :
遍历数组 arr,根据计数数组的信息将元素放回有序数组中的正确位置。排序后的数组为 sorted_arr = [1, 2, 2, 3, 3, 4, 8]。
计数排序代码实现: void CountSort (int * a, int n) {
int max = a[0 ], min = a[0 ];
for (int i = 0 ; i < n; i++) {
if (a[i] > max) {
max = a[i];
}
if (a[i] < min) {
min = a[i];
}
}
int range = max - min + 1 ;
int * count = (int *)calloc (range, sizeof (int ));
if (count == NULL ) {
perror ("calloc fail" );
}
for (int i = 0 ; i < n; i++) {
count[a[i] - min]++;
}
int j = 0 ;
for (int i = 0 ; i < range; i++) {
while (count[i]--) {
a[j++] = i + min;
}
}
}
int a[] = { 2 ,4 ,76 ,87654 ,98 ,12 ,76 ,454 ,23 ,98 };
虽然我们的排序成功了,但是这个程序内部开辟了非常多的空间,所以当一组数的最大值和最小值相差太多时,不建议使用计数排序。
三、注意事项
在使用计数排序时,需要确保待排序数组中的元素都是非负整数或可映射到非负整数范围内。如果待排序的元素不满足这个要求,就需要对其进行映射转换。
当待排序元素的范围非常大时,计数排序可能会消耗大量的内存空间,因此在实际应用中需要注意这一点。
四、计数排序的特点
时间复杂度 :
计数排序的时间复杂度为 O(n+k),其中 n 是待排序数组的长度,k 是待排序数组中元素的范围。当 k 不是很大时,计数排序的时间复杂度接近线性,快于任何基于比较的排序算法(如快速排序、归并排序等,它们的时间复杂度在理论上的下限是 O(n log n))。
空间复杂度 :
计数排序的空间复杂度为 O(k),其中 k 是待排序数组中元素的范围。因此,计数排序是一种牺牲空间换取时间的排序算法。
稳定性 :
计数排序是稳定的排序算法,即相等的元素在排序后的序列中保持它们原有的先后顺序。
适用场景 :
计数排序特别适用于整数排序,特别是当整数的范围不是很大时。对于非整数类型的数据或整数范围非常大的情况,计数排序可能不适用或效率较低。