三角數字
Q: 什么是三角數字?
A: 據說一群在畢達哥拉斯領導下工作的古希臘的數學家,發現了在數學序列1,3,6,10,15,21,……中有一種奇特的聯系。這個數列中的第N項是由第N-1項加N得到的。
由此,第二項是由第一項(1)加上2,得3。第三項是由第二項(3)加上3得到6,依次類推。
這個序列中的數字被稱為三角數字,因為它們可以被形象化地表示成對象的一個三角形排列。
Q: 如何使用循環求第N項?
A: 示例:TriangleNumber.java
Q: 如何使用遞歸求第N項?
A: 導致遞歸的方法直接返回而沒有再一次進行遞歸調用,此時我們稱為基值情況(base case)。
A: 每一個遞歸方法都有一個基值(中止)條件,以防止無限地遞歸下來,避免由此引發的程序崩潰,這一點至關重要。
A: 示例:TriangleNumber.java
Q: 到底發生了什么?
A: 通過插入一些輸出語句來跟蹤觀察參數和返回值,示例:TriangleNumber.java
輸出結果如下:
Enter a number = 5 Entering n = 5 Entering n = 4 Entering n = 3 Entering n = 2 Entering n = 1 Returning 1 Returning 3 Returning 6 Returning 10 Returning 15 Triangle = 15
A: calcN()方法調用自身時,它的形參從5開始,每次減1。這個方法反復地調用自身,直到方法的形參減少到1,於是方法返回。
A: 這會引發一系列的返回序列,每當返回時,這方法把調用它的形參N與其調用下一層方法的返回值相加。直到結果返回給main()。
A: 返回值概括了三角數字序列。
A: 注意,在最內層返回1之前,實際上同一時刻有5個不同的calcN()方法實例存在。最外層傳入的參數是5;最內層傳入的參數是1。
Q: 遞歸方法的特征有哪些?
A: 遞歸算法的關鍵特征:
- 調用自身
- 當它調用自身的時候,它這樣做是為了解決更小的問題
- 存在某個足夠簡單的問題的層次,在這一層算法不需要調用自己就可以直接解答,且返回結果。
Q: 遞歸方法是否是高效率的?
A: 調用一個方法會有一定的額外開銷。控制必須從這個調用的位置轉移到這個方法的開始處。除此之外,傳給這個方法的參數和這個方法的返回的地址都要被壓入到一個內部的棧,為的是這個方法可以訪問參數值和知道返回到哪里。
A: 就calcN()這個方法來講,因為有上述開銷,可能while循環方法執行的速度比遞歸要快,在此示例中遞歸的代價也許不斷太高。
A: 另外一個低效率反映在系統內存空間存儲所有的中間參數以及返回值,如果有大量的數據需要存儲,這就會引起棧溢出的問題。
A: 人們常常采用遞歸,是因為它從概念上簡化了問題,而不是因為它本質上更有效率。
最大公約數
Q: 如何求兩個正整數的最大公約數?
A: 12與16的最大公約數是4,一般記為(12,16)=4。
12、15、18的最大公約數是3,記為(12,15,18)=3。
A: 在求解最大公約數的幾種方法中,輾轉相除法最為出名,也叫歐幾里德算法。
Q: 最大公約數的代碼實現?
A: 示例:Gcd.java
變位字
Q: 什么是變位字(anagrams)?
A: 假設想要列出一個指定單詞的所有變位字,也就是列出該詞的全排列。我們稱這個工作是變位一個單詞。比如,全排列abc,會產生
abc acb bac bca cba cab
排列的數量是單詞字母數的階乘。所以算法至少時間O(n!)的。
A: 實際上,全排列算法對大型的數據是無法處理的,而一般情況下也不會要求我們去遍歷一個大型數據的全排列。
A: n 個元素的全排列問題可轉化為求n - 1個元素的全排列問題(遞歸設計)
A: 全排列的遞歸算法:
- 集合X中元素的全排列記為perm(X)
- (ri)perm(X) 表示在全排列 perm(X)的每一個排列前加上前綴得到的排列。
- R的全排列可歸納定義如下:
1) 當 n = 1 時,perm(R) = (r),其中 r 是集合 R 中唯一的元素;
2) 當 n > 1 時,perm(R) 由 (r1)perm(R1),(r2)perm(R2),…,(rn)perm(Rn)構成。
Q: 變位字的代碼實現?
A: 示例:AnagramTestCase.java
遞歸的二分查找
Q: 如何用遞歸取代循環?
A: 在前面第2章"數組"中討論過的二分查找,當時使用的是基於循環的方法來實現,現在可以改為使用遞歸的方法來實現。
A: 在遞歸的方法中,不用改變left或者right,而用left或者right的新值作為參數反復調用recFind()方法,它每一次調用自己都比上一次的范圍更小。
A: 當最內層的方法找到了指定的數據項,方法返回這個數據項所在數組的下標。於是這個值依次從每一層recFind()中返回,最后,find()返回值給類用戶。可以把示例中的DEBUG開關打開仔細觀察,如下圖。
A: 遞歸的方法和循環的方法有同樣的大O效率:O(logN)。遞歸的二分查找在代碼量上更為簡潔,但是它的速度可能會慢一些。
Q: 什么是分治算法?
A: 凡治眾如治寡,分數是也。 —《孫子兵法》
大概意思就是:治理大軍團就象治理小部隊一樣有效,是依靠合理的組織、結構、編制。
A: 將一個難以直接解決的大問題,合理分割成一些規模較小的相同問題,以便各個擊破,這個策略就叫分而治之(分治法)。
A: 遞歸的二分查找是分治算法的一個例子。把一個大問題分成兩個相對來說更小的問題,並且分別解決每一個小問題。對每一個小問題的解決方法是一樣的:把每個小問題分成兩個更小的問題,並且解決它們。這個過程一直持續下去直到易於求解的基值情況,就不用再繼續分了。
A: 分治算法通常要回到遞歸。
漢諾(Hanoi)塔問題
Q: 漢諾塔問題?
A: 用子樹的概念進行遞歸是解決漢諾塔難題的方法。假設想要把所有的盤子從源塔座上(from)移動到目標塔座上(to),有一個可以使用的中介塔座(inter)。假定在from上有n個盤子,算法如下:
- 從from移動上面n-1個盤子的子樹到inter上;
- 從from移動剩余的盤子(即最大的盤子)到塔座to上;
- 從inter移動子樹到to
A: 當開始的時候,原塔座是A,中介塔座是B,目標塔座是C,如下圖顯示了這種情況的三個步驟。如下圖,
當然這個方法並沒有解決如何把包括盤子1、2和3的子樹移動到塔座B上,要想解決這個問題,我們就得使用遞歸的思路,按照上面的三個步驟來套:從塔座A上移動最上面的兩個盤子的子樹到中介塔座C上,接着從A移動盤子3到塔座B上,然后把子樹從塔座C移回到塔座B上。
接下來就是如何把兩個盤子的子樹從塔座A上移動到塔座C上呢?從塔座A上移動只有一個盤子(盤子1)的子樹到塔座B上,這就是基值條件:當移動一個盤子的時候,只要移動它就可以了,沒有其他的事情要做。然后從塔座A移動更大的盤子(盤子2)到塔座C,並且把這顆子樹(盤子1)重新放置在這個更大的盤子上。
Q: 漢諾塔的代碼實現?
A: 示例:Tower.java
歸並排序
Q: 如何歸並兩個有序數組?
A: 在介紹歸並排序算法之前,我們先看如何歸並兩個有序數組A和B。
A: 將兩個有序表合並成一個有序表,稱為二路歸並。
A: 假設有兩個有序數組,不要求有相同的大小。設數組A有4個元素,數組B有6個元素,它們要被歸並到數組C。如下圖:
Q: 歸並排序的代碼實現?
A: 示例:MergeSort.java
A: 歸並排序的思想是把一個數組分為兩半,排序每一半,然后用merge()方法把數組的兩半歸並成一個有序的數組。如何來為每一部分排序呢?遞歸。反復地分割數組,直到得到的子數組只含有一個數據項,這就是基值條件。
A: 當初始的數組大小是2的n次冪的時候,最容易理解,如下圖,
A: 當數組的大小不是2的n次冪的時候,必須要歸並不同size的數組,如下圖,數組的size是12的情況,這里是一個size為2的數組要和一個size為1的數組歸並為一個size為3的數組。
A: 如果把DEBUG開關打開,就可以打印如下的日志:
Entering 0 ~ 3 Will sort left half of 0 ~ 1 Entering 0 ~ 1 Will sort left half of 0 ~ 0 Entering 0 ~ 0 Base-Case Return 0 ~ 0 Will sort right half of 1 ~ 1 Entering 1 ~ 1 Base-Case Return 1 ~ 1 Will merge halves into 0 ~ 1 Return 0 ~ 1 Will sort right half of 2 ~ 3 Entering 2 ~ 3 Will sort left half of 2 ~ 2 Entering 2 ~ 2 Base-Case Return 2 ~ 2 Will sort right half of 3 ~ 3 Entering 3 ~ 3 Base-Case Return 3 ~ 3 Will merge halves into 2 ~ 3 Return 2 ~ 3 Will merge halves into 0 ~ 3 Return 0 ~ 3
與上面的圖所展示的情況幾乎吻合。
A: 有人可能會問,所有的這些子數組都存放在什么地方?在這個算法中,創建了一個和初始數組一樣大小的工作空間數組,這些子數組就存儲在這個空間數組里面,也就是說原始數組的子數組被復制到工作空間數組的相應空間上,在每一次歸並之后,工作數組的內容被復制回原來的數組。
Q: 歸並排序的效率?
A: 冒泡排序、插入排序和選擇排序要用O(N2)時間,而歸並排序只要O(N*logN
)。如果N是10000,那么N2就是100000000,而N*logN只是40000。如果為這么多數據項排序用歸並排序的話,需要40秒,那么插入排序則會需要將近28個小時。
A: 如何知道這個O(N*logN
)時間呢?假設歸並排序中復制和比較時最費時的操作,遞歸的調用和返回不增加額外的開銷。
A: 復制的次數
考慮圖(Merging larger and larger arrys)中,首行下面的每一個單元都代表從數組復制到工作空間中的一個數據項。7個標數字的步驟顯示了需要有24次復制。對於有8個數據項的情況,復制的次數和O(N*logN
)成正比。
實際上,這些數據項不僅被復制到數組workspace中,而且也會被復制回原數組中,這會使得復制的次數增加了一倍。下表初略地表達了這個信息。
另一種用來看計算復制次數的方法是,排序8個數據項需要有3層,每一層包含8次復制,一層意味着所有元素都復制到相同大小的子數組中。所以就有3*8=24次復制了。
當N不是2的倍數時,比較的次數會落在2的乘方以內。對於12個數據項,總共有8次復制,並且對於100個數據項,總共有1344次復制。
A: 比較的次數
在歸並排序算法中,比較的次數總是比復制的次數稍微少一些。那么少多少呢?
假設數據項的個數是2的乘方,對於每一個獨立的歸並操作,比較的最大次數總是比正在被歸並的數據項個數少1,並且比較的最少次數是正在被歸並的數據項數目的一半。
下圖表明了試圖歸並各有4個數據項的兩個數組時的兩種場景,最差情況和最好情況。
A: 重新來看圖(Merging larger and larger arrys),可以看到為8個數據項進行排序,需要有7次歸並操作,正在被歸並的數據項個數以及相應的比較次數如下表所示,
對於每一次歸並,最大的比較次數比數據項的個數少一,我們把所有歸並的比較次數加在一起得到總數17,最小的比較次數總是被歸並的數據項個數的一半,總數為12。相似的算術運算得到如表(Number of Operations When N Is a Power Of 2)中最后一欄“比較次數欄”里。
A: 排序一個指定數組的實際比較次數依賴於數據是如何排列的,但是這個數字一定會在最大比較次數和最小比較次數之間。
消除遞歸
Q: 遞歸和棧的關系?
A: 遞歸和棧之間有一個緊密的聯系。
事實上,大部分編譯器都是使用棧來實現遞歸的。
當調用一個方法時,編譯器會把這個方法的所有參數及其返回地址都壓入棧中,然后把控制權交給這個方法。當這個方法返回時,這些值退棧,參數消失了,並且控制權重新回到返回地址處。
A: 任意一個遞歸方法都可以轉換為基於棧的方法來模擬,一個方法的內部實現大致經歷如下幾步:
- 當一個方法被調用時,它的形參以及返回地址被壓入一個棧中;
- 這個方法可以通過獲取棧頂元素的值來訪問它的參數;
- 當這個方法要返回時,它查看棧以獲得返回地址,然后這個地址以及方法的所有形參退棧,並且銷毀。
Q: 如何用棧模擬一個遞歸?
A: 我們以三角數字的遞歸轉化為基於棧的方法來說明,示例:TriangleNumber.java,如下圖:
函數的執行可根據6個指令地址來執行每一個細微的邏輯:
1) 初次調用會執行0x01地址的指令,把用戶輸入的值以及函數返回值地址0x06壓入棧;
2) 在函數的入口0x02,會檢查它的參數是否為1(通過棧頂元素獲取),如果參數是1,這就是基值條件,則將控制權交給0x05(即函數出口)執行。
如果參數不是1,則將遞歸地調用自身(0x03),這個遞歸的調用由參數n-1和返回值地址0x04入棧,以及控制權交給0x02執行。
3) 從遞歸調用返回的過程中,0x04執行了函數返回值加上它的參數n,然后這個方法退出0x05。
4) 當函數退出時,最后的Param對象退棧,這個信息不再需要。因此會執行0x06結束while死循環。
一些有趣的遞歸應用
Q: 求一個數的乘方?
A: 比如2^8,我們可以會求表達式2*2*2*2*2*2*2*2
的值,但是如果y的值很大,這個會顯得表達式很冗長。那么由沒有更快一點方法呢?一個解決的方法是重新組織這個問題,只要可能就拿2的方次相乘,而不是乘以2。
A: 這個方案以數學等式xy=(x2)y/2為基礎。可以使用遞歸的思路來解決這個問題,基值條件就是當y=1的時候就返回。
A: 如下圖,是以318為例的遞歸過程,其中要注意遞歸在返回的過程中,只要y是一個奇數,就得額外地乘以一個x。
A: 示例:Math.java
Q: 背包問題(The Knapsack Problem)?
A: 背包問題是計算機科學中的經典問題,在最簡單的形式中,包括試圖將不同重量的數據項放到背包中,以使背包最后達到了指定的總重量。不需要把所有的選項都放入背包中。
A: 假設想要讓背包精確地承重20磅,並且有 5 個可以放入的數據項,它們的重量分別是11 磅,8 磅,7 磅,6 磅,5 磅。這個問題可能對於人類來說很簡單,我們大概就可以計算出8磅+7磅 + 5磅=20磅。但是如果讓計算機來解決這個問題,就需要給計算機設定詳細的指令了。
算法如下:
1) 如果在這個過程的任何時刻,選擇的數據項的總和符合目標重量,那么工作便完成了。
2) 從選擇的第一個數據項開始,剩余的數據項的加和必須符合背包的目標重量減去第一個數據項的重量,這是一個新的目標重量。
3) 逐個的試每種剩余數據項組合的可能性,但是注意不要去試所有的組合,因為只要數據項的和大於目標重量的時候,就停止添加數據。
4) 如果沒有合適的組合,放棄第一個數據項,並且從第二個數據項開始再重復一遍整個過程。
5) 繼續從第三個數據項開始,如此下去直到你已經試驗了所有的組合,這時才知道有沒有解決方案。
A: 示例:Knapsack.java
Q: 組合:選擇一支隊伍?
A: 在數學中,組合是對事物的一種選擇,而不考慮他們的順序。
A: 比如有5個登山隊員,名稱為 A,B,C,D和E。想要從這五個隊員中選擇三個隊員去登峰,這時候如何列出所有的隊員組合。(不考慮順序)
如何來寫這樣一個程序打印所有的組合呢?ABC, ABD, ABE, ACD, ACE, ADE, BCD, BCE, BDE, CDE
A: 使用遞歸的解決方案,它包括把這些組合分成兩個部分:由A開始的組合和不是由A開始的組合。假設把從5個人中選出3個人的組合簡寫為(5,3)。規定n是這群人的大小,並且K是組隊的大小。那么根據法則可以得到:
(n, k)=(n-1, k-1)+(n-1, k)
A: 可以把這個問題看成是一棵樹,在第一行是(5,3),在第二行是(4,3)和(4,2),依次類推,這個樹中的節點對應於遞歸方法的調用,下圖顯示了例子(5,3)的樣子。
基值條件是指沒有意義的組合:某個數字是0以及隊員數大於人群數的情況。組合(1,1)是合法的,但是繼續分解它就沒有必要了。在這個圖中,虛線表示了基值條件,這時需要返回,而不是繼續分解了。
A: 當沿着樹往下走時,需要記住訪問過的節點序列。這如何做到呢? 示例:Combination.java
小結
- 一個遞歸的方法每次用不同的參數值反復調用自身
- 某種參數值使遞歸的方法返回,而不再調用自身,這種稱為基值條件
- 當遞歸方法返回時,遞歸過程通過逐漸完成各層方法實例的未執行部分,而從最內層返回到最外層的原始調用處
- 一個單詞的全排列可以通過反復地輪換它的字母以及全排列它最右邊的n-1個字母來遞歸得到
- 任何可以用遞歸完成的操作都可以用一個棧來實現
- 遞歸的方法可能效率低,如果是這樣的話,有時可以用一個簡單的循環或者一個基於棧的方法來替代它
參考
- 《Java數據結構和算法》Robert Lafore 著,第6章 - 遞歸