數據結構與算法 #002_複雜度分析,提升代碼效率的前提

❤️閱文後請用一句話總結您的心得和建議!❤️

1、代碼效率的影響

  • 如果這個效率低下的系統是離線的,那麼它會讓我們的開發週期、測試周期變得很長。
  • 如果這個效率低下的系統是在線的,那麼它隨時具有時間爆炸或者內存爆炸的可能性。

2、代碼效率的度量

  • 複雜度是衡量代碼運行效率的重要的度量因素。

  • 降低複雜度的必要性:

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

    • 問題一:這段代碼消耗的資源是什麼?

    • 回答:一般而言,代碼執行過程中會消耗計算時間和計算空間,那需要衡量的就是時間複雜度和空間複雜度。

    • 舉例:某個十字路口沒有建立立交橋時,所有車輛通過紅綠燈分批次行駛通過。當大量汽車同時過路口的時候,就會分別消耗大家的時間。但建了立交橋之後,所有車輛都可以同時通過了,因爲立交橋的存在,等於是消耗了空間資源,來換取了時間資源。

    • 問題二:這段代碼對於資源的消耗是多少?

    • 回答:我們不會關注這段代碼對於資源消耗的絕對量,因爲不管是時間還是空間,它們的消耗程度都與輸入的數據量高度相關,輸入數據少時消耗自然就少。爲了更客觀地衡量消耗程度,我們通常會關注時間或者空間消耗量與輸入數據量之間的關係。

3、複雜度的定義

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

  • 遵循的原則:

    • 複雜度與具體的常係數無關,例如 O(n) 和 O(2n) 表示的是同樣的複雜度。我們詳細分析下,O(2n) 等於 O(n+n),也等於 O(n) + O(n)。也就是說,一段 O(n) 複雜度的代碼只是先後執行兩遍 O(n),其複雜度是一致的。
    • 多項式級的複雜度相加的時候,選擇高者作爲結果,例如 O(n²)+O(n) 和 O(n²) 表示的是同樣的複雜度。具體分析一下就是,O(n²)+O(n) = O(n²+n)。隨着 n 越來越大,二階多項式的變化率是要比一階多項式更大的。因此,只需要通過更大變化率的二階多項式來表徵複雜度就可以了。
  • 注意:O(1) 也是表示一個特殊複雜度,含義爲某個任務通過有限可數的資源即可完成。此處有限可數的具體意義是,與輸入數據量 n 無關。

    • 舉例:你的代碼處理 10 條數據需要消耗 5 個單位的時間資源,3 個單位的空間資源。處理 1000 條數據,還是只需要消耗 5 個單位的時間資源,3 個單位的空間資源。那麼就能發現資源消耗與輸入數據量無關,就是 O(1) 的複雜度。
  • 實例分析:對於輸入的數組,輸出與之逆序的數組。例如,輸入 a=[1,2,3,4,5],輸出 [5,4,3,2,1]。

    • 方法一:建立並初始化數組 b,得到一個與輸入數組等長的全零數組。通過一個 for 循環,從左到右將 a 數組的元素,從右到左地賦值到 b 數組中,最後輸出數組 b 得到結果。

      public void s1_1() {
      	int a[] = { 1, 2, 3, 4, 5 };
      	int b[] = new int[5];
      	for (int i = 0; i < a.length; i++) {
      		b[i] = a[i];
      	}
      	for (int i = 0; i < a.length; i++) {
      		b[a.length - i - 1] = a[i];
      	}
      	System.out.println(Arrays.toString(b));
      }
      
    • 方法一分析:這段代碼的輸入數據是 a,數據量就等於數組 a 的長度。代碼中有兩個 for 循環,作用分別是給b 數組初始化和賦值,其執行次數都與輸入數據量相等。因此,代碼的時間複雜度就是 O(n)+O(n),也就是 O(n)。

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

    • 方法二:定義了緩存變量 tmp,接着通過一個 for 循環,從 0 遍歷到a 數組長度的一半(即 len(a)/2)。每次遍歷執行的是什麼內容?就是交換首尾對應的元素。最後打印數組 a,得到結果。

      public void s1_2() {
         	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 - i - 1];
         		a[a.length - i - 1] = tmp;
         	}
         	System.out.println(Arrays.toString(a));
      }
      
    • 方法二分析:這段代碼包含了一個 for 循環,執行的次數是數組長度的一半,時間複雜度變成了 O(n/2)。根據複雜度與具體的常係數無關的性質,這段代碼的時間複雜度也就是 O(n)。

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

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

