算法:1.複雜度分析

複雜度分析:

數據結構和算法解決的兩大問題:快和省。運行快,儲存省。

複雜度分析是算法學習的精髓:時間複雜度,空間複雜度。

事後統計法有很大侷限性:

  1. 測試結果依賴測試環境
  2. 測試結果受數據規模影響很大。

大O複雜度表示法:

例1:

1  int cal(int n) {
2    int sum = 0;
3    int i = 1;
4    for (; i <= n; ++i) {
5     sum = sum + i;
6    }
7    return sum;
8  }

從 CPU 的角度來看,這段代碼的每一行都執行着類似的操作:讀數據-運算-寫數據。儘管每行代碼對應的 CPU 執行的個數、執行的時間都不一樣,但是,我們這裏只是粗略估計,所以可以假設每行代碼執行的時間都一樣,爲 unit_time。在這個假設的基礎之上,這段代碼的總執行時間是多少呢?

第 2、3 行代碼分別需要 1 個 unit_time 的執行時間,第 4、5 行都運行了 n 遍,所以需要 2n * unit_time 的執行時間,所以這段代碼總的執行時間就是 (2n+2)*unit_time。可以看出來,所有代碼的執行時間 T(n) 與每行代碼的執行次數成正比

例2:

1  int cal(int n) {
2    int sum = 0;
3    int i = 1;
4    int j = 1;
5    for (; i <= n; ++i) {
6      j = 1;
7      for (; j <= n; ++j) {
8        sum = sum +  i * j;
9      }
10    }
11  }

第 2、3、4 行代碼,每行都需要 1 個 unit_time 的執行時間,第 5、6 行代碼循環執行了 n 遍,需要 2n * unit_time 的執行時間,第 7、8 行代碼循環執行了 n²遍,所以需要 2n² * unit_time 的執行時間。所以,整段代碼總的執行時間 T(n) = (2n²+2n+3) * unit_time 。

儘管我們不知道 unit_time 的具體值,但是通過這兩段代碼執行時間的推導過程,我們可以得到一個非常重要的規律,那就是,所有代碼的執行時間 T(n) 與每行代碼的執行次數 n 成正比

公式:
T(n)=O(f(n)) T(n)=O(f(n))
其中,

  • T(n) 表示代碼執行的時間;
  • n 表示數據規模的大小;
  • f(n) 表示每行代碼執行的次數總和。
  • O表示代碼的執行時間 T(n) 與 f(n) 表達式成正比。

例1即T(n) = O(2n+2),例2即 T(n) = O(2n²+2n+3)。這就是大 O 時間複雜度表示法。其並不具體表示代碼真正的執行時間,而是表示代碼執行時間隨數據規模增長的變化趨勢,所以,也叫作漸進時間複雜度(asymptotic time complexity),簡稱時間複雜度

當 n --> +∞,公式中的低階、常量、係數三部分並不左右增長趨勢,所以都可以忽略。我們只需要記錄一個最大量級就可以了,就可以記爲:T(n) = O(n); T(n) = O(n²)。

時間複雜度分析的三個方法:

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

大 O 這種複雜度表示方法只是表示一種變化趨勢。忽略公式中的常量、低階、係數,只需要記錄一個最大階的量級就可以了。所以,分析算法時間複雜度的時候,也只關注循環執行次數最多的那一段代碼就可以了。這段核心代碼執行次數的 n 的量級,就是整段要分析代碼的時間複雜度。

2.加法法則:總複雜度等於量級最大的那段代碼的複雜度
int cal(int n) {
   int sum_1 = 0;
   int p = 1;
   for (; p < 100; ++p) {
     sum_1 = sum_1 + p;
   }
 
   int sum_2 = 0;
   int q = 1;
   for (; q < n; ++q) {
     sum_2 = sum_2 + q;
   }
 
   int sum_3 = 0;
   int i = 1;
   int 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_1時間複雜度是個常量,理應被忽略,與規模n無關。強調一下,即便這段代碼循環 10000 次、100000 次,只要是一個已知的數,跟 n 無關,照樣也是常量級的執行時間。當 n 無限大的時候,就可以忽略。儘管對代碼的執行時間會有很大影響,但是回到時間複雜度的概念來說,它表示的是一個算法執行效率與數據規模增長的變化趨勢,所以不管常量的執行時間多大,我們都可以忽略掉。因爲它本身對增長趨勢並沒有影響。

sum_2是O(n),sum_3是O(n²)。綜合這三段代碼的時間複雜度,我們取其中最大的量級,即O(n²)。
T(n)=T1(n)+T2(n)=max(O(f(n)),O(g(n)))=O(max(f(n),g(n))). T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).

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

