排序算法
一、冒泡排序
1.1 简介
冒泡排序是蛮力法在排序问题上的一个典型的应用场景。
对于一个给定长度为 n 的无序数组,由初始位置开始,比较数组相邻两个元素。如果是逆序排列的,就交换它们的位置,重复多次之后,最大数就“沉”到了数组最后的位置。第二次再从初始位置开始,将第二大的元素沉到倒数第二个位置。这样一直做 n-1 次,整个数组就是有序的了。
对于一个长度为 n 的数组,整个算法消耗的时间为:(n-1) + (n-2) + ... + 1 = n(n-1)/2,那么它的时间复杂度为 O(n2)。同时,显而易见,整个算法只消耗一份数组的空间,所以空间复杂度为 O(1)。
另外,普及一下排序算法另一个重要的特性:稳定性。
所谓的稳定性,通俗地讲就是能保证两个相等的数在排序前后的顺序相同。
假定原数组有两个相同的元素 Ω,在数组的 a[i] 和 a[j] 位置。排序前 a[i] 在 a[j] 的前面,在排序之后,a[i] 仍然在 a[j] 的前面,那么就说这种排序方法具有稳定性。
冒泡排序是一种稳定排序。
蛮力法的应用有一个显著的特点,就是在经过适当的努力之后,可以对算法进行一定的改良,从而提高它的性能,但并不会减弱算法本身的时间复杂度。冒泡排序作为蛮力法的典型应用,自然也有这种特性。
1.2 改进
设定交换发生的标识位
对于待排序的数组,可以设定一个标志位,检查一次比较之后,是否有数据进行了交换,若是没有,那么整个数组就已经有序了,可以直接退出。极端情况下,如刚才提到的,对有序数组进行排序,只需要执行 n-1 次操作,就可以完成排序。
记录最后一次发生交换的位置
初始排序时,数组是在 [0, n] 区间遍历,那么下一轮的遍历是在 [0, n-1] 区间。
现在记录数组本轮遍历的最后一次交换发生在 lastSwapPos 位置,那么下一轮的遍历实际上是在 [0, lastSwapPos] 区间。
1.3 代码
#define ARRAY_LENGTH 10
void bubbleSort(int arr[])
{
int lastSwapPos = ARRAY_LENGTH - 1;
int lastSwapPosTemp = ARRAY_LENGTH - 1;
int temp = 0;
// ①
for (int i = 0; i < ARRAY_LENGTH - 1; i++) {
for (int j = 0; j < ARRAY_LENGTH - i - 1; j++) {
if (arr[j] > arr[j+1]) {
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
// ②
for (int i = 0; i < ARRAY_LENGTH - 1; i++) {
lastSwapPos = lastSwapPosTemp;
for (int j = 0; j < lastSwapPos; j++) {
if (arr[j] > arr[j+1]) {
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
lastSwapPosTemp = j;
}
}
// 一次都未交换的情况
if (lastSwapPos == lastSwapPosTemp) {
break;
}
}
for (int i = 0; i < ARRAY_LENGTH; i++) {
printf("%d ", arr[i]);
}
}
1.4 总结
优点:
- 空间复杂度 T = O(1)
- 稳定排序
- 在排序过程中,整个数组趋向稳定
- 对于已经有序的数组,排序效率高
缺点:
- 效率低
- 交换次数多,交换效率低(每次交换只减少一组逆序对)
- 不能并发执行
二、选择排序
2.1 简介
选择排序是蛮力法的一种实际应用。
首先扫描整个数组,找到最小的元素,然后和第一个元素进行交换,如此一来就等同于将最小的元素放到它在有序表中最终的位置上。然后从第二个元素开始扫描整个表,找到剩余 n-1 个元素中最小的元素,与第二个元素交换位置。以此类推,在执行 n-1 遍之后,这个数组就自然有序了。(当然每次找最大的元素,与最后一个元素交换也是可行的)
选择排序有一个最明显的优于冒泡排序的:数据交换的次数。在完全逆序的情况下,冒泡排序需要最多的 n*(n-1)/2 次交换,而选择排序最多产生 n-1 次交换。
选择排序的时间复杂度是 O(n2),空间复杂度是 O(1)。
选择排序有一个很重要的特性——它是一种不稳定排序。假定一个数组:[5, 5, 6, 1, 8],可以很清晰的发现,在选择排序结束之后,两个 5 并不能保持原来的顺序,第一个 5 在第四的位置,第二个 5 还在第二的位置。
2.2 改进
每一次外循环的遍历,可以同时找到最小值(minIndex)和最大值(maxIndex),分别和头、尾两个元素进行交换。这样一来外循环只要执行原来一半的循环次数就可以了。
但是需要注意一点:每次循环要进行 2 次交换,第一次最小值交换结束之后,在进行最大值交换的时候要先判断最大值是不是在第一个位置,在第一次最小值交换的时候已经换到了后面。
如 [5, 4, 3, 2, 1] 第一次最小值交换后变成了 [1, 4, 3, 2, 5],初始记录的 maxIndex = 0 就指向了 1,而不是 5。
2.3 代码
#define ARRAY_LENGTH 10
void selectSort(int arr[])
{
// 数组内容合法性判断
int minIndex;
int maxIndex;
// 方式 ①
for (int i = 0; i < ARRAY_LENGTH - 1; i++) {
minIndex = i;
for (int j = i; j < ARRAY_LENGTH; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 判断第一个是不是最小值,是的话可以不用交换
if (i != minIndex) {
swap(i, minIndex, arr);
}
}
// 方式 ②
for (int i = 0; i < ARRAY_LENGTH / 2; i++) {
minIndex = i;
maxIndex = i;
for (int j = i; j < ARRAY_LENGTH - i; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
if (arr[j] > arr[maxIndex]) {
maxIndex = j;
}
}
// 判断第一个是不是最小值,是的话可以不用交换
if (i != minIndex) {
swap(i, minIndex, arr);
}
if (ARRAY_LENGTH - 1 - i != maxIndex) {
// 防止最大数在第一个,优先和最小数进行交换
swap(ARRAY_LENGTH - 1 - i, (i == maxIndex) ? minIndex : maxIndex, arr);
}
}
for (int i = 0; i < ARRAY_LENGTH; i++) {
printf("%d ", arr[i]);
}
}
void swap(int idx1, int idx2, int arr[]) {
int temp = arr[idx1];
arr[idx1] = array[idx2];
arr[idx2] = temp;
}
三、直接插入排序
3.1 减治法(增量法)
- 减治法:对于一个全局的大问题,将其与一个更小规模的问题建立递推关系。
- 增量法:基于一个小规模问题的解,和一个更大规模的问题建立递推关系。
减治法和增量法,从本质上来讲,都是基于一种建立递推关系的思想来减小或扩大问题规模的方法。
很显然,无论是减治法还是增量法,其核心是如何建立一个大规模问题和一个小规模问题的递推关系。根据应用的场景不同,主要有以下 3 种变化形式:
- 减去一个常量。(直接插入排序)
- 减去一个常量因子。(二分查找法)
- 减去的规模可变。(辗转相除法)
3.2 直接插入排序
直接插入排序(straight insertion sort),有时也简称为插入排序(insertion sort),是减治法的一种典型应用。其基本思想如下:
对于数组 a[0, n] 的排序问题,假定数组在 a[0, n - 1] 排序的问题已经解决了。
考虑 a[n] 的值 x,从右向左扫描有序数组 a[0, n - 1],直到第一个小于等于 x 的元素,将 x 插在这个元素的后面。
很显然,基于增量法的思想在解决这个问题上拥有更高的效率。
直接插入排序对于完全逆序的数组,需要比较和移位的次数为 n(n-1)/2;对于已经有序的数组,只需要比较的次数是 n-1,需要移位的次数是 0。
当然,对于最好和最坏的情况的研究其实没有太大的意义,因为实际情况下,一般不会出现如此极端的情况。然而,直接插入排序对于基本有序的数组,会体现出良好的性能,这一特性,也给了它进一步优化的可能性。(希尔排序)
直接插入排序的时间复杂度是 O(n2),空间复杂度是 O(1),同时也是稳定排序。
3.3 优化
设置哨兵位
仔细分析直接插入排序的代码,会发现虽然每次都需要将数组向后移位,但是在此之前的判断却是可以优化的。
不难发现,每次都是从有序数组的最后一位开始,向前扫描的,这意味着,如果当前值比有序数组的第一位还要小,那就必须比较有序数组的长度 n 次。这个比较次数,在不影响算法稳定性的情况下,是可以简化的:记录上一次插入的值和位置,与当前插入值比较。若当前值小于上个值,将上个值插入的位置之后的数,全部向后移位,从上个值插入的位置作为比较的起点;反之,仍然从有序数组的最后一位开始比较。
二分查找法
优化直接插入排序的核心在于:快速定位当前数字待插入的位置。
在一个有序数组中查找一个给定的值,最快的方法无疑是二分查找法,对于当前数不在有序数组中的情况,java 官方的 JDK 源码 Arrays.binarySearch() 方法也给出了定位的方式。当然此方法的入参,需要将有序数组传递进去,这需要不断地组装数组,既消耗空间,也不现实,但是可以借鉴这方法,自己实现类似的功能。
这种定位方式有一个致命的缺点就是虽然能找到位置,但最终形成的数组会打破排序算法的稳定性,导致即使效率高出普通的直接插入排序法很多,但却不被使用。既然一定会打破稳定性,那么为什么不使用更优秀的希尔排序呢?
3.4 简单的性能比较
结论:
在某些特定场景下,由于入参的条件不同,自定义的方式实现执行效率可能高于源码。
对于小规模的数组,优化的结果和预想相反,效率比不上最初的方法。原因在于本身只是对于判断的优化,而不是执行次数的优化。在每次循环中,加上更多的计算去优化这个判断,在小数组上对于整个排序的效率,反而是一种伤害。
大规模数组,二分查找优化效率明显。
3.5 代码
#define ARRAY_LENGTH 10
void insertSort(int list[])
{
// 第1个数肯定是有序的,从第2个数开始遍历,依次插入有序序列
for (int i = 1; i < ARRAY_LENGTH; i++) {
int j = 0;
int temp = list[i]; // 取出第i个数,和前i-1个数比较后,插入合适位置
// 因为前i-1个数都是从小到大的有序序列,所以只要当前比较的数(list[j])比temp大,就把这个数后移一位
for (j = i - 1; j > -1 && temp < list[j]; j--) {
list[j + 1] = list[j];
}
list[j + 1] = temp;
}
for (int i = 0; i < ARRAY_LENGTH; i++) {
printf("%d ", list[i]);
}
}
// 根据上一次的位置,简化下一次定位
static void optimized_1(int array[]) {
// 记录上一个插入值的位置和数值
int checkValue = array[0];
int checkIndex = 0;
// 循环插入
for (int i = 1; i < ARRAY_LENGTH; i++) {
int cur = array[i];
int start = i - 1;
int j = 0;
// 根据上一个值,定位开始遍历的位置
if (cur < checkValue) {
start = checkIndex;
for (int j = i - 1; j > start - 1; j--) {
array[j + 1] = array[j];
}
}
// 剩余情况是:checkIndex 位置的数字,和其下一个坐标位置是相同的
// 循环判断 + 插入
for (j = start; j > -1; j--) {
if (cur < array[j]) {
array[j + 1] = array[j];
}
else {
break;
}
}
array[j + 1] = cur;
checkValue = cur;
checkIndex = j + 1;
}
for (int i = 0; i < ARRAY_LENGTH; i++) {
printf("%d ", array[i]);
}
}
四、希尔排序
4.1 和直接插入排序的关系
希尔排序 Shell sort 是直接插入排序的变种方式之一,更高效的改进版本。其基本思想如下:
①、记录按下标的一定增量分组,对每组进行直接插入排序。
②、不断地缩小增量,对每组进行直接插入排序,直至增量为 1。
数组:a[33, 13, 44, 21, 88, 8, 17]
增量 d = 3 分组 [33, 21, 17]、[13, 88]、[44, 8] 排序结果:[17, 13, 8, 21, 88, 44, 33]
增量 d = 3/2 = 1 分组 [17, 13, 8, 21, 88, 44, 33] 排序结果:[8, 13, 17, 21, 33, 44, 88]
由上可知,希尔排序本质就是多次使用直接插入排序。其优于直接插入排序的原因在之前也提到过,就是:直接插入排序对于基本有序的数组,拥有较高的性能。
希尔排序的时间复杂度是 O(n2)(只是针对最坏情况而言,平均的效率要远远高出其他时间复杂度为 O(n2) 的排序算法),空间复杂度是 O(1)。
希尔排序在提供优秀性能的同时,打破了排序算法的稳定性。
4.2 希尔排序的实现
希尔排序,又称为缩小增量排序,其重点显然在于初始增量 d 的选取,以及每次增量 d 缩小的额度。一般来说,初始增量设为数组长度的一半,同时每次增量减半,直至 d = 1,可以满足大多数的需求。
4.3 优化
1、设置哨兵位优化各分组的直接插入排序
2、二分查找法优化各分组的直接插入排序
4.4 简单的性能比较
结论:
直接插入排序的优化手段,对希尔排序没有作用,反而是一种伤害。原因是直接插入排序的优化手段对于小规模的数组是有害的,而希尔排序的原理是将整个数组拆成若干个小数组,利用直接插入排序对基本有序的数组拥有良好的性能这一特性出发的。
同样是不稳定排序,对比直接插入排序的二分查找优化,无论数组规模的大小,希尔排序在性能上都有明显的优势。
五、堆排序
堆排序 Heap Sort,是指将整个数组转化成 “堆” 这种数据结构,利用堆的性质,去简化排序的过程。堆排序,可以看做是“变治法”的一种实现。
From Wikipedia:https://en.wikipedia.org/wiki/Heapsort
5.1 堆
堆是一个可以被看做一棵树的数组对象,但是堆总是需要满足以下两条性质:
①、堆是一棵完全二叉树。树的每一层都是满的,除了最后一层最右边的元素有可能出现缺位。
②、父母优势。即堆中的所有子节点,总是不大于或不小于其父节点的值。
根据子节点与父节点的关系,可以将堆分为最大堆(大的值在上)和最小堆(小的值在上)两种情况。
5.2 堆的数组表现形式
数组 [9, 7, 3, 5, 1, 2, 6, 0, 8, 4] 按照顺序逐层放置可以转化为以下二叉树结构。(目前不具备堆的性质)
5.3 堆的性质
1、获取堆中某个节点的左节点、右节点、父节点
- (int)leftNode:(int)node
{
return (node << 1) + 1; // 0 << 1 = 0
}
- (int)rightNode:(int)node
{
return (node << 1) + 2;
}
- (int)parentNode:(int)node
{
return (node - 1) >> 1;
}
①、把完全二叉树每层的节点个数看成二进制 1、2、4、8...
②、当前节点在层 n(从 0 开始)的位置为 m(从 0 开始),那么节点在数组的位置为 index = 2(n - 1) + 2(n - 2) + ... + 20 + m
左边子节点就是 2n + 2(n - 1) + ... + 20 + m * 2 + 1 = index * 2 + 1
右边子节点就是 2n + 2(n - 1) + ... + 20 + m * 2 + 2 = index * 2 + 2
2、保持最大堆/最小堆的性质
当二叉树中的某个子节点,其左子树和右子树都具备堆的性质,那么可以通过一系列的交换,将以该子节点为根节点的树,转化为堆的结构。
/**
* @param array 待排序数组
* @param node 当前节点
* @param lastNode 最后一个节点
*/
- (void)maxHeapSort:(NSMutableArray *)array node:(int)node lastNode:(int)lastNode
{
int lNode = [self leftNode:node]; // 左子节点
int rNode = [self rightNode:node]; // 右子节点
int largestNode = node; // 最大值节点。初始为当前节点
// 如果左子节点没有超出范围 && 左子节点数值比 largestNode 的大
if (lNode <= lastNode && [array[lNode] integerValue] > [array[largestNode] integerValue]) {
largestNode = lNode;
}
// 如果右子节点没有超出范围 && 右子节点数值比 largestNode 的大
if (rNode <= lastNode && [array[rNode] integerValue] > [array[largestNode] integerValue]) {
largestNode = rNode;
}
// 如果当前节点不是最大值,发生交换
if (node != largestNode) {
[array exchangeObjectAtIndex:node withObjectAtIndex:largestNode];
[self maxHeapSort:array node:largestNode lastNode:lastNode];
}
}
3、构造最大堆/最小堆
将一个不具备堆性质的数组,转化为堆的结构,一般都是自底向上开始的。
自二叉树的第一个非叶节点开始,自底向上,依次保持二叉树的堆的性质,直至根节点。
NSMutableArray * arr = [NSMutableArray arrayWithObjects:@9, @7, @3, @5, @1, @2, @6, @0, @8, @4, nil];
int lastNonLeaf = (int)(arr.count - 2) >> 1;
for (int i = lastNonLeaf; i > -1; --i) {
[self maxHeapSort:arr node:i lastNode:arr.count - 1];
}
5.4 堆排序
①、将原数组转化为堆的形式。升序排列使用最大堆,降序排列使用最小堆。
②、将数组最后一位与第一位交换,因为是最大堆的关系,第一位数字就是最大值,落在了最后一位上。
③、剔除最后一位数字,将数组剩余部分,看作一颗新的二叉树。其中根节点的左子树和右子树都满足最大堆的性质,保持这棵新二叉树的最大堆性质。这里是通过自顶向下的方式直到最后一个叶子节点调整二叉树。
④、循环 ②、③,直至根节点,整个数组即自然有序。
NSMutableArray * arr = [NSMutableArray arrayWithObjects:@9, @7, @3, @5, @1, @2, @6, @0, @8, @4, nil];
int lastNonLeaf = (int)(arr.count - 2) >> 1; // 从第一个非叶子节点开始构造,即最后一个叶子节点的父节点。
// 数组 -> 堆 -> 最大堆
for (int i = lastNonLeaf; i > -1; --i) {
[self maxHeapSort:arr node:i lastNode:(int)arr.count - 1];
}
// 最大堆 -> 第一个与最后一个交换
for (int i = (int)arr.count - 1; i > 0; --i) {
[arr exchangeObjectAtIndex:i withObjectAtIndex:0];
[self maxHeapSort:arr node:0 lastNode:i - 1];
}
5.5 变治法代码
#include <stdio.h>
#define num 10
int temp;
void Description(int *a)
{
for(int i = 0; i < num; i++) {
printf("%d ", a[i]);
}
printf("\n");
}
/**
* @brief 筛选算法。用于维护堆,保持最大堆的性质
* @param a 数组
* @param k 调整的结点
* @param n 堆的大小
*/
void MaxHeapify(int *a, int k, int n)
{
int i = k, j;
j = 2 * i + 1; // 置 i 为要筛的结点,j 为 i 的左孩子
while(j < n) {
// j + 1 < n 用于判断 i 是否有右孩子。a[j] < a[j+1] 用于比较左右孩子的大小
if (j + 1 < n && a[j] < a[j+1])
j++;
if(a[i] > a[j]) { // i 节点大于左右孩子中的最大者
break;
}
else {
temp = a[i];
a[i] = a[j];
a[j] = temp;
i = j;
j = 2 * i + 1;
}
}
}
void HeapSort(int *a)
{
int i;
// 每建立一次堆,只能确定根节点是最大值,其他节点并没有按序排好,所以后面有重复建堆的过程,但是需要建堆的区间慢慢变少了
for(i = (num - 1)/2; i >= 0; i--) {
MaxHeapify(a, i, num); // 初始建堆,其中一开始 i 表示的是最后一个元素的父节点
Description(a);
}
printf("\n");
// 重复执行移走堆项及重建堆的操作
for (i = 1; i < num; i++) { // i 表示当前堆的大小,即等待排序的元素的个数
temp = a[0];
a[0] = a[num - i];
a[num - i] = temp;
MaxHeapify(a, 0, num - i); //从根节点处继续调整建堆,0 ... n-i 是无序区
Description(a);
}
}
int main()
{
int a[num] = {0, 1, 3, 5, 7, 9, 8, 6, 4, 2};
HeapSort(a);
Description(a);
return 0;
}
0 1 3 5 7 9 8 6 4 2
0 1 3 6 7 9 8 5 4 2
0 1 9 6 7 3 8 5 4 2
0 7 9 6 2 3 8 5 4 1
9 7 8 6 2 3 0 5 4 1
8 7 3 6 2 1 0 5 4 9
7 6 3 5 2 1 0 4 8 9
6 5 3 4 2 1 0 7 8 9
5 4 3 0 2 1 6 7 8 9
4 2 3 0 1 5 6 7 8 9
3 2 1 0 4 5 6 7 8 9
2 0 1 3 4 5 6 7 8 9
1 0 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9
5.6 堆排序的时间复杂度
堆排序由两部分组成:
- 构建最大堆
- 循环保持最大堆的性质
1、构建最大堆的时间复杂度 T = O(n)
构建最大堆的过程,是从第一个非叶节点开始的,即数组的 1/2 处。
考虑最坏情况:
- 二叉树的倒数第二行,总会与最后一行发生一次比较/交换。
- 二叉树的倒数第三行,总会与倒数第二行发生一次比较/交换,与最后一行发生一次比较/交换。
- ...
- 二叉树的第一行,总会与它下面的每一行都发生一次比较/交换,即交换次数为二叉树高度 d = log2n。
将这些比较/交换次数累加:
2、循环保持最大堆的性质的时间复杂度 T = O(n * log2n)
这一步的时间计算就相对比较明显了,在 n 次循环的内部,进行时间复杂度为 O(log2n) 的 maxHeapSort()。
所以总时间复杂度 T = O(n) + O(nlog2n) =O(nlog2n)
六、归并排序
归并排序 Merge Sort,又称为二路归并排序,是指将一个数组一分为二,对每一个子数组递归排序,最后将排好的子数组合并为一个有序数组的过程。
归并排序,是 “分治法” 应用的完美实现。
From Wikipedia: https://en.wikipedia.org/wiki/Merge_sort
6.1 图示
6.2 流程
通过图示可以发现归并排序一共只需要两个步骤:
- 分:将原数组分为 n 个子数组,每个子数组长度为 1。
- 合:依次将两个相邻的有序数组,合并成一个有序数组,重复操作直至剩下一个有序数组。
6.3 代码实现
1、分成两个子数组,左右递归
- (void)sort:(NSMutableArray *)arr left:(NSInteger)left right:(NSInteger)right
{
if (left < right) {
NSInteger mid = (left + right) >> 1; // ÷ 2
[self sort:arr left:left right:mid];
[self sort:arr left:(mid + 1) right:right];
// 将分开的两部分子数组合并
[self merge:arr left:left mid:mid right:right];
}
}
2、合并两个有序数组,此时处理新数组的排序
/**
* @brief 合并
*/
- (void)merge:(NSMutableArray *)arr left:(NSInteger)left mid:(NSInteger)mid right:(NSInteger)right
{
// 注意新数组的长度
NSMutableArray * newArr = [NSMutableArray arrayWithCapacity:right - left + 1];
NSInteger startIndex1 = left;
NSInteger startIndex2 = mid + 1;
for (int i = 0; i < right - left + 1; ++i) {
// 左侧数组检索完毕
if (startIndex1 == mid + 1) {
newArr[i] = arr[startIndex2++];
}
// 右侧数组检索完毕
else if (startIndex2 == right + 1) {
newArr[i] = arr[startIndex1++];
}
else {
// 使用两个指针记录数组位置,依次比较指针位置的数字,将较小的数字放入新数组。这样可以在线性的时间内完成合并工作。
newArr[i] = [arr[startIndex1] integerValue] < [arr[startIndex2] integerValue] ? arr[startIndex1++] : arr[startIndex2++];
}
}
[arr replaceObjectsInRange:NSMakeRange(left, newArr.count) withObjectsFromArray:newArr];
}
3、相似的完整代码:
#include <stdio.h>
#include <stdlib.h>
#define max 1024
int SR[max], TR[max];
/**
* @param SR 两个有序序列构成的序列
* @param TR 一个空数组,用来存放排序好之后的数字
* @param left 起始位置
* @param mid 两个序列的分解位置
* @param right 结束位置
*/
int merge(int SR[], int TR[], int left, int mid, int right)
{
int i = left, j = mid + 1, k = left; // k 是 TR 当前存入数字的下标
while(i <= mid && j <= right) {
if(SR[i] < SR[j]) {
TR[k++] = SR[i++];
}
else {
TR[k++] = SR[j++];
}
}
while(i <= mid) {
TR[k++] = SR[i++]; // 当前面一个序列有剩余的时候,直接把剩余数字放在 TR 的后面
}
while(j <= right) {
TR[k++] = SR[j++]; // 当后面一个序列有剩余的时候,直接把剩余数字放在 TR 的后面
}
return 0;
}
void copy(int SR[], int TR[], int s, int t)
{
for(int i = s; i <= t; i++) {
SR[i] = TR[i]; // TR -》 SR,相当于 TR 所在的区间有序
}
}
int mergesort2(int SR[], int left, int right)
{
if(left < right) {
int mid = (left + right) >> 1;
mergesort2(SR, left, mid); // 前一半序列继续进行归并排序
mergesort2(SR, mid+1, right); // 后一半序列同时进行归并排序
// 以上递归调用的结束条件是 left ≥ right,也就是分到只有一个数字进行归并排序的时候,一个序列只有一个数字,那么这个序列肯定是有序的
// 以上都是属于“分”的阶段,目的是获得两个有序的数列。
merge(SR, TR, left, mid, right); // 对这两个有序的数列,进行排序,变成一个同样大小但是有序的数列
copy(SR, TR, left, right); //将在TR中排序好的数列给SR,方便SR递归调用归并排序,因为每次两个归并排序的结果都是保存在TR中的,现在要进行下一步就必须在TR数列的基础上面=进行,所以我们把TR给SR
}
else {
;
}
return 0;
}
int main()
{
int n = 10;
int i = 0;
for(; i < n; i++) {
SR[i] = arc4random() % 100 + 1;
printf("%d ", SR[i]);
}
printf("\n");
mergesort2(SR, 0, n-1); // 升序排列
for(i = 0; i < n; i++) {
printf("%d ",SR[i]);
}
printf("\n");
return 0;
}
6.4 时间复杂度和空间复杂度
显而易见,递归的次数为 m = log2n,合并操作的时间消耗是线性的,所以时间复杂度 T(n) 如下:
空间复杂度为 O(n)。
6.5 空间复杂度为 O(1) 的归并排序
因为两个子数组已经有序,合并的步骤可以采取直接插入排序。
如此一来,可以将空间复杂度由 O(n) 降低至 O(1),然而相对的时间复杂度则由 O(nlog2n) 升至 O(n2)。
6.6 性能分析及优化
两种归并排序的算法,分别是采取了空间换时间,以及时间换空间的策略,性能各有优劣,但是通过分析可以得出以下特点:
- 计算机对于频繁开辟小数组空间的消耗,比开辟等价大的单个数组空间,代价要来的更大。
- 使用直接插入排序(从 1/2 处开始),由于拥有较小的最高次幂系数,其性能在长度 n 较小的时候,与 T(n) = O(log2n) 相差不大。
根据以上两个性质,可以在归并排序中,设置一个阈值。超过这个给定的阈值,则采取空间换时间的策略;反之,采用时间换空间的策略,从而提高归并排序的效率。
七、快速排序
7.1 简介
快速排序 Quick Sort,有时又称划分-交换排序(Partition-Exchange Sort)。
快速排序的主要思想是:选取一个数字,通过一次遍历,将这个数字放到其最终的位置,并且保证其最终位置的左侧都小于等于这个数字,其右侧都大于等于这个数字。
与归并排序相同,是“分治法”的又一个实现案例。快速排序中分治的思想体现在哪里呢?
①、把大问题分解成很多子问题(分区操作可以每次使得一位元素有序)
②、解决子问题(进行分区操作,每次使得一位元素有序)
③、所有子问题解决了,那么最大的问题也解决了。
一个优秀的快速排序实现,能比其竞争对手(归并排序、堆排序,都是时间复杂度为 O(nlog2n) 的排序算法),快 2-3 倍。
From Wikipedia: https://en.wikipedia.org/wiki/Quicksort
7.2 快速排序的具体步骤
快速排序的过程可以拆分成以下三个步骤:
- 从数组中选择一个数字。
- 根据这个数字,对整个数组进行一次划分,即:通过一系列的交换,将这个数字放到其最终位置,并且保证其左侧的数字都小于等于它,右侧的数字都大于等于它。
- 对两侧的子数组进行递归。
7.3 快速排序的基本代码
int partition(int array[], int left, int right)
{
// ...
}
void sort(int array[], int left, int right)
{
if (left < right) {
int q = partition(array, left, right);
sort(array, left, q - 1);
sort(array, q + 1, right);
}
}
7.4 划分 - 挖坑取数
快速排序的核心内容:如何对一个数组进行一次划分(partition)。
这里先介绍一种常见的划分算法,我(原作者Gerrard_Feng)称之为“挖坑取数”,具体过程如下:
- 将最左侧的数字作为待划分的数字,在快速排序中,称这个数字是划分的主元(pivot)。
- 从最右侧开始向前寻找,找到第一个比 pivot 小的数字(坑),交换 pivot。
- 此时“坑”的位置被交换到了最左侧,从最左侧的下一个位置开始向后寻找,找到第一个比 pivot 大的数字,交换 pivot。
- 循环从两侧“夹逼”找“坑”的步骤,直至 pivot 到它的最终位置。
int partition(int array[], int left, int right){
int pivot = array[left];
int i = left;
int j = right + 1;
bool forward = NO;
while(i < j) {
while(forward && array[++i] <= pivot && i < j);
while(!forward && array[--j] >= pivot && i < j);
// 交换 i、j 的内容
swap(array, i, j);
forward = !forward;
}
return j;
}
void swap(int arr[], int one, int two){
int temp = arr[one];
arr[one] = arr[two];
arr[two] = temp;
}
其中在“夹逼”过程中的最后一个判断 i < j,是为了防止在夹逼的过程中出现左侧小于右侧的情况。
退出循环后 i = j,所以最后一次交换也不会影响结果。
7.5 划分 - 挖坑取数 - 演示步骤
如果上面的说明过于抽象,这里演示一遍“挖坑取数”在数组 {3, 5, 1, 9, 8, 6, 0, 2, 4, 7} 的执行步骤:
- 取 pivot = 3。
- 从最右侧 7 开始,向前寻找,找到第一个小于等于 3 的数字:2,交换两者位置,得到数组: {2, 5, 1, 9, 8, 6, 0, 3, 4, 7} 。
- 从最左侧的下一个数字 5 开始,向后寻找,找到第一个大于等于 3 的数字:5,交换两者位置,得到数组: {2, 3, 1, 9, 8, 6, 0, 5, 4, 7} 。
- 从最右侧的上一个数字 0 开始,向前寻找,找到第一个小于等于 3 的数字:0,交换两者位置,得到数组: {2, 0, 1, 9, 8, 6, 3, 5, 4, 7} 。
- 从最左侧的下一个数字 1 开始,向后寻找,找到第一个大于等于 3 的数字:9,交换两者位置,得到数组: {2, 0, 1, 3, 8, 6, 9, 5, 4, 7} 。
- 从最右侧的上一个数字 6 开始,向前寻找,找到第一个小于等于 3 的数字,没有找到,得到最终数组:{2, 0, 1, 3, 8, 6, 9, 5, 4, 7}。
- 此时,pivot = 3 的左侧数字全部小于等于 3,右侧数字全部大于等于 3,划分完成。
7.6 划分 - 快慢指针
这里介绍另外一种划分算法,我(原作者)称之为“快慢指针”,具体过程如下:
- 将最右侧的数字作为主元。
- 使用两个指针 faster 和 slower,faster 初始指向第一个数字,slower 初始时指向 faster 上一个数字。
- 从第一个数字开始遍历数组,快指针随着数组遍历的过程增大。
- 遍历数组时,每当数字小于等于 pivot 时,慢指针前进一位,然后交换快慢指针的位置,即慢指针指向的数字,永远小于等于 pivot。
- 当遍历结束时,0-慢指针的最终位置,都保证小于等于 pivot。快指针的最终位置,为 pivot 的前一位。
- 慢指针向前移动一位(指向的数字保证大于等于 pivot,只有指向 pivot 时,等号成立),交换 pivot 与此时慢指针的位置。
int partition(int array[], int left, int right){ int pivot = array[right]; // 慢指针指向小于 pivot 的值的位置 int slower = left - 1;
for (int faster = left; faster < right; ++faster) { if(array[faster] <= pivot) { // 因为发现一个小于 pivot 的值,慢指针向右移一位,用于交换 slower++; swap(array, slower, faster); } } // 最终,慢指针右侧即为 pivot 的位置 swap(array, slower + 1, right); return slower + 1;
}
7.7 划分 - 快慢指针 - 演示步骤
同样地,演示一遍“快慢指针”在数组 A = {3, 5, 1, 9, 8, 6, 0, 2, 4, 7} 的执行步骤:
- 取 pivot = 7,快指针初始位置指向 3,faster = 0,慢指针初始位置在快指针的前一个位置,slower = -1。
- 第一个数,3 ≤ 7,慢指针前进一位 ->交换快慢指针的位置 ->快指针前进一位,得到数组:{3, 5, 1, 9, 8, 6, 0, 2, 4, 7},slower = 0,faster = 1。
- 第二个数,5 ≤ 7,慢指针前进一位 ->交换快慢指针的位置 ->快指针前进一位,得到数组:{3, 5, 1, 9, 8, 6, 0, 2, 4, 7},slower = 1,faster = 2。
- 第三个数,1 ≤ 7,慢指针前进一位 ->交换快慢指针的位置 ->快指针前进一位,得到数组:{3, 5, 1, 9, 8, 6, 0, 2, 4, 7},slower = 2,faster = 3。
- 第四个数,9 > 7,慢指针位置不动 ->保持原来的位置不变 ->快指针前进一位,得到数组:{3, 5, 1, 9, 8, 6, 0, 2, 4, 7},slower = 2,faster = 4。
- 第五个数,8 > 7,慢指针位置不动 ->保持原来的位置不变 ->快指针前进一位,得到数组:{3, 5, 1, 9, 8, 6, 0, 2, 4, 7},slower = 2,faster = 5。
- 第六个数,6 ≤ 7,慢指针前进一位 ->交换快慢指针的位置 ->快指针前进一位,得到数组:{3, 5, 1, 6, 8, 9, 0, 2, 4, 7},slower = 3,faster = 6。
- 第七个数,0 ≤ 7,慢指针前进一位 ->交换快慢指针的位置 ->快指针前进一位,得到数组:{3, 5, 1, 6, 0, 9, 8, 2, 4, 7},slower = 4,faster = 7。
- 第八个数,2 ≤ 7,慢指针前进一位 ->交换快慢指针的位置 ->快指针前进一位,得到数组:{3, 5, 1, 6, 0, 2, 8, 9, 4, 7},slower = 5,faster = 8。
- 第九个数,4 ≤ 7,慢指针前进一位 ->交换快慢指针的位置 ->快指针前进一位,得到数组:{3, 5, 1, 6, 0, 2, 4, 9, 8, 7},slower = 6,faster = 9。
- 此时,快指针 faster = 9,不满足小于 right = 9 的条件,退出循环,将主元 pivot = 7,与慢指针的后一个数字 A[7] = 9,做一次交换,得到最终数组:{3, 5, 1, 6, 0, 2, 4, 7, 8, 9},划分完成。
7.8 快速排序的时间复杂度和稳定性
- 最坏情况时间复杂度 T(n) = O(n2)。
- 最好情况时间复杂度 T(n) = O(nlog2n)。
- 平均情况时间复杂度 T(n) = O(nlog2n)。
- 最坏情况空间复杂度 T(n) = O(n)。
最好情况空间复杂度 T(n) = O(log2n)。
快速排序,始终只使用原来的数组空间 O(1),真正消耗的空间,是由递归的深度决定的。
无论哪一种快速排序排序的划分算法,都会打破快速排序的稳定性。
7.9 代码
#include <stdio.h>
#include <stdlib.h>
#define n 5
int a[n];
int temp;
static int count = 0;
void swap_t(int a[], int i, int j)
{
temp = a[i];
a[i] = a[j];
a[j] = temp;
count++;
}
int par(int a[], int left, int right)
{
int i = left;
int x = a[left]; // 基准元素
for(int j = left + 1; j <= right; j++)
{
if(a[j] <= x) { // 比 x 小的换到左侧
i++;
swap_t(a, i, j);
}
}
swap_t(a, left, i);
return i; // 轴位置
}
void QuickSort(int a[],int left, int right)
{
if(left < right) {
int r = par(a, left, right);
QuickSort(a, left, r - 1);
QuickSort(a, r + 1, right);
}
}
int main()
{
int i = 0;
for(;i < n; i++) {
a[i] = arc4random() % 100 + 1;
printf("%d ", a[i]);
}
printf("\n");
QuickSort(a, 0, n-1);
for(i = 0; i < n; i++) {
printf("%d ", a[i]);
}
printf("\n");
printf("交换了 %d 次", count);
return 0;
}
87 71 39 75 61
39 61 71 75 87
交换了 8 次
随机选择元素:
#include <stdio.h>
#include <stdlib.h>
#define n 5
int a[n];
int temp;
void swap_t(int a[], int i, int j)
{
temp = a[i];
a[i] = a[j];
a[j] = temp;
}
int par(int a[], int left, int right)
{
int i = left;
int x = a[left];
for(int j = left + 1; j <= right; j++) {
if(a[j] <= x) {
i++;
swap_t(a, i, j);
}
}
swap_t(a, left, i);
return i;
}
/// 生成随机的基准元素下标
int Random(int left, int right)
{
return arc4random() % (right - left + 1) + left;
}
// 用随机的基准元素进行快速排序
int Randomizedpar(int a[], int left, int right)
{
int i = Random(left, right);
swap_t(a, left, i); // 第一个和第 i 个交换,相当于有了一个随机基准元素
return par(a, left, right);
}
void RandomizedQuickSort(int a[], int left, int right)
{
if(left < right) {
int r = Randomizedpar(a, left, right);
printf("%d到%d之间的随机数:%d\n", left, right, r);
RandomizedQuickSort(a, left, r - 1);
RandomizedQuickSort(a, r + 1, right);
}
}
int main()
{
int i = 0;
for(; i< n; i++) {
a[i] = arc4random() % 100 + 1;
printf("%2d ", a[i]);
}
printf("\n");
RandomizedQuickSort(a, 0, n-1);
for(i = 0; i < n; i++) {
printf("%2d ", a[i]);
}
printf("\n");
return 0;
}
7.10 快速排序的性能瓶颈与优化策略
八、内容来源
Gerrard_Feng - 排序算法(一) 冒泡排序
Gerrard_Feng - 排序算法(二) 选择排序
常见排序算法导读(3)[简单选择排序]
Gerrard_Feng - 排序算法(三) 直接插入排序
Gerrard_Feng - 排序算法(四) 希尔排序
Gerrard_Feng - 排序算法(五)堆排序
使用减治法求解堆排序
堆排序原理及算法实现(最大堆)
Gerrard_Feng - 排序算法(六)归并排序
Gerrard_Feng - 排序算法(七)快速排序
快速排序算法分析和实现