數據結構與算法的重溫之旅(一)——複雜度分析

本系列全部文章的代碼都是用JavaScript實現,之因此用JavaScript實現是由於它能夠直接在瀏覽器宿主中運行代碼,即在瀏覽器中按f12打開控制檯,選擇console按鈕,在下面空白的文本框把本例的代碼黏貼上去回車便可運行。方便各位同窗學習和調試。

最近刷leetCode刷到後面medium級別的題目的時候就越力不從心,因而乎去極客時間那裏買了一門數據結構與算法的課來學習一下,本刊是記錄本身在這門課程的筆記,若有錯誤,勞煩勘正。算法

在講解複雜度分析以前,咱們先要知道爲何咱們寫程序的時候須要算法與數據結構。可能有些人以爲本身隨便擼一段代碼,保證業務流暢運行不報錯就能夠了,其實這樣的認知是十分膚淺的。你目前的解決方法只是解決了你目前測試當中所遇到的問題,當在複雜的生存環境中,有可能遇到的數據十分的龐大,一些你本來覺得完美的代碼可能在這複雜的環境中擠佔太多的服務器資源或客戶端資源,設置直接致使服務器宕機或者客戶端掛掉,因此咱們有必要用算法與數據結構來優化咱們的代碼。那什麼是算法,什麼是數據結構呢?按照個人理解:數據結構是數據的存儲狀態,算法則是對數據的操做方法。認知這一點很重要,不少人覺得算法就是數據結構,數據結構就是算法,其實他們兩個不是相等關係,而是相關聯的關係,做用於他們之間的其實就是數據,數據結構和算法誰也不能脫離誰單獨使用。數組

講到這裏咱們就進入正體,咱們爲何要分析一個算法與數據結構的複雜度呢,咱們之因此用算法與數據結構是爲了提升程序的性能,程序的哪方面的性能獲得提高呢?答案是他的所用時間和所佔空間(也就是所佔的服務資源),換句話來講,咱們用算法與數據結構的目的是要更快更省。瀏覽器

那咱們如何分析一個算法是否更快更省呢,傳統上有一個方法是過後統計法,即經過統計和監控獲得算法執行所用的時間和佔用的內存大小,可是這種方法十分的不許確,哪些因素會影響呢,主要是下面這些狀況:bash

1.測試結果很是依賴測試環境 服務器

在測試的過程當中若是你的機器硬件設備很好的話,測試得出的數據可能很好看,可是若是是換了一個老舊的機器的話,可能得出的數據跟好的機器得出的數據差異很大。 數據結構

2.測試結果受數據規模的影響很大數據結構和算法

有些數據若是數量不多用算法是看不出來不一樣算法之間有什麼優點區別。在排序算法中,在同等狀況下,好比數據是有序的狀況下,用冒泡排序所用的時間設置比用快速排序的時間還要少。函數

通過上面的例子,因此咱們必須經過一個科學的方法來比較各個算法之間的複雜度,這種方法咱們稱之爲大O複雜度表示法。下面咱們來分析一個算法的時間複雜度。post

算法的執行效率其實粗略的講是代碼執行所要花費的時間,那咱們應該如何看出一個算法所花的時間呢,下面貼個代碼來舉例子:性能

function test (val) {
    let sum = 0;
    let a = 1
    for (; a < val; a++) {
        sum = sum + 1
    }
    return sum
}複製代碼

咱們在估算程序每一個步驟所用的時間時,假設每一步所用的時間都是相等的,都爲unit_time,在這個假設的基礎上,咱們來計算一下該程序所用的時間。第 二、3 行代碼分別須要 1 個 unit_time 的執行時間,第 四、5 行都運行了 n 遍,因此須要 2n*unit_time 的執行時間,因此這段代碼總的執行時間就是 (2n+2)*unit_time。能夠看出來,全部代碼的執行時間 T(n) 與每行代碼的執行次數成正比。

同理,按照這個思路,咱們在拿一個程序來舉例:

function test (val) {
    var sum = 0
    var i = 1
    var j = 1
    for (; i <= val; i++) {
        j = 1
        for (; j <= val; j++) {
            sum = sum + i * j
        }
    }
}複製代碼

運用剛纔的思路,第 二、三、4 行代碼,每行都須要 1 個 unit_time 的執行時間,第 五、6 行代碼循環執行了 n 遍,須要 2n * unit_time 的執行時間,第 七、8 行代碼循環執行了 n^{^{2}}遍,因此須要 2n* unit_time 的執行時間。因此,整段代碼總的執行時間 T(n) = (2n^{_{^{2}}}+2n+3)*unit_time。儘管咱們不知道 unit_time 的具體值,可是經過這兩段代碼執行時間的推導過程,咱們能夠獲得一個很是重要的規律,那就是,全部代碼的執行時間 T(n) 與每行代碼的執行次數 n 成正比。也就是下面所要說的大O表示法:T(n) = O(f(n))

