原文链接 https://www.cnblogs.com/morethink/p/8419151.html
稳定与非稳定:
如果一个排序算法能够保留数组中重复元素的相对位置则可以被称为是 稳定 的。反之,则是 非稳定 的。
通常人们整理桥牌的方法是一张一张的来,将每一张牌插入到其他已经有序的牌中的适当位置。在计算机的实现中,为了要给插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位。
一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
动态效果如下:
注意:
如果 比较操作 的代价比 交换操作 大的话,可以采用二分查找法来减少 比较操作 的数目。该算法可以认为是 插入排序 的一个变种,称为二分查找插入排序。
/** * 通过交换进行插入排序,借鉴冒泡排序 * * @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).
堆的定义如下:nn个元素的序列{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(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | O(nlog2n)O(nlog2n) | O(1)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).
由于冒泡排序只在相邻元素大小不符合要求时才调换他们的位置, 它并不改变相同元素之间的相对顺序, 因此它是稳定的排序算法。
快速排序使用分治策略来把一个序列(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); }
以下是快速排序算法复杂度:
平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 |
---|---|---|---|
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)是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
它是这样实现的:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
基数排序按照优先从高位或低位来排序有两种实现方案:
我们以LSD为例,从最低位开始,具体算法描述如下:
基数排序:通过序列中各个元素的值,对排序的N个元素进行若干趟的“分配”与“收集”来实现排序。
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) | 稳定 |
从时间复杂度来说:
论是否有序的影响:
代码地址