Python演算法:推導、遞迴和規約

發表於2015-05-20

注:本節中我給定下面三個重要詞彙的中文翻譯分別是:Induction(推導)、Recursion(遞迴)和Reduction(規約)

本節主要介紹演算法設計的三個核心知識:Induction(推導)、Recursion(遞迴)和Reduction(規約),這是原書的重點和難點部分

正如標題所示,本節主要介紹下面三部分內容:

• Reduction means transforming one problem to another. We normally reduce an unknown problem to one we know how to solve. The reduction may involve transforming both the input (so it works with the new problem) and the output (so it’s valid for the original problem).

Reduction(規約)意味著對問題進行轉換,例如將一個未知的問題轉換成我們能夠解決的問題,轉換的過程可能涉及到對問題的輸入輸出的轉換。[問題規約在證明一個問題是否是NP完全問題時經常用到,如果我們能夠將一個問題規約成一個我們已知的NP完全問題的話,那麼這個問題也是NP完全問題]

下面給幅圖你就能夠明白了,實際上很多時候我們遇到一個問題時都是找一個我們已知的類似的能夠解決的問題,然後將這個我們新問題A規約到那個已知的問題B,中間經過一些輸入輸出的轉換,我們就能夠解決新問題A了。

• Induction (or, mathematical induction) is used to show that a statement is true for a large class of objects (often the natural numbers). We do this by first showing it to be true for a base case (such as the number 1) and then showing that it “carries over” from one object to the next (if it’s true for n –1, then it’s true for n).

Induction(推導)是一個數學意義上的推導,類似數學歸納法,主要是用來證明某個命題是正確的。首先我們證明對於基礎情況(例如在k=1時)是正確的,然後證明該命題遞推下去都是正確的(一般假設當k=n-1時是正確的,然後證明當k=n時也是正確的即可)

• Recursion is what happens when a function calls itself. Here we need to make sure the function works correctly for a (nonrecursive) base case and that it combines results from the recursive calls into a valid solution.

Recursion(遞迴)經常發生於一個函式呼叫自身的情況。遞迴函式說起來簡單,但是實現不太容易,我們要確保對於基礎情況(不遞迴的情況)能夠正常工作,此外,對於遞迴情況能夠將遞迴呼叫的結果組合起來得到一個有效的結果。

以上三個核心有很多相似點,比如它們都專注於求出目標解的某一步,我們只需要仔細思考這一步,剩下的就能夠自動完成了。如果我們更加仔細地去理解它們,我們會發現,Induction(推導)和Recursion(遞迴)其實彼此相互對應,也就是說一個Induction能夠寫出一個相應的Recursion,而一個Recursion也正好對應著一個Induction式子,也可以換個方式理解,Induction是從n-1到n的推導,而Recursion是從n到n-1的遞迴(下面有附圖可以幫助理解)。此外,Induction和Recursion其實都是某種Reduction,即Induction和Recursion的本質就是對問題進行規約!為了能夠對問題使用Induction或者說Recursion,Reduction一般是將一個問題變成另一個只是規模減小了的相同問題。

你也許會覺得奇怪,不對啊,剛才不是說Reduction是將一個問題規約成另一個問題嗎?現在怎麼又說成是將一個問題變成另一個只是規模減小了的相同問題了?其實,Reduction是有兩種的,上面的兩種都是Reduction!還記得前面介紹過的遞迴樹嗎?那其實就是將規模較大的問題轉換成幾個規模較小的問題,而且問題的形式並沒有改變,這就是一種Reduction。你可以理解這種情況下Reduction是降維的含義,也就類似機器學習中的Dimension Reduction,對高維資料進行降維了,問題保持不變。

These are two major variations of reductions: reducing to a different problem and reducing to a shrunken version of the same.

再看下下面這幅圖理解Induction和Recursion之間的關係

[關於它們三個的關係的原文闡述:Induction and recursion are, in a sense, mirror images of one another, and both can be seen as examples of reduction. To use induction (or recursion), the reduction must (generally) be between instances of the same problem of different sizes. ]

