算法是从经验中提炼出来的细化步骤.
一.概述
排序是一项基础操作,大量计算任务和作业因为进行了合理的排序预处理而变得简单,所以掌握排序算法是一项必须技能.
本文介绍了10种常见的排序算法,并从各个方面做了总结.
二.相关概念
2.1 排序稳定
相同数相对位置不变,对排序结果而言.
如果两个数相同,对他们进行的排序结果为他们的相对顺序不变。例如A={1,2,1,2,1}这里排序之后是A = {1,1,1,2,2} 稳定就是排序后第一个1就是排序前的第一个1,第二个1就是排序前第二个1,第三个1就是排序前的第三个1。同理2也是一样。不稳定就是他们的顺序与开始顺序不一致。
下列所列举的排序算法都是将元素从小到大排序.
2.2 原地排序
指不申请多余的空间进行的排序,就是在原来的排序数据中比较和交换的排序。例如快速排序,堆排序等都是原地排序,合并排序,计数排序等不是原地排序。
总体上说,排序算法有两种设计思路,一种是基于比较,另一种不是基于比较。
三.基于比较的排序算法
基于比较的排序算法有三种设计思路,分别为插入,交换和选择。对于插入排序,主要有直接插入排序,希尔排序;对于交换排序,主要有冒泡排序,快速排序;对于选择排序,主要有简单选择排序,堆排序;其它排序:归并排序。
3.1 冒泡排序
说到算法,肯定要提冒泡排序,冒泡法可以说是我第一个学到的算法.也是我本科使用最多的算法 (:sad.
3.1.1 思想
如图,冒泡法分为若干趟进行,每一趟比较相邻两个数的大小,若前面大于后面,则交换,第一趟下来,最大的数就跑到最后面.下一趟只要考虑前n-1个数,需要比较的也是前n-1个数.一直下去,直到没有交换操作.
其核心在于比较+交换.
什么时候我们会用冒泡排序呢?比如,体育课上从矮到高排队时,站队完毕后总会有人出来,比较挨着的两个人的身高,指挥到:你们俩调换一下,你们俩换一下.
3.1.2 关键代码
void bubbleSort(int[] array) {
int n = array.length;
int i,j,temp;
boolean swapped;
for (i=0;i<n-1;i++) {
swapped = false;
for (j=0;j<n-1-i;j++) {
if (array[j]>array[j+1]) {
temp = array[j];
array[j] = array[j+1];
array[j+1] = temp;
swapped = true;
}
}
if (!swapped) {
return;
}
}
}
3.1.3 特点
稳定排序,原地排序,时间复杂度O(n*n)
3.1.4 适用场景
数据基本有序.
3.2 选择排序
3.2.1 思想
如图,将排序序列分成排序和未排序的.首先在未排序的序列中找到最小值和第一个元素交换,然后在剩下的元素中继续寻找最小,和第一个元素交换.直到所有元素都排序完毕.
生活中的例子:假设班级选美,你肯定会选出最好看放在第一位,然后从剩下的选择最好看,以此类推,直到所有女生都排好序.
其核心思想在于查找+交换.
查找里包含比较.
3.2.2 关键代码
void selectionSort(int[] array) {
int len = array.length();
int i,j,min,temp;
for (i=0;i<len;++i) {
min = i; //记录下标
for (j=i+1;j<len;++j) {
if (array[min]>array[j]){
min = j;
}
}
temp = array[i];
array[i] = array[min];
array[min] = temp;
}
}
3.2.3 特点
不稳定排序(比如对3 3 2三个数进行排序,第一个3会与2交换),原地排序,时间复杂度O(N*N)
3.2.4 适用场景
交换少
43.3 堆排序
3.3.1 思想
利用堆这种数据结构,将待排序序列分成有序序列和无序序列.对无序序列构造最大堆,获得堆顶(最大值)放入有序序列,然后从剩下的元素继续前面的步骤,直到排序结束.
在堆中定义以下几种操作:
- 最大堆调整(Max-Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
- 创建最大堆(Build-Max-Heap):将堆所有数据重新排序,使其成为最大堆
- 堆排序(Heap-Sort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
堆用到了选择排序的思想,不同之处在于用到了堆数据结构.其核心思想在于构造最大堆+交换.
下面以书上的一个例子作为演示:
3.3.2 关键代码
private static void soutCore(int[] array) {
int len = array.length;
//构造大顶堆
buildHeap(array);
//堆顶排到尾部,分成有序块和无序块,对无序块重新构造大顶推
for (int i=len-1;i>0;--i){
swap(array,0,i);
//重新把最大值抬升到顶部
heapify(array,0,i);
}
}
private static void swap(int[] array, int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
private static void buildHeap(int[] array) {
int len = array.length;
//从中间元素开始
for (int i=len/2-1;i>=0;--i){
heapify(array, i, len);
}
}
//最大数抬升到堆顶
private static void heapify(int[] array, int idx, int max) {
int left = idx*2+1;
int right = idx*2+2;
int largestIndex;
if (left < max && array[left]>array[idx]) {
largestIndex = left;
}else {
largestIndex = idx;
}
if (right < max && array[right]>array[largestIndex]) {
largestIndex = right;
}
if (largestIndex!=idx) {
swap(array, idx, largestIndex);
heapify(array, largestIndex, max);
}
}
3.3.3 特点
非稳定排序,原地排序,时间复杂度O(N*lg N)
3.3.4 适用场景
时间复杂度稳定,保持O(N*lg N),但不如快排广泛.
3.4 快速排序
3.4.1 思想
如图,选取最后一个数作为基准,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面,在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作.该基准值所在位置就属排序的位置.然后递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
递归到最底部时,数列的大小是零或一,也就是已经排序好了。这个算法一定会结束,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
其核心在于递归+分而治之.
3.4.2 关键代码
private static void solve(int[] array, int start, int end) {
if (start<end) {
int p = solveCore(array, start,end);
solve(array,start, p-1);
solve(array, p+1,end);
}
}
private static int solveCore(int[] array,int start,int end) {
int x = array[end];
int p = -1;
for (int j=0;j<end;++j){
if (array[j]<=x) { //一定是小于等于
p++;
swap(array,p,j);
}
}
swap(array, p+1, end);
return p+1;
}
private static void swap(int[] array, int i, int j) {
if (array != null) {
int temp = array[i];
array[i] =array[j];
array[j] = temp;
}
}
3.4.3 特点
不稳定排序,原地排序,时间复杂度O(N*lg N)
3.4.4 适用场景
应用很广泛,差不多各种语言均提供了快排API
3.5 归并排序
3.5.1 思想
如图,(假设序列共有n个元素):
将序列每相邻两个数字进行归并操作,形成 floor(n/2)个序列,排序后每个序列包含两个元素
将上述序列再次归并,形成 floor(n/4)个序列,每个序列包含四个元素
重复步骤2,直到所有元素排序完毕
其核心在于递归+分而治之.
3.5.2 关键代码
static void merge_sort_recursive(int[] arr, int[] result, int start, int end) {
if (start >= end)
return;
int len = end - start, mid = (len >> 1) + start;
int start1 = start, end1 = mid;
int start2 = mid + 1, end2 = end;
merge_sort_recursive(arr, result, start1, end1);
merge_sort_recursive(arr, result, start2, end2);
int k = start;
while (start1 <= end1 && start2 <= end2)
result[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
while (start1 <= end1)
result[k++] = arr[start1++];
while (start2 <= end2)
result[k++] = arr[start2++];
for (k = start; k <= end; k++)
arr[k] = result[k];
}
public static void merge_sort(int[] arr) {
int len = arr.length;
int[] result = new int[len];
merge_sort_recursive(arr, result, 0, len - 1);
}
3.5.3 特点
稳定排序,非原地排序,时间复杂度O(N*logN)
3.5.4 适用场景
外部排序和大批量数据的排序.
3.6 插入排序
3.6.1 思想
如图,通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
其核心在于比较+交换.
3.6.2 关键代码
public static void insertion_sort(int[] arr) {
for (int i = 1; i < arr.length; i++ ) {
int temp = arr[i];
int j = i - 1;
//如果将赋值放到下一行的for循环内, 会导致在第10行出现j未声明的错误
for (; j >= 0 && arr[j] > temp; j-- ) {
arr[j + 1] = arr[j];
}
arr[j + 1] = temp;
}
}
3.6.3 特点
稳定排序,原地排序,时间复杂度O(N*N)
3.6.4 适用场景
当数据已经基本有序时,采用插入排序可以明显减少数据交换和数据移动次数,进而提升排序效率。
3.7 shell排序
3.7.1 思想
Shell 排序是对插入排序的一种改进.将数组列在一个表中并对列排序(用插入排序)。重复这过程,不过每次用更长的列来进行。最后整个表就只有一列了。将数组转换至表是为了更好地理解这算法,算法本身仅仅对原数组进行排序(通过增加索引的步长,例如是用i += step_size而不是i++)。
其核心在于选择步长+插入排序.
3.7.2 关键代码
public static void shell_sort(int[] arr) {
int gap = 1, i, j, len = arr.length;
int temp;
while (gap < len / 3)
gap = gap * 3 + 1; // <O(n^(3/2)) by Knuth,1973>: 1, 4, 13, 40, 121, ...
for (; gap > 0; gap /= 3)
for (i = gap; i < len; i++) {
temp = arr[i];
for (j = i - gap; j >= 0 && arr[j] > temp; j -= gap)
arr[j + gap] = arr[j];
arr[j + gap] = temp;
}
}
3.7.3 特点
非稳定排序,原地排序,时间复杂度O(n^lamda)(1 < lamda < 2), lamda和每次步长选择有关。
3.7.4 适用场景
因为增量初始值不容易选择,所以该算法不常用。
四.非基于比较的排序算法
4.1 计数排序
4.1.1 思想
计数排序使用一个额外的数组C,其中第i个元素是待排序数组A中值等于i的元素的个数。然后根据数组C来将A中的元素排到正确的位置。
通俗地理解,例如有10个年龄不同的人,统计出有8个人的年龄比A小,那A的年龄就排在第9位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去1的原因。
其核心在于统计+数范围限制.
4.1.2 关键代码
private static void sortCore(int[] array) {
int len = array.length;
//数组范围0-n,创建这样的K个容器
int[] b = new int[len];
for (int i=0;i<len;++i) {
b[array[i]]++; //计数
}
int index = 0;
for (int j=0;j<len;++j){
while (b[j]>0){
array[index++] = j;
b[j]--;
}
}
}
4.1.3 特点
稳定排序,非原地排序,时间复杂度O(N)
4.1.4 适用场景
比基数排序和桶排序广泛得多。
4.2 桶排序
4.1.1 思想
将元素装入对应的桶.
对桶内元素排序(插入排序),然后输出.
4.1.2 关键代码
/**
* @param a 待排序数组元素
* @param step 步长(桶的宽度/区间),具体长度可根据情况设定
* @return 桶的位置/索引
*/
private int indexFor(int a,int step){
return a/step;
}
public void bucketSort(int []arr){
int max=arr[0],min=arr[0];
for (int a:arr) {
if (max<a)
max=a;
if (min>a)
min=a;
}
//该值也可根据实际情况选择
int bucketNum=max/10-min/10+1;
List buckList=new ArrayList<List<Integer>>();
//create bucket
for (int i=1;i<=bucketNum;i++){
buckList.add(new ArrayList<Integer>());
}
//push into the bucket
for (int i=0;i<arr.length;i++){
int index=indexFor(arr[i],10);
((ArrayList<Integer>)buckList.get(index)).add(arr[i]);
}
ArrayList<Integer> bucket=null;
int index=0;
for (int i=0;i<bucketNum;i++){
bucket=(ArrayList<Integer>)buckList.get(i);
insertSort(bucket);
for (int k : bucket) {
arr[index++]=k;
}
}
}
//把桶内元素插入排序
private void insertSort(List<Integer> bucket){
for (int i=1;i<bucket.size();i++){
int temp=bucket.get(i);
int j=i-1;
for (; j>=0 && bucket.get(j)>temp;j--){
bucket.set(j+1,bucket.get(j));
}
bucket.set(j+1,temp);
}
}
4.1.3 特点
稳定排序,非原地排序,时间复杂度O(N)
4.1.4 适用场景
4.3 基数排序
此处不做说明.
4.1.1 思想
将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
它是这样实现的:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
五. 选择排序算法的标准
排序算法那么多,是不是有绝对的最优呢?
是不是时间复杂度越低,算法就最优呢?
像排序算法,归并算法,堆排序这三种,时间复杂度都是O(n^2), 又该如何选择呢?
时间最优还是空间最优?
考虑各个算法的短板.通过短板和应用场景来选择正确的排序算法.
标准 | 排序算法 |
---|---|
元素很少 | 插入排序 |
几乎有序 | 插入排序 |
关注最差情 | 堆排序(牢记:堆排序的最差时间复杂度依然是O(nlogn)) |
平均较好 | 快速排序 |
元素从密集范围取出 | 桶排序 |
代码量少 | 插入排序 |
六. 小结
各算法时间复杂度和空间复杂度列表
上表有些不严谨,待更.