---
# System prepended metadata

title: 【C++】競程筆記（DP：Top Down & Bottom up）
tags: [資料結構, 程式設計, C++, DP, 競程, 演算法, CP]

---

# 【C++】競程筆記（DP：Top Down & Bottom up）

[TOC]

程式碼範例參考：NTUCPC Guide，此筆記僅為個人學習用途。

## Stack overflow

Stack overflow 是一個 IT 論壇，然後在程式當中也會發生這種情形XD，有時候說 Stack overflow 也是一個雙關語。

至於 Stack overflow 是什麼呢？字面上意思就是堆疊溢位，是指程式使用過多的記憶體時導致呼叫堆疊產生的溢位。通常最常見的原因就是因為函式呼叫中使用到遞迴，導致遞迴過深。

遞迴的原理就是使用到了 stack 這個資料結構，每次呼叫會把值堆入堆疊，直到終止條件再將這些值一個一個拿出來。

## 94 . [Tutorial] 別離太遠

Problem Source：https://oj.ntucpc.org/problems/94

這題可以體會一下什麼叫做 stack overflow。

題目沒有給你 N = 1 跟 N = 2 的情況，但這兩個情況很簡單，稍微推算一下就可以知道方法數了。

- N = 1：由於只有一個，所以方法數是 1。
- N = 2：有 1 跟 2，但必須選擇 1 跟 2，因為編號差 2 - 1 = 1，所以只有一種方法。

題目後面很佛心還給你了 N = 3 跟 N = 4 的方法數，分別是 2 跟 3。

這時候將方法數排在一起看，就會發現有所端倪： $1, 1, 2, 3$ ，疑！？這不是費氏數列的長相嗎？

但在此之前，題目說 $N$ 可能高達 $10^7$ ，費氏數列的成長速度極快，就算用 unsigned long long 都會溢位，這時候題目告訴你可以將方法數除以 $10^9 + 7$ 的餘數，於是程式碼就可以這樣寫了：

```cpp=
#include <iostream>

using namespace std;

const int MOD = 1000000007;

int dp[10000010] = {0};

int solve(int N){
    if (N <= 2) return 1;
    if (dp[N] != 0) return dp[N];
    dp[N] = (solve(N - 1) + solve(N - 2)) % MOD;
    return dp[N];
}

int main(){
    int N;
    cin >> N;
    cout << solve(N);
}
```

當你輸入題目的範例測資 `10000000`，會發現在編譯器這裡出現 `Segmentation fault`，表示記憶體區段錯誤，也就是發生了 stack overflow 的情形。但交到 Online Judge 上後，竟然 AC 了。

原因是在 Online Judge 會將記憶體上限調很高，因此就不太會觸碰到那個上限了。

這種利用遞迴實作 DP 的方式叫做 Top Down，遇到 N 太大的情形容易造成 stack overflow。Top Down 的原意是由上往下算下去，從最大問題算到最小問題，最後從最小問題求解。

## Bottom up

與 Top Down 相反的就是 Bottom up，Bottom up 就是由下往上算。

Bottom up 使用到迴圈的方式做計算，從最小子問題不斷推算到最大問題。

剛才那題用 Bottom up 寫就會是這樣：

```cpp=
#include <iostream>

using namespace std;

const int MOD = 1000000007;

int dp[10000010] = {0};

int solve(int N){
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; ++i)
        dp[i] = (dp[i - 1] + dp[i - 2]) % MOD;
    return dp[N];
}

int main(){
    int N;
    cin >> N;
    cout << solve(N);
}
```

另外這支 Bottom up 程式碼也解決了 Top Down stack overflow 的問題。

## 95 . [Tutorial] 別離太近

Problem Source：https://oj.ntucpc.org/problems/95

這次是編號差不能小於 $2$ ，同樣的先把 $N = 1$ 跟 $N = 2$ 的情況拿出來談：

- $N = 1$ 的方法數只有 1。
- $N = 2$ 的方法數為 0，因為 1 跟 2 這兩個編號差小於 2，啥都不能做。

題目也貼心地告訴你 $N = 3$ 跟 $N = 4$ 的方法數分別是 1 跟 1。

列出來比對一下發現似乎沒什麼規律： $1, 0, 1, 1$ 。

那這時候可以考慮從第 $n$ 個人去找了，那第 $n$ 個被選中外，接下來哪些也可以被選中？ $n - 2$ 、 $n - 3$ 、 $n - 4$ 這些都可以，甚至連 $1$ 也行。

這部分用數學式稍微推一下就可知道，因為編號差必定要小於 2，假設編號 n 是最後一個被選中的，且倒數第二個被選中的可以是編號 $j$ ，而 $j$ 則必須滿足不等式 $n - j \ge 2$ ，即為 $j \le n - 2$ 。

