[本專題會對常見的數據結構及相應算法進行分析與總結,並會在每一個系列的博文中提供幾道相關的一線互聯網企業面試/筆試題來鞏固所學及幫助咱們查漏補缺。項目地址:https://github.com/absfree/Algo。因爲我的水平有限,敘述中不免存在不清晰準確的地方,但願你們能夠指正,謝謝你們:)] git
在進一步學習數據結構與算法前,咱們應該先掌握算法分析的通常方法。算法分析主要包括對算法的時空複雜度進行分析,但有些時候咱們更關心算法的實際運行性能如何,此外,算法可視化是一項幫助咱們理解算法實際執行過程的實用技能,在分析一些比較抽象的算法時,這項技能尤其實用。在在本篇博文中,咱們首先會介紹如何經過設計實驗來量化算法的實際運行性能,而後會介紹算法的時間複雜度的分析方法,咱們還會介紹可以很是便捷的預測算法性能的倍率實驗。固然,在文章的末尾,咱們會一塊兒來作幾道一線互聯網的相關面試/筆試題來鞏固所學,達到學以至用。github
在介紹算法的時空複雜度分析方法前,咱們先來介紹如下如何來量化算法的實際運行性能,這裏咱們選取的衡量算法性能的量化指標是它的實際運行時間。一般這個運行時間與算法要解決的問題規模相關,好比排序100萬個數的時間一般要比排序10萬個數的時間要長。因此咱們在觀察算法的運行時間時,還要同時考慮它所解決問題的規模,觀察隨着問題規模的增加,算法的實際運行時間時怎樣增加的。這裏咱們採用算法(第4版) (豆瓣)一書中的例子,代碼以下:面試
public class ThreeSum { public static int count(int[] a) { int N = a.length; int cnt = 0; for (int i = 0; i < N; i++) { for (int j = i + 1; j < N; j++) { for (int k = j + 1; k < N; k++) { if (a[i] + a[j] + a[k] == 0) { cnt++; } } } } return cnt; } public static void main(String[] args) { int[] a = StdIn.readAllInts(); StdOut.println(count(a)); } }
以上代碼用到的StdIn和StdOut這兩個類都在這裏:https://github.com/absfree/Algo。咱們能夠看到,以上代碼的功能是統計標準一個int[]數組中的全部和爲0的三整數元組的數量。採用的算法十分直接,就是從頭開始遍歷數組,每次取三個數,若和爲0,則計數加一,最後返回的計數值即爲和爲0的三元組的數量。這裏咱們採起含有整數數量分別爲1000、2000、4000的3個文件(這些文件能夠在上面的項目地址中找到),來對以上算法進行測試,觀察它的運行時間隨着問題規模的增加是怎樣變化的。算法
測量一個過程的運行時間的一個直接的方法就是,在這個過程運行先後各獲取一次當前時間,二者的差值即爲這個過程的運行時間。當咱們的過程自己須要的執行時間很短期,這個測量方法可能會存在一些偏差,可是咱們能夠經過執行屢次這個過程再取平均數來減少以致能夠忽略這個偏差。下面咱們來實際測量一下以上算法的運行時間,相關代碼以下:小程序
public static void main(String[] args) { int[] a = In.readInts(args[0]); long startTime = System.currentTimeMillis(); int count = count(a); long endTime = System.currentTimeMillis(); double time = (endTime - startTime) / 1000.0; StdOut.println("The result is: " + count + ", and takes " + time + " seconds."); }
咱們分別以1000、2000、4000個整數做爲輸入,獲得的運行結果以下數組
The result is: 70, and takes 1.017 seconds. //1000個整數 The result is: 528, and takes 7.894 seconds. //2000個整數 The result is: 4039, and takes 64.348 seconds. //4000個整數
咱們從以上結果大概可你看到,當問題的規模變爲原來的2倍時,實際運行時間大約變爲原來的8倍。根據這個現象咱們能夠作出一個猜測:程序的運行時間關於問題規模N的函數關係式爲T(N) = k*(n^3).數據結構
在這個關係式中,當n變爲原來的2倍時,T(N)會變爲原來的8倍。那麼ThreeSum算法的運行時間與問題規模是否知足以上的函數關係呢?在介紹算法時間複雜度的相關內容後,咱們會回過頭來再看這個問題。dom
關於算法的時間複雜度,這裏咱們先簡單介紹下相關的三種符號記法:函數
咱們在日常的算法分析中最經常使用到的是Big O notation。下面咱們將介紹分析算法的時間複雜度的具體方法,若對Big O notation的概念還不是很瞭解,推薦你們看這篇文章:http://blog.jobbole.com/55184/。性能
這部分咱們將以上面的ThreeSum程序爲例,來介紹一下算法時間複雜度的分析方法。爲了方便閱讀,這裏再貼一下上面的程序:
1 public static int count(int[] a) { 2 int N = a.length; 3 int cnt = 0; 4 for (int i = 0; i < N; i++) { 5 for (int j = i + 1; j < N; j++) { 6 for (int k = j + 1; k < N; k++) { 7 if (a[i] + a[j] + a[k] == 0) { 8 cnt++; 9 } 10 } 11 } 12 } 13 return cnt; 14 }
在介紹時間複雜度分析方法前,咱們首先來明確下算法的運行時間究竟取決於什麼。直觀地想,一個算法的運行時間也就是執行全部程序語句的耗時總和。然而在實際的分析中,咱們並不須要考慮全部程序語句的運行時間,咱們應該作的是集中注意力於最耗時的部分,也就是執行頻率最高並且最耗時的操做。也就是說,在對一個程序的時間複雜度進行分析前,咱們要先肯定這個程序中哪些語句的執行佔用的它的大部分執行時間,而那些儘管耗時大但只執行常數次(和問題規模無關)的操做咱們能夠忽略。咱們選出一個最耗時的操做,經過計算這些操做的執行次數來估計算法的時間複雜度,下面咱們來具體介紹這一過程。
首先咱們看到以上代碼的第1行和第2行的語句只會執行一次,所以咱們能夠忽略它們。而後咱們看到第4行到第12行是一個三層循環,最內存的循環體包含了一個if語句。也就是說,這個if語句是以上代碼中耗時最多的語句,咱們接下來只須要計算if語句的執行次數便可估計出這個算法的時間複雜度。以上算法中,咱們的問題規模爲N(輸入數組包含的元素數目),咱們也能夠看到,if語句的執行次數與N是相關的。咱們不可貴出,if語句會執行N * (N - 1) * (N - 2) / 6次,所以這個算法的時間複雜度爲O(n^3)。這也印證了咱們以前猜測的運行時間與問題規模的函數關係(T(n) = k * n ^ 3)。由此咱們也能夠知道,算法的時間複雜度刻畫的是隨着問題規模的增加,算法的運行時間的增加速度是怎樣的。在日常的使用中,Big O notation一般都不是嚴格表示最壞狀況下算法的運行時間上限,而是用來表示一般狀況下算法的漸進性能的上限,在使用Big O notation描述算法最壞狀況下運行時間的上限時,咱們一般加上限定詞「最壞狀況「。
經過以上分析,咱們知道分析算法的時間複雜度只須要兩步(比把大象放進冰箱還少一步:) ):
在以上的例子中咱們能夠看到,不論咱們輸入的整型數組是怎樣的,if語句的執行次數是不變的,也就是說上面算法的運行時間與輸入無關。而有些算法的實際運行時間高度依賴於咱們給定的輸入,關於這一問題下面咱們進行介紹。
算法的指望運行時間咱們能夠理解爲,在一般狀況下,算法的運行時間是多少。在不少時候,咱們更關心算法的指望運行時間而不是算法在最壞狀況下運行時間的上限,由於最壞狀況和最好狀況發生的機率是比較低的,咱們更常遇到的是通常狀況。好比說盡管快速排序算法與歸併排序算法的時間複雜度都爲O(nlogn),可是在相同的問題規模下,快速排序每每要比歸併排序快,所以快速排序算法的指望運行時間要比歸併排序的指望時間小。然而在最壞狀況下,快速排序的時間複雜度會變爲O(n^2),快速排序算法就是一個運行時間依賴於輸入的算法,對於這個問題,咱們能夠經過打亂輸入的待排序數組的順序來避免發生最壞狀況。
下面咱們來介紹一下算法(第4版) (豆瓣)一書中的「倍率實驗」。這個方法可以簡單有效地預測程序的性能並判斷他們的運行時間大體的增加數量級。在正式介紹倍率實驗前,咱們先來簡單介紹下「增加數量級「這一律念(一樣引用自《算法》一書):
咱們用~f(N)表示全部隨着N的增大除以f(N)的結果趨於1的函數。用g(N)~f(N)表示g(N) / f(N)隨着N的增大趨近於1。一般咱們用到的近似方式都是g(N) ~ a * f(N)。咱們將f(N)稱爲g(N)的增加數量級。
咱們仍是拿ThreeSum程序來舉例,假設g(N)表示在輸入數組尺寸爲N時執行if語句的次數。根據以上的定義,咱們就能夠獲得g(N) ~ N ^ 3(當N趨向於正無窮時,g(N) / N^3 趨近於1)。因此g(N)的增加數量級爲N^3,即ThreeSum算法的運行時間的增加數量級爲N^3。
如今,咱們來正式介紹倍率實驗(如下內容主要引用自上面提到的《算法》一書,同時結合了一些我的理解)。首先咱們來一個熱身的小程序:
public class DoublingTest { public static double timeTrial(int N) { int MAX = 1000000; int[] a = new int[N]; for (int i = 0; i < N; i++) { a[i] = StdRandom.uniform(-MAX, MAX); } long startTime = System.currentTimeMillis(); int count = ThreeSum.count(a); long endTime = System.currentTimeMillis(); double time = (endTime - startTime) / 1000.0; return time; } public static void main(String[] args) { for (int N = 250; true; N += N) { double time = timeTrial(N); StdOut.printf("%7d %5.1f\n", N, time); } } }
以上代碼會以250爲起點,每次講ThreeSum的問題規模翻一倍,並在每次運行ThreeSum後輸出本次問題規模和對應的運行時間。運行以上程序獲得的輸出以下所示:
250 0.0 500 0.1 1000 0.6 2000 4.3 4000 30.6
上面的輸出之因此和理論值有所出入是由於實際運行環境是複雜多變的,於是會產生許多誤差,儘量減少這種誤差的方式就是屢次運行以上程序並取平均值。有了上面這個熱身的小程序作鋪墊,接下來咱們就能夠正式介紹這個「能夠簡單有效地預測任意程序執行性能並判斷其運行時間的大體增加數量級」的方法了,實際上它的工做基於以上的DoublingTest程序,大體過程以下:
DoublingRatio程序以下:
運行倍率程序,咱們能夠獲得以下輸出:
250 0.0 2.0 500 0.1 5.5 1000 0.5 5.4 2000 3.7 7.0 4000 27.4 7.4 8000 218.0 8.0
咱們能夠看到,time/prev確實收斂到了8(2^3)。那麼,爲何經過使輸入不斷翻倍而反覆運行程序,運行時間的比例會趨於一個常數呢?答案是下面的[倍率定理]:
若T(N) ~ a * N^b * lgN,那麼T(2N) / T(N) ~2^b。
以上定理的證實很簡單,只須要計算T(2N) / T(N)在N趨向於正無窮時的極限便可。其中,「a * N^b * lgN」基本上涵蓋了常見算法的增加量級(a、b爲常數)。值得咱們注意的是,當一個算法的增加量級爲NlogN時,對它進行倍率測試,咱們會獲得它的運行時間的增加數量級約爲N。實際上,這並不矛盾,由於咱們並不能根據倍率實驗的結果推測出算法符合某個特定的數學模型,咱們只可以大體預測相應算法的性能(當N在16000到32000之間時,14N與NlgN十分接近)。
考慮下咱們以前在 深刻理解數據結構之鏈表 中提到的ResizingArrayStack,也就是底層用數組實現的支持動態調整大小的棧。每次添加一個元素到棧中後,咱們都會判斷當前元素是否填滿的數組,如果填滿了,則建立一個尺寸爲原來兩倍的新數組,並把全部元素從原數組複製到新數組中。咱們知道,在數組未填滿的狀況下,push操做的複雜度爲O(1),而當一個push操做使得數組被填滿,建立新數組及複製這一工做會使得push操做的複雜度驟然上升到O(n)。
對於上面那種狀況,咱們顯然不能說push的複雜度是O(n),咱們一般認爲push的「平均複雜度」爲O(1),由於畢竟每n個push操做纔會觸發一次「複製元素到新數組」,於是這n個push把這一代價一均攤,對於這一系列push中的每一個來講,它們的均攤代價就是O(1)。這種記錄全部操做的總成本併除以操做總數來說成本均攤的方法叫作均攤分析(也叫攤還分析)。
前面咱們介紹了算法分析的一些姿式,那麼如今咱們就來學以至用,一塊兒來解決幾道一線互聯網企業有關於算法分析的面試/筆試題。
【騰訊】下面算法的時間複雜度是____
int foo(int n) {
if (n <= 1) {
return 1;
}
return n * foo(n - 1);
}
看到這道題要咱們分析算法時間複雜度後,咱們要作的第一步即是肯定關鍵操做,這裏的關鍵操做顯然是if語句,那麼咱們只須要判斷if語句執行的次數便可。首先咱們看到這是一個遞歸過程:foo會不斷的調用自身,直到foo的實參小於等於1,foo就會返回1,以後便不會再執行if語句了。由此咱們能夠知道,if語句調用的次數爲n次,因此時間複雜度爲O(n)。
【京東】如下函數的時間複雜度爲____
void recursive(int n, int m, int o) {
if (n <= 0) {
printf("%d, %d\n", m, o);
} else {
recursive(n - 1, m + 1, o);
recursive(n - 1, m, o + 1);
}
}
這道題明顯要比上道題難一些,那麼讓咱們來循序漸進的解決它。首先,它的關鍵操做時if語句,所以咱們只需判斷出if語句的執行次數便可。以上函數會在n > 0的時候不斷遞歸調用自身,咱們要作的是判斷在到達遞歸的base case(即n <= 0)前,共執行了多少次if語句。咱們假設if語句的執行次數爲T(n, m, o),那麼咱們能夠進一步獲得:T(n, m, o) = T(n-1, m+1, o) + T(n-1, m, o+1) (當n > 0時)。咱們能夠看到base case與參數m, o無關,所以咱們能夠把以上表達式進一步簡化爲T(n) = 2T(n-1),由此咱們可得T(n) = 2T(n-1) = (2^2) * T(n-2)......因此咱們能夠獲得以上算法的時間複雜度爲O(2^n)。
【京東】以下程序的時間複雜度爲____(其中m > 1,e > 0)
x = m;
y = 1;
while (x - y > e) {
x = (x + y) / 2;
y = m / x;
}
print(x);
以上算法的關鍵操做即while語句中的兩條賦值語句,咱們只須要計算這兩條語句的執行次數便可。咱們能夠看到,當x - y > e時,while語句體內的語句就會執行,x = (x + y) / 2使得x不斷變小(當y<<x時,執行一次這個語句會使x變爲約原來的一半),假定y的值固定在1,那麼循環體的執行次數即爲~logm,而實際狀況是y在每次循環體最後都會被賦值爲m / x,這個值老是比y在上一輪循環中的值大,這樣一來x-y的值就會更小,因此以上算法的時間複雜度爲O(logm)。
【搜狗】假設某算法的計算時間可用遞推關係式T(n) = 2T(n/2) + n,T(1) = 1表示,則該算法的時間複雜度爲____
根據題目給的遞推關係式,咱們能夠進一步獲得:T(n) = 2(2T(n/4) + n/2) + n = ... 將遞推式進一步展開,咱們能夠獲得該算法的時間複雜度爲O(nlogn),這裏就不貼上詳細過程了。