萬字通俗講解何為複雜度

華為雲開發者社群發表於2022-02-23
摘要:複雜度分析主要就是時間複雜度和空間複雜度。

本文分享自華為雲社群《用通俗的語言講解複雜度》,作者: 龍哥手記 。

複雜度分析

剛剛我說過,在俺看來,複雜度分析是資料結構和演算法中最重要的知識點,當然學這篇只是把門找到,反之,學不會它,你就永遠找不到火門。

為什麼複雜度分析會這麼重要?

這個要從宇宙大爆炸,呃,從資料結構與演算法的本身說起。

我平常白天做夢的時候,總想著噹噹鹹魚,最好能帶薪拉屎就能賺大錢那種,資料結構與演算法雖然沒有俺這麼高大尚的夢想,但是它的出現也是跟我一樣總想在更少的時間及更少的儲存來提高效率唄。

可以從哪些方面來入手呢鐵子們,CPU 與 RAM 的消耗時間啊,通訊的頻寬時間啊,指令的數量啊,這麼多,我不學了不學,沒事呀,我們可以總結一套模型在理論上針對不同演算法的情況得出對應標準,複雜度這不就來了,你可以總結下,關於輸入資料量n的函式,是吧。

搞清楚為什麼,怎麼定義

那咋去度量“更少的時間和更少的儲存”,複雜度分析由此而生。

打個不恰當的比方

如果把資料結構與演算法看做武功招式,那複雜度分析就是對應的心法。

如果只是學會了資料結構與演算法的用法,不學複雜度分析,這就和你費盡千辛萬苦在隔壁老王家次臥進門右手地磚下偷挖出老王答辯村裡無敵手的村林至寶王霸拳,然鵝發現祕籍上只有招式,你卻沒學會隱藏的心法口訣,好好得王霸拳變的王八拳。只有學會了王霸之氣,才能虎軀一震,王霸之氣一瞨,震走村口光棍李養的哈巴狗。

鐵汁:哇,厲害厲害厲害,你胖你說都對,但還是沒必要學啊。


小希:???
鐵汁:現在很多網站啊包啊,程式碼隨便跑一下,就能輕輕鬆鬆知道多少時間佔了多少記憶體啊,演算法的效率不就輕鬆對比出來了麼?
小希:。。。。

two羊吐森破,吃葡萄不吐葡萄皮!

你們說的這種主流叫做事後分析法

簡單來說,就是你需要提前寫好演算法程式碼和偏好測試資料,然後在計算機上跑,通過最後得出的執行時間判斷演算法時效的高低水平,這裡的執行時間就是我們日常的時間。

我且不用 “萬一你費勁心思寫好的演算法程式碼本身是個很糟糕的寫法” 這種理由反駁你,事後統計法本身存在缺陷,它並不是一個對我們來說有用的度量指標:

首先,事後統計法太依賴計算機的軟體和硬體等效能。程式碼在 core i7處理器 的就比 core i5處理器的運算速度快,更不用說不同的作業系統,不同的程式語言等軟體方面,就算在同一臺電腦上,前面的條件都滿足,當時的記憶體或者 CPU 的使用率也會造成執行時的差異。

舉個例子,考察對全國人口普查資料的排序 $n=10^9$,使用氣泡排序$(10^9)^2$

對於普通電腦(1GHz $10^9$ flops)來說,大約需要$10^9$秒(30年)。

對於天河1號超級計算機(千萬億次 = 1P, $ 10^15$ flops),大約需要$10^3$秒(20分鐘)。

再者,事後統計法太依賴測試資料集的規模。同樣是排序演算法,你隨便整 5 個 10 個數排序,就算最垃圾的排序,也看起來很快跟火箭一樣,不好意思,那 10w個 100w個,那這些演算法的差距就很大了,而且同樣是 10w 個 100w 個數,順序和亂序的所花費時間也不等。

那問題來了,到底測試資料集選多少才合適?資料的順序如何訂好多才行?

說不出來了叭?

可以看出,我們需要一個不依賴效能和規模等外力影響就可以估算演算法效率,判斷演算法優劣的度量指標,而複雜度分析天生就是幹這個的,為了能自己分析,所以你必須理解要掌握