對於每個合法的 $j$ ，從 $1$ 到 $j$ 的選法恰好為 $f(j)$ ，所以最後寫成遞迴式會發現變成 $f(n - 2) + f(n - 3) + f(n - 4) + \cdots + f(1)$ ，剛好就是 $$f(n) = \sum^{n-2}_{j = 1} f(j)$$ 

但這有個問題，實際上寫程式會發現他的時間複雜度會是 $O(n^2)$ ，完全應付不了 $10^5$ 甚至 $10^7$ 的輸入，所以這個式子可以進一步做優化。

這邊設 $S(n) = \sum^n_{j = 1}f(j)$ ，S 函數表示的是 $f(1) + f(2) + \cdots + f(n)$ ，然後接下來你會發現 $f(n) = S(n - 2)$ 這件事。

而 $S(n) = S(n - 1) + f(n) = S(n - 1) + S(n - 2)$ ，這不就是費氏數列的長相嗎？

那這邊比較困難的是，要怎麼去理解 $S(n)$ 存在的意義是什麼？當展開 $f(n)$ 跟 $f(n - 1)$ 的時候，可以比較兩者差異： $f(n) - f(n - 1) = f(n - 2)$ ，他們兩者只差了一個 $f(n - 2)$ 。

而這個差異其實是很小的，但 $f(n)$ 又是求和的形式，所以不妨用一個變數去儲存目前總和，在計算新的 dp 值時將差異補上即可。

如果真不行的話，其實有蠻原始的方法，那就是繼續算，算到後來就會發現後面全都費氏數列。

範例程式碼（藉由輔助函數 $S(n)$ 推測出來的結果是費氏數列，因此直接寫下遞迴式）：

```cpp=
#include <iostream>

using namespace std;

const int MOD = 998244353;

int dp[10000010] = {0};

int solve(int N){
    dp[1] = 1, dp[2] = 0;
    for (int i = 3; i <= N; ++i){
        dp[i] = (dp[i - 1] + dp[i - 2]) % MOD;
    }
    return dp[N];
}

int main(){
    int N;
    cin >> N;
    cout << solve(N);
}
```

## c434. 連續正整數(Natural_Number)

Problem Source：https://zerojudge.tw/ShowProblem?problemid=c434

解題思路：

這題跟之前所有練習過的題目都不太相同，比較難去求出線性遞迴。

對於所有包含 $1$ 到 $n$ 的問題，可以將所有符合條件的集合分為兩類：

- 不包含 $n$ 的集合： $\{1, 2, \cdots, n - 1\}$ 中有 $f(n - 1)$ 個。
- 包含 $n$ 的集合則需繼續細分下去。

如果包含 $n$ ，則再根據 $n - 1$ 跟 $n - 2$ 繼續分類：

1. 包含 $n$ 但不包含 $n - 1$ ：此時 $n$ 不能形成三個連續正整數的集合，因此為 $\{1, 2, 3, \cdots, n-2\}$ → $f(n - 2)$
2. 包含 $n$ 跟 $n - 1$ 但不包含 $n - 2$ ：一樣， $n$ 跟 $n - 1$ 不能形成，所以是 $\{1, 2, 3, \cdots, n-3\}$ → $f(n - 3)$
3. 包含 $n, n - 1, n - 2$ ：這時候這三個數就可以形成連續三個正整數的集合，其餘的元素 $\{1, 2, 3, \cdots, n-3\}$ 可以任意選擇（包含 or 不包含兩種情況），有這 $n - 3$ 種二元選擇，所以是 $2^{n - 3}$

所以得到遞迴式 $f(n) = f(n - 1) + f(n - 2) + f(n - 3) + 2^{n - 3}$

base case 經過推斷後可得到：

- $f(1) = 0$
- $f(2) = 0$ （上面兩個情況 n 都是 1 跟 2，理論上不可能形成連續三個正整數）
- $f(3) = 1$ （一種情形： $\{1, 2, 3\}$ ）

範例程式碼：

因為遞迴式最後面有個指數函數，所以這部分會比較麻煩一點，如果直接寫上去的話很可能會只過一小部分測資或是 CE（浮點數不能跟整數互相運算）。

所以這邊要自己設計一個函式去做快速冪（Fast Exponentiation），把原本的指數運算 $O(n)$ 降到 $O(logn)$ 。設計函數的另外一個目的也在於讓他回傳 int （也可能是 long long）而非 double（原生函式 pow() 回傳 double）。

但在這題做快速冪會顯得有點不太好，原因是每次都要做快速冪，會有函式時間開銷，所以更好的做法是預處理，`MAXN = 1000010` 開一個 `pow2[MAXN]` 陣列來存 2 的冪次，以此在做 DP 時可以來查表。