[看了原書你會覺得,作者介紹演算法的方式很特別,作者有提到他的靈感來自哪裡:In fact, much of the material was inspired by Udi Manber’s wonderful paper “Using induction to design algorithms” from 1988 and his book from the following year, Introduction to Algorithms: A Creative Approach.]

也許你還感覺很暈,慢慢地看了後面的例子你就明白了。在介紹例子之前呢,先看下遞迴和迭代的異同,這個很重要,在後面介紹動態規劃演算法時我們還會反覆提到它們的異同。

[Induction is what you use to show that recursion is correct, and recursion is a very direct way of implementing most inductive algorithm ideas. However, rewriting the algorithm to be iterative can avoid the overhead and limitations of recursive functions in most (nonfunctional) programming languages. ]

有了Induction和Recursion,我們很容易就可以將一個inductive idea採用遞迴(recursion)的方式實現,根據我們的程式設計經驗(事實也是如此),任何一個遞迴方式的實現都可以改成非遞迴方式(即迭代方式)實現(反之亦然),而且非遞迴方式要好些,為什麼呢?因為非遞迴版本相對來講執行速度更快,因為沒有用棧去實現,也避免了棧溢位的情況,python中對棧深度是有限制的。

舉個例子,下面是一段遍歷序列的程式碼,如果大小設定為100沒有問題,如果設定為1000就會報RuntimeError的錯誤,提示超出了最大的遞迴深度。[當然,大家都不會像下面那樣寫程式碼對吧,這只是一個例子]

所以呢,很多時候雖然遞迴的思路更好想,程式碼也更好寫,但是迭代的程式碼更加高效一些,在動態規劃中還可以看到迭代版本還有其他的優點,當然,它還有些缺點,比如要考慮迭代的順序,如果迫不及待想知道請移步閱讀Python演算法設計篇之動態規劃,不過還是建議且聽我慢慢道來

下面我們通過排序來梳理下我們前面介紹的三個核心內容

我們如何對排序問題進行reduce呢?很顯然,有很多種方式,假如我們將原問題reduce成兩個規模為原來一半的子問題,我們就得到了合併排序(這個我們以後還會詳細介紹);假如我們每次只是reduce一個元素,比如假設前n-1個元素都排好序了,那麼我們只需要將第n個元素插入到前面的序列即可,這樣我們就得到了插入排序;再比如,假設我們找到其中最大的元素然後將它讓在位置n上,一直這麼下去我們就得到了選擇排序;繼續思考下去,假設我們找到某個元素(比如第k大的元素),然後將它放在位置k上,一直這麼下去我們就得到了快速排序(這個我們以後還會詳細介紹)。怎麼樣?我們前面學過的排序經過這麼一些reduce基本上都很清晰了對吧?

下面通過程式碼來體會下插入排序和選擇排序的兩個不同版本

遞迴版本的插入排序

改成迭代版本的插入排序如下

你會發現,兩個版本差不多,但是遞迴版本中list的size不能太大,否則就會棧溢位,而迭代版本不會有問題,還有一個區別就是方法引數,一般來說遞迴版本的引數都會多些

遞迴版本和迭代版本的選擇排序

下面我們來看個例子,這是一個經典的“名人問題”,我們要從人群中找到那個名人,所有人都認識名人,而名人則任何人都不認識。

[這個問題的一個變種就是從一系列有依賴關係的集合中找到那個依賴關係最開始的元素,比如多執行緒環境下的執行緒依賴問題,後面將要介紹的拓撲排序是解決這類問題更實際的解法。A more down-to-earth version of the same problem would be examining a set of dependencies and trying to find a place to start. For example, you might have threads in a multithreaded application waiting for each other, with even some cyclical dependencies (so-called deadlocks), and you’re looking for one thread that isn’t waiting for any of the others but that all of the others are dependent on. ]

在進一步分析之前我們可以發現,很顯然,我們可以暴力求解下,G[u][v]為True表示 u 認識 v。

