【算法】聊聊排序

程序开发过程中常常遇到对于一堆无序的数据进行排序的需求。对于不同的数据类型和存储位置,产生了不同的排序算法。掌握它们并应用在实际开发中是每一个程序员都需要具备的能力。这篇文章将聊聊10种常见的排序算法和它们的一些应用。如下是本文内容目录:


一、最简单的排序算法,时间复杂度O(n^2)

1. 选择排序

2. 插入排序

3. 冒泡排序

二、更快的排序算法们,时间复杂度O(nlogn)

1. 快速排序

2. 归并排序

3. 希尔排序

4. 堆排序

三、没有最快,只有更快。线性时间排序算法

1. 计数排序

2. 基数排序

3. 桶排序

四、快起来了是否还稳定?

五、一个另类状况,外排序。


排序是一个很常见的也很综合的问题,各种排序算法都经过了精巧的设计和优化来最大程度地满足开发需求。排序算法同时也是一个很值得研究的算法钟类,会让你从中得到很多的启发,是学习算法的一个很好的开端。


一、最简单的排序,时间复杂度为O(n^2)

1.选择排序

1.1原理

假设有一个无序的整型数组,{9,2,4,5,1,6,7},现在我们对它进行排序。最简单的思路我们可以想到按从小到大的顺序找到每个数然后放入相应的位置,比如我们首先找到最小的数是1,然后让道第一个位置,然后再找出第二小的数2,放在第二个位置上,依次类推,我们就排好了序。参考维基百科的插入排序动画。


JAVA参考代码如下:

void selectionSort(int[] array){
	int min = 0;
	int temp = 0;
	for(int i = 0; i < array.length - 1; i++){	
		min = i;
		for(int j = i + 1; j < array.length; j++){
			if(array[min] > array[j]) min = j;
		}
		if(min != i) {
			temp = array[i];
			array[i] = array[min];
			array[min] = temp;
		}
	}
}
这个过程就是每一次都选择一个相对最小的放在对应位置上,所以取名为选择排序。这个排序最接近于人的排序思维,因此比较容易理解。

1.2 算法分析

假设数组长度为n,这个算法其实第m趟循环确定一个数放入第m个位置。此时1到m为有序数列,m + 1到n为无序数组,所以比较n - m次能够确定在第m+1到n的元素中的最小值。显而易见,对于长度为n的数组,进行比较的次数为(n - 1) + (n - 2) + ... + 2 + 1,也就是(n - 1)(n - 1 + 1) / 2 = n(n - 1)/2次,所以时间复杂度为O(n^2)。空间复杂度呢?我们仅适用两个临时变量min和temp,所以空间复杂度为O(1)。

2. 插入排序

2.1原理

对于插入排序是在选择排序的思想上做了一些调整。我们假设数组长度为n,我们从第一个数开始,依次比较相邻两数的大小,如果发现后边一个a数更小,那么将这个数a逐个与前边的比较,如果发现放到某一个位置刚好让前后均为有序状态,那么这个数就应该放在那里。这合理吗?比如:6,7,18,9,10,21,12,我们发现12往前移动,移动到10和21中间就是它的位置,但是很明显前边还有18,明显没有有序啊?其实我们可以想想,算法是从前往后进行的处理,所以当处理到12时,18早被处理过了,所以根本不会出现上述情况。插入排序的思想就是,前边的都有序,那么后边的一个数a往前放,当遇到一个数小于a时,那么更之前的都必定小于a了。参考维基百科动画。


下边给出JAVA代码:

void insertSort(int[] array){
	int i = 0;
	int j = 0;
	int temp = 0;
	for(i = 1; i < array.length; i++){
		temp = array[i];
		j = i - 1;
		while(j >= 0 && temp < array[j]){
			array[j + 1] = array[j];
			j--;
		}
		array[j + 1] = temp;	
	}
}

2.2 算法分析

