基础排序方法
一.插入排序
1.直接插入排序
1.基本思想
直接插入排序的实现,我们可以参考扑克牌的排序
当我们要将新的牌插入现有牌堆时,保证当前手中的牌堆有序,再将新的牌与牌堆中的值进行比较,在进行比较与交换后,现在的牌堆就有序了。
2.具体实现
我们先进行单趟排序的实现,假设[0,end]的元素有序,这里即设第一个元素为有序的。
void InsertSort(int* a, int n)
{// [0, n-1]// [0, n-2]是最后一组// [0,end]有序 end+1位置的值插入[0,end],保持有序int end = 0;int tmp = a[end + 1];while (end >= 0){if (tmp < a[end]){a[end + 1] = a[end];--end;}else{break;}}a[end + 1] = tmp;}
此时要进行排序的元素为a[end+1],将该元素依次与现有的有序牌堆进行比较。若当前值小于end处的值,交换。
注意这里的将tmp的值放在end+1处,将这个式子放在了循环外边,这样既兼顾了因else语句的break跳出循环的情况,也考虑了当前要插入的数比原有数组的最小数还小从而不满足end>=0而跳出循环的情况。
完整的插入排序
void InsertSort(int* a, int n)
{// [0, n-1]for (int i = 0; i < n - 1; i++){// [0, n-2]是最后一组// [0,end]有序 end+1位置的值插入[0,end],保持有序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;}
}
3.一个具体例子
仅拿前四个元素排序如下
4.优化
这里的直接插入排序可以对比较的过程进行优化。因为要插入的是一个有序的数组,所以在比较时可以采用二分法。
int binary_search(int* a, int val, int start, int end) { // 使用二分查找找到插入位置 while (start <= end) { int mid = start + (end - start) / 2; if (a[mid] > val) { end = mid - 1; } else { start = mid + 1; } } return start; // 返回插入位置
} void InsertSort(int* a, int n) { for (int i = 1; i < n; i++) { int tmp = a[i]; // 使用二分查找查找插入位置 int pos = binary_search(a, tmp, 0, i - 1); // 移动元素以腾出插入位置 for (int j = i; j > pos; j--) { a[j] = a[j - 1]; } a[pos] = tmp; // 插入值 }
}
2.希尔排序
1.基本思想
先选定一个整数,把待排序文件中所有记录按间隔gap分组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达gap=1时,进行插入排序
步骤分为:a.预排序,让数组接近有序 b.插入排序
注意,尽管现在关于gap的最优间隔还未确定,但是可以确定的是最后一趟希尔排序gap一定为1(相邻为的元素进行比较) ,并且不难发现,每趟分为的组数为gap
2.具体实现
这里用gap每次都除以3加1的方式处理
不难发现当gap为1时就退化为了直接插入排序。
注意下述方法是多组同时进行排序,即先排第一组的第一个数据,再排第二组的第一个数据,再排第三组的第一个数据,再排第一组的第二个数据.....
void ShellSort(int* a, int n)
{int gap = n;while (gap > 1){// +1保证最后一个gap一定是1// gap > 1时是预排序// gap == 1时是插入排序gap = gap / 3 + 1;for (size_t i = 0; i < n - gap; ++i)//从此处可以看出分为了gap组{int end = i;int tmp = a[end + gap];while (end >= 0){if (tmp < a[end]){a[end + gap] = a[end];end -= gap;}else{break;}}a[end + gap] = tmp;}}
}
3.特点
不同于其他排序,希尔排序的时间复杂度并不方便计算,其主要原因之一是:
希尔排序的性能高度依赖于所选的增量序列(即分组的间隔)。不同的增量序列会导致不同的时间复杂度,例如:
- 希尔原始序列(n/2, n/4, ..., 1)的最坏时间复杂度为 O(n2)O(n2)。
- Hibbard序列(2k−12k−1)可将时间复杂度降至 O(n1.5)O(n1.5)。
- Sedgewick序列甚至能达到 O(n4/3)O(n4/3)。 由于每种序列的分析方法不同,且需单独推导其复杂度,因此缺乏统一的结果。
其次,在每次相同的gap排序之后,都会使得数据的有序性改变,因此希尔排序的时间复杂度比较难计算
二.选择排序
1.直接选择排序
1.基本思想
在元素集合array[i]--array[n-1]中选择关键码最大(小)的数据元素,若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换在剩余的array[i]--array[n-2](array[i+1]--array[n-1])集合中,重复上述步骤,直到集合剩余1个元素。值得注意的是,每进行一趟排序,一个数就会被排到他最终的位置
2.具体实现
初始时假定第一个元素就是最大和最小的元素。
在一趟排序中找出最大的与end处元素交换,最小的与begin处元素交换
在这里要注意begin和maxi重叠的情况,如果不处理会发现交换的maxi并非期待的maxi的情况
void SelectSort(int* a, 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 (a[i] > a[maxi]){maxi = i;}if (a[i] < a[mini]){mini = i;}}Swap(&a[begin], &a[mini]);if (begin == maxi)maxi = mini;Swap(&a[end], &a[maxi]);++begin;--end;}
}
2.堆排序
1.基本思想
这里用到了二叉树中堆的知识,包括堆的建立,向上调整和向下调整算法,堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
2.具体实现
之前的文章已经具体介绍,这里就不再赘述
void AdjustDown(int* a, int n, int parent)
{// 先假设左孩子小int child = parent * 2 + 1;while (child < n) // child >= n说明孩子不存在,调整到叶子了{// 找出小的那个孩子if (child + 1 < n && a[child + 1] > a[child]){++child;}if (a[child] > a[parent]){Swap(&a[child], &a[parent]);parent = child;child = parent * 2 + 1;}else{break;}}
}void HeapSort(int* a, int n)
{// 向下调整建堆 O(N)for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDown(a, n, i);}// O(N*logN)int end = n - 1;while (end > 0){Swap(&a[0], &a[end]);AdjustDown(a, end, 0);--end;}
}
三.交换排序
1.冒泡排序
1.基本思想
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动
2.具体实现
void BubbleSort(int* a, int n)
{for (int j = 0; j < n; j++){// 单趟int flag = 0;for (int i = 1; i < n - j; i++){if (a[i - 1] > a[i]){Swap(&a[i - 1], &a[i]);flag = 1;}}if (flag == 0){break;}}
}
3.优化
这里的优化是针对单趟排序中是否发生交换优化的,若经过单趟排序发现数组已经有序,则不必再进行后续循环
2.快速排序
1.基本思想
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。这里介绍hoare方法。可以注意到每个数经过一趟排序后都达到了最终有序序列的位置
2.具体实现
单次排序的意义是将一个数放在其最终位置,并且分割了左右子区间
用递归的方式遍历左右子区间,递归结束的条件是区间只有一个值(left==right)或者区间不存在(left>right)
int GetMidi(int* a, int left, int right)
{int midi = (left + right) / 2;// left midi rightif (a[left] < a[midi]){//在这里midi为中间值if (a[midi] < a[right]){return midi;}//在这里midi的数最大,left和right中较大的即中间值else if (a[left] < a[right]){return right;}else{return left;}}// a[left] > a[midi]else {if (a[midi] > a[right]){return midi;}else if (a[left] < a[right]){return left;}else{return right;}}
}// 避免有序情况下,效率退化
// 1、随机选key
// 2、三数取中
// 21:14
// 面试手撕,不用三数取中和小区间优化
// 后续讲一下思路即可
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 midi = GetMidi(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]);keyi = begin;// [left, keyi-1] keyi [keyi+1, right]QuickSort(a, left, keyi - 1);QuickSort(a, keyi + 1, right);}
}
3.一个具体例子
单趟排序如下
4.优化
a.对于快速排序,其存在一个比较大的问题是:当数据基本有序的时候效率非常低,产生栈溢出。
因为之前选的keyi均为最左边的值,也是导致这种情况的原因之一
首先right找比keyi小的数,一直找不到,直到与left相遇
接着分割区间,左区间不存在,右区间重复以上过程。。退化为O(n^2)。。
因此考虑用三数取中的方法进行优化,这样可以保证每次分割区间都基本做到二分划分
b.由满二叉树节点的特点可知,最后一层的递归次数占到总递归次数的百分之五十左右,可以想办法减少后几层的递归从而加快排序速度 ,使用小区间优化即可
三数取中的核心思想
-
问题根源: 当数据有序时,固定选择左/右端元素作为基准会导致每次分区只能减少一个元素(类似冒泡排序),递归深度达到 O(n)O(n),性能急剧下降。
-
解决方案: 从数组的
left
(左)、mid
(中)、right
(右)三个位置取元素,选择中间值的元素作为基准,使得基准值接近整体中位数,从而:- 减少分区不平衡的概率
- 避免极端情况下时间复杂度退化
-
数学意义: 三数取中将最坏情况概率从固定基准的 O(n2)O(n2) 降低到 O(nlogn)O(nlogn) 的期望时间复杂度。
小区间优化
当递归至数组中仅剩十个数据时,考虑使用直接插入排序可优化部分性能
思考
当left做keyi,右边先走时,为什么相遇的位置一定比keyi小?
left遇right的情况:这时一定是right遇到比keyi小的数停下来,left才走的。left没找到比keyi大的值,相遇的位置一定比keyi小
right遇left的情况:由于right先走,此时right要相遇left,一定是第二轮之后的循环。此时left所在的值是上一轮比keyi小的值交换的结果,所以right此时与left相遇是找不到比keyi小的值。
非递归方法:栈实现(这里是用数据结构的栈)
快排在进行较深递归时会有很大的栈溢出风险,因此考虑设计非递归实现快速排序
基本思想:在进行递归时,最重要的是传参数(在此处是传子区间的端点值),举一个例子,一个0-9的数组进行快排
由此分为了0-4和6-9两个区间,先将右子区间的端点值压入栈,再压左子区间端点值
不难发现,循环每走一次,取栈顶区间,单趟排序,右左子区间入栈,直至栈空所有数据排好
void QuickSortNonR(int* a, 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);int keyi = PartSort2(a, 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);
}
其中的PartSort为快速排序的前后指针写法
// 前后指针
int PartSort2(int* a, int left, int right)
{// 三数取中int midi = GetMidi(a, left, right);Swap(&a[left], &a[midi]);int keyi = left;int prev = left;int cur = prev+1;while (cur <= right){if (a[cur] < a[keyi] && ++prev != cur)Swap(&a[prev], &a[cur]);cur++;}Swap(&a[prev], &a[keyi]);return prev;
}
四.归并排序
1.基本思想
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
因为左区间,右区间有序才能归并,所以这里的递归是后序的思路
2.具体实现
void _MergeSort(int* a, int* tmp, int begin, int end)
{if (begin >= end)return;int mid = (begin + end) / 2;// 如果[begin, mid][mid+1, end]有序就可以进行归并了_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");return;}_MergeSort(a, tmp, 0, n - 1);free(tmp);tmp = NULL;
}
非递归方法:将单个数看作有序两两归并
-
顺序合并无需回溯 自底向上的归并排序通过逐步倍增子数组长度进行合并(如先合并长度为1的子数组,再合并长度为2的,依此类推)。这种合并过程是顺序且层次化的,每一步仅需遍历数组一次即可完成当前层的合并。由于不需要保存中间分解状态或回溯到之前的步骤,栈的后进先出(LIFO)特性并无优势,反而可能增加不必要的复杂度。
-
广度优先而非深度优先 递归版本的归并排序是深度优先的,需要先分解到最小子问题再逐步合并。栈可以模拟这种过程,但非递归的自底向上方法本质上是广度优先的——每一层的合并操作独立且完整,无需深入处理子问题后再返回。直接使用循环控制变量(如当前子数组长度
size
)即可高效管理合并流程,无需借助栈维护状态。 -
实现复杂度与效率 栈需要记录子数组的起始和结束索引,并确保合并顺序正确。例如,若用栈模拟自顶向下分解,需按特定顺序压栈以保证左半部分优先处理。这会增加代码的复杂性,而自底向上的循环实现仅需一个外层循环控制子数组长度,内层循环遍历合并位置,逻辑更简洁高效。
-
空间与性能开销 自底向上的归并排序已需额外空间存储合并结果(O(n)空间复杂度)。若引入栈,可能增加额外的空间开销用于保存中间状态,但并未带来性能提升。相比之下,迭代方法直接操作索引和步长,无需额外数据结构,时间和空间效率更优。
具体实现
在这里要特别注意,除了begin1不可能越界(>n)之外,其他三个参数均有可能越界,接下来针对不同情况进行改进
第一种情况:end2越界,只需要调整end2的最终位置为n-1即可
第二种和第三种情况:即begin2开始的数据就不需要归并,跳出循环即可
void MergeSortNonR(int* a, 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)//i控制的是每次归并的起始位置{// [begin1, end1][begin2, end2]int begin1 = i, end1 = i + gap - 1;int begin2 = i + gap, end2 = i + 2 * gap - 1;printf("[%d,%d][%d,%d] ", begin1, end1, begin2, end2);// 第二组都越界不存在,这一组就不需要归并if (begin2 >= n)break;// 第二的组begin2没越界,end2越界了,需要修正一下,继续归并if (end2 >= n)end2 = n - 1;int j = i;while (begin1 <= end1 && begin2 <= end2){if (a[begin1] < a[begin2]){tmp[j++] = a[begin1++];}else{tmp[j++] = a[begin2++];}}while (begin1 <= end1){tmp[j++] = a[begin1++];}while (begin2 <= end2){tmp[j++] = a[begin2++];}memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));}printf("\n");gap *= 2;}free(tmp);tmp = NULL;
}
思考
归并排序同时也是一种外排序(可以对磁盘中数据排序),可以对大量的数据进行排序 。那为什么其他的排序无法作为外排序呢?
1. 顺序访问特性
外排序的核心挑战是减少磁盘I/O的随机访问(因其效率极低)。归并排序的合并阶段仅需顺序读写数据:
- 将大文件分割为多个内存可容纳的块,每个块在内存中排序后写入磁盘。
- 合并时,依次从多个有序块中按顺序读取数据片段,比较后输出到结果文件,无需频繁跳转磁盘位置。
其他算法(如快速排序、堆排序)依赖随机访问或原地交换,导致磁盘I/O效率低下。
2. 分阶段处理能力
归并排序天然支持分治策略:
- 分块阶段:将大文件分成多个有序块(每个块用内排序处理)。
- 归并阶段:通过多路归并(如使用最小堆优化)逐步合并有序块,最终形成全局有序文件。
这种分阶段处理完美契合外排序的“部分排序-逐步合并”流程,而其他算法(如堆排序、快速排序)难以高效整合多个有序序列。
3. 稳定的时间复杂度
归并排序的时间复杂度恒为 O(n log n),无论数据分布如何。这一特性对处理海量数据至关重要,而快速排序在极端情况下可能退化为O(n²),外存环境下风险极高。
其他算法的局限性
- 快速排序:依赖随机访问和递归分区,磁盘I/O效率低;分区策略在合并阶段无法复用。
- 堆排序:构建堆需全局数据参与,难以分块处理;合并多有序块时需额外优化(如败者树),不如归并直接。
- 插入/冒泡排序:O(n²)时间复杂度无法应对大数据量。
五.非比较排序之计数排序
1.基本思想
计数排序的思想极为简单,在后面学习的知识称为哈希表。假设我们有一组数据:
我们需要创建出一个数组,其用于存储以上数据存储的数据,而数组下标表示以上数据或以上数据的相对表示
我们要做的只需要遍历原数组的数据,将统计结果记录在其中即可
2.具体实现
void CountSort(int* a, int n)
{//找出最大最小值int min = a[0], max = a[0];for (int i = 1; i < n; i++){if (a[i] < min)min = a[i];if (a[i] > max)max = a[i];}//创建大小为range的数组int range = max - min + 1;//printf("%d\n", range);int* count = (int*)calloc(range, sizeof(int));//此处用calloc可直接将count数组初始化为0if (count == NULL){perror("calloc fail");return;}// 统计次数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;}}free(count);
}
3.思考
该排序优点:利用count数组的自然序号记录数据,并且再经历一次遍历即可将排序结果输出
该排序缺点:当数据较为离散时,count数组开辟的空间大部分会被浪费;只能对整数进行排序,无法对浮点数排序
解决方案:当数据较为离散时,我们按照范围开辟count数组。求出最大值最小值max和min,数组的大小即为max-min+1。比如:
此时下标为0处存a[0]-min ,以此类推。
六.不同排序的特点和性能对比
稳定性的解释:当一组数据中存在两个或多个相同的数据,在经过一趟排序后这几个相同的数据的位置是否发生了改变。这个指标在结构体类型的排序中尤为重要
排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 特点 |
---|---|---|---|---|---|---|
冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 是 | 简单,适合小数据或基本有序。通过相邻元素交换实现。 |
选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 否 | 每次选最小元素交换,交换次数少但比较次数固定。 |
插入排序 | O(n) | O(n²) | O(n²) | O(1) | 是 | 适合小规模或部分有序数据,类似整理扑克牌。 |
希尔排序 | O(n) | O(n^1.3) | O(n²) | O(1) | 否 | 改进的插入排序,通过分组逐步有序化。复杂度依赖步长序列。 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 是 | 分治思想,稳定,适合外部排序(如大文件),需额外空间。 |
快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 否 | 分治思想,实际应用最快,但最坏情况较差。空间复杂度为递归栈深度。 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 否 | 基于堆结构,原地排序,适合内存有限场景。 |
计数排序 | O(n + range) | O(n + range) | O(n + range) | O(range) | 是 | 非比较排序,适合整数且范围小(k为数据范围),需额外空间计数。 |
基数排序 | O(nk) | O(nk) | O(nk) | O(n + k) | 是 | 按位排序(如个位→十位),k为最大位数,需稳定子排序(如计数排序)。 |
桶排序 | O(n + k) | O(n + k) | O(n²) | O(n + k) | 是 | 数据均匀分布时高效,分桶后排序,稳定性依赖桶内算法。 |