T(n)=T1(n)T2(n)=O(f(n))O(g(n))=O(f(n)g(n)) T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n))

也就是說,假設 T1(n) = O(n),T2(n) = O(n²),則 T1(n) * T2(n) = O(n³)。

int cal(int n) {
   int ret = 0; 
   int i = 1;
   for (; i < n; ++i) {
     ret = ret + f(i);
   } 
 } 
 
 int fun(int n) {
  int sum = 0;
  int i = 1;
  for (; i < n; ++i) {
    sum = sum + i;
  } 
  return sum;
 }

fun()爲T1(n) = O(n),而cal()本身有for循環即T2(n) = O(n)),for循環中又調用了fun(),即T(n) = T1(n) * T2(n) = O(n*n) = O(n²)。

複雜度分析這個東西關鍵在於“熟練”。

常見時間複雜度:

遞增順序依次是:

多項式量級:常量階O(1),對數階O(logn),線性階O(n),線性對數階O(nlogn),K方階O(n^k)

非多項式量級:指數階O(2ⁿ),階乘階O(n!)

注:非多項式量級的算法問題叫作NP(Non-Deterministic Polynomial,非確定多項式)問題。當數據規模 n 越來越大時,其執行時間會急劇增加,求解問題的執行時間會無限增長。所以,非多項式時間複雜度的算法其實是非常低效的算法,理應避免。

(C:\Users\lagou\Desktop\8d5d86ecb45043edb99825cebccd5632.png)]

源自:Big-O Complexity Chart

O(1)

常量級時間複雜度的表示方法,並不是指只執行了一行代碼,即便執行了三行,也只能是O(1);只要代碼的執行時間不隨 n 的增大而增長,這樣代碼的時間複雜度我們都記作 O(1)。或者說,一般情況下,只要算法中不存在循環語句、遞歸語句,即使有成千上萬行的代碼,其時間複雜度也是Ο(1)

O(logn)、O(nlogn)

對數階 最常見,也是最難分析的一種。

i=1;
 while (i <= n)  {
   i = i * 2;
 }

代碼執行次數 即i<=n截止,i=1,2,4,8,16,32……2^k……2^x=n;

求解執行次數就是 求解 2^x = n 中的x;x=log₂n,T(n)=O(log₂n);

實際上,不管是以 2 爲底、以 3 爲底,還是以 10 爲底,我們可以把所有對數階的時間複雜度都記爲 O(logn)。爲什麼呢?

我們知道,對數之間是可以互相轉換的,log₃n 就等於 log₃2 * log₂n,所以 O(log₃n) = O(C * log₂n),其中 C=log₃2 是一個常量。基於我們前面的一個理論:在採用大 O 標記複雜度的時候,可以忽略係數,即 O(Cf(n)) = O(f(n))。所以,O(log₂n) 就等於 O(log₃n)。因此,在對數階時間複雜度的表示方法裏,我們忽略對數的“底”,統一表示爲 O(logn)。

O(m+n)、O(m*n)
int cal(int m, int n) {
  int sum_1 = 0;
  int i = 1;
  for (; i < m; ++i) {
    sum_1 = sum_1 + i;
  }
 
  int sum_2 = 0;
  int j = 1;
  for (; j < n; ++j) {
    sum_2 = sum_2 + j;
  }
 
  return sum_1 + sum_2;
}

m 和 n 是表示兩個數據規模。我們無法事先評估 m 和 n 誰的量級大,所以我們在表示複雜度的時候,就不能簡單地利用加法法則,省略掉其中一個。所以,上面代碼的時間複雜度就是 O(m+n)。

針對這種情況,原來的加法法則就不正確了,我們需要將加法規則改爲:T1(m) + T2(n) = O(f(m) + g(n))。但是乘法法則繼續有效:T1(m)*T2(n) = O(f(m) * f(n))。

數據結構操作的複雜性
數據結構 連接 查找 插入 刪除
數組 1 n n n
n n 1 1
隊列 n n 1 1
鏈表 n n 1 1
哈希表 - n n n
二分查找樹 n n n n
B樹 log(n) log(n) log(n) log(n)
紅黑樹 log(n) log(n) log(n) log(n)
AVL樹 log(n) log(n) log(n) log(n)

數組排序算法複雜性:

名稱 最優 平均 最壞 內存 穩定
冒泡排序 n 1 Yes
插入排序 n 1 Yes
選擇排序 1 No
堆排序 n log(n) n log(n) n log(n) 1 No
歸併排序 n log(n) n log(n) n log(n) n Yes
快速排序 n log(n) n log(n) log(n) No
希爾排序 n log(n) 取決於差距序列 n (log(n))² 1 No

