1. 程式人生 > 實用技巧 >Java 排序遇到的神坑,我替你踩了!

Java 排序遇到的神坑,我替你踩了!

作者:nxlhero
來源:https://blog.51cto.com/nxlhero/2515850

問題描述

一個開發人員寫了一段明顯有問題的排序程式碼,大致如下:

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;

public class Test {

    public static void main(String[] args) throws InterruptedException {
        //測試資料: List裡放Map,按Map裡的name欄位排序
        HashMap<String, String> a = new HashMap<String, String>();
        a.put("name", "二");
        HashMap<String, String> b = new HashMap<String, String>();
        b.put("name", "一");
        HashMap<String, String> c = new HashMap<String, String>();
        c.put("name", "一");
        HashMap<String, String> d = new HashMap<String, String>();
        d.put("name", "四");
        HashMap<String, String> e = new HashMap<String, String>();
        e.put("name", "二");
        HashMap<String, String> f = new HashMap<String, String>();
        f.put("name", "三");
        ArrayList<HashMap<String, String>> list = new ArrayList<>();
        list.add(a);
        list.add(b);
        list.add(c);
        list.add(d);
        list.add(e);
        list.add(f);

        //排序:明顯有問題,因為只返回-1和0,也就是比較的時候永遠是小於等於
        Collections.sort(list, new Comparator<HashMap<String, String>>() {
            @Override
            public int compare(HashMap<String, String> o1, HashMap<String, String> o2) {
                String n1 = o1.get("name");
                String n2 = o2.get("name");
                if (n1.equals("一")) {
                    return -1;
                }
                if (n1.equals("二") && !n2.equals("一")) {
                    return -1;
                }
                if (n1.equals("三") && !"一二".contains(n2)) {
                    return -1;
                }
                if (n1.equals("四") && !"一二三".contains(n2)) {
                    return -1;
                }
                return 0;
            }
        });

        for(HashMap<String, String> x : list) {
            System.out.print(x.get("name"));
        }

    }
}

按理這個排序是有問題的,但是不管怎麼改變測試資料,排序結果都是對的(測試資料量較小),上面程式碼的輸出結果如下,用的jdk是1.7:

一一二二三四

但是,生產上是有問題的。

分析

Collections.sort,最終呼叫了Arrays.sort,在1.7中,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);
    }
}

如果配置了java.util.Arrays.useLegacyMergeSort這個引數,那麼就走老的LegacyMergeSort,否則就走新的TimSort。

我們在程式碼里加上下面一句話,輸出結果就是亂序的,這符合預期。

System.setProperty("java.util.Arrays.useLegacyMergeSort", "true");

檢查了一下生產上JVM的引數,果然加了這個引數。

但是為什麼走TimSort的結果是對的呢?繼續分析TimSort的程式碼,發現有一個特殊情況的處理:

// If array is small, do a "mini-TimSort" with no merges
if (nRemaining < MIN_MERGE) { //MIN_MERGE是32
    int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
    binarySort(a, lo, hi, lo + initRunLen, c);
    return;
}

也就是在陣列小於32的時候,進入這個裡面,然後沒有歸併。那我們先來測試一下大於32的情況。

public class Test { 
    public static void main(String[] args) throws InterruptedException {    
        ArrayList<HashMap<String, String>> list = new ArrayList<>();
        String[] xx = {"一","二","三","四"};
        for(int i = 0; i < 35; i++) {
            HashMap<String,String> x = new HashMap<String,String>();
            x.put("name", xx[(i+17)%4]);
            list.add(x);
        }
        Collections.sort(list, new Comparator<HashMap<String, String>>() {
            @Override
            public int compare(HashMap<String, String> o1, HashMap<String, String> o2) {
                String n1 = o1.get("name");
                String n2 = o2.get("name");
                if (n1.equals("一")) {
                    return -1;
                }
                if (n1.equals("二") && !n2.equals("一")) {
                    return -1;
                }
                if (n1.equals("三") && !"一二".contains(n2)) {
                    return -1;
                }
                if (n1.equals("四") && !"一二三".contains(n2)) {
                    return -1;
                }
                return 0;
            }
        });

        for(HashMap<String, String> x : list) {
            System.out.print(x.get("name"));
        }
    }
}

