1. 程式人生 > >硬幣找零,最長上升子序列,揹包問題等動態規劃問題詳解

硬幣找零,最長上升子序列,揹包問題等動態規劃問題詳解

1.硬幣找零

如果我們有面值為 1 元、3 元和 5 元的硬幣若干枚,如何用最少的硬幣湊夠 11 元?
首先我們思考一個問題,如何用最少的硬幣湊夠 i 元(i<11)?為什麼要這麼問呢? 兩個原因:1.當我們遇到一個大問題時,總是習慣把問題的規模變小,這樣便於分析討論。 2.這個規模變小後的問題和原來的問題是同質的,除了規模變小,其它的都是一樣的, 本質上它還是同一個問題(規模變小後的問題其實是原問題的子問題)。

好了,讓我們從最小的 i 開始吧。當 i=0,即我們需要多少個硬幣來湊夠 0 元。 由於 1,3,5 都大於 0,即沒有比 0 小的幣值,因此湊夠 0 元我們最少需要 0 個硬幣。 (這個分析很傻是不是?彆著急,這個思路有利於我們理清動態規劃究竟在做些什麼。) 這時候我們發現用一個標記來表示這句“湊夠 0 元我們最少需要 0 個硬幣。”會比較方便, 如果一直用純文字來表述,不出一會兒你就會覺得很繞了。那麼, 我們用 d(i)=j 來表示湊夠 i 元最少需要 j 個硬幣。於是我們已經得到了 d(0)=0, 表示湊夠 0 元最小需要 0 個硬幣。當 i=1 時,只有面值為 1 元的硬幣可用, 因此我們拿起一個面值為 1 的硬幣,接下來只需要湊夠 0 元即可,而這個是已經知道答案的, 即 d(0)=0。所以,d(1)=d(1-1)+1=d(0)+1=0+1=1。當 i=2 時, 仍然只有面值為 1 的硬幣可用,於是我拿起一個面值為 1 的硬幣, 接下來我只需要再湊夠 2-1=1 元即可(記得要用最小的硬幣數量),而這個答案也已經知道了。 所以 d(2)=d(2-1)+1=d(1)+1=1+1=2。一直到這裡,你都可能會覺得,好無聊, 感覺像做小學生的題目似的。因為我們一直都只能操作面值為 1 的硬幣!耐心點, 讓我們看看 i=3 時的情況。當 i=3 時,我們能用的硬幣就有兩種了:1 元的和 3 元的( 5 元的仍然沒用,因為你需要湊的數目是 3 元!5 元太多了親)。 既然能用的硬幣有兩種,我就有兩種方案。如果我拿了一個 1 元的硬幣,我的目標就變為了: 湊夠 3-1=2 元需要的最少硬幣數量。即 d(3)=d(3-1)+1=d(2)+1=2+1=3。 這個方案說的是,我拿 3 個 1 元的硬幣;第二種方案是我拿起一個 3 元的硬幣, 我的目標就變成:湊夠 3-3=0 元需要的最少硬幣數量。即 d(3)=d(3-3)+1=d(0)+1=0+1=1. 這個方案說的是,我拿 1 個 3 元的硬幣。好了,這兩種方案哪種更優呢? 記得我們可是要用最少的硬幣數量來湊夠 3 元的。所以, 選擇 d(3)=1,怎麼來的呢?具體是這樣得到的:d(3)=min{d(3-1)+1, d(3-3)+1}。

OK,碼了這麼多字講具體的東西,讓我們來點抽象的。從以上的文字中, 我們要抽出動態規劃裡非常重要的兩個概念:狀態和狀態轉移方程。

上文中 d(i)表示湊夠 i 元需要的最少硬幣數量,我們將它定義為該問題的”狀態”, 這個狀態是怎麼找出來的呢?一般來說,我們可以根據子問題定義狀態。你找到子問題,狀態也就浮出水面了。 最終我們要求解的問題,可以用這個狀態來表示:d(11),即湊夠 11 元最少需要多少個硬幣。 那狀態轉移方程是什麼呢?既然我們用 d(i)表示狀態,那麼狀態轉移方程自然包含 d(i), 上文中包含狀態 d(i)的方程是:d(3)=min{d(3-1)+1, d(3-3)+1}。沒錯, 它就是狀態轉移方程,描述狀態之間是如何轉移的。當然,我們要對它抽象一下,

d(i)=min{ d(i-vj)+1 },其中 i-vj >=0,vj表示第 j 個硬幣的面值;

