上一篇--JavaScript 演算法之複雜度分析文章中介紹了複雜度的分析,相信小夥伴們對常見程式碼的時間或者空間複雜度肯定能分析出來了。
思考測試
話不多說,出個題目考考大家,分析下面程式碼的時間複雜度(ps: 雖然說並不會這麼寫)
function find(n, x, arr) {
let ind = -1;
for (let i = 0; i < n; i++) {
if (arr[i] === x) ind = i;
}
return ind;
}
複製程式碼
上面函式的功能就是查詢一個變數 x 是否在 陣列 arr 中,如果在的話,返回所在的位置,否則就返回 -1。通過上一節的學習分析,這個函式的時間複雜度就很容易知道了,為 O(n)。
接下來,稍微優化下這個 find
函式,如果查詢到目標的話,就沒必要再往後查詢了。
請分析下優化後函式的時間複雜度:
function find(n, x, arr) {
let ind = -1;
for (let i = 0; i < n; i++) {
if (arr[i] === x) {
ind = i;
break;
}
}
return ind;
}
複製程式碼
現在程式碼的時間複雜度還為 O(n)嗎?不確定,利用上一章的分析法就無法解決了。
不同情況
因為要查詢的變數 x 可能會出現在陣列的任意位置。如果變數 x 恰好是陣列中第一個元素,那麼函式就會
break
,後續就不會繼續遍歷了,那時間複雜度就是 O(1)。但如果恰好是陣列中第末個元素,或者陣列中不存在變數 x 的話,那就需要把整個陣列都遍歷一遍,時間複雜度就成了 O(n)。所以,不同的情況下,這個函式的時間複雜度是不一樣的。
那為了表示程式碼在不同情況下的不同時間複雜度,需要了解以下三個概念:最好情況時間複雜度、最壞情況時間複雜度和平均情況時間複雜度**。
複製程式碼
理想情況
最好情況時間複雜度:在最理想的情況下,執行這段程式碼的時間複雜度。比如說剛才那段函式,在最理想的情況下,要查詢的變數 x 正好是陣列的第一個元素,這種情況下對應的時間複雜度就是最好情況時間複雜度。
糟糕情況
最壞情況時間複雜度:在最糟糕的情況下,執行這段程式碼的時間複雜度。比如說剛才那段函式,要查詢的變數 x 正好是陣列的第末個元素或者不在陣列中存在 ,查詢函式就會把陣列都遍歷一遍,這種情況下對應的時間複雜度就是最壞情況時間複雜度。
平均情況
但是,最好情況時間複雜度和最壞情況時間複雜度對應的都是極端情況下的程式碼複雜度,發生的概率其實並不大。
為了更好地表示平均情況下的複雜度,引入另一個概念:平均情況時間複雜度,簡稱為平均時間複雜度。
那如何分析平均時間複雜度呢,還是拿剛才那段查詢函式來說:
要查詢的變數 x 在陣列中的位置,有 n+1 種情況:在陣列的 0~n-1 位置中和不在陣列中。然後把每種情況下,查詢需要遍歷的元素個數累加起來,然後再除以 n+1,就可以得到需要遍歷的元素個數的平均值。
根據上章所說,時間複雜度的大 O 標記法中,可以省略掉係數、低階、常量,所以,把這個公式簡化之後,得到的平均時間複雜度就是 O(n)。
但是上面計算的過程中,沒有考慮到概率的問題,因為出現在每個位置的概率是不一樣的,所以得重新計算,如下分析:
要查詢的變數 x,要麼在陣列裡,要麼就不在陣列裡。簡單標記這兩種情況下的概率都為 1/2。另外,要查詢的資料出現在 0~n-1 這 n 個位置的概率也是一樣的,為 1/n。所以,根據概率乘法法則,要查詢的資料出現在 0~n-1 中任意位置的概率就是 1/(2n)。那我們把每種情況發生的概率都考慮進去,計算表示式就變成了:
最後的結果也叫做概率中的加權平均值,那最後此段函式的平均時間複雜度就為 O(n)。
這麼看,平均時間複雜度是不是好麻煩,還需要概率計算。實際上,在大多數情況下,我們並不需要區分最好、最壞、平均情況時間複雜度三種情況。很多時候,我們使用一個複雜度就可以滿足需求了。只有同一塊程式碼在不同的情況下,時間複雜度有量級的差距,我們才會使用這三種複雜度表示法來區分。
均攤情況
接下來再看一個概念,特殊的平均時間複雜度:均攤時間複雜度。
先來看一個特殊的函式,分析下它的時間複雜度:
{
var arr = new Array(n); // n 代表任意數字
var ind = 0;
function add(num) {
if (ind === arr.length) {
var sum = 0;
for (var i = 0; i < arr.length; i++) {
sum += arr[i];
}
arr[0] = sum;
ind = 1;
}
arr[ind] = num;
ind++;
}
}
複製程式碼
add
函式就是實現一個往陣列中新增資料的功能。先定義一個任意長度的空陣列,然後給陣列新增資料。當達到陣列長度後,也就是ind === array.length
時,用for
迴圈遍歷陣列求和,將求和之後的sum
值放到陣列的第一個位置,然後再將新的資料插入。但如果陣列一開始就有空的話,則直接將資料新增到陣列中。
來分析下此函式的時間複雜度:
最理想的情況下,陣列中有剩餘位置,我們只需要將資料新增到陣列下標為
ind
的位置就可以了,所以最好情況時間複雜度為 O(1)。 最糟糕的情況下,陣列中沒有剩餘位置,我們需要先做一次陣列的遍歷求和,然後再新增資料,所以最壞情況時間複雜度為 O(n)。
接下來分析需要計算的 平均時間複雜度:
由於陣列的長度是 n,根據資料新增的位置的不同,可以分為 n 種情況,每種情況的時間複雜度是 O(1)。除此之外,還有一種特殊的情況,就是在陣列沒有空閒空間時新增一個資料,這個時候的時間複雜度是 O(n)。而且,這 n+1 種情況發生的概率一樣,都是 1/(n+1)。
所以根據大 O 表示法,平均時間複雜度就為 O(1)。
其實 add
函式的平均複雜度不需要這麼複雜,接下來我們看看 find
函式和add
函式的區別:
find
函式在極端情況下,時間複雜度才為 O(1)。但add
函式在大部分情況下,時間複雜度都為 O(1)。只有個別情況下,時間複雜度才比較高,為 O(n)。- 對於
add
函式來說,O(1) 時間複雜度的新增和 O(n) 時間複雜度的新增,出現的頻率是非常有規律的,而且有一定的前後順序,一般都是一個 O(n) 新增之後,緊跟著 n-1 個 O(1) 的新增操作,迴圈往復。
所以,針對這樣一種特殊場景的複雜度分析,我們並不需要像之前講平均複雜度分析方法那樣,找出所有的輸入情況及相應的發生概率,然後再計算加權平均值。
針對這種特殊的情況,我們引入了一種更加簡單的分析方法:攤還分析法。通過攤還分析得到的時間複雜度,叫 均攤時間複雜度。
那如何使用攤還分析法來分析演算法的均攤時間複雜度呢?
還是看
add
函式。每一次 O(n) 的新增操作,都會跟著 n-1 次 O(1) 的新增操作,所以把耗時多的那次操作均攤到接下來的 n-1 次耗時少的操作上,均攤下來,這一組連續的操作的均攤時間複雜度就是 O(1)。這就是均攤分析的大致方法。
一般情況總結為:
對一個資料結構進行一組連續操作中,大部分情況下時間複雜度都很低,只有個別情況下時間複雜度比較高,而且這些操作之間存在前後連貫的時序關係,這個時候,我們就可以將這一組操作放在一塊兒分析,看是否能將較高時間複雜度那次操作的耗時,平攤到其他那些時間複雜度比較低的操作上。而且,在能夠應用均攤時間複雜度分析的場合,一般均攤時間複雜度就等於最好情況時間複雜度。
生活舉例
看高人如何把複雜度利用到生活中:
今天你準備去老王家拜訪下,可惜老王的愛人叫他去打個醬油,她告訴你說她限時 n 分鐘給他去買。
那麼你想著以他家到樓下小賣部來回最多一分鐘,那麼 “最好的情況”就是你只用等他一分鐘。
那麼也有可能遇到突發情況,比如說電梯沒電了,或者路上摔了一跤,天知道他去幹了什麼,用了 n 分鐘。沒辦法,老婆有令,n 分鐘限時,那這就是“最壞的情況”。
那“平均時間複雜度” 就是他有可能是第 1,2,3,...,n 中的某個分鐘回來,那平均就是 1+2+3+...n/n,把 所有可能出現的情況的時間複雜度 相加除以情況數 。
“均攤時間複雜度”的話就是把花時間多的分給花時間少的,得到一箇中間值。假如 n 是 10 分鐘,那麼 9 分鐘分 4 分鐘到 1 分鐘那,8 分 3 給 2...,那均攤下來就是 5 分鐘。
總結
4 個概念
- 最好情況時間複雜度:程式碼在最理想情況下執行的時間複雜度。
- 最壞情況時間複雜度:程式碼在最糟糕情況下執行的時間複雜度。
- 平均情況時間複雜度:用程式碼在所有情況下執行的次數的加權平均值表示。
- 均攤時間複雜度:在程式碼執行的所有複雜度情況中絕大部分是最好情況時間複雜度,個別情況是最壞情況時間複雜度且發生具有時序關係時,可以將個別最壞情況時間複雜度均攤到最好情況時間複雜度上。基本上均攤結果就等於最好情況時間複雜度。
引入目的
- 同一段程式碼在不同情況下時間複雜度會出現量級差異,為了更全面,更準確的描述程式碼的時間複雜度,所以引入這4個概念。
- 程式碼複雜度在不同情況下出現量級差別時才需要區別這四種複雜度。大多數情況下,是不需要區別分析它們的。
重點
如果有錯誤或者錯別字,還請給我留言指出,謝謝。
我們下期見。