对于插入排序,第m个元素,最坏的情况就是这是最小的数,需要比较(m - 1)次放入正确位置。若整个数组都是降序排列,则总的比较次数为1 + 2 + ... + (n - 2) + (n - 1) = n(n - 1)/2,所以最坏情况是一个O(n^2)的时间复杂度。但是最好的情况就是数组原本升序排列,则总的比较次数为n。所以平均时间复杂度依旧为O(n^2),空间复杂度O(1)。

2.3 优化一下

其实我们发现,当处理到第m个元素时,1到m-1个元素都已处于有序状态。所以确定m元素的位置时可以采用二分搜查,这样可以减少插入的比较次数,插入的时间复杂度提升到O(logn),整个时间复杂度就为O(nlogn)。


3. 冒泡排序

冒泡排序和插入排序真是太像了,唯一的区别在于每一轮循环的起始点不同。插入排序是从第m到1的过程,冒泡排序则是n到m的过程。每一轮循环用索引m表示当前有序数列的上届,即表示1到m - 1都有序,m到n是本轮处理的数列。然后从n开始到m,依次比较相邻两数,发现后一位数小于前一位数就交换,直到第m位置上放入了正确的数后m++。参考维基百科提供的动画,注意,动画中外循环由后往前,我提供的代码外循环是由前往后,即动画中是从大往小排序,我是从小往大排序


Java代码如下:

void bubbleSort(int[] array){
	int temp = 0;
	for(int i = 0; i < array.length; i++){
		for(int j = array.length - 1; j > i; j--){
			if(array[j] < array[j - 1]){
				temp = array[j];
				array[j] = array[j - 1];
				array[j - 1] = temp;
			}
		}
	}
}

2.2 算法分析

冒泡排序的比较过程很好计算,每一趟比较次数为n - m,所以总的次数为(n-1) + (n-2) + ... + 2 + 1 = n(n-1)/2,时间复杂度为O(n^2),空间复杂度O(1)。

2.3优化一下

我们知道,处理到第m个数的时候,1到m-1个元素都已有序,如果某一趟从n到m的比较之后,发现并未有任何的交换发生,其实代表n到m之间的元素也已有序,则整个数组已经有序就无需再进行之后的循环了。所以我们可以为冒泡排序加上一个标记布尔值来表征是否发生过交换。代码如下:

void bubbleSort(int[] array){
	int temp = 0;
	boolean flag = false;
	for(int i = 0; i < array.length; i++){
		for(int j = array.length - 1; j > i; j--){
			if(array[j] < array[j - 1]){
				flag = true;
				temp = array[j];
				array[j] = array[j - 1];
				array[j - 1] = temp;
			}
		}
		if(!flag) break;
		else flag = false;
	}
}

对于上边的三种排序方法,平均时间复杂度都为O(n^2),对于规模较大的数据进行排序时,难免耗时过大。那么设计一些新的更快的排序手段就极为必要了。下边我将谈论一下几种常见的时间复杂度为O(nlogn)的排序算法。


二、更快的排序算法们,时间复杂度O(nlogn)

1. 快速排序

1.1 原理

毫无疑问,这个名字是霸气的,敢自称快速排序,一定是很快的。快速排序的思想是分治,首先以一个数为基准(习惯选择第一个数),将数组分成两部分,左边都是小于基准数的元素,右边都是大于基准数的元素。然后将两边的数组又看成独立的数组,重复上述步骤,直到分出的数组只剩下一个数,则整个数组就有序了。代码实现中,在调整数组的方法上并不是这么的直白,需要认真思考一下。下图为维基百科提供的快速排序动画。


JAVA代码如下:

void quickSort(int[] arr, int low, int high) {
	int i = 0;
	if (low < high) {
		i = sort(arr, low, high);
		quickSort(arr, low, i - 1);
		quickSort(arr, i + 1, high);
	}
}

int sort(int[] arr, int low, int high) {
	int key = arr[low];
	while (low < high) {
		while (low < high && arr[high] >= key)
			high--;
		arr[low] = arr[high];
		while (low < high && arr[low] <= key)
			low++;
		arr[high] = arr[low];
	}
	arr[low] = key;
	return low;
}

