三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

从USACO P1353看动态规划:状态机DP与序列决策优化

从USACO P1353看动态规划:状态机DP与序列决策优化

1. 项目概述:从一道USACO经典题看信奥刷题的“道”与“术”

最近在带学生刷信奥题,又翻到了USACO的这道P1353 “Running S”。这题在洛谷上被标为“普及/提高-”,但不少刚接触动态规划(DP)的同学,第一次看到它还是会有点懵。题目大意是模拟一个奶牛Bessie的跑步计划:她有N分钟的时间,每分钟可以选择跑步或者休息。跑步会消耗体力,但能获得“距离”收益;休息能恢复体力。每分钟的跑步收益还和连续跑步的分钟数有关,跑得越久,每分钟收益可能越低(模拟疲劳)。目标是在N分钟后,使得总跑步距离最大,同时要保证在任何时候体力值不能为负。

这题有意思的地方在于,它不像传统的背包DP那样直观。它融合了状态机DP的思想,并且“体力值”和“连续跑步时间”这两个维度的状态交织在一起,构成了一个典型的“二维状态DP”问题。很多同学刷题时,一看到“状态设计”就头疼,觉得无从下手。其实,这道题是一个绝佳的模板,吃透了它,一类关于“带状态转移的序列决策问题”就都有了思路。今天,我就结合这道题,拆解一下信奥刷题,尤其是用C++攻克USACO这类竞赛题时,我们应该关注的核心技术点、思考路径和实操技巧。这不仅仅是解一道题,更是梳理一种解决问题的方法论。

2. 核心思路拆解:如何将生活场景抽象为状态转移方程

拿到题目,尤其是USACO这种描述略显冗长(带点故事性)的题目,第一步不是急着写代码,而是去故事化,做数学抽象。我们先把题目里的“奶牛”、“跑步”、“休息”这些外壳剥掉,看看内核是什么。

2.1 问题本质抽象

我们有一个时间序列,共N个时间单位(分钟)。在每个时间点i(1 <= i <= N),我们需要做出一个决策:跑(用1表示)还是休(用0表示)。这个决策受两个资源约束:

  1. 体力值(M):初始为M。跑步每分钟消耗1点体力,休息每分钟恢复1点体力(直到上限M)。体力不能为负。
  2. 连续跑步疲劳效应:每分钟跑步获得的距离D_i,并不固定。它取决于你已经连续跑步了多少分钟。题目会给出一个数组D[1..K],其中D[j]表示当你已经连续跑步j分钟时,这一分钟跑步能获得的距离。显然,通常D[j]会随着j增大而减小或不变,模拟越跑越累。

目标是最大化N分钟后的总距离。

2.2 状态设计:找到“记忆点”

动态规划的核心是状态设计和状态转移。状态就是描述问题在某个“时刻”的“快照”,它必须包含所有做出未来决策所需的信息。对于本题,在i分钟结束时,我们需要知道哪些信息,才能决定第i+1分钟是跑是休?

  1. 当前体力值(m):这决定了下一分钟能否跑步(m > 0)。
  2. 当前连续跑步的分钟数(j):这决定了如果下一分钟继续跑,能获得多少收益D[j+1]

那么,一个最自然的状态定义就出来了:dp[i][j][m]表示在第i分钟结束时,已经连续跑步了j分钟,并且当前体力值为m的情况下,能获得的最大总距离。

这里有一个关键点:j代表的是“连续跑步”的分钟数。如果这一分钟是休息的,那么j就应该是0。所以,j和当前分钟的动作是强相关的。

2.3 状态转移:决策分析

