稳定和非稳定
原地和非原地
比较和非比较
经典排序算法,算法启蒙。
冒泡排序(Bubble Sort),从数组的第 0 个元素开始,每次遍历,依据大小关系选择是否将第 i 个数和第 i+i 个数交换。每次循环,最大的数被放到数组的尾部,小的数渐渐漂浮到数组的前列。就像泡泡逐渐从池底浮到水面一样,因此称之为冒泡排序。
冒泡排序的代码如下
//sort1:冒泡排序
#include
#include
using namespace std;
int main()
{
vector<int> array={
9,5,1,5,6,3,2,7,5,7,10,14};
int n=array.size();
for(int i=0;i<n;i++)
{
for(int j=0;j<n-1;j++)
{
if(array[j]>array[j+1])
{
int temp=array[j];
array[j]=array[j+1];
array[j+1]=temp;
}
}
}
for(int i=0;i<n;i++)
{
cout<<array[i]<<" ";
}
return 0;
}
上面的代码实际每次循环都会把大的元素后移,小的元素前移。冒泡排序虽然是最基础的排序算法,但是它是一种稳定的排序算法,每次循环都能保证数组的有序性提升或者至少不变。
时间复杂度:
空间复杂度:
选择排序(Selection Sort),从数组的第 0 个元素开始,每次循环,找到当前剩余未排序的数中最小的值,将其与已排序最后一个的数的后一个数交换。
//sort2:选择排序
#include
#include
using namespace std;
int main()
{
vector<int> array={
9,4,6,8,3,5,1,2,7};
int n=array.size();
for(int i=0;i<n;i++)
{
int min_num=array[i];
int pos=i;
for(int j=i;j<n;j++)
{
if(array[j]<=min_num)
{
pos=j;
min_num=array[j];
}
}
int temp=array[i];
array[i]=min_num;
array[pos]=temp;
}
for(int i=0;i<n;i++)
{
cout<<array[i]<<" ";
}
return 0;
}
时间复杂度:
空间复杂度:
冒泡排序和选择排序的效率很低,究其原因是因为其对于某些常见情况如数组已经比较有序的情况没有优化。
插入排序(Insertion Sort),从数组的第 0 个元素开始,认为其已经排过序,将其作为参照,每次循环,找到数组的第 i (i>=1)个元素在前面已经排过序的数中位置,将其插入到已经排序过的数中,排过序的数依次后移相应的位数。和选择排序一样,我们将排过序的数放在数组的头部。当然也可以选择将最大的数放到尾部,从尾部向前遍历,看君喜好。
//sort3:基本插入排序
#include
#include
using namespace std;
int main()
{
vector<int> array={
9,4,6,8,3,5,1,2,7};
int n=array.size();
int current;
for(int i=1;i<n;i++)
{
current=array[i];
int pre_index=i-1;
while(pre_index>=0 && current<array[pre_index])
{
array[pre_index+1]=array[pre_index]; //这儿的处理比较巧妙,即判断一次,后移一位
pre_index--;
}
array[pre_index+1]=current; //插入的位置下标是pre_index+1
}
for(int i=0;i<n;i++)
{
cout<<array[i]<<" ";
}
return 0;
}
时间复杂度:
空间复杂度:
插入排序的算法对于比较有序的数组效率很高,很多时候其效率要高于冒泡排序和选择排序。但是到目前为止,排序算法的时间复杂度都没有突破 O ( n 2 ) O(n^2) O(n2) ,接下来介绍时间复杂度优于(或者大多数情况下优于 O ( n 2 ) O(n^2) O(n2) 的排序算法。
希尔排序(Shell Sort),由D.L.Shell于1959年提出。希尔排序是插入算法的改进算法,也是第一批时间复杂度突破 O ( n 2 ) O(n^2) O(n2) 的算法之一。插入排序对于大型乱序的数组排序很慢,因为它是通过交换相邻的元素实现已排序部分后移从而实现插入的。希尔排序为了加快速度改进了插入排序,对不相邻的子数组进行排序,并最终用插入排序将局部有序的数组排序。希尔排序是基于插入排序的以下两点性质而提出改进方法的:
对于新手,希尔排序比较难以理解,而且很多文章的解释含糊不清,根本没说到点子上。实际上希尔排序的思想非常简单,那就是:在对整个数组进行插入排序前,先大致将其排几次序,大致排序的方法是先将整个数组中距离比较远的个个数进行两两交换排序,把小数放到前面。比如对于一个长度为16的数组,可以先对相距为13的数两两排序,然后缩小距离,对相距为4的数两两排序,最后对相邻的两个数(相距为1)进行排序。大家必须明确,希尔排序无论排了几次序,最后必须来一次相邻距离为1的排序(也就是3中使用的基本插入排序),至于在这之前的相距为13和相距为4的排序完全是为最后一次的插入排序服务,也可以说相距为13的排序是使得相距为4的排序更加容易,相距为4的排序使得相距为1的排序更加容易。 在进行基本插入排序之前,先在比较大的距离上对数组进行几次排序,这就是希尔排序的核心所在,希尔排序能提高排序效率的根本原因也就无非是在基本插入排序之前进行了几次长间隔的大致排序。而这么做可以提高插入排序效率的基本依据就是:插入排序对于比较有序的数组有更高的排序效率。
希尔排序的方法是给定一个有序序列 H H H , H H H的值在1到数组长度之间,比如对于长度为16的数组,可以选择:
H = [ 1 , 4 , 13 ] H=[1,4,13] H=[1,4,13]
也可以选择
H = [ 1 , 3 , 7 , 15 ] H=[1,3,7,15] H=[1,3,7,15]
注意其中 H H H 必须以 1 结尾,因为1代表最后一次的插入排序。如果 H = [ 1 ] H=[1] H=[1],则希尔排序就退化为基本的插入排序。原则上 H H H 的取值是不固定的,但是一般情况下,为了程序的统一起见, H H H 往往是一个按一定规律产生的递增间隔的有序序列,并且一般情况下 H H H 并不会再程序开始之前算好,而是在循环的过程中计算其元素值 h h h 作为排序间隔(有的书也叫做增量或者排序增量)。下面给出希尔排序程序。
在上面解释希尔排序的时候,我们在长间隔排序的时候采用的方法是两两交换,先给出这种希尔排序的程序:
//sort4_1:希尔排序
//长间隔两两交换方式
#include
#include
using namespace std;
int main()
{
vector<int> array={
14,13,9,8,1,2,6,4,12,7,11,16,5,10,3,15};
int n=array.size();
int h=1;
int inc_fac=3; //Increment Factor 间隔调节因子
while (h<n/inc_fac) h=inc_fac*h+1;
int time=0; //元素交换次数
while(h>=1)
{
for(int i=h;i<n;i++)
{
for(int j=i;j>=h && array[j]<array[j-h];j-=h)
{
int temp=array[j];
array[j]=array[j-h];
array[j-h]=temp;
time++;
}
}
h=h/inc_fac;
}
for(int i=0;i<n;i++)
{
cout<<array[i]<<" ";
}
cout<<endl;
cout<<time;
return 0;
}
上面代码中的 inc_fac是一个排序间隔因子,可以调节排序间隔:
当 inc_fac=3 的时候,排序间隔为:
[ 1 , 4 , 13 ] [1,4,13] [1,4,13]
排序完成时所需的元素交换次数time为 22.
当inc_fac=2 的时候,排序间隔为:
[ 1 , 3 , 7 , 15 ] [1,3,7,15] [1,3,7,15]
排序完成时所需的元素交换次数time为 30.
当inc_fac=4的时候,排序间隔为:
[ 1 , 6 ] [1,6] [1,6]
排序完成所需的元素交换次数time为 42.
因此, h h h 的选取会影响算法的效率,尤其对于长数组,一般来说,其可能有多个 h h h 的选取方式可以使得算法的效率达到相似的水平。当 h h h 很大的时候,我们就能将元素移动到很远的地方,为实现更小的 h h h 有序创造方便。
实际上,上面的交换方式只是一种希尔排序的实现方式,其示意如下:
其通过间隔为 h h h 的元素事件交换排序达到将小数移动到前面的目的,随着 h h h 的不断减小,小的数逐渐移动到了数组前列。
希尔排序还可以将相邻比较远的数分组,然后分别采用插入排序,如下图所示:
可以看到,在 h > 1 h>1 h>1时,对比交换排序的方式,插入排序是将分组后的长间隔数组整体排序,而交换排序只是随相距为 h h h 的两个数进行了交换排序,因此在 h = 1 h=1 h=1 之前,采用插入排序得到的中间数组是更有序的,但是,采用插入排序的方法对长间隔分组数组进行排序的时候,需要考虑如何分组的问题,即数组的头部取几个元素,数组的尾部取几个元素,因此采用插入排序进行长间隔分组数组排序的时候,分组常采用偶数分组,从而使得前面取 h / 2 h/2 h/2 个元素,后面也取 h / 2 h/2 h/2 个元素。如可以令分组间隔为2的指数形式:
[ 1 , 2 , 2 2 , 2 3 , 2 4 , . . . ] [1,2,2^2,2^3,2^4,...] [1,2,22,23,24,...]
注意最大的分组间隔不要超过数组的个数。实际中,由于子数组是相互独立的,因此多采用的两两交换的排序方式,即将 h − h- h−子数组中的每个元素交换到比它大的元素之前去。下面给出采用这种指数间隔的插入排序实现希尔排序的代码:
//sort4_2:希尔排序
//指数间隔插入实现
#include
#include
using namespace std;
int main()
{
vector<int> array = {
14, 13, 9, 8, 1, 2, 6, 4, 12, 7, 11, 16, 5, 10, 3, 15};
int n = array.size();
int temp = 0;
int time = 0;
int gap = n / 2;
while (gap > 0)
{
for (int i = gap; i < n; i++)
{
temp = array[i];
int preIndex = i - gap;
while (preIndex >= 0 && array[preIndex] > temp)
{
array[preIndex + gap] = array[preIndex];
preIndex -= gap;
time++;
}
array[preIndex + gap] = temp;
}
gap /= 2;
}
for (int i = 0; i < n; i++)
{
cout << array[i] << " ";
}
cout << endl;
cout << "time: " << time;
return 0;
}
【总结】希尔排序可以提高效率的原因在于它权衡了子数组的规模和有序性,通过在基本插入排序之前将数组进行长间隔的多次提前排序,使得在进行最后一步间隔为1的基础插入排序之前,得到是一个比较有序的数组,从而使得最后一步的插入排序获得了很高的效率。很有意思的是,到目前为止,人们依然没有彻底弄清楚希尔排序的性能以及最优递增序列的选择。
时间复杂度:
空间复杂度:
归并排序(Merge Sort),其最初的想法很简单,那就是:如果要对一个大的有序数组进行排序,则可以将其分成小的数组然后分别对其排序,最后通过归并的方法将排序后的小数组归并为大数组。这里的归并指的是:将两个排序后的小数组按照元素的大小次序依次排序。
归并排序最吸引人的性质是,在任何情况下,对于长度为N的数组,其排序所需的时间复杂度都不会超过 N l o g N NlogN NlogN。归并排序的缺点是其空间复杂度(排序所需的额外空间)和 N N N 成正比。
归并排是分治思想的典型应用(分治思想是高效排序算法中的常用思想)。在归并算法提出初期,人们尝试了很多的方法希望找到一种原地的归并排序方法,即只需要常数辅助空间的归并方法,但是这些方法大都非常复杂。因此,实际中很少使用。实际可用的归并算法需要额外的非常数额外空间,归并算法可以分为两种,分别是:
下面分别介绍这两种排序方法的代码实现。
① 自顶向下的归并排序
自顶向下的归并是通过递归实现的,即将大数组分成两个小数组,两个小数组又可以分为两个小数组,其递归顺序为:
左数组排序 —— 右数组排序 —— 归并
这里所谓的递归顺序指的是先递归到最小不可分割的左数组,遇到递归终止条件返回,对最小左数组其排序后,然后对最小右数组排序,然后将两者合并,递归这一过程,直到排序完成,其排序过程有些类似于二叉树的后续遍历。
自顶向下的归并排序算法如下:
//sort5_1:自顶向下的归并排序(递归)
#include
#include
using namespace std;
void _merge(vector<int>& aux,vector<int>& a,int low,int mid,int high)
{
int i=low,j=mid+1;
for(int k=low;k<=high;k++) aux[k]=a[k]; //将a[low...high]复制到aux中
for(int k=low;k<=high;k++)
{
if(i>mid) a[k]=aux[j++]; //左半边用尽(取右半边元素)
else if(j>high) a[k]=aux[i++]; //右半边用尽(取左半边元素)
else if(aux[j]<aux[i]) a[k]=aux[j++]; //右半边元素小于左半边元素
else a[k]=aux[i++]; //右半边元素大于左半边元素
}
}
void _sort(vector<int>& aux,vector<int>& a,int low,int high)
{
if(high<=low) return; //递归终止条件、
int mid=low+(high-low)/2;
_sort(aux,a,low,mid); //左数组排序
_sort(aux,a,mid+1,high); //右数组排序
_merge(aux,a,low,mid,high); //合并
}
int main()
{
vector<int> array={
9,12,1,25,5,4,6,14,23,4,10,7,6,8,18,19,23};
int n=array.size();
vector<int> aux(n); //归并所需辅助数组,在类中可以将其作为私有成员
_sort(aux,array,0,n-1); //调用_sort()函数排序,注意这不是STL的sort()函数
for(int i=0;i<n;i++)
{
cout<<array[i]<<" ";
}
return 0;
}
将自定向下的排序方法,由于递归的构建方式,是从左到右排序的,即先将数组的左半边递归完成,然后又开始递归排序右半边的数组。
② 自底向上的归并排序
自底向上的归并排序不是通过递归实现的,而是先将数组分为不可分割的最小数组,然后两两归并、再四四归并、最后再八八归并,一直下去,最后一次归并的第二个数组可能比第一个数组要小(但是这对merge方法不是问题),直到排序完成。
自底向上的归并排序算法如下:
//sort5_2:自底向下的归并排序(非递归)
#include
#include
#include //support for min()
using namespace std;
void _merge(vector<int>& aux,vector<int>& a,int low,int mid,int high)
{
int i=low,j=mid+1;
for(int k=low;k<=high;k++) aux[k]=a[k]; //将a[low...high]复制到aux中
for(int k=low;k<=high;k++)
{
if(i>mid) a[k]=aux[j++]; //左半边用尽(取右半边元素)
else if(j>high) a[k]=aux[i++]; //右半边用尽(取左半边元素)
else if(aux[j]<aux[i]) a[k]=aux[j++]; //右半边元素小于左半边元素
else a[k]=aux[i++]; //右半边元素大于左半边元素
}
}
void _sort(vector<int>& aux,vector<int>& a)
{
int N=a.size();
for(int sub_sz=1;sub_sz<N;sub_sz=sub_sz+sub_sz) //sub_sz子数组长度
{
for(int low=0;low<N-sub_sz;low+=sub_sz+sub_sz)
{
_merge(aux,a,low,low+sub_sz-1,min(low+sub_sz+sub_sz-1,N-1));
}
}
}
int main()
{
vector<int> array={
7,4,6,9,1,3,5,8,6,2};
int n=array.size();
vector<int> aux(n); //归并所需辅助数组,在类中可以将其作为私有成员
_sort(aux,array); //调用_sort()函数排序,注意这不是STL的sort()函数
for(int i=0;i<n;i++)
{
cout<<array[i]<<" ";
}
return 0;
}
自底向下的归并排序是一种非递归的排序,其中子数组长度控制每次归并的数组长度,即:
归并排序算法最重要的一点就是可以证明他在最坏的情况下时间复杂度不会超过 O ( n l o g n ) O(nlogn) O(nlogn),对于归并排序算法:
时间复杂度:
空间复杂度:
快速排序(Quick Sort) 是应用最为广泛的排序算法,它由图灵奖获得者C. A. R. Hoare于1960年提出,被列为20实际十大算法之一。快速排序简单易懂,在大多数情况下排序效率优良,可以说是迄今为止最成功的排序算法。无论是在C++ STL亦或是Java SDK的底层实现中均可以看到它的身影。
快速排序的核心思想是:将待排序数组经过一趟排序后分为两个部分,其中左半部分小于数组中的一个值 p p p,而有半部分大于值 p p p,我们将值 p p p 所在的位置称为切分( p a r t i t i o n partition partition)位置,然后再对左右两个小数组分别排序(可以是递归的,也可以是非递归的)。
首先介绍如何将数组排序为比 p p p 大和比 p p p 的两部分,其实现其实很简单,那就是:遇到比 p p p大的数就将其插入到 p p p 的右边,遇到比 p p p 小的数就将其插入到 p p p 的左边。
对于快速排序,如何将比 p p p 大的元素放到 p p p 的左边、将比 p p p 小的元素放到 p p p 的右边、以及 p p p 的选择是快速排序的关键所在。其中 p p p 的选择是一个至今仍在研究的问题,实际中,一般就都是将数组的第一个元素作为切分元素。所以快速排序的关键问题就是如何将数组以 p p p 为界限分为左右两个部分,下面先给出代码实现,然后讲解其实现过程:
//sort6:快速排序
#include
#include
#include
using namespace std;
//将数组按切分值分为左右两部分,并返回切分值
int partition(vector<int>& array, int low, int high)
{
int i = low, j = high + 1;
int p = array[low];
while (1)
{
while (array[++i] < p) if (i == high) break; //a[i]>p 或 i==high 时跳出此while()循环
while (p < array[--j]) if (j == low) break; //p>a[j] 或 j==low 时跳出此while()循环
if (i >= j) break;
swap(array[i], array[j]);
}
swap(array[low], array[j]); //交换切分元素到正确位置
return j;
}
//排序
void sort(vector<int>& array, int low, int high)
{
if (high <= low) return; //递归终止条件
int j = partition(array, low, high);
sort(array, low, j - 1);
sort(array, j+1, high);
}
int main()
{
vector<int> array = {
40,10,65,20,90,30,25,80,70,60 };
int low = 0;
int high = array.size() - 1;
sort(array, low, high);
//打印排序后数组元素
for (auto val : array)
{
cout << val << " ";
}
return 0;
}
我们在上面的代码中,递归实现了快速排序,不过核心不是递归,而是partition()函数,其作用是将传入的数组切分排序并返回切值个sort()函数中的j,而 j-1 和 j+1 作为更小数组的high和low又被递归调用。下面说明partition()函数的工作过程:
另外需要说明的是,快速排序其实也是一种分治的排序算法。它将一个数组分为两个数组,将两部分独立地排序。快速排序和归并排序是互补的:归并排序将数组分为两个子数组分别排序,并将有序的子数组归并并以将整个数组排序;而快速排序将数组排序的方式则是当两个都有序时整个数组自然也就有序了。在第一种情况中,递归调用发生在处理整个数组之前;在第二种情况中,递归调用发生在处理整个数组之后。在归并排序中,一个数组被分为两半;在快速排序中,切分( p a r t i t i o n partition partition)的位置取决于数组的内容。
从统计角度,快速排序在多少情况下性能优良,可以肯定的是对于大小为 n n n 的数组,上面给出的快排算法的运行时间在 1.39 n l o g n 1.39nlogn 1.39nlogn 的某个常数因子的范围之内,归并排序其实也可以做到这一点,但是快速排序一般会更快。但是快速排序是不稳定的,且在切分不平衡上面给出的程序可能会极为低效。所以,后续对快排的改进大多都是对于切分方式的改进。总的来说,可以认为快速排序的性能如下:
时间复杂度:
空间复杂度:
堆的定义:堆是一棵完全二叉树,每个结点的值都大于等于其左右孩子的结点的堆称为大顶堆;每个结点的值都小于等于其左右孩子结点值的堆称为小顶堆。从堆的定义可以知道,根节点一定是堆中所有结点最大(小)者。
注意:堆是完全二叉树,既然是树,那么想当然的我们就可以用指针实现其结构,即每个结点都需要三个指针来指到它的上下结点(父结点和两个子节点各需要一个)。但是,千万不要忘了完全二叉树由于其特殊的结构,其仅用一个数组就能实现(因为其结点是层序连续的),所以我们用数组就可以实现大顶堆,实际上,堆排序就是用原数组不断原地构建堆有序的完全二叉树实现的,其不需要任何额外空间就可以达到对数世间复杂度,是一种相当相当经典的原地排序算法。建议读者在学习堆排序之前对二叉树(尤其是完全二叉树)进行充分的学习和理解。
下面给出一些完全二叉树的定义和一些重要性质:
1、定义
对于一课具有 n n n 个结点的二叉树按程序编号,如果编号 i ( 1 ≤ i ≤ n ) i(1\le i\le n) i(1≤i≤n)的结点与同样深度的慢二叉树中编号为 i i i 的结点在二叉树中位置完全相同,则这棵树称为完全二叉树。
2、性质
① 具有 n n n 个结点的完全二叉树的深度为 [ l o g 2 n ] + 1 [log_2^{n}]+1 [log2n]+1 ([ ]表示向下取整)。
② 如果对一棵有 n n n 个结点的完全二叉树的结点按层序排序(从第1层到 [ l o g 2 n ] + 1 [log_2^{n}]+1 [log2n]+1 层,每层从左到右),对任一结点 i i i有:
由于堆是有序二叉堆,所以简单点来说就是:对于堆,位置 i i i 的结点的父结点的位置为 [ i 2 ] [\frac {i}{2}] [2i] ,两个子节点的位置分别是 2 i 2i 2i 和 2 i + 1 2i+1 2i+1。对于堆排序,既可以用大顶堆实现,也可以用小顶堆实现,其只是在代码上有微小的差别,基本思想是一致的,下面采用大顶堆实现堆排序。
堆排序(Heap Sort) 的核心思想是先将待排序的序列构成一个有序堆,则堆顶的根节点就是整个序列的最大值(或者最小值),然后将最大值(最小值)(当前根节点、堆首位)移走(这里的移走实际上是将最大值与末位交换),然后将剩余的 n − 1 n-1 n−1 个序列重新调整为一个有序堆,然后将其根节点移走(第二大值)并插入到最大值(最小值)的前面,重复这一过程,直至排序完成。
因此,堆排序的核心就是有序堆的构建,前面说过,由于堆是完全二叉树,是层序连续的,所以且结构由数组就可以实现,下面以大顶堆为例,说明如何将一个数组构建成一个大顶堆。
假设我们要排序的原始数组为:
2 , 6 , 3 , 1 , 7 , 4 , 9 , 5 , 8 {2,6,3,1,7,4,9,5,8} 2,6,3,1,7,4,9,5,8
则其对应的完全二叉树如下所示:
堆排序算法分为两个步骤:
为什么这两个步骤要分开呢?原因是将原始无序数组初始化为一个大顶堆是一个耗时的过程,而初始大顶堆建好后,则排序就变的容易的多,我们每次循环和交换都可以将一个大元素放到数组的后面,文字是不容易理解的,也很难清楚的描述这个过程,但是当大家充分理解后面的程序代码后,就会很容易理解这个两个步骤为什么要分开,也会理解对排序的精髓所在。下面先介绍如何将无序数组初始化为一个大顶堆:
一般来说,有两种方法可以将数组初始化为大顶堆,一种是被称为上浮(swim)的方法,另一种则是被称为下沉(sink)的方法,这里我们选择下沉的方法将无序数组初始化为一个大顶堆 【注】 :
【注】 堆排序由著名计算机科学家、图灵奖得主弗洛伊德在1964年发明,是和快速排序(Quick Sort)齐名的高效排序算法。堆排序用原地实现了对数时间复杂度的排序,它是我们已知的唯一能够同时最优的利用空间和时间的方法。堆排序可以用大顶堆实现,也可以用小顶堆实现;可以用上浮(swim)实现堆的有序化,也可以用下沉(sink)实现,甚至可以两者同时使用,并且上浮/下沉算法可以用双循环实现,也可以用递归方法实现,本文不可能用有限的篇幅将这些方法及其组合 一 一 实现,本文采用的是最经典的大顶堆元素下沉的方法实现无序数组的初始化以及堆排序,不过只要理解了本文的程序,则其他程序解法也会很容易理解,所以各位朋友不必纠结于此。另外,如果有时间,本菜鸡可以考虑单独将堆排序写一篇文章,对其进行详细分析,大家可以关注一下。
由无序数组构建大顶堆的核心方法是从下到上,从右到左循环,将每个非叶子结点(比如上面的图中就是 3—2—1— 0 结点)下沉到不能再下沉的位置,则最终得到的就是一个大顶堆。其中,假设数组一共有 N N N 个元素,第 k ( k = 1 , 2... [ l o g 2 N ] + 1 ) k(k=1,2...[log_2{N}]+1) k(k=1,2...[log2N]+1) 层的结点(非叶子结点)最多可能会下沉 [ l o g 2 N ] + 1 [log_2{N}]+1 [log2N]+1 层,比如:
下沉时的原则是:如果父结点比某个孩子结点小,则将其与孩子结点的较大者交换,这是为了保证父节点的值比左右孩子结点的值都大。由原始数组构建大顶堆的过程如下所示:
得到初始大顶堆后,排序就变的很简单,后续过程是将堆顶元素(最大值9)和最后一个叶子结点(对应数组最后一个元素)交换,然后将剩余的8个元素(数组的0-7)重新调整为大顶堆,调整的过程不再需要像由原始数组构建大顶堆一样,需要对整个二叉树进行重新构建,而只需要交换后的堆顶元素1下沉到不能在下沉即可(即只需要一次下沉),然后重复以上交换和下沉过程,直至数组完全有序。其过程如下图所示:
其中,首次由无序数组初始化为大顶堆需要从右到左,从下到上循环下沉所有的非叶结点,而大顶堆建好后,每次只要交换堆顶元素(当前对最大值)到新堆尾部,然后将交换后的较小的堆顶元素下沉到合适位置,即可完成堆的修复,然后重复 交换——重构大顶堆——交换 的过程,直至排序完成。完整的代码如下:
//sort7:堆排序
//通过下沉方式实现
#include
#include
using namespace std;
//下沉方式实现非叶结点i下沉
void sinkToAdjustHeap(vector<int>& array,int i,int n)
{
while(2*i+1<=n)
{
int j=2*i+1; //j是i的左孩子
if(j<n && array[j]<array[j+1]) j++; //如果有右孩子且右孩子比父结点i大
if(array[i]>array[j]) break; //如果结点i比其孩子的值大,跳出
else
{
swap(array[i],array[j]);
i=j;
}
}
}
//1、由无序数组购进初始大顶堆
void buildMaxHeap(vector<int>& array,int n)
{
for(int i=(n/2-1);i>=0;i--) //依次下沉第3 2 1 0个堆元素
{
sinkToAdjustHeap(array,i,n);
}
}
int main()
{
vector<int> array={
2,6,3,1,7,4,9,5,8};
int n=array.size()-1;
//1、由无序数组构建大顶堆
buildMaxHeap(array,n);
//2、堆排序,每次循环数组尾部的排序数组都会增加一个
while (n>0)
{
swap(array[0],array[n]); //交换堆顶元素和当前堆的最后一个元素
n--;
sinkToAdjustHeap(array,0,n); //重新调整堆为大顶堆
}
//打印排序后的数组
for(int i=0;i<array.size();i++)
{
cout<<array[i]<<" ";
}
return 0;
}
至此,已经对基于非叶节点下沉方法的堆排序做了说明,堆排序比较难以理解但是非常经典,也是唯一一个能达到对数时间复杂度的原地排序算法。虽然由于堆排序无法很好的利用缓存导致现代系统中很少使用它,但是其深邃的思想仍然值得我们去解读和钻研。
堆排序是一种最坏情况下时间复杂度不会超过 2 n l o g n 2nlogn 2nlogn 的原地排序算法,对于堆排序算法:
时间复杂度:
空间复杂度:
上面介绍的排序方法都是比较排序,下面介绍的3中排序方法则属于非比较排序,之所以不需要比较是因为有辅助的记号帮助其比较元素大小。
计数排序(Counting Sort) 是一种比较土豪所以效率很高的排序算法,它用大量的额外内存换取了很低的排序时间复杂度。计数排序一般仅仅用于整数排序,其思想和LeetCode 739 每日温度的官方解法的思想比较类似。
计数排序需要一个辅助数组,辅助数组的第 i ( i = 0 , 1 , 2... [ m a x ( a r r a y ) − m i n ( a r r a y ) ] ) i(i=0,1,2...[max(array)-min(array)]) i(i=0,1,2...[max(array)−min(array)]) 个元素的值是待排序数组中值为 i + m i n ( a r r a y ) i+min(array) i+min(array) 的元素的个数。也就是说,辅助数组的下标和待排序数组中元素的值是对应的,这样,有序数组下标是有序的,因此只需统计辅助数组某个下标对应的值,就可以知道待排序数组中值为 下 标 + m i n ( a r r a y ) 下标+min(array) 下标+min(array) 的元素的个数,然后根据这个个数将待排序数组的 下 标 + m i n ( a r r a y ) 下标+min(array) 下标+min(array) 依次排列,就完成了数组的排序。计数排序的过程如下:
有上面的图示可以知道计数排序的步骤为:
其代码实现如下:
//sort8:计数排序
#include
#include
#include //support for min() and max()
using namespace std;
int main()
{
vector<int> array={
9,1,3,5,3,3,-1,8,-3,-1,5,3,7,8}; //待排序数组
int len=array.size();
//求待排序数组的最大值和最小值
int min_val=*min_element(array.begin(),array.end());
int max_val=*max_element(array.begin(),array.end());
//将待排序数组映射到区间[0,max_val-min_val]
for(int i=0;i<len;i++)
{
array[i]-=min_val;
}
vector<int> bucket(max_val-min_val+1); //辅助数组
//统计,统计后bucket包含了array的所有信息
for(int i=0;i<len;i++)
{
bucket[array[i]]++;
}
//下标还原,完成排序
int index=0;
for(int i=0;i<bucket.size();i++)
{
int num=bucket[i];
while(num>0)
{
array[index]=i+min_val;
index++;
num--;
}
}
//打印排序后的数组
for(int i=0;i<len;i++)
{
cout<<array[i]<<" ";
}
return 0;
}
上面的算法还可以做一点小小的改进,那就是将映射和排序放在一起:
//sort8_1:计数排序小改进
#include
#include
#include //support for min() and max()
using namespace std;
int main()
{
vector<int> array={
9,1,3,5,3,3,-1,8,-3,-1,5,3,7,8}; //待排序数组
int len=array.size();
//求待排序数组的最大值和最小值
int min_val=*min_element(array.begin(),array.end());
int max_val=*max_element(array.begin(),array.end());
vector<int> bucket(max_val-min_val+1); //辅助数组
//将待排序数组映射到区间[0,max_val-min_val],并计数
for(int i=0;i<len;i++)
{
array[i]-=min_val; //映射
bucket[array[i]]++; //计数
}
//下标还原,完成排序
int index=0;
for(int i=0;i<bucket.size();i++)
{
int num=bucket[i];
while(num>0)
{
array[index]=i+min_val;
index++;
num--;
}
}
//打印排序后的数组
for(int i=0;i<len;i++)
{
cout<<array[i]<<" ";
}
return 0;
}
由于我们使用的是数组这种数据结构辅助排序,使得计数排序在特殊情况如数组中元素跨度很大如:
10000000 , 0 , 101 , 101 , 100 , 100 , 100 , 99 {10000000,0,101,101,100,100,100,99} 10000000,0,101,101,100,100,100,99
这种情况下,按照计数排序的原理,需要的辅助数组长度为10000001,需要浪费大量的额外空间。但是对于密集的、跨度范围小的数组,计数排序效率极高。
对于计数排序,假设数组长度为 n n n ,辅助数组长度 k = m a x − m i n + 1 k=max-min+1 k=max−min+1,则:
时间复杂度:
空间复杂度:
桶排序(Bucket Sort) 的核心思想是将待排序数组划分为数个范围(数个桶),然后分别对每个非空桶进行排序,最后将所有的桶连接起来。桶排序其实也算一种分治思想,桶的范围远大,所需的额外辅助空间就越小,但是每个桶排序所用的时间就越长,对每个桶进行排序的时候,可以选择仍一一种排序算法(一般是用插入排序对各个桶进行排序),也可以递归使用桶排序(递归到1的时候返回)。
桶排序适用于元素基本均匀分布于数组的最大最小值区内的情况。
我们使用链表来保存桶内的元素,这样如果桶内没有元素,则不需要占用空间,如下图所示:
对应的代码如下:
//sort9:桶排序
#include
#include
#include
#include
using namespace std;
int main()
{
vector<int> array={
9,1,3,5,3,3,-1,8,-3,-1,5,3,7,8};
int min_val=*min_element(array.begin(),array.end());
int max_val=*max_element(array.begin(),array.end());
int bucketSize=3; //桶范围
int bucketCount=(max_val-min_val)/bucketSize+1; //桶个数
//vector> bucket(bucketCount,vector(bucketSize)); //初始化桶
vector<list<int>> bucket(bucketCount); //初始化桶
//元素入桶
for(int i=0;i<array.size();i++)
{
int pos=(array[i]-min_val)/bucketSize; //判断元素在哪个桶中
bucket[pos].push_back(array[i]); //入桶
}
//给各个桶中的元素排序,并将非空桶链接起来
int flag=0;
list<int> ans;
for(int i=0;i<bucket.size();i++)
{
if(bucket[i].size()>0)
{
++flag;
bucket[i].sort(); //给桶中的元素排序,这个算法也可以自己写
}
if(flag==1)
{
ans=bucket[i];
}
else
{
ans.merge(bucket[i]);
}
}
//打印排序后的元素
for(list<int>::iterator it=ans.begin();it!=ans.end();it++)
{
cout<<*it<<" ";
}
return 0;
}
说明:对于桶内的排序,为简单起见,使用了list的sort()方法,这里也可以换成其他排序方法,包括桶内可以不用链表保存数据,而可以采用固定长度的数组来统计数据,从而使得桶内又可以使用桶排序。
对于桶排序,设 n n n 为待排序数组元素的个数, k k k 为桶的个数,则
最好情况下时间复杂度:
空间复杂度:
基数排序(Radix Sort) 首先对每一个数按照最低位进行排序,然后按照下一个高位进行排序,直至排序完成。基数排序利用了桶的思想,基数排序聪明的地方在于他只用10个桶,因为任何十进制数的每一位数都在0-9之间。
基数排序有两种方法,分别是:
下面给出从低位到高位的排序算法:
//sort10:基数排序
#include
#include
#include
#include
#include //support for pow
using namespace std;
int main()
{
vector<int> array = {
39,2,4,17,1,28,29,35,37,21,52,20,65,37,23,54,50,59 };
int max_val = *max_element(array.begin(), array.end());
//求最大值的位数
int digits = 1;
while (max_val / 10 > 0)
{
++digits;
max_val /= 10;
}
//创建桶
vector<list<int>> bucket(10);
for (int i = 1; i <= digits; i++)
{
for (int j = 0; j < array.size(); j++)
{
//计算当前元素放在哪个桶
int radix = static_cast<int> (array[j]/pow(10, i - 1)) % 10;
bucket[radix].push_back(array[j]);
}
int k = 0;
for (int n = 0; n < 10; n++)
{
//每次按位排序后重新放到array中
for (auto value : bucket[n])
{
array[k++] = value;
}
//清空桶以便下一轮排序
bucket[n].clear();
}
}
//打印排序后的数组
for (auto v : array)
{
cout << v << " ";
}
return 0;
}
设 n n n 为待排序数组的元素个数, k k k 为最大值的位数,则对于基数排序:
时间复杂度
空间复杂度:
其中, m = 10 m=10 m=10 是10进制数的位的范围。