1.2 算法分析

数组的拆分是一个二分的过程,对于一个长度为n的数组,经过logn次拆分之后得到的所有子数组都只包含一个元素。因此快速排序需要拆分logn次,然后每一次拆分之后会遍历每一个数组来根据基准数重新调整数组,因此每一轮遍历都需要与基准数比较n次,所以最终时间复杂度为O(nlogn),这比之前的排序算法快多了,空间复杂度O(1)。

1.3 优化一下

但是快速排序的耗时会根据数据的特点有较大的波动,对于一个极其凌乱的数组,我们假设每一次的拆分都能把数组等分,那么这样的拆分就是一个完美的拆分,在logn次内拆分完成。但是假设数组是升序或者降序排列,每次选择的基准数都是数组中最大或者最小的时候,则整个数组需要n-1次拆分,这个时候的快速排序退化为选择排序,时间复杂度提升到O(n^2)次。当然,这样的情况是两个极端,虽然很少碰见,但是在实际开发中,从O(nlogn)到O(n^2)的波动还是无法让人接受的。网上有很多关于快速排序的测试,参考http://blog.chinaunix.net/uid-677314-id-2421086.html,所以对于最坏情况,有比较进行一下优化。

这里引入一个随机算法的解决方案。对于一个有序数列,选择第一个数作为基准数时会引起算法的退化,那么我们可以对于一个长度为n的数组,在每次排序调整之前,将首元素与剩下的n-1个数中的一个以1/(n-1)的概率交换一下,就可以有效提升快速排序在极端情况下的表现了。


2. 归并排序

2.1 原理

归并排序依旧是基于分治法的排序算法。与快速排序不同的是,归并排序是自底向上的排序过程。首先将数组一份为二,再二分为四,直到分出的数组都最多包含两个元素时切分结束。此时调整每个数组中的元素使得相互有序然后开始回溯,将相邻的有序数组两两组合成一个有序数组,当所有数组都组合完毕时,就得到了有序数组。对于数组{9,2,4,5,1,6,7},可以先二分为{9,2} {4,5} {1, 6} {7},然后调整数组为{2,9} {4,5} {1,6} {7},然后开始两两合并为{2,4,5,9} {1,6,7},最后合并为{1,2,4,5,6,7,9},排序结束。下图为维基百科提供的归并排序动画。


JAVA代码如下:

void separate(int[] arr, int low, int high) {
	int mid = (high - low) / 2 + low;
	if (low < high) {
		separate(arr, low, mid);
		separate(arr, mid + 1, high);
		merge(arr, low, high);
	}
}

void merge(int[] arr, int low, int high) {
	int mid = (high - low) / 2 + low;
	int n = low;
	int m = mid + 1;
	int temp = 0;
	int[] temparr = new int[high - low + 1];
	int i = 0;
	while (n <= mid && m <= high) {
		if (arr[n] <= arr[m]) {
			temparr[i] = arr[n];
			n++;
		} else if (arr[m] < arr[n]) {
			temparr[i] = arr[m];
			m++;
		}
		i++;
	}
	while (i < temparr.length) {
		if (n <= mid) {
			temparr[i] = arr[n];
			n++;
		} else {
			temparr[i] = arr[m];
			m++;
		}
		i++;
	}
	for (int j = 0; j < temparr.length; j++) {
		arr[low++] = temparr[j];
	}
}
2.2算法分析

归并排序的拆分过程因为是一个完美的二分,所以拆分次数为logn。合并两个有序数列是一个线性操作,所以归并排序的总的时间复杂度为O(nlogn)。空间复复杂度上,合并两个数组需要建立一个临时的数组来存放结果,所以合并两个长度为m的数组,需要建立一个2m的数组。因此空间复杂度为O(n)。


3. 希尔排序

3.1 原理