有了状态,我们来看从i-1i分钟,状态如何变化。这取决于第i分钟的决定。

  • 第i分钟选择休息

    • 前提:无(任何时候都可以休息)。
    • 状态变化:连续跑步分钟数j归零。体力值m增加1(但不能超过初始值M)。
    • 转移方程:dp[i][0][m'] = max(dp[i][0][m'], dp[i-1][j][m] + 0)。其中m' = min(m + 1, M)。注意,i-1分钟的状态j可以是任意值(因为休息打断了连续跑步)。
  • 第i分钟选择跑步

    • 前提:当前体力m > 0
    • 状态变化:连续跑步分钟数j增加1(即从i-1状态的某个j_prev变为j = j_prev + 1)。体力值m减少1。
    • 收益:获得距离D[j](注意,这里的j是跑步后的连续分钟数)。
    • 转移方程:dp[i][j][m-1] = max(dp[i][j][m-1], dp[i-1][j-1][m] + D[j])。这里要求j >= 1

2.4 初始化与答案

初始化:在0分钟时,还没开始,可以认为连续跑步0分钟,体力为满M,总距离为0。即dp[0][0][M] = 0,其他状态为负无穷(表示不可达)。

答案:N分钟结束后,答案就是所有可能状态dp[N][j][m]中的最大值,其中jm可以是任意合法值。因为题目只要求最终总距离最大,不关心结束时是跑是休,也不关心剩余体力。

注意:这个三维DP(ixjxm)的思路非常直接,但空间和时间复杂度是O(N * K * M)。K是连续跑步的最大可能分钟数(其实不会超过N和M的较小值)。对于本题典型数据范围(N<=10000, M<=500),N*M*M可能会超时或超内存。这就需要我们进行优化,这也是本题从“普及”迈向“提高”的关键一步。

3. 算法优化:降维与状态精简的艺术

直接三维DP在数据量大时不可行。我们必须观察状态转移的特性,进行优化。这是信奥刷题中提升能力的关键环节——不仅要写出暴力解,更要能优化出正解。

3.1 优化一:滚动数组压缩空间

时间维度i的转移只依赖于i-1,这是使用滚动数组的经典场景。我们可以将dp数组的第一维大小设为2,交替使用。这样空间复杂度从O(N * K * M)降为O(2 * K * M),即O(K * M)。

3.2 优化二:状态定义的转化与精简

三维状态的核心是j(连续跑步时间)和m(体力)。它们之间存在一个非常重要的关系:当你连续跑步了j分钟,你的体力消耗了j点(假设初始满体力)吗?不对,因为中间可能穿插了休息,体力会恢复。

但我们可以从另一个角度思考。定义状态dp[i][j]为:在第i分钟结束时,已经连续跑步了j分钟(j>0),此时能获得的最大总距离。那么,此时的体力是多少?根据规则,跑步每分钟耗1点体力。如果这j分钟是连续跑下来的,那么体力就是M - j。但这里有个问题,如果中间有休息,体力恢复,这个关系就不成立了。

所以,我们需要一个更能反映本质的状态。让我们回到最初的约束:体力不能为负,且休息能恢复体力。这实际上意味着,在任意时刻,你的体力值等于初始体力M,减去从开始到现在的净跑步时间(总跑步时间 - 总休息时间)?不对,因为跑步和休息是交错的,净跑步时间不能直接决定当前体力。

看来,jm的耦合度很高。我们尝试用j来隐式表达m。考虑一个事实:在连续跑步期间,体力是持续下降的。一次连续跑步开始时的体力,决定了这段连续跑步能持续多久。我们可以定义状态dp[i][j]为:第i分钟结束时,且第i分钟在跑步,已经连续跑步了j分钟,能获得的最大总距离。那么,要满足这个状态,第i-j分钟(即这段连续跑步开始的前一分钟)一定是休息的(或者i-j=0,即从开头开始跑)。并且,这段连续跑步开始时的体力,必须至少为j(因为要连续消耗j点体力)。

那么,这个“开始时的体力”怎么求呢?它等于初始体力M,减去在时间[1, i-j]这个区间内的净跑步消耗。这又回到了一个复杂的历史求和问题。

3.3 正解思路:两种状态的分列DP

上述分析表明,同时精确追踪jm很麻烦。USACO官方题解和社区普遍采用一种更巧妙的双状态DP思路,这也是本题最精妙的地方。

