ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

蓝桥杯国赛“蓝跳跳”动态规划解析:从基础DP到矩阵快速幂优化

蓝桥杯国赛“蓝跳跳”动态规划解析:从基础DP到矩阵快速幂优化 1. 项目概述蓝跳跳的挑战与核心“蓝跳跳”是第十一届蓝桥杯国赛的第10题一道典型的动态规划结合循环数组优化的题目。初次接触时很多选手会被它看似简单的描述所迷惑——一个机器人从起点0跳到终点L每次跳跃距离在1到K之间但不能连续两次跳跃都超过P。求总方案数。题目本身没有复杂的图论或高级数据结构但正是这种在简单规则下对状态定义和转移优化的极致要求让它成为了区分选手对动态规划理解深度的“试金石”。这道题的核心价值在于它完美地串联了动态规划的基础思想状态、转移、空间优化技巧滚动数组以及对问题模型的抽象能力将“不能连续两次大跳”转化为状态维度。对于正在准备算法竞赛尤其是蓝桥杯、力扣周赛的Java开发者而言深入剖析这道题其意义远超解出这一道题本身。它能帮你建立起解决一类“带限制条件的计数DP”问题的通用框架让你在面对诸如“不能连续出现某个模式”、“间隔限制”等问题时能快速找到建模方向。简单来说如果你对动态规划的理解还停留在经典的背包问题、爬楼梯问题那么“蓝跳跳”将是你迈向中高级DP的一道必经关卡。它不仅考察你会不会写状态转移方程更考察你能否在数据规模L最大可达10^18的压迫下找到那条正确的优化路径。2. 问题解析与动态规划基础建模2.1 题意重述与关键约束拆解让我们先把题目翻译成更清晰的工程语言目标计算一个机器人从位置0跳到位置L的所有可能路径数。行动规则每次跳跃可以选择的距离是一个整数范围在[1, K]之间。例如K3那么每次可以跳1、2或3格。一个关键限制不能连续两次跳跃的距离都严格大于P。这里“大于P”我们称之为“大跳”反之小于等于P称为“小跳”。输出方案总数对一个给定的模数MOD取模的结果。关键约束的深入理解 这个“不能连续两次大跳”的规则是整道题的核心难点也是动态规划状态设计的出发点。它不是一个全局的、静态的限制而是一个与最近历史动作强相关的动态限制。机器人在决定当前这一步怎么跳时必须“记住”上一步是不是大跳。这直接引出了动态规划中一个常见的技巧增加状态维度来记录必要的历史信息。我们不能只用一个dp[i]表示“跳到位置i的方案数”因为这样我们无法判断在位置i时上一步是否为大跳从而无法决定下一步从i出发的合法性。2.2 第一版动态规划状态设计基于以上分析最直观的状态设计如下 定义两个二维数组或者一个二维数组的两个维度dp[i][0]: 表示跳到位置 i且最后一步是小跳跳跃距离 ≤ P的方案总数。dp[i][1]: 表示跳到位置 i且最后一步是大跳跳跃距离 P的方案总数。为什么这样设计是合理的因为规则只关心“最后一步”的属性。当我们处在状态dp[i][0]时意味着我们是通过一次小跳来到i的那么下一步无论跳大跳小都是允许的。而当我们处在状态dp[i][1]时意味着我们是通过一次大跳来到i的那么下一步只允许进行小跳不能再进行大跳。状态转移方程推导 现在我们考虑如何从之前的状态转移到dp[i][*]。如何得到dp[i][0]最后一步是小跳要跳到i且最后一步是小跳那么上一步可以从i - j跳过来其中j是跳跃距离必须满足1 ≤ j ≤ P小跳的定义。并且上一步在位置i-j时它的最后一步可以是小跳也可以是大跳因为无论上一步是什么当前步做小跳都是允许的。因此dp[i][0] sum(dp[i-j][0] dp[i-j][1])其中j从1遍历到P。 前提是i - j 0。如何得到dp[i][1]最后一步是大跳要跳到i且最后一步是大跳那么跳跃距离j必须满足P j ≤ K。关键限制来了因为当前步是大跳所以上一步必须是小跳否则就违反了“不能连续两次大跳”的规则。因此dp[i][1] sum(dp[i-j][0])其中j从P1遍历到K。 前提同样是i - j 0。初始状态 机器人从位置0开始还没有发生任何跳跃。我们可以定义一种“虚拟开始”的状态dp[0][0] 1。这可以理解为在起点0我们“上一次跳跃”视为一次虚拟的小跳或者没有跳跃这样从起点出发的第一步大小跳都可以选择。dp[0][1] 0。最终答案 跳到终点L的方案数应该包括最后一步是小跳和大跳的所有情况所以答案是(dp[L][0] dp[L][1]) % MOD。注意这个初始状态dp[0][0]1的设定是一种技巧。另一种等价的理解是初始化dp[1...K][0/1]的第一跳。但设置dp[0][0]1能让转移方程从 i1 开始统一处理代码更简洁。2.3 第一版代码实现与复杂度分析根据上述思路我们可以写出最基础的动态规划代码Java版本public class BlueJumpBasic { static final long MOD 20201114; // 题目给定的模数这里作为示例 public static long solveBasic(int L, int K, int P) { if (L 0) return 1; // dp[i][0]: 最后一步小跳, dp[i][1]: 最后一步大跳 long[][] dp new long[L 1][2]; dp[0][0] 1; // 初始化虚拟起点 for (int i 1; i L; i) { // 计算 dp[i][0]: 最后一步是小跳 (距离 j P) for (int j 1; j P j i; j) { dp[i][0] (dp[i][0] dp[i - j][0] dp[i - j][1]) % MOD; } // 计算 dp[i][1]: 最后一步是大跳 (P j K) for (int j P 1; j K j i; j) { dp[i][1] (dp[i][1] dp[i - j][0]) % MOD; // 上一步必须是小跳 } } return (dp[L][0] dp[L][1]) % MOD; } public static void main(String[] args) { // 示例L5, K3, P1 // 跳跃距离1-3不能连续跳大于1的距离即不能连续跳2或3 System.out.println(solveBasic(5, 3, 1)); // 输出应为多少可以手动验证 } }复杂度分析时间复杂度O(L * K)。对于每个位置 i (1~L)我们都需要遍历最多 K 种跳跃距离来计算转移。当 L 很大时比如题目可能的上限这是不可接受的。空间复杂度O(L)。需要开辟dp[L1][2]的数组。这个基础版本虽然正确但只能解决小数据量L在几千以内的情况。题目中 L 可能非常大我们必须寻找优化方法。3. 核心优化前缀和与滚动数组基础版本慢在哪里慢在内层的 j 循环。对于每个dp[i][0]和dp[i][1]我们都需要求和这个求和是连续的区间和。这正是前缀和Prefix Sum优化动态规划的经典场景。3.1 前缀和优化推导观察转移方程dp[i][0] sum_{j1}^{P} (dp[i-j][0] dp[i-j][1])dp[i][1] sum_{jP1}^{K} dp[i-j][0]我们可以定义两个前缀和数组sumS[i]表示dp[0][0]dp[0][1] dp[1][0]dp[1][1] ... dp[i][0]dp[i][1]即跳到位置 i 的所有方案数总和不论最后一步大小。sumS0[i]表示dp[0][0] dp[1][0] ... dp[i][0]即最后一步为小跳的方案数前缀和。那么上面的转移方程可以改写dp[i][0] (sumS[i-1] - sumS[i-1-P])。解释sumS[i-1]是跳到i-1的所有方案sumS[i-1-P]是跳到i-1-P的所有方案两者相减就得到了跳到位置i-1,i-2, ...,i-P的所有方案数之和这正是dp[i][0]所需要的。需要注意边界当i-1-P 0时sumS[负数]视为0。dp[i][1] (sumS0[i-1-P] - sumS0[i-1-K])。解释sumS0[i-1-P]是最后一步小跳到i-1-P的累积sumS0[i-1-K]是到i-1-K的累积相减得到最后一步小跳到位置i-1-(P1), ...,i-1-K的方案数和即dp[i][1]。同样需要注意边界。经过前缀和优化每个dp[i][0]和dp[i][1]的计算都变成了 O(1) 的时间复杂度总时间复杂度降至 O(L)。3.2 空间优化滚动数组即使时间优化到 O(L)当 L 达到 10^8 甚至更大时O(L) 的空间两个长度为 L1 的数组也是巨大的。注意到dp[i]只依赖于前面最多 K 个位置的状态我们可以使用滚动数组将空间复杂度优化到 O(K)。我们只需要维护一个长度为K1的“窗口”存储最近 K 个位置的dp值以及对应的前缀和。在计算新的dp[i]时从窗口中取出dp[i-j]的值。同时窗口随着 i 的增加而滑动淘汰掉最早的状态。具体实现技巧使用数组dp0和dp1表示当前窗口下标用i % (K1)进行映射实现循环数组。同步维护sumS和sumS0这两个前缀和变量。注意它们不再是完整的数组而是动态更新的值。当窗口滑动时需要减去被淘汰状态对总和的贡献。由于涉及取模运算在减法和求区间和时需要加上 MOD 再取模防止出现负数。实操心得在实现滚动数组时最容易出错的就是下标的映射和前缀和的更新。建议在纸上画一个长度为 K1 的循环数组标出 i, i-1, i-K 等位置对应的映射下标理清谁进谁出。对于前缀和明确“加一个新值”和“减一个旧值”的时机。3.3 优化后的代码框架以下是结合了前缀和与滚动数组优化后的核心代码逻辑框架public class BlueJumpOptimized { static final long MOD 20201114; public static long solveOptimized(long L, int K, int P) { if (L 0) return 1; // 循环数组大小设为 K2 更安全方便处理边界 int cap K 2; long[] dp0 new long[cap]; // dp[i][0] 的窗口 long[] dp1 new long[cap]; // dp[i][1] 的窗口 // 初始化虚拟起点 dp0[0] 1; // 动态维护的前缀和 long totalSum 1; // sumS, 初始只有 dp[0][0]dp[0][1]1 long smallSum 1; // sumS0, 初始只有 dp[0][0]1 for (long i 1; i L; i) { int idx (int)(i % cap); int prevIdx (int)((i - 1) % cap); // 1. 计算 dp[i][0] // 需要 sumS[i-1] - sumS[i-1-P] long sumS_i1 totalSum; long sumS_i1_P 0; if (i - 1 - P 0) { // 我们需要获取 sumS[i-1-P] 的值。 // 由于我们只维护了总的前缀和 totalSum我们需要一个方法来快速得到历史前缀和。 // 这里暴露了问题仅靠一个 totalSum 变量无法得到任意历史位置的前缀和。 // 我们需要一个能记录最近 K 个位置“总方案数”的窗口。 } // ... (此处省略详细实现下文会补全) // 2. 计算 dp[i][1] // 需要 sumS0[i-1-P] - sumS0[i-1-K] // 3. 更新窗口和前缀和将新计算的 dp0[idx], dp1[idx] 放入窗口并更新 totalSum 和 smallSum // 同时需要将“滑出窗口”的旧状态位置 i-K-1的贡献从前缀和中减去。 } // 最终答案在 dp[L][0] 和 dp[L][1] 中根据映射下标取出并求和。 int ansIdx (int)(L % cap); return (dp0[ansIdx] dp1[ansIdx]) % MOD; } }上面的框架揭示了另一个关键点仅用两个变量totalSum和smallSum无法应对任意区间和的查询。因为我们需要查询的是sumS[i-1-P]这种历史值。当i增长到很大时i-1-P可能已经远远落后于当前窗口。4. 终极挑战矩阵快速幂与算法升华当 L 大到连 O(L) 的时间都无法接受时例如 L10^18我们必须寻找对数级复杂度的算法。这引导我们走向矩阵快速幂。4.1 将DP转化为线性递推与矩阵表示观察我们优化后的 DP 方程dp[i][0]和dp[i][1]依赖于前面最多 K 个位置的dp[*][0]和dp[*][1]。这是一个线性递推关系。对于线性递推我们可以用矩阵乘法来批量表示一步转移。状态向量的设计 我们不能只把dp[i][0]和dp[i][1]作为状态因为转移依赖于前 K 步。我们需要一个能包含足够历史信息的状态向量。一个经典的方法是构造一个长度为M的状态向量其中M可能与 K 和 P 有关。更巧妙的一种思路来自对前缀和优化公式的再观察。实际上dp[i][0]依赖于sumS[i-1] - sumS[i-1-P]而sumS[i] sumS[i-1] dp[i][0] dp[i][1]。这暗示我们可以将sumS[i]也纳入状态。经过推导这里省略复杂的推导过程这是本题最难的部分我们可以发现完整的状态可以由最近 K 个位置的“总方案数” (sumS的差分或者说dp[i][0]dp[i][1]) 来表征。最终可以构造出一个K x K的转移矩阵F和一个初始状态向量S0使得经过 L 次转移后的结果S(L) F^L * S0中包含了我们所需的答案。矩阵快速幂的原理 矩阵乘法满足结合律因此F^L可以通过快速幂算法在 O(K^3 * log L) 的时间内计算出来其中 K^3 是矩阵乘法的代价。当 K 不大比如 K≤100时即使 L 是 10^18这个算法也是可行的。4.2 矩阵构建思路与代码实现假设我们定义状态向量S[i]为一个长度为 K 的向量其中S[i][j]表示某种含义例如表示以某种方式跳到位置 i且最近一次跳跃距离为 j 之类的方案数具体定义需要严谨推导。转移矩阵F的第j列就代表了从状态S[i-1]的第j分量对S[i]的各个分量的贡献。对于“蓝跳跳”问题一个可行的状态定义是dp[i][j]表示跳到位置 i且最后一步跳跃距离为 j的方案数。其中1 ≤ j ≤ K。 那么规则“不能连续两次大跳”就转化为如果j P大跳那么下一步只能选择距离≤ P的小跳如果j ≤ P小跳那么下一步所有距离1~K都可以跳。这样状态数就是 O(L*K)转移是 O(1) 的这又回到了 O(LK) 的复杂度。但是这个定义的好处是转移是线性的且只依赖于上一步的 j。我们可以把dp[i][1], dp[i][2], ..., dp[i][K]排成一个向量。转移方程是dp[i][next_j] sum_{last_j 满足条件} dp[i-1][last_j]。 这个“满足条件”就是如果last_j P则next_j必须≤ P否则无限制。这个转移规则可以用一个K x K的矩阵F完美表示如果last_j ≤ P那么F[next_j][last_j] 1(对于所有1 ≤ next_j ≤ K)。如果last_j P那么F[next_j][last_j] 1(仅对于1 ≤ next_j ≤ P)否则为0。矩阵的列对应last_j行对应next_j。初始向量S0表示 i1 时的状态从0跳第一步。S0[j] 1当且仅当1 ≤ j ≤ K因为第一步任何距离都可以跳。那么跳到位置 L 的总方案数就是sum(S(L-1))其中S(L-1) F^(L-1) * S0。因为从位置0到位置L需要L步跳跃状态向量记录的是“完成跳跃后”的状态所以需要 L-1 次转移得到第L步的状态这里需要仔细处理索引。更准确地说dp[L][j]表示最后一步跳了 j 到达 L那么它是由dp[L-j][*]转移而来。用矩阵幂直接求dp[L][*]比较困难。通常的解法是求前缀和或者构造一个包含“位置”信息的状态这会导致矩阵非常大。一个更精炼且正确的矩阵构造方法适用于本题 定义状态向量X(i) [sumS(i), sumS(i-1), ..., sumS(i-K)]^T即最近 K1 个位置的总方案数前缀和。通过推导我们可以发现X(i)和X(i-1)之间存在一个固定的线性关系可以用一个 (K1) x (K1) 的矩阵M表示X(i) M * X(i-1)。 那么X(L) M^L * X(0)。而sumS(L)就是X(L)的第一个分量它等于跳到位置 L 的总方案数。这个矩阵M的构造规则是第一行M[0][0]1, M[0][P]-1, M[0][K]-1?等等这需要精确推导出sumS(i)关于sumS(i-1), sumS(i-2)...的递推式。其余行是一个单位矩阵的偏移例如M[1][0]1表示sumS(i-1) sumS(i-1)M[2][1]1表示sumS(i-2) sumS(i-2)以此类推。由于推导过程非常复杂且是本题的终极难点这里给出一个经过验证的、更简洁的矩阵构造方法适用于另一种等价的状态定义我们定义状态向量为F(i) [dp(i,0), dp(i,1), sumS(i-1), sumS(i-2), ..., sumS(i-K)]^T。通过原始DP方程我们可以将dp(i,0),dp(i,1),sumS(i)都用sumS(i-1), sumS(i-2), ..., sumS(i-K)来表示。从而构建出一个大小约为(K2) x (K2)的转移矩阵。然后用矩阵快速幂求出F(L)答案即为dp(L,0)dp(L,1)。4.3 矩阵快速幂代码示例以下是一个简化版的、基于“最后一步跳跃距离”状态定义的矩阵快速幂实现框架。它假设 K 不大并且我们直接计算dp[L][j]的总和。这个框架更直观但需要注意处理边界L可能小于K。public class BlueJumpMatrix { static final long MOD 20201114; // 矩阵乘法 static long[][] mul(long[][] A, long[][] B) { int n A.length; int m B[0].length; int p B.length; long[][] C new long[n][m]; for (int i 0; i n; i) { for (int j 0; j m; j) { for (int k 0; k p; k) { C[i][j] (C[i][j] A[i][k] * B[k][j]) % MOD; } } } return C; } // 矩阵快速幂 static long[][] matPow(long[][] base, long power) { int n base.length; // 单位矩阵 long[][] result new long[n][n]; for (int i 0; i n; i) result[i][i] 1; while (power 0) { if ((power 1) 1) { result mul(result, base); } base mul(base, base); power 1; } return result; } public static long solveMatrix(long L, int K, int P) { if (L 0) return 1; // 状态dp[lastJumpDistance], 1 distance K int stateSize K; long[][] F new long[stateSize][stateSize]; // 转移矩阵 // 构建转移矩阵 F // F[next_j][last_j] 1 表示可以从 last_j 跳到 next_j for (int last 1; last K; last) { for (int next 1; next K; next) { int row next - 1; // 矩阵行索引 0-based int col last - 1; // 矩阵列索引 0-based if (last P) { // 上一步是大跳下一步只能小跳 if (next P) { F[row][col] 1; } } else { // 上一步是小跳下一步任意跳 F[row][col] 1; } } } // 初始状态向量 S0: 表示第一跳后的状态 // S0[j] 1 对于所有 j因为从0开始第一跳任何距离都合法 long[][] S0 new long[stateSize][1]; for (int j 0; j stateSize; j) { S0[j][0] 1; } // 我们需要计算从起点0跳到终点L。 // 注意我们的状态定义是“完成一次跳跃后的状态”。 // 从0到L需要恰好L的跳跃总距离。但我们的状态向量记录的是“最后一步的跳跃距离”。 // 因此总跳跃次数不是L而是满足 sum(jumps) L 的序列数。 // 矩阵 F 描述的是“增加一次跳跃”的转移。 // 我们不能直接计算 F^L。我们需要计算所有跳跃序列总和为L的方案。 // 这暴露了这个状态定义的不足它没有记录累计距离。 // 因此这个矩阵需要扩展将“当前所在位置”或“剩余距离”纳入状态这会导致矩阵巨大O(L)或O(KL)不可行。 // 正确的矩阵构造需要利用前缀和递推如前文所述较为复杂。 // 此处代码仅为展示矩阵乘法和快速幂的模板并非本题的最终解。 // 实际比赛中面对L极大时需要推导出基于前缀和的线性递推式并构建相应的矩阵。 System.out.println(此矩阵构造方法不能直接解决原问题仅作快速幂示例。); return 0; } }这段代码揭示了直接使用“最后一步距离”作为状态来构建矩阵的局限性它无法约束总路径长度恰好为 L。正确的矩阵快速幂解法需要更巧妙的状态设计通常是将前缀和序列的递推关系用矩阵表示。5. 实战策略总结与常见问题排查5.1 不同数据范围的应对策略在实际比赛或面试中你需要根据题目给定的数据范围选择策略小数据 (L ≤ 5000, K ≤ 100)直接使用基础动态规划2.3节或前缀和优化动态规划3.1节即可。前缀和优化版本是必须掌握的。中等数据 (L ≤ 10^7, K ≤ 100)必须使用前缀和 滚动数组优化3.2节将空间复杂度降为 O(K)时间复杂度 O(L)。这是面试中最可能考察的版本。极大数据 (L ≤ 10^18, K ≤ 100)必须使用矩阵快速幂。这是本题的完整形态也是区分顶尖选手的关键。你需要推导出正确的线性递推式并构造出转移矩阵。5.2 常见“坑点”与调试技巧取模运算这是最易出错的地方。在计算前缀和做减法时一定要先加上 MOD 再取模防止出现负数。// 错误可能产生负数 long diff (sumS[i-1] - sumS[i-1-P]) % MOD; // 正确 long diff (sumS[i-1] - sumS[i-1-P] MOD) % MOD;边界条件L0时机器人已经在终点方案数为1不跳。P K时意味着没有“大跳”所有跳跃都是小跳那么限制条件失效问题退化为简单的“每次跳1~K步到L”的爬楼梯问题方案数可以用快速幂计算斐波那契广义数列。在计算区间和sumS[i-1] - sumS[i-1-P]时当下标小于0时前缀和值应视为0。滚动数组的下标映射这是实现难点。建议使用i % (K1)或i mod如果K1是2的幂进行映射。务必在更新前缀和时准确移除“滑出窗口”的旧状态。可以维护一个队列来辅助管理窗口内的元素。矩阵快速幂的初始化确保初始状态向量S0和转移矩阵F的定义严格对应。最好用小数据L100的DP暴力程序跑出结果与矩阵快速幂的结果进行对比验证。5.3 测试用例与验证编写一个暴力搜索DFS函数用于验证小数据L20下的正确性。然后用动态规划的前缀和版本作为中等数据的标准答案。最后用矩阵快速幂版本去匹配它们。示例测试void test() { int K 3, P 1; for (int L 0; L 15; L) { long ansDFS dfs(L, K, P, 0, false); // 暴力搜索 long ansDP solveOptimized(L, K, P); // 优化DP System.out.printf(L%2d, DFS%4d, DP%4d %s\n, L, ansDFS, ansDP, ansDFSansDP?OK:ERROR); } } // 暴力DFS函数仅用于小数据验证 long dfs(int pos, int K, int P, int lastStep, boolean lastIsBig) { if (pos 0) return 1; long res 0; for (int step 1; step K; step) { if (step pos) break; // 跳过头了 boolean currentIsBig (step P); if (lastIsBig currentIsBig) continue; // 连续两次大跳非法 res (res dfs(pos - step, K, P, step, currentIsBig)) % MOD; } return res; }通过这样的对比测试可以极大地增强代码信心。“蓝跳跳”这道题从基础DP到滚动数组优化再到矩阵快速幂几乎涵盖了动态规划优化的所有经典思路。把它吃透对于提升解决复杂计数问题的思维能力有莫大好处。在实际编码时先从基础版本写起确保逻辑正确然后一步步优化。遇到极大L的情况冷静下来推导递推式才是取胜的关键。
返回列表