回爐重造12時辰-程式碼效率優化方法論(一)

帝莘發表於2020-11-27

回爐重造12時辰-程式碼效率優化方法論(一)

 

衡量程式執行的效率:複雜度

  之前聽我一個朋友說過,他曾寫過這麼一段程式碼,就是將80萬條資料從一個庫中拿出來,插入另一個庫的兩個表中,同時這些資料要在solr中建索引,每插入1000條提交一次事務,結果就是,這段程式碼執行了3天多,才將80萬條資料插入資料庫完畢···

  暫且不提可以通過多執行緒提高插入速度,單純想一下3天多執行完一段程式碼,它的效率顯然是低下的。(狠狠的嘲諷我那哥們)

  如果這個效率低下的系統是離線的,那麼它會讓我們的開發週期、測試周期變得很長。

  如果這個效率低下的系統是線上的,那麼它隨時具有時間爆炸或者記憶體爆炸的可能性。

  此刻,我們就明白了衡量一段程式碼的執行效率對於我們開發工程師是至關重要的,而複雜度便是衡量程式碼執行效率的重要度量因素。

  在展開講複雜度之前,我們先看一下複雜度和計算機實際任務處理效率的關係。

  計算機通過一個個程式去執行計算任務,也就是對輸入資料進行加工處理,並最終得到結果的過程。每個程式都是由程式碼構成的。可見,編寫程式碼的核心就是要完成計算。但對於同一個計算任務,不同計算方法得到結果的過程複雜程度是不一樣的,這對你實際的任務處理效率就有了非常大的影響。

  舉個例子,你要在一個線上系統中實時處理資料。假設這個系統平均每分鐘會新增 300M 的資料量。如果你的程式碼不能在 1 分鐘內完成對這 300M 資料的處理,那麼這個系統就會發生時間爆炸和空間爆炸。表現就是,電腦執行越來越慢,直到當機。因此,我們需要講究合理的計算方法,去通過儘可能低複雜程度的程式碼完成計算任務。

 

  我們可以想一下,一段程式碼在執行的過程中消耗的資源無非就是計算時間和計算空間,消耗時間過長或者消耗空間過大都會導致複雜度的過高,因而導致程式碼效率低下,所以衡量複雜度的維度便是時間複雜度和空間複雜度。

 

  在這裡列舉一個生活的小例子,某居民居住聚集區A到工作聚集區B僅僅有一個小路可以通行,每天早高峰的時候,這條小路就會交通阻塞,這樣就大大消耗了大家的時間,但是後來在A,B之間又新建了兩條道路通行,交通堵塞就再也沒有發生,因為新建道路的存在,等於消耗了空間資源,來換取了時間資源。

 

  不管是空間還是時間,它們的消耗量都與輸入的資料量高度相關,輸入資料少消耗自然就少,但是資料量卻是我們沒法控制的,所以為了更客觀的衡量消耗程度,我們通常會關注時間或者空間消耗量與輸入資料量之間的關係。那麼問題來了,我們應該如何去計算複雜度呢?

  複雜度是一個關於輸入資料量 n 的函式。假設你的程式碼複雜度是 f(n),那麼就用個大寫字母 O 和括號,把 f(n) 括起來就可以了,即 O(f(n))。例如,O(n) 表示的是,複雜度與計算例項的個數 n 線性相關;O(logn) 表示的是,複雜度與計算例項的個數 n 對數相關

  通常,複雜度的計算方法遵循以下幾個原則:

  (1)首先,複雜度與具體的常係數無關,例如 O(n) 和 O(2n) 表示的是同樣的複雜度。我們詳細分析下,O(2n) 等於 O(n+n),也等於 O(n) + O(n)。也就是說,一段 O(n) 複雜度的程式碼只是先後執行兩遍 O(n),其複雜度是一致的。

  (2)其次,多項式級的複雜度相加的時候,選擇高者作為結果,例如 O(n²)+O(n) 和 O(n²) 表示的是同樣的複雜度。具體分析一下就是,O(n²)+O(n) = O(n²+n)。隨著 n 越來越大,二階多項式的變化率是要比一階多項式更大的。因此,只需要通過更大變化率的二階多項式來表徵複雜度就可以了。

  (3)O(1) 也是表示一個特殊複雜度,含義為某個任務通過有限可數的資源即可完成。此處有限可數的具體意義是,與輸入資料量 n 無關。例如,你的程式碼處理 10 條資料需要消耗 5 個單位的時間資源,3 個單位的空間資源。處理 1000 條資料,還是隻需要消耗 5 個單位的時間資源,3 個單位的空間資源。那麼就能發現資源消耗與輸入資料量無關,就是 O(1) 的複雜度。

  接下來我們通過一個小例子來闡述不同計算方法對複雜度的影響

  問題:輸入一個陣列,輸出它的逆序陣列,比如輸入a[] = {1,2,3,4,5},則輸出b[]={5,4,3,2,1}。

 

 

 方法一:

