大話資料結構學習筆記(二)——演算法
1 演算法定義
演算法:演算法是解決特定問題求解步驟的描述,在計算機中表現為指令的有限序列,並且每一條指令表示一個或多個操作。
什麼是演算法呢?演算法是描述解決問題的方法。演算法(Algorithm)這個單詞最早出現在波斯數學家阿勒·花刺子密在公元825年(相當於我們中國的唐朝時期)所寫的《印度數字算術》中。如今普遍認可的對演算法的定義是:
演算法是解決特定問題求解步驟的描述,在計算機中表現為指令的有限序列,並且每條指令表示一個或多個操作。
現實世界中的問題千奇百怪,演算法當然也就千變萬化,沒有通用的演算法可以解決所有的問題。 甚至解決一個小問題,很優秀的演算法卻不一定適合它。
演算法定義中,提到了指令,指令能被人或機器等計算裝置執行。它可以是計算機指令,也可以是我們平時的語言文字。
為了解決某個或某類問題,需要把指令表示成一定的操作序列,操作序列包括一組操作,每一個操作都完成特定的功能,這就是演算法了。
2 演算法的特性
演算法具有五個基本特性:輸入、輸出、有窮性、確定性和可行性。
2.1 輸入輸出
輸入和輸出特性比較容易理解,演算法具有零個或多個輸入,且具有一個或多個輸出。
2.2 有窮性
有窮性:指演算法在執行有限的步驟之後,自動結束而不會出現無限迴圈,並且每一個步驟在可接受的時間內完成。
現實中經常會寫出死迴圈的程式碼,這就是不滿足有窮性。當然這裡有窮的概念並不是純數學意義
的,而是在實際應用當中合理的、可以接受的“有邊界”。你說你寫一個演算法,計算機需要算上個二十年,一定會結束,它在數學意義上是有窮了,可是媳婦都熬成婆了,演算法的意義也不就大了。
2.3 確定性
確定性:演算法的每一步驟都具有確定的含義,不會出現二義性。
演算法在一定條件下,只有一條執行路徑,相同的輸入只能有唯一的輸出結果。演算法的每個步驟被精確定義而無歧義。
2.4 可行性
可行性:演算法的每一步都必須是可行的,也就是說,每一步都能夠通過執行有限次數完成。
3 演算法設計的要求
3.1 正確性
正確性:演算法的正確性是指演算法至少應該具有輸入、輸出和加工處理無歧義性、能正確反映問題的需求、能夠得到問題的正確答案。
但是演算法的“正確”通常在用法上有很大的差別,大體分為以下四個層次。
- 演算法程式沒有語法錯誤。
- 演算法程式對於合法的輸入資料能夠產生滿足要求的輸出結果。
- 演算法程式對於非法的輸入資料能夠得出滿足規格說明的結果。
- 演算法程式對於精心選擇的,甚至刁難的測試資料都有滿足要求的輸出結果。
對於這四層含義,層次1要求最低,但是僅僅沒有語法錯誤實在談不上是好演算法。 這就如同僅僅解決溫飽,不能算是生活幸福一樣。而層次4是最困難的,我們幾乎不可能逐一驗證所有的輸入都得到正確的結果。
因此演算法的正確性在大部分情況下都不可能用程式來證明,而是用數學方法證明的。證明一個複雜演算法在所有層次上都是正確的,代價非常昂貴。所以一般情況下,我們把層次3作為一個演算法是否正確的標準。
3.2 可讀性
可讀性:演算法設計的另一目的是為了便於閱讀、理解和交流。
可讀性高有助於人們理解演算法,晦澀難懂的演算法往往隱含錯誤,不易被發現,並且難於除錯和修改。
3.3 健壯性
健壯性:當輸入資料不合法時,演算法也能做出相關處理,而不是產生異常或莫名其妙的結果。
一個好的演算法還應該能對輸入資料不合法的情況做合適的處理。比如輸入的時間或者距離不應該是負數等。
3.4 時間效率高和儲存量低
時間效率指的是演算法的執行時間,對於同一個問題,如果有多個演算法能夠解決,執行時間短的演算法效率高,執行時間長的效率低。
儲存量需求指的是演算法在執行過程中需要的最大儲存空間,主要指演算法程式執行時所佔用的記憶體或外部硬碟儲存空間。
4 演算法效率的度量方法
4.1 事後統計法
事後統計方法:這種方法主要是通過設計好的測試程式和資料,利用計算機計時器對不同演算法編制的程式的執行時間進行比較,從而確定演算法效率的高低。
但這種方法顯然是有很大缺陷的(不科學、不準確):
- 必須依據演算法事先編制好程式,這通常需要花費大量的時間和精力。
- 時間的比較依賴計算機硬體和軟體等環境因素,有時會掩蓋演算法本身的優劣。
- 演算法的測試資料設計困難,並且程式的執行時間往往還與測試資料的規模有很大關係,效率高的演算法在小的測試資料面前往往得不到體現。
4.2 事前估算分析法
事前分析估算方法:在計算機程式編制前,依據統計方法對演算法進行估算。
經過分析,我們發現,一個用高階程式語言編寫的程式在計算機上執行時所消耗的時間取決於下列因素:
- 演算法採用的策略、方法。
- 編譯產生的程式碼質量。
- 問題的輸入規模。
- 機器執行指令的速度。
第1條當然是演算法好壞的根本,第2條要由軟體來支援,第4條要看軟體的效能。也就是說,拋開這些與計算機硬體、軟體有關的因素,一個程式的執行時間,依賴於演算法的好壞和問題的輸入規模。所謂問題的輸入規模是指輸入量的多少。
下面舉例兩種求和的演算法。
第一種演算法:
int i, sum = 0, n = 100; // 執行1次
for (i = 1; i <= n; i ++) //執行n+1次
{
sum = sum + i; // 執行n次
}
printf("%d", sum); // 執行1次
第二種演算法:
int sum = 0, n = 100; // 執行1次
sum = (1 + n) * n / 2; // 執行1次
printf("%d", sum); // 執行1次
顯然,第一種演算法,執行了1+(n+1)+n+1=2n+3
次;而第二種演算法,是1+1+1=3
次。我們關注中間程式碼的部分,將迴圈看做一個整體,忽略頭尾迴圈判斷的開銷,那麼這兩個演算法其實就是n
次與1次的差距。演算法好壞顯而易見。
我們再來延伸一下上面這個例子。
第三種演算法:
int i, j, x = 0, sum = 0, n = 100; // 執行1次
for (i = 1; i <= n; i ++)
{
for (j = 1; j <= n; j ++)
{
x ++; // 執行n×n次
sum = sum + x;
}
}
printf("%d", sum); // 執行1次
在這個例子中,迴圈部分的程式碼整體需要執行n^2
次。顯然這個演算法的執行次數對於同樣的輸入規模n=100
,要多於前面兩種演算法,這個演算法的執行時間隨著n
的增加也將遠遠多於前面兩個。
此時你會看到,測試執行時間最可靠的方法就是計算對執行時間有消耗的基本操作的執行次數。執行時間與這個計數成正比。
5 函式的漸近增長
我們給出這樣的定義,輸入規模n在沒有限制的情況下,只要超過一個數值
N
,這個函式就總是大於另一個函式,我們稱函式是漸近增長的。
函式的漸近增長:給定兩個函式f(n)
和g(n)
,如果存在一個整數N
,使得對於所有的n>N
,f(n)
總是比g(n)
大,那麼我們可以說f(n)
的增長漸近快於g(n)
。
判斷一個演算法的效率時,函式中的常數和其他次要項常常可以忽略,而更應該關注主項(最高階項)的階數。
判斷一個演算法好不好,我們只通過少量的資料是不能做出準確判斷的。如果我們可以對比這幾個演算法的關鍵執行次數函式的漸近增長性,基本就可以分析出:某個演算法,隨著n的增大,它會越來越優於另一演算法,或者越來越差於另一演算法。這其實就是事前估算方法的理論依據,通過演算法時間複雜度來估算演算法時間效率。
6 演算法時間複雜度
6.1 演算法時間複雜度定義
在進行演算法分析時,語句總的執行次數
T(n)
是關於問題規模n的函式,進而分析T(n)
隨n
的變化情況並確定T(n)
的數量級。演算法的時間複雜度,也就是演算法的時間量度,記作:
T(n)=O(f(n))
。它表示隨問題規模n
的增大,演算法執行時間的增長率和f(n)
的增長率相同,稱作演算法的漸近時間複雜度,簡稱為時間複雜度。其中f(n)
是問題規模n
的某個函式。
這樣用大寫O(n)
來體現演算法時間複雜度的記法,我們稱之為大O記法。
一般情況下,隨著n的增大,T(n)
增長最慢的演算法為最優演算法。
顯然,由此演算法時間複雜度的定義可知,我們的三個求和演算法的時間複雜度分別為O(n)
,O(1)
, O(n^2)
。我們分別給它們取了非官方的名稱,O(1)
叫常數階、O(n)
叫線性階、O(n^2)
叫平方階,當然,還有其他的一些階,我們之後會介紹。
6.2 推導大O階方法
推導大O階
- 用常數1取代執行時間中的所有加法常數。
- 在修改後的執行次數函式中,只保留最高階項。
- 如果最高階項存在且不為1,這去除與這個項相乘的常數。
6.3 常數階
首先說順序結構的時間複雜度。下面這個演算法,也就是剛才的第二種演算法(高斯演算法),為什麼時間複雜度不是O(3)
,而是O(1)
。
int sum = 0, n = 100; // 執行1次
sum = (1 + n) * n / 2; // 執行1次
printf("%d", sum); // 執行1次
這個演算法的執行次數函式是f(n)=3
。 根據我們推導大O階的方法, 第一
步就是把常數項3改為1。 在保留最高階項時發現, 它根本沒有最高階
項, 所以這個演算法的時間複雜度為O(1)
。
這種與問題的大小無關(n
的多少),執行時間恆定的演算法,我們稱之為具有O(1)
的時間複雜度, 又叫常數階。
注意:不管這個常數是多少,我們都記作O(1)
,而不能是O(3)
、O(12)
等其他任何數字, 這是初學者常常犯的錯誤。
對於分支結構而言,無論是真,還是假,執行的次數都是恆定的,不會隨著n的變大而發生變化,所以單純的分支結構(不包含在迴圈結構中),其時間複雜度也是O(1)
。
6.4 線性階
線性階的迴圈結構會複雜很多。要確定某個演算法的階次,我們常常需要確定某個特定語句或某個語句集執行的次數。因此,我們要分析演算法的複雜度,關鍵就是要分析迴圈結構的執行情況。
下面這段程式碼,它的迴圈的時間複雜度為O(n)
,因為迴圈體中的程式碼須要執行n次。
int i;
for (i = 0; i < n; i ++)
{
/* 時間複雜度為O(1)的程式步驟序列 */
}
6.5 對數階
下面的這段程式碼,時間複雜度又是多少呢?
int count = 1;
while (count < n)
{
count = count * 2;
/* 時間複雜度為O(1)的程式步驟序列 */
}
由於每次count
乘以2之後,就距離n
更近了一分。也就是說,有多少個2相乘後大於n
,則會退出迴圈。由2^x = n
得到x=log2(n)
。所以這個迴圈的時間複雜度為O(logn)
。
6.6 平方階
下面例子是一個迴圈簽到,它的內迴圈剛才我們已經分析過,時間複雜度為O(n)
。
int i, j;
for (i = 0; i < n; i ++)
{
for (j = 0; j < n; j ++)
{
/* 時間複雜度為O(1)的程式步驟序列 */
}
}
而對於外層的迴圈,不過是內部這個時間複雜度為O(n)
的語句再迴圈n
次,所以這段程式碼的時間複雜度為O(n^2)
。
如果外迴圈的迴圈次數改為了m
,時間複雜度就變為O(m×n)
。
int i, j;
for (i = 0; i < m; i ++)
{
for (j = 0; j < n; j ++)
{
/* 時間複雜度為O(1)的程式步驟序列 */
}
}
所以我們可以總結得出,迴圈的時間複雜度等於迴圈體的複雜度乘以該迴圈執行的次數。
那麼下面這個迴圈巢狀,它的時間複雜度是多少呢?
int i, j;
for (i = 0; i < n; i ++)
{
// 注意 j = i 而不是0
for (j = i; j < n; j ++) {
/* 時間複雜度為O(1)的程式步驟序列 */
}
}
由於當i=0
時,內迴圈執行了n
次, 當i=1
時,執行了n-1
次,……當i=n-1
時, 執行了1次。所以總的執行次數為:
用我們推導大O階的方法,第一條,沒有加法常數不予考慮;第二條,只保留最高階項, 因此保留n^2/2
;第三條,去除這個項相乘的常數,也就是去除1/2,最終這段程式碼的時間複雜度為O(n^2)
。
從這個例子,我們也可以得到一個經驗,其實理解大O推導不算難,難的是對數列的一些相關運算,這更多的是考察你的數學知識和能力,所以想考研的朋友,要想在求演算法時間複雜度這裡不失分,可能需要強化你的數學,特別是數列方面的知識和解題能力。
我們繼續看例子,對於方法呼叫的時間複雜度又如何分析。
int i, j;
for (i = 0; i < n; i ++)
{
function(i);
}
上面這段程式碼呼叫一個函式function
。
void function(int count)
{
print(count);
}
函式體是列印這個引數。其實這很好理解,function
函式的時間複雜度是O(1)
。所以整體的時間複雜度為O(n)
。
假如function
是下面這樣的:
void function(int count)
{
int j;
for (j = count; j < n; j ++) {
/* 時間複雜度為O(1)的程式步驟序列 */
}
}
事實上,這和剛才舉的例子是一樣的,只不過把巢狀內迴圈放到了函式中,所以最終的時間複雜度為O(n^2)
。
下面這段相對複雜的語句:
n ++; // 執行次數為1
function(n); // 執行次數為n
int i, j;
for (i = 0; i < n; i ++) // 執行次數為n^2
{
function(i);
}
for (i = 0; i < n; i ++) // 執行次數為n(n+1)/2
{
for (j = i; j < n; j ++)
{
/* 時間複雜度為O(1)的程式步驟序列 */
}
}
它的執行次數f(n)=1+n+n^2+n(n+1)/2=3/2·n^2+3/2·n+1
,根據推導大O階的方法,最終這段程式碼的時間複雜度也是O(n^2)
。
6.7 常見的時間複雜度
執行次數 | 函式階 | 非正式術語 |
---|---|---|
12 | O(1) | 常數階 |
2n+3 | O(n) | 線性階 |
3n^2+2n+1 | O(n^2) | 平方階 |
5log2(n)+20 | O(logn) | 對數階 |
2n+3nlog2(n)+19 | O(nlogn) | nlogn階 |
6n3+2n2+3n+4 | O(n^3) | 立方階 |
2^n | O(2^n) | 指數階 |
常用的時間複雜度所耗費的時間從小到大依次是:
\[O(1)<O(log_n)<O(n)<O(nlog_n)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n) \]我們前面已經談到了O(1)
常數階、O(logn)
對數階、O(n)
線性階、O(n^2)
平方階等,至於O(nlogn)
我們將會在今後的課程中介紹,而像O(n^3)
,過大的n
都會使得結果變得不現實。同樣指數階O(2^n)
和階乘階O(n!)
等除非是很小的n
值,否則哪怕n
只是100,都是噩夢般的執行時間。所以這種不切實際的演算法時間複雜度,一般我們都不去討論它。
6.8 最壞情況與平均情況
找東西有運氣好的時候,也有怎麼也找不到的情況。但在現實中,通常我們碰到的絕大多數既不是最好的也不是最壞的,所以算下來是平均情況居多。
演算法的分析也是類似,我們查詢一個有n個隨機數字陣列中的某個數字,最好的情況是第一個數字就是,那麼演算法的時間複雜度為O(1)
,但也有可能這個數字就在最後一個位置上待著,那麼演算法的時間複雜度就是O(n)
,這是最壞的一種情況了。
最壞情況執行時間是一種保證,那就是執行時間將不會再壞了。在應用中,這是一種最重要的需求,通常,除非特別指定,我們提到的執行時間都是最壞情況的執行時間。
而平均執行時間也就是從概率的角度看,這個數字在每一個位置的可能性是相同的,所以平均的查詢時間為n/2次後發現這個目標元素。
平均執行時間是所有情況中最有意義的,因為它是期望的執行時間。也就是說,我們執行一段程式程式碼時,是希望看到平均執行時間的。可現實中,平均執行時間很難通過分析得到,一般都是通過執行一定數量的實驗資料後估算出來的。
對演算法的分析,一種方法是計算所有情況的平均值,這種時間複雜度的計算方法稱為平均時間複雜度。另一種方法是計算最壞情況下的時間複雜度,這種方法稱為最壞時間複雜度。 一般在沒有特殊說明的情況下,都是指最壞時間複雜度。
6.9 演算法空間複雜度
演算法的空間複雜度通過計算演算法所需的儲存空間實現,演算法空間複雜度的計算公式記作:
S(n)=O(f(n))
,其中,n
為問題的規模,f(n)
為語句關於n
所佔儲存空間的函式。
一般情況下,一個程式在機器上執行時,除了需要儲存程式本身的指令、常數、變數和輸入資料外,還需要儲存對資料操作的儲存單元。若輸入資料所佔空間只取決於問題本身,和演算法無關,這樣只需要分析該演算法在實現時所需的輔助單元即可。若演算法執行時所需的輔助空間相對於輸入資料量而言是個常數,則稱此演算法為原地工作,空間複雜度為O(1)
。
通常,我們都使用“時間複雜度”來指執行時間的需求,使用“空間複雜度”指空間需求。當不用限定詞地使用“複雜度”時,通常都是指時間複雜度。