動機

寫個dp的總結,個人不喜歡課本上的什麼最佳子結構什麼的,所以不會有那種名詞 只要懂遞迴就可以做dp了

對了,遞迴的函式預設有@cache,做記憶法

DP概述

Dynamic programming = planning over time

這裡的time要理解成順序,我們是在每個時間點上做決策,所以通常會需要資料有sort過或是本身有一定的順序

這些順序就是其他講解DP的資料會看提到的階段

DP 與 backtracking

Weighted interval scheduling: 每個job有weight挑job不重疊,並讓wieght最大

假設有job都已開始時間排序好,同時有一個函數(prev)可以給出前一個離最近的job

那就可以給出一個dp,就是看要不要這份job f(i)表示選到i時的最大wieght總和

@cache
def f(i):
  if i == 0:
    return jobs[i]
  else:
    return max(f(i-1), jobs[i]+f(prev[i]))

subset: 求subset 每一個都可以選或不選

def subset(arr):
  if not arr:
    return [[]]
  else:
    sub = subset(arr[1:])
    return sub+[s + [arr[0]] for s in sub]

可以看出dp與backtracking都是選與不選!! 那兩者差在哪裡?

backtrack需要之前的所有資料 dp可以利用有結合律函數(min, max, sum…)把之前的所有資料用一筆代表

不只一種DP

Weighted interval scheduling也可以這樣看

定義f(i,time)為在選第i樣工作,從time之前都有空

def f(i, time):
  if i == 0:
    return jobs[i]
  elif end[i] > time:
    return f(i-1)
  else:
    return max(f(i-1, time), jobs[i]+f(i-1, start[i]))

dp其實主要牽涉到怎麼設定狀態,像是把時間放進來,就會變成不同的dp 但前面利用前處理,讓找下一個job的速度上去,進而減少一個維度(參數)

DP 與 greedy

Greedy 不行就 DP

當 Greedy 不對就代表 「這個問題不能只由這個子問題轉移」 換句話說,Greedy 就是可以證明只有一個轉移來源的 DP

可以順便看列舉為什麼慢? 因為除了現在確定的參數外,還要繼續往後看才能確定答案

貪心就是只看當下,就ok了 DP是除了現在還要加上以前的資料

證明greedy

因為貪心就是只看當下,所以會需要證明這做法是對的

  • 反證法:如果交換方案中任意兩個元素/相鄰的兩個元素後,答案不會變得更好,那麼可以推定目前的解已經是最優解了。
  • 歸納法:先算得出邊界情況(例如 )的最優解 ,然後再證明:對於每個 , 都可以由 推導出結果。

常見題型

  • 「我們將 XXX 按照某某順序排序,然後按某種順序(例如從小到大)選擇。」。
    • 離線,先處理後選擇
  • 「我們每次都取 XXX 中最大/小的東西,並更新 XXX。」
    • (有時「XXX 中最大/小的東西」可以優化,比如用優先隊列維護)
    • 在線,邊處理邊選擇 解法
  • 排序解法
    • 用排序法常見的情況是輸入一個包含幾個(一般一到兩個)權值的數組,通過排序然後遍歷模擬計算的方法求出最優值。
  • 後悔解法
    • 思路是無論當前的選項是否最優都接受,然後進行比較,如果選擇之後不是最優了,則反悔,捨棄掉這個選項;否則,正式接受。如此往復。

DP 與 DAG

DP的狀態可以看成graph上的點

同時這個graph不會有環,所以是DAG

這樣DP其實就是求DAG的最長/短距離

recursion & iteration

iteration其實是recursion的特例

iteration,是直接從base case直接往上做,對應到bottom-up

recursion與iteration差在recursion要記憶當初誰call的context(用stack) 之後從頭往下,到底(base case)就照著stack往回走,這就是top-down

如果遞迴call的當下沒有其他要做的事,其實這個遞迴就是iteration

有context: sum(arr[1:])處理完還要與arr[0]的相加

def sum(arr):
  if not arr:
    return 0
  else:
    return arr[0]+sum(arr[1:])

沒context: sum(arr[1:],acc+arr[0])做完就沒有其他事了

def sum(arr,acc=0):
  if not arr:
    return acc
  else:
    return sum(arr[1:],acc+arr[0])

所以上面那個版本其實就是iteration,不過需要tail-recursion elimiation優化才有iteration的實際效能

top-down & bottom-up

top-down & bottom-up 不等價

Leetcode 718

如果用top-down,因為要追蹤每個range的長度,所以變成n^3 但是用bottom-up,長度自然是對的,所以變成n^2

Leetcode 546

只能用top-down去做,因為很難確定base case與怎麼從base case長上去 反倒是top-down的分解,就很好做