空間複雜度

時間複雜度的全稱是漸進時間複雜度表示算法的執行時間與數據規模之間的增長關係。類比一下,空間複雜度全稱就是漸進空間複雜度(asymptotic space complexity),表示算法的存儲空間與數據規模之間的增長關係。1

1 void print(int n) {
2   int i = 0;
3   int[] a = new int[n];
4   for (i; i <n; ++i) {
5     a[i] = i * i;
6   }
 
  for (i = n-1; i >= 0; --i) {
    print out a[i]
  }
 }

第 2 行代碼中,我們申請了一個空間存儲變量 i,但是它是常量階的,跟數據規模 n 沒有關係,所以我們可以忽略。第 3 行申請了一個大小爲 n 的 int 類型數組,除此之外,剩下的代碼都沒有佔用更多的空間,所以整段代碼的空間複雜度就是 O(n)。

我們常見的空間複雜度就是 O(1)、O(n)、O(n² ),像 O(logn)、O(nlogn) 這樣的對數階複雜度平時都用不到。而且,空間複雜度分析比時間複雜度分析要簡單很多。所以,對於空間複雜度,掌握這些內容已經足夠了。

複雜度分析法則
1)單段代碼看高頻:比如循環。
2)多段代碼取最大:比如一段代碼中有單循環和多重循環,那麼取多重循環的複雜度。
3)嵌套代碼求乘積:比如遞歸、多重循環等
4)多個規模求加法:比如方法有兩個參數控制兩個循環的次數,那麼這時就取二者複雜度相加。

一、複雜度分析的4個概念:

  • 1.最壞情況時間複雜度:代碼在最理想情況下執行的時間複雜度。

  • 2.最好情況時間複雜度:代碼在最壞情況下執行的時間複雜度。

  • 3.平均時間複雜度:用代碼在所有情況下執行的次數的加權平均值表示。

  • 4.均攤時間複雜度:在代碼執行的所有複雜度情況中絕大部分是低級別的複雜度,個別情況是高級別複雜度且發生具有時序關係時,可以將個別高級別複雜度均攤到低級別複雜度上。基本上均攤結果就等於低級別複雜度。

    分別解釋四種情況:

    長度爲n的數組中查找x的下標,來返回,找不到則返回-1;

    // 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;//TODO:找到即跳出
        }
      }
      return pos;
    }
    

    1.如果不加break的話,O(n)

    2.加上break後,或許array[0]就是x,複雜度O(1)【最好情況】,只有數組不包含x時,才循環到n次【最差情況】。

    最好情況 和 最壞情況 都是極端情況。引入一個概念:平均時間複雜度。

    要查找的變量 x 在數組中的位置,有 n+1 種情況:在數組的 0~n-1 位置中不在數組中。我們把每種情況下,查找需要遍歷的元素個數累加起來,然後再除以 n+1,就可以得到需要遍歷的元素個數的平均值,即:
    1+2+3++n+nn+1=n(n+3)2(n+1) \frac {1+2+3+……+n+n}{n+1}=\frac{n(n+3)}{2(n+1)}
    省略掉係數、低階、常量,簡化之後,得到的平均時間複雜度就是 O(n)。

    這個結論雖然是正確的,但是計算過程稍微有點兒問題。究竟是什麼問題呢?我們剛講的這 n+1 種情況,出現的概率並不是一樣的。

    我們知道,要查找的變量 x,要麼在數組裏,要麼就不在數組裏。這兩種情況對應的概率統計起來很麻煩,爲了方便你理解,我們假設在數組中與不在數組中的概率都爲 1/2。另外,要查找的數據出現在 0~n-1 這 n 個位置的概率也是一樣的,爲 1/n。所以,根據概率乘法法則,要查找的數據出現在 0~n-1 中任意位置的概率就是 1/(2n)。

    因此,前面的推導過程中存在的最大問題就是,沒有將各種情況發生的概率考慮進去。如果我們把每種情況發生的概率也考慮進去,那平均時間複雜度的計算過程就變成了這樣:
    112n+212n+312n++n12n+n12=3n+14 1*\frac{1}{2n}+2*\frac{1}{2n}+3*\frac{1}{2n}+…+n*\frac{1}{2n}+n*\frac{1}{2}=\frac{3n+1}{4}
    這個值就是概率論中的加權平均值,也叫作期望值,所以平均時間複雜度的全稱應該叫加權平均時間複雜度或者期望時間複雜度

    去掉係數和常量,這段代碼的加權平均時間複雜度仍然是 O(n)。

    在大多數情況下,我們並不需要區分最好、最壞、平均情況時間複雜度三種情況。很多時候,我們使用一個複雜度就可以滿足需求了。只有同一塊代碼在不同的情況下,時間複雜度有量級的差距,我們纔會使用這三種複雜度表示法來區分。

    均攤時間複雜度

    平均複雜度只在某些特殊情況下才會用到,而均攤時間複雜度應用的場景比它更加特殊、更加有限。

     // array 表示一個長度爲 n 的數組
     // 代碼中的 array.length 就等於 n
     int[] array = new int[n];
     int count = 0;
     
     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;
        }
     
        array[count] = val;
        ++count;
     }
    

    這段代碼實現了一個往數組中插入數據的功能。當數組滿了之後,也就是代碼中的 count == array.length 時,我們用 for 循環遍歷數組求和,並清空數組,將求和之後的 sum 值放到數組的第一個位置,然後再將新的數據插入。但如果數組一開始就有空閒空間,則直接將數據插入數組。

    最好爲O(1),最差爲O(n)。

    那平均時間複雜度是多少呢?答案是 O(1)。我們還是可以通過前面講的概率論的方法來分析。

    假設數組的長度是 n,根據數據插入的位置的不同,我們可以分爲 n 種情況,每種情況的時間複雜度是 O(1)。除此之外,還有一種“額外”的情況,就是在數組沒有空閒空間時插入一個數據,這個時候的時間複雜度是 O(n)。而且,這 n+1 種情況發生的概率一樣,都是 1/(n+1)。所以,根據加權平均的計算方法,我們求得的平均時間複雜度就是:
    11n+1+11n+1++11n+1++11n+1+n1n+1=O(1) 1*\frac{1}{n+1}+1*\frac{1}{n+1}+…+1*\frac{1}{n+1}+…+1*\frac{1}{n+1}+n*\frac{1}{n+1}=O(1)

