1. 程式人生 > 其它 >如何理解時間複雜度和空間複雜度

如何理解時間複雜度和空間複雜度

你是怎麼理解演算法的呢?

簡單說就是,同一個功能

  • 別人寫的程式碼跑起來佔記憶體 100M,耗時 100 毫秒
  • 你寫的程式碼跑起來佔記憶體 500M,耗時 1000 毫秒,甚至更多

所以

  1. 衡量程式碼好壞有兩個非常重要的標準就是:執行時間佔用空間,就是我們後面要說到的時間複雜度和空間複雜度,也是學好演算法的重要基石
  2. 這也是會演算法和不會演算法的攻城獅的區別、更是薪資的區別,因為待遇好的大廠面試基本都有演算法

可能有人會問:別人是怎麼做到的?程式碼沒開發完 執行起來之前怎麼知道佔多少記憶體和執行時間呢?

確切的佔內用存或執行時間確實算不出來,而且同一段程式碼在不同效能的機器上執行的時間也不一樣,可是程式碼的基本執行次數,我們是可以算得出來的,這就要說到時間複雜度了

什麼是時間複雜度

看個栗子

function foo1(){
    console.log("我吃了一顆糖")
    console.log("我又吃了一顆糖")
    return "再吃一顆糖"
}

呼叫這個函式,裡面總執行次數就是3次,這個沒毛病,都不用算

那麼下面這個栗子呢

function foo2(n){
    for( let i = 0; i < n; i++){
        console.log("我吃了一顆糖")
    }
    return "一顆糖"
}

那這個函式裡面總執行次數呢?根據我們傳進去的值不一樣,執行次數也就不一樣,但是大概次數我們總能知道

let = 0               :執行 1 次
i < n                 : 執行 n+1 次
i++                   : 執行 n+1 次
console.log("執行了")  : 執行 n 次
return 1              : 執行 1 次

這個函式的總執行次數就是 3n + 4 次,對吧

可是我們開發不可能都這樣去數,所以根據程式碼執行時間的推導過程就有一個規律,也就是所有程式碼執行時間 T(n)和程式碼的執行次數 f(n) ,這個是成正比的,而這個規律有一個公式

T(n) = O( f(n) )

n 是輸入資料的大小或者輸入資料的數量  
T(n) 表示一段程式碼的總執行時間   
f(n) 表示一段程式碼的總執行次數   
O  表示程式碼的執行時間 T(n) 和 執行次數f(n) 成正比

完整的公式看著就很麻煩,彆著急,這個公式只要瞭解一下就可以了,為的就是讓你知道我們表示演算法複雜度的O()是怎麼來的,我們平時表示演算法複雜度主要就是用O(),讀作大歐表示法,是字母O不是零

只用一個O()表示,這樣看起來立馬就容易理解多了

回到剛才的兩個例子,就是上面的兩個函式

  • 第一個函式執行了3次,用複雜度表示就是 O(3)
  • 第二個函式執行了3n + 4次,複雜度就是 O(3n+4)

這樣有沒有覺得還是很麻煩,因為如果函式邏輯一樣的,只是執行次數差個幾次,像O(3) 和 O(4),有什麼差別?還要寫成兩種就有點多此一舉了,所以複雜度裡有統一的簡化的表示法,這個執行時間簡化的估算值就是我們最終的時間複雜度

簡化的過程如下

  • 如果只是常數直接估算為1,O(3) 的時間複雜度就是O(1),不是說只執行了1次,而是對常量級時間複雜度的一種表示法。一般情況下,只要演算法裡沒有迴圈和遞迴,就算有上萬行程式碼,時間複雜度也是O(1)
  • O(3n+4) 裡常數4對於總執行次數的幾乎沒有影響,直接忽略不計,係數 3 影響也不大,因為3n和n都是一個量級的,所以作為係數的常數3也估算為1或者可以理解為去掉係數,所以 O(3n+4) 的時間複雜度為O(n)
  • 如果是多項式,只需要保留n的最高次項,O( 666n³ + 666n² + n ),這個複雜度裡面的最高次項是n的3次方。因為隨著n的增大,後面的項的增長遠遠不及n的最高次項大,所以低於這個次項的直接忽略不計,常數也忽略不計,簡化後的時間複雜度為 O(n³)

這裡如果沒有理解的話,暫停理解一下

接下來結合栗子,看一下常見的時間複雜度

常用時間複雜度

O(1)

上面說了,一般情況下,只要演算法裡沒有迴圈和遞迴,就算有上萬行程式碼,時間複雜度也是O(1),因為它的執行次數不會隨著任何一個變數的增大而變長,比如下面這樣

function foo(){
    let n = 1
    let b = n * 100
    if(b === 100){
        console.log("開始吃糖")
    }
    console.log("我吃了1顆糖")
    console.log("我吃了2顆糖")
    ......
    console.log("我吃了10000顆糖")
}

O(n)

上面也介紹了 O(n),總的來說 只有一層迴圈或者遞迴等,時間複雜度就是O(n),比如下面這樣

function foo1(n){
    for( let i = 0; i < n; i++){
        console.log("我吃了一顆糖")
    }
}
function foo2(n){
    while( --n > 0){
        console.log("我吃了一顆糖")
    }
}
function foo3(n){
    console.log("我吃了一顆糖")
    --n > 0 && foo3(n)
}

O(n²)