技巧

狀態壓縮

其實就是把應該用set或array表示的狀態用bitmask表示

因為是bitmask,所以是整數,是整數就能dp

刷表法

省mem,locality變好

DP優化 (我不會,這個只會出現在競程)

太可怕了,就把資料列一列就好 分治、DP 進階 Aliens 優化 進階DP,DP優化

寫的小技巧

  • 產生新值時固定寫法
    • 有的是f[n + x] = combine(f[n], ....)
    • 有的是f[n] = combine(f[n - x], ....)
    • 最好固定寫法,個人是喜歡
      • f[n] = combine(f[n - x], ....)
      • 因為dp一定是由小的更新大的
  • bottom-up或是改寫時可以畫圖輔助
    • 狀態怎麼到起點 (參數怎麼變化)
    • 在table上是取什麼點 (方向)
  • 從終點開始思考怎麼到起點 (top-down開始做)
    • 除非bottom-up很明顯,或是top-down在這題是個悲劇

設計方式

dp就是由參數(遞迴的模型的組合)、對應的動作構成、定義回傳值,只能在求最佳化問題(有結合律的函數)使用

遞迴的模型

  • 線性 (1D)
    • f(i) = combine(f(j) for j in range(i))
  • 區間 (2D)
    • 列舉中點
      • f(i,j) = combine(combine2(f(i,k), f(k,j)) for k in range(i,j))
    • 控制左右
      • f(i,j) = combine(f(i-1,j), f(i,j-1))
    • 分塊
      • f(i,j) = combine(f(i,a), f(a,b), f(b,j)) for (a,b) in ...
  • 點 (2D)
    • 上、左、左上
      • f(i,j) = combine(f(i-1,j), f(i,j-1), f(i-1,j-1))
    • 子樹
      • f(root) = combine(f(child) for child in root.children)
  • set(狀態壓縮)
    • f(state) = combine(f(next(state)))

上面是可能的遞迴方式,除了遞迴外,還有其他需要的訊息,像是座標、成本等等

需要遞迴的,加上其他需要的訊息,就是dp的狀態

動作

這就是看題目了,像是edit distance就有delete、replace、insert 動作會影響到

  • 子問題怎麼組合
  • 參數怎麼變化
  • 有什麼參數

定義回傳值 (成本)

dp回來的值一定是可以與其他dp組合在一起的值,像

  • 最大最小
  • 總和
  • 長度

通常我們會知道怎麼算最終答案的成本,但是dp時會需要計算當下狀態成本的方式,也就是需要把成本計算分散到每一層 這個吃狀態怎麼設計 以及 觀察 範例可以看Optimal binary search trees的depth怎麼處理的

多看看其他人怎麼做

設計dp有的時候要自己加維度,就如同前面看到的dp不只一種,端看設計與實作

所以其實寫dp最重要的是觀察與想像,所以多做題

特別的DP

背包

01背包:

選到i,剩下space個空間

def f(i,space):
  if space < 0:
    return float('-inf')
  elif i < 0:
    return 0
  else:
    return max(f(i-1,space), price[i]+f(i-1, space-cost[i])) # !!

無限背包:

def f(i,space):
  if space < 0:
    return float('-inf')
  elif i < 0:
    return 0
  else:
    return max(f(i-1,space), price[i]+f(i, space-cost[i])) # !!

滾動 & 從頭或從尾

注意到i都是只有i-1,所以可以用滾動,但要改寫成bottom-up

01背包:

這裡與原本top-down的差不多,從i往前推

dp = [0] * W
for p,w in items:
  for nowW in reversed(range(w, W+1)):
    dp[nowW] = max(dp[nowW], p+dp[nowW-w])

無限背包:

這裡就與top-down完全不同了!!

top-down還是,從i往前推,但是可以在i停留,達成無限的效果 bottom-up為了要無限,所以必須每個合法的空間都要加一次,同時還要包含前面的影響

dp一定是小的先算好,所以一定從前面取,而現在我們想取的值是已經改變過的值,所以要從最小的空間開始往上推

01背包是不想取到已經改變過的值,所以要從最大的空間往下推

這裡也彰顯了從頭與從尾的不同!!

能不能拿到已經改變過的值?

  • 從頭: 能
  • 從尾: 不能
dp = [0] * W
for p,w in items:
  for nowW in range(w, W+1):
    dp[nowW] = max(dp[nowW], p+dp[nowW-w])

有環的DP

  1. 做離散化,每個點做一次
  2. 把環的長度變成兩倍

mutual recursion

Leetcode 188

最多賣k次,買完才能賣,求最大收益