我们定义两个数组:

  • dp_run[i][j]: 表示在第i分钟结束时,已经连续跑步了j分钟(即第i分钟在跑),能获得的最大总距离。
  • dp_rest[i]: 表示在第i分钟结束时,正在休息(即第i分钟在休息),能获得的最大总距离。

为什么这样定义是可行的?因为它把“连续跑步分钟数”这个信息,从体力值中解耦了出来,放到了dp_run的第二维j里。而体力约束,则通过j不能超过当前可用体力这个条件来体现。

状态转移:

  1. 从休息到跑步(开始一段新的连续跑步):第i分钟跑步,且连续跑步时长为1。

    • 来源:第i-1分钟在休息 (dp_rest[i-1])。
    • 条件:当前体力至少为1(这个条件在递推中通过j的范围控制,因为j从1开始,且j不能超过当前理论最大体力,但更精确的控制是,dp_run[i][1]只能从dp_rest[i-1]转移,而休息后体力至少为1)。
    • 转移:dp_run[i][1] = max(dp_run[i][1], dp_rest[i-1] + D[1])
  2. 继续跑步(延续一段连续跑步):第i分钟跑步,且连续跑步时长为j (j > 1)。

    • 来源:第i-1分钟也在跑步,且当时连续时长为j-1 (dp_run[i-1][j-1])。
    • 条件:j <= M(因为连续跑步j分钟需要消耗j点体力,初始体力为M,所以j最大为M)。这是体力约束的体现!
    • 转移:dp_run[i][j] = max(dp_run[i][j], dp_run[i-1][j-1] + D[j])
  3. 从跑步到休息(结束一段连续跑步):第i分钟休息。

    • 来源:第i-1分钟可以在任何状态(跑步或休息)。因为休息可以随时开始。
    • 但是,我们需要考虑的是,休息这一分钟本身没有收益。那么,dp_rest[i]应该取所有可能在第i分钟转为休息的状态中的最大值。
    • 具体来说,有两个来源:
      • 来源A:第i-1分钟就在休息,第i分钟继续休息。dp_rest[i] = max(dp_rest[i], dp_rest[i-1])
      • 来源B:第i-1分钟在跑步(连续时长为任意合法的j),第i分钟转为休息。dp_rest[i] = max(dp_rest[i], dp_run[i-1][j]), 对所有1 <= j <= M取最大值。

初始化与答案:初始化:dp_rest[0] = 0,表示0分钟时在休息,距离为0。dp_run[0][j]全部设为负无穷(或一个非常小的数),表示0分钟时不可能在跑步。 答案:第N分钟结束后,最大距离可以是休息状态,也可以是跑步状态(任意j)。所以答案是max(dp_rest[N], max_{j=1 to M}(dp_run[N][j]))

这个算法的复杂度是O(N * M),空间上dp_run是O(N * M),dp_rest是O(N)。结合滚动数组,空间可以优化到O(M)。这完全在题目数据范围内。

实操心得:这种“分状态列式”的DP思想非常实用。当单一状态难以同时表达多个有冲突或耦合的维度时,可以考虑将其拆分成几个互斥的状态,分别定义状态数组,并厘清它们之间的转移关系。这比强行用一个高维状态更清晰,也往往更容易优化。

4. C++代码实现与逐行解析

理解了最优算法,我们来看C++实现。这里我会给出两种版本的代码:第一种是直观但可能超时的三维DP(用于帮助理解),第二种是优化后的双状态DP正解。

4.1 版本一:三维DP(理解思路,非AC代码)

