
在数据结构广阔的领域里,排序是一项基础且核心的操作,它就像是数据世界的 “整理大师”,能将杂乱无章的数据变得井然有序 。在日常生活里,排序的概念也随处可见。比如图书馆中,书籍会按照类别、作者或者出版时间等规则排列,方便读者快速找到所需书籍;电商平台上,商品会依据销量、价格或者用户评价进行排序,帮助消费者更高效地筛选商品。
而在实际应用场景中,排序更是发挥着关键作用。在数据库系统中,对数据进行排序可以加快查询速度,提高数据检索的效率;在搜索引擎中,通过对搜索结果进行排序,能够将最相关、最有价值的信息呈现给用户。可以说,排序是数据处理和分析的基石,它为后续更复杂的数据操作和应用奠定了坚实的基础。接下来,就让我们深入探索排序的世界,领略各种排序算法的魅力与奥秘。
排序,就是将一组杂乱无章的记录序列,按照特定的规则调整为有序序列的操作。这里的 “规则”,通常是依据记录中的某个关键字(或字段)的大小关系来确定
在排序的世界里,稳定性是一个重要的概念。简单来讲,稳定排序就是在排序过程中,能够保证那些具有相同关键字的记录,它们的相对次序在排序前后保持不变。例如,有一个序列[2, 3a, 3b, 4, 5],其中3a和3b关键字相同,在经过稳定排序后,这个序列可能变为[2, 3a, 3b, 4, 5],3a仍然在3b之前,这就是稳定排序 。而不稳定排序则可能会改变相同关键字记录的相对次序,同样对于上述序列,经过不稳定排序后,可能得到[2, 3b, 3a, 4, 5],3a和3b的相对位置发生了变化。
为了更直观地理解,以后续将要讲解的冒泡排序和快速排序为例。冒泡排序是稳定排序算法,它在比较相邻元素时,如果两个元素相等,就不会交换它们的位置,所以能保证相同元素的相对顺序不变。而快速排序是不稳定排序算法,在其划分过程中,可能会把相同关键字的元素分在不同的子序列,从而导致它们的相对顺序在排序后发生改变。
根据排序过程中数据存储的位置,排序可分为内部排序和外部排序。内部排序是指在排序期间,所有参与排序的数据元素都全部存放在内存中,排序操作直接在内存中完成,无需借助外部存储设备 。像我们后续即将深入探讨的冒泡排序、插入排序、快速排序等,都属于内部排序。
与之相对的是外部排序,当数据量极其庞大,无法一次性全部装入内存时,就需要借助外部存储设备(如硬盘)来辅助完成排序操作。在排序过程中,数据会不断地在内存和外存之间进行移动。典型的外部排序算法如多路归并排序,它通过多次归并操作,逐步将大量数据排序 。在实际应用中,当处理海量数据时,就需要考虑使用外部排序算法来应对内存不足的问题。而本文,我们主要聚焦于内部排序算法的学习与探讨。

基本思想:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。