有了狀態和狀態轉移方程,這個問題基本上也就解決了。

def coins_solve(n):
    minarray = [x for x in range(n+1)]
    coins = [1, 3, 5]
    for i in range(n+1):
        for j in coins:
            # min {d(i - vj) + 1}
            if j <= i and minarray[i-j] + 1 < minarray[i]:
                minarray[i] = minarray[i - j] + 1
    print minarray
    print "the result is: ", minarray[-1]

coins_solve(11)

2.最長非降子序列(LIS:longest increasing subsequence)

一個序列有 N 個數:A[1],A[2],…,A[N],求出最長非降子序列的長度。

正如上面我們講的,面對這樣一個問題,我們首先要定義一個“狀態”來代表它的子問題, 並且找到它的解。注意,大部分情況下,某個狀態只與它前面出現的狀態有關, 而獨立於後面的狀態。

讓我們沿用“入門”一節裡那道簡單題的思路來一步步找到“狀態”和“狀態轉移方程”。 假如我們考慮求 A[1],A[2],…,A[i]的最長非降子序列的長度,其中 i<N, 那麼上面的問題變成了原問題的一個子問題(問題規模變小了,你可以讓 i=1,2,3 等來分析) 然後我們定義 d(i),表示前 i 個數中以 A[i]結尾的最長非降子序列的長度。OK, 對照“入門”中的簡單題,你應該可以估計到這個 d(i)就是我們要找的狀態。 如果我們把 d(1)到 d(N)都計算出來,那麼最終我們要找的答案就是這裡面最大的那個。 狀態找到了,下一步找出狀態轉移方程。

根據上面找到的狀態,我們可以得到:(下文的最長非降子序列都用 LIS 表示)

前 1 個數的 LIS 長度 d(1)=1(序列:5)
前 2 個數的 LIS 長度 d(2)=1(序列:3;3 前面沒有比 3 小的)
前 3 個數的 LIS 長度 d(3)=2(序列:3,4;4 前面有個比它小的 3,所以 d(3)=d(2)+1)
前 4 個數的 LIS 長度 d(4)=3(序列:3,4,8;8 前面比它小的有 3 個數,所以 d(4)=max{d(1),d(2),d(3)}+1=3)

OK,分析到這,我覺得狀態轉移方程已經很明顯了,如果我們已經求出了 d(1)到 d(i-1), 那麼 d(i)可以用下面的狀態轉移方程得到:

d(i) = max{1, d(j)+1},其中 j<i,A[j]<=A[i]

用大白話解釋就是,想要求 d(i),就把 i 前面的各個子序列中, 最後一個數不大於 A[i]的序列長度加 1,然後取出最大的長度即為 d(i)。 當然了,有可能 i 前面的各個子序列中最後一個數都大於 A[i],那麼 d(i)=1, 即它自身成為一個長度為 1 的子序列。

def LIS(rawlist):
    d = [1 for _ in range(len(rawlist))]
    result = 1
    for i in range(len(rawlist)):
        for j in range(i):
            if rawlist[j] <= rawlist[i]:
                d[i] = max(d[i], d[j] + 1)
            result = max(result, d[i])
    print result


rawlist = [5, 3, 4, 8, 6, 7]
LIS(rawlist)

3.揹包問題

