henryzhihua 2020-06-21
本文分析冒泡、快速、选择、插入、希尔、归并和堆排序,为了对以下各个算法进行方便的测试,测试主方法体如下(Java 实现):
public class Sort { public static void main(String[] args) { int[] input = {5, 4, 7, 1, 6, 2, 8, 9, 10}; // 此处调用方法,以调用冒泡排序为例 bubbleSort(input); for (int i = 1; i <= input.length; i++) { System.out.println(input[i - 1]); } } }
本篇博文所有排序实现均默认 从小到大。
每一次通过两两比较找到最大(小)的元素放在未排序序列的最后,直至有序。
public static void bubbleSort(int[] input) { for (int i = 1; i <= input.length; i++) { for (int j = 1; j <= input.length - i; j++) { if (input[j - 1] > input[j]) { int temp = input[j - 1]; input[j - 1] = input[j]; input[j] = temp; } } } }
最好时间复杂度:O(n^2)。当元素有序时,要比较。n 个元素,每个元素比较 n次。
最坏时间复杂度:O(n^2)。当元素无序时,也要比较。
平均时间复杂度:O(n^2)。
辅助空间:不需要额临时的存储空间来运行算法,所以为 O(1)。
稳定性:因为排序方式是相邻数比较后交换,如果序列中有相等的两个数,待两数相邻时,不会交换两者的位置,所以稳定。
某一趟遍历如果没有元素交换,flag 标记依旧为 true。说明已经排好序了,结束迭代。
public static void bubbleSort2(int[] input) { for (int i = 1; i <= input.length; i++) { boolean flag = true; for (int j = 1; j <= input.length - i; j++) { if (input[j - 1] > input[j]) { int temp = input[j - 1]; input[j - 1] = input[j]; input[j] = temp; } flag = false; } if (flag) break; } }
算法指标分析
最好时间复杂度:当序列有序时。第一个 for 循环中,第 2/3/11/12 行语句执行 1 次,即频度为 1;第二个 for 循环中,第 4、5、9 行语句执行 n-1 次,即频度为 n-1。T(n) = 3*(n-1)+4 = 3n+1 = O(n)。所以最好时间复杂度为 O(n)。
最坏时间复杂度:当序列为逆序时。第一个 for 循环的频度为 n;第二个 for 循环中,j 可以取 1,2,...,n-1,所以第 3/4/6/7/8 语句,频度均为 (1+n-1)??(n-1)/2。T(n) = n??(n-1)/2+n = O(n^2)。所以最坏时间复杂度为 O(n^2)。
我们可以看出在嵌套层数多的循环语句中,由最内层语句的频度 f(n) 决定时间复杂度。
记录某次遍历时最后发生元素交换的位置(LastExchange),这个位置之后的数据显然已经有序,不用再排序了。因此通过记录最后发生元素交换的位置就可以确定下次循环的范围。
public static void bubbleSort3(int[] input) { int LastExchange = input.length; boolean flag = true; for (int i = 1; i <= input.length; i++) { int k = LastExchange; for (int j = 1; j <= k - 1; j++) { if (input[j - 1] > input[j]) { int temp = input[j - 1]; input[j - 1] = input[j]; input[j] = temp; } LastExchange = j; flag = false; } if (flag) break; } }
又称划分交换排序(partition-exchange sort)。采用了分而治之的思想。
选择末尾数为基准值
20, 2, 10 ,1, 6, 9
6 5 3 1 8 7 2 4 ↑ ↑ ^ 2 5 3 1 8 7 6 4 ↑ ↑ ^ 2 1 3 5 8 7 6 4 ↑ ^ 2 1 3 [4] 8 7 6 5
// Java public static void quickSort(int[] input) { quick_sort(input, 0, input.length - 1); } private static void quick_sort(int[] input, int start, int end) { if (start >= end) { return; } int left = start, right = end - 1; int pivot = input[end]; while (left < right) { // 跳过 while (input[left] <= pivot && left < right) { left++; } while (input[right] >= pivot && left < right) { right--; } int temp = input[left]; input[left] = input[right]; input[right] = temp; } // 此时左右指针重合(left == right),其指向元素可能大于基准值 if (input[left] >= pivot) { int temp = input[left]; input[left] = pivot; input[end] = temp; } else left++; // 跳过已经有序的中数 quick_sort(input, start, left - 1); quick_sort(input, left, end); }
最好时间复杂度:n*log n。n:交换;log n:调用栈的高度(2^(high-1) = n),数组有序且基准值每次都是中间值。
最坏时间复杂度:n^2。调用栈的高度为 n,基准值每次都是最值。
平均时间复杂度:O(n log n)。随机选择基准值 (log n + n)/2 = log n
辅助空间:需要额临时的存储空间来运行算法,所以为 O(n log n)~O(n)。
稳定性:不稳定。
更多实现方式:快速排序的几种实现方式
每一次通过选择找到未排序序列的最小(大)元素放在已排序序列的末尾(交换位置),直至有序。
public static void selectionSort(int[] input) { for (int i = 1; i <= input.length; i++) { int minIndex = i - 1; for (int j = i; j < input.length; j++) { if (input[minIndex] > input[j]) minIndex = j; } if (minIndex != i - 1) { int temp = input[minIndex]; input[minIndex] = input[i - 1]; input[i - 1] = temp; } } }
不管序列有序还是逆序,第二个 for 循环的频度为 n*(n-1)/2。所以最坏时间复杂度和最好时间复杂度均为 O(n^2)。
平均时间复杂度:O(n^2)
辅助空间:不需要额临时的存储空间来运行算法,所以为 O(1)。
稳定性:不稳定。举例,5、7、5、2、9,找到最小 2 时,2 与 5 交换,此时两个 5 的相对位置发生改变。
对于每个未排序元素,在已排序序列中从后向前扫描,找到相应位置并插入。
6 5 3 1 8 7 2 4 5 6 3 1 8 7 2 4 5 6 交换 5 3 6 1 8 7 2 4 6 3 交换 3 5 6 1 8 7 2 4 3 5 交换
public static void insertionSort(int[] input) { for (int i = 1; i < input.length; i++) { for (int j = i; j > 0; j--) { if (input[j] < input[j - 1]) { int temp = input[j]; input[j] = input[j - 1]; input[j - 1] = temp; } else break; } } }
最好时间复杂度:当序列有序时。当第一个 for 循环运行时,在第二个 for 循环中,因为序列有序,所以只会相邻比较 1 次,就跳出循环。所以两个循环的频度均为 n-1,所以最好时间复杂度均为 O(n)。
最坏时间复杂度:当序列逆序时。第二个 for 循环的频度为 n(n-1)/2。所以最坏时间复杂度均为 O(n^2)。
平均时间复杂度:O((n+n^2)/2) = O(n^2)。
辅助空间:不需要额临时的存储空间来运行算法,所以为 O(1)。
稳定性:因为排序方式是两两比较,序列中如果相邻两个数相等,不会交换两者的位置,所以稳定。
希尔排序,也称递减增量排序算法,实质上是一种分组插入排序算法。是插入排序的一种更高效的改进版本。希尔排序是基于插入排序的以下两点性质而提出改进方法的:
{5, 4, 7, 1, 6, 2, 8, 9, 10}
,如果我们以步长为 4 开始进行排序,我们可以通过将这列表放在有 4 列的表中来更好地描述算法,这样他们就应该看起来是这样:5, 4, 7, 16, 2, 8, 910
5, 2, 7, 16, 4, 8, 910
5, 2
7, 1
6, 4
8, 9
10
5, 1, 6, 2, 7, 4, 8, 9, 10
public static void shellSort(int[] input) { int len = input.length, j; int gap = Math.round(len / 2); for (; gap > 0; gap /= 2) // 步长每次就减少一倍 for (int i = gap; i < len; i++) { int temp = input[i]; for (j = i - gap; j >= 0 && temp < input[j]; j -= gap) {// 列排序 input[j + gap] = input[j]; input[j] = temp; } } }
最好时间复杂度:当序列有序时。最好时间复杂度均为 O(n^s),1<s<2,跟算法步长有关。
最坏时间复杂度:当序列逆序时。最坏时间复杂度均为 O(n^2)。
平均时间复杂度:O(n log n)~O(n^2)。
辅助空间:不需要额临时的存储空间来运行算法,所以为 O(1)。
稳定性:不稳定。解释如下:
假设,序列为 5,5,7,3,2,取步长为 3 分组为 5,5,7 3,6 3 和 5 交换位置后,两个 5 的相对顺序发生改变,所以不稳定
使用到二叉堆这种数据结构,二叉堆是平衡二叉树。它具有以下性质:
本质上堆排序是由数组实现。
第一次将数组构造成大顶堆时,跟此演示稍有不同,默认按数组顺序放入二叉堆,没有边放边构造。
public static void heapSort(int[] input) { int i = input.length/ 2 - 1;// 最后一个非叶子节点 // 将数组构造成大顶堆 for (; i >= 0; i--) maxHeapify(input, i, input.length - 1); // 堆排,将大顶堆转换成有序数组 for (i = input.length - 1; i >= 0; i--) { int temp = input[0]; input[0] = input[i]; input[i] = temp; maxHeapify(input, 0, i - 1); } } // 构造大顶堆 public static void maxHeapify(int[] input, int start, int end) { int child; int root = start; // 父节点不是叶子节点时循环;只有一个根节点时不再比较 for (; root <= (end - 1) / 2 && end > 0; ) { child = root * 2 + 1;// 调整子节点 // 取较大的子节点 if (child + 1 <= end && input[child] < input[child + 1]) child += 1; if (input[root] < input[child]) { // 交换较小父节点和较大子节点的位置 int temp = input[root]; input[root] = input[child]; input[child] = temp; root = child;// 较大的子节点成为父节点 } else break; } }
最好时间复杂度:当序列有序时。最好时间复杂度均为 O(n log n),跟算法步长有关。
最坏时间复杂度:当序列逆序时。最坏时间复杂度均为 O(n log n)。
平均时间复杂度:O(n log n)。
辅助空间:不需要额临时的存储空间来运行算法,所以为 O(1)。
稳定性:不稳定。
先递归分解序列,再合并序列。也采用了分治法的思想。
此图主要思想一致,但代码实现过程中,6531 合并完成时,8724 还没分解。
public static void mergeSort(int[] input) { merge_sort(input, 0, input.length - 1); } public static void merge_sort(int[] input, int start, int end) { int middle; // 当序列中只有一个元素时,结束当前递归 if (start < end) { middle = (start + end) / 2;// 找出中位数(中数),中数越来越小 merge_sort(input, start, middle);// 中数左侧序列二分 merge_sort(input, middle + 1, end);// 中数右侧序列二分 merge(input, start, middle, end);// 合并成源序列 } } // 合并成源序列 public static void merge(int[] input, int left, int middle, int right) { int[] temp = new int[right - left + 1];// 用于存放新排好序的序列 int i = left;// 左序列的起始下标 int j = middle + 1;// 右序列的起始下标 int n = 0;//temp[] 数组的起始下标 // 通过比较将较小元素先放入 temp 数组 while (i <= middle && j <= right) { if (input[i] < input[j]) { temp[n] = input[i]; i++; } else { temp[n] = input[j]; j++; } n++; } // 将第一个序列的剩余元素放入 temp[] while (i <= middle) { temp[n] = input[i]; i++; n++; } // 将第二个序列的剩余元素放入 temp[] while (j <= right) { temp[n] = input[j]; j++; n++; } // 将 num[] 中的元素复制到数组 input for (int x = 0, y = left; x <= n && y <= right; x++, y++) input[y] = temp[x]; }
最好时间复杂度:当序列有序时。最好时间复杂度均为 O(n log n)。
最坏时间复杂度:当序列逆序时。最坏时间复杂度均为 O(n log n)。
平均时间复杂度:O(n log n)。
辅助空间:需要新建额外临时的存储空间来存储新排好序的序列,每一次归并都需要重新新建,新建频度为 n,所以辅助空间为 O(n)。
稳定性:稳定。因为两两比较。
时间频度:一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为 T(n)。
时间复杂度:在刚才提到的时间频度中,n 称为问题的规模,当 n 不断变化时,时间频度 T(n) 也会不断变化。但有时我们想知道它变化时呈现什么规律。为此,我们引入时间复杂度概念。 一般情况下,算法中基本操作重复执行的次数是问题规模 n 的某个函数,用 T(n) 表示,若有某个辅助函数 f(n), 使得当 n 趋近于无穷大时,T(n)/f(n) 的极限值为不等于零的常数 C,则称 f(n) 是 T(n) 的同数量级函数。记作 T(n)=O(f(n)), 称 O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。
虽然对 f(n) 没有规定,但是一般都是取尽可能简单的函数。例如,O(2n^2+n+1) = O(3n^2+n+3) = O(7n^2 + n) = O(n^2) ,一般都只用 O(n^2) 表示就可以了。注意到大 O 符号里隐藏着一个常数 C,所以 f(n) 里一般不加系数。如果把 T(n) 当做一棵树,那么 O(f(n)) 所表达的就是树干,只关心其中的主干,其他的细枝末节全都抛弃不管。
由上图可见,常见的算法时间复杂度由小到大依次为:Ο(1)<Ο(log n)<Ο(n)<Ο(nlog n)<Ο(n^2)<Ο(n^3)<…<Ο(2^n)<Ο(n!)。
分析以下代码的时间复杂度。
i=1; while (i<=n) i=i*2;
第 1 行语句的频度为 1
设第 3 行语句的时间频度为 f(n)k,2f(n) = n; -->f(n) = log n
第 2 行语句跟第 2 三行的频度一样,为 log n
以上代码的 T(n) = 2log n+1 = O(log n)
由此可见,T(n) 由最大的 f(n) 决定。在嵌套层数多的循环语句中,由最内层语句的频度 f(n) 决定 T(n)。注:log n = log?n = lg n;复杂度中以 2 为底,不是数学中以 10 为底。
一个算法的空间复杂度 (Space Complexity) 定义为该算法所耗费的存储空间,它也是问题规模 n 的函数。渐近空间复杂度也常常简称为空间复杂度。
一个算法在计算机存储器上所占用的存储空间,包括:
我们将算法在运行过程中临时占用的存储空间称为辅助空间,它随算法的不同而异,另外两种存储空间与算法本身无关。
如当一个算法的空间复杂度为一个常量,即不随被处理数据量 n 的大小而改变时,辅助空间可表示为 O(1);当一个算法的空间复杂度与以 2 为底的 n 的对数成正比时,辅助空间可表示为 O(1og?n);当一个算法的空间复杂度与 n 成线性比例关系时,辅助空间可表示为 O(n)。
在排序过程中,具有相同数值的对象的相对顺序被不被打乱。如果可以保证不被打乱就是稳定的,如果不能保证就是不稳定的。
根据每个排序算法关于稳定性的指标分析,可以得出以下结论:
以上算法博主亲测都能正确运行。以下是上述七种算法的性能指标分析对比情况。
排序方式 | 平均时间复杂度 | 最好时间复杂度 | 最坏时间复杂度 | 空间复杂度 (辅助空间) | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 稳定 |
快速排序 | O(n log n) | O(n log n) | O(n^2) | O(log n)~O(n) | 不稳定 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
插入排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
希尔排序 | O(n log n)~O(n^2) | O(n^s) (0<s<1,跟步长有关) | O(n^2) | O(1) | 不稳定 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
二分归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
<table id="table" class="table table-striped table-bordered table-hover table-nowrap" width="100%&qu