文章首發於微信公眾號:幾何思維
遞推是一種用若干步可重複運算來解決複雜問題的方法。
1.一維遞推
1.1 問題描述
有一個\(n\)層的樓梯,每次只可以向上爬1層或者2層,問爬完\(n\)層共有多少種不同的方式呢?
1.2 分析
設\(f(n)\)表示\(n\)層樓總共不同的方式。
假設此時位於第\(i\)層,因為每次只能爬1層或2層,所以到第\(i\)層只有2種方式。
- 從第\(i-1\)層爬上來。
- 從第\(i-2\)層爬上來。
所以得到遞推公式為\(f(n)=f(n-1)+f(n-2)\)。前2項之和等於第3項,其實就是斐波那契數列,
\(1, 1, 2, 3, 5, 8, 13, 21 \cdots \cdots\)
1.3 程式碼實現
f[0] = 1; f[1] = 1;
for (int i = 2; i < n; i++){
f[i] = f[i - 1] + f[i - 2]
}
cout << f[n - 1] << endl;
1.4 空間優化
每一步的遞推只與前2步有關,所以只需要記錄前2步的方案數,用滾動陣列,而不需要開\(o(n)\)的空間。
手動賦值
int f[3];
f[0] = 1;
f[1] = 1;
for (int i = 2; i < 10; ++i) {
f[2] = f[1] + f[0];
f[0] = f[1];
f[1] = f[2];
cout << f[2] << endl;
}
取模滾動
int f[3];
f[0] = 1;
f[1] = 1;
for (int i = 2; i < 10; ++i) {
f[i % 3] = f[(i - 1) % 3] + f[(i - 2) % 3];
cout << f[i % 3] << endl;
}
如果只與前一個狀態有關,比如\(f[n]=f[n-1]+1\),可以用0,1滾動,這個在動態規劃中會比較常用。
int f[2], t = 0;
f[0] = 1;
for (int i = 2; i < 10; ++i) {
t = 1 - t;
f[t] = f[1 - t] + 1;
cout << f[t] << endl;
}
遞推和動態規劃最大的區別:遞推的每一步是所有方案數的加和,而動態規劃在每一步遞推中,需要用max,min來選取一個最優策略。本質其實都是通過重複的小規模子問題推匯出大規模的結果。
1.5 時間優化
斐波那契數列遞推公式很簡單,但資料很大時,效率就比較低,因為遞推是\(O(n)\)複雜度。
通過矩陣公式變換可將加法變為乘法
如下將遞推公式放入矩陣:
\(
\begin {bmatrix}1&1\\1&0\end {bmatrix}
\times
\begin{pmatrix}f(n-1) \\ f(n-2)\end{pmatrix}
=\begin{pmatrix}f(n-1)+f(n-2) \\ f(n-1)\end{pmatrix}
=\begin{pmatrix}f(n) \\ f(n-1)\end{pmatrix}
\)
假設:
\(A=\begin {bmatrix}1&1\\1&0\end {bmatrix}\)
則:
\(A^n \times
\begin{pmatrix}f(1) \\ f(0)\end{pmatrix}
= \begin{pmatrix}f(n+1) \\ f(n)\end{pmatrix}
\)
\(A^n\)可以通過矩陣冪乘快速求出,時間複雜度為\(log_2N\),再帶入上式即可獲得數列值。
具體可以看另一篇遞推優化-矩陣冪乘
2.多維遞推
2.1 問題描述
從原點出發,每次只能向東,向北,向西走,且不能走已經走過的地方,問走\(n\)步共有多少種不同的方式呢?
2.2 分析
假設已經走到第\(i\)步,因為不能走已經走過的地方,那這一步能走的方式只會與上一步有關。因為每次走一步,要保證不走回頭路,就保證不走上一步走過的地方就行了。
每次有3個選擇,即向東,向北,向西。
- 第\(i-1\)步向東走,那麼第\(i\)步只能向北、向東。
- 第\(i-1\)步向西走,那麼第\(i\)步只能向北、向西。
- 第\(i-1\)步向北走,那麼第\(i\)步可以向北、向東,向西。
一維的\(f[n]\)只能記錄一個總數,而不能記錄狀態,所以要再多一維記錄上一步走的狀態。
設\(f[n][0],f[n][1],f[n][2]\)分別表示:第\(n\)步向東、向西、向北走總共不同的方式。
則有如下遞推關係:
- \(f[n][0] = f[n - 1][0] + f[n - 1][2];\)
- \(f[n][1] = f[n - 1][1] + f[n - 1][2];\)
- \(f[n][2] = f[n - 1][0] + f[n - 1][1] + f[n - 1][2];\)
2.3 程式碼實現
int f[100][3] = {0};
f[0][0] = 1;
f[0][1] = 1;
f[0][2] = 1;
for (int i = 1; i < n; ++i) {
f[i][0] = f[i - 1][0] + f[i - 1][2];
f[i][1] = f[i - 1][1] + f[i - 1][2];
f[i][2] = f[i - 1][0] + f[i - 1][1] + f[i - 1][2];
}
cout << f[n - 1][0] + f[n - 1][1] + f[n - 1][2] << endl;
2.4 進一步優化
設第\(n\)步的總方案數為\(s[n], s[n]=f[n][0]+f[n][1]+f[n][2]\)。
\(s[n]=2\times f[n-1][0]+2\times f[n-1][1]+3\times f[n-1][2]\)。
\(s[n]=2\times s[n-1]+f[n-1][2]\)。
而\(f[n-1][2]=f[n-2][0]+f[n-2][1]+f[n-2][2]=s[n-2]\)。
得\(s[n]=2\times s[n-1]+s[n-2]\)。
所以對公式變形,也可以通過一維的方式完成遞推,但這個關係無法直接通過建模構造出來。
3.圖遞推
3.1 問題描述
在一個\(n\times m\)的二維地圖中,一個人從左上角走到右下角,每次只能向右或者向下走,問到終點共有多少種不同的方式呢?
3.2 分析
假設已經位於某個位置,因為只能向右或者向下走,那上一步只能從上或者從左走過來。
設\(f[i][j]\)表示走到座標\((i,j)\)總共的方案數。
則\(f[i][j]=f[i-1][j]+f[i][j-1]\)。
3.3 程式碼實現
int f[10][10] = {0};
f[0][0] = 1;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
if (i - 1 >= 0) {
f[i][j] += f[i - 1][j];
}
if (j - 1 >= 0) {
f[i][j] += f[i][j - 1];
}
}
}
3.4 進一步思考
要到達終點,一定要向下走\(n-1\)步,向右走\(m-1\)步。
把每一步組合在一起來看,其實問題就等價於在\(n+m-2\)步中選擇\(n-1\)步向下走,或者選擇\(m-1\)步向右走,通過排列組合公式\(C_{n+m-2}^{n-1}\)就可以直接得到結果。
4.狀態壓縮遞推
4.1 問題描述
在一個\(n\times n\)的棋盤中放置棋子,有一些地方不能放置。要求放置棋子時任意2個棋子不能在同1行或同1列,問放置\(k\)個棋子有多少種不同的方式呢?
4.2 分析
對於每1個位置,只會有2種情況,就是放或不放。在資料規模不大的情況下可以用DFS(深度優先搜尋)列舉所有的情況就可以了。
那有沒有更好的方法呢?
這個最終是要求方案總數,而不需要考慮每一步是否需要擇優,所以是符合遞推模型,接下來就是怎麼找出遞推關係。
先分析一些隱含的規律,把問題理得更清晰:
- 每1行或者每1列都只能放置1個棋子,所以按每一行來列舉放置方法。
- 在嘗試第\(i\)行時,每一個位置\((i,j)\)能不能放置,不只是跟上一行有關,而是跟之前的所有行都有關。這就說明需要記錄之前放置的方法,也就是狀態。
那怎麼記錄之前放置的方案狀態呢,這就要用到狀態壓縮。
狀態壓縮:本質就是用二進位制記錄對應位置的2種狀態,0表示不放,1表示放。
對於\(n\)個位置,就可以用\(2^n\)個十進位制數來表示所有放置的方案。
繼續回到上面的問題,在嘗試第\(i\)行時,能否放置跟之前的\(i-1\)行都有關,意味著需要記錄之前所有行放置的狀態。
但看下面2種情況,圖1和圖2對於在嘗試放置第3行時,其實是等價的,前2列都是不能放置。也就是說這2種方案數是可以直接合並的,因為每1列也只能放一個,所以放置的方案狀態也可以直接合併成一行。
用\(f[i][j]\)表示前\(i\)行,放置方案為\(j\)總共的方案數。
第0行的過程如下:
第1行的過程如下:
如此遞推求出\(n\)行,\(2^n\)种放置方案的總數,\(f[n][2^n]\)。因為只能放置\(k\)個棋子,所以在\(2^n\)种放置方案中找出剛好是\(k\)個棋子的方案,也就是對應的狀態\(j\)轉化為二進位制時,有\(k\)個1。
4.3 小技巧:十進位制數如何快速求對應的二進位制包含1的個數呢?
目標數\(n\),通過\(n \& (n-1)\)運算,包含多少個1就剛好進行多少次該運算,可以快速求出1的個數。
4.4 程式碼實現
計算數\(n\)中包含1的個數
int countOne(int n) {
int total = 0;
while (n > 0) {
total++;
n &= n - 1;
}
return total;
}
變數定義及初始化
int i, n, k, line[8], f[2][256], num[256];
for (i = 0; i < 256; ++i) num[i] = countOne(i);
memset(f, 0, 2 * 256 * 4);
f[0][0] = 1;
int j, c, now = 0;
// 棋盤讀入
for (i = 0; i < n; ++i) {
int t = 0;
for (int j = 0; j < n; ++j) {
t <<= 1;
cin >> ch;
if (ch == '.') t += 1;
}
line[i] = t;
}
核心遞推
for (i = 0; i < n; ++i) {
now = 1 - now;
for (j = 0; j < 256; ++j)
if (num[j] <= k) {
//第i行不放棋子
f[now][j] += f[1 - now][j];
//第i行放棋子
for (c = 0; c < n; ++c) {
if ((j & 1 << c) == 0 && (line[i] & 1 << c) == 0) {
f[now][j | 1 << c] += f[1 - now][j];
}
}
}
}
// 列舉所有包含k個棋子的方案數
int ans = 0;
for (i = 0; i < 256; ++i) {
if (num[i] == k) {
ans += f[now][i];
}
}
cout << ans << endl;
5.總結
遞推最重要的思想,就是通過每一小步,找出與下一步之間的關係。關鍵在於思考問題的本質,對問題進行建模。常用\(f[i][j][k]\)等類似陣列來記錄,多一維就可以多記錄一維狀態資訊,要思考上一步真正有多少個因素會影響當前步,那一般這些就是一定要記錄的資訊。
掃描下方二維碼關注公眾號,第一時間獲取更新資訊!