為什麼?為什麼?Java處理排序後的陣列比沒有排序的快?想過沒有?

沉默王二發表於2020-08-17

先看再點贊,給自己一點思考的時間,微信搜尋【沉默王二】關注這個有顏值卻假裝靠才華苟且的程式設計師。
本文 GitHub github.com/itwanger 已收錄,裡面還有我精心為你準備的一線大廠面試題。

今天週日,沒什麼重要的事情要做,於是我早早的就醒來了。看了一會渡邊淳一的書,內心逐漸感到平靜——心情不佳的時候,書好像是最好的藥物。心情平靜了,就需要做一些更有意義的事情——逛技術網站,學習精進。

Stack Overflow 是我最喜歡逛的一個網站,它是我 Chrome 瀏覽器的第一個書籤。裡面有很多很多經典的問題,其中一些回答,剖析得深入我心。就比如說這個:“為什麼處理排序後的陣列比沒有排序的快?”

毫無疑問,直觀印象裡,排序後的陣列處理起來就是要比沒有排序的快,甚至不需要理由,就好像我們知道“夏天吃冰激凌就是爽,冬天穿羽絨服就是暖和”一樣。

但本著“知其然知其所以然”的態度,我們確實需要去搞清楚到底是為什麼?

來看一段 Java 程式碼:

/**
 * @author 沉默王二,一枚有趣的程式設計師
 */

public class SortArrayFasterDemo {
    public static void main(String[] args) {
        // 宣告陣列
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c) {
            data[c] = rnd.nextInt() % 256;
        }

        // !!! 排序後,比沒有排序要快
        Arrays.sort(data);

        // 測試
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // 迴圈
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128) {
                    sum += data[c];
                }
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

這段程式碼非常簡單,我來解釋一下:

  • 宣告一個指定長度(32768)的陣列。

  • 宣告一個 Random 隨機數物件,種子是 0;rnd.nextInt() % 256 將會產生一個餘數,餘數的絕對值在 0 到 256 之間,包括 0,不包括 256,可能是負數;使用餘數對陣列進行填充。

  • 使用 Arrays.sort() 進行排序。

  • 通過 for 迴圈巢狀計算陣列累加後的結果,並通過 System.nanoTime() 計算前後的時間差,精確到納秒級。

我本機的環境是 Mac OS,記憶體 16 GB,CPU Intel Core i7,IDE 用的是 IntelliJ IDEA,排序後和未排序後的結果如下:

排序後:2.811633398
未排序:9.41434346

時間差還是很明顯的,對吧?未排序的時候,等待結果的時候讓我有一種擔心:什麼時候結束啊?不會結束不了吧?

讀者朋友們有沒有玩過火炬之光啊?一款非常經典的單機遊戲,每一個場景都有一副地圖,地圖上有很多分支,但只有一個分支可以通往下一關;在沒有刷圖之前,地圖是模糊的,玩家並不知道哪一條分支是正確的。

如果僥倖跑的是一條正確的分支,那麼很快就能到達下一關;否則就要往回跑,尋找正確的那條分支,需要花費更多的時間,但同時也會收穫更多的經驗和聲望。

作為一名玩過火炬之光很久的老玩家,幾乎每一幅地圖我都刷過很多次,刷的次數多了,地圖差不多就刻進了我的腦袋,即便是一開始地圖是模糊的,我也能憑藉經驗和直覺找到最正確的那條分支,就省了很多折返跑的時間。

讀者朋友們應該注意到了,上面的程式碼中有一個 if 分支——if (data[c] >= 128),也就是說,如果陣列中的值大於等於 128,則對其進行累加,否則跳過。

那這個程式碼中的分支就好像火炬之光中的地圖分支,如果處理器能夠像我一樣提前預判,那累加的操作就會快很多,對吧?

處理器的內部結構我是不懂的,但它應該和我的大腦是類似的,遇到 if 分支的時候也需要停下來,猜一猜,到底要不要繼續,如果每次都猜對,那顯然就不需要折返跑,浪費時間。

這就是傳說中的分支預測!

我需要刷很多次圖才能正確地預測地圖上的路線,處理器需要排序才能提高判斷的準確率

計算機發展了這麼多年,已經變得非常非常聰明,對於條件的預測通常能達到 90% 以上的命中率。但是,如果分支是不可預測的,那處理器也無能為力啊,對不對?

排序後花費的時間少,未排序花費的時間多,罪魁禍首就在 if 語句上。

if (data[c] >= 128) {
    sum += data[c];
}

陣列中的值是均勻分佈的(-255 到 255 之間),至於是怎麼均勻分佈的,我們暫且不管,反正由 Random 類負責。

為了方便講解,我們暫時忽略掉負數的那一部分,從 0 到 255 說起。

來看經過排序後的資料:

data[] = 01234... 126127128129130... 250251252...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT

N 是小於 128 的,將會被 if 條件過濾掉;T 是將要累加到 sum 中的值。

再來看未排序的資料:

data[] = 22618512515819814421779202118,  14150177182133...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   

完全沒有辦法預測。

對比過後,就能發現,排序後的資料在遇到分支預測的時候,能夠輕鬆地過濾掉 50% 的資料,對吧?是有規律可循的。

那假如說不想排序,又想節省時間,有沒有辦法呢?

如果你直接問我的話,我肯定毫無辦法,兩手一攤,一副無奈臉。不過,Stack Overflow 以上帝視角給出了答案。

把:

if (data[c] >= 128) {
    sum += data[c];
}

更換為:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

通過位運算消除了 if 分支(並不完全等同),但我測試了一下,計算後的 sum 結果是相同的。

/**
 * @author 沉默王二,一枚有趣的程式設計師
 */

public class SortArrayFasterDemo {
    public static void main(String[] args) {
        // 宣告陣列
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random();
        for (int c = 0; c < arraySize; ++c) {
            data[c] = rnd.nextInt() % 256;
        }

        // 測試
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // 迴圈
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128) {
                    sum += data[c];
                }
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);

        // 測試
        long start1 = System.nanoTime();
        long sum1 = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // 迴圈
            for (int c = 0; c < arraySize; ++c)
            {
                int t = (data[c] - 128) >> 31;
                sum1 += ~t & data[c];
            }
        }

        System.out.println((System.nanoTime() - start1) / 1000000000.0);
        System.out.println("sum1 = " + sum1);
    }
}

輸出結果如下所示:

8.734795196
sum = 156871800000
1.596423307
sum1 = 156871800000

陣列累加後的結果是相同的,但時間上仍然差得非常多,這說明時間確實耗在分支預測上——如果陣列沒有排序的話。

最後,不得不說一句,大神級程式設計師不愧是大神級程式設計師,懂得位運算的程式設計師就是屌。

建議還在讀大學的讀者朋友多讀一讀《計算機作業系統原理》這種涉及到底層的書,對成為一名優秀的程式設計師很有幫助。畢竟大學期間,學習時間充分,社會壓力小,能夠做到心無旁騖,加油!


我是沉默王二,一枚有顏值卻假裝靠才華苟且的程式設計師。關注即可提升學習效率,別忘了三連啊,點贊、收藏、留言,我不挑,奧利給?

注:如果文章有任何問題,歡迎毫不留情地指正。

如果你覺得文章對你有些幫助,歡迎微信搜尋「沉默王二」第一時間閱讀,回覆關鍵字「小白」可以免費獲取我肝了 4 萬+字的 《Java 小白從入門到放肆》2.0 版;本文 GitHub github.com/itwanger 已收錄,歡迎 star。

相關文章