八大排序可以分为划分为五种类型:
一、插入排序(直接插入排序,希尔排序)
二、选择排序(简单选择排序,堆排序)
三、交换排序(冒泡排序,快速排序)
四、二路归并排序
五、基数排序
一、插入排序:
-
直接插入排序(InsertSort):
1)核心思想:
在要排序的一组数中,假设前面(n-1) [n>=2] 个数已经是排好顺序的,现在要把第n个数插到前面的有序数中,使得这n个数也是排好顺序的。如此反复循环,直到全部排好顺序。
2)代码示例:
void InsertSort(int arr[],int len)
{//最好情况和最坏情况时间复杂度都为O(n^2)
int i;//未排序序列待排序的元素下标
int j;//已排序序列最大元素的下标
int tmp;
for (i = 1; i < len; i++)
{
tmp = arr[i];
for (j = i - 1; j>=0 && arr[j]>tmp; --j)
{
arr[j + 1] = arr[j];
}
arr[j + 1] = tmp;
}
}
3)复杂度理解:
如果序列是完全有序的,插入排序只要比较n次,无需移动时间复杂度为O(n),如果序列是逆序的,插入排序要比较O(n²)和移动O(n²) ,所以平均复杂度为O(n²),最好为O(n),最坏为O(n²),排序过程中只要一个辅助空间,所以空间复杂度O(1)。
4)稳定性分析:
直接插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。刚开始这个小序列只包含第一个元素,事件复杂度O(n2)。比较是从这个小序列的末尾开始的。想要插入的元素和小序列的最大者开始比起,如果比它大则直接插在其后面,否则一直往前找它该插入的位置。如果遇见了一个和插入元素相等的,则把插入元素放在这个相等元素的后面。所以相等元素间的顺序没有改变,是稳定的。
-
希尔排序(ShellSort):
1)核心思想:
希尔排序先将要排序的一组数按某个增量d(n/2,n为要排序数的个数)分成若干组,每组中记录的下标相差d.对每组中全部元素进行直接插入排序,然后再用一个较小的增量(d/2)对它进行分组,在每组中再进行直接插入排序。当增量减到1时,进行直接插入排序后,排序完成。
2)代码示例:
static void Shell(int *arr,int len,int gap)//一趟shell过程,gap为分组数量
{
int i,j;
int tmp;
for(i=gap;i<len;i++)
{
tmp = arr[i];
for(j=i-gap;j>=0&&arr[j]>tmp;j-=gap)
{
arr[j+gap] = arr[j];
}
arr[j+gap] = tmp;
}
}
void ShellSort(int *arr,int len)//O(n^1.3~n^1.5),O(1),
{
int d[] = {5,3,1};
for(int i=0;i<sizeof(d)/sizeof(d[0]);i++)
{
Shell(arr,len,d[i]);
}
}
3)复杂度理解:
希尔排序的时间复杂度分析及其复杂,有的增量序列的复杂度至今还没人能够证明出来,只需要记住结论就行,{1,2,4,8,...}这种序列并不是很好的增量序列,使用这个增量序列的时间复杂度(最坏情形)是O(n²),Hibbard提出了另一个增量序列{1,3,7,...,2^k-1},这种序列的时间复杂度(最坏情形)为O(n^1.5),Sedgewick提出了几种增量序列,其最坏情形运行时间为O(n^1.3),其中最好的一个序列是{1,5,19,41,109,...},需要一个临时变量用来交换数组内数据位置,所以空间复杂度为O(1)。
4)稳定性分析:
希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比o(n^2)好一些。由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不稳定的。
二、选择排序:
-
简单选择排序(SelectSort)
1)核心思想:
在要排序的一组数中,选出最小的一个数与第一个位置的数交换;然后在剩下的数当中再找最小的与第二个位置的数交换,如此循环到倒数第二个数和最后一个数比较为止。
2)代码示例:
void SelectSort(int arr[],int len)
{
int i,j;
int min;//待排序数中最小值的下标
int tmp;
for(i =0;i<len-1;i++)
{
min = i;
for(j=i+1;j<len;j++)
{
if(arr[j] < arr[min])
{
min =j;
}
}
tmp = arr[i];
arr[i] = arr[min];
arr[min] = tmp;
}
}
3)复杂度理解:
简单选择排序是冒泡排序的改进,同样选择排序无论序列是怎样的都是要比较n(n-1)/2次的,最好、最坏、平均时间复杂度也都为O(n²),需要一个临时变量用来交换数组内数据位置,所以空间复杂度为O(1)。
4)稳定性分析:
每个元素都与第一个元素相比,产生交换,两重循环O(n2);举个例子,5 8 5 2 9,第一遍之后,2会与5交换,那么原序列中两个5的顺序就被破坏了。所以是不稳定的排序算法。
-
堆排序(HeapSort)
1)核心思想:
堆排序是一种树形选择排序,是对直接选择排序的有效改进。
堆的定义如下:具有n个元素的序列(h1,h2,...,hn),当且仅当满足(hi>=h2i,hi>=2i+1)或(hi<=h2i,hi<=2i+1)(i=1,2,...,n/2)时称之为堆。在这里只讨论满足前者条件的堆。
由堆的定义可以看出,堆顶元素(即第一个元素)必为最大项(大顶堆)。完全二叉树可以很直观地表示堆的结构。堆顶为根,其它为左子树、右子树。初始时把要排序的数的序列看作是一棵顺序存储的二叉树,调整它们的存储序,使之成为一个堆,这时堆的根节点的数最大。然后将根节点与堆的最后一个节点交换。然后对前面(n-1)个数重新调整使之成为堆。依此类推,直到只有两个节点的堆,并对它们作交换,最后得到有n个节点的有序序列。
从算法描述来看,堆排序需要两个过程,一是建立堆,二是堆顶与堆的最后一个元素交换位置。所以堆排序有两个函数组成。一是建堆的渗透函数,二是反复调用渗透函数实现排序的函数。
2)代码示例:
void Adjust(int *arr,int start,int end)//O(logn)//一次堆调整
{
int tmp = arr[start];
int parent = start;
for(int i=2*start+1;i<=end;i=2*i+1)//
{
if((i+1<=end) && (arr[i]<arr[i+1]))
{
++i;
}//i为左右孩子较大值的下标
if(tmp < arr[i])
{
arr[parent] = arr[i];
parent = i;
}
else
{
break;
}
}
arr[parent] = tmp;
}
void HeapSort(int *arr,int len)
{
int i;
for(i=(len-1-1)/2;i>=0;i--)//第一次建大根堆,O(nlogn)
{
Adjust(arr,i,len-1);
}
int tmp;
for(i=0;i<len-1;i++)//O(nlogn)
{
tmp = arr[0];
arr[0] = arr[len-1-i];
arr[len-1-i] = tmp;
Adjust(arr,0,len-1-i-1);
}
}
3)复杂度理解:
堆排序的时间复杂度,主要在初始化堆过程和每次选取最大数后重新建堆的过程,初始化建堆时的时间复杂度为O(n),更改堆元素后重建堆的时间复杂度为O(nlogn),所以堆排序的平均、最好、最坏时间复杂度都为O(nlogn),堆排序是就地排序,空间复杂度为O(1)。
4)稳定性分析:
我们知道堆的结构是节点i的孩子为2*i和2*i+1节点,大顶堆要求父节点大于等于其2个子节点,小顶堆要求父节点小于等于其2个子节点。在一个长为n的序列,堆排序的过程是从第n/2开始和其子节点共3个值选择最大(大顶堆)或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。但当为n/2-1, n/2-2, ...1这些个父节点选择元素时,就会破坏稳定性。有可能第n/2个父节点交换把后面一个元素交换过去了,而第n/2-1个父节点把后面一个相同的元素没有交换,那么这2个相同的元素之间的稳定性就被破坏了。所以,堆排序是不稳定的排序算法。
三、交换排序:
-
冒泡排序(BubbleSort)
1)核心思想:
在要排序的一组数中,对当前还未排好序的范围内的全部数,自上而下对相邻的两个数依次进行比较和调整,让较大的数往下沉,较小的往上冒。即:每当两相邻的数比较后发现它们的排序与排序要求相反时,就将它们互换。
2)代码示例:
void BubbleSort (int arr[],int len)
{
int i,j;
int tmp;
for(i =0;i<len-1;i++)
{
for(j=0;j<len-1-i;j++)
{
if (arr[j+1] < arr[j])
{
tmp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tmp;
}
}
}
}
3)复杂度理解:
冒泡排序不管序列是怎样,都是要比较n(n-1)/2 次的,最好、最坏、平均时间复杂度都为O(n²),需要一个临时变量用来交换数组内数据位置,所以空间复杂度为O(1)。有很多人说冒泡排序的最优的时间复杂度为O(n),其实这是在代码中使用一个标志位来判断是否已经排序好的,是冒泡排序的优化版,如果元素已经排序好,那么循环一次就直接退出。
4)稳定性分析:
冒泡排序是相邻元素之间的比较和交换,两重循环O(n2);如果两个相邻元素相等,是不会交换的。所以它是一种稳定的排序方法。
-
快速排序(QuickSort)
1)核心思想:
选择一个基准元素,通常选择第一个元素或者最后一个元素,通过一趟扫描,将待排序列分成两部分,一部分比基准元素小,一部分大于等于基准元素,此时基准元素在其排好序后的正确位置,然后再用同样的方法递归地排序划分的两部分。
2)代码示例:
int Partition(int *arr,int low,int high)//快排的一次划分,返回基准点,low和high的下标可取
{
int tmp = arr[low];
while(low<high)
{
while((low<high)&&arr[high]>=tmp)//从后往前找比基准小的数字
{
high--;
}
arr[low] = arr[high];
while((low<high)&&arr[low]<=tmp)//从前往后找比基准大的数字
{
low++ ;
}
arr[high]=arr[low];
}
arr[low] = tmp;
return low;
}
递归与非递归俩种方法(任选其一即可):
①递归:
//递归快速排序
static void Quick(int *arr,int low,int high)//快排
{
int par = Partition(arr,low,high); //一次快排后返回的基准点的位置
if(par>low+1)
{
Quick(arr,low,par-1);//递归一次快排
}
if(par<high-1)
{
Quick(arr,par+1,high);//递归一次快排
}
}
void QuickSort(int *arr,int len)//O(nlogn),O(logn),不稳定
{
Quick(arr,0,len-1);
}
②非递归:
//非递归快速排序
void QuickSort(int *arr,int len)
{
stack<int> s;//int *s = (int *)malloc(len*sizeof(int));
int low = 0;
int high = len-1;
int par = Partition(arr,low,high);
if(low < par-1)
{
s.push(low);
s.push(par-1);
}
if(par+1 < high)
{
s.push(par+1);
s.push(high);
}
while(!s.empty())
{
high = s.top();//获取栈顶值,不删
s.pop();
low = s.top();
s.pop();
par = Partition(arr,low,high);
if(low < par-1)
{
s.push(low);
s.push(par-1);
}
if(par+1 < high)
{
s.push(par+1);
s.push(high);
}
}
}
3)复杂度理解:
快速排序的时间复杂度最好是O(nlogn),平均时间复杂度也是O(nlogn),最坏情况是序列本来就是有序的,此时时间复杂度为O(n²),快速排序的空间复杂度可以理解为递归的深度,而递归的实现依靠栈,平均需要递归logn次,所以平均空间复杂度为O(logn)。
4)稳定性分析:
快速排序有两个方向,左边的i下标一直往右走,当a[i] <= a[center_index],其中center_index是中枢元素的数组下标,一般取为数组第0个元素。而右边的j下标一直往左走,当a[j] > a[center_index]。如果i和j都走不动了,i <= j, 交换a[i]和a[j],重复上面的过程,直到i>j。 交换a[j]和a[center_index],完成一趟快速排序。在中枢元素和a[j]交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为 5 3 3 4 3 8 9 10 11, 现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j]交换的时刻。
四、二路归并排序(MergeSort):
1)核心思想:
二路归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
2)代码示例:
void Merge(int *arr,int len,int gap)//一趟归并,gap为归并段的长度
{
int low1 = 0; //第一个归并段的起始下标
int high1 = low1+gap-1;//第一个归并段的结束下标
int low2 = high1+1;//第二个归并段的起始下标
int high2 = low2+gap<len?low2+gap-1:len-1;//第二个归并段的结束下标
int *brr = (int *)malloc(len*sizeof(int));
int i = 0;//brr下标
//有两个归并段
while(low2 < len)//保证第二个归并段至少一个数字
{
//两个归并段都还有数据
while(low1<=high1 && low2<=high2)
{
if(arr[low1] <= arr[low2])
{
brr[i++] = arr[low1++];
}
else
{
brr[i++] = arr[low2++];
}
}
//一个归并段已完成,另一个还有数据
while(low1<=high1)
{
brr[i++] = arr[low1++];
}
while(low2<=high2)
{
brr[i++] = arr[low2++];
}
low1 = high2+1;
high1 = low1+gap-1;
low2 = high1+1;
high2 = low2+gap<len?low2+gap-1 : len-1;
}
//不足两个归并段
while(low1 < len)
{
brr[i++] = arr[low1++];
}
for(i=0;i<len;i++)
{
arr[i] = brr[i];
}
free(brr);
}
void MergeSort(int *arr,int len)//O(nlogn),O(n),稳定
{
for(int i=1;i<len;i*=2)
{
Merge(arr,len,i);
}
}
3)复杂度理解:
二路归并排序需要一个临时temp[]来储存归并的结果,空间复杂度为O(n),时间复杂度为O(nlogn),可以将空间复杂度由 O(n) 降低至 O(1),然而相对的时间复杂度则由 O(nlogn) 升至 O(n²)。
4)稳定性分析:
二路归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。
五、基数排序(RadixSort):
1)核心思想:
基数排序就是将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
2)代码示例:
int FindMaxDigit(int arr[], int len)
{
int maxnumber = arr[0];
for (int i = 1; i < len; ++i)
{
if (arr[i] > maxnumber)
{
maxnumber = arr[i];
}
}
int count = 0;
while (maxnumber != 0)
{
maxnumber /= 10;
count++;
}
return count;
}
/*
digit :
0 个位
1 十位
num /10^0 %10;
num / 10^1 %10;
num / 10^2 %10;
double pow(double,int)
(int)pow(10.0,digit);
*/
int FindNumberDigit(int num, int digit)
{
return num / (int)pow(10.0, digit) % 10;
}
void Radix(int arr[], int **bucket,
int len, int digit)
{
//每个桶中现有的元素的个数
int count[10] = { 0 };
for (int i = 0; i < len; ++i)
{
int digitnumber = FindNumberDigit(arr[i], digit);
bucket[digitnumber][count[digitnumber]] = arr[i];
count[digitnumber]++;
}
int index = 0;
for (int j = 0; j < 10; ++j)
{
for (int k = 0; k < count[j]; ++k)
{
arr[index++] = bucket[j][k];
}
}
}
void RadixSort(int arr[], int len)
{
int** bucket = (int **)malloc(sizeof(int*)* 10);
for (int j = 0; j < 10; ++j)
{
bucket[j] = (int *)malloc(sizeof(int)*len);
}
int maxdigit = FindMaxDigit(arr, len);
for (int i = 0; i < maxdigit; ++i)
{
Radix(arr,bucket,len,i);
}
for (int j = 0; j < 10; ++j)
{
free(bucket[j]);
}
free(bucket);
}
3)复杂度理解:
基数排序对于 n 个记录,执行一次分配和收集的时间为O(n+r),如果关键字有 d 位,则要执行 d 遍,所以总的时间复杂度为 O(d(n+r))。该算法的空间复杂度就是在分配元素时,使用的桶空间,空间复杂度为O(n)。
4)稳定性分析:
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序,最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以其是稳定的排序算法。
上述排序算法适用场景:
若n较小(如n≤50),可采用直接插入或简单选择排序。当记录规模较小时,直接选择较好,否则因为直接选择移动的记录数少于直接插人,应选直接选择排序为宜。
若n较大,则应采用时间复杂度为O(nlgn)的排序方法:快速排序、堆排序或归并排序。
若序列初始状态基本有序,则直接插入和冒泡最佳,随机的快速排序也不错。插入排序对部分有序的数组很有效,所需的比较次数平均只有选择排序的一半。
希尔排序比插入排序和选择排序要快得多,并且数组越大,优势越大。如果需要解决一个排序问题而又没有系统排序函数可用(例如直接接触硬件或者运行于嵌入式系统中的代码),可以先用希尔排序,再考虑是否替换为更复杂的排序算法。
直接插入排序是而对于部分有序和小规模的数组比较实使用。
归并排序可以处理数百万甚至更大规模的数组,但是插入排序和选择排序做不到。归并排序的主要缺点是辅助数组所使用的额外空间和n的大小成正比。
快速排序的优点是原地排序(只需要一个很小的辅助栈),但是基准的选取是个问题,对于小数组,快速排序要比插入排序慢,但他是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短。
堆排序的优点是在排序时可以将需要排序的数组本身作为堆,无需任何额外空间,与选择排序有些类似,但所需的比较要少得多,堆排序适合例如嵌入式系统或低成本移动设备中容量有限的场景。