其实希尔排序又称为缩小增量插入排序,是插入排序的一个升级算法。其原理首先选择一个增量m,一般取数组长度n的一半,将所有元素和与它相聚m的元素组合成一个字数组,进行插入排序。之后再将m缩小一半,得到新的数组,继续进行插入排序,最后增量m缩小到1之后,再进行一次插入排序,即完成排序过程。动画演示可以参考这里http://student.zjzk.cn/course_ware/data_structure/web/flashhtml/shell.htm,下图为维基百科动画演示:


JAVA代码如下:

void shellSort(int[] arr, int length){
	int h = 1;
	int temp = 0;
		
	while (h < arr.length/3) {
	     h = h*3 + 1;    
	}
	    
	for (; h >= 1; h /= 3) {
	    for (int k = 0; k < h; k++) {
	        for (int i = h + k; i < arr.length; i+=h) {
	            for (int j = i; j >= h && arr[j] < arr[j-h]; j-=h) {
	               temp = arr[j];
	               arr[j] = arr[j - h];
	               arr[j - h] = temp;
	            }
	        }
	    }
	}
}
3.2 算法分析

希尔排序的过程分为数组按增量拆分和插入两个操作,毫无疑问,插入操作最坏情况下需要进行n-1次操作,但是和插入排序不同的是,希尔排序并不需要n轮循环,而是logn,所以总的时间复杂度为O(nlogn),这比直接插入排序来的快得多。空间复杂度上,希尔排序并使用固定数目的临时变量,所以为O(1)。


4.堆排序

4.1 原理

最后来讨论一种比较有意思的排序方式,堆排序。堆排序是基于堆这种数据结构的特性来进行排序的算法。我们知道堆可以构建为最大堆或最小堆,也就是说堆可以从一个数组中选出一个极值,如果我们每次选出一个极值后把它移除,然后对剩下的堆进行重建,当全部元素被移除之后,就完成一个完美的排序过程了。维基百科动画演示如下:


JAVA代码如下:

重头戏来了,堆排序首先是建堆,调用buildHeap()方法,然后进行排序调用heapSort()方法。该方法会交换首元素(极值)与第m个元素,然后将1到m-1个元素看做一个新的数组,对新的首元素进行siftDown来建立新堆。然后重复上述过程。

void buildHeap(int[] arr) {
	int length = arr.length - 2;
	for (int i = length / 2; i >= 0; i--) {
		siftDown(arr, i, arr.length);
	}
}

void siftDown(int[] arr, int i, int length) {
	int n = 0;
	while(i < length/2){
		if((i*2 +2) < length && arr[i * 2 + 1] < arr[i * 2 + 2]) n = i * 2 + 2;
		else n = i * 2 + 1;
		if(arr[i] < arr[n]){
			swap(arr, i, n);
			i = n;
		}else break;
	}
}
void swap(int[] arr, int i, int j) {
	int t = arr[i];
	arr[i] = arr[j];
	arr[j] = t;
}
	
void heapSort(int[] arr){
	int i = arr.length;
	while(i > 0){
		swap(arr, 0, i - 1);
		siftDown(arr, 0, --i);
	}
}
4.2 算法分析

建堆的过程是一个O(n)的过程,然后siftDown本身是一个二分的过程,也就是O(logn)的过程。最坏情况堆每一个数据都进行一次siftDown,则所以总的时间复杂度为O(nlogn),所以堆排序是一个时间复杂度O(nlogn)的排序算法。整个排序算法在原有数据结构中运行,所以空间复杂度为O(1)。堆排序是对堆这种数据结构的有效应用,了解了堆这种数据结构,也就很容易理解堆排序的原理了。

小结

上述几种排序算法比较常见,对于不同的数据类型和数据排列方式会有不同的表现,为了加深认识,这里提供两个有趣的视频。第一个视频将集中不同的排序算法进行了可视化演示,并对不同的数据类型进行了处理,从中你可以看出各种排序算法的优劣。第二个视频将排序算法音频花,通过它们发出的不同声音来了解算法。

https://www.youtube.com/watch?v=ZZuD6iUe3Pc

