Python演算法:動態規劃

發表於2015-05-20

本節主要結合一些經典的動規問題介紹動態規劃的備忘錄法和迭代法這兩種實現方式,並對這兩種方式進行對比

[這篇文章實際寫作時間在這個系列文章之前,所以寫作風格可能略有不同,嘿嘿]

大家都知道,動態規劃演算法一般都有下面兩種實現方式,前者我稱為遞迴版本,後者稱為迭代版本,根據前面的知識可知,這兩個版本是可以相互轉換的

1.直接自頂向下實現遞迴式,並將中間結果儲存,這叫備忘錄法;

2.按照遞迴式自底向上地迭代,將結果儲存在某個資料結構中求解。

程式設計有一個原則DRY=Don’t Repeat Yourself,就是說你的程式碼不要重複來重複去的,這個原則同樣可以用於理解動態規劃,動態規劃除了滿足最優子結構,它還存在子問題重疊的性質,我們不能重複地去解決這些子問題,所以我們將子問題的解儲存起來,類似快取機制,之後遇到這個子問題時直接取出子問題的解。

舉個簡單的例子,斐波那契數列中的元素的計算,很簡單,我們寫下如下的程式碼:

好,來測試下,執行fib(10)得到結果69,不錯,速度也還行,換個大的數字,試試100,這時你會發現,這個程式執行不出結果了,為什麼?遞迴太深了!要計算的子問題太多了!

所以,我們需要改進下,我們儲存每次計算出來的子問題的解,用什麼儲存呢?用Python中的dict!那怎麼實現儲存子問題的解呢?用Python中的裝飾器!

如果不是很瞭解Python的裝飾器,可以快速看下這篇總結中關於裝飾器的解釋:Python Basics

修改剛才的程式,得到如下程式碼,定義一個函式memo返回我們需要的裝飾器,這裡用cache儲存子問題的解,key是方法的引數,也就是數字n,值就是fib(n)返回的解。

重新執行下fib(100),你會發現這次很快就得到了結果573147844013817084101,這就是動態規劃的威力,上面使用的是第一種帶備忘錄的遞迴實現方式。

帶備忘錄的遞迴方式的優點就是易於理解,易於實現,程式碼簡潔乾淨,執行速度也不錯,直接從需要求解的問題出發,而且只計算需要求解的子問題,沒有多餘的計算。但是,它也有自己的缺點,因為是遞迴形式,所以有限的棧深度是它的硬傷,有些問題難免會出現棧溢位了。

於是,迭代版本的實現方式就誕生了!

迭代實現方式有2個好處:1.執行速度快,因為沒有用棧去實現,也避免了棧溢位的情況;2.迭代實現的話可以不使用dict來進行快取,而是使用其他的特殊cache結構,例如多維陣列等更為高效的資料結構。

那怎麼把遞迴版本轉變成迭代版本呢?

這就是遞迴實現和迭代實現的重要區別:遞迴實現不需要去考慮計算順序,只要給出問題,然後自頂向下去解就行;而迭代實現需要考慮計算順序,並且順序很重要,演算法在執行的過程中要保證當前要計算的問題中的子問題的解已經是求解好了的。

斐波那契數列的迭代版本很簡單,就是按順序來計算就行了,不解釋,關鍵是你可以看到我們就用了3個簡單變數就求解出來了,沒有使用任何高階的資料結構,節省了大量的空間。

斐波那契數列的變種經常出現在上樓梯的走法問題中,每次只能走一個臺階或者兩個臺階,廣義上思考的話,動態規劃也就是一個連續決策問題,到底當前這一步是選擇它(走一步)還是不選擇它(走兩步)呢?

其他問題也可以很快地變相思考發現它們其實是一樣的,例如求二項式係數C(n,k),楊輝三角(求從源點到目標點有多少種走法)等等問題。

二項式係數C(n,k)表示從n箇中選k個,假設我們現在處理n箇中的第1個,考慮是否選擇它。如果選擇它的話,那麼我們還需要從剩下的n-1箇中選k-1個,即C(n-1,k-1);如果不選擇它的話,我們需要從剩下的n-1中選k個,即C(n-1,k)。所以,C(n,k)=C(n-1,k-1)+C(n-1,k)

