浅谈博弈 DP

本文章同步发表在洛谷博客


什么是博弈 DP?

博弈,是一个多名玩家参与的竞争游戏。每次只允许一个人行动,并且通常采用轮流行动的方式。

每个人的目标都是在游戏中获胜,并且题目一般会假定所有人都足够聪明,都会采用最优策略。

一般的获胜或者失败条件,可能是分数达到一定值(或者最大或者最小),也可能是运转到某个局面。

大部分题目都只有 A 和 B 两名玩家,此时结果只有以下三种:

  • A 赢了,B 输了。
  • A 输了,B 赢了。
  • A 和 B 平局了。

博弈 DP 通常的状态设计

状态中一般需要包含游戏当前的局面描述,以及接下来即将行动的玩家编号(可用 \(0\)\(1\) 代表)。如果获胜情况与得分有关,状态设计中也需要包含玩家的得分。当前局面的结果就是当前状态所存储的东西。

一般将当前状态下即将行动的玩家称为先手,对方称为后手,并基于先手视角描述游戏结果。此时结果分为先手必胜、先手必败和平局三种。可分别用 \(0\)\(1\)\(2\) 描述。

需要注意的一点是,在局面变化的时候,先手角色可能变也可能不变,这与游戏规则密切相关。

博弈的两种常见类型

公平博弈

公平博弈,顾名思义就是一种特别公平的博弈。

具体来说,就是每名玩家可以进行的操作是完全相同的,比如取石子,每名玩家能取的颗数都是一样的。

这个时候,所有玩家之间没有区别,状态中可以省略先手这个属性,并且此时也只能基于先手视角描述结果。

零和博弈

零和博弈,表示的就是所有玩家的总收益是不变的。

也就是说,如果 A 玩家的分数多了 \(x\) 分,那么 B 玩家的分数就会少 \(x\) 分。又拿取石子来举例,如果 A 拿走了 \(x\) 颗石子,那么 B 就永远拿不到 A 拿走的这 \(x\) 颗石子了。

在这种情况下,可以将玩家得分这两个甚至三个状态合并成一个状态,例如先手得分,或是得分差值。

例题一:CF859C Pie Rules

注意到这题既是公平博弈,也是零和博弈。

定义 \(dp_i\) 表示先手当前的最大整数和。

转移方向是倒着的,即从 \(n\) 枚举到 \(1\)

为什么要这么干?因为我们只能确定在第一个数的时候,先手为 B,但是之后的所有数的先后手顺序就都没有记录了。因此这样会更加方便。

接下来考虑如何转移。如果先手决定将当前数分配给后手,则整数和与先手都不变,那么转移式为 $dp_{i} = dp_{i+1} $。如果先手决定自己占有当前数,由于先手变成了对方,整数和在转移的时候变成了补集,要做一下减法,那么转移式为 \(dp_{i} = sum - dp_{i+1} + a_i\)。其中 \(sum\) 为后缀和(不包括 \(a_i\)),可以在转移的时候实时推算。

结合一下上面的两个转移式,得出最终转移式——\(dp_{i} = \max ( dp_{i+1} , sum - dp_{i+1} + a_i )\)。当然,转移完之后要记得更新 \(sum = sum + a_i\) 哦。

最终的答案也比较明了了,A 的最大和是 \(sum - dp_1\),而 B 的最大和就是 \(dp_1\)

代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 105;
int n,a[N],dp[N],sum;
int main(){
    cin>>n;
    for(int i=1;i<=n;i++)cin>>a[i];
    for(int i=n;i>=1;i--){
        dp[i]=max(dp[i+1],sum-dp[i+1]+a[i]);
        sum+=a[i];
    }
    cout<<sum-dp[1]<<" "<<dp[1]<<"\n";
    return 0;
}

特别短,对吧?

例题二:AT_dp_k Stones

这个问题跟零和博弈关系不大(毕竟最终的获胜条件和石子总数没有关系),但这个问题是公平博弈。

