Python <演算法思想集結>之抽絲剝繭聊動態規劃

一枚大果殼發表於2022-05-30

1. 概述

動態規劃演算法應用非常之廣泛。

對於演算法學習者而言,不跨過動態規劃這道門,不算真正瞭解演算法。

初接觸動態規劃者,理解其思想精髓會存在一定的難度,本文將通過一個案例,抽絲剝繭般和大家聊聊動態規劃

動態規劃演算法有 3 個重要的概念:

  • 重疊子問題。
  • 最優子結構。
  • 狀態轉移。

只有吃透這 3 個概念,才叫真正理解什麼是動態規劃

什麼是重疊子問題?

動態規劃分治演算法有一個相似之處。

將原問題分解成相似的子問題,在求解的過程中通過子問題的解求出原問題的解。

動態規劃與分治演算法的區別:

  • 分治演算法的每一個子問題具有完全獨立性,只會被計算一次。

    二分查詢是典型的分治演算法實現,其子問題是把數列縮小後再二分查詢,每一個子問題只會被計算一次。

  • 動態規劃經分解得到的子問題往往不是互相獨立的,有些子問題會被重複計算多次,這便是重疊子問題

  • 同一個子問題被計算多次,完全是沒有必要的,可以快取已經計算過的子問題,再次需要子問題結果時只需要從快取中獲取便可。這便是動態規劃中的典型操作,優化重疊子問題,通過空間換時間的優化手段提高效能。

重疊子問題並不是動態規劃的專利,重疊子問題是一個很普見的現象。

什麼最優子結構?

最優子結構是動態規劃的必要條件。因為動態規劃只能應用於具有最優子結構的問題,在解決一個原始問題時,是否能套用動態規劃演算法,分析是否存在最優子結構是關鍵。

那麼!到底什麼是最優子結構?概念其實很簡單,區域性最優解能決定全域性最優解。

如拔河比賽中。如果 A隊中的每一名成員的力氣都是每一個班上最大的,由他們組成的拔河隊毫無疑問,一定是也是所有拔河隊中實力最強的。

如果把求解哪一個團隊的力量最大當成原始問題,則每一個人的力量是否最大就是子問題,則子問題的最優決定了原始問題的最優。

所以,動態規劃多用於求最值的應用場景。

不是說有 3 個概念嗎!

不急,先把狀態轉移這個概念放一放,稍後再解釋。

2. 流程

下面以一個案例的解決過程描述使用動態規劃的流程。

問題描述:小兔子的難題。

有一隻小兔子站在一片三角形的胡蘿蔔地的入口,如下圖所示,圖中的數字表示每一個坑中胡蘿蔔的數量,小兔子每次只能跳到左下角或者右下角的坑中,請問小兔子怎麼跳才能得到最多數量的胡蘿蔔?

首先這個問題是求最值問題, 是否能夠使用動態規劃求解,則需要一步一步分析,看是否有滿足使用動態規劃的條件。

dt01.png

2.1 是否存在子問題

先來一個分治思想:思考或觀察是否能把原始問題分解成相似的子問題,把解決問題的希望寄託在子問題上。

那麼,針對上述三角形數列,是否存在子問題?

現在從數字7出發,兔子有 2 條可行路線。

dt02.png

為了便於理解,首先模糊第 3 行後面的數字或假設第 3行之後根本不存在。

那麼原始問題就變成:

  • 先分別求解路線 1路線 2上的最大值。路線 1的最大值為 3,路線 2上的最大值是8

  • 然後求解出路線 1路線 2兩者之間的最大值 8。 把求得的結果和出發點的數字 7 相加,7+8=15 就是最後答案。

    只有 2 行時,兔子能獲得的最多蘿蔔數為 15,肉眼便能看的出來。

前面是假設第 3 行之後都不存在,現在把第 3 行放開,則路線 1 路線2的最大值就要發生變化,但是,對於原始問題來講,可以不用關心路線 1 和路線 2 是怎麼獲取到最大值,交給子問題自己處理就可以了。

反正,到時從路線 1路線 2 的結果中再選擇一個最大值就是。

dt03.png

把第 3 行放開後,路線 1 就要重新更新最大值,如上圖所示,路線 1也可以分解成子問題,分解後,也只需要關心子問題的返回結果。

  • 路線 1 的子問題有 2個,路線 1_1路線1_2。求解 2 個子問題的最大值後,再在 2 個子問題中選擇最大值8,最後路線 1的最大值為3+8=11
  • 路線 2 的子問題有 2個,路線 2_1路線2_2。求解 2 個子問題的最大值後,再在 2 個子問題中選擇最大值2,最後路線 2的最大值為8+2=10

當第 3 行放開後,更新路線 1路線2的最大值,對於原始問題而言,它只需要再在 2 個子問題中選擇最大值 11,最終問題的解為7+11=18