其實理解平均複雜度不需這麼複雜,我們可以對比 insert() 和find()的區別:

1.find() 只有在極端情況下,才爲 O(1)。但 insert() 通常爲 O(1),極端情況下O(n)。

2.insert() 中O(1) 和 O(n) 出現的頻率有規律,且有一定的前後時序關係,一個 O(n)之後,緊跟着 n-1 個 O(1) ,週期循環。

針對這種特殊的場景,完全不需要平均複雜度那樣用概率加權平均,我們引入了一種更加簡單的分析方法:攤還分析法,對應均攤時間複雜度

如何用?每一個O(n) 緊跟 n-1 個 O(1) ,把耗時多的那次操作均攤到接下來的 n-1 次耗時少的操作上,均攤下來,這一組連續的操作的均攤時間複雜度就是 O(1)。這就是均攤分析的大致思路。該方法不常用,點到爲止。

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

二、爲什麼要引入這4個概念?

1.同一段代碼在不同情況下時間複雜度會出現量級差異,爲了更全面,更準確的描述代碼的時間複雜度,所以引入這4個概念。

2.代碼複雜度在不同情況下出現量級差別時才需要區別這四種複雜度。大多數情況下,是不需要區別分析它們的。

三、如何分析平均、均攤時間複雜度?

1.平均時間複雜度

代碼在不同情況下複雜度出現量級差別,則用代碼所有可能情況下執行次數的加權平均值表示。

2.均攤時間複雜度

下時間複雜度比較高,而且這些操作之間存在前後連貫的時序關係,這個時候,我們就可以將這一組操作放在一塊兒分析,看是否能將較高時間複雜度那次操作的耗時,平攤到其他那些時間複雜度比較低的操作上。而且,在能夠應用均攤時間複雜度分析的場合,一般均攤時間複雜度就等於最好情況時間複雜度。

二、爲什麼要引入這4個概念?

1.同一段代碼在不同情況下時間複雜度會出現量級差異,爲了更全面,更準確的描述代碼的時間複雜度,所以引入這4個概念。

2.代碼複雜度在不同情況下出現量級差別時才需要區別這四種複雜度。大多數情況下,是不需要區別分析它們的。

三、如何分析平均、均攤時間複雜度?

1.平均時間複雜度

代碼在不同情況下複雜度出現量級差別,則用代碼所有可能情況下執行次數的加權平均值表示。

2.均攤時間複雜度

兩個條件滿足時使用:1)代碼在絕大多數情況下是低級別複雜度,只有極少數情況是高級別複雜度;2)低級別和高級別複雜度出現具有時序規律。均攤結果一般都等於低級別複雜度。

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