定义 \(dp_i\) 表示当前石子数 \(i\) 的时候,先手处于必胜状态还是必败状态。

由于先手在不断变化,因此如果要从 \(dp_j\) 转移到 \(dp_i\),那么 \(dp_i\) 的值和 \(dp_j\) 肯定是反的。因为如果在 \(j\) 状态下,那时的先手必胜,那么转到 \(i\) 状态下,先手成了另一人,那他就是必败了。反过来同理。

当然啦,两人都会采用最优策略,所以只要有一条路能走通(使自己必胜,即达到先手必胜状态),他在这一步肯定就必胜啦。

至于具体怎么转移,这个比较显然。可以先枚举 \(i\)\(1\)\(k\),然后再用 \(j\) 枚举可以取的石子个数(遍历 \(a\) 数组的所有数),转移的时候直接 dp[i]|=(i>=a[j]&&!dp[i-a[j]]) 即可。

代码:

#include<bits/stdc++.h>
using namespace std;
const int K = 1e5+5;
int n,k,a[105];bool dp[K];
int main(){
    cin>>n>>k;
    for(int i=1;i<=n;i++)cin>>a[i];
    for(int i=1;i<=k;i++)
        for(int j=1;j<=n;j++)
            dp[i]|=(i>=a[j]&&!dp[i-a[j]]);
    cout<<(dp[k]?"First\n":"Second\n");
    return 0;
}

也特别短,对吧?

例题三:CF786A Berzerk

这个题目既不是公平博弈(两个人可以移动的数字集合是不一样的),也不是零和博弈(并没有涉及取数之类的操作)。

考虑定义 \(dp_{opt,i}\) 表示当前先手为 \(opt\)(用 \(0\)\(1\) 分别代表两位玩家)并且怪物所在位置为 \(i\) 的情况下,是先手必败、先手必胜还是平局(分别用 \(0\)\(1\)\(2\) 表示)。

由于可能有环,直接 DP 不大好弄,可以使用类似于拓扑排序的方法,走拓扑序扩散型转移。

具体来说嘛,就是跑一个普通的 BFS,每次枚举让怪物移动的步数,然后计算出怪物移动后所待在的位置。当然啦,如果枚举到的这个状态已经被转移过了就没必要再浪费时间了。否则的话,如果可以走必胜之路,那么赶紧记录一下,并将新的数值塞进队列;要么就是已经遍历了它的所有可能的来源了,实在是没有一条必胜之路,那它也肯定必败了;还有别的情况,目前就暂时不需要考虑,当它无限循环就是。

由于这玩意儿是一个环,为了取模更加方便,我就将点编号为 \(0 \sim n-1\) 了,其中 \(0\) 就是原题面所说的黑洞。

代码:

#include<bits/stdc++.h>
#define pb push_back
#define pii pair<int,int>
#define fr first
#define se second
using namespace std;
const int N = 7005;
int n,d[2][N],dp[2][N];
vector<int> p[2];queue<pii> q;
int main(){
    cin>>n;
    for(int i=0;i<=1;i++){
        int cnt;cin>>cnt;
        while(cnt--){int x;cin>>x;p[i].pb(x);}
        for(int j=0;j<n;j++)d[i][j]=p[i].size();
    }
    for(int i=0;i<n;i++)dp[0][i]=2,dp[1][i]=2;
    dp[0][0]=0,dp[1][0]=0;q.push({0,0}),q.push({1,0});
    while(!q.empty()){
        auto [opt,u]=q.front();q.pop();
        for(int v:p[!opt]){
            int to=(u-v+n)%n;if(dp[!opt][to]!=2)continue;
            if(!dp[opt][u])dp[!opt][to]=1,q.push({!opt,to});
            else if(!--d[!opt][to])dp[!opt][to]=0,q.push({!opt,to});else;
        }
    }
    for(int o=0;o<=1;cout<<"\n"&&o++)for(int i=1;i<n;i++)
        if(!dp[o][i])cout<<"Lose ";else if(dp[o][i]==1)cout<<"Win ";else cout<<"Loop ";
    return 0;
}