時間複雜度

演算法的執行時間

對於某一問題的不同解決演算法。執行時間越短演算法效率越高,相反,執行時間越長,演算法效率越低。

那麼如何估計演算法複雜度?

所有人撤退,我們很熟悉的大 O 閃亮登場!

大佬們甩掉腦闊上最後一根秀髮的才發現,當用執行時間去描述一個演算法快慢的時候,演算法中執行的總步數顯得尤為重要

因為這只是估算,我們假設每一行程式碼的執行時間都為 Btime(一個位元時間),那麼演算法的總的執行時間 = 執行的總程式碼行數。

下面我們看一段簡單的程式碼。

程式碼1

//python

def longgege_sum (m);
      sum = 0;
      
      for longgege in range(m);
            sum += longgege
        return sum;    

在上面假設的情況下,這段求累加和的程式碼總的執行時間是多少呢?

第二行程式碼需要 1 Btime 的執行時間,第 4 行和第 5 行分別執行了 m 次, 所以每個需要 mBtime 的執行時間,所以總的執行時間就是(1 + 2m)Btime。

若我們S 函式來表示賦值語句的總執行時間,所以上面的時間可表達成 S(m)=(1 + 2m)*Btime, 說人話就是"資料集大小”為 m, 總的步數為 (1+2m) 的演算法執行時間為 S(m)"。

上面的公式得出,S(m) 和總步數是成正比關係,這個規律很重要的,告訴了一個易懂的趨勢,資料規模和執行時間之間有趨勢!

可能你對資料規模還是沒有概念

類比下

對於我們現在的家用計算機,如果想在1s之內解決問題:
O($n^2$) 的演算法可以處理大約 $10^4$ 級別的資料
O(n) 的演算法可以處理大約 $10^8$ 級別的資料
O(nlogn) 的演算法可以處理大約 $10^7$ 級別的資料

大 O 表示法

什麼是大O

很多鐵子說時間複雜度的時候你都知道O(n), O($n^2$), 但是說不清什麼是大O

演算法導論給出的解釋:大O用來表示上界的,上界意思說對任意資料輸入的演算法最壞情況或叫最長執行時間。

拿插入排序來講,它的時間複雜度我們都說是 O(n^2), 假如資料本來有序的情況下時間複雜度是 O(n),也就對於所有輸入情況來說,最壞是 O(n^2) 的時間複雜度,所以稱插入排序的時間複雜度為O(n^2)。

就是想告訴你,同一個演算法的時間複雜度不是一成不變的,和輸入的資料形式依然有關係,資料用例不一樣,時間複雜度也是不同的,這個要銘記住。還有平時面試官和我們探討一個演算法的實現以及效能,指的通常是理論情況下的時間複雜度。


鐵子:我讀書少,你可別騙我,你這還是有些抽象啊,而且你這適用所有的演算法嗎,未必吧


小希:你很有精神,你知道前 n 項和怎麼算不,


鐵子:這個我們初中老師講過的,等差可以算


小希:你就看好下面,這段碼

舉栗子

//計算1+2+3+....+n的和
int sum=0

for(int i=1; i<=n; i++){
   sum+=i
}

可以看到迴圈了 n 次吧,所以時間複雜度就是O(n), 即時間複雜度就是本程式計算的次數。

假如我們自己修改後的執行次數函式中,我們只去保留最高階項

如果最高階存在且不是 1,則去除與這個項相乘的常數,是如下表示式:

2n^2+3n+1 ->n^2

所以時間複雜度為 $n^2$
你可能疑問,為啥會去掉這些值,請看下圖

當計算量隨著次數原來越大的時候,n 和 1 的區別不是太大,而 $n^2$曲線 變得越來越大,所以這也是 2$n^2$+3n+1 -> $n^2$ 最後會估量成 $n^2$ 的原因,因為 3n+1 隨著計算次數變大,基本可以忽略不計。

鐵子:就這,你來個稍微複雜點的吧

安排