sell[i][k] = 第i天,剩k次,做賣的最大收益
buy[i][k] = 第i天,剩k次,做買的最大收益
def sell(i,k):
  if k <= 0 or i < 0:
    return 0
  else:
    return max(sell(i-1,k), price[i]+buy(i-1,k-1))

def buy(i,k):
  if k <= 0 or i < 0:
    return float('-inf')
  else:
    return max(buy(i-1,k), -price[i]+sell(i-1,k-1))

為什麼要兩個? buy有自己的收益,sell也有自己的收益 彼此又互相組成對方的收益

範例

來自演算法課本的dp範例

Algorithm Design(AD): 真的難,但有點東西 Introduction to Algorithm(CLRS): 厚到不能墊泡麵與當枕頭 Algorithms(DPV): 很薄,很精闢 The Algorithm Design Manual(TADM): 就是課本,但可以根據問題屬性去查對應的解法

edit distance

  • replace: (i-1,j-1)
  • delete: (i,j-1)
  • insert: (i-1,j)
    • insert多一個字,又馬上比對,所以與delete相反
def f(s1,s2):
  if not s1 and not s2:
    return 0
  elif s1[0] == s2[0]:
    return f(s1[1:], s2[1:])
  else:
    return 1+min(f(s1[1:], s2[1:]), f(s1, s2[1:]), f(s1[1:], s2))

Longest common subsequence & Longest increasing subsequence

Longest Increasing Subsequence

Maximum Subarray Sum (AD)

Maximum Subarray Sum

Traveling Salesman Problem

給定一系列城市和每對城市之間的距離,求訪問每一座城市一次並回到起始城市的最短迴路

cities是還可以去的點,1011,還有2, 1, 0可以去 i是目前的位置

def f(cities, i):
  if cities == 0:
    return 0
  else:
    return min([f(cities ^ (1 << i), j) + weight[i][j] for j in edges[i] if (1 << j) & cities) != 0], float('inf'))

Hamiltonian Path

有沒有一條每一點剛好經過一次的路線,起點和終點可以不相同

N是有幾個地點(len(graph.V()))

remains是還可以去的點,1011,還有2, 1, 0可以去 i是目前的位置

def f(remains, i):
  if remains == 0:
    return True
  else:
    return any(f(remains ^ (1 << i), j) for j in edges[i] if (1 << j) & remains) != 0)

Shortest paths (DPV)

path本身就是一段接一段的,也就是說它本身就是遞迴

可以回去,所謂的relaxtion,其實就是在原有的基礎上衍伸一段可以到的edge 這就是list!!

如果求最短,就是min,是具有結合律的函數,可以dp

所以可以列舉起、終點、中間點,一直更新最短長度

Rod cutting (CLRS)

給一條木頭,與長度對應到的價格,求最大收益

def f(size):
  if size == 0:
    return 0
  else:
    return max(f(size-sub)+price for sub,price in size2price.items() if size-sub >= 0)

Matrix-chain multiplication (CLRS, DPV)

有一排matrix要乘,求計算成本最小

成本: (a X b) * (b X c) = (a X c) 的 成本是 b

def f(i,j):
  if j-i <= 1: # 不會有一個matrix相乘
    return float('inf')
  else:
    return min(f(i,k)+f(k+1,j)+cost(k,k+1) for k in range(i+1, j-1))

Optimal binary search trees (CLRS)

給key與出現的頻率,組成一個搜尋成本最小的BST

成本: sum(freq[i]*depth)

一個是tree,所以要分左右 一個是範圍,所以有i,j

再來是計算成本,所以先把depth傳進去

def f(i,j,dep):
  if j-i == 0:
    return 0
  else:
    return min(f(i,k,dep+1)+f(k+1,j,dep+1)+freq[key[k]] * dep for k in range(i,j-1))

有沒有辦法把depth消滅掉? 看成橫的 先看tree

      i
   i-1 i+1
i-2        i+2

再看當時遞迴的範圍

              [i-2~i+2]
     [i-2~i-1]         [i+1~i+2]
[i-2]                           [i+2]

以i+2來說,由上往下看被加了3次,只看遞迴時的範圍,會發現剛好都i+2出現了3次 所以可以把depth去掉,變成每一次就把整個遞迴範圍的freq加總就好

def f(i,j):
  if j-i == 0:
    return 0
  else:
    return min(f(i,k)+f(k+1,j)+sum(freq[i:j]) for k in range(i,j-1))

Minimum Length Triangulation (TADM)

有一堆點,構成一個多邊形,把多邊形切成三角形,讓成本最小

成本: 所有三角形邊長的總和

def dist(p1, p2):
    return sqrt((p1[0] - p2[0])*(p1[0] - p2[0]) + (p1[1] - p2[1])*(p1[1] - p2[1]))
 
