問題引出
假設有一道題目:有一組N個數而要確定其中第k個最大者,我們稱之為選擇問題,那麼這個程式如何編寫?最直觀地,至少有兩種思路:
1、將N個數讀入一個陣列中,再通過某種簡單的演算法,比如氣泡排序法,以遞減順序將陣列排序,則第k個位置上的元素就是我們需要的元素
2、稍微好一些的做法,將k個元素讀入陣列並以遞減順序排序,接著將接下來的元素再逐個讀入,當新元素被讀到時,如果它小於陣列中的第k個元素則忽略之,否則將其放到陣列中正確的位置上,同時將陣列中的一個元素擠出陣列,當演算法終止時,位於第k個位置上的元素作為答案返回
這兩種演算法都很簡單,但是假設我們有一千萬個元素的隨機檔案和k=5000000進行模擬將發現,兩個演算法儘管最終都可以給出正確答案,但是在合理時間內均無法結束。因此,這兩種演算法都不能被認為是好的演算法,因為從實際角度出發,它們無法在合理的時間內處理輸入的資料。
資料結構和演算法分析的提出
在許多問題中,一個很重要的觀念是:寫出一個工作程式並不夠。如果這個程式在巨大的資料集上執行,那麼執行時間就變成了重要的問題,我們將在接下來的文章中看到對於大量的輸入如何估計程式的執行時間,尤其是如何在未具體編碼的情況下比較兩個程式執行的時間。我們還將看到徹底改程式序速度以及確定程式瓶頸的方法,這些方法將使得我們能夠發現需要我們集中精力努力優化的那些程式碼段。
那麼,首先,先了解一下什麼是資料結構和演算法分析(特別指出,後文的例子均以Java程式碼編寫)。
資料結構
資料結構是計算機儲存、組織資料的方式,是指資料相互之間存在一種或多種特定關係的資料元素的集合。通常情況下,精心選擇的資料結構可以帶來更高的執行或者儲存效率(這就是為什麼我們要研究資料結構的原因),資料結構往往同高效的檢索演算法和索引技術相關。
常見的資料結構有陣列、棧、佇列、連結串列、樹、雜湊等,這些資料結構將是本資料結構的分類中重點研究的物件。
演算法分析
演算法是為求解一個問題需要遵循的、被清楚指定的簡單指令的集合。對於一個問題,一旦某種演算法給定並且(以某種方式)被確定是正確的,那麼重要的非同步就是確定該演算法將需要多少注入時間或空間等資源量的問題。如果:
1、一個問題的求解演算法竟然需要長達一年時間,那麼這種演算法就很難有什麼用處
2、一個問題需要若干個GB的記憶體的演算法,在當前大多數機器上也是無法使用的
數學基礎
無論是資料結構還是演算法分析,都用到了大量的數學基礎,下面將這些數學的基礎簡單總結一下:
1、指數
(1)XAXB = XA+B
(2)XA/XB = XA-B
(3)(XA)B = XABsi
(4)XN + XN = 2XN ≠ X2N
(5)2N + 2N = 2N + 1
2、對數
(1)XA = B當且僅當logxB = A
(2)logAB = logCB / logCA
(3)logAB = logA + logB,A>0且B>0
3、級數
(1)∑2i = 2N+1 - 1
(2)∑Ai = (AN+1 - 1) / (A - 1),若0<A<1,則有∑Ai ≤ 1 / (1 - A)
4、模運算
如果N整除A、N整除B,那麼就說A與B模N同餘,記為A≡B(mod N)。直觀地看,這意味著無論是A還是B被N去除,所得餘數都是相同的,於是假如有A≡B(mod N),則:
(1)A + C ≡ B + C(mod N)
(2)AD ≡ BD (mod N)
時間複雜度
在電腦科學中,演算法的時間複雜度是一個函式,它定量描述了該演算法的執行時間。這是一個關於代表演算法輸入值的字串的長度的函式,時間複雜度常用大O符號表述,不包括這個函式的低階項和首項係數,使用這種方式時,時間複雜度可被稱為是漸進的,他考察當輸入值大小趨近無窮時的情況。
那麼首先先看一個簡單的例子,這裡是計算Σi3的一個簡單程式片段:
1 public static void main(String[] args) 2 { 3 System.out.println(sum(5)); 4 } 5 6 public static int sum(int n) 7 { 8 int partialSum; 9 10 partialSum = 0; 11 for (int i = 0; i <= n; i++) 12 partialSum += i * i * i; 13 14 return partialSum; 15 }
對這個程式片段的分析是簡單的:
1、宣告不記入時間
2、第10行和都14行各佔一個時間單元
3、第12行每次執行佔用4個時間單元(兩次乘法、一次加法和一次賦值),而執行N次共佔用4N個時間單元
4、第11行在初始化i,測試i≤n和對i的自增都隱含著開銷,所有這些總開銷是初始化1個時間單元,所有的測試為N+1個時間單元,所有自增為N個時間單元,共2N+2個時間單元
忽略呼叫方法和返回值的開銷,得到總量是6N+4個時間單元,按照最開始的定義不包括這個函式的低階項和首項係數,因此我們說該方法的時間複雜度是O(N)。繼而,我們順便得出若干個一般法則:
法則一----for迴圈
一個for迴圈的執行時間至多是該for迴圈內部那些語句(包括測試)的執行時間乘以迭代的次數,因此假如一個for迴圈迭代N次,那麼其時間複雜度應該為O(N)
法則二----巢狀for迴圈
從裡向外分析這些迴圈,在一組巢狀迴圈內部的一條語句總的執行時間為該語句的執行時間乘以該組所有的for迴圈的大小的乘積,因此假如有以下程式碼:
1 public static int mutliSum(int n) 2 { 3 int k = 0; 4 for (int i = 0; i < n; i++) 5 { 6 for (int j = 0; j < n; j++) 7 { 8 k++; 9 } 10 } 11 12 return k; 13 }
則其時間複雜度應為O(N2)
法則三----順序語句
將各個語句的執行時間求和即可,比如有以下程式碼:
1 public static int sum(int n) 2 { 3 int k = 0; 4 5 for (int i = 0; i < n; i++) 6 k++; 7 for (int i = 0; i < n; i++) 8 { 9 for (int j = 0; j < n; j++) 10 { 11 k++; 12 } 13 } 14 15 return k; 16 }
第一個for迴圈的時間複雜度為N,第二個巢狀for迴圈的時間複雜度為N2,綜合起來看sum方法的時間複雜度為O(N2)
常見的時間複雜度與時間效率的關係有如下的經驗規則:
O(1) < O(log2N) < O(N) < O(N * log2N) < O(N2) < O(N3) < O(N!)
至於每種時間複雜度對應哪種資料結構和演算法,後面都會講到,從上面的經驗規則來看:前四個演算法效率比較高,中間兩個差強人意,最後一個比較差(只要n比較大,這個演算法就動不了了)。