用下面程式碼進行測試,得到正確結果57

上面的暴力求解其實可以看做是一個reduce,每次reduce一個人,確定他是否是名人,顯然這樣做並不高效。那麼,對於名人問題我們還可以怎麼reduce呢?假設我們還是將規模為n的問題reduce成規模為n-1的問題,那麼我們要找到一個非名人(u),也就是找到一個人(u),他要麼認識其他某個人(v),要麼某個人(v)不認識他,也就是說,對於任何G[u][v],如果G[u][v]為True,那麼消去u;如果G[u][v]為False,那麼消去v,這樣就可以明顯加快查詢的速度!

基於上面的想法就有了下面的python實現,第二個for迴圈是用來驗證我們得到的結果是否正確(因為如果我們保證有一個名人的話那麼結果肯定正確,但是如果不能保證的話,那麼結果就要進行驗證)

看起來還不錯吧,我們將一個O(n2)的暴力解法變成了一個O(n)的快速解法。

[看書看到這裡時,我想起了另一個看起來很相似的問題,從n個元素中找出最大值和最小值。如果我們單獨地來查詢最大值和最小值,共需要(2n-2)次比較(也許你覺得還可以少幾次,但都還是和2n差不多對吧),但是,如果我們成對來處理,首先比較第一個元素和第二個元素,較大的那個作為當前最大值,較小的那個作為當前最小值(如果n是奇數的話,為了方便可以直接令第一個元素既是最大值又是最小值),然後向後移動,每次取兩個元素出來先比較,較小的那個去和當前最小值比較,較大的那個去和當前最大值比較,這樣的策略至多需要 3⌊n2⌋ 次比較。兩個問題雖然完全沒關係,但是解決方式總有那麼點千絲萬縷有木有?]

接下來我們看另一個更加重要的例子,拓撲排序,這是圖中很重要的一個演算法,在後面介紹到圖演算法的時候我們還會提到拓撲排序的另一個解法,它的應用範圍也非常廣,除了前面的依賴關係例子外,還有一個最突出的例子就是類Linux系統中軟體的安裝,每當我們在終端安裝一個軟體或者庫時,它會自動檢測它所依賴的那些部件(components)是否安裝了,如果沒有那麼就先安裝那些依賴項。此外,後面介紹到動態規劃時有一個單源最短路徑問題就利用了拓撲排序。

下圖是一個有向無環圖(DAG)和它對應的拓撲排序結果

拓撲排序這個問題怎麼進行reduce呢?和前面一樣,我們最直接的想法可能還是reduce one element,即去掉一個節點,先解決剩下的(n-1)個節點的拓撲排序問題,然後將這個去掉的節點插入到合適的位置,這個想法的實現非常類似前面的插入排序,插入的這個節點(也就是前面去掉的節點)的位置是在前面所有對它有依賴的節點之後。

上面這個演算法是平方時間的,還有沒有其他的reduction策略呢?前面的解法類似插入排序,既然又是reduce一個元素,很顯然我們可以試試類似選擇排序的策略,也就是說,我們找到一個節點,然後把它放在第一個位置上(後面有道練習題思考如果是放在最後一個位置上怎麼辦),假設我們直接就是將這個節點去掉會怎樣呢?如果剩下的圖還是一個DAG的話我們就將原來的問題規約成了一個相似但是規模更小的問題對不對?但是問題是我們選擇哪個節點會使得剩下的圖還是一個DAG呢?很顯然,如果一個節點的入度為0,也就是說沒有任何其他的節點依賴於它,那麼它肯定可以直接安全地刪除掉對不對?!

基於上面的思路就有了下面的解法,每次從圖中刪除一個入度為0的節點

[擴充套件知識:有意思的是,拓撲排序還和Python Method Resolution Order 有關,也就是用來確定某個方法是應該呼叫該例項的還是該例項的父類的還是繼續往上呼叫祖先類的對應方法。對於單繼承的語言這個很容易,順著繼承鏈一直往上找就行了,但是對於Python這類多重繼承的語言則不簡單,它需要更加複雜的策略,Python中使用了C3 Method Resolution Order,我不懂,想要了解的可以檢視 on python docs]