实际中我们在玩扑克牌是就用到了插入排序的思想
当插入第 i (i>=1) 个元素时,前面的 array[0],array[1],…,array[i-1] 已经排好序,此时用array[i] 的排序码与 array[i-1],array[i-2],… 的排序码顺序进行比较,找到插入位置 即将 array[i] 插入,原来位置上的元素顺序后移。
简单来说,插入排序就像是玩扑克牌时整理手中牌的过程 。它将数组分为已排序和未排序两部分,初始时已排序部分只有一个元素(即数组的第一个元素)。然后从第二个元素开始,将未排序部分的元素依次插入到已排序部分的合适位置,就像将新拿到的扑克牌插入到手中已整理好的牌的合适位置一样 。具体来说,对于当前要插入的元素,从已排序部分的末尾开始向前比较,如果已排序部分的元素大于要插入的元素,就将该元素向后移动一位,为要插入的元素腾出位置,直到找到合适的位置插入。
//1)直接插入排序
void InsertSort(int* arr, int n)
{
//i控制end是有序数列的最后一个
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = arr[end + 1];//把乱序序列的数据插入到有序序列
//tmp和有序序列里面的数据一个一个比较
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + 1] = arr[end];
end--;
}
else {
break;
}
}
arr[end + 1] = tmp;
}
}元素越接近有序,直接插入排序的时间效率越高
插入排序是稳定的排序算法 。在插入过程中,当遇到相等的元素时,新元素会插入到相等元素的后面,不会改变它们的相对顺序。
插入排序适用于部分有序的数据 。如果数组中大部分元素已经有序,那么插入排序的效率会相对较高,因为它只需要对少数无序元素进行插入操作 。同时,当数据量较小时,插入排序的简单实现和较低的时间复杂度开销也使其成为一个不错的选择 。比如在一些实时性要求较高但数据量不大的场景中,插入排序可以快速完成排序任务 。
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数(通常是gap =n/3+1),把待排序文件所有记录分成各组,所有的距离相等的记录分在同一组内,并对每一组内的记录进行排序,然后gap=gap/3+1得到下一个整数,再将数组分成各组,进行插入排序,当gap=1时,就相当于直接插入排序。它是在直接插入排序算法的基础上进行改进而来的,综合来说它的效率是要高于直接插入排序算法的。