如果放開第 4 行,將重演上述的過程。和原始問題一樣,都是從一個點出發,求解此點到目標行的最大值。所以說,此問題是存在子問題的。

並且,只要找到子問題的最優解,就能得到最終原始問題的最優解。不僅存在子問題,而且存在最優子結構。

顯然,這很符合遞迴套路:遞進給子問題,回溯子問題的結果。

  • 使用二維數列表儲存三角形數列中的所有資料。a=[[7],[3,8],[8,1,2],[2,7,4,4],[4,5,2,6,5]]

  • 原始問題為 f(0,0)從數列的(0,0)出發,向左下角和右下角前行,一直找到此路徑上的數字相加為最大。

    f(0,0)表示以第 1 行的第 1 列數字為起始點。

  • 分解原始問題 f(0,0)=a(0,0)+max(f(1,0)+f(1,1))

  • 因為每一個子問題又可以分解,讓表示式更通用 f(i,j)=a(i,j)+max(f(i+1,j)+f(i+1,j+1))

    (i+1,j)表示 (i,j)的左下角,(i+1,j+1)表示 (i,j)的右下角,

編碼實現:

# 已經數列
nums = [[7], [3, 8], [8, 1, 2], [2, 7, 4, 4], [4, 5, 2, 6, 5]]
# 遞迴函式
def get_max_lb(i, j):
    if i == len(nums) - 1:
        # 遞迴出口
        return nums[i][j]
    # 分解子問題
    return nums[i][j] + max(get_max_lb(i + 1, j), get_max_lb(i + 1, j + 1))
# 測試
res = get_max_lb(0, 0)
print(res)
'''
輸出結果
30
'''

不是說要聊聊動態規劃的流程嗎!怎麼跑到遞迴上去了。

其實所有能套用動態規劃的演算法題,都可以使用遞迴實現,因遞迴平時接觸多,從遞迴切入,可能更容易理解。

2.2 是否存在重疊子問題

先做一個實驗,增加三角形數的行數,也就是延長路徑線。

import random
nums = []
# 遞迴函式
def get_max_lb(i, j):
    if i == len(nums) - 1:
        return nums[i][j]
    return nums[i][j] + max(get_max_lb(i + 1, j), get_max_lb(i + 1, j + 1))
# 構建 100 行的二維列表
for i in range(100):
    nums.append([])
    for j in range(i + 1):
        nums[i].append(random.randint(1, 100))

res = get_max_lb(0, 0)
print(res)

執行程式後,久久沒有得到結果,甚至會超時。原因何在?如下圖:

dt04.png

路線1_2路線2_1的起點都是從同一個地方(藍色標註的位置)出發。顯然,從數字 1(藍色標註的數字)出發的這條路徑會被計算 2 次。在上圖中被重複計算的子路徑可不止一條。

這便是重疊子問題!子問題被重複計算。

dt05.png

當三角形數列的資料不是很多時,重複計算對整個程式的效能的影響微不足道 。如果資料很多時,大量的重複計算會讓計算機效能低下,並可能導致最後崩潰。

因為使用遞迴的時間複雜度為O(2^n)。當資料的行數變多時,可想而知,效能有多低下。

怎麼解決重疊子問題?

答案是:使用快取,把曾經計算過的子問題結果快取起來,當再次需要子問題結果時,直接從快取中獲取,就沒有必要再次計算。

這裡使用字典作為快取器,以子問題的起始位置為關鍵字,以子問題的結果為

import random
def get_max_lb(i, j):
    if i == len(nums) - 1:
        return nums[i][j]
    left_max = None
    right_max = None
    if (i + 1, j) in dic.keys():
        # 檢查快取中是否存在子問題的結果
        left_max = dic[i + 1, j]
    else:
        # 快取中沒有,才遞迴求解
        left_max = get_max_lb(i + 1, j)
        # 求解後的結果快取起來
        dic[(i + 1, j)] = left_max
    if (i + 1, j + 1) in dic.keys():
        right_max = dic[i + 1, j + 1]
    else:
        right_max = get_max_lb(i + 1, j + 1)
        dic[(i + 1, j + 1)] = right_max
    return nums[i][j] + max(left_max, right_max)

# 已經數列
nums = []
# 快取器
dic = {}
for i in range(100):
    nums.append([])
    for j in range(i + 1):
        nums[i].append(random.randint(1, 100))
# 遞迴呼叫
res = get_max_lb(0, 0)
print(res)

因使用隨機數生成資料,每次執行結果不一樣。但是,每次執行後的速度是非常給力的。

當出現重疊子問題時,可以快取曾經計算過的子問題。

好 !現在到了關鍵時刻,屏住呼吸,從分析快取中的資料開始。

