排序
- 一、排序的概念和应用
- 1.1、排序的概念
- 1.2、排序的应用
- 1.3、常见的排序算法
- 二、插入排序
- 2.1、直接插入排序
- 2.2、希尔排序
- 3.1.直接选择排序
- 3.2、堆排序
- 四、交换排序
- 4.1、冒泡排序
- 4.2、快速排序
- 4.2.1、hoare版本
- 4.2.2、挖坑法
- 4.2.3、前后指针版本
- 4.2.4、快排非递归(利用栈)
- 4.2.5、快排非递归(利用队列)
- 4.2.6、三路并排
- 五、归并排序
- 5.1、归并排序递归
- 5.2、归并排序非递归
- 六、非比较排序
- 6.1、计数排序
- 七、总结排序算法稳定性和时间复杂度
一、排序的概念和应用
1.1、排序的概念
- 排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
- 排序的稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
- 内部排序:数据元素全部放在内存中的排序。
- 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2、排序的应用
排序在生活中随处可见,比如,商品的价格排序,中国各大高校的排名,福布斯2022全球富豪榜,高考分数的排名等等。
1.3、常见的排序算法
二、插入排序
插入排序基本思想:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列
2.1、直接插入排序
直接插入排序时间复杂度为O(logN)的算法,它有着稳定和速度快的优点,缺点是比较次数越少,插入点后的数据移动就越多,特别是数据庞大的时候就需要大量的移动数据。
代码描述:
#include<iostream>
using namespace std;
void InsertSort(int* nums,int len) //给出待排序数组和数组元素个数
{
for (int i = 0; i < len - 1; ++i) //循环遍历 i<len-1的原因是防止越界,在插入过程中 会把最后一个元素插入排序数组中
{
int end = i; //end的下标代表着已经排序好的数组下标
int key = nums[end + 1]; //待排序的元素
while (end >= 0) //递增排序
{
if (key >= nums[end]) //如果待排序元素比已经排序数组的最大还大,就不用动
break;
else //如果比排序数组的最大元素小 那么就需要递归判断
{
nums[end + 1] = nums[end]; //直接把数组的最后一个元素后移一个位置,已排序数组范围变大,腾出一个位置
end--; //end--,缩小比较范围,让该元素继续跟前面的比较 直到跳出循环
}
}
nums[end + 1] = key; //把待排序元素插入数组中,end+1的原因是保持稳定性,不改变原数组
}
}
//测试
int main()
{
int nums[] = { 15,3,5,26,31,7,56,24,19,48,77 };
InsertSort(nums, sizeof(nums) / sizeof(nums[0]));
for (auto& n : nums)
{
cout << n << " "; //C++范围for,也可以用C语言遍历打印
}
cout << endl;
}
然后我们来简单分析一下时间复杂度、空间复杂度:
首先空间复杂度O(1),毫无疑问;
那么时间复杂度?
O (N ^ 2) 插入排序最坏的情况就是对逆序进行排序,那么每一趟都会挪动数据:
第一次:挪动1次 第二次:挪动2次 第三次:挪动3次 …… 第n-1次:挪动n-1次 很明显是个等差数列!
然后根据大O渐近表示法,时间复杂度就是O(N ^ 2); 那么既然最坏的情况我们都讨论了,最好的情况嘞!
最好的情况就是数据已经有序了,那么我们每一次都不需要挪动数据,只需要比较一次,就break掉了,所以最好的情况的时间复杂度就是O(N)这可是个非常不错的速度;
其实我们在认真想一下,如果数组越接近有序的话是不是插入排序的时间复杂度就越接近O(N)!!!
相反数组越接近逆序,时间复杂度就越接近O(N^2); 为此呢我们从这一点看出,插入排序是一个适应性比较强的排序算法!!
总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2.2、希尔排序
排序思想:
希尔排序法又称缩小增量法。是直接插入排序的改进版本,先选定一个整数gap,将其值赋为数据量的个数,然后将数据分为以gap为间隔的组先进行预排序。
预排序的规则和直接插入排序很相似,只不过直接插入排序是每次将相邻的两个数据进行比较并插入,而希尔排序则是每次将下标为n和n+gap的两个数据进行比较并插入,每一趟比较完成之后,gap变为gap/2或者gap/3+1,直到gap=1循环结束。
当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序了。最后一趟经过gap==1的直接插入排序后,数组就成功变成了有序。
那么我们为什么要进行预排序呢?主要是想快一点将大数排到后面去,小数排到前面去;
gap越大,数组预排序过后越接近无序;
gap越小,数组预排序过后越接近有序;
希尔排序的时间复杂度并不是很好计算,因此有许多不同的书籍给出的结论都有所差异,大约在O(n1.25)~O(1.6*n1.25)
之间。这里我们就折中一下记作O(n1.3) 。
代码描述:
void ShellSort(int* a, int n) //传入数组和数组元素个数
{
int gap = n; //记录gap初始化位元素个数
while (gap > 1)
{
gap = gap / 3 + 1; //也可以是 gap/=2; 最终gap会变成1 成为直接插入排序
for (int i = 0; i < n - gap; i++) //循环遍历 每个gap比较一次 比较 n-gap次
{
int end = i; //记录i的位置
int tmp = a[end + gap];//当前要比较的元素 提前记录便于后面插入到前面
while (end >= 0)
{
if (a[end] > tmp) //如果当前位置大 需要换
{
a[end + gap] = a[end];
end -= gap; //跳出循环 最后可能会多判断一次满足条件后走else跳出
}
else //不需要换 跳出
{
break;
}
}
a[end + gap] = tmp; //替换,end前面减去了gap 现在end+gap 指向原本的i位置
}
}
}
int main()
{
int nums[] = { 15,3,5,26,31,7,56,24,19,48,77 };
ShellSort(nums, sizeof(nums) / sizeof(nums[0]));
for (auto& n : nums) //C++范围for,也可以用C语言遍历打印
{
cout << n << " ";
}
cout << endl;
}
希尔排序的特性总结:
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
- 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定:
总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^1.3)
- 空间复杂度:O(1)
- 稳定性:不稳定
3.1.直接选择排序
选择排序(Selectionsort)是一种简单直观的排序算法。它的工作原理是:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。以此类推,直到全部待排序的数据元素的个数为零。
排序思想:
直接选择排序思想是对每个下标i,从i后面的元素中选择最小的那个和s[i]交换。
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。这里我们每次都选择出一个最大值和最小值,每次将最大值与待排序的区间的最前面的位置交换,将最小值与待排序的区间的最后面的位置交换。
这里我们需要注意的是:如果已经将最小值和待排序区间最前面的位置交换后,发现最前面的位置和最大值的位置发生了冲突,需要特殊判断并处理一下。
直接选择排序思想是对每个下标i,从i后面的元素中选择最小的那个和s[i]交换。
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。这里我们每次都选择出一个最大值和最小值,每次将最大值与待排序的区间的最前面的位置交换,将最小值与待排序的区间的最后面的位置交换。
这里我们需要注意的是:如果已经将最小值和待排序区间最前面的位置交换后,发现最前面的位置和最大值的位置发生了冲突,需要特殊判断并处理一下。
但是这里呢我们做一下小小的优化,我们一次性选择两个数出来,具体方案就是:
我们在[left,right]区间选出最小值的下标和最大值的下标将其分别和left位置值交换,right值交换,
然后缩小区间,left++,right–;当区间只剩下一个元素的时候我们就可以停止了!
void Swap(int* a, int* b) //交换元素
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void SelectSort(int* a, int n) //待排序数组 数组元素个数
{
int begin = 0, end = n - 1; //两个指针 指向开始和结尾
while (begin < end) //循环条件
{
int maxi = begin; //记录最大值下标
int mini = begin; //记录最小值下标
for (int i = begin + 1; i <= end; i++) //循环
{
if (a[i] < a[mini])
{
mini = i; //找到最小值下标
}
if (a[i] > a[maxi])
{
maxi = i; //找到最大值下标
}
}
Swap(&a[mini], &a[begin]);//交换最小值和begin位置
if (begin == maxi) //如果最大值在第一个会被交换到mini位置 需要更新最大值位置
maxi = mini;
Swap(&a[maxi], &a[end]); //交换最大值和最后一个
begin++; //双指针移动
end--;
}
}
int main()
{
int nums[] = { 15,3,5,26,31,7,56,24,19,48,77 };
SelectSort(nums, sizeof(nums) / sizeof(nums[0]));
for (auto& n : nums) //C++范围for,也可以用C语言遍历打印
{
cout << n << " ";
}
cout << endl;
}
时间复杂度不用说了,O(N^2);
并且对于这个排序来说没有什么最坏和最优情况,不管是什么情况都会去遍历一遍!
直接选择排序的特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
3.2、堆排序
排序思想:
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
这里我们简单来分析一下堆排序的思路,每次取堆顶数据和最后一个数据进行交换,然后再将除了最后一个元素外的所有元素进行向下调整使其再次成为一个堆。直到待调整的堆中的元素个数为0。
代码描述:
//交换函数
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
//向下调整建大堆
void AjustDown(int* a, int parent, int n) //需要堆数组 知道父亲的下标 和 数组元素个数
{
int child = parent * 2 + 1; //找到父亲的左孩子下标
while (child < n) //沿着编号为parent的这条路径向下调整
{
if (child + 1 < n && a[child + 1] > a[child]) //如果存在右孩子 同时 右孩子大于左孩子
child++; //我们要找到的是最大的孩子 child++
if (a[parent] < a[child]) //如果父亲小于孩子
{
Swap(&a[parent], &a[child]); //交换父亲和孩子
parent = child; //让孩子等于父亲 继续沿着parent调整
child = parent * 2 + 1;
}
else
{
break; //跳出循环 原因是已经调整完毕 找到最大的值放在堆顶
}
}
}
//向下调整建小堆
void AjustDown1(int* a, int parent, int n)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] < a[child]) //在此处调整
child++;
if (a[parent] > a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
//堆排序 降序 向下调整建立小堆
void HeapSort1(int* a, int n)
{
//先建堆(小根堆)
for (int i = (n - 2) >> 1; i >= 0; i--)
AjustDown1(a, i, n);
//堆排序
int size = n - 1;
while (size > 0)
{
Swap(&a[0], &a[size]);
AjustDown1(a, 0, size);
size--;
}
}
//堆排序 升序 向下调整建立大堆
void HeapSort(int* a, int n) //传入数组 和数组元素个数
{
//先建堆
for (int i = (n - 2) >> 1; i >= 0; i--) //从最后一个元素的父节点开始调整建堆
{
AjustDown(a, i, n); //向下调整建立大堆
}
//堆排序
int size = n - 1; //最后一个元素下标
while (size > 0) //循环判断 不断调整
{
Swap(&a[0], &a[size]); //交换堆顶和最后一个
AjustDown(a, 0, size); //堆顶元素向下调整
size--; //size-- 再次找到数组前面的元素 进行排序
//每次排序找到最大的数 放在后面 前面的不断调整 找到剩下的最大是数字
}
}
int main()
{
int nums[] = { 15,3,5,26,31,7,56,24,19,48,77 };
//HeapSort(nums, sizeof(nums) / sizeof(nums[0]));
HeapSort1(nums, sizeof(nums) / sizeof(nums[0]));
for (auto& n : nums) //C++范围for,也可以用C语言遍历打印
{
cout << n << " ";
}
cout << endl;
}
堆排序的特性总结:
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
四、交换排序
4.1、冒泡排序
主要思想就是,每进行一次冒泡就将最大值或最小值冒到了数组最后一个位置,然后数组元素减减,因为我已经将最大值或最小值冒到最后面了,不需要再对最后一个元素进行冒泡了,因此我们的数组元素个数要-1,减的就是已经冒好的元素!
void BubbleSort(int*a,int n)
{
for (int i = 0; i < n - 1; ++i) //需要排序的趟数
{
int flag = 0; //标记位
for (int j = 0; j < n - i - 1; ++j) //要排序的次数
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]); //交换满足升序
flag = 1; //设置标记位为1
}
}
if (flag == 0) //如果标记位仍为0,说明已经有序,不需要再比较了,直接退出
{
break;
}
}
}
这里我们稍微做了一点点优化,我们如果在某一次冒泡中,发现数组已经有序了,那么就不用再进行剩下的冒泡了,直接break掉就好了,为了实现这一操作,我们定义了标记参数
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
4.2、快速排序
其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
4.2.1、hoare版本
int PartSort(int* a, int begin, int end)
{
int left = begin;
int right = end;
int key = left;
while (left < right)
{
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[key]);
key = left;
return key;
}
void QuickSort(int* a, int begin, int end)
{
if (begin >= end) return;
int key = PartSort(a, begin, end);
QuickSort(a, begin, key - 1);
QuickSort(a, key + 1, end);
}
int main()
{
int nums[] = { 6,1,2,7,9,3,4,5,10,8 };
int len = sizeof(nums) / sizeof(nums[0]);
for (auto& t:nums)
{
cout << t << " ";
}
cout << endl;
//InsertSort(nums, len);
//selectSort(nums, len);
//ShellSort(nums, len);
//HeapSort(nums, len);
//BubbleSort(nums, len);
QuickSort(nums, 0, len - 1);
for (auto& t : nums)
{
cout << t << " ";
}
cout << endl;
}
单趟排序图解,如下:
如果以左边的left为key那么就要右边先找小
如果以右边的right为key那么左边就要先找大
原因
以排升序且选用最左边的值为key为例。要确保left与right相遇位置的值小于基准值,就必须让right先进行移动。
情况一: right先移动,停止后left进行移动与right相遇,相遇位置即为right的位置(必然比基准值小)
情况二: right先移动,right在找到比基准值小的值之前与left相遇,相遇位置是left所在的位置,该位置的值是上一轮交换过来的(必然比基准值小)
总结:
- 时间复杂度: O(NlogN)
- 空间复杂度: O(logN)
- 稳定性: 不稳定
4.2.2、挖坑法
挖坑法本质上与Hoare版本并无不同,只是从思想上而言更容易理解
图解如下:
void Swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
void QuickSort(int* arr, int begin, int end)
{
if (begin >= end)return;
int left = begin, right = end;
int pivot = left; //记录基准值的下标
int key = arr[pivot]; //记录基准值是多少
while (left < right)
{
//右边找小,放在左边
while (left < right && arr[right] >= key) {
--right;
}
arr[pivot] = arr[right];
pivot = right;
//左边找大,放在右边
while (left < right && arr[left] <= key) {
++left;
}
arr[pivot] = arr[left];
pivot = left;
}
pivot = left;
arr[pivot] = key;
QuickSort(arr, begin, pivot - 1);
QuickSort(arr, pivot + 1, end);
}
int main()
{
int nums[] = { 6,1,2,7,9,3,4,5,10,8 };
int len = sizeof(nums) / sizeof(nums[0]);
for (auto& t:nums)
{
cout << t << " ";
}
cout << endl;
//InsertSort(nums, len);
//selectSort(nums, len);
//ShellSort(nums, len);
//HeapSort(nums, len);
//BubbleSort(nums, len);
QuickSort(nums, 0, len - 1);
for (auto& t : nums)
{
cout << t << " ";
}
cout << endl;
}
4.2.3、前后指针版本
本质是相同的!!
void Swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
void QuickSort(int*a,int begin,int end)
{
if (begin >= end) return;
int key = begin; //基准值下标
int prev = begin; //前指针
int cur = begin + 1; //后指针
while (cur <= end) //退出条件 后指针大于end 前指针不动 后指针找小 找到后 prev++ 交换前后下标内的值
{
if (a[cur] < a[key] && ++prev != cur) //避免没必要的交换
{
Swap(&a[prev], &a[cur]);
}
++cur;
}
Swap(&a[key], &a[prev]); //交换基准值和prev
QuickSort(a, begin, prev - 1);
QuickSort(a, prev+1, end);
}
int main()
{
int nums[] = { 6,1,2,7,9,3,4,5,10,8 };
int len = sizeof(nums) / sizeof(nums[0]);
for (auto& t:nums)
{
cout << t << " ";
}
cout << endl;
//InsertSort(nums, len);
//selectSort(nums, len);
//ShellSort(nums, len);
//HeapSort(nums, len);
//BubbleSort(nums, len);
QuickSort(nums, 0, len - 1);
for (auto& t : nums)
{
cout << t << " ";
}
cout << endl;
}
4.2.4、快排非递归(利用栈)
递归的方式容易造成栈溢出
栈的特点就是先进行后出:
而我们的单躺排序是对一个区间进行操作,因此我们需要往栈里面存入区间,取的时候也应该按照区间去取:
基本思想:
先将待排序列的第一个元素的下标和最后一个元素的下标入栈。(区间入栈)
当栈不为空时,读取栈中的信息(一次读取两个: left、right),然后进行单趟排序,排完后获得了key的下标,然后判断key的左序列和右序列是否还需要排序,若还需要排序,就将相应序列的区间入栈;若不需排序了(序列只有一个元素或是不存在),就不需要将该序列的信息入栈。
反复执行步骤2,直到栈为空为止。
#include<iostream>
#include<stack>
using namespace std;
void InsertSort(int* nums, int n) //插入排序 向已排序数组插入新元素
{
for (int i = 0; i < n - 1; ++i)
{
int end = i;
int key = nums[end + 1];
while (end >= 0)
{
if (nums[end] > key)
{
nums[end + 1] = nums[end];
--end;
}
else
{
break;
}
}
nums[end + 1] = key;
}
}
void QuickSortStack(int* nums, int left, int right)
{
stack<int> st;
st.push(right);
st.push(left);
while (!st.empty())
{
int left = st.top();
st.pop();
int right = st.top();
st.pop();
if (right - left + 1 < 10)//小区间优化
{
InsertSort(nums + left, right - left + 1);
continue;
}
//挖坑单趟排序
int begin = left, end = right;
int pivot = begin;
int key = nums[begin];
while (begin < end)
{
//右边找小,放在左边
while (begin < end && nums[end] >= key)
{
--end;
}
nums[pivot] = nums[end];
pivot = end;
//左边找大,放在右边
while (begin < end && nums[begin] <= key)
{
++begin;
}
nums[pivot] = nums[begin];
pivot = begin;
}
pivot = begin;
nums[pivot] = key;
if (right > pivot + 1)
{
st.push(right);
st.push(pivot + 1);
}
if (left < pivot - 1)
{
st.push(pivot - 1);
st.push(left);
}
}
}
int main()
{
int nums[] = { 6,1,2,7,9,3,4,5,10,8 };
int len = sizeof(nums) / sizeof(nums[0]);
for (auto& t:nums)
{
cout << t << " ";
}
cout << endl;
//InsertSort(nums, len);
//selectSort(nums, len);
//ShellSort(nums, len);
//HeapSort(nums, len);
//BubbleSort(nums, len);
//QuickSort(nums, 0, len - 1);
QuickSortStack(nums, 0, len - 1);
for (auto& t : nums)
{
cout << t << " ";
}
cout << endl;
}
4.2.5、快排非递归(利用队列)
#include<queue>
void QuickSortQueue(int* nums, int left, int right)
{
queue<int>q;
q.push(left);
q.push(right);
while (!q.empty())
{
int left = q.front();
q.pop();
int right = q.front();
q.pop();
if (right - left + 1 < 15)//小区间优化
{
InsertSort(nums + left, right - left + 1);
continue;
}
//挖坑单趟排序
int begin = left, end = right;
int pivot = begin;
int key = nums[begin];
while (begin < end)
{
//右边找小,放在左边
while (begin < end && nums[end] >= key)
{
--end;
}
nums[pivot] = nums[end];
pivot = end;
//左边找大,放在右边
while (begin < end && nums[begin] <= key)
{
++begin;
}
nums[pivot] = nums[begin];
pivot = begin;
}
pivot = begin;
nums[pivot] = key;
if (left < pivot - 1)
{
q.push(left);
q.push(pivot - 1);
}
if (pivot + 1 < right)
{
q.push(pivot+1);
q.push(right);
}
}
}
int main()
{
int nums[] = { 6,1,2,7,9,3,4,5,8,10,15,24,18,34};
int len = sizeof(nums) / sizeof(nums[0]);
for (auto& t:nums)
{
cout << t << " ";
}
cout << endl;
//InsertSort(nums, len);
//selectSort(nums, len);
//ShellSort(nums, len);
//HeapSort(nums, len);
//BubbleSort(nums, len);
//QuickSort(nums, 0, len - 1);
//QuickSortStack(nums, 0, len - 1);
QuickSortQueue(nums, 0, len - 1);
for (auto& t : nums)
{
cout << t << " ";
}
cout << endl;
}
三数取中:用来获取较为合适的key值
int GetMid(int* arr, int left, int right)//三数取中
{
int mid = (left + right) >> 1;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right]) return mid;
else if (arr[left] > arr[right]) return left;
else return right;
}
else//arr[left] >= arr[mid]
{
if (arr[mid] > arr[right]) return mid;
else if (arr[left] > arr[right]) return right;
else return left;
}
}
4.2.6、三路并排
其实上面的优化已经很好了,但是对于数组中的元素全是同一个元素这也的特例快排还是不能很好的解决,就算有上面的优化,此时快排的时间复杂度也会退化到O(N^2),为了解决这个问题大佬们提出了一个nb的算法,这个算法叫做三路并排,就是将数组分为上个区间:严格小于key的区间、等于key的区间、严格大于可以的区间:
算法的主要思路呢,是利用三个指针:left、right、cur;cur从left开始,然后key先保存一下left位置的值(也可以以右边作为基准值,那么key也就保存right所指的值),然后cur不断往后走;
1、如果cur位置的值小于key,那么就将cur此时所指的值往左边“甩”,即:交换left位置和cur位置的值,然后left++,cur++;
2、如果cur位置的值等于key,那么cur直接往后走;
3、如果cur位置的值大于key,交换cur位置的值和right位置的值;
void QuickSort3(int* nums, int left, int right)//三路并排(没有加三数取中、小区间优化)
{
if (left >= right)
return;
int begin = left;
int end = right;
int cur = left;
int key = nums[cur];
while (cur <= right)
{
if (nums[cur] < key)
{
Swap(nums+cur , left + nums);
left++;
cur++;
}
else if (nums[cur] == key)
cur++;
else
{
Swap(nums + cur, nums + right);
right--;
}
}
QuickSort3(nums, begin, left - 1);
QuickSort3(nums, right + 1, end);
}
总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
五、归并排序
基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并;
5.1、归并排序递归
简单来说就是在两个有序区间之内选数,每次都选小的出来,放入临时数组中,当两个有序数组都选完过后,我们就直接将临时数组里面的元素在拷回原数组!
但是现在我们如何得到一个有序区间?
我们就将区间不断二分呗!当区间只剩下一个元素不就可以看成一个有序区间了?
代码实现:
void _MergeSort(int* a, int begin, int end, int* tmp) {
if (begin >= end)
return;
int mid = (begin + end) / 2;
//递归分解,化大为小
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//归并
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2) {
//if (a[begin1] > a[begin2]) {//逆序
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, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n) {
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL) {
perror("malloc fail");
exit(-1);
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
总共logN层,每一层大概遍历N次,除了第一层不用归并之外,每一层的每个小区间都要归并,每一层的所有小区间合计起来就是O(N),因此logN层的时间复杂度就是O(NlogN)
所有归并排序的时间复杂度就是O(NlogN);
空间复杂度:这颗“树”的深度就是logN,也就是递归深度,我们要建立logN个栈帧,然后加上额外的临时空间O(N),所以准确的空间复杂度就是O(logN+N),然后大O渐进表示就是O(N);
5.2、归并排序非递归
归并排序的非递归的话,用如果还是像快排的非递归方式那样的话,是不行的也就是说单独用栈或队列的话完不成,主要是该递归属于“后序遍历”,我们归并完了这一层,但是我们找不到上一层,我们也就无法回到上一层,继续归并!如果我们能知道上一层的区间话,我们就能很好的对整个数组进行归并;为此我们需要从底层开始归并,我们可以设置一个gap,表示每个小区间的元素个数,然后我们两两一个大区间,(gap初始化为1)对这个大区间进行归并
void MergeSortNonR(int* a, int n) {
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL) {
perror("malloc fail");
exit(-1);
}
//归并每组数据个数,从1开始,直接归并
int rangeN = 1;
while (rangeN < n) {
for (int i = 0; i < n; i += rangeN * 2) {
int begin1 = i, end1 = i + rangeN - 1;
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
int j = i;
//end1 begin2 end2越界
if (end1 >= n) {
//修正成不存在的区间 >归并完了拷贝
end1 = n - 1;
begin2 = n;
end2 = n - 1;
//break;//跳出,必须每组归并拷贝才能用这种方法
}
else if (begin2 >= n) {
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n) {
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2) {
//if (a[begin1] > a[begin2]) {//逆序
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));
}
//整体归并拷贝
rangeN *= 2;
}
free(tmp);
tmp = NULL;
}
六、非比较排序
6.1、计数排序
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整型数据。
//计数排序(适合范围集中的数据)(只适合整型)
// 时间复杂度O(N+range)
//空间复杂度O(range)
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* countA = calloc(range,sizeof(int));//与malloc区别就是直接初始化为0
if (countA == NULL) {
perror("calloc fail");
exit(-1);
}
// 1.统计次数,映射的方式,正负数均可统计
for (int i = 0; i < n; i++) {
countA[a[i] - min]++;
}
//2.排序
int k = 0;
for (int j = 0; j < range; j++) {
while (countA[j]--) {
a[k++] = j + min;
}
}
free(countA);
}
时间复杂度:O(N + range)
空间复杂度:O(range)
稳定性: 对于只能排序整型的排序算法,无讨论是否稳定的必要性