一些基本概念
排序方法的稳定性
对于一个乱序的代码,如果其中值相同的两个记录在排序后前后关系不变,我们认为该排序方法是稳定的。
内排序和外排序
内排序指的是所有待排序的记录都在内存中。外排序则是由于数据过大部分数据在内存在导致的,在排序过程中需要交换数据。
排序算法性能的影响因素
1.时间性能
对于内排序中,主要操作主要是:比较和移动。高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数。
2.辅助空间
辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。
3.算法的复杂性
算法根据复杂性可以分为简单算法(冒泡排序、简单选择排序、直接插入排序)和改进算法(希尔排序、堆排序、归并排序、快速排序)
什么是记录有序?
当在一组记录中,小的记录集中在头部,大的记录集中在尾部,不大不小的在中间的时候,我们称这个记录基本有序。
七个具体的排序算法
冒泡排序
1.简单的交换排序
锁定第一个数,在剩下的数中寻找比第一个数小的数,然后交换他们的位置。即,双重循环,每次找到剩下未排序数组中的最大/小值。
vector<int> nums{9,1,5,8,3,7,4,6,2};
for(int i=0;i<nums.size();i++){
for(int j=i+1;j<nums.size();j++){
if(nums[i]>nums[j]){
//按照升序排列
swap(nums,i,j);
}
}
}
优点:简单。
缺点:对剩余未排序序列无帮助,甚至具有反效果。
2.冒泡排序算法
锁定第一个数,剩下的数从末端开始两两对比,将两者中更小的那个数向上冒(交换)。
vector<int> nums{9,1,5,8,3,7,4,6,2};
for(int i=0;i<nums.size();i++){
for(int j=nums.size()-2;j>=i;j++){
if(nums[j]>nums[j+1]){
//按照升序排列
swap(nums,j,j+1);
}
}
}
优点:较简单交换排序,每次确定一个数的过程中,还能对未排序序列进行一个梳理。
缺点:已经完成的排列的子序列仍然需要遍历比较,从而空间复杂度和简单交换排序是一样的(O(N^2))。
3.冒泡排序优化
优化的目的就是为了当序列已经排好以后,可以提前结束循环。为此需要使用一个标记变量flag,来确定序列是否已经排列完成了。
vector<int> nums{9,1,5,8,3,7,4,6,2};
bool flag =true;
for(int i=0;i<nums.size() && flag;i++){
flag=false;
for(int j=nums.size()-2;j>=i;j++){
if(nums[j]>nums[j+1]){
//按照升序排列
swap(nums,j,j+1);
flag=true;
}
}
}
优点:可以避免已有序情况下的格外无效遍历。
缺点:最坏情况下的时间复杂度为:O(N2)。时间复杂度分析方法:最好情况下,序列本身就是有序的,那么进行(n-1)次循环即可,即O(N)。最坏情况下是完全逆序的,那么就需要比较n(n-1)/2次,即O(N2)。
简单选择排序
与冒泡逢小必换的方式相比,简单选择排序每次通过遍历找到当前序列中最小的那个值,然后再和最前面的值进行一次交换。
vector<int> nums{9,1,5,8,3,7,4,6,2};
int i,j,min;
for(i=0;i<nums.size();i++){
min=i;
for(j=i+1;j<nums.size();j++){
if(nums[min]>nums[j]){
min=j;
}
}
if(i!=min)
swap(L,i,min);
}
优点:最明显的优点在于交换移动记录的次数少。
缺点:时间复杂度无论情况的好坏一直都是O(N2)。然而由于交换次数少,性能上要略优于冒泡排序。
直接插入排序
//在原有的数据上扩充一格作为哨兵
int length =nums.size();
vector<int> insert_nums(length+1);
for(int i=1;i<length+1;i++){
insert_nums[i]=nums[i-1];
}
int i,j;
for(i=2;i<insert_nums.size();i++){
if(insert_nums[i]<insert_nums[i-1]){
insert_nums[0]=insert_nums[i];
for(j=i-1;insert_nums[j]>insert_nums[0];j--){
insert_nums[j+1]=insert_nums[j];
}
insert_nums[j+1]=insert_nums[0];
}
}
优点:空间复杂度上只使用了一个辅助位;时间复杂度最好情况可以达到O(n),最坏情况下为比较次数为(n+2)(n-1)/2,移动次数为(n+4)(n-1)/2;但是如果记录是随机的,那么根据概率相同的原则,平均比较和移动次数约为n2/4。因此时间复杂度为O(n2).效果要比冒泡和简单选择排序要好一点。尤其是记录基本有序和记录数量比较少的情况下
希尔排序
在记录量小的条件下,直接插入排序具有明显的优势,因此很容易想到将记录分割成多个小组,但是如果组内记录大小差距很大,即记录不是基本有序的,那么小组排完序后,对整个记录来说意义不大。因此希尔排序的核心就是通过跳跃式直接插入排序将记录进行整理,使得其变得基本有序,从而突破了O(N2)的排序速度瓶颈。
//在原有的数据上扩充一格作为哨兵
int length =nums.size();
vector<int> insert_nums(length+1);
for(int i=1;i<length+1;i++){
insert_nums[i]=nums[i-1];
}
int incream=length;
do{
incream=incream/3+1;
for(i=incream+1;i<insert_nums.size();i++){
if(insert_nums[i]<insert_nums[i-incream]){
insert_nums[0]=insert_nums[i];
for(j=i-incream;j>0 && insert_nums[j]>insert_nums[0];j-=incream){
insert_nums[j+incream]=insert_nums[j];
}
insert_nums[j+incream]=insert_nums[0];
}
}
}
while(incream>1);
优点:由于通过优化使得记录不断趋近有序序列。因此突破了算法速度的极限,达到了O(n3/2)。
缺点:由于跳跃性的移动,使得该方法不是一种稳定的排序算法。同时算法的效果受到跳跃间隔的影响。
堆排序
堆排序是对简单选择排序的改进,即在每次遍历寻找最小值的时候,对其他被遍历也进行相应的调整。
“堆”是一颗完全二叉树:每个节点都大于/小于或等于其左右孩子结点,称为大顶堆/小顶堆。
基于大顶堆,其基本原来就是每次将整理好的堆顶元素与末尾元素进行交换,然后将剩下的元素进行堆排序。
int length =nums.size();
int i;
for(i = length/2;i>=0;i--){
HeapAdjust(nums,i,length);
}
for(i=length;i>0;i++){
swap(nums,0,i);
HeapAdjust(nums,0,i-1);
}
void HeapAdjust(vector<int> & nums,int i,int j){
int temp=nums[i];
for(k=i*2;i<=j;i*=2){
if(k<j && nums[k]<nums[k+1])
k++;
if(temp>nums[k]) break;
nums[i]=nums[k];
nums[k]=temp;
s=j;
}
nums[s]=temp;
}
优点:由于对初始排序不敏感,因此对于任意的情况,时间复杂度为O(nlogn)。
缺点:需要构建堆,不适合数量少的排序,而且是不稳定排序。
归并排序
1.递归归并排序
两两排序合并,获得更大的子数组再排序合并。
void MergeSort(vector<int> nums){
MSort(nums,nums,1.nums.size())
}
void MSort(vector<int> &n_in,vector<int> &n_out,int s,int t){
int mid;
vector<int> temp(n_in.size());
if(s==t) n_out[s]=n_in[s];
else{
mid=(s+t)/2;
MSort(n_in,temp,s,mid);
MSort(n_in,temp,mid+1,t);
Merge(temp,n_out,s,m,t);
}
}
void Merge(vector<int>&n_in,vector<int>&n_out,int i,int m,int n){
int i,k,l;
for(j=m+1,k=i;i<=m && j<=n;k++){
if(n_in[i]<n_in[j])
n_out[k]=n_in[i++];
else
n_out[k]=n_in[j++];
}
if(i<=m){
for(l=0;l<m-i;l++){
n_out[k+l]=n_in[i+l];
}
}
if(j<=n){
for(l=0;l<m-j;l++){
n_out[k+l]=n_in[j+l];
}
}
}
优点:不需要考虑像堆结构一样的复杂结构,而且时间复杂度稳定在O(nlogn)。由于归并的特点,所以是一种稳定的算法。
缺点:比较占用内存,因为用的是递归,而且每次需要创建一个长度为n的临时数组。
2.非递归归并排序
递归的方式虽然结构清晰,但是会格外占用相当量的时间和空间。
void MergeSort2(vector<int> &nums){
//使用两个数组来回倒腾
vector<int> nums_copy(nums);
int length=nums.size();
int k=1;//跳跃步长
while(k<length){
MergePass(nums,nums_copy,k,length);
k=k*2;
MergePass(nums_copy,nums,k,length);
k=k*2;
}
}
void MergePass(vector<int> &nums1,vector<int> &nums2,int s,int n){
int i=0;
int j;
while(i<=n-2*s+1){
Merge(nums1,nums2,i,i+s,i+2*s);
i=i+2*s;
}
if(i<n-s)
Merge(nums1,nums2,i,i+s,n);
else
for(j=i;j<n;j++){
nums2[j]=nums2[i];
}
}
优点:避免了递归时深度为lo2gn的栈空间,同时在时间性能上也有所提升。
缺点:没啥明显的缺点。
快速排序(王者算法)
基本思想:通过一趟排序将待排记录分割成独立的两部分,其中一部分的关键字均比另一部分的关键字小,然后进一步对两个部分分别进行如上操作,直到整个序列有序。
void QuickSort(vecto<int> & nums){
QSort(nums,1,nums.size());
}
void Qsort(vector<int> &nums,int low,int hight){
int pivot;
if(low<hight){
pivot=Partition(L,low,hight);
Qsort(L,low,pivot-1);
Qsort(L,pivot+1,hight);
}
}
int Partition(vector<int> &nums,int low,int hight){
int pivotkey=nums[low];
while(low<hight){
while(low<hight && nums[hight]> pivotkey)
hight--;
swap(nums,low,hight);
while(low<hight && nums[low]<pivotkey)
low++;
swap[nums,low,hight];
}
return low;
}
优点:在枢轴记录为待排序序列的中部位置时,递归树是平衡的,性能比较好。平均状况下,时间复杂度为O(nlogn)。
缺点:如果枢轴记录选到了大段或者小端,那么性能就会大幅度降低。而且由于跳跃式的选择交换,快速排序算法是不稳定的。
2.快速排序算法的改进
2.1随机选取枢纽法,即通过随机选择记录序列中的一个数,可以降低选到最大或最小的概率,但性能总体还是不稳定。
2.2三数取中法,即选择左、中、右三个数排序取中值,作为枢轴,从概率上取到极端小或大的可能性微乎其微。
int mid =(left+right)/2;
if(nums[left]>nums[mid]) swap(nums,left,mid);
if(nums[left]>nums[right]) swap(nums,left,right);
if(nums[mid]>nums[right]) swap(nums,mid,right);
对于大记录序列,可以采取“九数取中”,分三组取中再取中。
2.3优化交换(用替换代替交换)
考虑到多次交换是没有必要的,可以通过替换序列值,最后再插入初始值的方式避免频繁使用swap函数。
int Partition(vector<int> &nums,int low,int hight){
int pivotkey=nums[low];
while(low<hight){
while(low<hight && nums[hight]> pivotkey)
hight--;
nums[low]=nums[hight];
while(low<hight && nums[low]<pivotkey)
low++;
nums[hight]=nums[low];
}
nums[low]=pivotkey;
return low;
}
2.4小数组优化,即当数组较小时(自己根据情况设定一个阈值),才有直接插入排序的效果比快速排序来的强。
2.5递归优化。因为当枢轴选择不恰当时,栈的深度会接近n,而栈的大小是有限的。通过尾递归的方法可以减小栈的深度(类似与DFS的后序遍历)。
void QSort(vector<int> &nums,int low,int hight){
int pivot;
pivot=Partition(nums,low,hight);
QSort(nums,low,pivot-1);
low=pivot+1;
}
计数排序
计数排序适合于数字的递增或递减排序。在“1~100”的范围内,效果格外的好。其基本步骤入下:
1.遍历一遍待排序的数组source,找到其中的最大值MaxNum
2.根据最大值MaxNum+1建立计数数组CountingMap
3.遍历CountingMap,将数字根据出现的频率依次放回原数组source
#include <iostream>
#include <vector>
using namespace std;
void CountingSort(vector<int>& source, int MaxNum);
int main()
{
vector<int> source{ 3,4,2,3,5,7,4,3,8,4,1,2,4 };//待排序数组
int MaxNum = INT_MIN;
//找到待排序数组中最大的数
for (int i = 0; i < source.size(); i++) {
if (MaxNum < source[i]) {
MaxNum = source[i];
}
}
//调用计数排序对数组进行排序
CountingSort(source, MaxNum);
//打印排序后的数组
for (int i = 0; i < source.size(); i++) {
cout << source[i];
}
cout << '\n';
return 0;
}
void CountingSort(vector<int>& source, int MaxNum) {
vector<int> CountingMap(MaxNum + 1,0);
//将待排序数组source中的数字放入CountingMap对应的位置
for (int i = 0; i < source.size(); i++) {
CountingMap[source[i]]++;
}
//在通过遍历将CountingMap中的值逐渐放回source
int index = 0;
for (int i = 0; i < CountingMap.size(); i++) {
while (CountingMap[i] > 0) {
CountingMap[i]--;
source[index] = i;
index++;
}
}
}