我來具體的解釋一下這個公式:其中,T(n) 咱們已經講過了,它表示代碼執行的時間;n 表示數據規模的大小;f(n) 表示每行代碼執行的次數總和。由於這是一個公式,因此用 f(n) 來表示。公式中的 O,表示代碼的執行時間 T(n) 與 f(n) 表達式成正比。因此第一個例子中的T(n) = O(2n+2)和第二個例子中的T(n) = O(2n^{2}+2n+3),這就是大O時間複雜度表示法。。大 O 時間複雜度實際上並不具體表示代碼真正的執行時間,而是表示代碼執行時間隨數據規模增加的變化趨勢,因此這也叫漸進時間複雜度(asymptotic time complexity),簡稱時間複雜度。當咱們的變量n很大的時候,其實在公式中低階、常量和係數三個部分並不會左右整個增加趨勢,因此咱們能夠把他們忽略,記錄最大的量級就能夠了,因此上面兩個實例的時間複雜度爲:T(n) = O(n)T(n) = O(n^{2})

如今咱們按照理論,進一步的說明比較實用判斷時間複雜度的方法:

1.只關注循環執行次數最多的一段代碼

剛剛有講,大 O 這種複雜度表示方法只是表示一種變化趨勢。咱們一般會忽略掉公式中的常量、低階、係數,只須要記錄一個最大階的量級就能夠了。因此,咱們在分析一個算法、一段代碼的時間複雜度的時候,也只關注循環執行次數最多的那一段代碼就能夠了。這段核心代碼執行次數的 n 的量級,就是整段要分析代碼的時間複雜度。如第一個代碼例子裏,第二、3行都是常量級的運行時間,與n無關,咱們能夠忽略他們的用時,而第四、5行則是與n相關,整個程序當中執行次數最多的代碼,這兩行代碼在上面有說過執行了n次,因此時間複雜度爲O(n)。

2.加法法則:總複雜度等於量級最大的那段代碼的複雜度

以下面的這段代碼:

function test(n) {
   let sum_1 = 0;
   let p = 1;
   for (; p < 100; ++p) {
     sum_1 = sum_1 + p;
   }

   let sum_2 = 0;
   let q = 1;
   for (; q < n; ++q) {
     sum_2 = sum_2 + q;
   }
 
   let sum_3 = 0;
   let i = 1;
   let j = 1;
   for (; i <= n; ++i) {
     j = 1; 
     for (; j <= n; ++j) {
       sum_3 = sum_3 +  i * j;
     }
   }
 
   return sum_1 + sum_2 + sum_3;
 }
複製代碼

這個代碼其實分紅了三份,分別是求sum_一、sum_2和sum_3,咱們把他們的時間複雜度放在一塊兒比較,而後再取時間複雜度最大的做爲整個代碼的複雜度。按照代碼來分析,首先第一塊sum_1是進行100次的循環運算,這個只是一個常量級的運算時間,和n無關。第二塊代碼則進行了n次的循環運算,則時間複雜度爲O(n)。第三塊代碼則進行了n的平方次循環運算,獲得的時間複雜度爲O(n^{2}),因此經過上面的比較,咱們能夠獲得該程序的時間複雜度爲O(n^{2}),也就是說總的時間複雜度等於量級最大的那段代碼的時間複雜度。抽線成具體公式則是:

若是T_{1}(n)=O(f(n)), T_2(n)=O(g(n)),則T(n)=T_1(n)+T_2(n)=max(O(f(n)), O(g(n))) = O(max(f(n), g(n))

3.乘法法則:嵌套代碼的複雜度等於嵌套內外代碼複雜度的乘積

講完了加法法則,其實你們能夠經過發散思惟猜到乘法法則的公式是什麼樣的,乘法法則的公式以下所示:T_1(n)=O(f(n)),T_2(n)=O(g(n)),則T(n)=T_1(n)*T_2(n)=O(f(n))*O(g(n))=O(f(n)*g(n))。按照公式,若是f(n)等於n,g(n)等於n的平方的話,那最後的T(n)則等於n的立方。下面舉例來佐證:

function test(n) {
   let ret = 0; 
   let i = 1;
   for (; i < n; ++i) {
     ret = ret + f(i);
   } 
 } 
 
 function f(n) {
  let sum = 0;
  let i = 1;
  for (; i < n; ++i) {
    sum = sum + i;
  } 
  return sum;
 }
複製代碼

咱們單獨看 test() 函數。假設 f() 只是一個普通的操做,那第 4~6 行的時間複雜度就是,T1(n) = O(n)。但 f() 函數自己不是一個簡單的操做,它的時間複雜度是 T2(n) = O(n),因此,整個 cal() 函數的時間複雜度就是,T(n) = T1(n) * T2(n) = O(n*n) = O(n^{2})

其實上面的這三種方法不須要死記硬背,只要多運用多實踐就能夠記到心中。時間複雜度分爲多項式和非多項式,非多項式只有O(2^n)O(n!),多項式有不少種,下面來詳細的講一下常見的時間複雜度:

1.O(1)時間複雜度

O(1)表示的是n是個常量,並非說代碼只有一行,而是指代碼裏面沒有遞歸、循環語句的時候,即便代碼有成千上萬行,時間複雜度仍然是個常量,和n無關。

2.O(logn)和O(nlogn)時間複雜度

這兩個時間複雜度表示的是一個對數階,比較常見這種對象階的時間複雜度出在二分查找那裏,下面以一個簡單的例子來舉例O(logn)時間複雜度

var i = 1
 var n = 10
 while (i <= n)  {
   i = i * 2;
 }
複製代碼

在這個例子當中,這個循環只需運行三次便可,這個代碼有點像咱們高中時候學的等比數列,i每次都乘以2,這樣的話咱們就獲得公式2^{x} = n,利用高中學過的對數只是,咱們獲得x = log_{2}n,因此這裏的時間複雜度是log_{2}n。不過在這裏咱們就有疑問了,爲何不是log_xn時間複雜度,而是logn的時間複雜度呢。在數學上,咱們能夠對對數提取公因式,好比咱們如今有一個log_{3}n的數,提取公因式得:log_{3}2*log_{2}n,咱們在上面有說過常數是能夠省略的,因此獲得的是logn。而nlog則更簡單,它是經過在上面再加一層循環,利用上面說到的乘法原則所得而成。

3.O(n+m)和O(n*m)時間複雜度

當一個代碼塊裏面有兩個循環體,而且兩個循環體都是至關於不一樣的變量的時候,這個時間複雜度就由兩個數據來決定的了。例子以下:

function test(m, n) {
  var sum_1 = 0;
  var i = 1;
  for (; i < m; ++i) {
    sum_1 = sum_1 + i;
  }

  var sum_2 = 0;
  var j = 1;
  for (; j < n; ++j) {
    sum_2 = sum_2 + j;
  }

  return sum_1 + sum_2;
}
複製代碼

在這裏,兩個代碼塊裏的循環是分別相對於m和n的,因此咱們這裏要將上面的加法原則進一步的修改,原來的加法原則是創建在循環都是對應同一個n的時候取時間複雜度最大的這一個,而這裏因爲沒法判斷哪個的時間複雜度最大因此只能讓他們相加:T_1(n)+T_2(m)=O(f(n)+g(m))。而O(n*m)是利用乘法原則,因此結論沒變。

4.O(n!)和O(2^n)時間複雜度

這兩個時間複雜度區別與以前提過的時間複雜度,這裏的時間複雜度都是非多項式,因爲時間消耗太大,通常比較少用到這些的時間複雜度。

在講完了時間複雜度以後,下面將空間複雜度就簡單不少。空間複雜度的全稱是漸進空間複雜度(asymptotic space complexity),表示算法的存儲空間與數據規模之間的增加關係。以下例子:

function print(n) {
  var i = 0;
  var a = new Array(n);
  for (i; i <n; ++i) {
    a[i] = i * i;
  }

  for (i = n-1; i >= 0; --i) {
    console.log(a[i])
  }
}
複製代碼

跟時間複雜度分析同樣,咱們能夠看到,第 2 行代碼中,咱們申請了一個空間存儲變量 i,可是它是常量階的,跟數據規模 n 沒有關係,因此咱們能夠忽略。第 3 行申請了一個大小爲 n 的 int 類型數組,除此以外,剩下的代碼都沒有佔用更多的空間,因此整段代碼的空間複雜度就是 O(n)。空間複雜度的常見類型就O(1)、O(n)和O(n^2),像 O(logn)、O(nlogn) 這樣的對數階複雜度平時都用不到。並且,空間複雜度分析比時間複雜度分析要簡單不少。因此,對於空間複雜度,掌握剛我說的這些內容已經足夠了。

最後以一幅時間複雜度的圖來總結一下常見的時間複雜度:

下一篇文章:數據結構與算法的重溫之旅(二)——複雜度進階分析​​​​​​​

延伸閱讀:數據結構與算法的重溫之旅(番外篇1)——談談斐波那契數列​​​​​​​

相關文章
相關標籤/搜索