markdown复制## 1. 项目背景与需求解析
这道来自USACO 2017年1月银组赛题的"Hoof, Paper, Scissor G"(蹄子剪刀布G),本质上是一个博弈论与动态规划结合的经典题型。题目描述两个玩家进行N轮石头剪刀布游戏,其中一方(贝茜)的出拳序列固定,另一方(农夫约翰)可以改变不超过K次出拳策略,目标是让约翰获得最大胜利次数。
实际刷题中,这类题目考察的核心能力包括:
- 状态转移方程的构建能力
- 三维DP数组的空间优化技巧
- 博弈规则的数学建模能力
- 边界条件的处理意识
> 注意:USACO银组题目往往在基础算法中设置精妙的变化点,这道题看似是简单博弈,实则暗含对DP优化能力的考察。
## 2. 算法核心思路拆解
### 2.1 问题转化建模
将原问题转化为状态机模型:
- 状态维度1:当前轮次(1~N)
- 状态维度2:已使用变化次数(0~K)
- 状态维度3:当前出拳策略(H/P/S)
定义dp[i][j][k]表示:
- 进行到第i轮时
- 已经改变j次策略
- 当前策略为k(0=Hoof, 1=Paper, 2=Scissor)
时的最大胜场数
### 2.2 状态转移方程
对于每个状态dp[i][j][k],考虑两种可能:
1. 不改变策略:从dp[i-1][j][k]继承
2. 改变策略:从dp[i-1][j-1][m]转移(m≠k)
转移时需要比较当前策略k是否能击败贝茜第i轮的出拳:
```cpp
int win = (k == beats[bessie[i-1]]) ? 1 : 0;
dp[i][j][k] = max(
dp[i-1][j][k] + win, // 保持策略
dp[i-1][j-1][m] + win // 改变策略
);
其中beats[]数组预定义胜负关系:
cpp复制// beats[x]表示能击败x的策略
beats['H'] = 'S'; // 剪刀胜蹄子
beats['P'] = 'H'; // 蹄子胜布
beats['S'] = 'P'; // 布胜剪刀
3. 关键实现细节
3.1 输入处理优化
原始输入为N K后接N行字符,常规做法是:
cpp复制cin >> N >> K;
vector<char> bessie(N);
for(int i=0; i<N; ++i) cin >> bessie[i];
更高效的IO处理方式:
cpp复制ios::sync_with_stdio(false);
cin.tie(0);
char c;
while(cin >> c) {
if(c == 'H' || c == 'P' || c == 'S')
bessie.push_back(c);
}
3.2 DP数组初始化技巧
三维数组的两种实现方式对比:
- 原生三维数组(栈空间可能溢出):
cpp复制int dp[MAX_N][MAX_K][3];
- 滚动数组优化(推荐):
cpp复制int dp[2][MAX_K][3]; // 交替使用
初始化时需注意:
- 第0轮所有状态胜场为0
- 非法状态(j<0)需要特殊处理
3.3 空间复杂度优化
通过观察状态转移方程,可以发现:
- 当前轮次只依赖前一轮次
- 可采用滚动数组将空间从O(NK3)降到O(K*3)
实现关键:
cpp复制int now = i % 2, prev = 1 - now;
dp[now][j][k] = max(...);
4. 完整代码实现
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int N, K;
cin >> N >> K;
vector<char> bessie(N);
for(auto& c : bessie) cin >> c;
// beats[x] = 能击败x的策略
char beats[256]{};
beats['H'] = 'S';
beats['P'] = 'H';
beats['S'] = 'P';
// dp[轮次%2][改变次数][当前策略]
int dp[2][21][3] = {};
for(int i=1; i<=N; ++i) {
int now = i%2, prev = 1-now;
for(int j=0; j<=K; ++j) {
for(int k=0; k<3; ++k) {
char cur = (k==0)?'H':(k==1)?'P':'S';
int win = (cur == beats[bessie[i-1]]);
// 不改变策略的情况
dp[now][j][k] = dp[prev][j][k] + win;
// 尝试改变策略(j>0时)
if(j > 0) {
for(int m=0; m<3; ++m) {
if(m == k) continue;
dp[now][j][k] = max(dp[now][j][k],
dp[prev][j-1][m] + win);
}
}
}
}
}
int ans = 0;
for(int k=0; k<3; ++k)
ans = max(ans, dp[N%2][K][k]);
cout << ans << endl;
return 0;
}
5. 性能优化与测试用例
5.1 时间复杂度分析
- 三重循环:O(NK3*3) ≈ O(9NK)
- N≤1e5, K≤20时完全可接受
5.2 典型测试案例
text复制// 样例输入1
5 1
P
P
H
P
S
// 样例输出1
4
边界测试案例:
text复制// 全胜无需改变
3 0
H
S
P
// 输出3
5.3 调试技巧
- 打印DP表快照:
cpp复制if(i < 5) { // 打印前5轮DP状态
cout << "Round " << i << ":\n";
for(int j=0;j<=K;++j)
cout << dp[now][j][0] << " "
<< dp[now][j][1] << " "
<< dp[now][j][2] << endl;
}
- 使用assert检查非法状态:
cpp复制assert(j >=0 && j <=K);
assert(k >=0 && k <3);
6. 常见错误与修正方案
6.1 初始化不全
错误表现:输出结果比预期小
修正方法:
cpp复制// 正确初始化方式
memset(dp, 0, sizeof dp);
for(int k=0; k<3; ++k)
dp[0][0][k] = 0;
6.2 状态转移遗漏
典型错误:忘记考虑保持策略的情况
正确写法:
cpp复制dp[now][j][k] = dp[prev][j][k] + win; // 必须放在改变策略前
6.3 数组越界
易错点:j-1可能为负值
防御性编程:
cpp复制if(j > 0) { // 确保j-1有效
for(int m=0; m<3; ++m) {
if(m == k) continue;
dp[now][j][k] = max(...);
}
}
7. 算法扩展思考
7.1 变种1:限制连续改变次数
若题目增加限制:"连续改变策略不超过M次",需增加状态维度:
cpp复制dp[i][j][k][l] // l表示当前连续改变次数
7.2 变种2:概率化版本
贝茜的出拳有一定概率分布,则需使用期望DP:
cpp复制double dp[i][j][k]; // 存储期望胜场
7.3 记忆化搜索实现
部分选手更习惯DFS+记忆化:
cpp复制int memo[N][K][3];
int dfs(int i, int j, int k) {
if(i == N) return 0;
if(memo[i][j][k] != -1) return memo[i][j][k];
int res = dfs(i+1, j, k) + win(i,k);
if(j > 0) {
for(int m=0; m<3; ++m)
res = max(res, dfs(i+1,j-1,m)+win(i,m));
}
return memo[i][j][k] = res;
}
在实际刷题训练中,建议先用标准DP写法通过后,再尝试其他实现方式以加深理解。这道题的经典之处在于将看似简单的游戏规则转化为严谨的状态转移模型,对培养抽象建模能力非常有帮助。
code复制