我們需要選擇n個元素中的若干個來形成最優解,假定為k個。那麼對於這k個元素a1, a2, …ak來說,它們組成的物品組合必然滿足總重量<=揹包重量限制,而且它們的價值必然是最大的。因為它們是我們假定的最優選擇嘛,肯定價值應該是最大的。假定ak是我們按照前面順序放入的最後一個物品。它的重量為wk,它的價值為vk。既然我們前面選擇的這k個元素構成了最優選擇,如果我們把這個ak物品拿走,對應於k-1個物品來說,它們所涵蓋的重量範圍為0-(W-wk)。假定W為揹包允許承重的量。假定最終的價值是V,剩下的物品所構成的價值為V-vk。這剩下的k-1個元素是不是構成了一個這種W-wk的最優解呢?
我們可以用反證法來推導。假定拿走ak這個物品後,剩下的這些物品沒有構成W-wk重量範圍的最佳價值選擇。那麼我們肯定有另外k-1個元素,他們在W-wk重量範圍內構成的價值更大。如果這樣的話,我們用這k-1個物品再加上第k個,他們構成的最終W重量範圍內的價值就是最優的。這豈不是和我們前面假設的k個元素構成最佳矛盾了嗎?所以我們可以肯定,在這k個元素裡拿掉最後那個元素,前面剩下的元素依然構成一個最佳解。
現在我們經過前面的推理已經得到了一個基本的遞推關係,就是一個最優解的子解集也是最優的。可是,我們該怎麼來求得這個最優解呢?我們這樣來看。假定我們定義一個函式c[i, w]表示到第i個元素為止,在限制總重量為w的情況下我們所能選擇到的最優解。那麼這個最優解要麼包含有i這個物品,要麼不包含,肯定是這兩種情況中的一種。如果我們選擇了第i個物品,那麼實際上這個最優解是c[i - 1, w-wi] + vi。而如果我們沒有選擇第i個物品,這個最優解是c[i-1, w]。這樣,實際上對於到底要不要取第i個物品,我們只要比較這兩種情況,哪個的結果值更大不就是最優的麼?
在前面討論的關係裡,還有一個情況我們需要考慮的就是,我們這個最優解是基於選擇物品i時總重量還是在w範圍內的,如果超出了呢?我們肯定不能選擇它,這就和c[i-1, w]一樣。
這裡有一點值得注意,這裡的wi指的是第i個物品的重量,而不是到第i個物品時的總重量。
另外,對於初始的情況呢?很明顯c[0, w]裡不管w是多少,肯定為0。因為它表示我們一個物品都不選擇的情況。c[i, 0]也一樣,當我們總重量限制為0時,肯定價值為0。
這樣,基於我們前面討論的這3個部分,我們可以得到一個如下的遞推公式:
在這裡插入圖片描述

有了這個關係,我們可以更進一步的來考慮程式碼實現了。我們有這麼一個遞迴的關係,其中,後面的函式結果其實是依賴於前面的結果的。我們只要按照前面求出來最基礎的最優條件,然後往後面一步步遞推,就可以找到結果了。
我們再來考慮一下具體實現的細節。這一組物品分別有價值和重量,我們可以定義兩個陣列int[] v, int[] w。v[i]表示第i個物品的價值,w[i]表示第i個物品的重量。為了表示c[i, w],我們可以使用一個int[i][w]的矩陣。其中i的最大值為物品的數量,而w表示最大的重量限制。按照前面的遞推關係,c[i][0]和c[0][w]都是0。而我們所要求的最終結果是c[n][w]。所以我們實際中建立的矩陣是(n + 1) x (w + 1)的規格。

# n:物品件數;c:最大承重為c的揹包;w:各個物品的重量;p:各個物品的價值
# 第一步建立最大價值矩陣(橫座標表示[0,c]整數揹包承重):(n+1)*(c+1)
# 技巧:python 生成二維陣列(陣列)通常先生成列再生成行
def bag(n,c,w,p):
    res=[[-1 for j in range(c+1)]for i in range(n+1)]
    for j in range(c+1):
        # 第0行全部賦值為0,物品編號從1開始.為了下面賦值方便
        res[0][j]=0
    for i in range(1, n+1):
        for j in range(1, c+1):
            res[i][j]=res[i-1][j]
            # 生成了n*c有效矩陣,以下公式w[i-1],p[i-1]代表從第一個元素w[0],p[0]開始取。
            if(j >= w[i-1]) and res[i-1][j-w[i-1]]+p[i-1]>res[i][j]:
                res[i][j] = res[i-1][j-w[i-1]]+p[i-1]
    return res


# 以下程式碼功能:標記出有放入揹包的物品
# 反過來標記,在相同價值情況下,後一件物品比前一件物品的最大價值大,則表示物品i#有被加入到揹包,x陣列設定為True。設初始為j=c。
def show(n, c, w, res):
    print '最大價值為:',res[n][c]
    x=[False for i in range(n)]
    j=c
    for i in range(1,n+1):
        if res[i][j]>res[i-1][j]:
            x[i-1]=True
            j-=w[i-1]
    print '選擇的物品為:'
    for i in range(n):
        if x[i]:
            print '第',i,'個,'
    print''


if __name__=='__main__':
    n=5
    c=10
    w=[2,2,6,5,4]
    p=[6,3,5,4,6]
    res=bag(n,c,w,p)
    show(n,c,w,res)

最後的結果為:

最大價值為: 15
選擇的物品為:
第 0 個,
第 1 個,
第 4 個,

參考文獻:
1.https://www.deeplearn.me/216.html
2.https://www.jianshu.com/p/25f4a183ede5