public void testOne() {
    int a[] = { 1, 2, 3, 4, 5 };
    int b[] = new int[a.length];
    for (int i = 0; i < a.length; i++) {

        b[i] = a[i];

    }
    for (int i = 0; i < a.length; i++) {

        b[a.length - 1 - i] = a[i];

    }
    System.out.println(Arrays.toString(b));
}

  這段程式碼的輸入資料是 a,資料量就等於陣列 a 的長度。程式碼中有兩個 for 迴圈,作用分別是給b 陣列初始化和賦值,其執行次數都與輸入資料量相等。因此,程式碼的時間複雜度就是 O(n)+O(n),也就是 O(n)。

  空間方面主要體現在計算過程中,對於儲存資源的消耗情況。上面這段程式碼中,我們定義了一個新的陣列 b,它與輸入陣列 a 的長度相等。因此,空間複雜度就是 O(n)。

  方法二:

public void testTwo() {
    int a[] = { 1, 2, 3, 4, 5 };
    int tmp = 0;
    for (int i = 0; i < (a.length / 2); i++) {

        tmp = a[i];

        a[i] = a[a.length - 1 - i];

        a[a.length - 1 - i] = tmp;

    }
        System.out.println(Arrays.toString(a));

}

  這段程式碼包含了一個 for 迴圈,執行的次數是陣列長度的一半,時間複雜度變成了 O(n/2)。根據複雜度與具體的常係數無關的性質,這段程式碼的時間複雜度也就是 O(n)。

  空間方面,我們定義了一個 tmp 變數,它與陣列長度無關。也就是說,輸入是 5 個元素的陣列,需要一個 tmp 變數;輸入是 50 個元素的陣列,依然只需要一個 tmp 變數。因此,空間複雜度與輸入陣列長度無關,即 O(1)。

  可見,對於同一個問題,採用不同的編碼方法,對時間和空間的消耗是有可能不一樣的。因此,我們在寫程式碼的時候,一方面要完成任務目標;另一方面,也需要考慮時間複雜度和空間複雜度,以求用盡可能少的時間損耗和儘可能少的空間損耗去完成任務。

 

時間複雜度與程式碼結構的關係

  從本質來看,時間複雜度與程式碼的結構有著非常緊密的關係;而空間複雜度與資料結構的設計有關。接下來我先來系統地講一下時間複雜度和程式碼結構的關係。

程式碼的時間複雜度,與程式碼的結構有非常強的關係,我們一起來看一些具體的例子。

  例 1,定義了一個陣列 a = [1, 4, 3],查詢陣列 a 中的最大值,程式碼如下:

public void s1_3() {

    int a[] = { 1, 4, 3 };

    int max_val = -1;

    for (int i = 0; i < a.length; i++) {

        if (a[i] > max_val) {

            max_val = a[i];

        }

    }

    System.out.println(max_val);

}

  這個例子比較簡單,實現方法就是,暫存當前最大值並把所有元素遍歷一遍即可。因為程式碼的結構上需要使用一個 for 迴圈,對陣列所有元素處理一遍,所以時間複雜度為 O(n)。

  例2,下面的程式碼定義了一個陣列 a = [1, 3, 4, 3, 4, 1, 3],並會在這個陣列中查詢出現次數最多的那個數字:

public void s1_4() {

    int a[] = { 1, 3, 4, 3, 4, 1, 3 };

    int val_max = -1;

    int time_max = 0;

    int time_tmp = 0;

    for (int i = 0; i < a.length; i++) {

        time_tmp = 0;

        for (int j = 0; j < a.length; j++) {

            if (a[i] == a[j]) {

            time_tmp += 1;

        }

        if (time_tmp > time_max) {

            time_max = time_tmp;

            val_max = a[i];

        }

        }

    }

    System.out.println(val_max);

}

  這段程式碼中,我們採用了雙層迴圈的方式計算:第一層迴圈,我們對陣列中的每個元素進行遍歷;第二層迴圈,對於每個元素計算出現的次數,並且通過當前元素次數 time_tmp 和全域性最大次數變數 time_max 的大小關係,持續儲存出現次數最多的那個元素及其出現次數。由於是雙層迴圈,這段程式碼在時間方面的消耗就是 n*n 的複雜度,也就是 O(n²)。

綜上,我們總結出一些經驗性的結論:

1.一個順序結構的程式碼,時間複雜度是 O(1)。

2.二分查詢,或者更通用地說是採用分而治之的二分策略,時間複雜度都是 O(logn)。

3.一個簡單的 for 迴圈,時間複雜度是 O(n)。

4.兩個順序執行的 for 迴圈,時間複雜度是 O(n)+O(n)=O(2n),其實也是 O(n)。

5.兩個巢狀的 for 迴圈,時間複雜度是 O(n²)。

 

降低時間複雜度的必要性

  很多新手的程式設計師,對降低時間複雜度並沒有那麼強的意識。這主要是在學校或者實驗室中,參加的課程作業或者科研專案,普遍都不是實時的、線上的工程環境。

  實際的線上環境中,使用者的訪問請求可以看作一個流式資料。假設這個資料流中,每個訪問的平均時間間隔是 t。如果你的程式碼無法在 t 時間內處理完單次的訪問請求,那麼這個系統就會一波未平一波又起,最終被大量積壓的任務給壓垮。這就要求程式設計師必須通過優化程式碼、優化資料結構,來降低時間複雜度。

 

  為了更好理解,我們來看一些客觀資料。

  假設某個計算任務需要處理 10 萬 條資料,你編寫的程式碼複雜度不同計算次數也會大相徑庭:

  如果是 O(n²) 的時間複雜度,那麼計算的次數就大概是 100 億次左右。

  如果是 O(n),那麼計算的次數就是 10 萬 次左右。

  如果你再厲害一些,能在 O(log n) 的複雜度下完成任務,那麼計算的次數就是 17 次左右(log 100000 = 16.61,計算機通常是二分法,這裡的對數可以以 2 為底去估計)。

  數字是不是一下子變得很懸殊?通常在小資料集上,時間複雜度的降低在絕對處理時間上沒有太多體現。但在當今的大資料環境下,時間複雜度的優化將會帶來巨大的系統收益。而這是優秀程式設計師必須具備的工程開發基本意識。

 

文章知識點來源於自購課程《重學資料結構與演算法》,在這裡做一個整理總結分享給大家。

工作中忙於開發,幾乎沒考慮過程式碼效率,所以準備把演算法和資料結構一點一點的拾起,不要讓自己變成一個盲目開發的程式猿。

敬請期待接下來會更新的部落格--《回爐重造12時辰--程式碼效率優化方法論(二)》,關於將時間複雜度轉化為空間複雜度的介紹。

我是帝莘,希望能在部落格園和你進行技術交流和思想交流!

相關文章