結合前面的裝飾器,我們很快便可以實現求二項式係數的遞迴實現程式碼,其中的memo函式完全沒變,只是在函式cnk前面新增了@memo而已,就這麼簡單!

它的迭代版本也比較簡單,這裡使用了defaultdict,略高階的資料結構,和dict不同的是,當查詢的key不存在對應的value時,會返回一個預設的值,這個很有用,下面的程式碼可以看到。 如果不瞭解defaultdict的話可以看下Python中的高階資料結構

楊輝三角大家都熟悉,在國外這個叫Pascal Triangle,它和二項式係數特別相似,看下圖,除了兩邊的數字之外,裡面的任何一個數字都是由它上面相鄰的兩個元素相加得到,想想C(n,k)=C(n-1,k-1)+C(n-1,k)不也就是這個含義嗎?

所以說,順序對於迭代版本的動態規劃實現很重要,下面舉個例項,用動態規劃解決有向無環圖的單源最短路徑問題。假設有如下圖所示的圖,當然,我們看到的是這個有向無環圖經過了拓撲排序之後的結果,從a到f的最短路徑用灰色標明瞭。

好,怎麼實現呢?

我們有兩種思考方式:

1.”去哪裡?”:我們順向思維,首先假設從a點出發到所有其他點的距離都是無窮大,然後,按照拓撲排序的順序,從a點出發,接著更新a點能夠到達的其他的點的距離,那麼就是b點和f點,b點的距離變成2,f點的距離變成9。因為這個有向無環圖是經過了拓撲排序的,所以按照拓撲順序訪問一遍所有的點(到了目標點就可以停止了)就能夠得到a點到所有已訪問到的點的最短距離,也就是說,當到達哪個點的時候,我們就找到了從a點到該點的最短距離,拓撲排序保證了後面的點不會指向前面的點,所以訪問到後面的點時不可能再更新它前面的點的最短距離!(這裡的更新也就是前面第4節介紹過的relaxtion)這種思維方式的程式碼實現就是迭代版本。

[這裡涉及到了拓撲排序,前面第5節Traversal中介紹過了,這裡為了方便沒看前面的童鞋理解,W直接使用的是經過拓撲排序之後的結果。]

用圖來表示計算過程就是下面所示:

2.”從哪裡來?”:我們逆向思維,目標是要到f,那從a點經過哪個點到f點會近些呢?只能是求解從a點出發能夠到達的那些點哪個距離f點更近,這裡a點能夠到達b點和f點,f點到f點距離是0,但是a到f點的距離是9,可能不是最近的路,所以還要看b點到f點有多近,看b點到f點有多近就是求解從b點出發能夠到達的那些點哪個距離f點更近,所以又繞回來了,也就是遞迴下去,直到我們能夠回答從a點經過哪個點到f點會更近。這種思維方式的程式碼實現就是遞迴版本。

這種情況下,不需要輸入是經過了拓撲排序的,所以你可以任意修改輸入W中節點的順序,結果都是一樣的,而上面採用迭代實現方式必須要是拓撲排序了的,從中你就可以看出迭代版本和遞迴版本的區別了。

用圖來表示計算過程就如下圖所示:

[擴充套件內容:對DAG求單源最短路徑的動態規劃問題的總結,比較難理解,附上原文]

Although the basic algorithm is the same, there are many ways of finding the shortest path in a DAG, and, by extension, solving most DP problems. You could do it recursively, with memoization, or you could do it iteratively, with relaxation. For the recursion, you could start at the first node, try various “next steps,” and then recurse on the remainder, or (if you graph representation permits) you could look at the last node and try “previous steps” and recurse on the initial part. The former is usually much more natural, while the latter corresponds more closely to what happens in the iterative version.

Now, if you use the iterative version, you also have two choices: you can relax the edges out of each node (in topologically sorted order), or you can relax all edges into each node. The latter more obviously yields a correct result but requires access to nodes by following edges backward. This isn’t as far-fetched as it seems when you’re working with an implicit DAG in some nongraph problem. (For example, in the longest increasing subsequence problem, discussed later in this chapter, looking at all backward “edges” can be a useful perspective.)

