動態規劃之博弈問題

上一篇文章 幾道智力題 中討論到一個有趣的「石頭遊戲」,經過題目的限制條件,這個遊戲是先手必勝的。可是智力題終究是智力題,真正的算法問題確定不會是投機取巧能搞定的。因此,本文就借石頭遊戲來說講「假設兩我的都足夠聰明,最後誰會獲勝」這一類問題該如何用動態規劃算法解決。python

博弈類問題的套路都差很少,下文舉例講解,其核心思路是在二維 dp 的基礎上使用元組分別存儲兩我的的博弈結果。掌握了這個技巧之後,別人再問你什麼倆海盜分寶石,倆人拿硬幣的問題,你就告訴別人:我懶得想,直接給你寫個算法算一下得了。git

咱們「石頭遊戲」改的更具備通常性:算法

你和你的朋友面前有一排石頭堆,用一個數組 piles 表示,piles[i] 表示第 i 堆石子有多少個。大家輪流拿石頭,一次拿一堆,可是隻能拿走最左邊或者最右邊的石頭堆。全部石頭被拿完後,誰擁有的石頭多,誰獲勝。編程

石頭的堆數能夠是任意正整數,石頭的總數也能夠是任意正整數,這樣就能打破先手必勝的局面了。好比有三堆石頭 piles = [1, 100, 3],先手無論拿 1 仍是 3,可以決定勝負的 100 都會被後手拿走,後手會獲勝。數組

假設兩人都很聰明,請你設計一個算法,返回先手和後手的最後得分(石頭總數)之差。好比上面那個例子,先手能得到 4 分,後手會得到 100 分,你的算法應該返回 -96。app

這樣推廣以後,這個問題算是一道 Hard 的動態規劃問題了。博弈問題的難點在於,兩我的要輪流進行選擇,並且都賊精明,應該如何編程表示這個過程呢?框架

仍是強調屢次的套路,首先明確 dp 數組的含義,而後和股票買賣系列問題相似,只要找到「狀態」和「選擇」,一切就水到渠成了。ide

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。學習

1、定義 dp 數組的含義

定義 dp 數組的含義是頗有技術含量的,同一問題可能有多種定義方法,不一樣的定義會引出不一樣的狀態轉移方程,不過只要邏輯沒有問題,最終都能獲得相同的答案。優化

我建議不要迷戀那些看起來很牛逼,代碼很短小的奇技淫巧,最好是穩一點,採起可解釋性最好,最容易推廣的設計思路。本文就給出一種博弈問題的通用設計框架。

介紹 dp 數組的含義以前,咱們先看一下 dp 數組最終的樣子:

f8d22397ebb88500b0ececb667e61d53.jpg

下文講解時,認爲元組是包含 first 和 second 屬性的一個類,並且爲了節省篇幅,將這兩個屬性簡寫爲 fir 和 sec。好比按上圖的數據,咱們說 dp[1][3].fir = 10dp[0][1].sec = 3

先回答幾個讀者可能提出的問題:

這個二維 dp table 中存儲的是元組,怎麼編程表示呢?這個 dp table 有一半根本沒用上,怎麼優化?很簡單,都不要管,先把解題的思路想明白了再談也不遲。

如下是對 dp 數組含義的解釋:

dp[i][j].fir 表示,對於 piles[i...j] 這部分石頭堆,先手能得到的最高分數。
dp[i][j].sec 表示,對於 piles[i...j] 這部分石頭堆,後手能得到的最高分數。

舉例理解一下,假設 piles = [3, 9, 1, 2],索引從 0 開始
dp[0][1].fir = 9 意味着:面對石頭堆 [3, 9],先手最終可以得到 9 分。
dp[1][3].sec = 2 意味着:面對石頭堆 [9, 1, 2],後手最終可以得到 2 分。

咱們想求的答案是先手和後手最終分數之差,按照這個定義也就是 dp[0][n-1].fir - dp[0][n-1].sec,即面對整個 piles,先手的最優得分和後手的最優得分之差。

2、狀態轉移方程

寫狀態轉移方程很簡單,首先要找到全部「狀態」和每一個狀態能夠作的「選擇」,而後擇優。

根據前面對 dp 數組的定義,狀態顯然有三個:開始的索引 i,結束的索引 j,當前輪到的人。

dp[i][j][fir or sec]
其中:
0 <= i < piles.length
i <= j < piles.length

對於這個問題的每一個狀態,能夠作的選擇有兩個:選擇最左邊的那堆石頭,或者選擇最右邊的那堆石頭。 咱們能夠這樣窮舉全部狀態:

n = piles.length
for 0 <= i < n:
    for j <= i < n:
        for who in {fir, sec}:
            dp[i][j][who] = max(left, right)

上面的僞碼是動態規劃的一個大體的框架,股票系列問題中也有相似的僞碼。這道題的難點在於,兩人是交替進行選擇的,也就是說先手的選擇會對後手有影響,這怎麼表達出來呢?

根據咱們對 dp 數組的定義,很容易解決這個難點,寫出狀態轉移方程:

dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec)
dp[i][j].fir = max(    選擇最左邊的石頭堆     ,     選擇最右邊的石頭堆     )
# 解釋:我做爲先手,面對 piles[i...j] 時,有兩種選擇:
# 要麼我選擇最左邊的那一堆石頭,而後面對 piles[i+1...j]
# 可是此時輪到對方,至關於我變成了後手;
# 要麼我選擇最右邊的那一堆石頭,而後面對 piles[i...j-1]
# 可是此時輪到對方,至關於我變成了後手。

if 先手選擇左邊:
    dp[i][j].sec = dp[i+1][j].fir
if 先手選擇右邊:
    dp[i][j].sec = dp[i][j-1].fir
# 解釋:我做爲後手,要等先手先選擇,有兩種狀況:
# 若是先手選擇了最左邊那堆,給我剩下了 piles[i+1...j]
# 此時輪到我,我變成了先手;
# 若是先手選擇了最右邊那堆,給我剩下了 piles[i...j-1]
# 此時輪到我,我變成了先手。

根據 dp 數組的定義,咱們也能夠找出 base case,也就是最簡單的狀況:

dp[i][j].fir = piles[i]
dp[i][j].sec = 0
其中 0 <= i == j < n
# 解釋:i 和 j 相等就是說面前只有一堆石頭 piles[i]
# 那麼顯然先手的得分爲 piles[i]
# 後手沒有石頭拿了,得分爲 0

7df9c5908f045ad5b5d34af63c2346b5.jpg

這裏須要注意一點,咱們發現 base case 是斜着的,並且咱們推算 dp[i][j] 時須要用到 dp[i+1][j] 和 dp[i][j-1]:

e77714ad98ea7ce8e3d81cb9013191c0.jpg

因此說算法不能簡單的一行一行遍歷 dp 數組,而要斜着或者倒着遍歷數組:

b6aaf8750b6affc5abca85fde6da6381.jpg

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。

3、代碼實現

如何實現這個 fir 和 sec 元組呢,你能夠用 python,自帶元組類型;或者使用 C++ 的 pair 容器;或者用一個三維數組 dp[n][n][2],最後一個維度就至關於元組;或者咱們本身寫一個 Pair 類:

class Pair {
    int fir, sec;
    Pair(int fir, int sec) {
        this.fir = fir;
        this.sec = sec;
    }
}

而後直接把咱們的狀態轉移方程翻譯成代碼便可,注意咱們要倒着遍歷數組:

/* 返回遊戲最後先手和後手的得分之差 */
int stoneGame(int[] piles) {
/* 返回遊戲最後先手和後手的得分之差 */
int stoneGame(int[] piles) {
    int n = piles.length;
    // 初始化 dp 數組
    Pair[][] dp = new Pair[n][n];
    for (int i = 0; i < n; i++) 
        for (int j = i; j < n; j++)
            dp[i][j] = new Pair(0, 0);
    // 填入 base case
    for (int i = 0; i < n; i++) {
        dp[i][i].fir = piles[i];
        dp[i][i].sec = 0;
    }
    // 斜着遍歷數組
    for (int i = n - 2; i >= 0; i--) {
        for (int j = i + 1; j < n; j++) {
            int j = l + i - 1;
            // 先手選擇最左邊或最右邊的分數
            int left = piles[i] + dp[i+1][j].sec;
            int right = piles[j] + dp[i][j-1].sec;
            // 套用狀態轉移方程
            // 先手確定會選擇更大的結果,後手的選擇隨之改變
            if (left > right) {
                dp[i][j].fir = left;
                dp[i][j].sec = dp[i+1][j].fir;
            } else {
                dp[i][j].fir = right;
                dp[i][j].sec = dp[i][j-1].fir;
            }
        }
    }
    Pair res = dp[0][n-1];
    return res.fir - res.sec;
}
}

動態規劃解法,若是沒有狀態轉移方程指導,絕對是一頭霧水,可是根據前面的詳細解釋,讀者應該能夠清晰理解這一大段代碼的含義。

並且,注意到計算 dp[i][j] 只依賴其左邊和下邊的元素,因此說確定有優化空間,轉換成一維 dp,想象一下把二維平面壓扁,也就是投影到一維。可是,一維 dp 比較複雜,可解釋性不好,你們就沒必要浪費這個時間去理解了。

4、最後總結

本文給出瞭解決博弈問題的動態規劃解法。博弈問題的前提通常都是在兩個聰明人之間進行,編程描述這種遊戲的通常方法是二維 dp 數組,數組中經過元組分別表示兩人的最優決策。

之因此這樣設計,是由於先手在作出選擇以後,就成了後手,後手在對方作完選擇後,就變成了先手。這種角色轉換使得咱們能夠重用以前的結果,典型的動態規劃標誌。

讀到這裏的朋友應該能理解算法解決博弈問題的套路了。學習算法,必定要注重算法的模板框架,而不是一些看起來牛逼的思路,也不要奢求上來就寫一個最優的解法。不要捨不得多用空間,不要過早嘗試優化,不要害怕多維數組。dp 數組就是存儲信息避免重複計算的,隨便用,直到咱滿意爲止。

Reference:

這篇文章參考了 YouTube 視頻 https://www.youtube.com/watch?v=WxpIHvsu1RI

相關文章
相關標籤/搜索