```cpp=
#include <iostream>

using namespace std;

const int MOD = 1000000007;
const int MAXN = 1000010;

long long dp[MAXN] = {0};
long long pow2[MAXN];

void precompute() {
    pow2[0] = 1;
    for (int i = 1; i < MAXN; ++i) {
        pow2[i] = (pow2[i-1] * 2) % MOD; // 注意這邊也要做 MOD 模運算
    }
}

long long solve(int N){
    dp[1] = 0, dp[2] = 0, dp[3] = 1;
    for (int i = 4; i <= N; ++i){
        dp[i] = (dp[i - 1] + dp[i - 2] + dp[i - 3] + pow2[i-3]) % MOD;
    }
    return dp[N];
}

int main(){
    ios::sync_with_stdio(false), cin.tie(nullptr);
    precompute();
    
    int T;
    cin >> T;
    while (T--){
        int N;
        cin >> N;
        cout << solve(N) << "\n";
    }
}
```

## 習題練習

### g555. 白色世界(簡易版)

Problem Source：https://zerojudge.tw/ShowProblem?problemid=g555

解題思路：

- 第 $n$ 格不染：前 $n - 1$ 要染，所以得到 $f(n - 1)$ 。
- 第 $n$ 格染色：第 $n - 1$ 不能染，而前 $n - 2$ 可染，所以得到了 $f(n - 2)$ 。

而這邊要注意，最後面需要再 $+ 1$ ，原因是在當第 $n$ 格染色的時候，前 $n - 2$ 也可以都不染。最後得到完整遞迴式： $f(n) = f(n - 1) + f(n - 2) + 1$

base case 為 $f(1) = 1, f(2) = 2$ 。

範例程式碼：

我發現如果每次呼叫 $f(n)$ 都要重新算一次 dp 會 TLE，所以這邊改成先算出全部的，後續根據 n 再去查表即可。

```cpp=
#include <iostream>

using namespace std;

const int MOD = 998244353;
const int MAXN = 200010;
long long dp[MAXN];

void precompute(){
    dp[1] = 1, dp[2] = 2;
    for (int i = 3; i <= MAXN; ++i){
        dp[i] = (dp[i - 1] + dp[i - 2] + 1) % MOD;
    }
}

int main(){
    ios::sync_with_stdio(false), cin.tie(nullptr);
    precompute();
    int t;
    cin >> t;
    while (t--){
        int n;
        cin >> n;
        cout << dp[n] << "\n";
    }
}
```

### Dice Combinations

Problem Source：https://cses.fi/problemset/task/1633

題目翻譯：

你的任務是計算透過擲骰子一次或多次來建構和為 $n$ 的數列的方法數。每次擲骰子的結果介於 $1$ 和 $6$ 之間。

舉例來說，如果 $n = 3$ ，則有四種方法：

- 1 + 1 + 1
- 1 + 2
- 2 + 1
- 3

解題思路：

假設 $i$ 是擲骰子的點數，如果在最後一次擲出 $i$ 點，則前面所有擲骰的和必須為 $n - i$ 。

Why？像 $n = 3$ 的其中一個方法， $1 + 1 + 1$ 裡面三個 1 都是擲出來的點數，如果最後一個是 1，則前面的和必須為 3 - 1 = 2，剛好也呼應了。

所以這邊來列遞迴式囉：

- 最後擲出 1 點 → $f(n - 1)$
- 最後擲出 2 點 → $f(n - 2)$
- ...
- 最後擲出 6 點 → $f(n - 6)$

完整遞迴式： $f(n - 1) + f(n - 2) + f(n - 3) + f(n - 4) + f(n - 5) + f(n - 6)$

另外 base case 為 n = 0，f(0) = 1 的情況。

範例程式碼：

```cpp=
#include <iostream>

using namespace std;

const int MOD = 1000000007;

int main(){
    int n;
    cin >> n;
    long long dp[n + 1];
    dp[0] = 1;
    for (int i = 1; i <= n; ++i){
        dp[i] = 0;
        for (int dice = 1; dice <= 6; ++dice){
            if (i - dice >= 0){
                dp[i] = (dp[i] + dp[i - dice]) % MOD;
            }
        }
    }
    cout << dp[n];
}
```

可以進一步用滑動窗口（Sliding Window）的方式優化空間複雜度至 $O(1)$ ：

```cpp=
#include <iostream>
using namespace std;

const int MOD = 1000000007;

int main() {
    int n;
    cin >> n;
    
    // 只保存最近 7 個值（dp[i-6] 到 dp[i]）
    long long dp[7] = {1, 0, 0, 0, 0, 0, 0}; // dp[0] = 1
    
    for (int i = 1; i <= n; i++) {
        long long sum = 0;
        // 加總前 6 個值
        for (int j = 1; j <= 6; j++) {
            if (i - j >= 0) {
                sum = (sum + dp[(i - j) % 7]) % MOD;
            }
        }
        dp[i % 7] = sum;
    }
    
    cout << dp[n % 7] << endl;
    
    return 0;
}
```

