Arrays.sort()解读
在学习了排序算法之后, 再来看看 Java 源码中的, Arrays.sort() 方法对于排序的实现.
都是对基本数据类型的排序实现, 下面来看看这段代码:
- Arrays.sort(int[] a)
public static void sort(int[] a) { DualPivotQuicksort.sort(a, 0, a.length - 1, null, 0, 0);
}
- DualPivotQuicksort.sort
在这里我将代码一步步拆开来看, 一点点解读, 并尝试在自己调用, 复制解读代码:
static void sort(int[] a, int left, int right, int[] work, int workBase, int workLen)
参数部分暂时跳过, 在使用时自然能看到对应的作用;
但在这里需要注意一个问题, 则是 left right 分别为 0, 和 array.length - 1, 传入的是数组的始末索引;
if (right - left < QUICKSORT_THRESHOLD) { sort(a, left, right, true); return; }
- 第一部分:
在数组长度, 小于 QUICKSORT_THRESHOLD(286) 的时候, 采取的是双轴快速排序.
至于这里的常量取值, 不是很明白, 个人觉得应该是经验数值.
private static void sort(int[] a, int left, int right, boolean leftmost) int length = right - left + 1;
这里表示当前需要排序的长度;
if (length < INSERTION_SORT_THRESHOLD) {
这里就比较好理解, 在使用快速排序的时候, 如果数组被切分到一定长度, 则选择 插入排序来替换, 速度更快, 需要的空间也更小. 这里的长度取的是 47.
if (leftmost) {
对于leftmost, 文档说明为 指示当前数组为数组的左侧部分, 在三项快速排序中, 我们将数组分为三部分, < mid, == mid, >mid, 虽然在双轴快速排序中, 与三向快速排序切分细节略有不同, 但这里的 leftmost指的是数组 <mid, 部分的数组.
for (int i = left, j = i; i < right; j = ++i) { int ai = a[i + 1]; while (ai < a[j]) { //优化 a[j + 1] = a[j]; if (j-- == left) { break; } } a[j + 1] = ai; }
传统插入排序, 在这里就采取了插入排序的优化手段之一: 减少交换次数, 使得较大的部分直接右移, 而非通过交换的方式.
else { do { if (left >= right) { return; } } while (a[++left] >= a[left - 1]);
这段代码还是比较有趣的, 在数组的快速排序拆分过程中, 将右侧, 也就是 >mid 的部分, 因为右侧都是大于等于 mid值的, 直接跳过其中已经排序好的部分, 将插入排序的起始位置选定一个更合适的位置, 优化排序;
for (int k = left; ++left <= right; k = ++left) { ... ... a[right + 1] = last;
这段是‘双插入排序‘的核心代码, 等快速插入排序看完之后, 再来看这段代码;
int seventh = (length >> 3) + (length >> 6) + 1; int e3 = (left + right) >>> 1; // The midpoint int e2 = e3 - seventh; int e1 = e2 - seventh; int e4 = e3 + seventh; int e5 = e4 + seventh; if (a[e2] < a[e1]) { int t = a[e2]; a[e2] = a[e1]; a[e1] = t; } if (a[e3] < a[e2]) { int t = a[e3]; a[e3] = a[e2]; a[e2] = t; if (t < a[e1]) { a[e2] = a[e1]; a[e1] = t; } } if (a[e4] < a[e3]) { int t = a[e4]; a[e4] = a[e3]; a[e3] = t; if (t < a[e2]) { a[e3] = a[e2]; a[e2] = t; if (t < a[e1]) { a[e2] = a[e1]; a[e1] = t; } } } if (a[e5] < a[e4]) { int t = a[e5]; a[e5] = a[e4]; a[e4] = t; if (t < a[e3]) { a[e4] = a[e3]; a[e3] = t; if (t < a[e2]) { a[e3] = a[e2]; a[e2] = t; if (t < a[e1]) { a[e2] = a[e1]; a[e1] = t; } } } }
这段代码是这个 sort方法的真正起始部分, 前面的部分, 是迭代的结束代码;
不同于在快速排序中我们所采取的 打乱数组, 从第一位开始选取的方式, 而是先将数组进行 6 分, 取出中间的5个节点, 将节点通过插入排序的方式排序;
至于说为什么采取这样的方式进行6分, 嗯, 据经验所得.
同样的在这里有一个点 容易被忽略, 由于数组的最大长度为 286, 则, seventh的最大值同样为41, 所以只进行一次快速排序, 在数组被切分后就执行插入排序.
int less = left; // The index of the first element of center part int great = right; // The index before the first element of right part
因为初始只有一个分区, center part 和 right part 为一个, 但 这里的 less表示, 中间分区的第一个元素, great + 1 表示 右侧分区的第一个元素.
if (a[e1] != a[e2] && a[e2] != a[e3] && a[e3] != a[e4] && a[e4] != a[e5]) { int pivot1 = a[e2]; int pivot2 = a[e4];
在5个节点两两不等时, 采取 a[e2], a[e4], 作为双枢轴快速排序的两个 枢纽.
a[e2] = a[left]; a[e4] = a[right];
在这段代码中, 能够看出来的仅仅是 将 pivot1, pivot2, 所对应的元素不参与排序, 同时将左右两端的元素移位, 此时, 在循环中, 可以跳过左右两个元素.(所以还是不明白有什么更深层的含义)
while (a[++less] < pivot1); while (a[--great] > pivot2); outer: for (int k = less - 1; ++k <= great; ) { int ak = a[k]; if (ak < pivot1) { // Move a[k] to left part a[k] = a[less]; /* * Here and below we use "a[i] = b; i++;" instead * of "a[i++] = b;" due to performance issue. */ a[less] = ak; ++less; } else if (ak > pivot2) { // Move a[k] to right part //将 great指针 不断左移,直到, <= pivot2 while (a[great] > pivot2) { if (great-- == k) { break outer; } } if (a[great] < pivot1) { // a[great] <= pivot2 a[k] = a[less]; a[less] = a[great]; ++less; } else { // pivot1 <= a[great] <= pivot2 a[k] = a[great]; } /* * Here and below we use "a[i] = b; i--;" instead * of "a[i--] = b;" due to performance issue. */ a[great] = ak; --great; } } *left part center part right part * +--------------------------------------------------------------+ * | < pivot1 | pivot1 <= && <= pivot2 | ? | > pivot2 | * +--------------------------------------------------------------+ * ^ ^ ^ * | | | * less k great
与快速排序类似:
(left, less) : <pivot1 的值
[less, k): pivot1 <= && <= pivot2
[k, great]: 表示未排序的值.
(great, right): > pivot2
a[left] = a[less - 1]; a[less - 1] = pivot1; a[right] = a[great + 1]; a[great + 1] = pivot2;
在这里将之前被替换的值交换回来.
sort(a, left, less - 2, leftmost); sort(a, great + 2, right, false);
在这里需要注意的一点是: sort(a, great + 2, right, false);
a[great + 1]表示的是右侧数组最小的值, 因此在插入排序中可以被当做哨兵.
但上面这一步, 仅仅是将 左右排序, 中间部分还没有进行排序;
if (less < e1 && e5 < great) {
如果中间部分数组过大的话
while (a[less] == pivot1) { ++less; } while (a[great] == pivot2) { --great; } /* left part center part right part * +----------------------------------------------------------+ * | == pivot1 | pivot1 < && < pivot2 | ? | == pivot2 | * +----------------------------------------------------------+ * ^ ^ ^ * | | | * less k great ******************************************************/ outer: for (int k = less - 1; ++k <= great; ) { int ak = a[k]; if (ak == pivot1) { // Move a[k] to left part a[k] = a[less]; a[less] = ak; ++less; } else if (ak == pivot2) { // Move a[k] to right part while (a[great] == pivot2) { if (great-- == k) { break outer; } } if (a[great] == pivot1) { // a[great] < pivot2 a[k] = a[less]; a[less] = pivot1; ++less; } else { // pivot1 < a[great] < pivot2 a[k] = a[great]; } a[great] = ak; --great; } } } sort(a, less, great, false);
这段代码就不再多做解释, 同样是 切分, 只是方式有所区别;
} else { // Partitioning with one pivot int pivot = a[e3];
这里也不再赘述, 如果不满足 , a[e1] 到 a[e5] 均不相等的话, 则取中间元素, 作为切分元素, 标准的 三向快速排序.
那么回过头来, 看看 插入排序的一段代码: 即 leftmost为 false 时:
for (int k = left; ++left <= right; k = ++left) { int a1 = a[k], a2 = a[left]; if (a1 < a2) { a2 = a1; a1 = a[left]; } while (a1 < a[--k]) { a[k + 2] = a[k]; } a[++k + 1] = a1; while (a2 < a[--k]) { a[k + 1] = a[k]; } a[k + 1] = a2; } int last = a[right]; while (last < a[--right]) { a[right + 1] = a[right]; } a[right + 1] = last;
在这里, 当 leftmost为 false时, 被排序的部分 a[left - 1]为元素中最小的部分, 充当哨兵的角色. 于是采取 双插入排序, 理解起来也比较简单, 每次选取两个元素排序, 首先将两个元素中较大的 做插入排序, 而后在 第一个元素的插入位置作为起点将第二个元素插入排序;
同时由于有哨兵存在, 所以少一个边界判定.
- 主体部分二
int[] run = new int[MAX_RUN_COUNT + 1]; int count = 0; run[0] = left;
结合下面代码一起来看这段话:
// Check if the array is nearly sorted for (int k = left; k < right; run[count] = k) { if (a[k] < a[k + 1]) { // ascending while (++k <= right && a[k - 1] <= a[k]); } else if (a[k] > a[k + 1]) { // descending while (++k <= right && a[k - 1] >= a[k]); for (int lo = run[count] - 1, hi = k; ++lo < --hi; ) { int t = a[lo]; a[lo] = a[hi]; a[hi] = t; } } else { // equal for (int m = MAX_RUN_LENGTH; ++k <= right && a[k - 1] == a[k]; ) { if (--m == 0) { sort(a, left, right, true); return; } } } if (++count == MAX_RUN_COUNT) { sort(a, left, right, true); return; } }
在这里需要把握一下 count 的值, 以更好地估计, 每执行一次, k + 1, count的值等于子数组的数量. run[count]记录的是每一个子数组的起点元素的索引, 每个子数组的实际范围应该是:
a[run[count]] ~ a[run[count + 1] - 1];
其中第1个子数组 count 为0;
如果最后的子数组不是单元素数组, 则 run[count] = k, 此时 k = right + 1, 否则的话,run[count] = k = right.
从这里解释, 判断 数组的无序性, 用了一个比较有趣的理念, 任何一个数组都可以被拆分成 若干个有序数组, 无论是升序, 降序 或是等值数组, 在这里就是将数组拆分成若干个升序子数组.
如果为升序, 则将子数组倒序即可.
run存储的则是 子数组的 每一个开始和结束的下标, 在这里需要注意的两个地方是:
在 划分子数组时 的判等条件, 在之前的 排序算法的学习时, 归并排序对于含有大量重复元素的排序效率并不高, 而三向快速排序无疑是极为适合的,所以当重复元素连续超过33个的时候, 即认为数组中重复元素过多, 需要选择三向快速排序.
当 ++count 达到最大值 67, 也就是子数组数超过当前限度, 表明数组的无序性较强, 则切换成快速排序.
if (run[count] == right++) { // The last run contains one element run[++count] = right; } else if (count == 1) { // The array is already sorted return; }
在这里又有所不同: 如果最后一个子数组为单元素子数组, 则此时的 run[count] = right; 否则为 right + 1; 为了保证末尾始终指向 right + 1, 所以需要用这种方式来满足, 至于原因, 下文解答.
如果划分后,原数组中的最后一个元素并不是独自为一个子序列,此时run数组的最后一个元素已经是哨兵,就不需要再添加了。
byte odd = 0; for (int n = 1; (n <<= 1) < count; odd ^= 1);
这段判断子数组是否是 2的幂次, 如果是2的幂次, 在归并排序中, 数组恰好被切分成对等的子数组, 在之前的 排序学习中, 我们在 归并排序中, 有一种优化的方式是从目标数组拷贝到源数组, 通过这种将目标数组与源数组来回转换的方式 , 实现了 不需要在过程中每次都对 数组进行复制.
但当时是在迭代中使用, 而这里则是通过递归的方式.
所以需要通过 odd决定 第一次被拷贝的源数组究竟是 a 还是 b;
// Use or create temporary array b for merging int[] b; // temp array; alternates with a int ao, bo; // array offsets from ‘left‘ int blen = right - left; // space needed for b if (work == null || workLen < blen || workBase + blen > work.length) { work = new int[blen]; workBase = 0; } if (odd == 0) { System.arraycopy(a, left, work, workBase, blen); b = a; bo = 0; a = work; ao = workBase - left; } else { b = work; ao = 0; bo = workBase - left; }
这段代码正是确定了第一次的目标数组和源数组.
// Merging for (int last; count > 1; count = last) { for (int k = (last = 0) + 2; k <= count; k += 2) { int hi = run[k], mi = run[k - 1]; for (int i = run[k - 2], p = i, q = mi; i < hi; ++i) { if (q >= hi || p < mi && a[p + ao] <= a[q + ao]) { b[i + bo] = a[p++ + ao]; } else { b[i + bo] = a[q++ + ao]; } } //生成第二轮归并的索引 run[++last] = hi; } //如果子数组个数为奇数, 直接填充至最末端 if ((count & 1) != 0) { for (int i = right, lo = run[count - 1]; --i >= lo; b[i + bo] = a[i + ao] ); run[++last] = right; } int[] t = a; a = b; b = t; int o = ao; ao = bo; bo = o; }
观察不难发现, 在merge归并排序中, 这里并没有采取我们所更容易操作理解的 递归 的方式来进行排序,也不难理解, 节省空间. 但这段代码写的还是很精巧, 将原本需要四个判断的合并成两行代码;
一直以来对 数组个数为奇数时 直接合并进已排序数组表示非常困惑, 却一直忽略了 run[++last] = right; 这句代码, 它表示, 不仅仅是合并进已排序数组, 同时也保留这个子数组, 作为一个单独的子数组参与下次的排序;
至于之前提到过 对于最后一个子数组所含元素个数不同, 有不同的处理方式, 从而保证了末尾始终未 right + 1, 因为在归并排序中, 内循环的判断代码:
for (int i = run[k - 2], p = i, q = mi; i < hi; ++i)
为 i < hi, 在代码中的 right值为 array.length - 1; 为了不对末尾元素再多做区别, 简化代码, 所以使得 run[count] 始终等于 right + 1, 也就是数组长度.
- 总结
代码分析到这里已经结束了, 也没有遗留的问题点, 心得也就总结一下:
- 排序算法的选择:
不难发现, 在数组不大的情况下, 优先选择快速排序, 如果更小, 则用插入排序解决问题. 甚至于快速排序也做了优化.
在数组元素较多的时候, 要分几种情况:
数组无序性较强, 或者重复元素较多, 这两种情况都采取快速排序.
数组有序性还不错, 则采用归并排序.
- 算法的优化
- 快速排序的处理上, 采取了双轴快速排序, 省去了打乱数组选择 切分点的方式, 做了一步简化. 但我们又知道, 正是为了防止极端情况的出现, 因此才要在原来的方式中打乱数组. 所以 快速排序中将5个轴心进行排序, 将数组均分, 等等思路都是相当好的.
- 不仅如此, 如果第一次切分中间元素仍然过多, 又用了三向快速排序的方式进行处理. 比起打乱数组 是一种更好的选择, 虽然, 代码变长了.
- 快速排序, 也仅仅只进行了一次递归(包含大量重复元素的大容量数组除外), 同时巧妙利用了 双轴快速排序中出现的边界元素, 充当哨兵的角色, 选择了 双插入排序 用更快地速度处理数组.
- 双插入排序 对插入排序的进一步优化. 选择了两个元素中较大的先进行插入, 其次插入较小的, 思路相当精巧有趣.
- 数组有序行的校验, 这块代码比较有趣, 在有序性校验的同时, 用了一个容量不大的数组run[] 将原数组进行切分, 却仅仅只保存索引, 快捷方便, 同时在校验的时候, 将升序 等值做了进一步处理优化, 方便了下一步的处理.
- 归并排序的核心代码优化, 同样的, 借用了辅助数组, 使得处理数组更为方便, if (q >= hi || p < mi && a[p + ao] <= a[q + ao]) 简洁快速的比较, 进一步优化了原先的比较方式.
- 在迭代的同时, 不需要对数组进行复制排序, 之前的算法中有实现这点, 但是用的是递归的方式进行处理. 进一步优化数组, 节省空间.
- 读代码的心得
- 代码优化往往就是在那一两行读不懂的代码中, 还是得打破砂锅问到底的态度.
- 需要细致, 争取弄明白每一个变量究竟是为了什么, 起到怎样的作用.
当然这点我觉得是在研究算法类代码时, 因为优化速度, 内存空间比较重要, 另一类巧取思路, 就可大而化之, 不必读懂每一句代码.把握核心点即可.
- 还是要对算法有一定了解, 甚至于其他基础有所了解, 对艺术不懂的人, 即使原样复现, 又怎么能理解 巧夺天工的技艺, 天马行空的想象?
- 排序算法的选择:
参考:
http://blog.csdn.net/octopusflying/article/details/52388012