1. 程式人生 > 實用技巧 >動態規劃之博弈問題

動態規劃之博弈問題

讀完本文,你可以去力扣拿下如下題目:

877.石子游戲

-----------

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

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

我們「石頭遊戲」改的更具有一般性:

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

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

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

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

還是強調多次的套路,首先明確 dp 陣列的含義,然後和股票買賣系列問題類似,只要找到「狀態」和「選擇」,一切就水到渠成了。

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

一、定義 dp 陣列的含義

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

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

介紹 dp 陣列的含義之前,我們先看一下 dp 陣列最終的樣子:

下文講解時,認為元組是包含 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,先手的最優得分和後手的最優得分之差。

二、狀態轉移方程

寫狀態轉移方程很簡單,首先要找到所有「狀態」和每個狀態可以做的「選擇」,然後擇優。

根據前面對 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

這裡需要注意一點,我們發現 base case 是斜著的,而且我們推算 dp[i][j] 時需要用到 dp[i+1][j] 和 dp[i][j-1]:

所以說演算法不能簡單的一行一行遍歷 dp 陣列,而要斜著或者倒著遍歷陣列:

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

三、程式碼實現

如何實現這個 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 比較複雜,可解釋性很差,大家就不必浪費這個時間去理解了。

四、最後總結

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

之所以這樣設計,是因為先手在做出選擇之後,就成了後手,後手在對方做完選擇後,就變成了先手。這種角色轉換使得我們可以重用之前的結果,典型的動態規劃標誌。

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

Reference:

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

_____________

我的 線上電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 演算法倉庫 已經獲得了 70k star,歡迎標星!