---
tags: solution
---
*[返回教材目錄](https://hackmd.io/@PuSaff/ryqPRzENq)*
<style>
.blackout {
color: #191919;
background-color: #191919;
}
.blackout:hover {
color: white;
}
</style>
# a232 骰子組合
> 給定三個數字 $N, M, F$,請求出執行一個範圍$1\sim F$ 的亂數生成器,在執行 $N$ 次後,將所有數字加起來等於 $M$ 的方法數。
>
> ***Tags:** 暴力(brute-force), 動態規劃(DP)*
:::spoiler <b>注: 滑鼠移動到<span class=blackout> 黑條 </span>上自動反白,點開</b>
會顯示Subtask詳解,想自己解的自行注意
:::
----
## Subtask 1.
如果我已經知道骰兩顆六面骰骰到1~12點分別有幾種,那麼我能怎麼算出骰三顆骰子骰到一點、兩點、三點...的方法數呢?
:::spoiler **關鍵字: <span class=blackout> 暴力遞迴 </span>**
如果骰兩顆骰子要得到7點,那就是
* 第一顆1點,第二顆6點
* 第一顆2點,第二顆5點
* 第一顆3點,第二顆4點
* ...
* 第一顆6點,第二顆1點
<br>
如果要三顆骰7點呢? 跟上面很相似
* 前兩顆1點,第二顆6點
* 前兩顆2點,第二顆5點
* 前兩顆3點,第二顆4點
* ...
* 前兩顆6點,第二顆1點
由此可知,想得知`n`顆骰子骰到`m`點的情形,可以由`n-1`顆骰子的情形推得。以六面骰為例的話,即前`n-1`顆骰到`m-6, m-5, m-4, m-3, m-2`或`m-1`,第`n`顆再骰到對應的點數。
```cpp=
#include <iostream>
using namespace std;
const int MOD = 998244353;
long long recur(int n, int m, int f) {
if (n == 1) //一顆骰的點數為1~F
return (n > 0) && (n <= f);
long long sum = 0;
for (int i=1; i<=f; ++i) {
sum += recur(n-1, m-i, f);
sum %= MOD;
}
return sum % MOD;
}
signed main() {
long long n, m, f;
cin >> n >> m >> f;
cout << recur(n, m, f) << '\n';
return 0;
}
```
時間複雜度: $O(N^F)$
:::
## Subtask 2.
$N, M$的範圍變大了,用`Subtask 1`的解的話會`TLE`。想一想在`Subtask 1`的寫法裡面,有沒有多餘的計算?
:::spoiler **關鍵字: <span class=blackout> 動態規劃 </span>**
以 $N, M, F = 5, 10, 3$ 為例,呼叫`Subtask 1`中我們的遞迴函式,大約會長這樣:
```graphviz
digraph G {
R[label="f(5, 10, 3)"]
A0[label="f(4, 9, 3)"]
A1[label="f(4, 8, 3)"]
A2[label="f(4, 7, 3)"]
B0[label="f(3, 8, 3)"]
B1[label="f(3, 7, 3)"]
B2[label="f(3, 6, 3)"]
B3[label="f(3, 5, 3)"]
B4[label="f(3, 4, 3)"]
R -> A0, A1, A2
A0 -> B0, B1, B2
A1 -> B1, B2, B3
A2 -> B2, B3, B4
}
```
從上面的圖可以發現,我們呼叫了許多參數完全相同的函式。這就像是,在數學考試中剛剛才花很多時間解完一題,下一題問你一模一樣的題目,你還再重算一次的意思是一樣的。
聰明的作法,是將答案記起來,之後如果有問到一樣的東西就不用重算了。
```cpp=
#include <iostream>
#include <algorithm>
using namespace std;
const int MOD = 998244353;
long long dp[105][105];
long long DP(int n, int m, int f) {
if (0 >= m) return 0;
if (n == 1) return (1 <= m) && (m <= f);
if (dp[n][m] != 0) return dp[n][m];
for (int i=1; i<=f; i++)
dp[n][m] = (dp[n][m] + DP(n-1, m-i, f)) % MOD;
return dp[n][m];
}
```
以上是遞迴(`Top-down`)的做法,但比起用迴圈(`Bottom-up`)慢很多。將`Top-down`改成`Bottom-up`的做法,就是利用特殊的計算順序,讓我們在算到`f(a, b, c)`的時候,所需用到的`f(a-1, b-1, c), f(a-1, b-2, c)...`都已經被我們算好了。
<table>
<tr>
<th></th>
<th>Top-down作法</th>
<th>Bottom-up作法</th>
</tr>
<tr>
<th>優勢</th>
<td><b>比較好寫</b>,只要推出關係式,基本上程式就出來了</td>
<td>不必一值呼叫自己,而<b>節省了執行時間</b></td>
</tr>
</table>
```cpp=
#include <iostream>
#include <algorithm>
using namespace std;
const int MOD = 998244353;
long long dp[105][105];
long long gv(int idx, int r) { //避免戳到負的位置
if (idx <= 0) return 0;
return dp[r][idx];
}
long long DP(int n, int m, int f) {
for (int i=0; i<=n; ++i)
fill(dp[i], dp[i]+m+1, 0);
fill(dp[1]+1, dp[1]+1+f, 1);
//先算丟一顆骰,再算丟兩顆,再算丟三顆...
for (int i=2; i<=n; ++i) {
for (int j=i; j<=m; ++j) {
for (int k=1; k<=f; ++k) {
dp[i][j] = (dp[i][j] + gv(j-k, i-1)) % MOD;
}
}
}
return dp[n][m];
}
```
時間複雜度: $O(NMF)$
:::
## Subtask 3.
$N, M$ 的範圍變更大了。`Subtask 2`的時間複雜度還是可以過,但是本題的記憶體限制在`64MB`,不夠用。想一想有沒有哪些資訊,是在程式執行的過程中就能被拋棄的,而不必一直記錄到最後?
:::spoiler **關鍵字: <span class=blackout> 滾動式DP </span>**
我們再看看`Subtask 2`中寫的DP。將他的轉移式(從上一個狀態推得下一個狀態的數學式)寫出來,大概是這樣的:
$$
dp[i][j] = \sum_{k=1}^{k\leq F}{dp[i-1][j-k]}
$$
有沒有發現甚麼? 想要得到骰了`n`顆骰子的情形,我們需要,而且**只需要**,知道骰了`n-1`顆骰子的情形即可,`n-2, n-3, n-4...`顆骰的情形已經不重要了。
照這個邏輯走下去,我們可以將之前的`DP`改一改: 在即將計算`n=3`顆骰時,將之前算的`n=1`顆骰時所紀錄的答案丟掉;在即將計算`n=4`顆骰時,將`n=2`顆骰的答案丟掉...
```cpp=
//計算n顆骰時,只會用到n-1顆骰時的情形。
//因此只需要兩個陣列,分別是:
//新計算的,n顆骰時的情形,存到dp[1]裡面
//曾算過的,n-1顆骰時的情形,存在dp[0]裡面
long long dp[2][3005];
long long rollingDP(int n, int m, int f) {
fill(dp[0], dp[0]+3005, 0);
fill(dp[0]+1, dp[0]+1+f, 1);
for (int i=1; i<n; ++i) {
for (int j=i+1; j<=m; ++j) {
for (int k=1; k<=f; ++k) {
dp[1][j] = (dp[1][j] + gv(j-k)) % MOD;
}
}
//即將計算n+1顆骰時的情形
//將dp[1]裡面的東西複製到dp[0]裡面
//(這裡用swap,但結果一樣)
//再將dp[1]重製
swap(dp[0], dp[1]);
fill(dp[1], dp[1]+3005, 0);
}
return dp[0][m];
}
```
原空間複雜度: $O(NM)$
新空間複雜度: $O(N)$
:::
## Subtask 4.
$F$ 的範圍變大了,`Subtask 3`的解法的不足以應付了,會`TLE`。像`Subtask 1 → 2`時那樣,檢查看看有沒有多餘的計算吧。
:::spoiler **關鍵字: <span class=blackout> 前綴和/滑窗 </span>**
我們再回到轉移式觀察看看:
$$
dp[i][j] = \sum_{k=1}^{k\leq F}{dp[i-1][j-k]}
$$
... ...看不出甚麼東西?
不如拿一些數字丟進去,看看計算的過程是怎麼樣的。
```
N, M, F = 6, 17, 4
...
dp[3][7] = dp[2][6]+dp[2][5]+dp[2][4]+dp[2][3]
dp[3][8] = dp[2][7]+dp[2][6]+dp[2][5]+dp[2][4]
dp[3][9] = dp[2][8]+dp[2][7]+dp[2][5]+dp[2][6]
```
有沒有發現一件事情,就是從`dp[3][7]`到`dp[3][8]`,其值只是少了`dp[2][3]`,多了`dp[2][7]`,而`dp[3][9]`也有類似的關係。
如果依照這樣的思路推廣,那麼要算`dp[i][j]`的時候,其實只要計算`dp[i][j-1] - dp[i-1][j-1-F] + dp[i-1][j-1]`就好了,而不必算`dp[i-1][j-1] + dp[i-1][j-2]...`。
以六面骰為例:
![image alt](https://github.com//PuSapphire/R3_Gitbook/blob/master/.gitbook/assets/form%20c2%20DP.jpg?raw=true)
就很像在「移動窗戶」一樣,在算下一個值的時候,只需要把尾端縮回來、頭往前延伸,而不必整個區間重算。(英文關鍵字: sliding window)
如此一來,計算`dp[i][j]`的時候,便只需要一加一減;而之前的算法,則是每次都需要算`F`個數字的和!
```cpp=
long long dp[2][3005];
long long slidingDP(int n, int m, int f) {
fill(dp[0], dp[0]+3005, 0);
fill(dp[0]+1, dp[0]+1+f, 1);
for (int i=1; i<n; ++i) {
long long cur = dp[0][i];
for (int j=i+1; j<=m; ++j) {
for (int k=1; k<=f; ++k) {
dp[1][j] = cur;
cur -= gv(j-f); //扣掉尾端
cur += dp[0][j]; //加入前端
cur %= MOD;
}
}
swap(dp[0], dp[1]);
fill(dp[1], dp[1]+3005, 0);
}
return dp[0][m];
}
```
時間複雜度: $O(NM)$
:::