一個小白的算法學習之路。讀《算法導論》第一天。本文僅做爲學習的心得記錄。前端
對於一個程序員來講,不管資歷深淺,對算法一詞的含義必定會或多或少有本身的體會,在《算法導論》中,做者在第一章就將算法定義爲一種計算過程。
咱們第一次遇到算法,首先關心的一定是算法的正確性,有些人可能不耐煩了,這正確性有什麼好說的,三歲小孩都能分辨一條簡單算法對不對。的確,正確的算法就是以正確結果結束的算法。(廢話!!!)可是錯誤的算法就不那麼簡單了,錯誤的算法有時以錯誤的結果結束,有時甚至不會結束(依舊廢話。。。。。。)可是!!若是你認爲錯誤的算法一無用處,那就大錯特錯了。不正確的算法其實只要錯誤率可控,有時多是有用的。程序員
事實上,許多人或許都認爲學習算法是沒有必要的,畢竟若是我要寫一個排序程序,只要去算法手冊上找一下,就會找到無數種算法。這讓學習算法看上去就彷佛無關緊要了。但算法真正的魅力,在於它永遠處於科技發展的最前端。只使用已有的算法並非學習《算法導論》這本書的真正目的,由於市面上的任何一本算法教程均可以知足你的需求(雖然不少內容也都來源於《算法導論》),處在科技前沿,學會把不可能變爲可能纔是學習這本書的真正含義。這本書在教授你從如何分析算法到如何設計算法這一個過程,這纔是我看這本書的目的,而不只僅是爲了學習其中的算法。算法
既然有算法,就一定有其對應的問題,首先咱們先引入一個問題
排序問題:
輸入:n個數的一個序列<a1,a2,...,an>
輸出:輸入序列的一個排序<b1,b2,...,bn>,其b1≤b2≤...≤bn
若是你不想下次看到插入排序這個詞語,卻只有一種似曾相識的感受,而後急忙去google上copy代碼的話,那首先很重要的一點就是創建直觀印象。固然,這個算法的直觀印象其實早就在咱們腦海裏根深蒂固了,咱們都玩過鬥地主,牌發到本身手裏,第一個反映就是整理本身的牌,右手從牌堆裏拿出一張,插在左手已經排好順序的牌的正確位置,而且不斷重複。咱們很容易創建起了對插入排序的第一印象。不過計算機代碼畢竟有別於現實生活,如何將這個直觀的過程經過代碼實現呢?
讓咱們首先從咱們腦海裏僅有的直觀印象開始分析,從牌堆裏拿出一張牌,這就是數組中的取值操做,而排到正確位置這就彷佛再也不那麼直觀,通常咱們習慣從小到大開始比較牌的大小而後把牌插進去,可是數組就不那麼容易隨隨便便插入一個值,要想插入一個值,必須把插入地方以後的元素都向後移動一位。可見這個方法有些麻煩,那咱們便換一種方法,從大到小的比較,若是要插入的值比與之比較的值小便把與之比較的值在數組中的位置向後移動一位。這樣算法的實現便開始清晰明瞭起來。數組
INSERTION-SORT(A) for j=2 to A.length key=A[j] //從牌堆裏拿出一張牌 i=j-1 //與插入值比較的元素下標 //進行比較 while i>0 and A[i]>key A[i+1]=A[i] //把比插入值大的元素在數組中向後移動一位 i=i-1 //再比較數組中前一個值 A[i]=key //將牌插入正確的位置
固然這是一個極其簡單的算法,這樣註釋或許有一些小題大做,可是這是理解算法的一個很好的例子。咱們能夠仔細觀察整個算法過程(我相信經過上面的解釋很容易在腦海中運行整個算法的運算過程),咱們會發現,在整個算法運行的過程中A[1...j-1]是始終是有序的,畢竟直觀印象上左手的牌確定是一直有序的。這些性質被表示爲循環不變式,事實上它的主要做用就是幫助咱們理解算法的正確性。咱們應該也已經清晰的感受到了。函數
分析算法,是咱們學習整本書最基礎的技能,畢竟只有學會分析算法,在咱們本身設計算法時才能分析本身算法的性能和所需的資源性能
Analysis of Algorithm is theoretical study of computer program's performance and resource usage ——Charles E.Leiserson學習
這裏咱們更多的關注算法的性能,也就是計算時間。
若是必須很是嚴謹的分析,咱們須要考慮實現算法的模型,‘運行時間’和‘輸入規模’的定義之類複雜的問題,但就我目前看完第二章看來除了增長分析的嚴謹並無帶來其餘顯而易見的好處,因此在此我準備忽略這些繁瑣的先決條件,讓咱們先假設一行簡單的僞代碼須要的運行時間爲常數c。
這樣讓咱們開始分析上面的僞代碼,首先咱們須要表示出每一行僞代碼運行的時間及運行的次數
INSERTION-SORT(A)            代價(時間)                        次數google
for j=2 to A.length c1 n key=A[j] c2 n-1 i=j-1 c3 n-1 while i>0 and A[i]>key c4 T1 A[i+1]=A[i] c5 T2 i=i-1 c6 T3 A[i]=key c7 n-1
其中T1=$$\sum_{j=2}^nt_j$$
T2=T3=$$\sum_{j=2}^n(t_j-1)$$
而$$t_j$$表示的就是在第j次(事實上是第j-1次)For循環中while循環的循環次數。
列出了代價和次數,接下來很容易能求得:
$$T(n)=c_1n+c_2(n-1)+c_3(n-1)+c_4\sum_{j=2}^nt_j+c_5\sum_{j=2}^n(t_j-1)+c_6\sum_{j=2}^n(t_j-1)+c_7(n-1)$$
接下來一個小小的問題就是如何化簡這個看似很長的公式,若是咱們仔細觀察,便會發現,化簡這道公式並非很難,只有一個問題,就是咱們並不知道$$t_j$$等於什麼,由於每次輸入的序列不一樣,tj便會不同,這也可見其實輸入的數據對算法的性能一樣有影響。若是咱們須要繼續分析下去(其實也必需要分析下去,否則上面那個公式啥也看不出來),便須要開始分狀況討論(初中,高中最討厭老師說這道題要分狀況討論。。。結果大了仍是逃不了這命運。)翻譯
首先是最佳狀況,很容易看出,最佳狀況就是壓根不移動數組,插入的值已經在合適的位置,那麼$$t_j=1$$
T(n)很容易就能夠化簡出來(我就再也不算了)
咱們能夠簡單表示爲$$T(n)=an+b$$
這是一個線性函數設計
而後是最壞狀況,就是插入值必須和以前的每個數比較過去,那麼while循環就一共就比較了j次(算上最後一次退出循環比較的那次),那麼$$t_j=j$$
而後又通過了一系列簡單的化簡T(n)能夠表示爲$$an^2+bn+c$$
所以他是n的二次函數
在通常狀況下咱們更加關心算法的最壞狀況,主要有一下三點緣由
一、咱們能夠保證這個算法必定能在某個時間內完成。
二、有些算法,最壞狀況常常出現。
三、平均狀況經常和最壞狀況同樣壞(你能夠試一下tj=j/2的狀況)
說到歸併排序不得不提到分治法這個概念。要記住的 是分治法每層遞歸都有三個步驟:分解、解決、合併。
那先讓咱們看一下歸併排序是如何實現這三個步驟的
分解:分解待排序的n個元素的序列成爲各具備n/2個元素的子序列(讓咱們暫且假定其爲偶數,咱們會發現奇偶數對程序的影響並不大)
解決:遞歸的解決兩個子序列
合併:合併兩個已排序的子序列產生答案
這個算法和上一個算法相比依舊不算很難,可是遞歸或許對我這樣的小白來講有些抽象,可是用通俗的話歸納這整個過程,其實就是把一個序列先一半一半切切成小塊再從新裝起來的過程。當序列分解到長度爲1時,便開始從新組裝這個序列。由此不難看出,整個歸併算法重點就是如何組裝這個序列,或者說重點就在於合併。
因此若是咱們解決了如何合併兩個已排序的子序列,整個歸併排序基本上就完成了。那如何解決合併這個問題呢?咱們但願最後獲得的序列按從小到大的順序排列,第一步天然而然想到的即是找到最小的一個元素,因爲咱們如今有的是兩個已經排序的序列,因此最小的元素一定是這兩個序列的最小元素中的一個(或兩個,若是這兩個序列最小元素相等的話)這樣來看僞代碼便清晰了許多。還須要提的一點是,若是一個序列中元素用完,另外一個序列中剩下的元素即是已排好序的能夠直接添加到合併的序列當中,爲了實現這個判斷,咱們設定一個哨兵值,放在兩個序列末尾。
MERGE(A,p,q,r) n1=q-p+1 //兩個序列的長度 n2=r-q //兩個序列的長度 let L[1...n1+1] and R[1...n2+1] be new arrays for i= 1 to n1 L[i]=A[p+i-1] for j=1 to n2 R[j]=A[q+j] L[n1+1]= ∞ R[n2+1]= ∞ //以上代碼是建立兩個新數組,並賦值 i=1 j=1 for k=p to r //接下來即是尋找兩個數組中最小的元素 if L[i]≤R[j] A[k]=L[i] i=i+1 else A[k]=R[j] j=j+1
咱們能夠用以前說的循環不變量來證實此過程的正確性,在此再也不贅述。
解決了最關鍵的合併部分,接下來實現歸併算法便很是容易了,只是以前歸併算法描述的翻譯,如下是僞代碼:
MERGE-SORT(A,p,r) if p<r q=(p+r)/2 //分解 MERGE-SORT(A,p,q) //解決 MERGE-SORT(A,q+1,r) //解決 MERGE(A,p,q,r) //合併
須要注意的是,if語句其實就是對最小規模問題的直接求解,最小規模狀況就是隻有一個元素的時候,即咱們什麼都不須要作,只須要中止遞歸就行。