希尔排序特性总结:
//2)希尔排序
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)//预排序
{
gap = gap / 3 + 1;
//gap == 1 直接插入排序
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = arr[end + gap];
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else {
break;
}
}
arr[end + gap] = tmp;
}
}
}希尔排序是不稳定排序,因为相同的元素可能被分到不同的子序列中,导致相同元素的相对位置可能在排序中发生变化。
基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
简单来说,就是将数组分为已排序和未排序两部分。在每一轮中,从未排序的部分中选择最小(或最大)的元素,然后将其与未排序部分的第一个(最后一个)元素交换位置,这样每一轮都能确定一个元素的最终位置,直到整个数组都被排序。
//直接选择排序
void SelectSort(int* arr, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin;
int maxi = begin;
for (int i = begin; i <= end; i++)
{
if (arr[i] < arr[mini])
{
mini = i;
}
if (arr[i] > arr[maxi])
{
maxi = i;
}
}
Swap(&arr[mini], &arr[begin]);
Swap(&arr[maxi], &arr[end]);
//先交换最小值与begin,要考虑最大值在begin的情况
//先交换最大值与end,要考虑最小值在end的情况
if (maxi == begin)
{
maxi = mini;
}
begin++;
end--;
}
}时间复杂度:O(n^2)
空间复杂度:O(1),属于原地排序算法
选择排序是不稳定的排序算法,比如,当找到的最大元素与 end 所在位置的元素相同是,交换后相同元素的相对位置就发生了变化。
由于选择排序的时间复杂度较高,不适用于大规模数据的高效排序。但在一些对内存开销敏感,而对排序效率要求不高的场景下,它仍然可以发挥作用 。比如当内存资源非常有限,只能使用少量额外空间时,选择排序的原地排序特性使其成为一个可选方案;或者在一些简单的应用场景中,数据量较小且对排序时间没有严格要求时,也可以使用选择排序。
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。每次从堆顶取出最大(大顶堆)或最小(小顶堆)元素,将其与堆的最后一个元素交换位置,然后对除最后一个元素外的剩余元素重新调整堆,使其仍然满足堆的性质 。不断重复这个过程,直到堆中只剩下一个元素,此时数组就有序了。
//2)堆排序(要用到向上调整算法或向下调整算法)
void HeapSort(int* arr, int n)
{
//小堆:降序
//大堆:升序
//向下调整算法建堆(n)
2^h-1=n h=log2(n+1) 所以时间复杂度为logn
//越往上走,节点个数越多,调整次数越少
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, i, n);
}
//向上调整算法建堆(n*logn)
//越往下走,节点个数越多,调整次数越多
//for (int i = 1; i < n; i++)
//{
// AdjustUp(arr, i);
//}
//n*logn
int end = n - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
//交换后,必须使用向下调整算法保持堆的有序性
AdjustDown(arr, 0, end);
end--;
}
}
//交换
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//向上调整算法(log n)
void AdjustUp(int* arr, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
child = parent;
parent = (child - 1) / 2;
}
else {
break;
}
}
}
//向下调整算法(log n)
void AdjustDown(int* arr, int parent, int n)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && arr[child] < arr[child + 1])
{
child++;
}
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else {
break;
}
}
}时间复杂度:
分析向上调整算法建堆和向下调整算法建堆的时间复杂度
空间复杂度:O(1),属于原地排序算法
堆排序是不稳定排序。
[2, 2, 1],构建大顶堆后为 [2, 2, 1],第一次交换堆顶(第一个 2)与末尾(1),得到 [1, 2, 2],破坏了原相对顺序。基本思想:
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动
1. 原理
冒泡排序是一种简单直观的交换排序算法 。它如同水中的气泡,大的气泡会逐渐 “浮” 到水面,而在冒泡排序中,较大的元素会通过不断比较相邻元素并交换位置,逐步 “冒泡” 到数组的末尾
从数组的第一个元素开始,依次比较相邻的两个元素,如果前一个元素大于后一个元素(假设是升序排序),就交换它们的位置。这样,每一轮比较都会将当前未排序部分的最大元素 “冒泡” 到当前未排序部分的末尾。然后进行下一轮比较,此时由于上一轮已经将一个最大元素放到了末尾,所以下一轮比较的范围就可以减少一个元素 。不断重复这个过程,直到没有任何一对相邻元素需要交换,这就意味着数组已经完全有序。
2. 代码实现
//1)冒泡排序
void BubbleSort(int* arr, int n)
{
for (int i = 0; i < n - 1; i++)
{
int exchange = 0;
for (int j = 0; j < n - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
exchange = 1;
Swap(&arr[j], &arr[j + 1]);
}
}
if (exchange == 0)
{
break;
}
}
}时间复杂度:最好的情况:O(n);最坏的情况:O(n^2);平均情况:O(n^2)
空间复杂度:冒泡排序是原地排序算法,它只需要常数级别的额外空间,用于临时存储交换的元素,所以空间复杂度为O(1)
冒泡排序是稳定的排序算法 。这是因为在比较相邻元素时,如果两个元素相等,冒泡排序不会交换它们的位置,从而保证了相等元素的相对顺序在排序前后保持不变。
由于冒泡排序的时间复杂度较高,在大规模数据排序时效率较低,但它在一些特定场景下仍有应用价值 。比如当数据规模较小,此时O(n^2)的时间复杂度影响不大,而且其实现简单,代码量少;或者当数据基本有序时,冒泡排序可以很快完成排序,因为只需要少量的比较和交换操作。
快速排序是一种基于分治思想的排序算法,它的核心步骤如下:
//快速排序
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
//找基准值(有多个方法)
//_QuickSort⽤于按照基准值将区间[left,right]中的元素进⾏划分
int keyi = _QuickSort(arr, left, right);
//左序列[left,keyi-1]
//右序列[keyi+1,right]
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi + 1, right);
}找基准值的函数方法:
1)hoare 版本
思路
//hoare版本
int _QuickSort(int* arr, int left, int right)
{
int keyi = left;
left++;
//left找比基准值大的,right找比基准值小的
//虽然有两个while循环,但是一个从左往右,一个从右往左,只循环了一遍
while (left <= right)
//1)如果left==right时,相遇值要比基准值要大,right要继续--
//2)如果left==right时,相遇值要比基准值要小,right不用动
//这也是为什么left==right不跳出循环的原因
{
//right:从右往左找比基准值小的,小的往左放
while (left <= right && arr[right] > arr[keyi]) //找到的其实是小于等于key的值
//arr[right]和arr[keyi]不能是>=,必须是>
//因为当数组值全都为一个相同的值时,加=,基准值每次都是数组下标为0的数,时间复杂度会变为O(n^2)
{
right--;
}
//left:从左往右找比基准值大的,大的往左放
while (left <= right && arr[left] < arr[keyi]) //找到的其实是大于等于key的值
{
left++;
}
//left和right交换
if (left <= right)
//left和right相等时也要进入该循环,因为交换之后才会++、--避免死循环
{
//3)如果left==right时,相遇值等于基准值,left和right交换之后一定要++、--,否则会死循环
Swap(&arr[left++], &arr[right--]);
}
}
//right位置就是基准值的位置
Swap(&arr[keyi], &arr[right]);
return right;
}为什么跳出循环后right位置的值一定不大于key? 当 left > right 时,即 right 走到 left 的左侧,而 left 扫过的数据一定不大于key,因此 right 此时指向的数据一定不大于 key。
基准值的选取有什么要求吗?left 和 right 谁先走是否会影响结果? 基准值为 left 时,left 和 right 谁先走不影响 基准值为 right 时,right 要先走,并且 keyi 与 left 交换,最后return left
2)挖坑法
思路
//找基准值(挖坑法)
int _QuickSort2(int* arr, int left, int right)
{
//不能先让left++,left要始终指向最左侧
int hole = left;
int key = arr[hole];
while (left < right)
{
while (left < right && arr[right] > key)
{
right--;
}
arr[hole] = arr[right];
hole = right;
while (left < right && arr[left] < key)
{
left++;
}
arr[hole] = arr[left];
hole = left;
}
arr[hole] = key;
return hole;
}3)lomuto 双指针法
思路
//找基准值(lumoto双指针法)
int _QuickSort3(int* arr, int left,int right)
{
//cur从左往右找比基准值小的数据
//1)cur指向的数据比基准值小,pre++,cur和prev交换位置,cur++
//2)cur指向的数据不比基准值小,cur++
int prev = left;
int cur = prev + 1;
int keyi = left;
while (cur <= right)
{
//cur谁和基准值比较
if (arr[cur] < arr[keyi] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
++cur;
}
Swap(&arr[keyi], &arr[prev]);
return prev;
}1.时间复杂度:O(n*logn)
递归算法时间复杂度 == 单次递归时间复杂度 * 递归次数,递归次数就是二叉树的高度/深度,可以看成是一个满二叉树,n=2^h-1,所以 h=log2(n+1)。最坏的情况,如果基准值找的不好(每次基准值都在最左边,递归次数为n次)/数组有序,时间复杂度为O(n^2)。
2.空间复杂度:O(logn)
快速排序是不稳定的排序算法 。在分区过程中,相同的元素可能会交换位置,使得相等元素的相对顺序可能发生改变。
快速排序适用于大数据量且对效率有较高要求的场景 。在大多数情况下性能优越,所以在数据库索引构建、大规模数据处理等场景中被广泛应用 。例如,在数据库中对大量数据进行排序以加快查询速度时,快速排序可以高效地完成任务 。同时,因为它是原地排序,不需要大量额外空间,在内存资源有限的情况下也能发挥很好的作用 。
非递归快速排序与递归版本的核心逻辑一致,都基于 “分治” 思想:
//快速排序(非递归版本-----栈)
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);
STPop(&st);
int end = STTop(&st);
STPop(&st);
//[begin,end]-----找基准值
int keyi = begin;
int prev = begin, cur = begin + 1;
while (cur <= end)
{
if (arr[cur] < arr[keyi] && ++prev != cur)
{
Swap(&arr[prev], &arr[cur]);
}
++cur;
}
Swap(&arr[prev], &arr[keyi]);
keyi = prev;
//begin keyi end
//左序列[begin,keyi-1]
//右序列[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);
}
}
STDestory(&st);
}时间复杂度:平均情况O(n log n),最坏情况(数组有序且选最左为基准)O(n²)(可通过随机选择基准优化至期望O(n log n))。
空间复杂度:栈的空间开销最坏O(n)(如有序数组),平均O(log n)。
因交换操作可能改变相同元素的相对顺序,属于不稳定排序。
适合处理大规模数据,尤其当递归深度受限(如编程语言递归栈较小时)。非递归快速排序通过栈模拟递归过程,保留了快速排序的高效性,同时避免了递归可能带来的问题。核心是用栈存储子数组范围,通过循环分区实现排序,是实际开发中处理大数据排序的常用方案。
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序核心步骤:

void _MergeSort(int* arr, int left, int right, int* tmp)
{
if (left >= right)
{
return;
}
int mid = (left + right) / 2;
//分解
_MergeSort(arr, left, mid, tmp);
_MergeSort(arr, mid + 1, right, tmp);
//合并
int begin1 = left, begin2 = mid + 1;
int end1 = mid, end2 = right;
int index = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
if (arr[begin2] < arr[begin1])
{
tmp[index++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
//tmp中的数据放到arr数组中
for (int i = left; i <= right; i++)
{
arr[i] = tmp[i];
}
}
//归并排序
void MergeSort(int* arr, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
//[0,n-1]
_MergeSort(arr, 0, n - 1, tmp);
free(tmp);
}时间复杂度:O(nlogn)
无论原始数组是否有序,拆分阶段需O(log n)次递归,合并阶段每次需O(n)时间,总时间复杂度为 O(n log n)(稳定)。
空间复杂度:需要一个与原数组长度相同的临时数组,空间复杂度为 O(n)(非原地排序)。
合并时若两元素相等,优先取左数组元素,因此是 稳定排序。
适合处理大量数据(如百万级元素),但因需额外空间,不适合内存受限的场景。
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。
//非比较排序——计数排序
void CountSort(int* arr, int n)
{
//找最大值和最小值
int max = arr[0];
int min = arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] < min)
{
min = arr[i];
}
if (arr[i] > max)
{
max = arr[i];
}
}
//count数组大小
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * (range));
if (count == NULL)
{
perror("mallox fail!");
exit(1);
}
memset(count, 0, sizeof(int) * (range));
//
for (int i = 0; i < n; i++)
{
count[arr[i] - min]++;
}
int index = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
arr[index++] = min + i;
}
}
}时间复杂度:O(n)
确定范围、统计次数、计算前缀和、构建结果均为O(n + k)(n为元素个数,k为值范围max-min+1)。当k较小时(如k≈n),时间复杂度接近O(n),效率远高于比较型排序(如快排O(n log n))。
空间复杂度:需要计数数组和结果数组,空间复杂度为O(n + k)。
从原始数组末尾向前遍历并放置元素,相同元素的相对顺序不变,因此是稳定排序。
k较小(如k <= 10^6),否则空间开销过大;
排序算法作为数据结构领域的基石,在计算机科学的各个角落都有着举足轻重的地位。从简单直观的冒泡排序、选择排序和插入排序,到基于分治思想的快速排序、归并排序,再到利用数据特性的堆排序以及非比较类的计数排序、桶排序和基数排序,每一种算法都有其独特的原理、特性和适用场景 。
在实际应用中,我们需要根据数据的规模、特点、稳定性要求以及空间复杂度等多方面因素,综合权衡选择最合适的排序算法 。对于初学者来说,深入理解每种排序算法的原理和实现,通过大量的代码实践来掌握它们,是提升编程能力和算法思维的重要途径 。希望大家在后续的学习和工作中,能够灵活运用排序算法,解决实际问题,在数据的海洋中畅游 。
如有不足或改进之处,欢迎大家在评论区积极讨论,后续我也会持续更新数据结构相关的知识。文章制作不易,如果文章对你有帮助,就点赞收藏关注支持一下作者吧,让我们一起努力,共同进步!