Java的List.sort()排序方法源码应用解析
作者:五岳
我的博客有一些积压很久的草稿,有的完成度太低、内容过时的我不定期会删除;另一些完成度比较高,但随着工作变动、时间过去太久的博文,无力继续研究相关方向和更新。好在 AI 发展迅速,我借助 DeepSeek 做了 review,修改掉格式错误和笔误,并补充了缺失的章节,算完成了小小的心愿。
本文最初写于 2019-12-17,我使用 AI 调整了全文 markdown 格式,并补充了 mergeAt 合并算法与 galloping mode 等章节。
本文以JDK8为准。参考链接:https://www.jb51.net/program/365556nt9.htm
一、排序的入口:List.sort()
按照常识,List是一个接口,照理说sort()是不会实现的。JDK8新增了default关键字来修饰接口里的方法,将方法标识为默认方法,对应的实现:
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for (Object e : a) {
i.next();
i.set((E) e);
}
}
借用了Arrays.sort()的方法,那么Arrays.sort()又是怎么实现的呢?
二、第一层实现:Arrays.sort()
public static <T> void sort(T[] a, Comparator<? super T> c) {
if (c == null) {
sort(a);
} else {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a, c);
else
TimSort.sort(a, 0, a.length, c, null, 0, 0);
}
}
这里可以看到一个判断分支if (LegacyMergeSort.userRequested),如果为true,使用归并排序,否则使用TimSort。userRequested的值是用下面的代码取得的,允许用户通过参数手动选择是否使用归并排序:
userRequested = java.security.AccessController.doPrivileged(new sun.security.action.GetBooleanAction("java.util.Arrays.useLegacyMergeSort")).booleanValue();
我们先看看归并排序的实现。注意这段代码上方有段注释:未来的发行版中会被移除,也就意味着后续不会再使用归并排序了。
private static <T> void legacyMergeSort(T[] a, Comparator<? super T> c) {
T[] aux = a.clone();
if (c==null)
mergeSort(aux, a, 0, a.length, 0);
else
mergeSort(aux, a, 0, a.length, 0, c);
}
这两个mergeSort的区别只在于是否使用传入的Comparator。如果不使用,则使用Object.equals()的默认比较方式来进行排序。下面先深入看一下mergeSort的实现。
三、第二层实现之mergeSort
归并排序的思想是:
- 将数组拆分成两部分,分别对这两部分进行递归的归并排序。
- 排序完成后,同时用两个迭代器/指针遍历两个数组,依次将其中最符合要求的(从小到大排序就是最小的,从大到小排序就是最大的)放入新数组中。
- 如果一个数组的元素放完了,就把另一个数组剩余所有元素按已有顺序全部放入新数组中。
3.1 为什么选择归并排序?
在众多的常见比较排序算法中,在以空间换时间的策略下,归并排序有着最好的平均性能,并且是稳定的。这句话怎么理解呢?
- 比较排序,即通过比较来决定元素的顺序。
- 常见的比较排序,包括插入排序、希尔排序、选择排序、堆排序、冒泡排序、快速排序、归并排序。
- 空间换时间,是指归并排序需要额外的临时数组,空间复杂度为O(n)
- 最好的平均性能,也是O(nlogn),不会像快速排序在最差情况下的O(n^)
- 稳定的,是指排序后,相同大小的元素前后顺序不会改变。平均性能为O(nlogn)的堆排序和快速排序都是不稳定的。
各种常见排序算法的比较和原理本文就不展开讲了,网络上连篇累牍非常多,有兴趣可以自行了解。
3.2 归并排序源码解读
private static void mergeSort(Object[] src, Object[] dest, int low, int high, int off) {
int length = high - low;
// Insertion sort on smallest arrays
if (length < INSERTIONSORT_THRESHOLD) {
for (int i=low; i<high; i++)
for (int j=i; j>low &&
((Comparable) dest[j-1]).compareTo(dest[j])>0; j--)
swap(dest, j, j-1);
return;
}
// Recursively sort halves of dest into src
int destLow = low;
int destHigh = high;
low += off;
high += off;
int mid = (low + high) >>> 1;
mergeSort(dest, src, low, mid, -off);
mergeSort(dest, src, mid, high, -off);
// If list is already sorted, just copy from src to dest. This is an
// optimization that results in faster sorts for nearly ordered lists.
if (((Comparable)src[mid-1]).compareTo(src[mid]) <= 0) {
System.arraycopy(src, low, dest, destLow, length);
return;
}
// Merge sorted halves (now in src) into dest
for(int i = destLow, p = low, q = mid; i < destHigh; i++) {
if (q >= high || p < mid && ((Comparable)src[p]).compareTo(src[q])<=0)
dest[i] = src[p++];
else
dest[i] = src[q++];
}
}这个算法分两部分:
- 当数组长度<INSERTIONSORT_THRESHOLD(源码中是7)时,直接使用插入排序,用一部分时间复杂度上升换取一部分空间复杂度的下降。
- 对于归并排序的递归部分:
- off表示偏移量,这个参数只有在数组内部分元素排序时才是一个非0值,整个数组排序时是0,可以不关心。
- high表示传入的是数组长度,因此数组最后一个元素的下标是(high-1)。low则是第一个元素的下标。
- 计算中值使用了位操作。当长度为大于1的奇数时,均分的两个子数组,由于舍去了小数值,前一个数组的元素比后一个少一个。
- 对于归并排序的归并部分:
- 如果前一个数组的最后一个元素比后一个数组的第一个元素(大/小)直接合并,这里的System.arraycopy是native方法。
- 归并的遍历,简化了写法,没有先做下标的运算再调用System.arraycopy,而是直接两个数组一起遍历。可以看出此处for循环中的if是合并了很多场景写成的。
四、第二层实现之TimSort
4.1 背景
看完归并排序的实现,才是重头:TimSort。以下摘自JDK注释:
A stable, adaptive, iterative mergesort that requires far fewer than nlg(n) comparisons when running on partially sorted arrays, while offering performance comparable to a traditional mergesort when run on random arrays. Like all proper mergesorts, this sort is stable and runs O(n log n) time (worst case). In the worst case, this sort requires temporary storage space for n/2 object references; in the best case, it requires only a small constant amount of space.
简单概括一下:TimSort是稳定、自适应、迭代的归并排序,在部分有序的数组上比较次数远少于nlogn,在随机数组上的表现和传统的归并排序一样。
TimSort的理论最初在1993年由Peter Mcllroy提出,由Tim Peters于2002年在Python中应用,后续逐步成为了包括Java、Swift、谷歌浏览器的默认排序方法。Tim Peters 本人对 TimSort 原理的介绍见:http://svn.python.org/projects/python/trunk/Objects/listsort.txt
4.2 基本概念
不打算在这里介绍过多的概念,造成阅读代码时的困难,先知道一些核心的概念就行了。
- run —— 可以直观地翻译为
一趟跑步的距离、旅程、航程等,代表了一部分已经有序的子数组。这里不打算自己造概念,以下仍称为run。 - galloping mode —— 可以译为加速模式,同样不翻译。
4.3 算法框架
先不纠结具体细节,看一下TimSort的整体框架。注意在Arrays.sort()调用处的入参是TimSort.sort(a, 0, a.length, c, null, 0, 0),其中a是待排序数组T[] a, c是比较器Comparator<? super T> c。
static <T> void sort(T[] a, int lo, int hi, Comparator<? super T> c,
T[] work, int workBase, int workLen) {
assert c != null && a != null && lo >= 0 && lo <= hi && hi <= a.length;
// (1)第一部分
int nRemaining = hi - lo;
if (nRemaining < 2)
return; // Arrays of size 0 and 1 are always sorted
// If array is small, do a "mini-TimSort" with no merges
if (nRemaining < MIN_MERGE) {
int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
binarySort(a, lo, hi, lo + initRunLen, c);
return;
}
//(2)
/**
* March over the array once, left to right, finding natural runs,
* extending short natural runs to minRun elements, and merging runs
* to maintain stack invariant.
*/
TimSort<T> ts = new TimSort<>(a, c, work, workBase, workLen);
int minRun = minRunLength(nRemaining);
do {
// Identify next run
int runLen = countRunAndMakeAscending(a, lo, hi, c);
// If run is short, extend to min(minRun, nRemaining)
if (runLen < minRun) {
int force = nRemaining <= minRun ? nRemaining : minRun;
binarySort(a, lo, lo + force, lo + runLen, c);
runLen = force;
}
// Push run onto pending-run stack, and maybe merge
ts.pushRun(lo, runLen);
ts.mergeCollapse();
// Advance to find next run
lo += runLen;
nRemaining -= runLen;
} while (nRemaining != 0);
// Merge all remaining runs to complete sort
assert lo == hi;
ts.mergeForceCollapse();
assert ts.stackSize == 1;
}先看最开头的部分(1)。如果待排序部分nRemaining过小(为0或1),直接返回结果,很好理解。
如果nRemaining<MIN_MERGE(MIN_MERGE被设置为32),则使用一个“微型”的TimSort,也即二分插入排序的变体。这个二分插入排序内部不做任何合并。这一步的解读参考后文5.1节,这里暂时跳过,只需要知道countRunAndMakeAscending方法的目的是计算待排序部分__从起始位置起,保持升序或降序的最长的run的长度initRunLen__。
接着看第(2)部分。这部分先创建了一个TimSort对象,对象内有什么先不管,继续看框架部分。int minRun = minRunLength(nRemaining)是一个纯粹对数值计算方法,目的是计算出将数组需要划分成多少个run,这时的run的长度使用minRun表示,并且需要让nRemaining/minRun最接近一个2的幂(实际上nRemaining/k严格小于2的幂)。这种情况下的分组的个数接近2的幂,更利于合并。具体的计算方法minRunLength()不在这节展开,请参考后文5.2节。
计算出要拆分的run的个数minRun后,进入循环,循环中包括:
- 计算数组中从lo开始最长连续递增的序列长度
- 如果长度不满一个minRun,强制将从lo开始的数组扩展到minRun长度或在排序中可以处理的最大长度nRemaining。
- 将当前处理的run压栈
pushRun并视情况mergemergeCollapse,并将lo下标前移,未排序个数nRemaining减去本次的runLen - 循环直到nRemaining==0。
- 循环完成时,做一次强制merge——
mergeForceCollapse更具体的实现,接下来需要研究一下TimSort这个类使用的三个类方法pushRun、mergeCollapse、mergeForceCollapse。
4.4 TimSort类的实例化
TimSort类只会在调用TimSort.sort()方法时实例化一次,并且这个构造方法是private的。借助这个对象实例,保存一些处理中数据。
4.4.1 成员变量
简单翻译了一下注释。如果看不懂,可以先不纠结,继续看后续的排序方法来理解。
/**
* 最小的待合并的run长度的阈值
*/
private static final int MIN_MERGE = 32;
/**
* 待排序的数组
*/
private final T[] a;
/**
* 比较器
*/
private final Comparator<? super T> c;
/**
* galloping mode 的初始阈值。连续取胜达到该次数后切换到 gallop
*/
private static final int MIN_GALLOP = 7;
/**
* 当前进入 galloping mode 的阈值,随数据特征动态调整
*/
private int minGallop = MIN_GALLOP;
/**
* 初始化用于存放临时排序数据的数组长度最大值
*/
private static final int INITIAL_TMP_STORAGE_LENGTH = 256;
/**
* 用于归并的临时数组
*/
private T[] tmp;
private int tmpBase; // base of tmp array slice
private int tmpLen; // length of tmp array slice
/**
* 待归并的run的栈。第i个run下标从base[i]开始,长度是len[i]。
* 满足runBase[i] + runLen[i] == runBase[i + 1]
*
*/
private int stackSize = 0; // Number of pending runs on stack
private final int[] runBase;
private final int[] runLen;4.4.2 构造方法
排序时创建的参数是work=null的,因此不会走下面的分支。
构造方法初始化了用于存放临时数据的数组,以及用来存放待排序栈的run的起始下标和长度。
private TimSort(T[] a, Comparator<? super T> c, T[] work, int workBase, int workLen) {
this.a = a;
this.c = c;
// Allocate temp storage (which may be increased later if necessary)
int len = a.length;
int tlen = (len < 2 * INITIAL_TMP_STORAGE_LENGTH) ?
len >>> 1 : INITIAL_TMP_STORAGE_LENGTH;
if (work == null || workLen < tlen || workBase + tlen > work.length) {
@SuppressWarnings({"unchecked", "UnnecessaryLocalVariable"})
T[] newArray = (T[])java.lang.reflect.Array.newInstance
(a.getClass().getComponentType(), tlen);
tmp = newArray;
tmpBase = 0;
tmpLen = tlen;
}
else {
tmp = work;
tmpBase = workBase;
tmpLen = workLen;
}
/*
* Allocate runs-to-be-merged stack (which cannot be expanded). The
* stack length requirements are described in listsort.txt. The C
* version always uses the same stack length (85), but this was
* measured to be too expensive when sorting "mid-sized" arrays (e.g.,
* 100 elements) in Java. Therefore, we use smaller (but sufficiently
* large) stack lengths for smaller arrays. The "magic numbers" in the
* computation below must be changed if MIN_MERGE is decreased. See
* the MIN_MERGE declaration above for more information.
* The maximum value of 49 allows for an array up to length
* Integer.MAX_VALUE-4, if array is filled by the worst case stack size
* increasing scenario. More explanations are given in section 4 of:
* http://envisage-project.eu/wp-content/uploads/2015/02/sorting.pdf
*/
int stackLen = (len < 120 ? 5 :
len < 1542 ? 10 :
len < 119151 ? 24 : 49);
runBase = new int[stackLen];
runLen = new int[stackLen];
}4.4.3 排序过程
4.4.3.1 run栈的处理
首先看下怎么处理存放各个run的栈的。
压栈方法pushRun,将当前处理的run放入栈中:
private void pushRun(int runBase, int runLen) {
this.runBase[stackSize] = runBase;
this.runLen[stackSize] = runLen;
stackSize++;
}
压栈后,会对当前的run栈立刻判断是否需要归并,并做合并mergeCollapse(),全部run处理完后,再调用mergeForceCollapse()合并全部的run。
先不管判断分支的含义,看下整体的结构:
private void mergeCollapse() {
while (stackSize > 1) {
int n = stackSize - 2;
if (n > 0 && runLen[n-1] <= runLen[n] + runLen[n+1]) {
if (runLen[n - 1] < runLen[n + 1])
n--;
mergeAt(n);
} else if (runLen[n] <= runLen[n + 1]) {
mergeAt(n);
} else {
break; // Invariant is established
}
}
}
看下这段逻辑的含义:
调用该方法时,n>=0。
当n>=1时,栈中至少3个run。这时检查第n-1个是否比后两个长度小。如果是,再判断n-1是否比n+1长度小,如果仍然是,那么处理n-1这个栈,即mergeAt(n);否则,处理n这个栈;
当n>=1,但是第n-1个比n和n+1的长度都长时,处理第n个;
n=0,即只有两个栈时,处理第0个run,即mergeAt(n);
其他情况忽略,本次不合并栈。
private void mergeForceCollapse() {
while (stackSize > 1) {
int n = stackSize - 2;
if (n > 0 && runLen[n - 1] < runLen[n + 1])
n--;
mergeAt(n);
}
}
对于最后一次合并,类似的,如果n-1的长度小于n+1,就合并n-1这个run。其他情况合并第n个
直到整个栈清空。
4.4.3.2 mergeAt(n)
mergeAt 是 TimSort 合并两个相邻 run 的核心方法。mergeCollapse 和 mergeForceCollapse 最终都调用它。源码如下:
private void mergeAt(int i) {
assert stackSize >= 2;
assert i >= 0;
assert i == stackSize - 2 || i == stackSize - 3;
int base1 = runBase[i];
int len1 = runLen[i];
int base2 = runBase[i + 1];
int len2 = runLen[i + 1];
assert len1 > 0 && len2 > 0;
assert base1 + len1 == base2;
// 合并后 run 的总长写入 i,如果 i 是倒数第三个(栈中还有个 i+2),
// 把最后一个 run 前移一格,保证栈的连续性
runLen[i] = len1 + len2;
if (i == stackSize - 3) {
runBase[i + 1] = runBase[i + 2];
runLen[i + 1] = runLen[i + 2];
}
stackSize--;
// 第一步:用 gallopRight 找到 run2 第一个元素在 run1 中的插入位置,
// 该位置之前的 run1 元素已经就位,不需要参与合并
int k = gallopRight(a[base2], a, base1, len1, 0, c);
assert k >= 0;
base1 += k;
len1 -= k;
if (len1 == 0)
return; // run1 全部已就位
// 第二步:用 gallopLeft 找到 run1 最后一个元素在 run2 中的插入位置,
// 该位置之后的 run2 元素也已经就位
len2 = gallopLeft(a[base1 + len1 - 1], a, base2, len2, len2 - 1, c);
assert len2 >= 0;
if (len2 == 0)
return; // run2 全部已就位
// 第三步:短的向长的合并——短的一方会被拷贝到临时数组
if (len1 <= len2)
mergeLo(base1, len1, base2, len2);
else
mergeHi(base1, len1, base2, len2);
}这个方法的执行分三步。
第一步:收缩边界。 在正式合并前,先通过 galloping search 把 run 两侧已经就位的元素排除掉。比如 run1 = [1, 3, 5, 7],run2 = [6, 8, 10],用 run2 的第一个元素 6 在 run1 中做 gallopRight,找到 6 应该插入的位置——7 之前,所以 1、3、5 不用参与合并。同理,用 run1 的最后一个元素 7 在 run2 中做 gallopLeft,找到 8 之后的位置——8、10 也不需要参与合并。真正需要归并的只有 [7] 和 [6]。
这层收缩能在部分有序的数组上大幅减少元素移动次数。
第二步:选择合并方向。 比较两个 run 的长度,短的那一方会被拷贝到临时数组 tmp 中,长的直接在原数组 a 上操作。mergeLo 对应 run1 是短的情况,mergeHi 对应 run2 是短的情况,两者逻辑对称。
第三步:合并 + galloping mode。 以 mergeLo 为例(run1 较短,已被拷贝到 tmp):
private void mergeLo(int base1, int len1, int base2, int len2) {
assert len1 > 0 && len2 > 0 && base1 + len1 == base2;
T[] a = this.a;
T[] tmp = ensureCapacity(len1); // 确保临时数组有足够空间
int cursor1 = tmpBase; // tmp 中 run1 的游标
int cursor2 = base2; // a 中 run2 的游标
int dest = base1; // 合并结果写入 a 的起始位置
System.arraycopy(a, base1, tmp, cursor1, len1);
// 先把 run2 的第一个元素写入,同时初始化比较循环
a[dest++] = a[cursor2++];
if (--len2 == 0) {
System.arraycopy(tmp, cursor1, a, dest, len1);
return;
}
if (len1 == 1) {
System.arraycopy(a, cursor2, a, dest, len2);
a[dest + len2] = tmp[cursor1];
return;
}
Comparator<? super T> c = this.c;
int minGallop = this.minGallop;
outer:
while (true) {
int count1 = 0; // run1 连胜次数
int count2 = 0; // run2 连胜次数
// 阶段 A:线性归并
do {
assert len1 > 1 && len2 > 0;
if (c.compare(a[cursor2], tmp[cursor1]) < 0) {
a[dest++] = a[cursor2++];
count2++;
count1 = 0;
if (--len2 == 0)
break outer;
} else {
a[dest++] = tmp[cursor1++];
count1++;
count2 = 0;
if (--len1 == 1)
break outer;
}
} while ((count1 | count2) < minGallop);
// 阶段 B:galloping mode
// 某一方连续赢了 minGallop 次,说明这一方有很大可能继续赢下去,
// 切换到 gallop 批量跳过
do {
assert len1 > 1 && len2 > 0;
count1 = gallopRight(a[cursor2], tmp, cursor1, len1, 0, c);
if (count1 != 0) {
System.arraycopy(tmp, cursor1, a, dest, count1);
dest += count1;
cursor1 += count1;
len1 -= count1;
if (len1 <= 1)
break outer;
}
a[dest++] = a[cursor2++];
if (--len2 == 0)
break outer;
count2 = gallopLeft(tmp[cursor1], a, cursor2, len2, 0, c);
if (count2 != 0) {
System.arraycopy(a, cursor2, a, dest, count2);
dest += count2;
cursor2 += count2;
len2 -= count2;
if (len2 == 0)
break outer;
}
a[dest++] = tmp[cursor1++];
if (--len1 == 1)
break outer;
minGallop--;
} while (count1 >= MIN_GALLOP | count2 >= MIN_GALLOP);
// galloping 结束后调整阈值:如果 galloping 回报不够(退出时
// count 都小于 MIN_GALLOP),把阈值 +2,下次更难进入,避免
// 在随机数据上频繁切换带来的开销
if (minGallop < 0)
minGallop = 0;
minGallop += 2;
}
this.minGallop = minGallop < 1 ? 1 : minGallop;
// 收尾:把剩余元素(都在同一方)批量拷入
if (len1 == 1) {
assert len2 > 0;
System.arraycopy(a, cursor2, a, dest, len2);
a[dest + len2] = tmp[cursor1];
} else if (len1 == 0) {
throw new IllegalArgumentException(
"Comparison method violates its general contract!");
} else {
assert len2 == 0;
System.arraycopy(tmp, cursor1, a, dest, len1);
}
}这段逻辑的核心在于 galloping mode 的切换时机。
为什么需要 galloping mode? 考虑一个极端场景:run1 的所有元素都小于 run2。这时 run1 会连续取胜,如果一直用线性归并逐元素比较,每次都要比较 run1 的下一个元素和 run2 的当前元素,做了大量无用比较。galloping 的思想是:既然某一方在连续赢,说明这一方可能有很多元素都排在前面,不如用指数搜索直接定位位置,然后整段拷贝。
gallopRight 的实现原理:
private static <T> int gallopRight(T key, T[] a, int base, int len,
int hint, Comparator<? super T> c) {
assert len > 0 && hint >= 0 && hint < len;
int ofs = 1;
int lastOfs = 0;
if (c.compare(key, a[base + hint]) < 0) {
// 向左 gallop:从 hint 开始,步长 1, 3, 7, 15... 向左试探
int maxOfs = hint + 1;
while (ofs < maxOfs && c.compare(key, a[base + hint - ofs]) < 0) {
lastOfs = ofs;
ofs = (ofs << 1) + 1;
if (ofs <= 0) // 溢出保护
ofs = maxOfs;
}
if (ofs > maxOfs)
ofs = maxOfs;
int tmp = lastOfs;
lastOfs = hint - ofs;
ofs = hint - tmp;
} else {
// 向右 gallop:步长 1, 3, 7, 15... 向右试探
int maxOfs = len - hint;
while (ofs < maxOfs && c.compare(key, a[base + hint + ofs]) >= 0) {
lastOfs = ofs;
ofs = (ofs << 1) + 1;
if (ofs <= 0)
ofs = maxOfs;
}
if (ofs > maxOfs)
ofs = maxOfs;
lastOfs += hint;
ofs += hint;
}
assert -1 <= lastOfs && lastOfs < ofs && ofs <= len;
// 在 [lastOfs, ofs) 区间内二分查找,区间长度已被 gallop 大幅缩小
lastOfs++;
while (lastOfs < ofs) {
int m = lastOfs + ((ofs - lastOfs) >>> 1);
if (c.compare(key, a[base + m]) < 0)
ofs = m;
else
lastOfs = m + 1;
}
assert lastOfs == ofs;
return ofs;
}hint 参数是预期位置。对于 mergeAt 中的 gallopRight(a[base2], a, base1, len1, 0, c),hint=0 表示估计 key 大概率在 run1 的开头附近;缩边界完成后在 mergeLo 中调用时,hint=0 则是因为游标已经推进了,key 位置预期也在当前位置附近。
minGallop 的自适应机制。 minGallop 初始值是 MIN_GALLOP(7)。每次退出 galloping mode 时,如果说明 galloping 没有起到显著效果(双方 gallop 跳过的数量都小于 MIN_GALLOP),就把 minGallop 加 2——提高进入门槛,避免在随机数据上频繁切换。如果 galloping 确实跳过了大量元素,minGallop 会逐渐降到 1——更积极地进入加速模式。这种自适应的结果是:数据越有序,越频繁地用到 galloping;数据越随机,越倾向于老老实实线性归并。
mergeHi 是对称实现。 run2 较短时,把 run2 拷到 tmp,run1 留在 a 中从后往前合并,逻辑与 mergeLo 对称,不再展开。
边界情况的处理。 mergeLo 开头用 a[dest++] = a[cursor2++] 先把 run2 第一个元素写进去,目的是避免合并初始阶段需要额外的边界判断。收尾时如果 run1 只剩一个元素(len1 == 1),那 run2 剩余全部在前、run1 最后这个元素垫后,直接做两次 arraycopy。如果比较器不符合契约(len1 == 0 但 len2 > 0),JDK 会抛出 IllegalArgumentException——这是 Java 7 引入 TimSort 后比较器 bug 常见的报错。
mergeAt 加上 galloping mode 构成了 TimSort 与传统归并排序最核心的区别。传统归并排序在每次合并时都老老实实地逐一比较,TimSort 则利用了"数据在现实中经常部分有序"这一事实,用 galloping 跳跃大量已经就位的元素,从而在部分有序的数组上将比较次数降到远低于 O(nlogn)。
五、未介绍的部分
5.1 变体二分插入排序
对算法的框架观察一下可以发现,在二分插入排序前,都调用了countRunAndMakeAscending方法来计算待排序部分__从起始位置起,保持升序或降序的最长的run的长度initRunLen__。如果是降序,会将这个run反转。
private static <T> int countRunAndMakeAscending(T[] a, int lo, int hi,
Comparator<? super T> c) {
assert lo < hi;
int runHi = lo + 1;
if (runHi == hi)
return 1;
// Find end of run, and reverse range if descending
if (c.compare(a[runHi++], a[lo]) < 0) { // Descending
while (runHi < hi && c.compare(a[runHi], a[runHi - 1]) < 0)
runHi++;
reverseRange(a, lo, runHi);
} else { // Ascending
while (runHi < hi && c.compare(a[runHi], a[runHi - 1]) >= 0)
runHi++;
}
return runHi - lo;
}然后对run之后的剩余数组,进行二分插入排序,如下:
private static <T> void binarySort(T[] a, int lo, int hi, int start,
Comparator<? super T> c) {
assert lo <= start && start <= hi;
if (start == lo)
start++;
for ( ; start < hi; start++) {
T pivot = a[start];
// Set left (and right) to the index where a[start] (pivot) belongs
int left = lo;
int right = start;
assert left <= right;
/*
* Invariants:
* pivot >= all in [lo, left).
* pivot < all in [right, start).
*/
while (left < right) {
int mid = (left + right) >>> 1;
if (c.compare(pivot, a[mid]) < 0)
right = mid;
else
left = mid + 1;
}
assert left == right;
/*
* The invariants still hold: pivot >= all in [lo, left) and
* pivot < all in [left, start), so pivot belongs at left. Note
* that if there are elements equal to pivot, left points to the
* first slot after them -- that's why this sort is stable.
* Slide elements over to make room for pivot.
*/
int n = start - left; // The number of elements to move
// Switch is just an optimization for arraycopy in default case
switch (n) {
case 2: a[left + 2] = a[left + 1];
case 1: a[left + 1] = a[left];
break;
default: System.arraycopy(a, left, a, left + 1, n);
}
a[left] = pivot;
}
}为什么不直接使用二分插入排序?可以理解为,尽量减少对已排序或已部分排序的数组在二分排序时造成的性能下降而做的预处理。
5.2 minRunLength计算方法
目的是计算一个最小的run的长度minRun,使得n/minRun最接近一个2的幂,代码如下:
private static int minRunLength(int n) {
assert n >= 0;
int r = 0; // Becomes 1 if any 1 bits are shifted off
while (n >= MIN_MERGE) {
r |= (n & 1);
n >>= 1;
}
return n + r;
}当n < MIN_MERGE也即32时,直接用n本身作为一个run的长度。
当n >= MIN_MERGE时,先观察下一些特殊情况:
例1:n=60,二进制表示为111100
根据算法:
第一轮 r = 0, n = 30,满足循环终止条件,获得n+r=30。60/30=15,小于16。此时run的个数为2,run的长度为30。
例2:n=64,二进制表示为100000
根据算法:
第一轮 r = 0, n = 32
第二轮 r = 0, n = 16,满足循环终止条件,获得n+r=16。64/16=4,正好是2的幂。此时run的个数为4,run的长度为16。
例3:n=65,二进制表示为100001
根据算法:
第一轮 r = 1, n = 32
第二轮 r = 1, n = 16,满足循环终止条件,获得n+r=17。65/17=3,即run的个数为3,run的长度为17。
分析
可以看出,每轮循环n都会除以2,r的值只和n的排除掉最高6位的其余各位中是否含1有关,计算出的值k满足MIN_MERGE/2 <= k <= MIN_MERGE。
进一步分析,在n >= 32的前提下,n总可以写成2^4 * 2^k + x的形式,其中k>=1,x < 2k。注意x的取值范围,如果x>=2k,那么这部分可以与2^4 * 2^k合并从而获得新的x'满足这个条件。
那么,经过k次循环后:
- n' = 2^4 + x/(2^k), 此时MIN_MERGE/2 <= n'<MIN_MERGE
- r=1或0,取决于最后k位中是否含1。
可以理解为,只要n不是2的幂,就将minRun稍微放大一些(+1),用稍微减少一点run的个数来确保各个run的长度尽可能相同,否则会出现前(k-1)个run的长度为 ⌊n/k⌋,最后一个run的长度兜底剩余元素(即n - (k-1)*⌊n/k⌋),长短不均。
这种思想,我理解是将不能整除的部分尽量分散到各个run中,而不是留一个长度不定的run,从而将性能平均化。
到此这篇关于Java的List.sort()排序方法源码理解的文章就介绍到这了,更多相关Java List.sort()排序方法内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
