「Mathematical Analysis of Algorithms」是著名計算機界大神Knuth在1971年發表的論文。之前只是據說Knuth很是神,看了這篇論文才體會到Knuth到底有多神…Orzpython
此外,特別感謝 @solaaaaa 聚聚,沒有他的指導我可能根本看不懂這篇論文…...算法
做爲算法分析這一領域的早期論文,這篇文章回答瞭如下兩個問題:數組
算法分析的核心目的是什麼?數據結構
使用量化方法來衡量算法的「好壞」。函數
算法分析解決的是哪些類型的問題?spa
A. 對一個特定算法的時間複雜度和空間複雜度的分析。設計
B. 對解決一類問題的全部算法進行分析,試圖找出「最好」的算法。code
此外,本文特別指出,雖然B類的問題看上去比A類的問題更有價值去解決,但對(2)類問題的研究存在兩個重大的問題:一、只要對「最好」的定義稍有改變,一個B類問題就會變成另外一個徹底不一樣的(2)類問題;二、B類問題每每過於複雜,難以進行有效的數學建模,而若是創建過於簡化的模型,容易獲得不合常理的結果。出於以上緣由,只有少數B類問題(如基於比較的排序) 能獲得有效的解決,絕大多數算法分析依然是對A類問題的研究。排序
以後本文的核心,就是經過兩個例子,來展示算法分析的基本思路。遞歸
給定一個一維數組\(x_1, x_2, …, x_n\)和一個對\(1,2,…,n\)的排列\(p(1),p(2),…,p(n)\),咱們須要將一維數組\(x\)根據函數\(p\)替換爲一維數組$x_{p(1)}, x_{p(2)}, …,x_{p(n)} $,有如下要求:
對如下算法的設計基於如下一個重要的事實:在任意一個排列\(p(1),p(2),…,p(n)\),咱們總會存在若干個「環」,這個環形如\(p(i_1)=i_2,p(i_2)=i_3,…,p(i_k)=i_1\)。學過抽象代數的同窗能夠對此給出一個嚴格證實。例如對於一個排列:
\[ (p(1), p(2),…,p(9))=(8,2,7,1,6,9,3,4,5) \]
經過觀察咱們發現瞭如下環:
\[ \begin{cases} p(1)=8, p(8)=4, p(4)=1 \\ p(2)=2 \\ p(3)=7 ,p(7 )=3 \\ p(5)=6,p(6)=9,p(9)=5 \end{cases} \]
咱們就能夠定義這個環中最小的值爲這個環的頭元素。那麼只要咱們發現了一個環的頭元素\(k\),就能夠將原來的數組中的\(x_k\)空出來,將緊隨其後的元素\(x_{p(k)}\)填入,而後\(x_{p(k)}\)的位置就會被空出來能夠填入\(x_{p(p(k))}\)……最後將頭元素\(x_k\)填入環尾部那個排列對應的\(x\)數組的位置便可。
對應的僞代碼描述以下:
for j = 1 to n: # 外循環 # 判斷 j 是否是環的頭元素 # 從p(j)開始遍歷這個環 k = p(j) # 若是j不是環的頭元素,那麼就會存在一個環上點k<j while k > j: # 循環1 a k = p(k) if k == j: # 語塊2 b # k就是環的頭元素 y = x[j], l = p(k) while l != j: # 循環3 c x[k] = x[l], k = l, l = p(k) x[k] = y # 到這一步爲止一個環上的元素所有按照排列置換完畢
對一個特定算法分析(A類問題)而言,最重要的是證實算法的正確性。在正確的基礎上,咱們討論它的最好狀況複雜度,最壞狀況複雜度和平均狀況複雜度。對平均狀況的分析每每有更重要的意義。但就算法分析而言,通常對平均狀況的分析會複雜得多,最後經常歸結爲某個很難的數學問題。
就這個問題而言,爲了便於討論,咱們將內層循環和語句塊如上面僞代碼註釋所示進行標號$a,b,。
事實上,算法的設計過程已經簡單說明了這個算法的正確性。要給出一個嚴謹的證實很是麻煩這裏再也不贅述。
顯然,外層循環不管如何都會被執行\(n\)次,所以咱們主要考慮內層\(a\)和\(b\)的執行次數。
咱們先構造只有一個長\(n\)的環的狀況。令
\[ (p(1), p(2),…,p(n))=(2,3,...,n,1) \]
那麼\(a\)就進入了最壞狀況,這樣,對於第\(i\)個外層循環,\(a\)循環須要執行\(n-i\)次,總共執行次數爲
\[ \sum_{i=1}^n (n-i) = O(n^2) \]
巧妙的是,此時恰好是\(b\)的最好狀況!所以\(b\)只用執行一次,這一次執行的複雜度爲\(O(n)\)。
所以,這個最壞狀況的執行時間爲\(O(n^2)\)。
咱們再構造有\(n\)個環的狀況。令
\[ (p(1),p(2),...,p(n))=(1,2,...,n) \]
這時\(b\)進入了最壞狀況,\(a\)進入了最好狀況,相似分析可得這種狀況的執行時間爲\(O(n)\)。
咱們首先對這個問題進行從新建模。依然是對以前舉過的排列
\[ (p(1), p(2),…,p(9))=(8,2,7,1,6,9,3,4,5) \]
咱們能夠將它的環寫成\((5,6,9)(3,7)(2)(1,8,4)\),知足:一、每一個環開頭是其最小的元素。二、每一個環的頭元素從大到小遞減,那麼就能夠直接表示成另外一個排列\((q(1),q(2),…,q(9))=(5,6,9,3,7,2,1,8,4)\),由於咱們只要找到其中的數\(q_k\)使得\(q_k=min\{q_1,q_2,…,q_k\}\),那麼這個數就是一個環的頭元素,這個環從這個元素開始直到下一個具備相同性質的元素以前爲止。好比,因爲\(3=min\{5,6,9,3\}\),咱們就發現了\(q(4)=3\)是一個環的頭元素,這個環是\((3,7)\),由於咱們發現了\(7\)以後的\(2\)比前面全部數都小,因此\(2\)就是下一個環的元素了。這樣,咱們就創建了從一個排列\(p\)到另外一個排列\(q\)的映射。
咱們對\(q\)這個排列,首先研究\(a\)循環在平均狀況下的執行次數。
對於任意一個\(a\)循環,它從一個隨機的環上的元素開始向後遍歷,也就是說,若是外循環中\(j=q(i)\),\(k\)就會一直日後進入\(q(i+1),q(i+2),…,\)直到找到一個位置\(q(i+r)\)使得\(q(i+r)<q(i)\),或者\(q(i)\)就是環的頭元素。因此,當外循環\(j=q(i)\)時,就會從\(q(i)\)到\(q(i+r)\)執行這麼屢次。這樣咱們能夠用如下方式來表示循環\(a\)的執行次數。令
\[ y_{ij} = \begin{cases} 1, if\ q(i)<q(k)\ for\ i < k \le j \\ 0, \ otherwise \end {cases} \]
那麼
\[ a=\sum_{1\le i<j \le n}y_{ij} \]
對於上面的例子而言第一行只有\(y_{12}=y_{13}=1\),由於當外循環中的變量\(j=q(1)\)時,循環\(a\)會被執行\(2\)次,直到遍歷回到\(q(1)\)發現\(q(1)\)就是頭元素爲止,因爲咱們構造\(q\)的時候讓頭元素遞減,因此若是考慮成由於發現\(q(1)<q(4)\)而退出,不會對研究循環\(a\)的執行次數形成影響。一樣地對於外循環變量\(j=q(2)\)的狀況,只會日後執行\(1\)次就會發現\(q(2)<q(4)\),所以第二行只有\(y_{23}=1\)。相似分析還能找到\(y_{45}=y_{78}=y_{79}=1\)。
爲何要定義這麼一個\(y_{ij}\)呢?由於咱們很是容易就能夠發現\(y_{ij}\)的平均值\(\bar{y}_{ij}\)。這個平均值就是總共\(n!\)個排列中\(y_{ij}=1\)的排列個數。這也就是\(q(i)=min(q(k)|i \le k \le j)\)的機率,也就是\({1}/({j-i+1})\)(給K神跪了)所以
\[ \begin{align} \bar{a} &= \sum_{1\le i<j\le n}\bar{y}_{ij}\\ &= \sum_{1\le i < j \le n}\frac{1}{j-i+1} \ \ {(咱們固定i先對j求和,而後對i求和)}\\ &= (\frac{1}{2-1+1}+\frac{1}{3-1+1}+...+\frac{1}{n-1+1}) \\ &+(\frac{1}{3-2+1}+\frac{1}{4-2+1}+...+\frac{1}{n-2+1})+...+(\frac{1}{n-(n-1)+1}) \\ &= \frac{n-1}{2}+\frac{n-2}{3}+...+\frac{1}{n} \\ &= \sum_{2 \le r \le n}\frac{n+1-r}{r} \ \ (令r=j-i+1) \\ &= (n+1)(H_n-1)-(n-1) \\ &= (n+1)H_n - 2n \\ \end {align} \]
其中\(H_n\)是調和級數,使用積分法能夠證實
\[ H_n=\sum_{i=1}^{n}\frac{1}{i}=O(logn) \]
因此咱們獲得\(a\)循環的平均執行次數爲\(O(logn)\)。
下面分析\(b\)的執行次數。顯然,環有多少個,\(b\)就會被進入多少次。這全部次數加起來的循環\(c\)的執行代價爲\(O(n)\)固定不變。所以咱們須要考慮\(b\)平均會被進入多少次,也就是全部長度爲\(n\)排列中平均有多少個環。在Knuth的TAOCP, 1.2.10中已經對此有過度析,所以原文沒有寫出。(由於我沒有TAOCP有大概也看不懂)因此咱們只寫一個結論:有\(k\)個環的排列共有\([n,k]\)個,這個\([n,k]\)是第一類Stirling數。最後能夠獲得\(b\)的平均值恰好是調和級數!(我推了推死活推不出來…)
\[ \bar{b} = H_n = O(logn) \]
經過以上討論咱們就嚴格證實了平均狀況下這個算法的複雜度是\(O(n\log n)\)。
原文還有關於\(a,b\)的方差討論,因爲計算極其繁雜在此就不寫了。重要的結論是,它的方差證實了\(O(n^2)\)的最壞狀況是很是罕見的。
咱們能夠經過增長一個變量來避免所有元素移動完成後的沒必要要的遍歷,但這不會改善平均狀況和最壞狀況的複雜度。可是有一個方法能夠將最壞狀況複雜度下降到\(O(n\log n)\),這個方法的關鍵是對於外循環遍歷到的一個\(j\),咱們同時搜索\(p(j), p^{-1}(j),p(p(j)),p^{-1}(p^{-1}(j)),…\),其中\(p^{-1}(j)\)是其反函數。這樣,咱們從新考慮最壞狀況,顯然就是整個排列只有一個長度爲\(n\)的環,例如排列\((1,2,…,n)\)。這樣,最壞狀況就是咱們從\(j\)開始向環的兩邊搜索到頭元素的時間,再加上j在環前面和環後面的長度分別爲\(k\)和\(n-k\)的部分都處在最壞狀況的時間之和。設最壞狀況爲\(f(n)\),就獲得以下遞推式:
\[ \begin{cases} f(n)=\max_{1\le k < n}\{\min\{k, n-k\} + f(k) + f(n-k)\} \\ f(1) = 0 \end{cases} \]
(心裏OS:這種東西TM也能解?!)
K神拒絕回答你的提問並直接把答案甩在了你臉上:
\[ f(n)=\sum_{0 \le n < n}v(k) \ \ (v(k)是k的二進制表示中1的個數) \\ f(2^{a_1}+2^{a_2}+...+2^{a_r})=\frac{1}{2}(a_12^{a_1}+(a_2+2)2^{a_2}+...+(a_r+2r-2)2^{a_r}) \\ 其中a_1>a_2>...>a_r \]
在其餘幾篇由Bush、Mirsky、Drazin、和Griffith發表的論文裏有對其複雜度的詳細分析。經過這些分析咱們知道了這個解法在最壞狀況下的複雜度爲\(O(n\log n)\)。(個人心裏已經崩潰了)
給定一個數組\(a_1,a_2,…,a_n\),試用盡量小的比較次數找出其中第\(t\)大的值。
這個問題相對比較常見一些,甚至曾經出現爲數據結構做業題……可是其實這個問題想到算法容易,算法的分析並無那麼容易。首先一個\(O(n\log n)\)的排序算法總能解決咱們的問題,但有沒有複雜度更低的方法呢?經過快速排序思路的啓發咱們能夠想到這樣一個算法,假如咱們對數組\(a_i,a_{i+1},…,a_j\)搜索,首先調用其中的Partition()方法將數組頭元素\(a_i\)的位置放到一個位置使得他左邊的元素都比他小,右邊的元素都比他大;而後根據他的位置\(k\)來縮小咱們搜索第\(t\)大數的範圍:若是\(k=t\)咱們就找到了這個元素;\(k<t\)則對\(a_{k+1},…,a_j\)搜索;\(k>t\)就對\(a_i,…,a_{k-1}\)搜索。這是一個標準的分治算法。能夠寫出如下僞代碼:
FindtthNumber(a, i, j, t): key = a[i] # Partition的實現請參考快速排序相關資料 # Partition返回的是分割後的數組下標 # 減去數組開頭的位置獲得a[k]是a[i]-a[j]裏第幾大的數 k = Partition(key, a, i, j) - i + 1 if k == t: return a[k] else if k < t: return FindtthNumber(a, k + 1, j, t - k) else: return FindtthNumber(a, i, k - 1, t)
經過僞代碼咱們能夠看出,影響一個子問題的變量有兩個:\(n\)和\(t\),\(n\)是這個子問題中數組的長度,\(t\)是這個子問題中咱們要找的第\(t\)大的數。不妨設這個子問題爲\(C(n, t)\)。假設咱們的分割到什麼位置徹底隨機,即這個子問題分割找到第\(1\)大、第\(2\)大、…、第\(n\)大的數的個數徹底相等,均爲\(1/n\),咱們就能分析獲得如下遞推方程:
\[ \begin{cases} C(1,1) = 0\\ C(n, t) = n - 1 + \frac{1}{n}(A(n,t)+B(n,t))\\ \end{cases}\\ \mbox{其中} \\ A(n, t) = C(n - 1,t-1)+C(n-2,t-2)+...+C(n-t+1,1)\\ B(n,t) = C(t,t)+C(t + 1,t) + ...+C(n-1, t) \]
顯然\(A(n,t)\)對應於遞歸函數中\(k<t\)的狀況,\(B(n,t)\)對應於遞歸函數中\(k>t\)的狀況。
以後咱們任務就是求解這個遞推方程了。通常人看到這個遞推方程大概就會放棄了吧,然而Knuth發現這個遞推方程是能夠求解的(跪了跪了)!依然是使用差消迭代法。首先經過觀察咱們看到
\[ A(n+1,t+1) = A(n,t)+C(n,t)\\ B(n+1,t) = B(n,t)+C(n,t) \]
這樣咱們就能夠把\(C(n+1,t+1), C(n,t+1), C(n,t),C(n-1,t)\)按以下方式相減:
\[ (n+1)C(n+1,t+1)-nC(n,t+1)-nC(n,t)+(n-1)C(n-1,t) \\ = (n+1)n - n(n-1) - n(n-1) + (n-1)(n-2)\\ + A(n+1,t+1)-A(n,t+1)-A(n,t)+A(n-1,t) \\ +B(n+1,t+1)-B(n,t+1)-B(n,t)+B(n-1,t) \\ = 2 + C(n,t) - C(n-1,t)+C(n,t+1)-C(n-1,t) \]
推出來
\[ C(n+1,t+1)-C(n,t+1)-C(n,t)+C(n-1,t)=\frac{2}{n+1}\\ (C(n+1,t+1)-C(n,t))-(C(n,t+1)-C(n-1,t))=\frac{2}{n+1} \]
以後須要列出邊界條件,相似以前的推導
\[ \begin{align} C(n,1)&=n-1+\frac{1}{n}(C(1,1)+C(2,1)+...+C(n-1,1)) \\ (n+1)C(n+1,1)-nC(n,1)&=(n+1)n-n(n-1)+C(n,1)\\ C(n+1,1)-C(n,1)&=\frac{2n}{n+1}=2-\frac{2}{n+1}\\ C(n,1)&=2n-2H_n\\ \Rightarrow C(n,n)&=2n-2H_n\ \ (對稱性) \end{align} \]
由上述兩式能夠計算獲得
\[ C(n+1,t+1)-C(n,t)=\frac{2}{n+1}+\frac{2}{n}+...+\frac{2}{t+2}+C(t+1,t+1)-C(t,t)\\ =2(H_{n+1}-H_{t+1})+2-\frac{2}{t+1} \]
不斷迭代
\[ C(n,t)=2\sum_{k=2}^{t}(H_{n-t+k}-H_k+1-\frac{1}{k})+C(n+1-t,1)\\ =2((n+1)H_n-(n+3-t)H_{n+1-t}-(t+2)H_t+n+3) \]
因爲調和級數\(H_n=O(\log n)\),咱們就嚴格證實了不管\(n,t\)取什麼值,平均狀況下算法的複雜度\(C(n,t)=O(n)\)。咱們的算法比較次數已經足夠小了,那麼如何尋找最少的比較次數?這就從一個A類問題變成了B類問題,而且很是難,感興趣的讀者能夠參考相關論文了解對這方面的進展。
被前面兩個算法搞暈了以後,相信你們已經瞭解算法分析是個什麼樣的過程了(笑)。最後Knuth對算法分析作了以下總結:
[1] "Mathematical Analysis of Algorithms" Donald Knuth, 1971.