4、複雜度與代碼結構的關係

  • 代碼的時間複雜度,與代碼的結構有非常強的關係

  • 舉例1:定義了一個數組 a = [1, 4, 3],查找數組 a 中的最大值

    public void s1_3() {
      	int a[] = { 1, 4, 3 };
      	int max_val = -1;
      	int max_inx = -1;
      	for (int i = 0; i < a.length; i++) {
      		if (a[i] > max_val) {
      			max_val = a[i];
      			max_inx = 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];
      		}
      	}
      }
      Sysem.out.println(val_max);
    }
    
  • 分析:這段代碼中,我們採用了雙層循環的方式計算:第一層循環,我們對數組中的每個元素進行遍歷;第二層循環,對於每個元素計算出現的次數,並且通過當前元素次數 time_tmp 和全局最大次數變量 time_max 的大小關係,持續保存出現次數最多的那個元素及其出現次數。由於是雙層循環,這段代碼在時間方面的消耗就是 n*n 的複雜度,也就是 O(n²)。

  • (經驗性)結論:

    • 一個順序結構的代碼,時間複雜度是 O(1)。
    • 二分查找,或者更通用地說是採用分而治之的二分策略,時間複雜度都是 O(logn)。
    • 一個簡單的 for 循環,時間複雜度是 O(n)。
    • 兩個順序執行的 for 循環,時間複雜度是 O(n)+O(n)=O(2n),其實也是 O(n)。
    • 兩個嵌套的 for 循環,時間複雜度是 O(n²)。

5、降低複雜度的必要性

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

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

  • 舉例:某個計算任務需要處理 10萬 條數據,此時如果代碼複雜度:

    • 是 O(n²) 的時間複雜度,那麼計算的次數就大概是 100 億次左右。
    • 是 O(n),那麼計算的次數就是 10萬 次左右。
    • 是 O(log n) 的複雜度下完成任務(厲害的工程師.),那麼計算的次數就是 17 次左右(log 100000 = 16.61,計算機通常是二分法,這裏的對數可以以 2 爲底去估計)。
  • 結論:代碼複雜度的區別使得計算量變得很懸殊。通常在小數據集上,時間複雜度的降低在絕對處理時間上沒有太多體現。但在當今的大數據環境下,時間複雜度的優化將會帶來巨大的系統收益。而這是優秀工程師必須具備的工程開發基本意識。

6、總結

  • 複雜度通常包括時間複雜度和空間複雜度。在具體計算複雜度時需要注意以下幾點:
    • 它與具體的常係數無關,O(n) 和 O(2n) 表示的是同樣的複雜度。
      複雜度相加的時候,選擇高者作爲結果,也就是說 O(n²)+O(n) 和 O(n²) 表示的是同樣的複雜度。
    • O(1) 也是表示一個特殊複雜度,即任務與算例個數 n 無關。
    • 複雜度細分爲時間複雜度和空間複雜度,其中時間複雜度與代碼的結構設計高度相關;
    • 空間複雜度與代碼中數據結構的選擇高度相關。
  • 會計算一段代碼的時間複雜度和空間複雜度,是工程師的基本功。這項技能你在實際工作中一定會用到,甚至在參加互聯網公司面試的時候,也是面試中的必考內容

內容參考課程《重學數據結構與算法》

❤️閱文後請用一句話總結您的心得和建議!❤️

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章