引子
TimSort是一種穩(wěn)定的、自適應(yīng)的變種歸并排序。當(dāng)被應(yīng)用在部分有序的數(shù)組排序問題時,TimSort有遠(yuǎn)好于O(NlgN)的時間性能;而在最差情況下,TimSort也能保持與傳統(tǒng)歸并排序相近的表現(xiàn)。
TimSort最早由Tim Peters實現(xiàn)在Python的list sort中,從JDK 1.7開始被引入并成為Java中Arrays的默認(rèn)排序算法。
原理
歸并排序的核心思路是先將原數(shù)組分割成長度為1的子序列組,再遞歸地將兩個相鄰的子序列合并成一個序列。傳統(tǒng)歸并排序完全忽視了原數(shù)組中的順序關(guān)系,因而在最好和最壞情況下都擁有O(NlgN)的時間復(fù)雜度。反過來,利用原數(shù)組中的序列就成了優(yōu)化歸并排序的一個很好的切入點。
下面這幾幅圖展示了一個較為簡單直觀的思路:依次尋找所給數(shù)組中盡量長的序列(升序列,或降序列經(jīng)反轉(zhuǎn)形成的升序列),將這些序列做歸并操作。
然而,稍加思考后不難發(fā)現(xiàn),如果每次都將新發(fā)現(xiàn)的升序列直接與已排序好的部分進(jìn)行歸并,只能帶來O(N^2)的時間性能(別問,畫圖欠考慮又不想改了)。即使我們選擇了好的歸并策略,其性能提升能否抵消以及超過花費在尋找升序列上的開銷也仍存疑,尤其是當(dāng)找到的升序列普遍比較短的時候。所以,讓我們觀察下JDK中的TimSort實現(xiàn)是如何解決這些問題的。
源碼分析
二分排序:
此函數(shù)是在TimSort主循環(huán)中被調(diào)用到的一個方法,由于其作用和邏輯都相對獨立,因此可以放在最前面單獨進(jìn)行分析
private static <T> void binarySort(T[] a, int lo, int hi, int start,
Comparator<? super T> c) {
// lo和hi分別是當(dāng)前所操作數(shù)組的低索引和高索引,同名參數(shù)在此文件很多函數(shù)中都有用到
// 此函數(shù)中保證:lo <= start <= hi
// 此外根據(jù)主循環(huán)對此函數(shù)的調(diào)用可以知道,數(shù)組a中[lo, start)內(nèi)的元素是保證升序的,[start, hi)內(nèi)的元素是默認(rèn)無序的
assert lo <= start && start <= hi;
if (start == lo)
start++;
// 大致思路就是遍歷[start, hi)中的元素,分別插入[lo, start)中
for ( ; start < hi; start++) {
T pivot = a[start];
int left = lo;
int right = start;
assert left <= right;
// 二分查找的循環(huán)實現(xiàn)
while (left < right) {
int mid = (left + right) >>> 1;
if (c.compare(pivot, a[mid]) < 0)
right = mid;
else
left = mid + 1;
}
assert left == right;
// 根據(jù)當(dāng)前元素索引到目標(biāo)索引的距離,選擇移動策略
int n = start - left;
switch (n) {
// 減少對arraycopy()的調(diào)用,挺巧妙的
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;
}
}
私有變量:
private static final int MIN_MERGE = 32; //使用TimSort的閥值,長度小于閥值的數(shù)組使用傳統(tǒng)歸并排序
private final T[] a; //待排序數(shù)組
private final Comparator<? super T> c; //比較器
private static final int MIN_GALLOP = 7; //進(jìn)入Gallop模式的閥值
private int minGallop = MIN_GALLOP;
//用于歸并操作的臨時數(shù)組的相關(guān)變量
private static final int INITIAL_TMP_STORAGE_LENGTH = 256;
private T[] tmp;
private int tmpBase;
private int tmpLen;
//用于保存升序列的棧
//在算法運行時保證:runBase[i] + runLen[i] == runBase[i+1]
private int stackSize = 0;
private final int[] runBase; //每個升序列頭在原數(shù)組中的索引
private final int[] runLen; //每個升序列的長度
工廠方法:
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;
int nRemaining = hi - lo;
if (nRemaining < 2)
return;
// 若數(shù)組長度小于閥值,將不適用TimSort
if (nRemaining < MIN_MERGE) {
// 嘗試一次尋找升序列
// 這里binarySort的時間復(fù)雜度是O((hi - initRunLen) * lg(hi - lo))
// 因此所找到起始升序列的長度對總體運行時間的影響明顯
int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
binarySort(a, lo, hi, lo + initRunLen, c);
return;
}
// 正式進(jìn)入TimSort的邏輯,首先調(diào)用私有的構(gòu)造器
TimSort<T> ts = new TimSort<>(a, c, work, workBase, workLen);
int minRun = minRunLength(nRemaining);
do {
// 尋找下一個序列
int runLen = countRunAndMakeAscending(a, lo, hi, c);
// 如果這個序列長度小于閥值minRun,會將后續(xù)minRun個元素一起做二分排序
// minRun的取值策略很有趣,后面會詳細(xì)討論
if (runLen < minRun) {
// 在主循環(huán)的最后一趟,剩余元素可能不夠minRun個了,就直接將所有元素做二分排序
int force = nRemaining <= minRun ? nRemaining : minRun;
binarySort(a, lo, lo + force, lo + runLen, c);
runLen = force;
}
ts.pushRun(lo, runLen); //將此序列壓入棧
ts.mergeCollapse(); //根據(jù)棧頂若干序列的長度關(guān)系,嘗試清棧
// 更新索引
lo += runLen;
nRemaining -= runLen;
} while (nRemaining != 0);
// 主循環(huán)結(jié)束時,完成強制清棧等收尾工作
assert lo == hi;
ts.mergeForceCollapse();
assert ts.stackSize == 1;
}
構(gòu)造器:
private TimSort(T[] a, Comparator<? super T> c, T[] work, int workBase, int workLen) {
this.a = a;
this.c = c;
// 為歸并操作分配空間,大致為原數(shù)組的一半
// 可能根據(jù)需要,在其他函數(shù)中被修改
int len = a.length;
int tlen = (len < 2 * INITIAL_TMP_STORAGE_LENGTH) ?
len >>> 1 : INITIAL_TMP_STORAGE_LENGTH;
// 如果從IDE里查看usage的話, 會發(fā)現(xiàn)絕大部分傳入的work參數(shù)都是null, 從而進(jìn)入這個分支
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;
}
// 根據(jù)原數(shù)組長度,為序列棧分配初始空間
// 真·魔數(shù),難以揣測具體數(shù)值來源
// 值得一提的是,用這種方法寫長串if-else邏輯個人還是第一次見,學(xué)到了
int stackLen = (len < 120 ? 5 :
len < 1542 ? 10 :
len < 119151 ? 24 : 40);
runBase = new int[stackLen];
runLen = new int[stackLen];
}
尋找升序列:
// 此函數(shù)嘗試在給定的數(shù)組段中,從最低位開始,尋找一個升序列
// 或?qū)ふ乙粋€降序列,并原地反轉(zhuǎn)成升序列
// 返回值是所找到升序列的長度
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;
// 根據(jù)前兩個元素的大小關(guān)系來判斷下個序列是升序或降序
if (c.compare(a[runHi++], a[lo]) < 0) { // 降序
while (runHi < hi && c.compare(a[runHi], a[runHi - 1]) < 0)
runHi++;
reverseRange(a, lo, runHi); // 原地反轉(zhuǎn)
} else { // 升序
while (runHi < hi && c.compare(a[runHi], a[runHi - 1]) >= 0)
runHi++;
}
return runHi - lo;
}
原地反轉(zhuǎn):
// 沒有什么好說的
private static void reverseRange(Object[] a, int lo, int hi) {
hi--;
while (lo < hi) {
Object t = a[lo];
a[lo++] = a[hi];
a[hi--] = t;
}
}
最短序列長度閥值生成函數(shù)(姑且這么描述 吧。。):
// 注意,這個函數(shù)在一次TimSort生命周期中只被調(diào)用一次
// 入?yún)⑹窃瓟?shù)組總長度
private static int minRunLength(int n) {
assert n >= 0;
int r = 0;
// 首先,當(dāng) n < MIN_MERGE 時,返回 n
// (實際上這種情況下,這個函數(shù)根本調(diào)用不到)
// 若 n 為 2 的冪,那么 r 最終會為 0,返回結(jié)果是 MIN_MERGE / 2
// 在剩余情況下,返回值 k 落在 [MIN_MERGE/2, MIN_MERGE] 間
// 同時保證 n / k 接近且嚴(yán)格小于一個 2 的冪
while (n >= MIN_MERGE) {
r |= (n & 1);
n >>= 1;
}
return n + r;
}
序列壓棧操作:
// 見私有變量中的注釋描述
private void pushRun(int runBase, int runLen) {
this.runBase[stackSize] = runBase;
this.runLen[stackSize] = runLen;
stackSize++;
}
棧內(nèi)歸并:
// 此方法的作用是將序列棧中的第 i 和 第 i + 1 個序列歸并成一個序列,
// 并放在第 i 個位置上
private void mergeAt(int i) {
assert stackSize >= 2;
assert i >= 0;
// 注意 i 必須是棧內(nèi)倒數(shù)第二個或倒數(shù)第三個序列
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;
// 更新棧的狀態(tài)
runLen[i] = len1 + len2;
// 如果準(zhǔn)備進(jìn)行歸并的是倒數(shù)第二和倒數(shù)第三的序列,那么還需要維護(hù)倒數(shù)第一序列的狀態(tài)
if (i == stackSize - 3) {
runBase[i + 1] = runBase[i + 2];
runLen[i + 1] = runLen[i + 2];
}
stackSize--;
// 下面就是正式的歸并操作邏輯
// 目前我們有序列 1: [base1, base1 + len1) 和序列 2 : [base2, base2 + len2)
// 其中 base1 + len1 == base2
// 首先嘗試在序列 1 中尋找一個索引 base1 + k
// 使得 [base1 + k, base1 + len1) 中的每一個元素都比序列 2 中的元素小
// 從而減少實際需進(jìn)行歸并操作的序列長度
int k = gallopRight(a[base2], a, base1, len1, 0, c);
assert k >= 0;
base1 += k;
len1 -= k;
if (len1 == 0)
return;
// 與上一段類似,在序列 2 中尋找索引 base2 + k
// 使得 [base2 + k, base2 + len2) 中每一個元素都比序列 1 中的元素大
len2 = gallopLeft(a[base1 + len1 - 1], a, base2, len2, len2 - 1, c);
assert len2 >= 0;
if (len2 == 0)
return;
// 將剪枝后的兩個序列進(jìn)行歸并,以長度較短的序列為基底
if (len1 <= len2)
mergeLo(base1, len1, base2, len2);
else
mergeHi(base1, len1, base2, len2);
}
序列內(nèi)查找索引:
// 在序列內(nèi)查找一個與給定元素值最接近的索引,直接想法應(yīng)該是二分
// 這個函數(shù)實現(xiàn)也是基于二分搜索的,同時又增加了一些啟發(fā)式的邏輯
// 比如 hint 這個參數(shù),根據(jù)原注釋,如果傳入時越接近目標(biāo)索引,此函數(shù)運行越快
private static <T> int gallopLeft(T key, T[] a, int base, int len, int hint,
Comparator<? super T> c) {
assert len > 0 && hint >= 0 && hint < len;
// 在經(jīng)典二分查找開始前,先嘗試縮小查找范圍
int lastOfs = 0;
int ofs = 1;
// 大致思路是依次嘗試 [base, base+1), [base+1, base+3), [base+3, base+7)... 等區(qū)間
// 同時根據(jù) hint 的值,對區(qū)間上下界進(jìn)行偏移
if (c.compare(key, a[base + hint]) > 0) {
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;
} else {
final 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;
}
assert -1 <= lastOfs && lastOfs < ofs && ofs <= len;
// 在區(qū)間 [base + lastOfs, base + ofs) 上進(jìn)行查找,經(jīng)典的二分循環(huán)實現(xiàn)
// 個人認(rèn)為讀此方法的代碼時,可以從這里開始,然后帶著對 lastOfs 和 ofs 的問題回頭讀前半部分
lastOfs++;
while (lastOfs < ofs) {
int m = lastOfs + ((ofs - lastOfs) >>> 1);
if (c.compare(key, a[base + m]) > 0)
lastOfs = m + 1;
else
ofs = m;
}
assert lastOfs == ofs;
return ofs;
}
// 與 gallopLeft 相對稱
private static <T> int gallopRight(T key, T[] a, int base, int len,
int hint, Comparator<? super T> c);
歸并:
private void mergeLo(int base1, int len1, int base2, int len2) {
assert len1 > 0 && len2 > 0 && base1 + len1 == base2;
// 出于性能考慮,將類中的對象用本地變量形式聲明出來,這種用法出現(xiàn)多次
T[] a = this.a;
T[] tmp = ensureCapacity(len1);
// 聲明游標(biāo) cursor1 和 cursor2 分別指向兩個序列頭部
int cursor1 = tmpBase;
int cursor2 = base2;
int dest = base1;
System.arraycopy(a, base1, tmp, cursor1, len1);
// 對一些極端情況的處理
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;
// 以下是個人認(rèn)為整個TimSort中最能體現(xiàn)“啟發(fā)性”的一段邏輯
outer:
while (true) {
// 一次元素比較中,某個序列派出的元素較小,定義為這個序列“贏”了一次
int count1 = 0; // 序列 1 贏的次數(shù)
int count2 = 0; // 序列 2 贏的次數(shù)
// 在下面的循環(huán)中,如果先不管 count1 和 count2, 那就是經(jīng)典的歸并操作邏輯
do {
assert len1 > 1 && len2 > 0;
if (c.compare(a[cursor2], tmp[cursor1]) < 0) {
a[dest++] = a[cursor2++];
// 注意到若一個序列開始贏了,就將另一個序列贏的計數(shù)清零
// 因此 count1 和 count2 運行時實際代表了對應(yīng)序列連續(xù)贏的次數(shù)
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);
// 因此當(dāng)某一序列連續(xù)贏的次數(shù)超過了 minGallop 這個閥值,就開始進(jìn)入 gallop 模式
// 在此模式下,不再逐元素地比較大小后移動游標(biāo)
// 而是基于序列1中的某個元素可能比序列2中的一大段元素都要小的預(yù)測(或者反過來,類似)
// 調(diào)用 gallopRight 方法,用 O(lgN) 的速度找到序列 1 中這個元素應(yīng)當(dāng)出現(xiàn)在序列 2 中的位置
// 其目的是加速歸并過程
do {
assert len1 > 1 && len2 > 0;
count1 = gallopRight(a[cursor2], tmp, cursor1, len1, 0, c);
// 我們已經(jīng)知道 gallopRight 返回的是索引的偏移量
// 在此循環(huán)的邏輯下,此返回值也恰好是序列 1 連續(xù)贏的次數(shù)
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 的調(diào)整很有趣
// 注意到 minGallop 決定了能否進(jìn)入 gallop 模式, 而 MIN_GALLOP 決定了能否保持在 gallop 模式
// 個人認(rèn)為可以跟人的記憶過程做個類比,比如記單詞
// 設(shè)想一名同學(xué)正在備考GRE,他將每天的任務(wù)分成組
// 其中第一組任務(wù)是復(fù)習(xí),其他組是學(xué)習(xí)新單詞
// 學(xué)習(xí)一遍所需的時間固定為 MIN_GALLOP, 復(fù)習(xí)所需時間不固定
// 一般而言,基于遺忘規(guī)律,復(fù)習(xí)所需時間會隨天數(shù)逐漸變長(minGallop += 2;)
// 同時,今天這名同學(xué)在這些單詞上每多學(xué)習(xí)一遍,第二天復(fù)習(xí)所需要的時間就減少一小時
// (這樣類比其實有硬傷,今天多花了7小時才為明天省了1小時,有點跑步長壽的意思。。。)
minGallop--;
} while (count1 >= MIN_GALLOP | count2 >= MIN_GALLOP);
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;
assert len1 > 1;
System.arraycopy(tmp, cursor1, a, dest, len1);
}
}
// 與 mergeLo 相對稱
private void mergeHi(int base1, int len1, int base2, int len2) ;
為歸并操作分配空間:
private T[] ensureCapacity(int minCapacity) {
if (tmpLen < minCapacity) {
// 尋找一個盡量小的但大于 minCapacity 的 2 的冪
// 經(jīng)典的位運算 (Hacker's Delight: figure 3-3, java.lang.Integer 中也用到了很多書中算法)
int newSize = minCapacity;
newSize |= newSize >> 1;
newSize |= newSize >> 2;
newSize |= newSize >> 4;
newSize |= newSize >> 8;
newSize |= newSize >> 16;
newSize++;
if (newSize < 0)
newSize = minCapacity;
else
newSize = Math.min(newSize, a.length >>> 1);
@SuppressWarnings({"unchecked", "UnnecessaryLocalVariable"})
T[] newArray = (T[])java.lang.reflect.Array.newInstance
(a.getClass().getComponentType(), newSize);
tmp = newArray;
tmpLen = newSize;
tmpBase = 0;
}
return tmp;
}
清棧:
// 會根據(jù)棧內(nèi)倒數(shù)第一,倒數(shù)第二和倒數(shù)第三(如果有)序列的長度關(guān)系控制是否歸并
// 大致思路是優(yōu)先將兩個長度較短的序列歸并(當(dāng)然,指的是倒一&倒二,或倒二&倒三)
// 在時機不當(dāng)時,還可以等待TimSort主循環(huán)向棧內(nèi)壓入了更多序列后,再嘗試歸并
// (腦補下2048游戲合并方格的過程,還挺好理解的)
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;
}
}
}
// 主循環(huán)結(jié)束時調(diào)用,強制清棧
private void mergeForceCollapse() {
while (stackSize > 1) {
int n = stackSize - 2;
if (n > 0 && runLen[n - 1] < runLen[n + 1])
n--;
mergeAt(n);
}
}
回顧總結(jié)
不難發(fā)現(xiàn) mergeCollapse --> mergeAt --> mergeLo & mergeHi --> gallopLeft & gallopRight 這個調(diào)用鏈內(nèi)的幾個函數(shù),其實可以看作是一些“常規(guī)操作”的常數(shù)級優(yōu)化,也可以用較為簡單的經(jīng)典實現(xiàn)代替。
整個TimSort的核心思路還是體現(xiàn)在主循環(huán)里面,如果用一句話去概括,個人認(rèn)為是將整個排序任務(wù)分割成若干二分插入排序和歸并排序。一般來說,短而無序的數(shù)組先用二分插排轉(zhuǎn)換成長序列,再將幾個長序列歸并起來。這其中策略切換以及閥值的計算過程很值得細(xì)細(xì)體會。比如考慮一個極端差的情況,原數(shù)組中沒有連續(xù)三個及以上的子序列,再去看主循環(huán)的執(zhí)行過程:
// 返回值 k 落在 [MIN_MERGE/2, MIN_MERGE] 間
// 同時保證 n / k 接近且嚴(yán)格小于一個 2 的冪
// 我們設(shè) t = Round(n / k) + 1
int minRun = minRunLength(nRemaining);
do {
int runLen = countRunAndMakeAscending(a, lo, hi, c);
// 此時都要進(jìn)入這個分支,每次從數(shù)組中取一個 minRun 長的數(shù)組進(jìn)行 binarySort
if (runLen < minRun) {
int force = nRemaining <= minRun ? nRemaining : minRun;
binarySort(a, lo, lo + force, lo + runLen, c);
runLen = force;
}
// 此時,每次(除最后一次)壓棧的序列長度也都是 minRun,共壓棧 t 次
ts.pushRun(lo, runLen);
// 在這種情況下,mergeCollapse 的行為就很好預(yù)測了
// 總運行時間大致正比于 n * Round(log(2, t) + 1)
// 這樣就不難看出為什么 minRunLength 要保證 n / k 接近并小于一個 2 的冪了
ts.mergeCollapse();
lo += runLen;
nRemaining -= runLen;
} while (nRemaining != 0);
有一些仍沒能想明白的點,比如 MIN_GALLOP 和 MIN_MERGE 的取值,應(yīng)該對整體性能也有關(guān)鍵性的影響。
(突然想到了一個挺好玩的問題,一個長度為32的實數(shù)數(shù)組中,沒有長度為7及以上的序列的概率是多大呢?)