#include <iostream> #include <cstring> #include <algorithm> using namespace std; const int MAXN = 10005, MAXM = 505; const int INF = 0x3f3f3f3f; int D[MAXM]; // D[j]: 连续跑j分钟时的每分钟收益 int dp[2][MAXM][MAXM]; // 滚动数组: dp[now][j][m] int main() { int N, M; cin >> N >> M; for (int j = 1; j <= M; ++j) { cin >> D[j]; } // 初始化 memset(dp, -0x3f, sizeof(dp)); // 初始化为负无穷 int now = 0, pre = 1; dp[now][0][M] = 0; // 第0分钟,连续跑0分钟,体力M,距离0 for (int i = 1; i <= N; ++i) { swap(now, pre); // 滚动 memset(dp[now], -0x3f, sizeof(dp[now])); // 清空当前层 for (int j = 0; j <= min(i, M); ++j) { // 连续跑步分钟数 for (int m = 0; m <= M; ++m) { // 当前体力 int &prev_state = dp[pre][j][m]; if (prev_state < -INF / 2) continue; // 不可达状态 // 第i分钟选择休息 int new_m_rest = min(m + 1, M); dp[now][0][new_m_rest] = max(dp[now][0][new_m_rest], prev_state); // 第i分钟选择跑步 (需要体力>0) if (m > 0) { int new_j_run = j + 1; int new_m_run = m - 1; // 注意:跑步收益取决于跑步后的连续分钟数 new_j_run // 但D数组下标可能越界,题目中D只给到M,连续跑步超过M分钟后收益可能为0或按最后一项算 // 这里假设如果new_j_run > M,收益为D[M]或0,根据题目具体规定调整。 int gain = (new_j_run <= M) ? D[new_j_run] : 0; // 假设超过M后收益为0 dp[now][new_j_run][new_m_run] = max(dp[now][new_j_run][new_m_run], prev_state + gain); } } } // 实际上,上面的转移对于“休息”来源处理不完整,因为dp[pre][j][m]的j是上一分钟结束时的连续值。 // 当第i分钟休息时,上一分钟的j可以是任意值,我们只从dp[pre][j][m]转移到了dp[now][0][new_m]。 // 这本身是对的。但跑步转移时,我们是从dp[pre][j][m]转移到dp[now][j+1][m-1],要求j是上一分钟结束时的连续值。 // 这个三维DP的状态定义是“结束时连续跑了j分钟”,所以转移逻辑是自洽的。 } int ans = 0; for (int j = 0; j <= M; ++j) { for (int m = 0; m <= M; ++m) { ans = max(ans, dp[now][j][m]); } } cout << ans << endl; return 0; }

这个版本逻辑复杂,且状态转移容易写错,尤其是处理“休息后体力恢复”和“跑步收益与连续时间关系”时,下标处理很繁琐。更重要的是,它的复杂度是O(N * M^2),对于N=10000, M=500,运算次数高达25亿,必然超时。

4.2 版本二:双状态DP(AC正解)

#include <iostream> #include <cstring> #include <algorithm> using namespace std; const int MAXN = 10005, MAXM = 505; const int INF = 0x3f3f3f3f; int D[MAXM]; // D[j]: 连续跑j分钟时的每分钟收益 int dp_run[2][MAXM]; // dp_run[now][j]: 当前分钟在跑,且连续跑了j分钟的最大距离 int dp_rest[2]; // dp_rest[now]: 当前分钟在休息的最大距离 int main() { int N, M; cin >> N >> M; for (int j = 1; j <= M; ++j) { cin >> D[j]; } // 初始化 memset(dp_run, -0x3f, sizeof(dp_run)); // 初始化为负无穷,表示不可达 memset(dp_rest, -0x3f, sizeof(dp_rest)); int now = 0, pre = 1; dp_rest[now] = 0; // 第0分钟,在休息,距离为0 for (int i = 1; i <= N; ++i) { swap(now, pre); // 滚动数组交换 // 清空当前层(注意:dp_rest[now]会在转移中被更新,不能简单置为-INF) // 我们可以在每次转移前,将dp_run[now]初始化为-INF,dp_rest[now]从两个来源取max,所以先置为-INF也没问题 memset(dp_run[now], -0x3f, sizeof(dp_run[now])); dp_rest[now] = -INF; // 转移1: 从休息到跑步 (开始一段新的跑步) if (dp_rest[pre] > -INF/2) { // 如果上一分钟休息状态可达 // 这一分钟跑步,连续时长j=1 dp_run[now][1] = max(dp_run[now][1], dp_rest[pre] + D[1]); } // 转移2: 继续跑步 (延续上一分钟的跑步) for (int j = 2; j <= M; ++j) { // 连续时长从2到M if (dp_run[pre][j-1] > -INF/2) { // 上一分钟在跑,且连续时长为j-1 dp_run[now][j] = max(dp_run[now][j], dp_run[pre][j-1] + D[j]); } } // 注意:j=1的跑步状态,除了从休息转移来,也可能从上一分钟跑步但j=0转移?不,dp_run定义中j>=1。 // 所以j=1的跑步状态只有“从休息来”这一种转移。 // 转移3: 到休息状态 // 来源A: 上一分钟也在休息 dp_rest[now] = max(dp_rest[now], dp_rest[pre]); // 来源B: 上一分钟在跑步(任何连续时长j) for (int j = 1; j <= M; ++j) { if (dp_run[pre][j] > -INF/2) { dp_rest[now] = max(dp_rest[now], dp_run[pre][j]); } } } // 计算答案:第N分钟后的最大距离,可以是休息状态,也可以是跑步状态(任意j) int ans = dp_rest[now]; // 先取休息状态 for (int j = 1; j <= M; ++j) { ans = max(ans, dp_run[now][j]); } cout << ans << endl; return 0; }