//利用sums方法求三部分和
//java
public static int sums(int n) {

int num1 = 0;
for(int longege1 = 1; longege1 <= n; longege1++){
            num1=num1+longege1;
}

int num2 = 0;
for(int longege2 = 1; longege2 <= n; longege2++){
     for(int i = 0; i <= n; i++) {
            num2=num2+longege2;
     }
}

int num3 = 0;
for(int longege3 = 1; longege3 <= n; longege3++){
     for(int i = 0; i <= n; i++) {
        for(int j = 0; j <= n; j++) {
            num3=num3+longege3;
        }
     }
}

return num1 + num2 +num3;
}

上面這段是求三部分的和,經過之前的學習應該很容易知道,第一部分的時間複雜度是 O(n), 第二部分的時間複雜度是 O($n^2$), 第三部分是O($n^3$)。

正常來講,這段程式碼的S(n)=O(n)+O($n^2$)+O($n^3$),按照我們按“主導”部分,顯然前面兩個兄弟都直接帕斯掉,最終S(n)=O($n^2$)。

通過這幾個例子,聰明的鐵子們坑定會發現,對於時間複雜度分析來說,只要找出“主導”作用的部分程式碼即可,這個主導就是最高的那個複雜度,也就是執行次數最多的那部分 n 的量級

剩下的就是多加練習,有意識的多去練多去想,就可以和我一樣 帥氣穩啦。

好吧

我來介紹幾種爹,不是,幾種階~

常數階 O(1)
function test($n){
    echo $n;
    echo $n;
    echo $n;
}

沒有迴圈的,不管 $n 是多少,它只執行 3 次,那麼時間複雜度就是O(3),取為O(1)

線性階 O(n)

for($i=1;$i<=$n;$i++){
    $sum+=$i
}

熟悉的平(立)方階:o($n^2$)/o($n^3$)

$sum=0;
for($i=1;$i<=$n;$i++){
    for($j=1;$j<$n;$j++){
    $sum+=$j
    }
}

兩次迴圈,裡面迴圈執行了 n 次,外層迴圈也執行了 n 次,所以時間複雜度為O(n^2),立方階一樣

特殊平方階:O($n^2$/2+n/2)->O($n^2$)

for(){
    for(){
    .....            ----------->n^2
    }
}
                                  +
for(){
                     ------------> n
}

                                  +
                                 
echo $a+$b         --------------> 1

所以整體上計算次數為 n^2+n+1,我們算時間複雜度為O(n^2)

對數階:O(log2n)

int longege = 1 

while(longege < m) {
    longege = longege * 2;
}

還是根據之前講的,我們先找“主導”,在裡面主導就是最後一行,只要算出它的時間複雜度,這段的時間複雜度就知道了。

這段說人話就是,乘多少個 2 就會 >= m?

假設需要 y 個,那麼相當於求:

<center>$2^y=m$</center>

<center>y=log2m</center>

所以上述程式碼的時間複雜度應該為O(log2m)。

但是這種對數複雜度來說,不管你是以2, 3為底,還是以 20 為底,通通記作(logn)

這就要從對數的換底公式說起。

除了資料集規模會影響演算法的執行時間外,“資料的具體情況”也會影響執行時間。

我們來看這麼一段程式碼:

public static int find_word(int[] arr, String word) {

                int flag = -1;
                for(int i = 0; i <= arr.length; i++) {
                        if(arr[i] == word) {
                              flag = i;
                        }
                        
                        break;
                }
                
                return flag;
} 

上面這段簡單程式碼是要求字元變數 word 在陣列 arr 中出現的位置,我用這段來解釋“資料的具體情況”是什麼意思。

變數 word 可能出現在陣列 arr 的任意位置,假設 a=['a', 'b', 'c', 'd']:

  • 當 word = 'a', 正好是列表中的第 1 個,後面的不需要在遍歷,那麼本情況下的時間複雜度是O(1)。
  • 當 word = 'd' 或者 word='e', 這兩種情況是整個列表全部遍歷完,那麼這些情況下的時間複雜度是O(n)。

根據不同情況,我們有了最好情況時間複雜度,最壞情況時間複雜度和平均情況時間複雜度這三個概念。