這次果然翻車了。

一一一一二二二二二三三三三三四四四四一一一一二二二二三三三三四四四四四

我們通過程式碼來看一下為什麼小於32的時候排序成功了。

首先,我們的比較函式,只有在真正小於或者等於情況下返回了-1,其餘情況返回了0,包括大於的情況也返回了0。

比如

兩個值 結果
一一 -1
一二 -1
三二 0
四四 -1
三一 0

為了簡化,下面用阿拉伯數字代替

以211423為例,

if (nRemaining < MIN_MERGE) {
    int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
    binarySort(a, lo, hi, lo + initRunLen, c);
    return;
}

第一步,是找到嚴格遞增或者遞減的最大長度,如果是升序,就不處理,降序的話,就reverse。

211423經過處理後變成了112 423,最大遞減長度為3(因為1和1相比的結果為-1,所以也被當作嚴格遞減),然後211被reverse成112

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;
}

接下來,從第四個位置開始,找到它的位置,移動資料,讓每一個數字找到合適的位置,具體的程式碼如下:

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;

        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;
    }
}

對於112423的移動過程如下:

第一次:112 4 23, 在左邊找到合適4的位置,結果為1124 23

第二次:1124 2 3, 在左邊找到2合適的位置,結果11224 3

第三次:11224 3,在左邊找到3合適的位置,結果為112234,結束

在整個函式中,我們發現了一個問題,那就是隻用到了c.compare(pivot, a[mid]) < 0,而大於0和等於0的情況沒有用到,而我們的比較函式正好是返回小於0的時候是正確的,所以並不會影響這個函式的執行結果。也就是說,只要真正小於的時候返回了-1,不小於的時候返回了0或者1,對這個函式是沒有影響的,正因為如此這個函式是個穩定排序。

但是在countRunAndMakeAscending這個函式裡用到了>=0。我們看一下這種情況,也就是陣列的開頭是遞增的時候,會用到>=0

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;
}

假設輸入的是1234123,前邊2和1相比結果是0,3和2也是0,4和3也是0,1和4是-1,所以最大遞增序列是1234,同時不用reverse,傳給下一個函式的輸入為1234 123,結果三次插入,結果也是對的。

總結

綜上分析可以得出結論,就是因為在jdk 1.7中,如果陣列小於32個元素,加入對於小於的比較都是-1, 其他的都是0,那麼結果是正確的,這是因為演算法本身的特性。但是大於32時,就不對了,會看到分段排好序了,這是因為歸併的時候比較結果都是0,導致沒有做歸併。

其實sort的Comparator是有坑的,必須把所有情況都考慮周到,而且要滿足以下特性:

1 ) 自反性: x , y 的比較結果和 y , x 的比較結果相反。
2 ) 傳遞性: x > y , y > z ,則 x > z 。
3 ) 對稱性: x = y ,則 x , z 比較結果和 y , z 比較結果相同。

上面的Comparator如果要寫的對,應該這麼寫,把所有情況列出來,當然也可以通過一些條件簡化,但是簡化的後果就是上面的結果,需要充分測試。

Collections.sort(list, new Comparator<HashMap<String, String>>() {
    @Override
    public int compare(HashMap<String, String> o1, HashMap<String, String> o2) {
        String n1 = o1.get("name");
        String n2 = o2.get("name");
        if (n1.equals("一") && n2.equals("一")) {
            return 0;
        }
        if (n1.equals("一") && n2.equals("二")) {
            return -1;
        }
        if (n1.equals("一") && n2.equals("三")) {
            return -1;
        }
        if (n1.equals("一") && n2.equals("四")) {
            return -1;
        }
        if (n1.equals("二") && n2.equals("一")) {
            return 1;
        }
        ......

    }
});

近期熱文推薦:

1.Java 15 正式釋出, 14 個新特性,重新整理你的認知!!

2.終於靠開源專案弄到 IntelliJ IDEA 啟用碼了,真香!

3.我用 Java 8 寫了一段邏輯,同事直呼看不懂,你試試看。。

4.吊打 Tomcat ,Undertow 效能很炸!!

5.《Java開發手冊(嵩山版)》最新發布,速速下載!

覺得不錯,別忘了隨手點贊+轉發哦!