例题四:CF1987D World is Mine

注意到这个题目如果没有重复的数字那么直接交替吃就成了。

但是在有重复的情况下,就要麻烦很多。

A 想要吃这一层级的数字只需要耗费一轮的时间,而 B 如果想拦住 A 吃这一层级的数字需要耗费这个数字出现次数轮。

因此 B 要通过合理的取舍才能最小化 A 吃的蛋糕块数。所以他需要攒一些轮数来吃蛋糕以抵挡 A。

先把所有数字进行一遍类似于桶排序和离散化的东西,将其分为 \(Cnt\) 个组,每组的所有数字都是相同的,并且第 \(i\) 组有 \(c_i\) 个数字。

定义 \(dp_{i,j}\) 表示当前考虑了前 \(i\) 组数字,并且 B 目前攒了 \(j\) 轮时间的情况下,A 吃的蛋糕的最小块数。

转移的时候考虑两种情况。第一种情况就是当前轮的次数继续攒,也不去妨碍 A 吃第 \(i\) 组蛋糕,那么 \(dp_{i,j} = dp_{i-1,j-1} + 1\)。还有一种情况就是 B 这一轮要发力了,借助之前攒下的轮数,把第 \(i\) 组蛋糕(总共 \(c_i\) 个)全部吃掉,那么 \(dp_{i,j} = dp_{i-1 , j+c_i}\)。不过注意只有在 \(j + c_i < i\) 的情况下才能这样做。然后对以上两种情况取 \(\min\) 即可。

最后的答案就是 \(dp_{Cnt,0}\)。为什么?首先肯定要考虑所有组数字,对吧,并且 B 不能在最后还剩下轮数了,得把它们全部花光!

代码:

#include<bits/stdc++.h>
using namespace std;
const int N = 5005;
int T,n,a[N],tmp[N],Cnt,c[N],dp[N][N];
int main(){
    cin>>T;
    while(T--){
        cin>>n;for(int i=1;i<=n;i++)cin>>a[i];
        sort(a+1,a+n+1);for(int i=1;i<=n;i++)tmp[i]=a[i];
        Cnt=unique(tmp+1,tmp+n+1)-tmp-1;
        for(int i=1;i<=n;i++)a[i]=lower_bound(tmp+1,tmp+Cnt+1,a[i])-tmp;
        for(int i=1;i<=n;i++)c[i]=0;for(int i=1;i<=n;i++)c[a[i]]++;
        for(int i=1;i<=Cnt;i++)for(int j=0;j<=i;j++){
            dp[i][j]=dp[i-1][max(0,j-1)]+1;
            if(j+c[i]<i)dp[i][j]=min(dp[i][j],dp[i-1][j+c[i]]);
        }
        cout<<dp[Cnt][0]<<"\n";
    }
    return 0;
}

简单总结概括一下

博弈 DP 这个东西,难就难在以下四点:

  1. DP 状态的设计:既要全面描述当前的局面,又要方便转移和计算最终结果,还不能定义过多维导致空间浪费。
  2. 转移方程的考虑:一定要考虑周全所有的转移情况,转移式一般由多个部分构成,有多种情况能转移到当前状态(收集型),或者当前状态能扩展到多种状态(扩散型)。
  3. 状态转移的顺序与方向:这里的 DP 转移可不仅仅是直接正着枚举并转移,有的时候反过来枚举会更优更方便,有的时候甚至要动用 DFS、BFS 还有记忆化搜索来转移。
  4. 平局或无解的情况:大多博弈 DP 的题目都存在平局情况,甚至有的题目存在所谓的无解情况,这个时候要考虑判定,因为平局或者无解情况很容易将代码带入死循环中,必须加入一定的判断条件才能保证正确。

只要将这些东西全理清楚了,博弈 DP 的题目也就非常简单了。

码这么多字也不容易,还希望各位留个赞支持一下,真是太感谢啦!

posted @ 2025-08-09 15:42  嘎嘎喵  阅读(189)  评论(0)    收藏  举报