下面看一段程式碼,我們分別從最好和最壞的情況下去分析其時間複雜度。

// n 表示陣列 array 的長度
int find (int[] array, int n, int x) {
    int i = 0;
    int pos = -1;
    for (int i=0; i < n; i++) {
        if (array[i] == x) {
            pos = i;
        }
    }
    
    return pos;
}

這段程式碼的功能是,在一個無序的陣列中,查詢變數 x 出現的位置。如果沒有找到就返回 -1。籠統的分析一下:核心程式碼執行了 n 次,所以其時間複雜度是 O(n),其中,n 代表陣列的長度。

程式碼簡單優化一下:可以在中途找到符合條件的元素時,提前結束迴圈。

// n 表示陣列 array 的長度
int find (int[] array, int n, int x) {
    int i = 0;
    int pos = -1;
    for (; i < n; i++) {
        if (array[i] == x) {
            pos = i;
            break;
        }
    }
    return pos;
}

優化完之後的程式碼就不能簡單粗暴的說其時間複雜度就是 O(n)了。因為,遍歷可能在陣列下標為 0 ~ n-1 中的任何一個下標所指向的元素中結束迴圈,是不是很神奇

繼續往下分析:

  1. 假設,陣列中第一個元素正好是要查詢的變數 x ,顯而易見那時間複雜度就是 O(1)。
  2. 假設,陣列中不存在變數 x ,那我們就需要把整個陣列遍歷一遍,時間複雜度就成了 O(n)。

所以,在不同的情況下,這段程式碼的時間複雜度是不同的。

等我踹口氣

嗯~

還有個問題

這幾個概念從我相信你從字面意思也能理解,最好時間複雜度就是,在最理想的情況下,執行這段程式碼的時間複雜度。對應假設1。時間複雜度就是 O(1)。

同理,最壞時間複雜度就是,在最糟糕的情況下,執行這段程式碼的時間複雜度。對應假設2。時間複雜度就是O(n)。

平均時間複雜度

首先先來說它,又叫“加權平均時間複雜度”,為什麼叫加權呢?是不是很奇怪,因為,通常計算平均時間複雜度,需要把概率考慮進去,也就是,我們計算平均時間複雜度的時候,需要一個“加權值”,來真正的計算平均時間複雜度。

我們程式碼整個例子,對平均時間複雜度進行分析:

// n 表示陣列 array 的長度
int find(int[] array, int n, int x) {

  int i = 0;
  int pos = -1;
  for (; i < n; ++i) {
    if (array[i] == x) {
       pos = I;
       break;
    }
  }
  
  return pos;
}

程式碼很簡單,表示在一個陣列中,要找到 x 這個數,最好複雜度是 O(1), 最壞是 O(n)。

那平均複雜度是怎麼計算呢?

先說簡單的平均值計算公式:

上面的程式碼,所有查詢 x 的時間相加:1 + 2 + 3 +······ + n + n (這個 n 表示當 x 不存在時遍歷 array 需要的次數) , 另外,需要查詢的次數為 n + 1 次,那麼結果就是:
8

代入大 O 表示式,結果是 O(n)。

這個公式表達的是:計算所有可能出現的情況之和,然後除以可能出現的情況的次數。說白了,這是一個絕對平均的結果。表示每個結果都可能出現 n + 1 次。

這是一個較為粗暴的假設。

如果稍微使用一個簡單的概率來計算呢?

這裡有 2 個概率:

x 變數是否在陣列中的概率,有 2 種情況—— 在與不在,所以,他的概率是 1/2.
x 變數出現在陣列的概率,有n 種情況,但只會出現一次,所以是 1/n.
我們把兩個概率進行相乘,結果是 1/(2n). 這個數,就是“加權值”。

如何使用加權值對上面程式碼的“複雜度”進行計算?

然後,我們在這公式上把 (n + 1)換成“權重”:也就是 1/2n。
9

結果為 3n + 1 / 4 。這個也就是“加權後的平均時間複雜度”,表示,執行了 1 + 2 + ···· + n + n 次的“加權平均值”。

如果使用大 O 表示法,去除係數,常數,低階,那麼他的最終結果就是 O(n)。

