本文对常见的排序算法进行了总结。php
常见排序算法以下:前端
它们都属于内部排序,也就是只考虑数据量较小仅须要使用内存的排序算法,他们之间关系以下:
\[ \begin{cases}内部排序 \begin{cases}插入排序\begin{cases}直接插入排序\\希尔排序\end{cases}\\选择排序\begin{cases}简单选择排序\\堆排序\end{cases}\\交换排序\begin{cases}冒泡排序\\快速排序 \end{cases}\\归并排序\\ 基数排序\end{cases}\\外部排序 \end{cases} \]java
\[ \left\{\begin{matrix} 内部排序\\ 外部排序 \end{matrix}\right. \]git
稳定与非稳定:github
若是一个排序算法可以保留数组中重复元素的相对位置则能够被称为是 稳定 的。反之,则是 非稳定 的。面试
一般人们整理桥牌的方法是一张一张的来,将每一张牌插入到其余已经有序的牌中的适当位置。在计算机的实现中,为了要给插入的元素腾出空间,咱们须要将其他全部元素在插入以前都向右移动一位。算法
通常来讲,插入排序都采用in-place在数组上实现。具体算法描述以下:segmentfault
动态效果以下:api
注意:
若是 比较操做 的代价比 交换操做 大的话,能够采用二分查找法来减小 比较操做 的数目。该算法能够认为是 插入排序 的一个变种,称为二分查找插入排序。数组
/** * 经过交换进行插入排序,借鉴冒泡排序 * * @param a */ public static void sort(int[] a) { for (int i = 0; i < a.length - 1; i++) { for (int j = i + 1; j > 0; j--) { if (a[j] < a[j - 1]) { int temp = a[j]; a[j] = a[j - 1]; a[j - 1] = temp; } } } } /** * 经过将较大的元素都向右移动而不老是交换两个元素 * * @param a */ public static void sort2(int[] a) { for (int i = 1; i < a.length; i++) { int num = a[i]; int j; for (j = i; j > 0 && num < a[j - 1]; j--) { a[j] = a[j - 1]; } a[j] = num; } }
直接插入排序复杂度以下:
平均时间复杂度 | 最好状况 | 最坏状况 | 空间复杂度 |
---|---|---|---|
O(n²) | O(n²) | O(n²) | O(1) |
插入排序所需的时间取决于输入元素的初始顺序。例如,对一个很大且其中的元素已经有序(或接近有序)的数组进行排序将会比随机顺序的数组或是逆序数组进行排序要快得多。
希尔排序,也称 递减增量排序算法,是插入排序的一种更高效的改进版本。希尔排序是 非稳定排序算法。
希尔排序是基于插入排序的如下两点性质而提出改进方法的:
希尔排序是先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
将待排序数组按照步长gap进行分组,而后将每组的元素利用直接插入排序的方法进行排序;每次再将gap折半减少,循环上述操做;当gap=1时,利用直接插入,完成排序。
能够看到步长的选择是希尔排序的重要部分。只要最终步长为1任何步长序列均可以工做。通常来讲最简单的步长取值是初次取数组长度的一半为增量,以后每次再减半,直到增量为1。更好的步长序列取值能够参考维基百科。
效果以下:
下面参考《算法》中给出的步长选择策略,《算法》中给出的解释是
下面代码中递增序列的计算和使用都很简单,和复杂递增序列的性能接近。当能够证实复杂的序列在最坏状况下的性能要好于咱们所使用的递增序列。更加优秀的递增序列有待咱们去发现。
public static void sort(int[] a) { int length = a.length; int h = 1; while (h < length / 3) h = 3 * h + 1; for (; h >= 1; h /= 3) { for (int i = 0; i < a.length - h; i += h) { for (int j = i + h; j > 0; j -= h) { if (a[j] < a[j - h]) { int temp = a[j]; a[j] = a[j - h]; a[j - h] = temp; } } } } }
如下是希尔排序复杂度:
平均时间复杂度 | 最好状况 | 最坏状况 | 空间复杂度 |
---|---|---|---|
O(nlog2 n) | O(nlog2 n) | O(nlog2 n) | O(1) |
希尔排序更高效的缘由是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序以后子数组都是部分有序的,这两种状况都很适合插入排序。
选择排序(Selection sort)是一种简单直观的排序算法。它的工做原理以下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,而后,再从剩余未排序元素中继续寻找最小(大)元素,而后放到已排序序列的末尾。以此类推,直到全部元素均排序完毕。
选择排序的主要优势与数据移动有关。若是某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,所以对 n个元素的表进行排序总共进行至多 n-1 次交换。在全部的彻底依靠交换去移动元素的排序方法中,选择排序属于很是好的一种。
动图效果以下所示:
public static void sort(int[] a) { for (int i = 0; i < a.length; i++) { int min = i; //选出以后待排序中值最小的位置 for (int j = i + 1; j < a.length; j++) { if (a[j] < a[min]) { min = j; } } //最小值不等于当前值时进行交换 if (min != i) { int temp = a[i]; a[i] = a[min]; a[min] = temp; } } }
如下是选择排序复杂度:
平均时间复杂度 | 最好状况 | 最坏状况 | 空间复杂度 |
---|---|---|---|
O(n²) | O(n²) | O(n²) | O(1) |
选择排序的简单和直观名副其实,这也造就了它”出了名的慢性子”,不管是哪一种状况,哪怕原数组已排序完成,它也将花费将近n²/2次遍从来确认一遍。即使是这样,它的排序结果也仍是不稳定的。 惟一值得高兴的是,它并不耗费额外的内存空间。
1991年的计算机先驱奖得到者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德(Robert W.Floyd) 和威廉姆斯(J.Williams) 在1964年共同发明了著名的堆排序算法(Heap Sort).
堆的定义以下:\(n\)个元素的序列{k1,k2,..,kn}
当且仅当知足下关系时,称之为堆。
把此序列对应的二维数组当作一个彻底二叉树。那么堆的含义就是:彻底二叉树中任何一个非叶子节点的值均不大于(或不小于)其左,右孩子节点的值。 由上述性质可知大顶堆的堆顶的关键字确定是全部关键字中最大的,小顶堆的堆顶的关键字是全部关键字中最小的。所以咱们可以使用大顶堆进行升序排序, 使用小顶堆进行降序排序。
此处以大顶堆为例,堆排序的过程就是将待排序的序列构形成一个堆,选出堆中最大的移走,再把剩余的元素调整成堆,找出最大的再移走,重复直至有序。
动图效果以下所示:
从算法描述来看,堆排序须要两个过程,一是创建堆,二是堆顶与堆的最后一个元素交换位置。因此堆排序有两个函数组成。一是建堆函数,二是反复调用建堆函数以选择出剩余未排元素中最大的数来实现排序的函数。
总结起来就是定义了如下几种操做:
对于堆节点的访问:
(2*i+1)
;(2*i+2)
;floor((i-1)/2)
;/** * @param a */ public static void sort(int[] a) { for (int i = a.length - 1; i > 0; i--) { max_heapify(a, i); //堆顶元素(第一个元素)与Kn交换 int temp = a[0]; a[0] = a[i]; a[i] = temp; } } /*** * * 将数组堆化 * i = 第一个非叶子节点。 * 从第一个非叶子节点开始便可。无需从最后一个叶子节点开始。 * 叶子节点能够看做已符合堆要求的节点,根节点就是它本身且本身如下值为最大。 * * @param a * @param n */ public static void max_heapify(int[] a, int n) { int child; for (int i = (n - 1) / 2; i >= 0; i--) { //左子节点位置 child = 2 * i + 1; //右子节点存在且大于左子节点,child变成右子节点 if (child != n && a[child] < a[child + 1]) { child++; } //交换父节点与左右子节点中的最大值 if (a[i] < a[child]) { int temp = a[i]; a[i] = a[child]; a[child] = temp; } } }
平均时间复杂度 | 最好状况 | 最坏状况 | 空间复杂度 |
---|---|---|---|
\(O(n \log_{2}n)\) | \(O(n \log_{2}n)\) | \(O(n \log_{2}n)\) | \(O(1)\) |
因为堆排序中初始化堆的过程比较次数较多, 所以它不太适用于小序列。 同时因为屡次任意下标相互交换位置, 相同元素之间本来相对的顺序被破坏了, 所以, 它是不稳定的排序。
我想对于它每一个学过C语言的都会了解,这多是不少人接触的第一个排序算法。
冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,若是他们的顺序错误就把他们交换过来。走访数列的工做是重复地进行直到没有再须要交换,也就是说该数列已经排序完成。这个算法的名字由来是由于越小的元素会经由交换慢慢“浮”到数列的顶端。
冒泡排序算法的运做以下:
public static void sort(int[] a) { //外层循环控制比较的次数 for (int i = 0; i < a.length - 1; i++) { //内层循环控制到达位置 for (int j = 0; j < a.length - i - 1; j++) { //前面的元素比后面大就交换 if (a[j] > a[j + 1]) { int temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; } } } }
如下是冒泡排序算法复杂度:
平均时间复杂度 | 最好状况 | 最坏状况 | 空间复杂度 |
---|---|---|---|
O(n²) | O(n) | O(n²) | O(1) |
冒泡排序是最容易实现的排序, 最坏的状况是每次都须要交换, 共需遍历并交换将近n²/2次, 时间复杂度为O(n²). 最佳的状况是内循环遍历一次后发现排序是对的, 所以退出循环, 时间复杂度为O(n). 平均来说, 时间复杂度为O(n²). 因为冒泡排序中只有缓存的temp变量须要内存空间, 所以空间复杂度为常量O(1).
因为冒泡排序只在相邻元素大小不符合要求时才调换他们的位置, 它并不改变相同元素之间的相对顺序, 所以它是稳定的排序算法。
快速排序是由东尼·霍尔所发展的一种排序算法。在平均情况下,排序 n 个项目要 Ο(nlogn) 次比较。在最坏情况下则须要 Ο(n2) 次比较,但这种情况并不常见。事实上,快速排序一般明显比其余 Ο(nlogn) 算法更快,由于它的内部循环(inner loop)能够在大部分的架构上颇有效率地被实现出来。
快速排序的基本思想:挖坑填数+分治法。
快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。
快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。
快速排序的名字起的是简单粗暴,由于一听到这个名字你就知道它存在的意义,就是快,并且效率高!它是处理大数据最快的排序算法之一了。虽然 Worst Case 的时间复杂度达到了 O(n²),可是人家就是优秀,在大多数状况下都比平均时间复杂度为 O(n logn) 的排序算法表现要更好。
快速排序使用分治策略来把一个序列(list)分为两个子序列(sub-lists)。步骤为:
递归到最底部时,数列的大小是零或一,也就是已经排序好了。这个算法必定会结束,由于在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
用伪代码描述以下:
i = L; j = R;
将基准数挖出造成第一个坑a[i]
。j--
,由后向前找比它小的数,找到后挖出此数填前一个坑a[i]
中。i++
,由前向后找比它大的数,找到后也挖出此数填到前一个坑a[j]
中。i==j
,将基准数填入a[i]
中public static void sort(int[] a, int low, int high) { //已经排完 if (low >= high) { return; } int left = low; int right = high; //保存基准值 int pivot = a[left]; while (left < right) { //从后向前找到比基准小的元素 while (left < right && a[right] >= pivot) right--; a[left] = a[right]; //从前日后找到比基准大的元素 while (left < right && a[left] <= pivot) left++; a[right] = a[left]; } // 放置基准值,准备分治递归快排 a[left] = pivot; sort(a, low, left - 1); sort(a, left + 1, high); }
上面是递归版的快速排序:经过把基准插入到合适的位置来实现分治,并递归地对分治后的两个划分继续快排。那么非递归版的快排如何实现呢?
由于 递归的本质是栈 ,因此咱们非递归实现的过程当中,能够借助栈来保存中间变量就能够实现非递归了。在这里中间变量也就是经过Pritation函数划分区间以后分红左右两部分的首尾指针,只须要保存这两部分的首尾指针便可。
public static void sortByStack(int[] a) { Stack<Integer> stack = new Stack<Integer>(); //初始状态的左右指针入栈 stack.push(0); stack.push(a.length - 1); while (!stack.isEmpty()) { //出栈进行划分 int high = stack.pop(); int low = stack.pop(); int pivotIndex = partition(a, low, high); //保存中间变量 if (pivotIndex > low) { stack.push(low); stack.push(pivotIndex - 1); } if (pivotIndex < high && pivotIndex >= 0) { stack.push(pivotIndex + 1); stack.push(high); } } } private static int partition(int[] a, int low, int high) { if (low >= high) return -1; int left = low; int right = high; //保存基准的值 int pivot = a[left]; while (left < right) { //从后向前找到比基准小的元素,插入到基准位置中 while (left < right && a[right] >= pivot) { right--; } a[left] = a[right]; //从前日后找到比基准大的元素 while (left < right && a[left] <= pivot) { left++; } a[right] = a[left]; } //放置基准值,准备分治递归快排 a[left] = pivot; return left; }
和大多数递归排序算法同样,改进快速排序性能的一个简单方法基于如下两点:
所以,在排序小数组时应该切换到插入排序。
快速排序是一般被认为在同数量级(O(nlog2n))的排序方法中平均性能最好的。但若初始序列按关键码有序或基本有序时,快排序反而蜕化为冒泡排序。为改进之,一般以“三者取中法”来选取基准记录,即将排序区间的两个端点与中点三个记录关键码居中的调整为支点记录。
实际应用中常常会出现含有大量重复元素的数组。例如,一个元素所有重复的子数组就不须要继续排序了,但咱们的算法还会继续将它切分为更小的数组。在有大量重复元素的状况下,快速排序的递归性会使元素所有重复的子数组常常出现,这就有很大的改进潜力,经当前实现的线性对数级的性能提升到线性级别。
算法描述:
代码实现:
public static void sortThreeWay(int[] a, int lo, int hi) { if (lo >= hi) { return; } int v = a[lo], lt = lo, i = lo + 1, gt = hi; while (i <= gt) { if (a[i] < v) { swap(a, i++, lt++); } else if (a[i] > v) { swap(a, i, gt--); } else { i++; } } sortThreeWay(a, lo, lt - 1); sortThreeWay(a, gt + 1, hi); } private static void swap(int[] a, int i, int j) { int t = a[i]; a[i] = a[j]; a[j] = t; }
如下是快速排序算法复杂度:
平均时间复杂度 | 最好状况 | 最坏状况 | 空间复杂度 |
---|---|---|---|
O(nlog₂n) | O(nlog₂n) | O(n²) | O(1)(原地分区递归版) |
归并排序是创建在归并操做上的一种有效的排序算法,1945年由约翰·冯·诺伊曼首次提出。该算法是采用分治法(Divide and Conquer)的一个很是典型的应用,且各层分治递归能够同时进行。
归并排序算法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每一个子序列是有序的。而后再把有序子序列合并为总体有序序列。
归并排序可经过两种方式实现:
递归法(假设序列共有n个元素):
迭代法
归并排序其实要作两件事:
所以,归并排序实际上就是两个操做,拆分+合并
下面是递归的方法:
public class Merge { //归并所需的辅助数组 private static int[] aux; public static void sort(int[] a) { //一次性分配空间 aux = new int[a.length]; sort(a, 0, a.length - 1); } public static void sort(int[] a, int low, int high) { if (low >= high) { return; } int mid = (low + high) / 2; //将左半边排序 sort(a, low, mid); //将右半边排序 sort(a, mid + 1, high); merge(a, low, mid, high); } /** * 该方法先将全部元素复制到aux[]中,而后在归并会a[]中。方法咋归并时(第二个for循环) * 进行了4个条件判断: * - 左半边用尽(取右半边的元素) * - 右半边用尽(取左半边的元素) * - 右半边的当前元素小于左半边的当前元素(取右半边的元素) * - 右半边的当前元素大于等于左半边的当前元素(取左半边的元素) * @param a * @param low * @param mid * @param high */ public static void merge(int[] a, int low, int mid, int high) { //将a[low..mid]和a[mid+1..high]归并 int i = low, j = mid + 1; for (int k = low; k <= high; k++) { aux[k] = a[k]; } 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++]; } } } }
如下是归并排序算法复杂度:
平均时间复杂度 | 最好状况 | 最坏状况 | 空间复杂度 |
---|---|---|---|
O(nlog₂n) | O(nlog₂n) | O(nlog₂n) | O(n) |
从效率上看,归并排序可算是排序算法中的”佼佼者”. 假设数组长度为n,那么拆分数组共需logn,, 又每步都是一个普通的合并子数组的过程, 时间复杂度为O(n), 故其综合时间复杂度为O(nlogn)。另外一方面, 归并排序屡次递归过程当中拆分的子数组须要保存在内存空间, 其空间复杂度为O(n)。
归并排序最吸引人的性质是它可以保证将任意长度为N的数组排序所需时间和NlogN成正比,它的主要缺点则是他所需的额外空间和N成正比。
基数排序的发明能够追溯到1887年赫尔曼·何乐礼在打孔卡片制表机(Tabulation Machine), 排序器每次只能看到一个列。它是基于元素值的每一个位上的字符来排序的。 对于数字而言就是分别基于个位,十位, 百位或千位等等数字来排序。
基数排序(Radix sort)是一种非比较型整数排序算法,其原理是将整数按位数切割成不一样的数字,而后按每一个位数分别比较。因为整数也能够表达字符串(好比名字或日期)和特定格式的浮点数,因此基数排序也不是只能使用于整数。
它是这样实现的:将全部待比较数值(正整数)统一为一样的数位长度,数位较短的数前面补零。而后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成之后,数列就变成一个有序序列。
基数排序按照优先从高位或低位来排序有两种实现方案:
MSD(Most significant digital) 从最左侧高位开始进行排序。先按k1排序分组, 同一组中记录, 关键码k1相等, 再对各组按k2排序分红子组, 以后, 对后面的关键码继续这样的排序分组, 直到按最次位关键码kd对各子组排序后. 再将各组链接起来, 便获得一个有序序列。MSD方式适用于位数多的序列。
LSD (Least significant digital)从最右侧低位开始进行排序。先从kd开始排序,再对kd-1进行排序,依次重复,直到对k1排序后便获得一个有序序列。LSD方式适用于位数少的序列。
咱们以LSD为例,从最低位开始,具体算法描述以下:
基数排序:经过序列中各个元素的值,对排序的N个元素进行若干趟的“分配”与“收集”来实现排序。
分配:咱们将L[i]中的元素取出,首先肯定其个位上的数字,根据该数字分配到与之序号相同的桶中
收集:当序列中全部的元素都分配到对应的桶中,再按照顺序依次将桶中的元素收集造成新的一个待排序列L[]。对新造成的序列L[]重复执行分配和收集元素中的十位、百位...直到分配完该序列中的最高位,则排序结束
public static void sort(int[] arr) { if (arr.length <= 1) return; //取得数组中的最大数,并取得位数 int max = 0; for (int i = 0; i < arr.length; i++) { if (max < arr[i]) { max = arr[i]; } } int maxDigit = 1; while (max / 10 > 0) { maxDigit++; max = max / 10; } //申请一个桶空间 int[][] buckets = new int[10][arr.length]; int base = 10; //从低位到高位,对每一位遍历,将全部元素分配到桶中 for (int i = 0; i < maxDigit; i++) { int[] bktLen = new int[10]; //存储各个桶中存储元素的数量 //分配:将全部元素分配到桶中 for (int j = 0; j < arr.length; j++) { int whichBucket = (arr[j] % base) / (base / 10); buckets[whichBucket][bktLen[whichBucket]] = arr[j]; bktLen[whichBucket]++; } //收集:将不一样桶里数据挨个捞出来,为下一轮高位排序作准备,因为靠近桶底的元素排名靠前,所以从桶底先捞 int k = 0; for (int b = 0; b < buckets.length; b++) { for (int p = 0; p < bktLen[b]; p++) { arr[k++] = buckets[b][p]; } } System.out.println("Sorting: " + Arrays.toString(arr)); base *= 10; } }
如下是基数排序算法复杂度,其中k为最大数的位数:
平均时间复杂度 | 最好状况 | 最坏状况 | 空间复杂度 |
---|---|---|---|
O(d*(n+r)) | O(d*(n+r)) | O(d*(n+r)) | O(n+r) |
其中,d 为位数,r 为基数,n 为原数组个数。在基数排序中,由于没有比较操做,因此在复杂上,最好的状况与最坏的状况在时间上是一致的,均为 O(d*(n + r))
。
基数排序更适合用于对时间, 字符串等这些 总体权值未知的数据 进行排序。
基数排序不改变相同元素之间的相对顺序,所以它是稳定的排序算法。
基数排序 vs 计数排序 vs 桶排序
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差别:
各类排序性能对好比下:
排序类型 | 平均状况 | 最好状况 | 最坏状况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
直接插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
折半插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
希尔排序 | O(n^1.3) | O(nlogn) | O(n²) | O(1) | 不稳定 |
归并排序 | O(nlog₂n) | O(nlog₂n) | O(nlog₂n) | O(n) | 稳定 |
快速排序 | O(nlog₂n) | O(nlog₂n) | O(n²) | O(nlog₂n) | 不稳定 |
堆排序 | O(nlog₂n) | O(nlog₂n) | O(nlog₂n) | O(1) | 不稳定 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
桶排序 | O(n+k) | O(n+k) | O(n²) | O(n+k) | (不)稳定 |
基数排序 | O(d(n+k)) | O(d(n+k)) | O(d(n+kd)) | O(n+kd) | 稳定 |
从时间复杂度来讲:
论是否有序的影响:
参考资料: