什么是排序?我们来看一下《大话数据结构》这本书上的定义:
那么我们需要首先了解关于排序的相关名词或者称为属性:
这里稳定性和复杂度是体现一个排序算法是否更合理更便捷的衡量标准。
稳定性分为稳定和不稳定两种,表示排序算法对原数据顺序的改变,例如:若有a=b,排序前a在b之前,排序后a还是在b之前,那么说这个排序是稳定的;相反,若排序后a变到了b之后,那么说这种排序是不稳定的。
复杂度分为空间复杂度和时间复杂度,用来描述一个排序算法占用的时间或者空间的大小。时间复杂度 : 一个算法执行所耗费的时间。空间复杂度 :运行完一个程序所需内存的大小。
根据排序算法排序方式的不同将其分为两种类型:内排序和外排序。内排序是指所有排序操作都在内存中完成,而外排序是指由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行。
本文主要讲的是内排序的多种方法:
对于内排序而言,算法主要受三个方面影响:
根据排序过程中借助的主要操作,内排序分为:插入排序、交换排序、选择排序、和归并排序。本文讲解的七种排序算法,按照算法复杂度分为两大类,冒泡排序、简单选择排序和直接插入排序属于简单算法,而希尔排序、堆排序、归并排序、快速排序属于改进算法。
在以下排序算法中,都会涉及到对数据的交换等操作,为了简化流程,这里放置一个所有排序算法操作的数据类及交换数据类中数组的函数代码:
//数据集
private static final int MAXSIZE = 10;
private int[] arr = new int[MAXSIZE];
private Random random = new Random();
/*初始化数据*/
public void initArr() {
for (int i = 0; i < MAXSIZE; i++)
arr[i] = random.nextInt(100);
}
/*交换*/
private void swap(int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
/*打印数组*/
public void print(){
for(int i:arr)
System.out.print(i+" ");
System.out.println();
}
冒泡排序是一种交换排序,冒泡排序类似于操作股市,不断的通过买进卖出以期获得收益。它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序为止。
我们这里需要注意一点:基本思想和实现的原理是不一样的,对于每个排序算法都是如此。
以下为实现代码:
/*冒泡排序初阶*/
public void bubbleSort0() {
for (int i = 0; i < MAXSIZE; i++) {
for (int j = MAXSIZE-2; j>=i; j--) {
if (arr[j] > arr[j+1])
swap(j,j+1);
}
}
}
下图为冒泡排序的理解:
从下标为 1 的位置开始,总共执行数组长度次内循环:内循环中,从后向前依次比较相邻的两个数据的大小,最后将最小的(或者最大的)数据交换到最上方。简单来说就是将未排序部分的数据中的最大或最小值,交换到未排序位置的最上方,实现其有序。
冒泡排序优化:若除了第一个和第二个位置的数字需要交换,其余都是有序的,那么我们再执行上述操作,之后几轮的操作就是多余的,这就可以进行优化:
优化思想:加入一个标记位,当有交换发生时,将其设为true,若为false则没有交换发生,说明之后的所有数据都已经有序,那么就不需要进行后续的操作了。
/*冒泡排序优化*/
public void bubbleSort1(){
boolean flag = true;//标记位
for(int i=0;i<MAXSIZE && flag;i++){
flag = false;
for(int j=MAXSIZE-2;j>i;j--){
if (arr[j] > arr[j+1]){
flag = true;
swap(j,j+1);
}
}
}
}
冒泡排序复杂度分析:最好的情况,需要排序的数据本身有序,那么比较次数就是n-1次,没有数据交换,时间复杂度为 O ( n ) O(n) O(n),最坏的情况,数据本事是逆序的,此时需要比较 1 + 2 + 3 + . . . + ( n − 1 ) = n ( n − 1 ) / 2 1+2+3+...+(n-1)= n(n-1)/2 1+2+3+...+(n−1)=n(n−1)/2次,此时时间复杂度为 O ( n 2 ) O(n^2) O(n2)。平均时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。
简单选择排序也类似股市选手,但是不同点在于他并不急于出手,而是不断的观察,找到合适的时机再出手。基本思想是每一趟在 n − i + 1 ( i = 1 , 2 , . . . , n − 1 ) n-i+1(i=1,2,...,n-1) n−i+1(i=1,2,...,n−1)个记录中选取关键字最小(或最大)的数据作为有序序列的第一个数据。
简单选择排序代码如下:通过 n − i n-i n−i次关键字间的比较,从 n − i + 1 n-i+1 n−i+1个记录中选出关键字最小的记录,并和第 i i i个记录交换。
/*简单选择排序*/
public void selectSort(){
int min;
for(int i=0;i<MAXSIZE;i++){//循环执行数组长度次
min = i;//最小值下标
for(int j=i+1;j<MAXSIZE;j++){//遍历i之后的数据
if(arr[j] < arr[min])//更新最小值
min = j;
}
if(i!=min)//若最小值不为i下标所存数据,将最小值和i位置数据交换
swap(i,min);
}
}
简单选择排序复杂度分析:从排序过程看,最大的特点为交换移动次数少,无论是最好最差的情况,其比较次数都一样多,需要比较 n ( n − 1 ) / 2 n(n-1)/2 n(n−1)/2次,交换次数最好时为0次,最差时为 n − 1 n-1 n−1次。最终的排序时间是比较和交换的次数总和,因此,总时间复杂度依然为 O ( n 2 ) O(n^2) O(n2) 。尽管其与冒泡排序相同,但其性能还是优于冒泡排序。
直接插入排序类似于玩扑克牌,你该如何整理下图扑克牌呢?答案很简单,你会把5插入4之后,再把2放到3之前。直接插入排序的基本操作是将一个记录插入到已排序好的有序表中,从而得到一个新的有序表。
代码如下:
/*直接插入排序*/
public void insertSort(){
int current;
for (int i=0; i<r.length-1;i++) {
if(r[i+1] <r[i]){//i+1需要插入
current = r[i+1];//保存需要插入的数据
int j=i;
for (;j>=0 && current<r[j];j--)//找到需要插入的位置
r[j+1] = r[j];
r[j+1] = current;//插入数据
}
}
}
具体代码实现为:从头开始遍历数据,若该数据比前一个数据小(或大),那么遍历之前的数据,找到它应该存放的位置,确保这个数据之前的数据都是有序的,然后继续遍历,直到最后将所有的数据都插入到了应该放的位置。
直接插入排序复杂度分析:空间上只需要一个辅助空间,那么主要看时间复杂度,最好的情况为本身有序,那么比较次数就是 n − 1 n-1 n−1次,没有移动次数,时间复杂度为 O ( n ) O(n) O(n),最坏的情况为本身逆序,此时需要比较 2 + 3 + . . . . + n = ( n + 2 ) ( n − 1 ) / 2 2+3+....+n = (n+2)(n-1)/2 2+3+....+n=(n+2)(n−1)/2次,移动次数为 ( n + 4 ) ( n − 1 ) / 2 (n+4)(n-1)/2 (n+4)(n−1)/2次。根据概率相同的原则,平均比较和移动次数约为 n 2 / 4 n^2/4 n2/4次,因此时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。同样的时间复杂度,直接插入排序法比冒泡和简单选择排序性能要好一些。
人们想了很多的办法,都是为了提高内排序的速度。但是在很长时间内,人们发现虽然有各种各样的算法,但是时间复杂度都是 O ( n 2 ) O(n^2) O(n2),计算机学术界充斥着“排序算法不可能突破 O ( n 2 ) O(n^2) O(n2)”,直到有一位科学家发布了超过 O ( n 2 ) O(n^2) O(n2)的新排序算法后,紧接着就出现了好几种可以超越 O ( n 2 ) O(n^2) O(n2)的算法,并把内排序算法的时间复杂度提高到了 O ( n l o g n ) O(nlogn) O(nlogn)。是的,希尔排序就是突破这个时间复杂度的第一批算法之一。
希尔排序是对直接插入排序的升级优化,我们讲直接插入排序在两种情况下很高效,分别是少量的插入操作和数据数量很少,但是现实情况中很少出现。希尔排序就是对大规模数据进行分组,对子数据组分别进行直接插入排序,而后当整个数据基本有序时,对整体进行一次插入排序。这里需要强调,基本有序是指小的关键字基本都在前,大的在后。为了保证数据的基本有序,采用跳跃分割的方法:将相距某个“增量”的数据组成一个子数据组,这样才能够保证子数据组分别进行插入排序后,整个结果时基本有序的。
我们先放上代码,跟着代码一起,了解希尔排序:
/*希尔排序*/
public void shellSort(){
int gap = r.length;
while (gap>0){
gap = gap/2;/*增量序列*/
for(int i=gap;i<r.length;i++){
if(r[i]<r[i-gap]){//需将r[i]插入有序增量子数据组
//直接插入排序的算法
int current = r[i];//保存需要插入的数据
int j;
for (j=i-gap;j>=0 && current<r[j];j-=gap) {//找到需要插入的位置
r[j+gap] = r[j];
}
r[j+gap] = current;//插入数据
}
}
}
}
整个流程如下:
需要注意的是:增量序列的最后一个增量必须等于1
希尔排序复杂度分析:希尔排序的关键是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序效率提高。其实就目前,增量的选取还是一个数学难题。大量的研究表明,增量序列为 时,效率不错,其时间复杂度为 O ( n 1.5 ) O(n^{1.5}) O(n1.5),要好于直接排序的 O ( n 2 ) O(n^2) O(n2)。一般认为其时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn) 。另外由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。
堆排序是对简单选择排序的优化。简单选择排序中,待排序的 n n n个数据需要比较 n − 1 n-1 n−1次,这样的操作并没有把每一趟的比较结果保存下来,所以下一次排序中又重复执行了这些比较操作。堆排序,我们需要先理解什么是堆:
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大根堆;或者每个结点的值都小于或者等于其左右孩子结点的值,称为小根堆。
那么如果用层序遍历的方式向堆中存入数据,则满足以下关系表达式:
举个例子:
堆排序就是利用堆来进行排序的方法。它的基本思想是:将待排序的序列构成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(和堆数组的末尾元素交换),然后将剩下的 n − 1 n-1 n−1个元素重新构造成一个堆,再得到一个次大的值,如此反复,直到只有两个节点的堆,并对它们作交换,最后得到有 n n n个节点的有序序列。
实现堆排序主要分为两点:
首先, n n n个元素初始建堆:
其次,调整成大根堆的方法:
代码如下:
/*堆排序*/
public void heapSort(){
for(int i=(r.length-1)/2;i>=0;i--)//将堆调整成大根堆
heapAdjust(i,r.length);
for(int i=r.length-1;i>0;i--){
swap(0,i);//将堆顶记录和当前未排序序列子序列的最后一个记录交换
heapAdjust(0,i);//重新调整成大根堆
}
}
/**
* 堆调整
* @param s
* @param length
*/
private void heapAdjust(int s, int length) {
int temp = r[s];
for(int j=2*s+1;j<length;j=(2*j+1)){//沿关键字较大的孩子结点向下选择
if(j+1<length && r[j]<r[j+1])
++j;//j为较大元素的下标
if(temp >= r[j])
break; //该元素应插入位置s上
r[s] = r[j];
s = j;
}
r[s] = temp;//插入
}
堆排序复杂度分析:对于每个非端点结点来说,其实最多进行两次比较和互换操作,因此构建堆的时间复杂度为 O ( n ) O(n) O(n)。正式排序时,第i次取堆顶记录建堆需要 O ( l o g i ) O(logi) O(logi)的时间,完全二叉树的某个结点到根结点的距离为 l o g i + 1 logi + 1 logi+1,并且需要取 n − 1 n-1 n−1次堆顶记录,因此重建堆的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。所以总的来说,堆排序的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。优于堆排序对原始数据不敏感,故最好、最坏和平均时间复杂度都为 O ( n l o g n ) O(nlogn) O(nlogn) 。空间复杂度上,因为记录的比较与交换是跳跃式的,因此堆排序也是一种不稳定的排序算法。由于构建堆需要的比较次数比较多,因此不适合排序个数少的情况。
在之前的堆排序中,充分利用了完全二叉树的深度是 [ l o g n ] + 1 [logn]+1 [logn]+1的特性,所以效率较高,但是同时,堆结构的设计就比较复杂,我们来介绍一种更简单直接的办法利用完全二叉树。
归并排序就像高考当中分数线的划分,一般来讲,假如全省一本预计录取一万人,那么就会把所有学校学生的成绩合并后,进行排序,第一万名的分数就是一本的分数线。类似的,归并排序就是利用归并的思想实现的排序算法。它的原理是假设初始序列含有n个元素,则可以看成n个有序的子序列,每个子序列的长度为1,然后两两合并,得到 n / 2 n/2 n/2个长度为2或1的有序子序列,之后再两两归并,重复如此直到得到长度为n的有序序列为止。
例如我们要排列一个数组序列{ 16 , 7 , 13 , 10 , 9 , 15 , 3 , 2 , 5 , 8 , 12 , 1 , 11 , 4 , 6 , 14 16,7,13,10,9,15,3,2,5,8,12,1,11,4,6,14 16,7,13,10,9,15,3,2,5,8,12,1,11,4,6,14},它的排序流程如下,可以观察到像一个倒置的完全二叉树。
归并排序的非递归实现代码如下:
/**
* 归并排序
*
* 优化方向:可以看到若输入3 1 2 4 5时,第二轮循环就有序了,但是系统还是会进行第三论循环比较。
*/
public void mergeSort() {
for (int i = 1; i < r.length; i *= 2) {//选择不同的归并长度进行分组,直到整个数据分为一组,结束归并
merge(i);
}
}
/**
* 归并排序中分段归并实现
*
* @param gap 归并段的长度 :1 2 4 8...
*/
public void merge(int gap) {
//分别定义四个指针,指向两个归并端的前端和后端
int L1 = 0,
R1 = L1 + gap - 1,
L2 = R1 + 1,
R2 = L2 + gap - 1 < r.length ?
L2 + gap - 1 : r.length - 1;//防止R2越界
int[] brr = new int[r.length]; //存储归并后的元素
int i = 0;//遍历brr的下标
//有两个归并段的情况
while (L2 < r.length) {
while (R1 >= L1 && R2 >= L2) {//当归并段未比较完时,循环比较两个归并段的元素大小
if (r[L1] < (r[L2])) {
brr[i++] = r[L1++];
} else {
brr[i++] = r[L2++];
}
}
//一个归并段归并完毕,拷贝未浏览完的剩余归并段
if (L1 > R1) {
for (int j = L2; j <= R2; j++) {
brr[i++] = r[j];
}
}
if (L2 > R2) {
for (int j = L1; j <= R1; j++) {
brr[i++] = r[j];
}
}
//更新标记位置 进行下两个归并段的归并,直到数据结束位置
L1 = R2 + 1;
R1 = L1 + gap - 1;
L2 = R1 + 1;
R2 = L2 + gap - 1 < r.length ? L2 + gap - 1 : r.length - 1;
}
//只有一个归并段 直接拷贝到brr中
for (int j = L1; j < r.length; j++) {
brr[i++] = r[j];
}
//更改原来的数据
for (int j : brr) {
r[j] = brr[j];
}
}
归并排序的复杂度分析:归并排序需要将相邻的长度为gap的序列两两归并,因此需要将所有待排序序列都扫描一遍,耗时 O ( n ) O(n) O(n),由完全二叉树的深度可知,整个归并排序需要进行 l o g n logn logn次,总的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn) ,且这是最好、最坏、平均的时间性能。递归算法的空间复杂度为 O ( n + l o g n ) O(n+logn) O(n+logn)。
这里我们需要说明,以上非递归的方式避免了递归时申请的空间,空间只是用到了申请归并临时的数组,因此空间复杂度为 O ( n ) O(n) O(n),并且避免递归也在时间性能上有一定提升,应尽量考虑非递归方法。
快速排序,被列为20世纪十大算法之一。快速排序是我们之前认为最慢的冒泡排序的升级版,它的实现增大了记录的比较和移动距离,将关键字较大的记录从前面直接移动到后面,关键字小的从后面直接移动到前面,从而减少了总的比较和移动次数。
快速排序的基本思想:通过一趟排序将待排序序列分割成独立的两部分,其中一部分数据的关键字均比另一部分数据的关键字小,则可分别对这两部分数据继续进行排序,从而达到整个序列有序的目的。
代码实现如下:
/**
* 快速排序算法 partition获取基准数: 基准数左边都比基准数小,基准数右边都比基准数大
*
* @param begin 头部位置指针
* @param end 尾部位置指针
* @return
*/
private int partition( int begin, int end) {
int temp = r[begin];
while (begin < end) {
while (begin < end && r[end]>=temp) {//当尾部元素比基准数大时,不需要移动,尾部标记向前
end--;
}
r[begin] = r[end];//此时尾部标记的元素比基准数小,应该在基准数的左边。将尾部元素放在前面。这里begin位置已经存储在temp中了
while (begin < end && r[begin]<temp) {//同样,当头部元素比基准数小时,不需要移动,头部标记向后
begin++;
}
r[end] = r[begin];//此时尾部标记的元素比基准数小,应该在基准数的左边。将尾部元素放在前面。这时end位置元素已经在上一个循环开始之间的begin位置
}
r[begin] = temp;//结束循环,指针重合,此时end=begin,需要将拿出的元素放回中间位置
return begin;//返回找到的基准数下标
}
/**
* 快速排序递归
* @param begin
* @param end
*/
private void quick( int begin, int end) {
int index = partition(begin, end); //获取基准数的下标
if (index - begin >= 2) {//基准左边还存在两个以上的元素,继续划分排序
quick(begin, index - 1);
}
if (end - index >= 2) {//基准右边还存在两个以上的元素,继续划分排序
quick(index + 1, end);
}
}
/**
* 快速排序方法入口
*/
public void quickSort() {
quick(0, r.length-1);
}
实现的逻辑,就是先找到一个基准(书中称为枢轴(pivot)),在进行比较的同时,保证基准数的左边都小于基准数,右边都大于基准数,基准左右的子序列继续执行此过程,直到子序列剩下一个元素为止,返回。整个所有的子序列都结束,即子序列已经到了原子程度,每个子序列都只有一个元素,也就完成了排序。
快速排序的复杂度分析:快排的时间性能取决于快速排序递归的深度,如果找到的基准正好是序列的中间值,那么递归树就是平衡的,最优情况下,partition每次划分都很均匀,如果排序n个元素,递归树深度就为 l o g n + 1 logn+1 logn+1,即仅需递归 l o g n logn logn次,需要时间为 T ( n ) T(n) T(n)的话,基准将数组一分为二,那么各自递归还需要 T ( n / 2 ) T(n/2) T(n/2)时间(最好情况,二分),数学计算如下:
得出最优情况下时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。最坏的情况(序列为正序或逆序),每次划分都只得到一个比上一次少一个记录的子序列,则需要 n − 1 n-1 n−1次递归调用,且第 i i i次划分需要经过 n − 1 n-1 n−1次关键字的比较才能找到第 i i i个记录,也就是基准。因此比较次数为 ,最终的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。平均情况为 O ( n l o g n ) O(nlogn) O(nlogn) ,证明忽略。
空间复杂度主要是递归的栈空间的使用,最好的情况为 O ( n l o g n ) O(nlogn) O(nlogn),最坏的情况为 O ( n ) O(n) O(n),平均情况,空间复杂度也为 O ( n l o g n ) O(nlogn) O(nlogn) 。由于关键字的比较和交换是跳跃的,因此快速排序是一种不稳定的排序算法。
快速排序的优化:
优化选取基准
我们上面讲,基准为中间值是最好的情况,但目前每次求基准时,总时固定选取第一个关键字作首个基准。有人提出随机获得一个在begin和end之间的数rnd,让它成为关键字就不容易出现这种情况。这被称为随机选取枢轴法。但是这种方法还是有随机性的问题,继续改进提出了三数取中法(median-of-three),即取三个关键字先进行排序,将中间数作为基准,一般取左端,右端和中间三个数,也可以随机选取。这样这个中间数至少不是最大数或最小数。由于整个序列的无序,随机选取和从左中右选取三个数其实是一回事,而且由于随机数生成带来的时间消耗,因此不考录随机数。
优化不必要的交换
上面的代码实现中已经写出了,注意其中并没有用到swap交换函数,而是选择保存数值并覆盖,用这种方法,减少了不必要的交换。
优化小数组的排序方案
可以增加一个判断,当end-begin不大于某个常数(常数的取值随实际调整),就用直接插入排序,这样保证最大化利用两种排序的优势完成排序。
优化递归操作
此处优化根据不同语言不同,但是目的就是减少递归,提高性能
了不起的排序算法
快速排序用“快速”命名,到今天,快速排序法经过多次优化,整体性能是排序算法中最好的。
从算法简单性来看,7种算法分为两类:
简单算法:冒泡、简单选择、直接插入
改进算法:希尔、堆、归并、快速
从平均情况看,后三种改进算法更好。
从最好情况看,冒泡和直接插入排序更好。
从最坏情况看,堆排序与归并牌序又强过快速排序以及其他简单排序。
从空间复杂度来看,归并和快速需要空间要求,其他都是少量需求。
从稳定性来看,归并是最好的选择。
从待排序个数来看,个数越小,采用简单排序方法更合适;个数越大,采用改进排序更合适。
若数据的关键字信息量较大,则简单选择排序最有优势。
如有任何问题或错误,欢迎留言私信指正讨论。
本文参考:
大话数据结构 / 程杰 著. —北京:清华大学出版社,2011.6
博主「Top_Spirit」的原创文章
博主「规速」的原创文章