可以看到,前者使用的是分母沒有做任何權重的措施,僅僅是簡單的 n + 1,而後者,我們做了簡單的權重計算,認為出現的概率不是 n + 1,而是 1/2n。

可以說,加權值,是為了在前者的基礎上,,目的是更加的準確。也就是說,要計算準確的平均時間複雜度,就需要準確的計算這個“權重值”,而權重值會受到資料範圍,資料種類影響。因此需要在實際操作中,進行調參。

簡單來說,就拿 “x 變數是否在陣列中的概率” 這個值來說,不一定是 1/2 ,如果有這樣一組資料{y, s, f, f, g, x, g, h}, 那麼,他的概率還是 1/2嗎,實際上只有 1/8,所以,還是得根據實際情況來。

均攤時間複雜度

啊這,聽起來和平均時間複雜度有點熟悉呢。但是均攤時間複雜度的應用場景比平均時間複雜度更加特殊、更加有限。下面看一段程式碼:

//array 表示一個長度為 n 的陣列
//程式碼中的 array.length 就等於 n

static int[] array = new int[]{1, 2, 3, 4, 5};
static int count = 2;    
public static void insert(int val) {

      // 陣列沒有空閒空間的情況
      if (count == array.length) {
          int sum = 0;
          for (int i = 0; i < array.length; i++) {
              sum = sum + array[i];
        }
        array[0] = sum;
        count = 1;
        System.out.println("array.length:::" + array.length + "sum:" + sum);
    }
    
        // 陣列有空閒空間的情況
        array[count] = val;
        count++;
          System.out.println("count!=array.length:" + array.length + ",,,count::" + count);
    for (int i = 0; i < array.length; i++) {
    
        System.out.println("array[" + i + "] = " + array[i]);
    }
}

上面實現了向一個陣列中插入資料的功能。當陣列滿了之後,也就是程式碼中的 count == array.length 時,我們用 for 迴圈遍歷陣列求和,並清空陣列,將求和之後的 sum 值放到陣列的第一個位置,然後再將新的資料插入。但如果陣列一開始就有空閒空間,則直接將陣列插入陣列。

現在分析一下這段程式碼的時間複雜度,最理想的情況下,陣列中有空閒空間,我們只需要將資料插入到下標為 count 的位置就可以了,所以最好情況時間複雜度為 O(1)。最壞的情況下,陣列中沒有空閒空間了,我們需要先做一次陣列的遍歷求和,然後再將資料插入,所以最壞情況時間複雜度為 O(n)。

接著我們按上面的方法分析一下平均時間複雜度。假設陣列的長度是 n ,根據插入資料的位置不同,我們可以分為 n 種情況,每種情況的時間複雜度是 O(1)。此外,還有一種 :“額外” 的情況,就是在陣列沒有空閒空間插入一個資料,這個時候的時間複雜度是 O(n)。而且,這 n + 1 中情況發生的概率是一樣的,都是 1/(n + 1)。

其實,這個例子裡的平均複雜度分析並不需要這麼複雜,不需要使用概率的知識。我們先來對比一下這個 insert() 的例子和上面的 find() 的例子,二者的區別如下:

  • 區別一:

首先,find() 函式在極端情況下,複雜度才為 O(1)。但 insert() 在大部分情況下,時間複雜度都為 O(1)。只有個別情況下,複雜度才比較高哦,為 O(n)。這是兩者間的第一個區別。

  • 區別二:

對於 insert() 函式來說, O(1)時間複雜度的插入和 O(n)時間複雜度的插入,出現的頻率是非常有規律的,而且有一定的前後時序關係,一般都是一個 O(n) 插入之後,緊跟著 n - 1 個 O(1) 的插入操作,迴圈往復而已。

所以,針對這樣一種特殊場景的複雜度分析,我們不需要像之前平均複雜度分析方法那樣,找出所有的輸入情況及相應的發生概率,然後再計算加權平均值,這種就沒必要

針對這種特殊的場景,我們引入一種更加簡單的分析方法:攤還分析法,通過攤還分析得到的時間複雜度叫做均攤時間複雜度。