Outward relaxation, called reaching, is exactly equivalent when you relax all edges. As explained, once you get to a node, all its in-edges will have been relaxed anyway. However, with reaching, you can do something that’s hard in the recursive version (or relaxing in-edges): pruning. If, for example, you’re only interested in finding all nodes that are within a distance r, you can skip any node that has distance estimate greater than r. You will still need to visit every node, but you can potentially ignore lots of edges during the relaxation. This won’t affect the asymptotic running time, though (Exercise 8-6).

Note that finding the shortest paths in a DAG is surprisingly similar to, for example, finding the longest path, or even counting the number of paths between two nodes in a DAG. The latter problem is exactly what we did with Pascal’s triangle earlier; the exact same approach would work for an arbitrary graph. These things aren’t quite as easy for general graphs, though. Finding shortest paths in a general graph is a bit harder (in fact, Chapter 9 is devoted to this topic), while finding the longest path is an unsolved problem (see Chapter 11 for more on this).

好,我們差不多搞清楚了動態規劃的本質以及兩種實現方式的優缺點,下面我們來實踐下,舉最常用的例子:矩陣鏈乘問題,內容較多,所以請點選連結過去閱讀完了之後回來看總結

OK,希望我把動態規劃講清楚了,總結下:動態規劃其實就是一個連續決策的過程,每次決策我們可能有多種選擇(二項式係數和0-1揹包問題中我們只有兩個選擇,DAG圖的單源最短路徑中我們的選擇要看點的出邊或者入邊,矩陣鏈乘問題中就是矩陣鏈可以分開的位置總數…),我們每次選擇最好的那個作為我們的決策。所以,動態規劃的時間複雜度其實和這兩者有關,也就是子問題的個數以及子問題的選擇個數,一般情況下動態規劃演算法的時間複雜度就是兩者的乘積。

動態規劃有兩種實現方式:一種是帶備忘錄的遞迴形式,這種方式直接從原問題出發,遇到子問題就去求解子問題並儲存子問題的解,下次遇到的時候直接取出來,問題求解的過程看起來就像是先自頂向下地展開問題,然後自下而上的進行決策;另一個實現方式是迭代方式,這種方式需要考慮如何給定一個子問題的求解方式,使得後面求解規模較大的問題是需要求解的子問題都已經求解好了,它的缺點就是可能有些子問題不要算但是它還是算了,而遞迴實現方式只會計算它需要求解的子問題。


練習1:來試試寫寫最長公共子序列吧,這篇文章中給出了Python版本的5種實現方式喲!

練習2:演算法導論問題 15-4: Planning a company party 計劃一個公司聚會

Start example Professor Stewart is consulting for the president of a corporation that is planning a company party. The company has a hierarchical structure; that is, the supervisor relation forms a tree rooted at the president. The personnel office has ranked each employee with a conviviality rating, which is a real number. In order to make the party fun for all attendees, the president does not want both an employee and his or her immediate supervisor to attend.

Professor Stewart is given the tree that describes the structure of the corporation, using the left-child, right-sibling representation described in Section 10.4. Each node of the tree holds, in addition to the pointers, the name of an employee and that employee’s conviviality ranking. Describe an algorithm to make up a guest list that maximizes the sum of the conviviality ratings of the guests. Analyze the running time of your algorithm.

原問題可以轉換成:假設有一棵樹,用左孩子右兄弟的表示方式表示,樹的每個結點有個值,選了某個結點,就不能選擇它的父結點,求整棵樹選的節點值最大是多少。

假設如下:

dp[i][0]表示不選i結點時,i子樹的最大價值

dp[i][1]表示選i結點時,i子樹的最大價值

列出狀態方程

dp[i][0] = sum(max(dp[u][0], dp[u][1]))  (如果不選i結點,u為結點i的兒子)

dp[i][1] = sum(dp[u][0]) + val[i]  (如果選i結點,val[i]表示i結點的價值)

最後就是求max(dp[root][0], dp[root][1])

相關文章