https://www.youtube.com/watch?v=t8g-iYGHpEA


三、没有最快,只有更快。线性时间排序算法

上述的算法具有普适性,对于绝大部分的数据都可以很好的进行应用。特别是时间复杂度为O(nlogn)的几种算法,在实际开发中的表现非常不错,所以应用很广。但是我们也常常遇到一些特殊的数据,使用这些常见排序算法虽然也可以,但是稍微观察一下数据的特点,我们可以构建出更快的算法来进行排序。这一部分我将讨论几种线性时间的排序算法,他们的时间复杂度为O(n)。


1. 计数排序

1.1 原理

计数排序是一种不需要进行比较就可以完成排序的算法,但是有一个苛刻的前提——需要知道包含元素的值的范围。对于一个数组A,其中元素的值都是0~65535,那么对于这样的数组,我们就可以使用计数排序进行线性时间的排序处理。首先定义一个临时数组B,然后B的长度就是A中值得范围,即int[] B = new int[65536];此时B中每一个值代表当前索引在A中出现的次数,也就是如果在A中出现一次2,就在把B[2]加1,这样最后可以得到对于A中元素出现的次数统计,然后依照由低到高的顺序,将B中每个索引出现的次数打印输出就得到了A中的排序结果。JAVA代码如下:

void countingSort(int[] array){
	int[] count = new int[65536];
		
	for(int i = 0; i < array.length; i++){
		count[array[i]]++;
	}
	
	for(int i = 0, n = 0; i < count.length; i++){
		if(count[i] != 0){
			for(int j = 0; j < count[i];n++, j++)
				array[n + j] = i;
		}
	}
}
1.2 算法分析

前边说过,计数排序的苛刻条件就是需要提前知道值得范围k,当范围k确定后,排序的过程就是两轮遍历,所以时间复杂度O(n+k)。空间上来讲,计数的临时空间大小只与数据本身有关,与数据量无关,所以空间复杂度常常为O(1)。另外这个算法在考虑负数情况时,需要对索引进行调整。


2. 基数排序

2.1 原理

基数排序的原理是逐位比较数组中的数字,首先按各位大小排序,然后按十位,再是百位,直到处理到最高位。本质上来讲是将数据分类处理。在处理个位时,是为了让之后具有相同十位数字的数处于一个升序状态,处理十位是为了让相同百位的数字升序,以此类推。处理到了最高位,数组就有序了。

JAVA代码如下:

void sort(int[] number, int d){
     intk = 0;
     intn = 1;
     intm = 1; 
     int[][]temp = newint[10][number.length]; 
     int[]order = newint[10]; 
     while(m <= d)
     {
         for(inti = 0; i < number.length; i++)
         {
             intlsd = ((number[i] / n) % 10);
             temp[lsd][order[lsd]] = number[i];
             order[lsd]++;
         }
         for(inti = 0; i < 10; i++)
         {
             if(order[i] != 0)
                 for(intj = 0; j < order[i]; j++)
                 {
                     number[k] = temp[i][j];
                     k++;
                 }
             order[i] = 0;
         }
         n *= 10;
         k = 0;
         m++;
    }
}
2.2 算法分析

基数排序的循环次数取决于数组中最大位数k,如果数组长n,则最多需要进行kn次比较。所以时间复杂度是O(kn),此处如果k比较小,其实这种限行时间算法会比O(nlogn)的算法更快,如果k较大,则慢于O(nlogn)。


3. 桶排序
3.1 原理

桶排序更是一种算法思想。根据数据的特点,一个建立一个大小为m的桶,然后将数据经过 m = f(n) 方法计算其值对应的桶,然后最后对每个桶进行单独的排序。当每个桶中的数据较少,使用任何的排序算法都可以高效进行排序。桶排序类似于Hash的思想,如何确定f(n)方法和桶的数量是通排序的关键,就像如何确定Hash方法减少冲突一样。桶排序更像是一种思想,这里不给出具体实现代码,但是举个例子:

对于数组A = {25,12,6,122,8,56,7,43,114,65},f(n)可以确定为n/10,那么这个数组需要8个桶,类似计数排序一样使用另外一个数组来记录,分别为:{6 8 7} {12} {25} {43} {56} {65} {114} {122},然后组内排序再依序输出即可。

3.2 算法分析

假设数组长度为n,划分为k个桶,使用快速算法对k个桶进行分别排序,考虑每个桶中数量m << n,则可忽略快速排序的时间logm。如果每个桶只有一个数据,则通排序时间复杂度为O(n),如果所有元素全进入一个桶,则时间复杂度为O(nlogn)。所以应用桶排序之前,至少需要对数据范围特性有一个充分的了解才能让桶排序有一个满意的表现。不过空间上,需要花费更多来换取更快的时间。


小结

其中上述的几种线性排序算法,都是应用了桶排序的分类思想,差异在于如何划分桶的方法上,其中的分类方法类似于Hash过程。不得不说,将排序算法从O(nlogn)尽力往O(n)提升的过程中,也应用到了查找算法从O(logn)往O(1)提升的思想——用空间换时间。很多时候对空间的扩展要比对时间的提升的成本来的低,所以如果空间富足,对于数据由足够的了解和把握,使用这些线性时间算法可以为你带来奇效。


四、快起来了是否还稳定?

排序算法种类繁多,不止于我讲述的上述几种,但是在众多排序算法中,有一个问题其实是很多实际开发中需要考虑的问题——稳定性。稳定性就是指在排序中,对于两个值相同的元素,在排序过程它们的相对位置发生变化与否。

在O(n^2)的三种排序中,对于插入和冒泡排序,我们知道它们采用的是两两比较交换的方式,因此值相同时是不会发生交换的,所以它们都是稳定排序。但是选择排序则有所不同。每次交换是子数组首元素与后边元素的极值发生的交换,所有连个相同元素间可能发生位置上的改变。比如5,2,3,5,1,6这个数组,将5与1发生交换时,两个5就不是了相对位置的变化。所以选择排序时不稳定排序。

但是对于O(nlogn)的集中排序就不那么简单了。除了归并排序,其他三种,快速,希尔,堆排序都是不稳定排序。为什么呢?是因为这三种排序算法使用分支算法,相同值得元素可能会被切分到不同的子数组中去进行处理,所以相对位置发生变化是正常的了。然后归并不同,归并排序是自低向上的过程,是有序数组合并的过程,这里边存在两两比较的情况,所以它是O(nlogn)排序算法中唯一一个稳定排序。

对于我介绍的线性时间排序,它们都是稳定排序。因为在无论是计数排序还是基数排序,在划分子数组的过程中,都是依序遍历原数组来进行的,所以值也是依序放入子数组的。但是对于桶排序来讲,如果在对桶进行排序时使用了不稳定排序结果就不一样了。

这里有一篇文章详细解释了这几种算法的稳定性原因http://www.cnblogs.com/codingmylife/archive/2012/10/21/2732980.html


五、一个另类状况,外排序。

上边讨论的所有排序算法都没有跳出一个圈——所有数据都全部放入内存中。我们要知道,计算机的内存是有限的,操作系统分配给程序的内存有一个上界。如果一个数据文件足够大,无法完全放入内存时,就无法简单应用上述任何一个排序算法。对于一个超大文件进行排序我们称之为外排序。

外排序的基本原理是,首先将超大文件拆分称为k个子文件,要保证这k个子文件,每一个都可以被单独读入内存,然后对子文件进行排序,这样就保证所有k个子文件都独立有序。然后开始逐一读入k个文件中的第一个元素,进行k路归并排序,然后存入另一个大文件中。这样的归并排序进行完毕后,另一个大文件就是一个完整排序结果了。参考一篇文章,谈论外排序,其中k路归并时,使用了败者树来减少比较次数。http://blog.chinaunix.net/uid-25324849-id-2182916.html



  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值