那究竟如何使用攤還分析法來分析演算法的均攤時間複雜度呢?

繼續看這個 insert() 的例子。每一次 O(n) 的插入操作,都會跟著 n - 1次 O(1) 的插入操作,所以把耗時多的那次操作均攤到接下來的 n - 1 次耗時少的操作上,均攤下來,這一組連續的操作的均攤時間複雜度就是 O(1)。這就是均攤分析的大致思路,可以吧?

對一個資料結構進行連續操作中,大部分情況下時間複雜度都很低,只有個別情況下時間複雜度比較高,而且這些操作之間存在前後連貫的時序關係,這個時候我們就可以把這一組操作放在一起來分析,看是否能將較高時間複雜度那次操作的耗時,平攤到其他那些時間複雜度比較低的操作上。 而且,在能夠應用均攤時間複雜度分析的場合,一般均攤時間複雜度就約等於最好情況時間複雜度。

總之,均攤是平均時間複雜度的極限情況

均攤時間複雜度可能不是很好理解,尤其是與平均時間複雜度的區別。但是,我們可以將其理解為一種特殊的平均時間複雜度。最主要的還是應該掌握它的分析方法,這種思想而已。

之所以引入這幾個複雜度的概念,是因為,我們在同一段程式碼,在不同輸入的情況下,複雜度量級有可能是不一樣的。在引入這幾個概念之後,我們可以更加全面的表示一段程式碼的執行效率。

平時大家更多的關心是最好的時間複雜度,這沒錯,提高時間效率嗎沒錯

我覺得更關心最壞情況而不是最好情況,理由如下:

(1)最壞情況它能給出演算法執行時間的上界,這樣可以確信,無論給什麼輸入,演算法的執行時間都不會超過這個上界,這樣為比較和分析心裡有個底。

(2)最壞情況是種悲觀估計,但是對於很多問題,平均情況和最壞情況的時間複雜度相差不多,比如插入排序這個例子,平均情況和最壞情況的時間複雜度都是輸入長度n的二次函式。

空間複雜度

空間複雜度跟時間複雜度相比,你需要掌握的內容不多。
也是一樣,它也是描述的一種趨勢,只不過這趨勢是程式碼在執行過程中臨時變數佔用的記憶體空間

嗯?臨時嗎

就要從程式碼在計算機中的執行擺起啦。

程式碼在計算機中的執行的儲存佔用,主要 分成 3 部分

  • 程式碼本身所佔用的
  • 輸入資料所佔用的
  • 臨時變數所佔的

前兩個它自己就是要必須佔用空間,與程式碼的效能就沒關係,所以最後衡量程式碼的空間複雜度,只關心在執行過程中臨時佔用的記憶體空間。

怎麼算?

<center>R(n) = O(f(n))</center>

空間複雜度記作R(n), 表示形式與時間複雜度 S(n) 一致。

分析下

下面我們用一段簡單程式碼來理解下空間複雜度。

//python
def longege_ListSum(n):

      let = []
      for i in range(n):
                let.append(i)
          
      return let
      

上面這段中明顯有兩個臨時變數 let 和 i

let 是建了一個空列表,這個列表佔的記憶體會隨著 for 迴圈的增加而增加,最大到 n 結束,所有呢,let 的空間複雜度是 O(n), i 是存元素位置的常數階,和規模 n 已經沒有關係了,所以這段程式碼的空間複雜度為 O(n)。

我們再來看下 它

O($n^2$)

//java
public static void longege_ListSum(n) {

    int[] arr1 = new int[0];   
        for(int i=0; i<=n; i++) {
            int arr2 = new int[0];
            
            for(int j=0; j<=n; j++) {
                  arr1.apend(arr2);  
          }
  }
  
        return arr1;
}

還是一樣的分析方式,很明顯上面這的程式碼手動建立了一個二維陣列 arr1, 一維 佔用 $n^2$, 所以它就是這段程式碼的空間複雜度啦。

那二分法是多少