本章後面作者提到了一些其他的內容

1.Strong Assumptions

主要對於Induction,為了更加準確方便地從n-1遞推到n,常常需要對問題做很強的假設。

2.Invariants and Correctness

迴圈不變式,這在演算法導論上有詳細介紹,迴圈不變式是用來證明某個演算法是正確的一種方式,主要有下面三個步驟[這裡和演算法導論上介紹的不太一樣,道理類似]:

(1). Use induction to show that it is, in fact, true after each iteration.
(2). Show that we’ll get the correct answer if the algorithm terminates.
(3). Show that the algorithm terminates.

3.Relaxation and Gradual Improvement

鬆弛技術是指某個演算法使得當前得到的解有進一步的提升,越來越接近最優解(準確解),這個技術非常實用,每次鬆弛可以看作是向最終解前進了“一步”,我們的目標自然是希望鬆弛的次數越少越好,關鍵就是要確定鬆弛的順序(好的鬆弛順序可以讓我們直接朝著最優解前進,縮短演算法執行時間),後面要介紹的圖中的Bellman-Ford演算法、Dijkstra演算法以及DAG圖上的最短路徑問題都是如此

4.Reduction + Contraposition = Hardness Proof

規約是用於證明一個問題是否是一個很難的問題的好方式,假設我們能夠將問題A規約至問題B,如果問題B很簡單,那麼問題A肯定也很簡單。逆反一下我們就得到,如果問題A很難,那麼問題B就也很難。比如,我們知道了哈密頓迴路問題是NP完全問題,要證明哈密頓路徑問題也是NP完全問題,就可以將哈密頓迴路問題規約為哈密頓路徑問題。

[這裡作者並沒有過多的提到問題A規約至問題B的複雜度,演算法導論中有提到,作者可能隱藏了規約的複雜度不大的含義,比如說多項式時間內能夠完成,也就是下面的fast readuction]

“fast + fast = fast.” 的含義是:fast readuction + fast solution to B = fast solution to A

兩條重要的規約經驗:

• If you can (easily) reduce A to B, then B is at least as hard as A.

• If you want to show that X is hard and you know that Y is hard, reduce Y to X.

5.Problem Solving Advice

作者提供的解決一個問題的建議:

(1)Make sure you really understand the problem.

搞明白你要解決的問題

What is the input? The output? What’s the precise relationship between the two? Try to represent the problem instances as familiar structures, such as sequences or graphs. A direct, brute-force solution can sometimes help clarify exactly what the problem is.

(2)Look for a reduction.

尋找一個規約方式

Can you transform the input so it works as input for another problem that you can solve? Can you transform the resulting output so that you can use it? Can you reduce an instance if size n to an instance of size k < n and extend the recursive solution (inductive hypothesis) back to n?

(3)Are there extra assumptions you can exploit?

還有其他的重要的假設條件嗎,有時候我們如果只考慮該問題的特殊情況的話沒準能夠有所收穫

Integers in a fixed value range can be sorted more efficiently than arbitrary values. Finding the shortest path in a DAG is easier than in an arbitrary graph, and using only non-negative edge weights is often easier than arbitrary edge weights.


問題4-18. 隨機生成DAG圖

Write a function for generating random DAGs. Write an automatic test that checks that topsort gives a valid orderings, using your DAG generator.

You could generate DAGs by, for example, randomly ordering the nodes, and add a random number of forward-pointing edges to each of them.

問題4-19. 修改拓撲排序

Redesign topsort so it selects the last node in each iteration, rather than the first.

This is quite similar to the original. You now have to maintain the out-degrees of the remaining nodes, and insert each node before the ones you have already found. (Remember not to insert anything in the beginning of a list, though; rather, append, and then reverse it at the end, to avoid a quadratic running time.)

[注意是使用append然後reverse,而不要使用insert]

相關文章