人工智慧之最大最小值演算法+剪枝優化(演算法 + C++實現)
阿新 • • 發佈:2019-02-08
1、最小最大值方法簡述
現在我們來看看博弈樹節點標註的另一種方法:最小最大值方法。整個博弈樹儘管大的出奇,然而在只有一部分有用的情況下,利用最小最大值方法是有其優點的,很容易推廣使用。
比方說,競賽的結果是以錢為賭注的。為方便起見,設賭金為一塊錢。如果棋手贏的,他就獲得一塊錢;如果他輸了,這輸一塊錢。在和局的情況下,他不輸也不贏。
我們把棋手贏的錢稱之為收益。如果棋手贏了,其收益為1;如果輸了,收益為-1;和局時為0。
現在我們來定義一個節點的值。對於w節點其收益為1;l為-1;d為0。
我們用不著一定要先把節點標上w、d或l,然後再去註明它們的值,而是可以直接計算出它們的值。
前面我們已經知道,對於每一個葉節點,比賽的結果或者是贏、或者是輸、或者是和局。因此我們先把比賽能贏的那些葉節點標上1,把和局的節點標上0,把輸的節點標上-1。
如同我們前面說過的那樣。從葉節點開始,其餘節點按照其子節點的值來計算出它的值,一直到把根結點的值算出來。
這種計算後面所包含的想法是:棋手總是選擇能得到最大收益的棋步;而對手總是選擇通往最小收益的棋步。更確切地說,我們按下面的規則來計算每個節點的值:
輪到棋手走時,節點的值是其子節點之中的最大值。
輪到對手走時,節點的值為其子節點中的最小值。
到現在為止,最大最小值方法已經給我們提供了一個簡明而有效的手段來標註博弈樹的節點。然而,這種方法不是採用任意的標號,如前面所說的w、l或d來標註節點,而是用數字來標註。這樣做使得我們能夠很容易的推廣這個方法,不用把節點的值侷限於1、0或-1。
深度優先的最小最大值評價方法
現在我們回頭來看看棋手對當前的棋局是如何考慮的,以及應如何決定下一步的走法。假設有了一種方法,它對任何棋局都可以給出所有的合法棋步,同時還能給出採用這些合法棋步中任何一步以後所形成的新的棋局。另外我們假設已經有了上面所談到判斷終局的方法。我們希望能決定棋手應走的棋步。
我們從當前的棋局開始,將其擴充套件,然後再擴充套件它的子棋局,如此類推。這樣我們就得到了一個部分博弈樹。但是,每一個結點在展開之前,我們都要先檢測這個節點是否為終局。如果是終局那麼就不展開這個節點;如果不是那麼就展開它。
上述的擴充套件一直進行下去,直到所有的節點都不能再展開為止。最後我們得到了這樣一個博弈樹,它的根結點對應於當前的棋局而其葉節點對應於我們認為的終局。
應用最小最大值方法,我們對該博弈樹的每一個結點都標註上一個值。如前所述,棋手所要走的節點,它的值是其所有子節點中的最大值;而對手所走的節點,它的值是其所有子節點中的最小值。由於這些節點的計算方法本身的原故,那些非葉節點所標註的值通常稱為回溯值。
對棋手來說,他所選擇的最佳棋步就是能夠得到具有最大回溯值棋局的棋步。
在對手的下一步棋走完以後,那麼就要從這時的當前棋局開始建造一個新的博弈樹。這一過程會不斷重複。因此,每一個部分博弈樹實際上只是用來決定一步棋。真正用到的值僅僅是根的子節點的回溯值。這些子節點所對應的棋局就是棋手自由選擇棋步所能走到的棋局。
實際上,我們的確用不著一次就把整個博弈樹儲存起來。如果運用一個叫做深度優先的最小最大值方法,那麼在只儲存整個博弈樹的很小一部分的情況下,我們也能計算出根的子節點的值。當節點需要計算其值時,我們就產生這個節點;當節點已經完成它的使命之後,我們便刪除這個節點。
同所有的深度優先方法一樣,深度優先的最小最大值方法是從根部開始處理,沿著從親節點到子節點的方向進行,直到遇到終局為止。然後再返回,一直到找到另一條未曾探索的路徑,再沿著這條路徑到終局為止,如此類推。
這一方法可以同時既建立博弈樹,又對其節點進行估值計算。在沿著從親節點到子節點的方向採用這一方法時,節點是展開的。當節點第一次展開時,只新建了一個子節點。其他子節點只有在過程返回並重新訪問該節點時才建立。只有那些處於當前正在搜尋路徑上的子節點以及那些尚未標註回溯值的子節點才被儲存起來。
當採用這種方法達到終節點時,就使用評價函式來處理該終節點所對應的棋局,並對該節點標上評價值。然後,這種過程又回溯到終結點的新節點上去。
如前所述,在回溯期間如果訪問到某個節點,通常要對該節點繼續進行擴充套件,產生一個新的子節點,進而在對該新的子節點進行處理。實際上,該節點的所有子節點最終都會產生出來(即相應的棋局的所有合法棋步都會被探測到)。在處理過程重新遇到該節點時,就根據其子節點的值來計算出該節點的回溯值。是取這些之節點中的最大值還是最小值作為回溯值,這取決於輪到哪一方下棋。這時所有的子節點就不再有用了,可以刪除掉。程式過程又回到剛剛賦值的親節點上,再繼續上述過程。
除了那些正處於擴充套件路徑上的子結點以外,我們不需要儲存任何別的子節點。我們對每一節點只標出其部分回溯值。在該節點產生時,該值作為節點的初始值;在以後節點每次被重新訪問時,該值都會被更新。在任何時候,一個節點的部分回溯值總是其計算出來的子節點中的最大值或最小值。當一個節點的所有子節點的值都計算出來以後,其部分回溯值就作為該節點的值,也就是所有子節點中的最大值或者最小值。
對於輪到棋手下棋的一個節點來說,其部分回溯值一開始就給定為一個很大的負數。當重新訪問到這個節點時,就要把這個值同剛剛計算出來的子節點的值進行比較,取它們中間大的那個作為該節點的新的回溯值。
對於輪到對手下棋的一個節點來說,其部分回溯值一開始就給定為一個很大的正數。當重新訪問到這個節點時,就要把這個值同剛剛計算出來的時間點的值進行比較,取它們中間小的那個作為該節點的新的回溯值。
在我們需要儲存的任何時刻,應該儲存的節點僅僅是從根節點到該時刻當前節點之間的路徑上的那些節點。把這些節點儲存到堆疊中是非常方便的。在堆疊中,根節點在底部,當前節點在頂部。
現在我們來看看博弈樹節點標註的另一種方法:最小最大值方法。整個博弈樹儘管大的出奇,然而在只有一部分有用的情況下,利用最小最大值方法是有其優點的,很容易推廣使用。
比方說,競賽的結果是以錢為賭注的。為方便起見,設賭金為一塊錢。如果棋手贏的,他就獲得一塊錢;如果他輸了,這輸一塊錢。在和局的情況下,他不輸也不贏。
我們把棋手贏的錢稱之為收益。如果棋手贏了,其收益為1;如果輸了,收益為-1;和局時為0。
現在我們來定義一個節點的值。對於w節點其收益為1;l為-1;d為0。
我們用不著一定要先把節點標上w、d或l,然後再去註明它們的值,而是可以直接計算出它們的值。
前面我們已經知道,對於每一個葉節點,比賽的結果或者是贏、或者是輸、或者是和局。因此我們先把比賽能贏的那些葉節點標上1,把和局的節點標上0,把輸的節點標上-1。
如同我們前面說過的那樣。從葉節點開始,其餘節點按照其子節點的值來計算出它的值,一直到把根結點的值算出來。
這種計算後面所包含的想法是:棋手總是選擇能得到最大收益的棋步;而對手總是選擇通往最小收益的棋步。更確切地說,我們按下面的規則來計算每個節點的值:
輪到棋手走時,節點的值是其子節點之中的最大值。
輪到對手走時,節點的值為其子節點中的最小值。
到現在為止,最大最小值方法已經給我們提供了一個簡明而有效的手段來標註博弈樹的節點。然而,這種方法不是採用任意的標號,如前面所說的w、l或d來標註節點,而是用數字來標註。這樣做使得我們能夠很容易的推廣這個方法,不用把節點的值侷限於1、0或-1。
深度優先的最小最大值評價方法
現在我們回頭來看看棋手對當前的棋局是如何考慮的,以及應如何決定下一步的走法。假設有了一種方法,它對任何棋局都可以給出所有的合法棋步,同時還能給出採用這些合法棋步中任何一步以後所形成的新的棋局。另外我們假設已經有了上面所談到判斷終局的方法。我們希望能決定棋手應走的棋步。
我們從當前的棋局開始,將其擴充套件,然後再擴充套件它的子棋局,如此類推。這樣我們就得到了一個部分博弈樹。但是,每一個結點在展開之前,我們都要先檢測這個節點是否為終局。如果是終局那麼就不展開這個節點;如果不是那麼就展開它。
上述的擴充套件一直進行下去,直到所有的節點都不能再展開為止。最後我們得到了這樣一個博弈樹,它的根結點對應於當前的棋局而其葉節點對應於我們認為的終局。
應用最小最大值方法,我們對該博弈樹的每一個結點都標註上一個值。如前所述,棋手所要走的節點,它的值是其所有子節點中的最大值;而對手所走的節點,它的值是其所有子節點中的最小值。由於這些節點的計算方法本身的原故,那些非葉節點所標註的值通常稱為回溯值。
對棋手來說,他所選擇的最佳棋步就是能夠得到具有最大回溯值棋局的棋步。
在對手的下一步棋走完以後,那麼就要從這時的當前棋局開始建造一個新的博弈樹。這一過程會不斷重複。因此,每一個部分博弈樹實際上只是用來決定一步棋。真正用到的值僅僅是根的子節點的回溯值。這些子節點所對應的棋局就是棋手自由選擇棋步所能走到的棋局。
實際上,我們的確用不著一次就把整個博弈樹儲存起來。如果運用一個叫做深度優先的最小最大值方法,那麼在只儲存整個博弈樹的很小一部分的情況下,我們也能計算出根的子節點的值。當節點需要計算其值時,我們就產生這個節點;當節點已經完成它的使命之後,我們便刪除這個節點。
同所有的深度優先方法一樣,深度優先的最小最大值方法是從根部開始處理,沿著從親節點到子節點的方向進行,直到遇到終局為止。然後再返回,一直到找到另一條未曾探索的路徑,再沿著這條路徑到終局為止,如此類推。
這一方法可以同時既建立博弈樹,又對其節點進行估值計算。在沿著從親節點到子節點的方向採用這一方法時,節點是展開的。當節點第一次展開時,只新建了一個子節點。其他子節點只有在過程返回並重新訪問該節點時才建立。只有那些處於當前正在搜尋路徑上的子節點以及那些尚未標註回溯值的子節點才被儲存起來。
當採用這種方法達到終節點時,就使用評價函式來處理該終節點所對應的棋局,並對該節點標上評價值。然後,這種過程又回溯到終結點的新節點上去。
如前所述,在回溯期間如果訪問到某個節點,通常要對該節點繼續進行擴充套件,產生一個新的子節點,進而在對該新的子節點進行處理。實際上,該節點的所有子節點最終都會產生出來(即相應的棋局的所有合法棋步都會被探測到)。在處理過程重新遇到該節點時,就根據其子節點的值來計算出該節點的回溯值。是取這些之節點中的最大值還是最小值作為回溯值,這取決於輪到哪一方下棋。這時所有的子節點就不再有用了,可以刪除掉。程式過程又回到剛剛賦值的親節點上,再繼續上述過程。
除了那些正處於擴充套件路徑上的子結點以外,我們不需要儲存任何別的子節點。我們對每一節點只標出其部分回溯值。在該節點產生時,該值作為節點的初始值;在以後節點每次被重新訪問時,該值都會被更新。在任何時候,一個節點的部分回溯值總是其計算出來的子節點中的最大值或最小值。當一個節點的所有子節點的值都計算出來以後,其部分回溯值就作為該節點的值,也就是所有子節點中的最大值或者最小值。
對於輪到棋手下棋的一個節點來說,其部分回溯值一開始就給定為一個很大的負數。當重新訪問到這個節點時,就要把這個值同剛剛計算出來的子節點的值進行比較,取它們中間大的那個作為該節點的新的回溯值。
對於輪到對手下棋的一個節點來說,其部分回溯值一開始就給定為一個很大的正數。當重新訪問到這個節點時,就要把這個值同剛剛計算出來的時間點的值進行比較,取它們中間小的那個作為該節點的新的回溯值。
在我們需要儲存的任何時刻,應該儲存的節點僅僅是從根節點到該時刻當前節點之間的路徑上的那些節點。把這些節點儲存到堆疊中是非常方便的。在堆疊中,根節點在底部,當前節點在頂部。
/* 最小值最大值演算法: 電腦假走一步,人再假走一步,電腦通過計算人假走一步以後得到的最低分(getMinScore), 然後在這些最低分中取一個最高分(getMaxScore),作為最後此時的局面分。 因為很顯然,電腦要走的最好那步是 讓使用者 局面分 最低的那步。 也就是說,電腦在和人博弈時,電腦想想走局面分最高的那一步,但是走完這一步以後有可能會讓 使用者有更高局面分的選擇,所以就要把 下下步使用者最低局面分 作為 自己的下步最高局面分。 詳解: level(1):第一局面 --->取 “最大值” level(2):第二局面(1) 第二局面(2) 第二局面(3) --->取 “最小值” level(3):第二局面(1—1,10分)、第二局面(1—2,100分) 第二局面(3—1,20分)第二局面(3—1,30分) 電腦走,假設會出現以下三個局面,在第二局面中,局面分是由分支傳上來的,電腦需要在這些局面分中取 “最大值” 上面電腦走完以後,人走,可能出現以下局面,這就需要在所有子樹中求取 “最小值”,作為上層父節點的局面分 比如:你發現了一個小偷偷東西(假設偷了三個包裹),小偷和你達成一個協議(你和小偷博弈)讓你放他走,協議是:這三個包裹的東西你都可以看見, 一號包:1元、100元,二號包:20元、30元,三號包:5元,60元,然後你選一個包,但是由小偷給你從包裡拿東西。很明顯,你會選擇二號包, 也很明顯,小偷會選擇給你那個20元! 也就是說,在象棋博弈時,假設人也很聰明,當電腦走完以後,人會走讓電腦局面分低的那步。 比如上面圖解情況: 在(1)的子樹下1-1和1-2是10分、100分,取10分作為父節點(1)的分數; 在(3)的子樹下3-1和3-2是20分、 30分,取20分作為父節點(3)的分數; 然後在(1)和(3)中取最大值20分,所以電腦走的是(3)這個分支!也就是說,人你再聰明,電腦還是的20分。很簡單,如果電腦走(1),那麼,人就會 走給它10分的那條路,顯然,這不是getBestMove! */ /* 剪枝優化:將同級的前一分支的結果(curMin、curMax)傳到下一分支,以判斷是否還需要繼續計算比較! 比如上例:計算完level(2)的(1)以後得到的結果是10分,所以在計算level(2)的(3)的第一分支時得到20,以後就不再計算,直接刪掉此路。 */ int SingleGame::getMinScore(int level, int curMin) { if(level == 0) return calcScore(); //間接遞迴的結束條件,並計算一個局面分 QVector<Step*> steps; getAllPossibleMove(steps); //紅棋的getAllPossibleMove int minInAllMaxScore = 300000; while(steps.count()) { Step* step = steps.last(); steps.removeLast(); fakeMove(step); //傳入minInAllMaxScore,進行剪枝 int maxScore = getMaxScore(level-1, minInAllMaxScore);//getMinScore呼叫getMaxScoreget——間接遞迴 unfakeMove(step); delete step; //剪枝優化 if(maxScore <= curMin) //新增 = 以後的提升程式的效率!原因:在象棋博弈的時候,很少有吃棋的動作,基本上都是走棋!所以局面分相等的情況很多! { while(steps.count()) //避免記憶體洩露 { Step* step = steps.last(); steps.removeLast(); delete step; } return maxScore; //當在下層後面分支(分支的分支)出現更小的值時(必然不可能走),直接return!後面的分支不用再計算,提高速度! } if(maxScore < minInAllMaxScore) { minInAllMaxScore = maxScore; } } return minInAllMaxScore; } int SingleGame::getMaxScore(int level, int curMax) //level控制間接遞迴的層數 { if(level == 0) return calcScore(); //間接遞迴的結束條件 QVector<Step*> steps; getAllPossibleMove(steps); int maxInAllMinScore = -300000; while(steps.count()) { Step* step = steps.last(); steps.removeLast(); fakeMove(step); //傳入minInAllMaxScore,進行剪枝 int minScore = getMinScore(level-1, maxInAllMinScore);//getMaxScoreget呼叫getMinScore unfakeMove(step); delete step; //剪枝優化 if(minScore >= curMax) //新增 = 以後的提升程式的效率!原因:在象棋博弈的時候,很少有吃棋的動作,基本上都是走棋!所以局面分相等的情況很多! { while(steps.count()) //避免記憶體洩露 { Step* step = steps.last(); steps.removeLast(); delete step; } return minScore; //當後面分支出現較大值時(必然不可能走),直接return!後面的分支不用再計算,提高速度! } if(minScore > maxInAllMinScore) { maxInAllMinScore = minScore; } } return maxInAllMinScore; }