int select(int a[], int k, int len)
{
     int left = 0;
     int right = len - 1;
     while (left <= right)
     {
          int mid = left + ((right - left) >> 2);
          if (a[mid] == k)
          {
              return 1;
          }
          else if (a[mid] > k)
          {
              right = mid - 1;
          }
          else
          {
              left = mid + 1;
          }
     }
     return NULL;
}

程式碼中的 k、len、a[] 所分配的空間都不隨著處理資料量變化,因此它的空間複雜度 S(n) = O(1)

順便說下

15
在最壞的情況下迴圈x次後找到, n / ($2^x$)=1; x=log2n;它的時間複雜度就是log2n。

在斐波那契數求空間複雜度的過程中,需要去考慮函式棧幀的過程,比如當我們求第五個斐波那契數的時候,這時候需要先開闢空間存放第四個數,然後再開闢空間存放第三個數;當開闢空間到第二個和第一個數的時候,第三個數得到結果並返回到第四個數中,第四個數的值已知後返回到第五個數,過程裡面,最大佔用空間即為層數減一

開闢空間的大小最多等於層數+1,也就是說求第N個斐波那契數,空間複雜度即為O(N)。 

遞迴演算法的時間複雜度

1)一次遞迴呼叫,如果遞迴函式中,只進行一次遞迴呼叫,並且遞迴深度是 depth, 那麼每個遞迴函式,它的時間複雜度為 T, 則總體的時間複雜度為O(T * depth)。

比如說,二分搜尋法的遞迴深度是 logn(每次都是對半分),則複雜度是O(Tlogn)

總結個公式就是,遞迴的時間複雜度=遞迴總次數 * 每次遞迴的次數,空間複雜度=遞迴的深度(即樹的高度)

2)兩次遞迴呼叫(關注遞迴呼叫次數)
我們先看下面這個例子

int f(int n) {
         assert(n >= 0);
         
         if(n == 0)
             return 1;
          else
             return f(n-1) + f(n-1); 
}

怎樣才能知道它的遞迴次數呢,我們可以畫一棵遞迴樹,再把樹的節點數出來
16

可以看到,這是指數級的演算法,很慢很慢的!但這在我們的搜尋領域是有很大的意義的。

但是我們的高階排序演算法如,歸併,快排也是2次遞迴呼叫,但時間複雜度只有O(nlogn)級別,那是因為

1)上面的深度為n,而高階排序的深度都只有logn

2)在高階排序演算法中我們在每個節點處理的資料規模是元件縮小的。

斐波那契數列的優化

斐波那契數列迴圈演算法:
 
//時間複雜度:O(n)
//空間複雜度:O(1)
long long Fib(long N)
{
    long long  first = 1;
    long long second = 1;
    long long  ret = 0;
    int i = 3;
    for (; i <= N; ++i)
    {
        ret = first + second;
        first = second;
        second = ret;
    }
    return second;
}
int main()
{
    printf("%u\n",Fib(50));
    system("pause");
    return 0;
    
}
//斐波那契數列遞迴演算法:
//時間複雜度: O(2^n)
//空間複雜度:O(n)
long long Fib(long long N)
{
    return (N < 3) ? 1 : Fib(N - 1) + Fib(N - 2);
}
 
int main()
{
    printf("%u\n",Fib(1,1,50));
    system("pause");
    return 0;
}

注意:
斐波那契數列的時間複雜度為二叉樹的個數;
斐波那契數列的時間複雜度為函式呼叫棧的次數即二叉樹的深度。

//斐波那契尾遞迴演算法:(優化)
//時間複雜度:O(n)
//空間複雜度:O(n)
long long Fib(long long first,long long second,int N)
{
    if (N < 3 )
    {
        return 1;
    }
    if (N == 3)
    {
        return first + second;
    }
    return Fib(second,first+second,N-1); 
}

