1. 题目背景与核心问题解析
P9979 [USACO23DEC] Target Practice S 是USACO 2023年12月白银组的一道动态规划经典题目。这道题在竞赛圈被称为"靶场练习",考察的是选手对动态规划状态设计和转移方程的构建能力。
题目场景设定在一个靶场,有n个靶子排成一排,每个靶子有对应的得分值。玩家可以进行k次射击,每次可以选择任意一个靶子射击,但连续射击同一个靶子会导致得分递减。具体规则是:
- 第一次射击某靶子获得完整分数
- 第二次连续射击同一靶子得分减半(向下取整)
- 第三次及以后连续射击同一靶子不得分
这个问题本质上是一个带约束条件的最优化问题,需要在有限的射击次数内,通过合理的靶子选择顺序,使得总得分最大化。这与经典的"采药问题"或"股票买卖问题"有相似之处,但增加了状态依赖的得分衰减这一特殊约束。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动态规划状态设计
2.1 基础状态定义
对于这类有明显阶段特征的优化问题,我们采用动态规划来解决。定义dp[i][j][l]表示:
- 前i个靶子
- 使用了j次射击机会
- 最后射击的是第l个靶子(l=0表示尚未开始射击)
这种三维状态设计能够完整记录问题的所有关键信息。其中第三维l用于记录最后射击的靶子编号,这对处理连续射击的惩罚机制至关重要。
2.2 状态转移方程
状态转移需要考虑以下几种情况:
- 初始状态:dp[0][0][0] = 0(未射击任何靶子,得分为0)
- 对于每个靶子i,考虑是否射击它:
- 如果不射击:dp[i][j][l] = dp[i-1][j][l]
- 如果射击:
- 如果上次射击的不是当前靶子:得分增加完整值
- 如果上次射击的是当前靶子:根据连续次数计算衰减得分
具体转移方程可以表示为:
code复制dp[i][j][l] = max(
dp[i-1][j][l], // 不选当前靶子
dp[i-1][j-1][k] + score(i, l==i) // 选当前靶子,k表示上次射击的靶子
)
2.3 得分计算函数
score函数需要根据连续射击次数计算当前得分:
cpp复制int score(int target, bool same_as_last) {
if (!same_as_last) return targets[target];
if (consecutive[target] == 1) return targets[target] / 2;
return 0;
}
3. 算法优化与实现细节
3.1 空间优化技巧
原始三维DP的空间复杂度是O(nkn),对于较大的n和k可能超出内存限制。我们可以观察到每次转移只依赖前一行(i-1)的数据,因此可以优化为二维滚动数组:
cpp复制int dp[2][K+1][N+1]; // 使用滚动数组
3.2 边界条件处理
需要特别注意以下边界情况:
- 射击次数j不能超过总次数k
- 不能从未射击状态直接转移到连续射击状态
- 得分衰减的计算要准确处理整数除法
3.3 完整C++实现
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
const int MAXK = 1005;
int n, k;
int targets[MAXN];
int dp[2][MAXK][MAXN];
int main() {
cin >> n >> k;
for (int i = 1; i <= n; ++i) {
cin >> targets[i];
}
memset(dp, -1, sizeof(dp));
dp[0][0][0] = 0;
for (int i = 1; i <= n; ++i) {
int curr = i % 2;
int prev = 1 - curr;
memset(dp[curr], -1, sizeof(dp[curr]));
for (int j = 0; j <= k; ++j) {
for (int l = 0; l <= n; ++l) {
if (dp[prev][j][l] == -1) continue;
// 不选当前靶子
if (dp[curr][j][l] < dp[prev][j][l]) {
dp[curr][j][l] = dp[prev][j][l];
}
// 选当前靶子
if (j < k) {
int new_score = dp[prev][j][l];
if (l == i) { // 连续射击同一靶子
int consecutive = ...; // 需要额外记录连续次数
if (consecutive == 1) new_score += targets[i] / 2;
// 第三次及以上不得分
} else {
new_score += targets[i];
}
if (dp[curr][j+1][i] < new_score) {
dp[curr][j+1][i] = new_score;
}
}
}
}
}
int max_score = 0;
for (int j = 0; j <= k; ++j) {
for (int l = 0; l <= n; ++l) {
max_score = max(max_score, dp[n%2][j][l]);
}
}
cout << max_score << endl;
return 0;
}
4. 复杂度分析与优化方向
4.1 时间复杂度分析
基础DP实现的时间复杂度是O(n²k),因为:
- 外层循环n个靶子
- 中层循环k次射击
- 内层循环n种最后射击状态
对于USACO白银组的数据规模(n,k≤1000),这个复杂度是勉强可以接受的,但可能在大数据时面临时间压力。
4.2 进一步优化思路
- 状态压缩:可以尝试将最后射击的靶子状态用位运算表示,减少空间占用
- 贪心优化:在某些特殊情况下,可以优先射击高分靶子,减少状态转移次数
- 记忆化搜索:改用自顶向下的记忆化搜索实现,可能减少不必要的状态计算
4.3 实际竞赛中的取舍
在编程竞赛中,需要根据数据范围选择合适的实现方式:
- 小数据(n,k≤100):可以使用标准三维DP
- 中等数据(n,k≤1000):应该使用滚动数组优化
- 大数据(n,k≤1e5):需要寻找线性或nlogn的贪心算法
5. 常见错误与调试技巧
5.1 典型错误模式
- 状态设计不完整:忘记记录最后射击的靶子,导致无法正确处理得分衰减
- 初始化错误:未正确设置初始状态或初始值为极小值
- 边界条件遗漏:未处理射击次数用完或靶子用完的情况
- 整数溢出:得分累加时未使用足够大的数据类型
5.2 调试建议
- 先在小数据上手动计算验证
- 打印DP表中间状态检查转移是否正确
- 使用assert检查数组越界
- 对比暴力搜索的结果(对于小数据)
5.3 测试用例设计
设计测试用例时应考虑:
- 所有靶子相同分数
- 靶子分数差异极大
- 射击次数等于靶子数量
- 射击次数远多于或少于靶子数量
- 极端情况(n=1或k=1)
例如:
code复制// 测试用例1
3 2
5 10 5
// 最优策略:射击10两次,得分10+5=15
// 测试用例2
4 3
8 4 2 1
// 最优策略:射击8两次和4一次,得分8+4+4=16
6. 动态规划问题的通用解题框架
通过这道题,我们可以总结解决动态规划问题的一般步骤:
- 问题分析:识别最优子结构和重叠子问题
- 状态定义:确定需要记录哪些信息才能完整描述问题状态
- 转移方程:明确状态之间的转移关系和边界条件
- 初始化:设置初始状态的合理值
- 计算顺序:确定填表的顺序(自底向上或自顶向下)
- 结果提取:从最终状态中提取所需答案
- 优化:考虑空间优化或其他剪枝策略
对于USACO等编程竞赛,建议建立自己的动态规划解题模板,包含常用优化技巧和调试工具,这样可以快速应对比赛中的各种变种题目。
