# DP 定義
紀錄已經解決的問題的答案,避免重複計算,用舊有的資訊快速計算新資訊,並且分為兩種方式
1. Buttom-Up (由底層慢慢往上推論計算)
2. Top-Down (由上層慢慢往下推論計算)
先來看一個例子
## 費氏數列

所以可以用上面 Array 的方式儲存省略計算
```python=
# python
def fas(n) :
if n == 0 : return 0
if n == 1 : return 1
if (dp[n] == 0) : dp[n] = fas(n-1) + fas(n-2)
return dp[n]
```
```cpp=
// c++
int dp[10005] = {} ; // 注意初始化陣列才能讓所有元素變成0
int fas(int n) {
if (n == 0) return 0 ;
if (n == 1) return 1 ;
if (dp[n] == 0) dp[n] = fas(n-1) + fas(n-2) ;
return dp[n]
}
```
計算: 時間複雜度: $O(n)$
查詢: 時間複雜度: $O(1)$
用途: 在需要多次查詢費是數列的結果時可以使用這種方式
### 費氏數列延伸-走樓梯問題
第一題是很經典的爬樓梯問題,當你一次只能走一步,或是走兩步的情況下,有多少種方式可以爬到 n階 的階梯?
我們先一個一個看,找出規律:
* f(1) = 1 (一步)
* f(2) = 2 (一步+一步 or 兩步)
* f(3) = 3 (一步+一步+一步 or 一步+兩步 or 兩步+一步)
* f(4) = 5 (1+1+1+1 or 1+1+2 or 1+2+1 or 2+1+1 or 2+2)
## DP 的本質-有向無環圖的最短路
一張有向無環圖,有 n 個節點,無數的邊,問某點 N1 到某點 N2 的最短路徑
簡單來說,你可以去找 N1 到所有點的最短路,然後就可以推出到 N2 的最短路了
其實就是在到達某點的時候,可以判斷這個點能前往的點是否有更短的路徑
```cpp=
int short_to_x(int x) {
if (visited[x]) return shortest[x] ; // 已經走過的"目前"最短路
visited[x] = true ;
if (x == destination) shorted[x] = 0 ; // 終點
else {
shortest[x] = MAX_INT ; // 假設為最大距離
for (auto out: next[x]) { // 找所有出邊
int dis = short_to_x(out.node) + out.w ; // 試試看新路線
if (dis < shortest[x]) shortest[x] = dis ;
}
}
return shortest[x] ;
}
void DAG() {
for (int i=1;i<=n;i++) { // 把任意點當成 N1
short_to_x(i) ;
}
}
```
總而言之,動態規劃只要找好抽象化的部分
就可以得到一個有向無環圖(也可以說是一個拓樸排序)
## Top-Down v.s Bottom-Up
### Top_Down的優缺點
優點
* 轉移式很直覺(把算過的結果儲存就好)
缺點
1. pass by value && pass by reference(傳值、傳址差異)
2. stack overflow(python內建限制1000層)
3. 時間複雜度的常數很大(看不出來但會影響執行速度)
### Bottom-Up的優缺點
優點
* 寫起來很乾淨
缺點
* 轉移式比較難想到
## 組合題-骰子問題
* 骰⼦有 1 ~ 6 點,現在可以骰無限顆骰⼦,依序骰每⼀個骰⼦
* 求最後共有幾種骰法會使所有骰⼦的點數和為 n
舉例 :
○ n = 3 , 組合數 = 4
○ 1 + 1 + 1
○ 2 + 1
○ 1 + 2
○ 3
### 實際動態規劃步驟
第一步都是 **定義轉移式** ,轉移式有點類似 Function
這題我們定義轉移式 $dp[i] =$ 擲出骰子點數為 $i$ 的組合數量
因為骰子最多只能投到 6 點
所以要判斷 $dp[i]$ 的時候,要一次判斷前 6 個數字的組合數量
也就是 $dp[i] = dp[i-1] + dp[i-2] + ... + dp[i-6]$
如果還是不能理解的話,可以先用 7 當作例子
如果前面骰子總和是 1(也就是7-6),那麼這時候只要再投出 6 點就會剛好是 7
對於 $i - 6$ 的情況來說, $dp[i] += dp[i-6]$
如果前面骰子總和是 2(也就是7-5),那麼這時候只要再投出 5 點就會剛好是 7
其他點數以此類推,所以最後用一個迴圈就可以計算出結果
```python=
# python
mod = 10**9 + 7
n = int(input())
dp = [0] * (n+1)
dp[0] = 1
for i in range(1,n+1) :
for j in range(1,6+1) :
if (i-j >= 0) :
dp[i] += dp[i-j]
dp[i] %= mod
else : break
print(dp[n])
```
```cpp=
// c++
int mod = 1e9 + 7 ;
int main() {
int n ;
cin >> n ;
dp[0] = 1 ;
for (int i=1;i<=n;i++)
for (int k=1;k<=6;k++)
if (i-k >= 0) // 判斷i-1到i-6的組合數
dp[i] = (dp[i] + dp[i-k]) % mod ;
cout << dp[n] << '\n' ;
}
```
### 延伸問題-硬幣找零問題
硬幣找零問題可以有非常多版本,此版本的問題是給定 n 種硬幣
並且給定每個硬幣的面額 $C_i$,可重複選取同一種硬幣,
最後求找零總金額 x 的硬幣選法有幾種
比如要找零 $6$ 元,硬幣有 $2$ 種 ${1,3}$
找零的方式就有下列幾種
* (1,1,1,1,1,1)
* (1,1,1,3)
* (1,1,3,1)
* (1,3,1,1)
* (3,1,1,1)
* (3,3)
跟剛剛題目的思路很像,但是多了不固定的硬幣面額,所以我們可以用一個 Array 儲存面額
再來可以定義轉移式 $dp[i] =$ 總合為 $i$ 的硬幣選法有幾種
因為有未知的硬幣種類與面額,Array的長度(硬幣種類數量)跟內容(各硬幣面額)很重要
對於 $i-C_1$ 來說,只要再一個 $C_1$ 就可以湊到 $i$
對於 $i-C_2$ 來說,只要再一個 $C_2$ 就可以湊到 $i$
後面以此類推,所以跟剛剛題目的原理一樣,只是硬幣變成 $C_1$~$C_n$
```python=
# python
n, m = map(int, input().split())
dp = [0] * (m+1)
money = list(map(int, input().split()))
dp[0] = 1
for i in range(1,m+1) :
for k in range(n) :
if (i-money[k] >= 0) :
dp[i] = (dp[i] + dp[i-money[k]]) % mod
print(dp[m])
```
```cpp=
// c++
int n, m ;
cin >> n >> m ;
vector <int> my(n) ;
for (int i=0;i<n;i++) cin >> my[i] ;
dp[0] = 1 ;
for (int i=1;i<=m;i++)
for (int k=0;k<n;k++)
if (i-my[k] >= 0) dp[i] = (dp[i]+dp[i-my[k]]) % mod ;
cout << dp[m] ;
```
## 選擇行動題-青蛙跳石頭
有一隻青蛙在山腳下,山路總共有 $n$ 個石頭,每一個石頭的高度為 $h_i$
這隻青蛙每次只能跳 1 或 2 顆石頭,並且它如果原本在高度 $a$ 的石頭想要跳到高度 $b$ 的石頭上
需要花費 $\vert a - b\vert$ 的體力,求青蛙跳到第 $n$ 個石頭所需要的**最少體力花費**
解題思路 :
這題相較於前兩題沒有這麼直觀了,但是依然可以從題目得知
跳到第 $i$ 個石頭的可能只有兩種,就是從 $i-1$ or $i-2$ 個石頭跳過去
所以只要能算到 $i-1$ ans $i-2$ 兩個石頭的最少體力花費,就可以算出第 $i$ 個石頭
定義轉移式 $dp[i] =$ 跳到第 $i$ 個石頭最少體力花費
青蛙初始位置在 $i=1$,所以 $dp[1] = 0, dp[2] = \vert h_1 - h_2\vert$
因為剩下的石頭都有 $i-1$ or $i-2$ 兩個可能,所以要找出最小花費的方案
如果第 $i-2$ 個石頭高度是 $a$,第$i-1$ 個石頭高度是 $b$
所以要找 $min(dp[i-2]+\vert a-h_i\vert, dp[i-1]+\vert b-h_i\vert)$
```python=
# python
dp = [0] * (n+1)
h = list(map(int, input().split()))
dp[1] = 0
dp[2] = abs(a[2]-a[1])
for i in range(3,n+1) :
dp[i] = min(dp[i-1]+abs(h[i]-h[i-1]),
dp[i-2]+abs(h[i]-h[i-2]))
print(dp[n])
```
```cpp=
// c++
vector <int> h(n+1), dp (n+1, (int)1e9) ;
for (int i=1;i<=n;i++) cin >> a[i] ;
dp[1] = 0, dp[2] = abs(a[2]-a[1]) ;
for (int i=3;i<=n;i++) {
dp[i] = min(dp[i-1]+abs(h[i]-h[i-1]), \
dp[i-2]+abs(h[i]-h[i-2])) ;
}
cout << dp[n] << '\n' ;
```
## 選擇行動題-放暑假
小沐放暑假只會做三件事
1. coding(寫程式)
2. eating(吃水餃)
3. sleeping(睡覺)
已知 :
如果在第 $i$ 天做第 $1$ 件事情小沐會得到 $a_i$ 的快樂度
如果在第 $i$ 天做第 $2$ 件事情小沐會得到 $b_i$ 的快樂度
如果在第 $i$ 天做第 $3$ 件事情小沐會得到 $c_i$ 的快樂度
並且小沐不會連續兩天做同一件事情,如果暑假有 $n$ 天
小沐在各種規劃下,她的快樂度最大是多少
解題思路 :
首先我們先整理題目的資訊,因為每一天都有 $3$ 種選擇
所以在第 $n$ 天的數據就會有 $3$ 個,跟之前的骰子、硬幣不同
在第 $i$ 天做第 $k$ 件事情不會在快樂度上影響到第 $i+1$ 天
也就是說不能用一維 Array 去儲存快樂值,而是用二維陣列儲存
最後定義移轉式 : $dp[i][k] =$ 第 $i$ 天做第 $k$ 件事情能得到的最大快樂度
(這是第一次使用二維移轉式,可能會比較有問題,多注意)
由移轉式得知 : $dp[1][1] = a_1,\ dp[1][2] = b_1,\ dp[1][3] = c_1$
(這裡先試試看做題,之後再看看情況講解)
因為不能連續兩天做同一件事情,所以我們在判斷的時候要記得
$dp[i][k_1] = max(dp[i-1][k_2],\ dp[i-1][k_3]) + a_1$
其他兩個移轉式以此類推
```python=
# python
a = [[0]*4 for i in range(10005)]
n = int(input())
for i in range(1,n+1) :
for j in range(1,4) :
a[i][k] = int(input())
dp[1][1] = a[1][1]
dp[1][2] = a[1][2]
dp[1][3] = a[1][3]
for i in range(2,n+1) :
dp[i][1] = max(dp[i-1][2], dp[i-1][3]) + a[i][1]
dp[i][2] = max(dp[i-1][1], dp[i-1][3]) + a[i][2]
dp[i][3] = max(dp[i-1][1], dp[i-1][2]) + a[i][3]
print(max(dp[n][1],dp[n][2],dp[n][3]))
```
```cpp=
// c++
int a[10005][4] ;
int n ;
cin >> n ;
for (int i=1;i<=n;i++)
for (int k=1;k<=3;k++)
cin >> a[i][k] ; // 會從index1開始是因為好理解
dp[1][1] = a[1][1], dp[1][2] = a[1][2], dp[1][3] = a[1][3] ;
for (int i=2;i<=n;i++) {
dp[i][1] = max(dp[i-1][2], dp[i-1][3]) + a[i][1] ;
dp[i][2] = max(dp[i-1][1], dp[i-1][3]) + a[i][2] ;
dp[i][3] = max(dp[i-1][1], dp[i-1][2]) + a[i][3] ;
}
cout << max(dp[n][1],dp[n][2],dp[n][3]) << '\n' ;
```
時間複雜度為 : $O(n)$
## 路徑之和問題
在高中常見的排列組合數學題之一
就是問一個 $n\times m$ 的矩形左上到右下共有幾種可能的走法(只能往下或往右走)
我們學過的解法是第一行第一列都只有 $1$ 種可能,剩下都是左邊格子+上方格子
這個解法的最原始狀態應該是這樣表達的
* 因為要走到某個點只能往下或往右,所以只會從左方或上方的格子過來
* 所以目前格子的可能走法 = 上方格子可能走法 + 左方格子可能走法
* 而第一行(直、上下的)沒有左方的格子,所以等於上方格子可能走法
* 第一列(橫、左右的)沒有上方的格子,所以等於左方格子可能走法
這時候因為原點也只有一種可能走法,所以程式碼會像是這樣
```python=
n, m = map(int, input().split())
G = [[0]*m for i in range(n)]
G[0][0] = 1
for i in range(1, n) :
G[i][0] = G[i-1][0]
for j in range(1, m) :
G[0][j] = G[0][j-1]
for i in range(1, n) :
for j in range(1, m) :
G[i][j] = G[i-1][j] + G[i][j-1]
print(G[n-1][m-1])
```
```cpp=
int n, m ;
cin >> n >> m ;
vector<vector<int>> G(n, vector<int> (m, 0)) ;
G[0][0] = 1 ;
for (int i=1;i<n;i++)
G[i][0] = G[i-1][0] ;
for (int j=1;j<m;j++)
G[0][j] = G[0][j-1] ;
for (int i=1;i<n;i++)
for (int j=1;j<m;j++)
G[i][j] = G[i-1][j] + G[i][j-1] ;
cout << G[n-1][m-1] << '\n' ;
```
當然大部分題目不會這麼單純,我們可以先看一題求路徑和的
### d378. 最小路徑
[題目連結](https://zerojudge.tw/ShowProblem?problemid=d378)
解題思路 :
同樣也是從左上到右下,也是只能往右與往下,但求路徑總和最小,一樣先列出算式
* 終點格子最小路徑 = max(終點上方格子最小路徑, 終點左方格子最小路徑) + 終點格子值
* 那其實對於每一個格子也是與終點同樣的處理方式
* 唯第一行、列要做跟之前同樣的處理
```cpp=
#include<bits/stdc++.h>
using namespace std ;
int G[105][105] ;
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0) ;
int n, m, idx = 1 ;
while (cin >> n >> m) {
for (int i=0;i<n;i++) {
for (int j=0;j<m;j++) {
cin >> G[i][j] ;
if (i && j) // 非第一行或第一列
G[i][j] += min(G[i-1][j], G[i][j-1]) ;
else if (i) // j = 0 第一行(直、上下的)
G[i][0] += G[i-1][0] ;
else if (j) // i = 0 第一列(橫、左右的)
G[0][j] += G[0][j-1] ;
}
}
cout << "Case #" << idx << " :\n" ; // 第 idx 個測資
cout << G[n-1][m-1] << '\n' ; // 最右下
idx++ ;
}
return 0 ;
}
```
當然這種題目肯定也多大多數人來說不是很難,下一題才是新手感覺最難的題型
### 2182 . 幸運表格 (Lucky)
[題目連結](https://tioj.ck.tp.edu.tw/problems/2182)
解題思路 :
這題可以從任一點開始,只要超出表格範圍就結束
同樣只能向下或向右,問路徑總和最大為多少
如果照我們最開始的想法,從任一點開始做
這樣明顯很慢,時間複雜度為 $O(n^2 m^2)$
但是我們可以轉換一下想法,如果超出範圍是結束
是不是代表終點只可能是最右邊那一行 $A$ 跟最下方的那一列 $B$
這時候終點已經固定了,並且肯定比起點少
所以我們把路徑的順序轉換一下
現在起點為 $A$ 或 $B$,請問走到任一點的路徑總和最大可能為多少
確保路徑過程只能往上或往左(也就是只能往下與往右的顛倒)
這時候你再嘗試把運算式列出來
* 對於某一點 $X$ 來說,走到 $X$ 的路徑指可能是從右方或下方走過來
* 所以 $X$ 的最大路徑總和 = max($X$ 下方路徑總和, $X$ 右方路徑總和) + $X$ 的值
* 但對於最右方那一行來說,$X$ 只能從下往上走到,最大路徑總和 = $X$ 下方路徑總和 + $X$ 的值
* 對於最下方那一列來說,$X$ 只能從右往左走到,最大路徑總和 = $X$ 右方路徑總和 + $X$ 的值
```cpp=
#include<bits/stdc++.h>
using namespace std ;
int G[1002][1002], dp[1002][1002] ; // 多的空間會是 0
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0) ;
int m, n ;
cin >> m >> n ;
for (int i=0;i<m;i++)
for (int j=0;j<n;j++)
cin >> G[i][j] ;
// 由於全域變數的矩陣會讓元素都為 0
// 所以不需要把最下方(最右方)跟其他位置做區分
// 但這個習慣只適合用在競程,所以請自行判斷是否要使用
int ans = INT_MIN ;
for (int i=m-1;i>=0;i--) {
for (int j=n-1;j>=0;j--) {
dp[i][j] = max(dp[i+1][j], dp[i][j+1]) + G[i][j] ;
ans = max(ans, dp[i][j]) ;
}
}
cout << ans << '\n' ;
return 0 ;
}
```
## 0/1 背包問題
由前面的題目應該能比較清楚得知關於動態規劃的用法
所以我們進入 DP 最具代表性的問題之一 : 背包問題
* 屬於 NP-Hard(還沒有找到多項式時間內的解法,對新手來說不重要)
* 題目給定 $n$ 種物品,容量 $m$ 的背包
* 第 $i$ 的物品會佔據背包 $w_i$ 的容量,它的價值是 $v_i$
* 要求讓背包所有物品價值越高越好
定義移轉式 : $dp[i][k] =$ 表示考慮選擇要不要拿第 $i$ 個物品的情況下,背包容量 $k$ 能獲得的最大價值
對於 dp[i][k] 來說有拿與不拿兩種
如果拿了第 $i$ 個物品,並且目前背包只有 $k$ 的容量
則可以反推在選擇要不要拿第 $i-1$ 個物品時,最多能用的容量只有 $k-w_i$ (從後面往前推,比較難想)
* 因為拿了第 $i$ 個物品會佔據 $w_i$ 的容量
* 如果在第 $i-1$ 個物品時,用掉超過 $k-w_i$ 的容量,則不能拿取第 $i$ 個物品
最後得出要拿第 $i$ 個物品的移轉式為 :
$dp[i][k] = dp[i-1][k-w_i] + v_i$ (第 $i$ 個物品的價值)
當然還有不拿這個選項,如果不拿
反推在選擇要不要拿第 $i-1$ 個物品時,最多能用的容量就有 $k$
$dp[i][k] = dp[i-1][k]$ (第 $i$ 個物品的價值)
所以把拿跟不拿兩種可能的轉移式結合就是
$dp[i][k] = max(dp[i-1][k],\ dp[i-1][k-w_i] + v_i$ (第 $i$ 個物品的價值)
最後的答案就是 $dp[n][m]$,也就是說對於這個二維陣列
我們必須求出整個二維陣列對應的數值才能算出答案
而且要從 $i = 0$ 開始推,慢慢往上推出結果(Bottom-Up)
時間複雜度就是走過整個二維陣列 : $O(nm)$
```python=
for i in range(1,n+1) :
for k in range(1,m+1) :
if (k-w[i] >= 0) :
dp[i][k] = max(dp[i-1][k-w[i]] + v[i], dp[i-1][k])
else :
dp[i][k] = dp[i-1][k]
print(dp[n][m])
```
```cpp=
// c++
// 注意要 dp[0][1~m] = 0
for (int i=1;i<=n;i++) {
for (int k=0;k<=m;k++) {
// 選擇要不要拿第 i 個物品
if (k-w[i] >= 0) dp[i] = max(dp[i-1][k-w[i]] + v[i], dp[i-1][k]) ;
else dp[i][k] = dp[i-1][k] ;
}
}
cout << dp[n][m] << '\n' ;
```
### 延伸-同物品無限拿取的背包問題
跟0/1背包問題的敘述一樣,但是同物品可重複放入背包,也就是有無限多個物品,但一樣 $n$ 種
因為物品可以重複拿,所以之前定義的轉移式 $dp[i] =>$ 選擇要不要拿第 $i$ 個物品 失去作用
重新定義轉移式為 : $dp[i] =$ 背包容量為 $i$ 時,能得到的最大價值
因為在任何容量,只要體積(重量)沒爆,就能拿任一種物品
也就是說,在容量為 $i$ 的情況下有 m 種可能
* 前一個選擇結束耗費容量最多 $i-w_1$ 的情況下拿第 $i$ 個物品
* 前一個選擇結束耗費容量最多 $i-w_2$ 的情況下拿第 $i$ 個物品
後面以此類推,概念跟硬幣還有骰子很像(也是用for迴圈去跑過每種物品)
```cpp=
for (int i=1;i<=m;i++) {
for (int k=1;k<=n;k++) { // for迴圈去跑過每種物品
if (k-w[i] >= 0) dp[i] = max(dp[i], dp[i-w[k]] + v[k]) ;
}
}
cout << dp[m] << '\n' ; // 輸出容量 m 的最大價值
```
時間複雜度 : $O(nm)$
### 背包問題的瓶頸和優化-滾動陣列
時間複雜度根據NP-Hard問題所以很難改變
但空間複雜度似乎有很大的改進空間(除了無限背包 $O(m)$)
用 $n*m$ 的表格去紀錄(儲存)資料會有點太大了,空間複雜度是 $O(nm)$
這是因為DP是用 空間換時間的操作,如果 $n*m$ 太大,我們還是無法完成
這裡要介紹一個空間複雜度 $O(m)$ 的解法-**滾動陣列**
滾動陣列的核心概念就是 **沒用的空間重複使用**
從一般(有限)背包問題的轉移式 $dp[i][k] = max(dp[i-1][k], dp[i-1][k-w_i] + v_i)$
可以發現如果 $i = 7$ 的情況下,**只會**用到 $dp[i]$ 跟 $dp[i-1]$ 兩個陣列而已
也就是說 $dp[1\sim5]$ 完全不會用到,因為每一次計算只要用到上一次計算的結果
這時候就是滾動陣列出場的時候了
滾動陣列的實作要注意三點
1. 對於背包問題只會用到現在跟上一次的結果
2. 只要開兩個長度 $m$ 的陣列儲存資料
3. 這樣空間複雜度就從 $O(nm) -> O(m)$
這次一樣為了比較好理解把 Array 開成 $dp[2+1][m+1]$
用習慣或是理解後可以改成 dp[2][m] 就可以
至於使用兩個陣列的辦法就是判斷 $i$ 的奇偶
也就是 $dp[i\%2][k] = max(dp[(i-1)\%2][k],dp[(i-1)\%2][k-w_i] + v_i)$
那麼如果更進一步減少空間使用一維陣列呢 ?
因為結果可以一直使用不用特意清空,並且每次都是拿前一次的結果
這樣的概念可能會變成這種片段
```cpp=
// c++
for (int i=1;i<=n;i++)
for (int k=1;k<=m;k++)
if (k-w[i] >= 0) dp[k] = max(dp[k], dp[k-w[i]] + v[i]) ;
cout << dp[m] << '\n' ;
```
如果你帶入數字或是實際代測資跑過一遍就會發現有錯,我們用數字跑一次
假設我們在 $dp[5]$ 的時候拿了第 $i$ 個物品,這時候物品的體積是 $5$
結果到了 $dp[10]$ 的時候 : $dp[10] = dp[10-5] + v_i$
也就是說我們重複拿了第 $i$ 個物品,這樣就變成無限背包的解法了
問題就是因為 $k$ 較小的資訊更新會影響到 $k$ 較大的資訊
並且我們在更新資訊時**只要上一次的資訊**,那麼我們可以讓 $k$ 從 $m$ 跑回來
先處理 $k$ 較大的資訊就能避免被影響到,這樣就不會重複拿了
```cpp=
// c++
for (int i=1;i<=n;i++)
for (int k=m;k>=w[i];k--)
dp[k] = max(dp[k], dp[k-w[i]] + v[i]) ;
cout << dp[m] << '\n' ;
```
最後下一個總結,背包問題的變形其實有很多(下面會舉例)
並且比賽或題目遇到背包問題都會包裝得比較精美
### 變形-平分問題
* 現在已經很常見了,因為時代變遷(~~出題者沒創意~~)的關係
[d390. 00562 - Dividing coins](https://zerojudge.tw/ShowProblem?problemid=d390)
有 $m$ 個,每個硬幣的數值為 $a_i$,現在要把 $m$ 個硬幣分給兩個人
你的目標是要讓這兩個人拿到的數值總和差越小越好(就是兩個人要差不多錢)
先經過思考在看下面的詳解
解題思路 :
首先我們考慮到最完美的情況下,兩個人的總額應該都是 $sum(a) / 2$
這時候就可以題目理解成背包問題,給 $n$ 個物品,最大容量是 $sum(a) / 2$
每個硬幣的價值是 $a_i$,體積也是 $a_i$,求最多能裝入多少價值的硬幣
最後答案就是 $sum(a)\ -\ dp[sum(a) / 2]\ \times\ 2$
(注意不能使用 $sum(a) / 2\ -\ dp[sum(a) / 2]$ 因為會忽略奇數)
更詳細的說就是在 $dp[k]$ 能放多少錢,假如 $k = 6$ 但硬幣組合可能只有到 $5$
這時候 $dp[6] == 5$ 所以就是在 $k$ 為上限的情況下,能湊出的最大硬幣總額
### 延伸-有限背包問題
* 每種物品會有不同的數量 $num_i$
[參考講解](https://hackmd.io/@nehs-iced-7th/Hk9_m5KH_/https%3A%2F%2Fhackmd.io%2F%40Paul-Liao%2FSJHC9SE0u)
其他條件相同,問怎麼能獲得最大價值
解題思路 :
其實這跟 $0/1$ 背包問題一樣,只是可挑選物品數量變成 $\ge\ 1$
最直覺的想法就是多加一個迴圈跑過物品總數量
把多個同種物品當作 $num_i$ 個不同種物品,但是重量跟價值一樣
```cpp=
// c++
for (int i=1;i<=n;i++)
for (int j=0;j<num[i];j++) // 當作不同物品做處理
for (int k=m;k>=w[i];k--)
dp[k] = max(dp[k], dp[k-w[i]] + v[i]) ;
cout << dp[m] << '\n' ;
// 注意已經使用滾動陣列進行優化
```
這時候的複雜度會變成很抽象的樣子 $O(nm \times max(num))$
不過有更加抽象的方式可以進行優化(聽不懂就別說,需要較高數學能力)
---
二進制湊數字-使用迴圈跑過所有數字很直覺,但是用二進制跑出數字也可以
假設我要湊出 $6$ 以內的所有數字,使用 $(1,\ 2,\ 3)$ 就可以湊出來
* 1 = 1
* 2 = 2
* 3 = 3
* 4 = 1 + 3
* 5 = 2 + 3
* 6 = 1 + 2 + 3
也就是說,如果我們要解決總數量 $x$ 的物品,可以對 $x$ 做拆分
將 $x$ 拆分為 $(2^0,\ 2^1,\ 2^2,\ ...\ ,\ 2^p, q )$,$p$ 是使 $2^{p+1} - 1 \le x$ 的最大整數
而 $q = x - (2^{p+1} - 1)$,看起來有點抽象,我舉個例子幫助理解
假設 $x = 9$,因為 $2^3 = 8,\ 2^4 = 16$,所以此時的 $p$ 會是 $2$ (可以帶入上面的式子看看)
如果少了 $q$ 原本的 $(2^0,\ 2^1,\ 2^2)$ 最多總和是 $(1+2+4) => 7 => 2^3-1$
此時多了 $q$ 會是 $9-(2^{2+1}-1)\ ==\ 9-8+1\ ==\ 2$
所以 $q$ 的功用就是把數字湊起來的最後關鍵
* $(1+2+4)+2$ => $(2^0+2^1+2^2)+2$ => $9$ => $q = 2$
* $(1+2+4)+1$ => $(2^0+2^1+2^2)+1$ => $8$ => $q = 2$
這樣就可以不用用到 $2^3(8)$,還可以湊出 $8\sim15$ 的數字
所以原本要跑 $num_i$ 的程式,就可以變成最多$log_2(num_i) + 1$
此時的複雜度就會變成 $O(nm \times log(num))$
```cpp=
// c++
for (int i=1;i<=n;i++) {
int v, w, num, j ;
cin >> v >> w >> num ;
for(j=0;((1<<(j+1))-1)<=num;j++) { // 此時的 j 就是 p
int new_w = w<<j, new_v = v<<j ; // 根據數量不同的物品定義重量價值
for(int c=m;c>=new_w;c--) {
dp[c]=max(dp[c],dp[c-new_w]+new_v) ;
}
}
//
w = w*p-(w<<j)+w ; // 定義重量為剩餘數量 × 原始重量
v = v*p-(v<<j)+v ; // 價值也是
if(w) // 檢查是否還有剩餘的物品可以放入背包
for(int c=m;c>=w;c--)
dp[c]=max(dp[c],dp[c-w]+v) ;
}
cout << dp[m] << '\n' ;
// 已經使用二進位+滾動陣列進行優化
```
---
單調隊列解法(後面補上)
### 與有限背包概念相似的題目
給定 n 個硬幣,每個硬幣都有特定的⾯額(面額可重複但是不同硬幣)
請求出最⼩的⾯額,這⼀個⾯額是**不能**透過這 n 個硬幣湊出來的
範例輸入 :
5
2 9 1 2 7
範例輸出 :
6
解題思路 :
題目要求最小跟無法被給定硬幣湊出來兩個條件
從上述的範例輸出輸入來看,前 5 都能被湊出來
* 1 = 1, 2 = 2, 3 = 1 + 2, 4 = 2 + 2, 5 = 1 + 2 + 2
就最直觀的想法來說,湊硬幣除了要考慮到不同的組合,還要從最小的面額開始湊
那麼我們就可以先對硬幣面額大小做出排序,然後再繼續找規律
假設我們拿掉所有的 $2$,那麼最小值就會是 $2$,此時的硬幣是 $(1,7,9)$
假設保留一個 $2$,那麼最小值就是 $4$,此時的硬幣是 $(1,2,7,9)$
假設完全按這題目給定的範例,最小值是 $6$,此時的硬幣是 $(1,2,2,7,9)$
這時候會發現,第一種可能的答案是 $1$(第一個硬幣)$+1$,第二種可能是 $1+2$(前兩個硬幣和)$+1$
第三種可能的答案是 $1+2+2(前三個硬幣和)+1$,三個答案的共同點有兩個
1. 都要 $+1$
2. 在當前最大面額總和可能 $+1$ < 下一個硬幣面額大小 的時候,找到答案
也就是說,只要將硬幣排序完,再一一去判斷第二點,就能知道答案
```cpp=
int coin[5005] = {} ;
int main() {
int n ;
cin >> n ;
for (int i=0;i<n;i++) cin >> coin[i] ;
sort(coin,coin+n) ; // 排序硬幣面額
int total = 1 ; // 目前最大總和+1
for (int i=0;i<n;i++) {
if (total < coin[i]) break ; // 找到比總和+1更大的面額
total += coin[i] ;
}
cout << total << '\n' ;
}
```
## LIS-最長遞增子序列
所謂最長嚴格子序列就是在一個數值序列當中,找到最長的遞增(嚴格)數列
比如數列 : $A[4] = (1,3,2,4)$ 當中的 LIS 就是 $(1,3,4)$ 或 $(1,2,4)$
所以 LIS 不一定只有一個,通常題目會有限制某一個答案
先介紹時間複雜度 : $O(n^2)$ 的解法
定義移轉式 : $dp[i] =$ 表示結尾為 $A[i]$ 的 LIS 長度
LIS 需要從左到右去找出遞增序列,如果從移轉式的定義來看
$dp[1] = A[1]$ 因為子序列只有一個元素
那麼如果在求 $dp[4]$ 呢 我們要考慮到中間會有略過數字的可能
因為只是要求遞增的數列而已,所以可以看看前面數值跟 $dp[i]$ 的關係
如果 $dp[1] < dp[4]$ 那麼就可以將固定結尾為 $A[1]$ 的 LIS 後面接上 $A[4]$
如果 $dp[2] < dp[4]$ 那麼就可以將固定結尾為 $A[2]$ 的 LIS 後面接上 $A[4]$
後面以此類推,也就是說只要用迴圈跑過 $1$ ~ $i$ 就可以了
```cpp=
// c++
int A[5] = {0,1,3,2,4} ; // 方便理解多放一個空格
vector <int> dp(n+1,0) ; // 方便理解多放一個空格
dp[1] = 1 ;
int ans = 0 ;
for (int i=1;i<=n;i++) {
for (int k=1;k<=i;k++)
if (A[i] > A[k])
dp[i] = max(dp[i], dp[k] + 1) ;
ans = max(ans, dp[i]) ;
}
// 注意答案不是 dp[4],dp[4] 是以 A[4] 當結尾的 LIS 長度
cout << ans << '\n' ;
```
如果我們害怕 TLE,有什麼辦法可以去縮小時間複雜度呢
(Robinson-Schensted-Knuth Algorithm)
(應該可以證明,但有點麻煩)
我們可以用兩個演算法搭配 DP 來輔助我們
1. 二分搜 Binary Search
2. 貪心演算法 Greedy Algorithm
我們額外開一個陣列 $B$,定義 $B[i]$ 為在 $i+1$ 長度的所有 LIS 中最小的結尾數值
我舉個例子來說明,假設現在長度為 $3$ 的 LIS 有 $(1,2,4)$, $(1,3,5)$, $(1,2,5)$, $(1,3,4)$
那麼這時候就會 $B[2] = 4$
如果這個數字能夠越小,表示整個 LIS 延續下去的可能越大(貪心)
跑過 $a[0]$ ~ $a[n-1]$,每次 $a[i]$ 可以被插入到 $B$ 中的哪個位置(二分搜)
假如 $B = (10,25,30)$,這時候 $a[i] = 12$,$B = (10,12,30)$
因為在長度為 $2$ 時,LIS 的最小結尾數值要選 $12$
也就是比較 $(10,25)$ 和 $(10,12)$
只要讓選擇的數值越小,後面在做 LIS 越輕鬆
假如 $B = (10,12,30)$,這時候 $a[i] = 45$,$B = (10,12,30,45)$
也就是有一個當前最大的數字可以放入
這時候要找出最長的 LIS,就要從 $B$ 的定義推論
$B[i]$ 是在 $i+1$ 長度的所有 LIS 中最小的結尾數值
也就是說,如果存在 $B[i]$,那麼就一定存在 $i+1$ 長度的 LIS
最後答案就是 $B$ 的長度
```cpp=
// c++
int solve(vector <int> a) {
vector <int> dp ; // 這裡的 dp 是 B
dp.push_back(-1e9) ;
for (int i=0;i<a.size();i++) {
auto it = lower_bound(dp.begin(),dp.end(),a[i]) ;
if (it == dp.end()) dp.push_back(a[i]) ;
else dp[it-dp.begin()] = a[i] ;
}
return dp.size() - 1 ; // 答案-1 是因為前面有插入一個最小值(才能進行第一次二分搜)
}
```
### d242. 00481 - What Goes Up
[題目連結](https://zerojudge.tw/ShowProblem?problemid=d242)
寫一個程式從一連串的整數序列中選出最長的嚴格遞增子序列(strictly increasing subsequence)
例如:$(1,3,2,2,4,0)$ 中最長的嚴格遞增子序列為 $(1,3,4)$ or $(1,2,4)$
解題思路 :
這題除了要求 LIS 的長度,還要求輸出其元素,所以只用上述的方法會無法達到輸出元素的要求
我們可以透過定義一個 Array $len$ : 元素 $a[i]$ 在 LIS 當中最後(數值最大)可能位置
舉個例子 $A = (1,3,2)$ 那麼 $B$ 就會從 $(1)$ -> $(1,3)$ -> $(1,2)$
而 $len = (1,2,2)$,這個意思就是說,$A$ 當中的 $1$ 在 LIS 中只能放到第 $1$ 位
如果 $A = (1,2,3)$ 那麼 $B$ 就會從 $(1)$ -> $(1,2)$ -> $(1,2,3)$
但是 $len = (1,2,3)$,因為此時 LIS 是 $(1,2,3)$,所以 A 當中的 $3$ 可以放到第 $3$ 位
由於題目還要求輸出最後方的 LIS,所以要從後方找出 LIS 元素
```cpp=
// c++
#include<bits/stdc++.h>
using namespace std ;
#define MAX 500005
int dp[MAX], len[MAX], a[MAX] ; // 用陣列比較快
int main() {
ios::sync_with_stdio(0) ; cin.tie(0) ; // 加速輸入
int i = 0, dplen = 0 ;
while (cin >> a[i]) {
// dplen 是最大 LIS 長度, i 是 a 長度
int l = 1, r = dplen, index = r+1 ;
while (l <= r) { // 二分搜
int mid = (l + r) / 2 ; // 中點
if (dp[mid] >= a[i]) r = mid - 1, index = mid ;
else l = mid + 1 ;
}
len[i] = index ; // 表示 a[i] 最多能放到 LIS 的甚麼位置
dp[index] = a[i++] ; // B 陣列
if (index > dplen) dplen = index ; // 比之前 LIS 更長
}
cout << dplen << "\n-\n" ;
int stklen = 0, stk[MAX] ;
// 因為要求相同長度 LIS 挑較靠右的(比較晚出現的)
// 所以要從後面找出在 LIS 同位置最靠右的元素
for (i--;i>=0 && dplen;i--)
if (len[i] == dplen)
stk[stklen++] = a[i], dplen-- ;
while (stklen)
cout << stk[--stklen] << '\n' ;
return 0 ;
}
```
### f608. 4. 飛黃騰達
[題目連結](https://zerojudge.tw/ShowProblem?problemid=f608)
飛黃是一種生物,活在二維座標平面上
有隻特別的飛黃一開始在座標 (0, 0) 的位置,而且你知道它只會往右上方移動
也就是移動的時只可以走到 $x$ 座標跟 $y$ 座標都不比原本小的位置
(所以 $nx \ge x\ \ \&\&\ \ ny \ge y$,不過程式中的位置應該是往右下)
現在座標平面的第一象限上有 n 個位置有果實,給定這 m 個果實的座標 (a, b)
你想要知道這隻特別的飛黃最多可以吃到幾個果實
(它必須移動到果實所在的座標才可以吃到果實)
解題思路 :
可能滿多人會有疑惑,以為要考慮到兩個參數的 LIS,其實沒有這麼誇張
我們回想一下 LIS 的定義,就是絕對嚴格遞增序列,但這題只需要要求 $\ge$
所以這裡要做出兩個想法上的改變
1. 因為可以 $\ge$,所以 lower_bound 改成 upper_bound
2. 因為可以 $\ge$,所以把某一個參數可以用 "排序"(省略 LIS)
根據這兩個想法,只要先用 $x$ 做排序,再對 $y$ 用 LIS(非嚴格的)
```cpp=
#include<bits/stdc++.h>
using namespace std ;
typedef pair<int, int> Pii ;
#define FF first
#define SS second
Pii p[10000001] ;
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0) ;
int n ;
cin >> n ;
for (int i=0;i<n;i++)
cin >> p[i].FF >> p[i].SS ;
sort(p, p+n) ; // 這樣 x 就可以照順序排
vector<int> LIS ; // 只存放 y 因為 x 已經排好了
for (int i=0;i<n;i++) {
auto idx = upper_bound(LIS.begin(), LIS.end(), p[i].SS) ;
if (idx == LIS.end()) LIS.push_back(p[i].second) ;
else *idx = p[i].SS ;
}
cout << LIS.size() << '\n' ;
return 0 ;
}
```
## LCS-最長公共子序列
所謂最長公共子序列,就是在兩個序列中各自選出一個子序列,保證這兩個子序列相同
此時的子序列長度就是 LCS 的長度,注意子序列的定義是
在序列中可以刪除某些元素但不能"調換元素順序"的產物
比如在序列 "ABCD" 中可以找到子序列 "ACD" 但沒有 "ADC"
(以下 n 代表序列一的長度,m 代表序列二的長度)
因為可以刪除與不能調換的原因,一個序列的子序列可能有 $2^n$ 個
也就是說,暴力解會達到 $O(2^{nm})$ 的時間複雜度
但如果這題用 DP 去解,時間複雜度只會是 $O(nm)$
通常我們在處理這個問題時會這樣定義
`LCS[i][j]` 位置為序列 A 的前 $i$ 個元素與序列 B 的前 $j$ 個元素的 LCS 長度
注意這裡不需要以 `A[i]` 跟 `B[j]` 作為結尾,而是表示一種終點的感覺
而在這樣的情況下,LCS 大致上有三種可能
1. 前 $i-1$ 個元素與前 $j$ 個元素
2. 前 $i$ 個元素與前 $j-1$ 個元素
4. 前 $i-1$ 個元素且前 $j-1$ 個元素 + 1
第三種情況必須在 `A[i] == B[j]` 才能做為可能
因為這裡等於是新增元素到舊的 LCS 了,必須要符合"兩者相同"的特質
```cpp=
void LCS() {
for (int i=1;i<=n;i++) {
for (int j=1;j<=m;j++) {
LCS[i][j] = max(LCS[i-1][j], LCS[i][j-1]) ;
if (A[i] == B[j]) max(LCS[i][j], LCS[i-1][j-1]+1) ;
}
}
}
```
如果細心一點就會發現,判斷 LCS 時只需要 $i-1$ 或是 $j-1$ 的位置
那這時候是不是就能使用滾動陣列呢 如果用滾動陣列就可以減少大量空間
```cpp=
void LCS() {
int f = 0, t = 1 ;
for (int i=0;i<m;i++) {
for (int j=1;j<=n;j++) {
LCS[t][j] = (s2[i] == s1[j-1]) ? LCS[f][j-1]+1 : \
max(LCS[t][j-1], LCS[f][j]) ;
}
swap(f, t) ;
}
cout << LCS[f][n] ;
}
```
### a133. 10066 - The Twin Towers
[題目連結](https://zerojudge.tw/ShowProblem?problemid=a133)
給定 $2n$ 個字串,請兩兩一組找出他們的 LCS
```cpp=
```
## 最短修改距離
給定兩個字串 $s1$, $s2$,問 $s1$ 最少要經過幾次操作才能變成 $s2$
操作可以是 "修改 $s1$ 的字元","去除 $s1$ 的字元","增加字元到 $s1$"
可以先根據比較小的情況做判斷,並定義轉移式
* 如果 $s1$ = ""(空字串), $s1$ 變 $s2 \rightarrow s2$ 的長度
* 對於 $s2[:j]$ 來說, $s1$ 變 $s2[:j] \rightarrow j$
* 反之如果 $s2$ = "", $s1$ 變 $s2\rightarrow s1$ 的長度
* 對於 $s1[:j]$ 來說, $s1[:j]$ 變 $s2 \rightarrow j$
* 定義轉移式 $dp[i][j] =$ $s1[:i]$ 變 $s2[:j]$
這時候就要注意了,假設 $s1[i] = s2[j]$,因為不需要更動所以會是 $dp[i-1][j-1]$
但是如果 $s1[i] \not = s2[j]$,那就會是 $min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1$
### f507. 1207 - AGTC
[題目連結](https://zerojudge.tw/ShowProblem?problemid=f507)
同樣的概念,就不再次講解了
```cpp=
#include<bits/stdc++.h>
using namespace std ;
int dp[1040][1040] ;
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0) ;
int n, m ;
string s1, s2 ;
while (cin >> n >> s1 >> m >> s2) {
for (int i=0;i<=n;i++) {
for (int j=0;j<=m;j++) {
if (i == 0) dp[0][j] = j ;
else if (j == 0) dp[i][0] = i ;
else if (s1[i-1] == s2[j-1])
dp[i][j] = dp[i-1][j-1] ;
else
dp[i][j] = min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]}) + 1 ;
}
}
cout << dp[n][m] << '\n' ;
}
return 0 ;
}
```
## 找一維區間內元素相加最大
對一串數字來說,如果我想在這串數字中找到一個區間 $T$,使這個區間 $T$ 的總和是所有 $T$ 中最大的
所以先簡單做一個移轉式 `dp[i] = 區間 T 最尾端最多到第 i 個元素的最大總和`
這個移轉式沒辦法很輕鬆的用 $O(n)$ 處理,因為你要去枚舉區間 $[\ k,j\ ]$ 的總和,$k
\le j\le i$
但是這裡可以發現兩件事情,所以能把移轉式修改成能用 $O(n)$ 的方法處理
1. 區間 $[\ k,j\ ]$ 的總和為 $[\ k,j-1\ ] + A[\ j\ ]$
2. `dp[i] = max(枚舉所有以 j 結尾的區間中的最大總和)` ,$j\le i$
這裡在對第二點做更詳細的講解,假設數字為 $\{-2,-1,1\}$
區間總共會有 $\{-2\}$、$\{-1\}$、$\{1\}$、$\{-2,-1\}$、$\{-1,1\}$、$\{-2,-1,1\}$ 六個
那假設要找 `dp[1]`,也就是最多只能跑到 $-1$,我能用的區間就是 $\{-2\}$、$\{-1\}$、$\{-2,-1\}$
所以 `dp[1] = max(-2,-1,(-2)+(-1))`,分別為這三個區間的總和,如果再進一步推導
要找 `dp[2]`,能用的區間除了之前的三個還有 $\{1\}$、$\{-1,1\}$、$\{-2,-1,1\}$
很明顯 $\{-1,1\}$、$\{-2,-1,1\}$ 是由 `dp[1]` 能用的區間加入 `A[2]`
$\{-2\}$ 不能加入 `A[2]`,因為區間必須連續,所以這裡要做一個轉換
如果 `dp2[i] = 以第 i 個元素作結尾的區間最大總和`,這樣就可以讓 `dp` 跟 `dp2` 有關聯
也就是 `dp[i] = max(dp2[0], dp2[1], ..., dp2[i])`
這時候你可能會發現一件事情,只需要求出所有的 dp2 就行,然後開始找 dp2 的規律
* 如果 `dp2[i-1] <= 0` 代表前面的區間和對找最大總和"無"幫助,所以 `dp2[i] = A[i]`
* 如果 `dp2[i-1] > 0` 代表前面的區間和對找最大總和"有"幫助,所以 `dp2[i] = dp2[i-1] + A[i]`
這時候要求一串數字的區間最大總和就可以用下列的程式碼計算
```cpp=
vector<int> A(n), dp(n, 0) ; // 長度 n
dp[0] = A[0] ;
int ans = A[0] ;
for (int i=1;i<n;i++) {
dp[i] = max(dp[i-1], 0) + A[i] ;
ans = max(ans, dp[i]) ;
}
cout << ans << '\n' ;
```
如果你搞不清楚我前面的推導的話,可以選擇將結論背起來,或是把一開始定義的移轉式拿來用
### 2191 . D. 質感測試
[題目連結](https://tioj.ck.tp.edu.tw/problems/2191)
給一個圓盤上面有多個點(預設關閉),現在有一根棒子以圓心旋轉,長度剛好為直徑
當棒子通過某個點會將其點開啟或關閉,(如原先開啟則關閉,反之原先關閉就開啟)
每個點都會有質感係數 $x$,當點開啟時質感係數為 $x$,當關閉時質感係數為 $0$
你可以自行決定棒子一開始要放在哪裡,以及棒子旋轉角度(可以轉不到一圈)
整體質感 = 所有開啟的點質感係數總和,問最大的整體質感為多少
解題思路 :
首先要注意一個問題,因為棒子長度是直徑,所以有可能一次改到多個點
這些點有一個特性,如果你把圓心當作原點,那同斜率的點會一起被改到
所以如果用斜率做順序(注意若 $x = 0$ 需特別處理),整個問題就會變區間最大和
但是最大總和區間有可能貫穿結尾跟起點,所以要區分情況
* 如果有貫穿 -> 總和 - 最小連續和
* 如果無貫穿 -> 最大連續和
```cpp=
#include<bits/stdc++.h>
using namespace std ;
#define SS second
// 因為可能有多個點同時被掃到(到原點斜率相同)
// 所以用 map 存同斜率的值
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0) ;
int n ;
cin >> n ;
double x, y, w ;
map<double, int> mp ;
for (int i=0;i<n;i++) {
cin >> x >> y >> w ;
if (x == 0) mp[INT_MAX] += w ; // 斜率無限大
else mp[y/x] += w ;
}
// nmax 以目前位置為結尾最大總和
// good 所有區間中最大總和
// nmin 以目前位置為結尾最小總和
// bad 所有區間中最小總和
// sum 所有元素總和
int good = 0, nmax = 0, bad = 0, nmin = 0, sum = 0 ;
for (auto& it: mp) {
nmax = max(nmax, 0) + it.SS ;
good = max(good, nmax) ;
nmin = min(nmin, 0) + it.SS ;
bad = min(bad, nmin) ;
sum += it.SS ;
}
// 答案會是區間最大或全部-區間最小
// 全部-區間最小其實是跨越區間終點跟起點的區間和
// 因為時鐘是圓形,所以起點可以為任何可能
// 而起點轉一圈到起點才是全部區間
// 所以有些區間會需要跨越區間終點
cout << max(good, sum-bad) << '\n' ;
return 0 ;
}
```
## 找一維區間內元素相乘最大
簡單來說,這題想要找的是最大乘積的子陣列
```python=
num = [-1,2,3,-4]
ans = 6 # [2,3]
num = [-1,0,-2]
ans = 0 # [0]
```
解題思路 :
有三個重點
1. 陣列裡面有負數,要考慮當下最小乘積
2. 用 dpmax, dpmin 來儲存當下最大(小)乘積
3. 最後用 ans 儲存所有最大乘積(答案)
通常我們最直覺的思考可能用列舉的方式解決,只要用兩個for迴圈就可以解決
程式碼如下 :
```python=
def find_max(nums) :
longs = len(nums)
ans = nums[0] # 最大子陣列乘積
for i in range(longs) :
now = 1
for j in range(i,longs) :
now *= nums[j] # 目前的子陣列乘積
ans = max(ans, now) # 比較
return ans
nums = [1,0,-100,101,5,0] # 505
ans = find_max(nums)
print(ans)
```
但是用這個演算法太慢了O(n^2^)
所以我們要換一個想法去執行,在這邊我們介紹DP的解法
假如我們可以用**兩個變數**去儲存**當前位置的最大(小)乘積**
這樣跑完整個陣列後就會得到全部最大(小)乘積
假設我們要找最大子陣列乘積的陣列是 [ -1 , 1 , -1 , -1 , 2 , 2 , -1 , -5 , 0 ]
可以先把情況定義好,假設我們遇到一個新的數,目前位置的最大乘積有三種可能
1. 目前位置的數值
2. 之前最大乘積
3. 之前最大乘積 × 目前位置的數值
| | 1 | 2 | 5 | 8(最後) |
| ---------------- | ---------- | ---------- | ---------- | ---------- |
| 目前位置的數值 A | 1 | -1 | 2 | 0 |
| 之前最大乘積 ans | 1 | 1 | 2 | 20 |
| A × ans | 1 | -1 | 4 | 0 |
| 結果 | 第一種 | 第二種 | 第三種 | 第二種 |
所以只要每次都判斷三種可能的大小,就能知道答案
但是還有一種可能我故意沒寫出來,記得之前我說要**考慮最小乘積**嗎
假設最小乘積是 -N ,結果 N 超大,**剛好後面接一個 -1**
之前最小乘積會**直接變成目前最大乘積**
所以還要考慮到第四種可能 **"之前最小乘積 × 目前位置的數值"** 才可以
程式碼如下 :
```python=
def find_max(nums) :
ans = nums[0] # 之前最大乘積
longs = len(nums)
dpmin = ans # 當前位置最小乘積
for i in range(1,longs) :
now = nums[i] # 當前位置的數值
max_now = now * ans # 之前最大值 × 現在的數值
min_now = now * dpmin # 之前最小值 × 現在的數值
# 判斷四種情況
ans = max(now, ans, max_now, min_now)
# 更新目前最小乘積
dpmin = min(now, max_now, min_now)
return ans
nums = [1,0,-100,101,5,0] # 505
ans = find_max(nums)
print(ans)
```
## 其他例題
### a697. [NOIP 2012 普及組] 3.摆花
解題思路 :
```cpp=
#include<bits/stdc++.h>
using namespace std ;
int dp[105] = {0} ;
signed main() {
ios::sync_with_stdio(0), cout.tie(0), cin.tie(0) ;
int n, m, i, j, k, a ;
cin >> n >> m ;
dp[0] = 1 ;
for(i=0;i<n;i++) {
cin >> a ;
for (j=m;j>=0;j--) { // 對於第 j 盆來說,還可以放入 m-j盆植物
for (k=1;k<=a;k++) {
if (j+k <= m) {
dp[j+k] = (dp[j+k] + dp[j]) % 1000007 ;
}
else break ;
}
}
}
cout << dp[m] << '\n' ;
return 0 ;
}
```
### TIOJ 2182 . 幸運表格 (Lucky)
解題思路 :
這題比較有意思,因為可以從任一點往右或下走,直到走到範圍外
可能很多人沒想到,這種問題其實是要"反過來"看的
如果要求走到範圍外,反過來就是從範圍外往左或上走求最大值
這樣我們的起點就固定為最右方與最下方的範圍外

我們拿上圖舉例,假設綠色數字為表格範圍內的數字,黑色數字在範圍外
兩個藍色箭頭指向的數字 "4",對於這個 "4" 來說
要走到他這點有可能是從下方或右方來,之後做 max() 運算就可以了
```cpp=
#include<bits/stdc++.h>
using namespace std ;
int G[1002][1002], dp[1002][1002] ; // 範圍外預設為 0
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0) ;
int m, n ;
cin >> m >> n ;
for (int i=0;i<m;i++)
for (int j=0;j<n;j++)
cin >> G[i][j] ;
int ans = INT_MIN ;
for (int i=m-1;i>=0;i--) {
for (int j=n-1;j>=0;j--) {
dp[i][j] = max(dp[i+1][j], dp[i][j+1]) + G[i][j] ;
ans = max(ans, dp[i][j]) ;
}
}
cout << ans << '\n' ;
return 0 ;
}
```
### d784. 一、連續元素的和
解題思路 :
求最大區間和
```cpp=
#include<bits/stdc++.h>
using namespace std ;
signed main() {
ios::sync_with_stdio(0), cout.tie(0), cin.tie(0) ;
int t ;
cin >> t ;
while (t--) {
int n, i, tmp, ans, in ;
cin >> n >> ans ;
tmp = ans ;
for (i=1;i<n;i++) {
cin >> in ;
if (tmp < 0) tmp = 0 ;
tmp += in ;
ans = max(ans, tmp) ;
}
cout << ans << '\n' ;
}
return 0 ;
}
```
### APCS考古題 m373. 4. 投資遊戲
解題思路 :
先從 $k = 0$ 的情況開始判斷
定義移轉式 $dp[i]\ =$ 結尾必定 index 為 $i$ 的最大區間和(一定要放入一個數字)
從移轉式定義知道 $dp[0] = max(val[0], 0)$,那麼剩下的數字就可以被推出來
如果遇到一個新的數字 $x$,新的最大區間和有兩種可能
1. 單一個數字 $x$,可以表示成 $0+x$
2. 舊的區間和 $+x$
因為舊的區間和可能是負數,加上負數後區間和也不會比較大,還不如用 $0+val[i]$
於是 $dp[i]$ 就可以用下面的程式碼表示
`dp[i] = max(dp[i-1]+val[i], dp[i-1])`
=> `dp[i] = max(dp[i-1], 0) + val[i]`
再來判斷 $k > 0$ 的情況
因為多了一項 $k$,移轉式也要做出改變,根據最大區間和的可能情況修改
```cpp=
#include<bits/stdc++.h>
using namespace std ;
int val[150005] ;
int dp[2][22] ;
signed main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0) ;
int n, k, i, j ;
while (cin >> n >> k) {
for (i=0;i<n;i++) cin >> val[i] ;
int ans = 0 ;
dp[1][0] = 0, dp[0][0] = 0 ;
for (i=0;i<n;i++) {
int x = i%2, y = (i+1)%2 ;
dp[x][0] = max(dp[y][0],0) + val[i] ;
for(j=1;j<=k;j++)
dp[x][j] = max(dp[y][j-1], dp[y][j]+val[i]) ;
ans = max(ans, dp[x][k]) ;
}
cout << ans << '\n' ;
}
return 0 ;
}
```
```python=
n, k = 9, 0
val = [3, 1, -2, 3, -2, 3, -5, 2, 2]
dp = [[0]*(n+1) for i in range(2)]
ans = 0
for i in range(0,n) :
x = i%2
y = (i+1)%2
dp[x][0] = max(dp[y][0],0) + val[i]
for j in range(1,k+1) :
dp[x][j] = max(dp[y][j-1], dp[y][j]+val[i])
ans = max(ans, dp[x][k])
print(ans)
```
### e791. a2.空間切割(Cut)
解題思路 :
由於空間的切割可以看成是
```cpp=
#include<bits/stdc++.h>
using namespace std ;
typedef long long LL ;
LL ans[51][51] = {} ;
signed main() {
ios::sync_with_stdio(0), cout.tie(0), cin.tie(0) ;
int n, i, j, d, c ;
for (i=0;i<51;i++) {
ans[i][0] = 1 ;
ans[0][i] = 1 ;
}
for (i=1;i<51;i++)
for (j=1;j<51;j++)
ans[i][j] = ans[i][j-1] + ans[i-1][j-1] ;
cin >> n ;
while (n--) {
cin >> d >> c ;
cout << ans[d][c] << '\n' ;
}
return 0 ;
}
```