代码关键点解析:

  1. 状态数组定义dp_run[2][MAXM]dp_rest[2]。使用滚动数组,nowpre指针交替。
  2. 初始化:第0分钟,只有休息状态是合法的,距离为0。所有跑步状态初始为负无穷(-INF)。
  3. 转移顺序:在每一分钟i的循环内,我们基于pre层(i-1分钟)的状态,计算now层(i分钟)的状态。注意要先计算dp_run[now],再计算dp_rest[now],因为dp_rest[now]的计算依赖于dp_run[pre],而不依赖于dp_run[now],所以顺序可以调整,但逻辑清晰更重要。
  4. 边界处理
    • dp_run[now][1]的转移:只能从dp_rest[pre]来,代表开始一段新的跑步。
    • dp_run[now][j] (j>=2)的转移:只能从dp_run[pre][j-1]来,代表继续跑步。
    • dp_rest[now]的转移:有两个来源,取最大值。
  5. 负无穷的使用:我们用-INF(一个很大的负数)表示状态不可达。在比较和转移时,需要判断状态是否可达(> -INF/2),避免负无穷参与运算导致错误。
  6. 答案获取:遍历所有可能终态取最大值。

这个算法的时间复杂度是O(N * M),空间复杂度是O(M),完美通过本题。

5. 调试与常见问题排查

即使思路正确,代码实现时也难免遇到问题。以下是调试这道题时常见的坑点和排查技巧。

5.1 样例无法通过

首先,一定要使用USACO或洛谷提供的样例进行测试。如果样例不过,检查以下几点:

  1. 输入读取:确认NMD[1..M]的读取是否正确。D数组的下标是从1开始到M。
  2. 初始化dp_rest[0]是否初始化为0?dp_run是否初始化为负无穷?滚动数组每轮是否正确清空了dp_run[now]
  3. 转移条件
    • dp_run[now][1] = dp_rest[pre] + D[1],这里是否用了dp_rest[pre]而不是dp_rest[now]
    • dp_run[now][j] = dp_run[pre][j-1] + D[j],循环j是否从2开始?D数组的下标j是否正确?
    • dp_rest[now]在取max时,是否同时考虑了dp_rest[pre]和所有dp_run[pre][j]
  4. 答案计算:最后是取max(dp_rest[now], max_j(dp_run[now][j])),不要漏掉休息状态。
  5. 数组大小MAXM是否足够大(至少为M+5)?dp_run的第二维是[MAXM],对应连续跑步分钟数jj最大为M

5.2 结果偏小

如果程序能运行,但结果比预期小,可能是状态转移时“取最大值”的逻辑有遗漏,或者某些状态的初始化值不对,导致最优解没有被传递下去。

  • 检查负无穷的值INF定义为0x3f3f3f3f是常见的做法,其值约为1e9。确保-INF在加减D[j]后不会溢出变成正数(本题距离总和不会太大,一般不会)。也可以使用-1e9
  • 检查所有转移来源:特别是dp_rest[now]的来源,是否漏掉了dp_rest[pre]?是否对所有j从1到M的dp_run[pre][j]都进行了比较?
  • 验证简单情况:可以手动构造小数据,比如N=3, M=2, D[1]=5, D[2]=3。然后模拟你的DP过程,看每一步的状态值是否正确。

