尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

动态规划状态设计精讲:从“蓝跳跳”问题看后效性处理与矩阵快速幂优化

动态规划状态设计精讲:从“蓝跳跳”问题看后效性处理与矩阵快速幂优化 1. 问题引入从一个“跳格子”游戏说起最近在整理算法竞赛的经典题目时又翻到了蓝桥杯国赛这道关于“蓝跳跳”的题。乍一看题目描述像是一个简单的动态规划或者递推问题无非是规定步长计算从起点跳到终点的方案数。但当你真正动手去推导和实现时会发现里面藏着好几个非常容易踩进去的“坑”这些坑恰恰是区分普通选手和顶尖选手的关键。这道题的核心远不止是写出一个能算出答案的递推式那么简单它考察的是对动态规划状态定义的深刻理解、对大数取模运算的严谨处理以及如何将看似复杂的“限制条件”转化为清晰、可维护的状态转移方程。很多朋友在初次接触时可能会觉得“这不就是个带限制的爬楼梯问题吗” 但问题就出在这个“限制”上。题目通常是这样描述的蓝跳跳要从位置0跳到位置LL是一个很大的数比如10^18它每次可以向前跳跃1到K步K是最大步长。但是有一个额外的规则不能连续两次跳跃都超过等于P步P是另一个给定的参数P≤K。我们需要计算在满足此规则下跳到终点L的所有可能方案数结果对一个很大的质数比如20201114取模。如果你直接套用经典爬楼梯问题的思路定义一个dp[i]表示跳到位置i的方案数然后尝试去维护“上一次是否跳了长步”这个状态很快就会陷入混乱。因为当前位置i的方案数不仅依赖于i-1, i-2, ... i-K这些位置的状态还依赖于这些状态是通过“长跳”还是“短跳”过来的而“长跳”的定义又依赖于上一次的跳跃。这种二维甚至三维的状态纠缠会让递推式变得异常复杂且容易出错。这道题的精妙之处就在于它引导我们跳出“位置”的单一维度去思考一个更本质的划分方式。2. 核心难点拆解为什么不能直接用dp[i]我们先来深入分析一下如果只用dp[i]表示跳到位置i的总方案数会遇到什么麻烦。假设dp[i]已经包含了所有合法跳到i的方案。当我们想计算dp[i1]时它可以从dp[i]跳1步过来从dp[i-1]跳2步过来……一直到从dp[i-K1]跳K步过来。但是这里有一个致命问题从dp[i]跳1步到i1这个“1步”是短步假设P1那么无论dp[i]所代表的方案中最后一步是长是短这次跳跃都是合法的。然而如果是从dp[i-K1]跳K步到i1且K≥P那么这次跳跃就是一次“长跳”。这次跳跃是否合法就完全取决于dp[i-K1]所代表的那些方案里最后一步是不是长跳。如果最后一步也是长跳那么这次跳跃就违反了“不能连续两次长跳”的规则。你看问题来了dp[i-K1]这个值是一个总和它混合了“最后一步是长跳”和“最后一步是短跳”两种子方案。我们无法直接从dp[i-K1]这个孤立的数字里分辨出有多少方案是能以长跳结尾的。因此我们无法正确地完成状态转移。关键洞察这个限制条件禁止连续长跳是一个“后效性”条件。当前决策这一步跳多远的合法性依赖于上一个决策上一步跳了多远的结果。在动态规划中如果存在后效性通常的解决方案是扩充状态将影响当前决策的历史信息也纳入状态定义中。所以我们必须把“最后一步的跳跃类型”这个信息作为状态的一部分。这是解决本题最核心、也是第一步必须想清楚的事情。3. 状态定义与转移方程的构建基于上面的分析我们很自然地定义两个状态数组dp_long[i]: 表示**跳到位置i且最后一步是长跳步长≥P**的方案数。dp_short[i]: 表示**跳到位置i且最后一步是短跳步长P**的方案数。那么跳到位置i的总方案数dp_total[i] dp_long[i] dp_short[i]。注意这里我们并不需要一个单独的dp_total数组它只是一个用于理解和验证的中间量。接下来我们分别推导这两个状态如何从之前的状态转移而来。1.dp_short[i]的转移最后一步是短跳既然最后一步是短跳步长j满足1 j P那么它可以从位置i-j跳过来。而无论位置i-j的最后一步是长跳还是短跳这次短跳都是合法的因为规则只限制连续长跳不限制短跳。因此dp_short[i] sum(dp_total[i-j])其中j从1到P-1。 换句话说dp_short[i]等于前面P-1个位置的总方案数之和。2.dp_long[i]的转移最后一步是长跳最后一步是长跳步长j满足P j K它从位置i-j跳过来。但是为了保证这次长跳合法位置i-j的最后一步绝对不能是长跳否则就连续两次长跳了。所以i-j位置只能以短跳结尾。 因此dp_long[i] sum(dp_short[i-j])其中j从P到K。 也就是说dp_long[i]等于从i-K到i-P这个区间内所有位置的dp_short值之和。初始状态我们从位置0开始。可以认为在位置0时还没有发生任何跳跃。为了后续计算方便我们通常这样初始化dp_short[0] 1。这可以理解为“虚拟”的短跳起点或者理解为到达0点有一种方案不动。dp_long[0] 0。在起点没有发生过跳跃自然没有“最后一步是长跳”的方案。对于i 0所有dp值均为0。有了状态定义和转移方程理论上我们就可以从i1开始一直递推到iL然后答案就是dp_total[L] dp_short[L] dp_long[L]。4. 算法优化从O(LK)到O(L)再到O(log L)如果L比较小比如L≤10^6我们完全可以用一个循环配合前缀和技巧来实现上述转移时间复杂度是O(L)。具体步骤如下维护两个前缀和数组或者变量sum_total[i] dp_total[1] dp_total[2] ... dp_total[i]即总方案数的前缀和。sum_short[i] dp_short[1] dp_short[2] ... dp_short[i]即短跳方案数的前缀和。根据转移方程dp_short[i] (sum_total[i-1] - sum_total[i-P]) mod M。这计算了从i-(P-1)到i-1的总方案数和。dp_long[i] (sum_short[i-P] - sum_short[i-K-1]) mod M。这计算了从i-K到i-P的短跳方案数和。注意处理下标越界的情况当i-P等小于0时前缀和取0。每计算出一个新的dp_short[i]和dp_long[i]就更新sum_total和sum_short。这个O(L)的算法对于L在10^6量级是可行的。但题目中的L往往巨大10^18O(L)的算法显然会超时。这就引出了本题的第二个难点也是最大的亮点如何利用矩阵快速幂将线性递推加速到对数级。我们的状态转移是线性的dp_short[i]和dp_long[i]都依赖于前面有限个状态的值。我们可以把最近关心的若干个状态值堆叠成一个状态向量然后寻找一个转移矩阵使得“下一时刻的状态向量 转移矩阵 × 当前时刻的状态向量”。观察我们的依赖关系要计算dp_short[i]和dp_long[i]我们需要知道dp_short[i]: 需要知道dp_total[i-1] ... dp_total[i-P1]这包含了dp_short和dp_long。dp_long[i]: 需要知道dp_short[i-P] ... dp_short[i-K]。这意味着要计算i时刻的状态我们至少需要知道从i-K到i-1这K个位置的所有dp_short和dp_long吗其实不需要全部。因为我们的转移只依赖于区间和。我们可以巧妙地设计状态向量让它包含足够的信息来计算这些区间和。一个经典且有效的设计是令状态向量F(i) [dp_total[i], dp_total[i-1], ..., dp_total[i-K1], dp_short[i], dp_short[i-1], ..., dp_short[i-P1]]。 这个向量的长度是K P。它包含了计算下一个状态所需的全部“历史”总方案数和短跳方案数。通过构造一个(KP) x (KP)的矩阵M我们可以实现F(i) M * F(i-1)。构造转移矩阵M是一个精细活你需要根据dp_short[i]和dp_long[i]的递推公式仔细地在矩阵M的对应行填上1或0。dp_total[i]就是dp_short[i] dp_long[i]所以它对应的行是前两行对应列的和。一旦我们得到了转移矩阵M和初始状态向量F(0)可以通过手动计算前KP个初始值得到那么跳到位置L的状态向量就是F(L) M^L * F(0)。计算M^L可以使用矩阵快速幂时间复杂度为O((KP)^3 * log L)。由于K和P通常不大≤100而log L很小这个算法可以轻松处理L10^18的情况。实操心得在竞赛中如果你能推导到矩阵快速幂这一步基本上就稳了。但这里有一个巨大的“坑”初始状态向量F(0)的构建。你不能简单地把dp[0]代入因为我们的状态向量包含了i-1, i-2,...的历史值。通常需要手动模拟递推计算出前KP个位置的dp_short和dp_total值然后用这些值来填充初始向量F(KP-1)假设我们的向量包含的是最近的历史。然后我们实际要计算的是F(L) M^(L-K-P1) * F(KP-1)。这一步下标处理非常容易出错务必在纸上画清楚并用小数据验证。5. 实现细节与避坑指南理论通了代码实现上依然危机四伏。下面我结合自己的踩坑经验总结几个关键点坑点一取模运算与负数在计算前缀和差值sum_total[i-1] - sum_total[i-P]时或者矩阵运算中结果可能是负数。我们必须保证每次加法、减法运算后都立即调整到非负。# 错误的做法 dp_s (sum_total[i-1] - sum_total[i-P]) % MOD # 如果差为负Python结果正确但C/Java会出错 # 正确的做法通用 dp_s (sum_total[i-1] - sum_total[i-P]) % MOD # 或者在C/Java中 long dp_s (sum_total[i-1] - sum_total[i-P] MOD) % MOD;在矩阵乘法中每一个乘加操作后都要取模防止溢出。坑点二边界条件处理当i很小时i P或i K递推公式中的前缀和下标可能为负数。对于dp_short[i]当i P时sum_total[i-P]视为0。实际上dp_short[i]就是sum_total[i-1]。对于dp_long[i]当i K时sum_short[i-K-1]视为0。当i P时根本不可能有长跳方案dp_long[i]直接为0。 在初始化前缀和数组时通常将下标0的前缀和设为0下标为负的也视为0并在代码中做好判断。坑点三矩阵快速幂的初始状态这是最多人栽跟头的地方。假设我们构造的状态向量是F(i) [dp_total[i], dp_total[i-1], ..., dp_total[i-K1], dp_short[i], dp_short[i-1], ..., dp_short[i-P1]]那么F(KP-1)应该包含从dp_total[KP-1]到dp_total[P]以及dp_short[KP-1]到dp_short[K]这里需要根据你的具体设计调整。务必写一个暴力DP函数O(L)先计算出小数据如L100下每个位置的dp值。然后用你构造的矩阵M和初始向量验证M^1 * F(init)是否等于F(init1)一直验证几步。只有小数据完全匹配才能保证矩阵构造正确。坑点四长跳步长范围包含P题目条件是“连续两次跳跃都大于等于P步”不被允许。这意味着步长等于P的跳跃也属于“长跳”。在定义dp_long和转移方程时j的范围是P j K千万不要漏了等号。坑点五结果取模最终答案ans (dp_short[L] dp_long[L]) % MOD。注意在矩阵快速幂求出最终状态向量后你需要从中提取出对应于位置L的dp_short和dp_long分量根据你状态向量的设计方式然后相加取模。6. 代码框架与测试用例这里给出一个O(L)的DP结合前缀和的Python代码框架用于小数据验证和帮助理解。矩阵快速幂的代码较长但结构是标准的。MOD 20201114 def solve_naive(L, K, P): 朴素DP解法用于验证思路和小数据测试。 时间复杂度 O(L*K)仅适用于非常小的L。 if L 0: return 1 # dp_long[i], dp_short[i] dp_long [0] * (L 1) dp_short [0] * (L 1) # 初始化在位置0可以视为以短跳结束虚拟 dp_short[0] 1 dp_long[0] 0 for i in range(1, L 1): # 计算 dp_short[i] for j in range(1, P): if i - j 0: # 可以从 i-j 跳短步 j 过来无论 i-j 最后一步是什么跳 dp_short[i] (dp_short[i] dp_short[i-j] dp_long[i-j]) % MOD # 计算 dp_long[i] for j in range(P, K 1): if i - j 0: # 可以从 i-j 跳长步 j 过来要求 i-j 最后一步必须是短跳 dp_long[i] (dp_long[i] dp_short[i-j]) % MOD return (dp_short[L] dp_long[L]) % MOD def solve_fast_small(L, K, P): 使用前缀和优化的DP解法时间复杂度 O(L)。 适用于L在10^6量级。 if L 0: return 1 dp_short [0] * (L 1) dp_long [0] * (L 1) sum_total [0] * (L 1) # dp_total的前缀和 sum_short [0] * (L 1) # dp_short的前缀和 # 初始化位置0 dp_short[0] 1 dp_long[0] 0 sum_total[0] 1 # dp_total[0] 1 sum_short[0] 1 # dp_short[0] 1 for i in range(1, L 1): # 计算 dp_short[i] # sum_total[i-1] - sum_total[i-P] left i - P if left 0: dp_short[i] sum_total[i-1] else: dp_short[i] (sum_total[i-1] - sum_total[left]) % MOD # 计算 dp_long[i] # sum_short[i-P] - sum_short[i-K-1] left_l i - K - 1 right_l i - P if right_l 0: dp_long[i] 0 else: if left_l 0: dp_long[i] sum_short[right_l] else: dp_long[i] (sum_short[right_l] - sum_short[left_l]) % MOD # 更新前缀和 total_i (dp_short[i] dp_long[i]) % MOD sum_total[i] (sum_total[i-1] total_i) % MOD sum_short[i] (sum_short[i-1] dp_short[i]) % MOD return (dp_short[L] dp_long[L]) % MOD # 测试用例 if __name__ __main__: # 测试数据1: 小数据用朴素法和优化法对比 L, K, P 5, 3, 2 ans1 solve_naive(L, K, P) ans2 solve_fast_small(L, K, P) print(fL{L}, K{K}, P{P}) print(f朴素法结果: {ans1}) print(f优化法结果: {ans2}) print(f结果是否一致: {ans1 ans2}) print(- * 20) # 测试数据2: 稍大数据只能使用优化法 L, K, P 1000, 10, 4 ans solve_fast_small(L, K, P) print(fL{L}, K{K}, P{P}) print(f优化法结果: {ans})对于矩阵快速幂的解法其代码框架是标准的定义矩阵类实现矩阵乘法和快速幂然后构造特定的转移矩阵M和初始向量V计算M^pow * V并提取答案。关键在于正确构造M和V这需要根据你最终确定的状态向量维度来仔细填充。强烈建议先用上面的solve_fast_small函数跑出前几十项dp值然后用来调试你的矩阵构造确保每一步转移都正确无误。这道“蓝跳跳”题目从一个简单的游戏规则出发层层递进地考察了动态规划的状态设计、前缀和优化、矩阵快速幂应用以及对取模运算的严谨性是一道不可多得的、锻炼综合思维的好题。理解它不仅是为了解决一道竞赛题更是为了掌握处理一类具有后效性的线性递推问题的方法论。下次再遇到“不能连续如何如何”的计数问题你就能立刻想到扩充状态分离类型然后可能就是一道矩阵快速幂的裸题了。
返回列表