def cost(points, i, j, k):
    return dist(points[i], points[j]) + dist(points[j], points[k]) + dist(points[k], points[i])

def f(i,j):
  if j-i <= 2:
    return float('inf')
  elif j-i == 3:
    return cost(i,i+1,j)
  else:
    return min(f(i,k) + cost(i,k,j) + f(k,j) for k in range(i,j))

Independent sets in trees (DPV)

Independent set就是裡面的點沒有edge互連,求最多點的set

就是選了root就不能選children,但可以加上grand-children

f(root) = max(sum(f(children)), 1+sum(grand-children))

Maximum Rectangle Problem (AD)

給個matrix,求從中框出的長方形的內部數字總和

先做row的prefix sum 再根據前面的prefix sum,做每個column的prefix sum

之後就是用排容: f(i,j,n,m) = area(i,j) - area(i-n,j) - area(i,j-m) + area(i-n, j-m)

Segmented least squares (AD)

有一堆點,要用最少的線去fit這些點

定義回傳值: cost

定義cost: 每個點的e + 常數*幾根線

用到Optimal binary search trees把depth分散到每一層去做的技巧

def error(i,j):
  # Check 6. DYNAMIC PROGRAMMING I page 30
  pass

def f(i):
  if i == 0:
    return 0
  else:
    return min(error(k,i) + 常數 + f(k-1) for k in range(i))

Find the longest path in a matrix with given constraints

這裡要注意的是沒有去重的東西,所以開始dfs時要從終點開始走

def path(i,j):
    ret = float('-inf')
    if 0 <= i < n and 0 <= j < m:
        if (i,j) == (n-1,m-1):
            ret = 0
        else:
            dirs = [(i-1,j),(i,j-1),(i+1,j),(i,j+1)]
            ret = max(ret, *[path(x,y) for (x,y) in dirs])
    return ret

for i in reversed(range(n)):
    for j in reversed(range(m)):
        path(i,j)

Assembly Line Scheduling

dag最長路徑

# t :: time to switch another line
# a :: time of each station
# e :: cost on starting line
# x :: cost on goal
def f(i,j):
    if j == 0:
        return e[i] + a[i][j]
    ret = min(f(i,j-1), t[int(not i)][j-1]+f(int(not i), j-1))
    if j == len(t[0]): # at goal
        ret += x[i]
    return ret

Optimal Strategy for a Game

一開始先用一個me表示對面在做決策 有點像買股票的那一提

def f(i,j,me):
    if i>j:
        return 0
    elif i==j:
        return arr[i] if me else 0
    else:
        if me:
            # ????
            return max(arr[i]+f(i+1,j,not me), arr[j]+f(i,j-1,not me))
        else:
            # ????

Q: 看別人的分數幹嘛 A: 雖然說我們的結果與他們有關,但是他的分數與我們無關

下面的也是類似的

def x(i,j):
    if i>j:
        return 0
    elif i == j:
        return arr[i]
    else:
        return max(arr[i]-y(i+1,j), arr[j]-y(i,j-1))
def y(i,j):
    if i>j:
        return 0
    elif i == j:
        return arr[i]
    else:
        return max(arr[i]-x(i+1,j), arr[j]-x(i,j-1))

選下一輪能選的時候分數最少的case (關注要建構的目標)

def f(i,j):
    if i>j:
        return 0
    elif i == j:
        return arr[i]
    else:
        return max(arr[i]+min(f(i+2, j), f(i+1,j-1)), arr[j]+min(f(i,j-2), f(i+1,j-1)))

Partition problem

def f(i,cnt):
    if i == 0:
        return arr[i] == cnt
    else:
        return f(i-1,cnt-arr[i]) or f(i-1,cnt)
return f(len(arr)-1, sum(arr)//2) if sum(arr) % 2 == 0 else False

Partition a set into two subsets such that the difference of subset sums is minimum

這個會變成backtrack

def f(i, cnt):
    if i == 0:
        return abs((sum(arr) - cnt) - cnt)
    else:
        return min(f(i - 1, cnt+arr[i - 1]), f(i - 1, cnt))

原因是f想直接算差值,但是差值一定要等到最後,把所有路都走過 可以對其中一個維度做離散化

def f(i, cnt):
    if i == 0:
        return cnt == 0
    else:
        return f(i-1, cnt) or f(i-1,cnt-arr[i])

for n in reversed(range(sum(arr)//2)):
    if f(len(arr)-1, n):
        print(sum(arr)-2*n)

題外話: functional DP

函数式的动态规划

Ref

Greedy & DQ & DP 夜深人静写算法(二)- 动态规划入门 6. DYNAMIC PROGRAMMING I 背包 DP