比如巢狀迴圈,如下面這樣的,裡層迴圈執行 n 次,外層迴圈也執行 n 次,總執行次數就是 n x n,時間複雜度就是 n 的平方,也就是O(n²)。假設 n 是 10,那麼裡面的就會列印 10 x 10 = 100 次

function foo1(n){
    for( let i = 0; i < n; i++){
        for( let j = 0; j < n; j++){
            console.log("我吃了一顆糖")
        }
    }
}

還有這樣的,總執行次數為 n + n²,上面說了,如果是多項式,取最高次項,所以這個時間複雜度也是O(n²)

function foo2(n){
    for( let k = 0; k < n; k++){
        console.log("我吃了一顆糖")
    }
    for( let i = 0; i < n; i++){
        for( let j = 0; j < n; j++){
            console.log("我吃了一顆糖")
        }
    }
}

//或者下面這樣,以執行時間最長的,作為時間複雜度的依據,所以下面的時間複雜度就是 O(n²)
function foo3(n){
    if( n > 100){
        for( let k = 0; k < n; k++){
            console.log("我吃了一顆糖")
        }
    }else{
        for( let i = 0; i < n; i++){
            for( let j = 0; j < n; j++){
                console.log("我吃了一顆糖")
            }
        }
    }
}

O(logn)

舉個栗子,這裡有一包糖

這包糖裡有16顆,每天吃這一包糖的一半,請問多少天吃完?

意思就是16不斷除以2,除幾次之後等於1?用程式碼表示

function foo1(n){
    let day = 0
    while(n > 1){
        n = n/2
        day++
    }
    return day
}
console.log( foo1(16) ) // 4

迴圈次數的影響主要來源於 n/2 ,這個時間複雜度就是O(logn),這個複雜度是怎麼來的呢,彆著急,繼續看

再比如下面這樣

function foo2(n){
    for(let i = 0; i < n; i *= 2){
        console.log("一天")
    }
}
foo2( 16 )

裡面的列印執行了 4 次,迴圈次數主要影響來源於 i *= 2 ,這個時間複雜度也是O(logn)

這個 O(logn) 是怎麼來的,這裡補充一個小學三年級數學的知識點,對數,我們看一張圖

沒有理解的話再看一下,理解一下規律

  • 真數:就是真數,這道題裡就是16
  • 底數:就是值變化的規律,比如每次迴圈都是i*=2,這個乘以2就是規律。比如1,2,3,4,5...這樣的值的話,底就是1,每個數變化的規律是+1嘛
  • 對數:在這道題裡可以理解成x2乘了多少次,這個次數

仔細觀察規律就會發現這道題裡底數是 2,而我們要求的天數就是這個對數4,在對數裡有一個表達公式

a^b= n  讀作以a為底,b的對數=n,在這道題裡我們知道a和n的值,也就是  2^b= 16 然後求 b

把這個公式轉換一下的寫法如下

log(a) n = b    在這道題裡就是   log(2) 16 = ?  答案就是 4

公式是固定的,這個16不是固定的,是我們傳進去的 n,所以可以理解為這道題就是求 log(2)n = ?

用時間複雜度表示就是 O(log(2)n),由於時間複雜度需要去掉常數和係數,而log的底數跟係數是一樣的,所以也需要去掉,所以最後這個正確的時間複雜度就是O(logn)

emmmmm.....

沒有理解的話,可以暫停理解一下

其他還有一些時間複雜度,我由快到慢排列了一下,如下表順序

複雜度名稱
O(1) 常數複雜度
O(logn) 對數複雜度
O(n) 線性時間複雜度
O(nlogn) 線性對數複雜度
O(n²) 平方
O(n³) 立方
O(2^n) 指數,一點資料量就卡的不行
O(n!) 階乘,就更慢了

這些時間複雜度有什麼區別呢,看張圖

隨著資料量或者 n 的增大,時間複雜度也隨之增加,也就是執行時間的增加,會越來越慢,越來越卡

總的來說時間複雜度就是執行時間增長的趨勢,那麼空間複雜度就是儲存空間增長的趨勢

什麼是空間複雜度

空間複雜度就是演算法需要多少記憶體,佔用了多少空間

常用的空間複雜度有O(1)O(n)O(n²)

O(1)

只要不會因為演算法裡的執行,導致額外的空間增長,就算是一萬行,空間複雜度也是O(1),比如下面這樣,時間複雜度也是 O(1)

function foo(){
    let n = 1
    let b = n * 100
    if(b === 100){
        console.log("開始吃糖")
    }
    console.log("我吃了1顆糖")
    console.log("我吃了2顆糖")
    ......
    console.log("我吃了10000顆糖")
}

O(n)

比如下面這樣,n 的數值越大,演算法需要分配的空間就需要越多,來儲存數組裡的值,所以它的空間複雜度就是O(n),時間複雜度也是 O(n)

function foo(n){
    let arr = []
    for( let i = 1; i < n; i++ ) {
        arr[i] = i
    }
}

O(n²)

O(n²) 這種空間複雜度一般出現在比如二維陣列,或是矩陣的情況下

不用說,你肯定明白是啥情況啦

就是遍歷生成類似這樣格式的

let arr = [
    [1,2,3,4,5],
    [1,2,3,4,5],
    [1,2,3,4,5]
]

結語

想要學好演算法,就必須要理解複雜度這個重要基石

複雜度分析不難,關鍵還是在於多練。每次看到程式碼的時候,簡單的一眼就能看出複雜度,難的稍微分析一下也能得出答案。推薦去 leetCode 刷題,App或者PC端都可以。