使用矩陣乘方的演算法再次優化斐波那契數列演算法。

 static int Fibonacci(int n)
      {
           if (n <= 1)
            return n;
 
      int[,] f = { { 1, 1 }, { 1, 0 } };
       Power(f, n - 1);
 
       return f[0, 0];
   }

    static void Power(int[,] f, int n)
    {
      if (n <= 1)
        return;
 
       int[,] m = { { 1, 1 }, { 1, 0 } };

     Power(f, n / 2);
       Multiply(f, f);

       if (n % 2 != 0)
        Multiply(f, m);
     }

   static void Multiply(int[,] f, int[,] m)
   {
      int x = f[0, 0] * m[0, 0] + f[0, 1] * m[1, 0];
     int y = f[0, 0] * m[0, 1] + f[0, 1] * m[1, 1];
       int z = f[1, 0] * m[0, 0] + f[1, 1] * m[1, 0];
       int w = f[1, 0] * m[0, 1] + f[1, 1] * m[1, 1];

      f[0, 0] = x;
       f[0, 1] = y;
       f[1, 0] = z;
     f[1, 1] = w;
     }
     

優化之後演算法複雜度為O(log2n)。

請背下來

演算法刷題過程中,我們會遇到各種的時間複雜度,但就算你程式碼變出花來,幾乎也逃不出下面幾種常見的時間複雜度。

壓箱底的圖

上表中的時間複雜度是由上往下依次增加,所以 O(1) 效率最高,O(n) 和 O($n^2$) 這兩個前面已經說過了,上表最後一個效率低的離譜,以後要是不幸碰到我了在提一下,就不細說。

可能有些朋友還是疑惑,現在計算機硬體效能越來越強了,我們為啥還這麼重視時間複雜度呢?

問的很好,我們可以用幾個栗子來對比下,看下之間它們有多大的差距。

有100個人站在你面前,你一眼就發現了你喜歡的她。

如果換成10000個,結果並沒有什麼區別。

這就是 O(1) 有100個人站在你面前,你得一個一個看過去,才能找到你的女神。無論你用什麼順序看,我總有一種辦法,把你的女神放在你最後看到的那個。如果換成10 000個,你就有了100倍的工作量。

這就是O(n)

假如現在有100個人站在你面前,臉盲的你得兩兩結對、一對一對觀察,才能找到唯一的一對雙胞胎。同上,我總是可以把雙胞胎放在你最後找到的一對。你至多一共要觀察 4950 對。

如果換成10000個,那就是49 995 000對,也就是 10100 倍的工作量
17

這就是O(n²)

有128個人站在你面前,你要把他們按照高矮排序——我們假設用歸併來解——你先把他們分成兩個64人的大隊,每個大隊分成兩個32人的中隊,每個中隊分成……直到最後每一個“小小…小隊”只剩一個人。顯然,一個人一定是已經排好序的。

然後反向操作,將剛剛拆分的隊伍合併起來。合併隊伍的時候,由於已經排好序,只需要取出兩隊排頭進行比較,就找到了最矮的一個,取出來——如此進行下去,合成的兩倍大的隊伍也將是有序的。顯然,這個合併操作,是O(n)的。總共的比較次數,不會超過兩個隊伍的總人數。

最後的問題只是,有多少次“分割-合併”操作。每次分割數量都減半,很明顯是 7 次。由此看來,大致需要執行 7×128 次操作。

這就是O(nlogn)

不同的時間複雜度,差距是明顯的,好了,下面給出你需要銘記在心裡的

資料結構的時間複雜度

排序演算法的時間複雜度

12
演算法穩定性什麼意思?如果排序前兩個相等的資料其在序列中的先後位置順序與排序後它們兩個先後位置順序相同,我們說演算法具有穩定性,有什麼意義呢?如果排序的演算法是穩定的,第一個次排序的結果和關鍵欄位可以為第二個次排序所用

最後,演算法和效能的關係,衡量一個演算法的好壞,主要通過資料量大小來評估時間和空間,這些最後都直接會影響到程式效能,一般空間利用率小的,所需時間相對較長。所以效能優化策略裡面經常聽到 空間換時間,時間換空間這樣說法。

到這裡,複雜度分析就全部講完啦,只要你認真看完這篇文章,相信你會對複雜度分析有個基本的認識。複雜度分析本身不難,記得平時寫程式碼的時候遇到問題有意識多估計一下自己的程式碼,感覺就會就會越來越熟悉的。

點選關注,第一時間瞭解華為雲新鮮技術~​

相關文章