Algorithm Master Road:算法的时间/空间复杂度
很多同學都覺得算法很難,難以入門,難以理解,更難以掌握和運用,其實歸根溯源,我們可以把所有的問題都通過枚舉法來解決,但是受困于「時間」和「空間」的因素,有的時候并不能枚舉所有的情況,所以需要通過精妙的算法設計避免枚舉一些顯而易見錯誤的情況。
那么既然說到了「時間」和「空間」,這篇文章就跟大家聊一下算法設計過程中必須要考慮的時間復雜度和空間復雜度。
什么是時間復雜度和空間復雜度?
算法的時間復雜度和空間復雜度是對算法執行效率的分析,也是一個對算法的度量單位,目的是看算法實際是否可行,并且當同一個問題有多種解法時,可以進行時間和空間性能上的比較,以便從中挑選出較優算法。
衡量算法執行效率的方法有兩種:
事后統計法需要先將算法實現,然后測算其真正的時間和空間開銷。這種方法的缺陷很顯然,一是必須把算法轉換成可執行的程序,二是時空開銷的測算結果依賴于計算機的軟硬件等環境因素。
雖然我們都希望自己的算法更高效,但是每設計出來一個算法都寫出程序來測算時空開銷顯然是不現實的,所有我們通常采用事前分析法,在編程前就盡量準確地估計程序的時空開銷,通過數學分析和計算來估計算法的復雜度。
時間復雜度
算法的執行時間
先來看幾個定義:
語句頻度:一條語句的重復執行次數稱為語句頻度。
單位時間:由于語句的執行要由源程序經編譯程序翻譯成目標代碼,目標代碼經裝配再執行,因此語句執行一次實際所需的具體時間跟機器軟、硬件環境相關,所以設每條語句執行一次所需的時間均為單位時間。
一個算法的執行時間大致上等于其所有語句執行時間的總和,而語句的執行時間=語句頻度×執行一次所需時間。
如果不考慮計算機的軟硬件等環境因素,影響算法時間代價的最主要因素是問題規模,問題規模是算法求解問題輸入量的多少,是問題大小的本質表示,一般用整數n表示。
問題規模n對不同的問題含義不同,例如:
- 排序算法:n為參加排序的記錄數
- 矩陣運算:n為矩陣的階數
- 多項式運算:n為多項式的項數
- 集合運算:n為集合中元素的個數
- 樹有關運算:n為樹的結點個數
- 圖有關運算:n為圖的頂點數或邊數
顯然n越大算法的執行時間越長。
例如:求兩個n階矩陣的乘積算法
矩陣乘法定義:
for(int i = 1; i < n + 1; i++) { // 頻度: n + 1 <思考:為什么是 n+1 而不是 n ?> for(int j = 1; j < n + 1; j++) { // 頻度: n * (n + 1) c[i][j] = 0; // 頻度: n^2 for(int k = 1; k < n + 1; k++) { // 頻度: n^2 * (n + 1)c[i][j] = c[i][j] + a[i][k] * b[k][j]; // 頻度: n^3}} }該算法中所有語句頻度之和,是矩陣階數n的函數,用f(n)表示:f(n)=2n3+3n2+2n+1f(n)=2n^3+3n^2+2n+1f(n)=2n3+3n2+2n+1。
算法的時間復雜度定義
通常,算法的執行時間是隨問題規模增長而增長的,因此對算法的評價通常只需考慮其隨問題規模增長的趨勢。這種情況下,我們只需要考慮當問題規模充分大時,算法中基本語句的執行次數在漸進意義下的階。
比如矩陣的乘積算法中,當n趨向于無窮大時,顯然有:lim?n→∞f(n)n3=lim?n→∞2n3+3n2+2n+1n3=2\lim_{n\to \infty}\frac{f(n)}{n^{3}}=\lim_{n\to \infty}\frac{2n^3+3n^2+2n+1}{n^3}=2limn→∞?n3f(n)?=limn→∞?n32n3+3n2+2n+1?=2。
也就是說,當n充分大時,f(n)和n3之比是一個不等于零的常數,即f(n)和n3是同階的,或者說f(n)和n3的數量級相同。
在這里,我們用“O”來表示數量級,記作T(n)=O(f(n))=O(n3),其中T(n)=O(f(n))就是算法的時間復雜度,它表示隨問題規模n的增大,算法執行時間的增長率和f(n)的增長率相同,也叫算法的漸進時間復雜度,簡稱時間復雜度。
最好、最壞和平均時間復雜度
對于某些問題的算法,其基本語句的頻度不僅僅與問題的規模相關,還依賴于其它因素。
例如:在一維數組a中順序查找某個值等于e的元素,并返回其所在位置:
int find(int e) {for(int i = 0; i < n; i++) {if(a[i] == e) {return i + 1;}}return -1; }容易看出,此算法中基本語句的頻度不僅與問題規模n有關,還與輸入實例中數組a[i]的各元素值及e的取值有關。
假如運氣爆棚,每次要找的元素e正好是數組中的第一個元素,那么不論數組的規模多大,基本語句的頻度f(n)=1;假如運氣極差,每次要找的元素e是數組的最后一個元素,則基本語句的頻度f(n)=n。
對于一個算法來說,需要考慮各種可能出現的情況,以及每一種情況出現的概率,一般情況下,可假設待查找的元素在數組中所有位置上出現的可能性相同,則可取基本語句的頻度在最好情況和最壞情況的平均值,即f(n)=n/2。
最壞時間復雜度:在最壞情況下,算法的時間復雜度
最好時間復雜度:在最好情況下,算法的時間復雜度
平均時間復雜度:所有可能輸入實例在等概率出現的情況下,算法的期望運行時間
通常只討論算法在最壞情況下的時間復雜度,確定算法執行時間的上界,以保證算法的運行時間不會比它更長。
時間復雜度分析舉例
分析算法時間復雜度的基本方法為:找出所有語句中語句頻度最大的那條語句作為基本語句,計算基本語句的頻度得到問題規模n的某個函數f(n),取其數量級用符號“O”表示即可。
定理 1.1:若f(n)=amnm+am?1nm?1+...+a1n+a0f(n)=a_{m}n^{m}+a_{m-1}n^{m-1}+...+a_{1}n+a_{0}f(n)=am?nm+am?1?nm?1+...+a1?n+a0?是一個m次多項式,則T(n)=O(nm)。
定理 1.1 說明,在計算算法時間復雜度時,可以忽略所有低次冪和最高次冪的系數,這樣可以簡化算法分析,也體現出了增長率的含義。
下面舉例說明一些常見的時間復雜度。
常數階
例:獲取程序支持的最大值。
const int MAXN = 1024; int get_max() {return MAXN; }這個比較好理解,一共就一句話,沒有循環,是常數時間,表示為 O(1)。
實際上,如果算法的執行時間是一個與問題規模n無關的常數,那么算法的時間復雜度為T(n)=O(1),稱為常數階。
對數階
例:給定n(n<1000)個元素的有序數組a和整數v,求v在數組中對的下標,若不存在則返回-1。
這是一個常見的查找問題,我們可以用O(n)的算法遍歷整個數組,然后去找v的值。當然,也有更快的辦法,注意到題目中的條件,數組a是有序的,所以我們可以利用二分查找來實現。
int binary_search(int n, int a[], int v) {int l = 0, r = n - 1;while(l <= r) {mid = (l + r) >> 1;if(a[mid] == v) return mid;else if(a[mid] < v)r = mid + 1;elsel = mid + 1;}return -1; }由于我們每次都可以把搜索范圍縮小一半,假設基本語句的頻度為f(n),則有2f(n)<n,f(n)<log2n,所以算法的時間復雜度為T(n)=O(log2n),稱為對數階。
線性階
例:給定n(n<1000)個元素ai,求其中奇數有多少個?
判斷一個數是偶數還是奇數,只需要求它除上 2 的余數是 0 還是 1,那么我們把所有數都判斷一遍,并且對符合條件的情況進行計數,最后返回這個計數器就是答案。
int count_odd(int n, int a[]) {int cnt = 0;for(int i = 0; i < n; ++i) {if(a[i] & 1)++cnt;}return cnt; }其中a & 1等價于a % 2
這個就是經典的線性時間復雜度O(n),稱為線性階。
線性對數階
例:給定n(n<1000)個元素的有序數組a,求有多少個二元組(i, j),滿足ai+aj=1024?其中(i<j)
枚舉ai,然后在[i+1, n)范圍內查找是否存在aj=1024-ai,存在則計數器+1,而這個查找的過程可以采用二分查找。
int count_odd(int n, int a[]) {int cnt = 0;for (int i = 0; i < n; ++i) {int l = i + 1, r = n - 1;while (l <= r) {mid = (l + r) >> 1;if(a[mid] == 1024 - a[i]) ++cnt;else if(a[mid] < v)r = mid + 1;elsel = mid + 1;}}return cnt; }該算法的時間復雜度為T(n)=O(nlog2n),稱為線性對數階。
平方階
例:給定n(n<1000)個元素ai,求有多少個二元組(i, j),滿足ai+aj是奇數?其中(i<j)
還是秉承枚舉法的思想,需要兩個變量i和j,枚舉ai和aj,再對ai+aj進行奇偶性判斷。
int count_odd_pair(int n, int a[]) {int cnt = 0;for(i = 0; i < n; ++i) {for(j = i+1; j < n; ++j) {if( (a[i] + a[j]) & 1) ++cnt;}}return cnt; }對循環語句只需要考慮循環體中語句的執行次數,以上程序段中頻度最大的語句是if( (a[i] + a[j]) & 1) ++cnt;,其頻度為f(n)=n(n?1)2f(n)=\frac{n(n-1)}{2}f(n)=2n(n?1)?,所以該算法的時間復雜度為T(n)=O(n2),稱為平方階。
多數情況下,當有若干個循環語句時,算法的時間復雜度是由最深層循環內的基本語句的頻度f(n)決定的。
立方階
例:給定n(n<1000)個元素ai,求有多少個三元組(i, j, k),滿足ai+aj+ak是奇數?其中(i<j<k)
相信通過前面兩個例子的分析,可以直接給出代碼了。
int count_odd_triple(int n, int a[]) {int cnt = 0;for(i = 0; i < n; ++i) {for(j = i+1; j < n; ++j) {for(int k = j+1; k < n; ++k) {if( (a[i] + a[j] + a[k]) & 1 )++cnt;}}}return cnt; }該程序段中頻度最大的語句是:if( (a[i] + a[j] + a[k]) & 1 ) ++cnt;,這條最深層循環內的基本語句的頻度依賴于各層循環變量的取值,算法的時間復雜度為T(n)=O(n3),稱為立方階。
常見的時間復雜度按數量級遞增排序:常數階O(1)<對數階O(log2n)<線性階O(n)<線性對數階O(nlog2n)<平方階O(n2)<立方階O(n3)<…<k次方階O(nk)<指數階O(2n)<階乘階O(n!)。
時間復雜度的計算
對于很多時間復雜度的題目,很多都是在做題時一眼就能看出程序的時間復雜度,但是無法規范地表述其推導過程。在這里總結此類題型的兩種形式以及做題技巧。
此類題應該找出主體語句中與T(n)成正比的循環變量,將之代入條件中進行計算。
m++的次數恰好與T(n)成正比,記t為該程序的執行次數并令t=m-5,有m=t+5,則(t+5+1)(t+5+1)<n,得t<n?6t<\sqrt{n}-6t<n??6,即T(n)=O(n)T(n)=O(\sqrt{n})T(n)=O(n?)。
循環主體中的變量與循環條件無關
此類題可采用數學歸納法或直接累計循環次數。多層循環時從內到外分析,忽略單步語句、條件判斷語句,只關注主體語句的執行次數。此類問題又可分為遞歸程序和非遞歸程序。
-
遞歸程序:使用公式進行遞推
-
非遞歸程序:直接累計次數
空間復雜度
算法的空間復雜度S(n)定義為該算法所需要的存儲空間,它也是問題規模n的函數:S(n)=O(f(n))S(n)=O(f(n))S(n)=O(f(n))。
一個程序在機器上執行時,除了需要寄存本身所用的指令、常數、變量和輸入數據外,還需要一些對數據進行操作和存儲一些為實現計算所需信息的輔助空間。
其中,對于輸入數據所占的具體存儲量取決于問題本身,與算法無關,這樣只需分析該算法在實現時所需的輔助空格鍵就可以了。
例:數組逆序,將一維數組a中的n個數逆序存放到原數組中。
算法1:
for(int i = 0; i < n; i++) {b[i] = a[n - i - 1]; } for(int i = 0; i < n; i++) {a[i] = b[i]; }算法1需要另外借助一個大小為n的輔助數組b,所以其空間復雜度為O(n)。
算法2:
for(int i = 0; i < n / 2; i++) {t = a[i];a[i] = a[n - i - 1];a[n - i - 1] = t; }算法2僅需要另外借助一個變量t,與問題規模n大小無關,所以其空間復雜度為O(1)。
算法原地工作是指算法所需的輔助空間為常量,即S(n)=O(1)。
關于「 算法時間/空間復雜度 」 的內容到這里就結束了。
總結
以上是生活随笔為你收集整理的Algorithm Master Road:算法的时间/空间复杂度的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: unable to access ‘ht
- 下一篇: 若一个用户进程通过read系统调用读取一