5.3 超时或超内存

如果使用三维DP,大概率会超时。确保你使用的是双状态DP+滚动数组。

  • 空间dp_run[2][MAXM]dp_rest[2],这是正确的。
  • 时间:双循环是for i 1..Nfor j 2..M,复杂度O(N*M)。对于N=10000, M=500,是5e6次操作,完全在1秒内。

5.4 一个易错点:关于D数组的边界

题目中,D[j]j最大给到M。但是,连续跑步分钟数j可能超过M吗?在我们的状态定义dp_run[i][j]中,j的取值范围是1 <= j <= M。因为如果连续跑步分钟数j > M,需要的体力至少为j,这已经超过了初始体力M,所以是不可能的。因此,我们只需要D[1..M]。在转移dp_run[now][j] = dp_run[pre][j-1] + D[j]时,j最大为M,不会越界。

5.5 调试输出技巧

在不确定的时候,可以在内层循环后输出关键状态的值。

if (i <= 5) { // 只输出前5分钟调试 cout << "Minute " << i << ": "; cout << "rest=" << dp_rest[now] << " "; for (int j=1; j<=min(M, 5); ++j) { if (dp_run[now][j] > -INF/2) cout << "run" << j << "=" << dp_run[now][j] << " "; } cout << endl; }

通过观察前几分钟状态值的变化,可以快速定位转移错误。

6. 举一反三:这类DP问题的通用思考框架

P1353这道题代表了一类“带状态转移的序列决策问题”。其通用思考框架可以总结如下:

  1. 确定决策序列:问题通常是在一个线性序列(时间、空间)上做一系列决策。本题是N分钟,每分钟决定跑/休。
  2. 提取关键状态变量:哪些信息会影响未来的决策?本题是“当前连续跑步时长”和“当前体力”。但体力可以通过连续跑步时长和总时间间接推算(在双状态DP中,我们用j<=M来约束体力),所以有时可以精简。
  3. 设计DP状态
    • 尝试单一状态数组:如dp[i][s1][s2]...。如果维度太多或转移复杂,考虑拆分。
    • 考虑状态拆分:当系统处于几种“模式”时(如本题的“跑步模式”和“休息模式”),为每种模式设计单独的状态数组,往往能简化转移。模式间的切换就是状态转移。
  4. 推导状态转移方程:对每个状态,考虑前一时刻所有可能的状态,以及当前时刻的决策,如何转移到当前状态。务必注意决策的可行性条件(如本题跑步需体力>0)。
  5. 确定初始化和答案:初始时刻的状态(通常是第0个决策前)要设好。答案通常是最终时刻所有状态中的最优值。
  6. 分析复杂度并优化
    • 空间优化:如果i维只依赖i-1维,用滚动数组。
    • 状态优化:观察状态变量间的关系,看能否减少维度。例如本题,将体力和连续跑步时长两个信息,融合到“跑步状态中的连续时长j”这一个变量里,并用j<=M来体现体力约束。
    • 转移优化:有时转移可以写成前缀最大值等形式,进一步降低复杂度。

类似的USACO题目还有“Cow Cycling”(自行车比赛)、“Cow Frisbee Team”(掷飞盘队)等,都是这种序列决策+状态DP的变体。多练习几道,就能培养出对这种问题的“感觉”。

刷题不是背题,而是掌握题目背后的思想。这道P1353“Running S”就像一把钥匙,帮你打开了一类动态规划问题的大门。理解了它的双状态设计和转移逻辑,以后再遇到类似有“连续”、“冷却”、“资源累积与消耗”等元素的题目,你就能更快地识别模型,设计出高效的状态表示。这才是信奥刷题,乃至所有算法学习中最有价值的部分。

← 返回列表