《计算之魂》读书笔记 04
1.4 关于排序的讨论
- 排序算法在计算机算法中占有重要位置
- 根据时间复杂度,排序算法可分为两类:
- 复杂度为 O ( n 2 ) \bm{O(n^2)} O(n2):大多较直观
- 复杂度为
O
(
n
l
o
g
n
)
\bm{O(nlogn)}
O(nlogn):执行效率高
- 要理解排序算法,就要先掌握 递归和分治
回顾:通过学习前两小节内容,我们认识了 5 种直观和高效的排序算法
【1.4.3】针对特殊情况,我们是否还有更好的答案?
-
使用一种排序算法难以兼顾多维需求,因此今天排序算法的改进大多是 混合排序算法
-
内省排序(Introsort):
-
快速排序 + 堆排序 + 插入排序
-
在大多标准函数库(STL)的排序函数中使用
-
结合三种算法的优点,最坏情况下的运行时间为 O ( n l o g n ) O(nlogn) O(nlogn)
-
动态演示(from YouTube):
-
伪代码:
sort(A : array): # 递归深度限制 depthLimit = 2xfloor(log(length(A))) introsort(A, depthLimit) introsort(A, depthLimit): n = length(A) if n <= 16: # 数据规模小时(元素数量低于某个阈值),切换到插入排序 insertionSort(A) if depthLimit == 0: # 当递归深度超过一定限制时,切换到堆排序 heapsort(A) else: # 规模适中,切换到快速排序,p 即 pivot p = partition(A) introsort(A[0 : p - 1], depthLimit - 1) introsort(A[p + 1 : n], depthLimit - 1)
-
这里,快速排序的 pivot 选取使用了 “三点中值法”,即每次选取的 pivot,是 首、末、中 这三个位置值的中位数,以避免最坏情况发生
-
实现示例:
import java.io.IOException; public class IntroSort { // the actual data that has to be sorted private int a[]; // the number of elements in the data private int n; // Constructor to initialize the size of the data IntroSort(int n) { a = new int[n]; this.n = 0; } // The utility function to insert the data private void dataAppend(int temp) { a[n] = temp; n++; } private void swap(int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; } // To maxHeap a subtree rooted with node i which is an index in a[]. heapN is size of heap private void maxHeap(int i, int heapN, int begin) { int temp = a[begin + i - 1]; int child; while (i <= heapN / 2) { child = 2 * i; if (child < heapN && a[begin + child - 1] < a[begin + child]) child++; if (temp >= a[begin + child - 1]) break; a[begin + i - 1] = a[begin + child - 1]; i = child; } a[begin + i - 1] = temp; } // Function to build the heap (rearranging the array) private void heapify(int begin, int end, int heapN) { for (int i = (heapN) / 2; i >= 1; i--) maxHeap(i, heapN, begin); } // main function to do heapsort private void heapSort(int begin, int end) { int heapN = end - begin; // Build heap (rearrange array) this.heapify(begin, end, heapN); // One by one extract an element from heap for (int i = heapN; i >= 1; i--) { // Move current root to end swap(begin, begin + i); // call maxHeap() on the reduced heap maxHeap(1, i, begin); } } // function that implements insertion sort private void insertionSort(int left, int right) { for (int i = left; i <= right; i++) { int key = a[i]; int j = i; // Move elements of arr[0..i-1] while (j > left && a[j - 1] > key) { a[j] = a[j - 1]; j--; } a[j] = key; } } // Function for finding the median of the three elements private int findPivot(int a1, int b1, int c1) { int max = Math.max(Math.max(a[a1], a[b1]), a[c1]); int min = Math.min(Math.min(a[a1], a[b1]), a[c1]); int median = max ^ min ^ a[a1] ^ a[b1] ^ a[c1]; if (median == a[a1]) return a1; if (median == a[b1]) return b1; return c1; } // takes the last element as pivot private int partition(int low, int high) { int pivot = a[high]; // Index of smaller element int i = (low - 1); for (int j = low; j <= high - 1; j++) { if (a[j] <= pivot) { i++; swap(i, j); } } swap(i + 1, high); return (i + 1); } // The main function that implements Introsort // low --> Starting index, // high --> Ending index, // depthLimit --> recursion level private void sortDataUtil(int begin, int end, int depthLimit) { if (end - begin > 16) { if (depthLimit == 0) { this.heapSort(begin, end); return; } depthLimit = depthLimit - 1; int pivot = findPivot(begin, begin + ((end - begin) / 2) + 1, end); swap(pivot, end); int p = partition(begin, end); sortDataUtil(begin, p - 1, depthLimit); sortDataUtil(p + 1, end, depthLimit); } else { insertionSort(begin, end); } } private void sortData() { int depthLimit = (int) (2 * Math.floor(Math.log(n) / Math.log(2))); this.sortDataUtil(0, n - 1, depthLimit); } private void printData() { for (int i = 0; i < n; i++) System.out.print(a[i] + " "); } public static void main(String args[]) throws IOException { int[] inp = {2, 10, 24, 2, 10, 11, 27, 4, 2, 4, 28, 16, 9, 8, 28, 10, 13, 24, 22, 28, 0, 13, 27, 13, 3, 23, 18, 22, 8, 8}; int n = inp.length; IntroSort introsort = new IntroSort(n); for (int i = 0; i < n; i++) { introsort.dataAppend(inp[i]); } introsort.sortData(); introsort.printData(); } }
-
给 6000 个元素排序,堆排序、插入排序、快速排序、内省排序 效率对比(ms):
-
缺点:和堆排序、快速排序一样,内省排序不稳定
-
- 蒂姆排序(Timsort):
-
插入排序 + 归并排序
-
结合插入排序的直观和归并排序的效率,同时改进了归并排序中的顺序比较大小(少做无用功),在 Java 和 Android 操作系统内部使用广泛
-
最坏情况下的时间复杂度是: O ( n l o g n ) O(nlogn) O(nlogn)
-
可以看作是以块(run)为单位的归并排序(块内部元素已排好序)
-
观察下面随机序列中相邻的数:
我们发现大多相邻的数并非大小交替,而是构成多个递增或递减子序列,蒂姆排序正是利用此特性减少操作 -
大致流程:
- 找出序列中各个递增和递减的子序列(如太短,二分查找,插入排序)
- 把这些子序列放入堆栈中(临时存储)
- 按照规则合并这些块(先合并最短,批处理归并,跳跃式预测)
-
成块插入模拟图:(跳跃式 + 批处理)
-
实现示例:
public class TimSort { static int MIN_MERGE = 32; public static int minRunLength(int n) { assert n >= 0; // Becomes 1 if any 1 bits are shifted off int r = 0; while (n >= MIN_MERGE) { r |= (n & 1); n >>= 1; } return n + r; } public static void insertionSort(int[] arr, int left, int right) { for (int i = left + 1; i <= right; i++) { int temp = arr[i]; int j = i - 1; while (j >= left && arr[j] > temp) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = temp; } } // Merge function merges the sorted runs public static void merge(int[] arr, int l, int m, int r) { // Original array is broken in two parts left and right array int len1 = m - l + 1, len2 = r - m; int[] left = new int[len1]; int[] right = new int[len2]; for (int x = 0; x < len1; x++) { left[x] = arr[l + x]; } for (int x = 0; x < len2; x++) { right[x] = arr[m + 1 + x]; } int i = 0; int j = 0; int k = l; // After comparing, we merge those two array in larger sub array while (i < len1 && j < len2) { if (left[i] <= right[j]) { arr[k] = left[i]; i++; } else { arr[k] = right[j]; j++; } k++; } // Copy remaining elements of left, if any while (i < len1) { arr[k] = left[i]; k++; i++; } // Copy remaining elements of right, if any while (j < len2) { arr[k] = right[j]; k++; j++; } } public static void timSort(int[] arr, int n) { int minRun = minRunLength(MIN_MERGE); // Sort individual subarrays of size RUN for (int i = 0; i < n; i += minRun) { insertionSort(arr, i, Math.min((i + MIN_MERGE - 1), (n - 1))); } // Merge from size RUN (or 32). It will merge to form size 64, 128, 256 and so on for (int size = minRun; size < n; size = 2 * size) { // After every merge, we increase left by 2*size for (int left = 0; left < n; left += 2 * size) { // Find ending point of left sub array mid+1 is starting point of right sub array int mid = left + size - 1; int right = Math.min((left + 2 * size - 1), (n - 1)); // Merge sub array arr[left.....mid] & arr[mid+1....right] if (mid < right) merge(arr, left, mid, right); } } } public static void printArray(int[] arr, int n) { for (int i = 0; i < n; i++) { System.out.print(arr[i] + " "); } System.out.print("\n"); } public static void main(String[] args) { int[] arr = {-2, 7, 15, -14, 0, 15, 0, 7, -7, -4, -13, 5, 8, -14, 12}; int n = arr.length; System.out.println("Given Array is"); printArray(arr, n); timSort(arr, n); System.out.println("After Sorting Array is"); printArray(arr, n); } }
-
【特点】:稳定、便于多列列表排序、应用广泛
-
【附录】为什么排序算法的复杂度不可能小于 O ( n l o g n ) O(nlogn) O(nlogn)?
-
对于任意一个序列,其元素有很多排列方式,其中:
- 最小的序列是将其中每一个元素从小到大排好序
- 最小的序列是将其中每一个元素从小到大排好序
-
假定有序列 a 1 , a 2 , . . . , a i , . . . , a j , . . . , a N a_{1},a_{2},...,\bm{a_{i}},...,\bm{a_{j}},...,a_{N} a1,a2,...,ai,...,aj,...,aN 和 a 1 , a 2 , . . . , a j , . . . , a i , . . . , a N a_{1},a_{2},...,\bm{a_{j}},...,\bm{a_{i}},...,a_{N} a1,a2,...,aj,...,ai,...,aN,除了在第 i 个和第 j 个位置上的元素彼此互换(i < j),其他元素都相同
- 如果 a i ≤ a j a_{i} ≤ a_{j} ai≤aj,第一个序列就小于第二个序列
- 如果
a
j
≤
a
i
a_{j} ≤ a_{i}
aj≤ai,则第二个序列小于第一个序列
-
假如我们做 k 次比较,最多能区分出 2 k \bm{2^k} 2k 种不同序列的大小
- 如果我们有 M 种序列,要区分出它们的大小,需要 logM 次比较
- 如果我们有 M 种序列,要区分出它们的大小,需要 logM 次比较
-
结论证明:(根据上面的推导)
- N N N 个元素组成的数组能排列出 N ! N! N! 种序列
- 因此,要选出其中最小的一个,至少需要 l o g N ! logN! logN! 次比较
- 使用斯特林公式: l n N ! = N l n N − N + O ( l n N ) lnN!=NlnN−N+O(lnN) lnN!=NlnN−N+O(lnN)
- 可以得到复杂度: l o g N ! = O ( N l o g N ) logN!=O(NlogN) logN!=O(NlogN)
- 综上,任何排序算法的复杂度都不可能小于 O ( n l o g n ) O(nlogn) O(nlogn)
【本节思考题】
- 已完成,详见上周笔记
总结
-
通过阅读 1.4.3,我们了解了在一些特殊情况下(出于多维要求),是需要寻找一些更好的排序算法的(混合排序算法)。同时,如果能结合不同排序算法的特点去不断 “强化” 少做无用功原则,就能慢慢摸索到一些算法的敲门,从而找到一点感觉了。
-
至此,第一章学习结束。我们主要阅读学习了计算机软硬件的发展史、大O表示法、复杂度及数量级概念、逆向思维、少做无用功原则、递归和分治思想、直观和高效的排序算法等内容。最后,感谢吴军老师的开源授权,以及DW社区的积极组织。
参考资料
- 《计算之魂》第一章
- 【Introspective sort】
- 【TimSort】
- 《STL源码剖析》
- 《计算之魂》读书笔记 03