遞推的思維構建與技巧實現

幾何思維發表於2021-02-05

文章首發於微信公眾號:幾何思維

遞推是一種用若干步可重複運算來解決複雜問題的方法。

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]\)等類似陣列來記錄,多一維就可以多記錄一維狀態資訊,要思考上一步真正有多少個因素會影響當前步,那一般這些就是一定要記錄的資訊。


掃描下方二維碼關注公眾號,第一時間獲取更新資訊!

遞推的思維構建與技巧實現

相關文章