使用遞迴解決問題,從結構上可以看出是從上向下的一種處理機制。所謂從上向下,也就是由原始問題開始一路去尋找答案。從本題來講,就是從第一行一直找到最後一行,或者說從未知找到``已知`。

根據遞迴的特點,可知快取資料的操作是在回溯過程中發生的。

dt06.png

當再次需要呼叫某一個子問題時,這時才有可能從快取中獲取到已經計算出來的結果。快取中的資料是每一個子問題的結果,如果知道了某一個子問題,就可以通過子問題計算出父問題。

這時,可能就會有一個想法?

從已知找到未知。

任何一條路徑只有到達最後一行後才能知道最後的結果。可以認為,最後一行是已知資料。先快取最後一行,那麼倒數第 2 行每一個位置到最後一行的路徑的最大值就可以直接求出來。

同理,知道了倒數第 2 行的每一個位置的路徑最大值,就可以求解出倒數第 3行每一個位置上的最大值。以此類推一直到第 1 行。

天呀!多完美,還用什麼遞迴。

可以認為這種思想便是動態規劃的核心:自下向上

2.3 狀態轉移

還差最後一步,就能把前面的遞迴轉換成動態規劃實現。

什麼是狀態轉移?

前面分析從最後 1 開始求最大值過程,是不是有點像田徑場上的多人接力賽跑,第 1 名運動力爭跑第 1,把狀態轉移給第 2名運動員,第 2名運動員持續保持第 1,然後把狀態轉移給第 3運動員,第 3名運動員也保持他這一圈的第 1,一至到最後一名運動員,都保持自己所在那一圈中的第 1。很顯然最後結果,他們這個團隊一定是第 1名。

把子問題的值傳遞給另一個子問題,這便是狀態轉移。當然在轉移過程中,一定會存在一個表示式,用來計算如何轉移。

用來儲存每一個子問題狀態的表稱為 dp 表,其實就是前面遞迴中的快取器。

用來計算如何轉移的表示式,稱為狀態轉移方程式。

dt07.png

有了上述的這張表,就可以使用動態規劃自下向上的方式解決“兔子的難題”這個問題。

nums = [[7], [3, 8], [8, 1, 2], [2, 7, 4, 4], [4, 5, 2, 6, 5]]

# dp列表
dp = []
idx = 0
# 從最後一行開始
for i in range(len(nums) - 1, -1, -1):
    dp.append([])
    for j in range(len(nums[i])):
        if i == len(nums) - 1:
            # 最後一行快取於狀態轉移表中
            dp[idx].append(nums[i][j])
        else:
            dp[idx].append(nums[i][j] + max(dp[idx - 1][j], dp[idx - 1][j + 1]))
    idx += 1

print(dp)
'''
輸出結果:
[[4, 5, 2, 6, 5], [7, 12, 10, 10], [20, 13, 12], [23, 21], [30]]
'''

程式執行後,最終輸出結果和前面手工繪製的dp表中的資料一模一樣。

其實動態規劃實現是前面遞迴操作的逆過程。時間複雜度是O(n^2)。

並不是所有的遞迴操作都可以使用動態規劃進行逆操作,只有符合動態規劃條件的遞迴操作才可以。

上述解決問題時,使用了一個二維列表充當dp表,並儲存所有的中間資訊。

思考一下,真的有必要儲存所有的中間資訊嗎?

在狀態轉移過程中,我們僅關心當前得到的狀態資訊,曾經的狀態資訊其實完全可以不用儲存。

所以,上述程式完全可以使用一個一維列表來儲存狀態資訊。

nums = [[7], [3, 8], [8, 1, 2], [2, 7, 4, 4], [4, 5, 2, 6, 5]]

# dp表
dp = []
# 臨時表
tmp = []
# 從最後一行開始
for i in range(len(nums) - 1, -1, -1):
    # 把上一步得到的狀態資料提出來
    tmp = dp.copy()
    # 清除 dp 表中原來的資料,準備儲存最新的狀態資料
    dp.clear()
    for j in range(len(nums[i])):
        if i == len(nums) - 1:
            # 最後一行快取於狀態轉移表中
            dp.append(nums[i][j])
        else:
            dp.append(nums[i][j] + max(tmp[j], tmp[j + 1]))

print(dp)
'''
輸出結果:
[30]
'''

3.總結

動態規劃問題一般都可以使用遞迴實現,遞迴是一種自上向下的解決方案,而動態規劃是自下向上的解決方案,兩者在解決同一個問題時的思考角度不一樣,但本質是一樣的。

並不是所有的遞迴操作都能轉換成動態規劃,是否能使用動態規劃演算法,則需要原始問題符合最優子結構重疊子問題2 個條件。在使用動態規劃過程中,找到狀態轉移表示式是關鍵。

相關文章