
1. 从棋盘到状态理解“中国象棋”问题的本质看到这个标题很多人的第一反应可能是懵的。一个叫“中国象棋”的题目怎么和“DP优化”扯上关系这其实是一道非常经典的、披着象棋外衣的动态规划计数问题。它来自AHOI2009题目编号P2051。题目大意是在一个N行M列的棋盘上放置若干个“炮”中国象棋中的炮要求任意两个炮不能互相攻击。问有多少种合法的放置方案。这里的“攻击”规则遵循中国象棋中炮的走法炮可以攻击同行或同列中恰好隔着一个棋子的另一个炮。换句话说在任意一行或一列中不能出现三个或以上的炮并且不能出现两个炮紧挨着中间没有其他棋子隔开的情况。更精确的约束是每一行、每一列的炮的数量都不能超过2个。所以问题的核心根本不是下棋而是在网格上放置棋子并满足行列的数量约束。这是一个典型的组合计数问题规模N, M ≤ 100暗示我们需要一个高效的状态压缩动态规划DP算法。暴力枚举每个格子放或不放复杂度是O(2^(N*M))完全不可行。我们必须找到一种聪明的状态定义来刻画棋盘上“炮”的分布情况同时满足行列的约束。2. 状态设计的艺术如何刻画棋盘布局既然约束是针对每一行和每一列的炮数0个、1个或2个一个最直接的想法是记录每一列当前有多少个炮0,1,2。但是M最大为100如果状态是dp[i][c1][c2][...][c100]这显然是个天文数字。我们需要压缩。这里的关键洞察是我们并不需要关心具体是哪几列有1个炮或2个炮我们只需要知道当前有多少列有0个炮、多少列有1个炮、多少列有2个炮。因为对于当前要处理的行来说它与这些列的交互方式只取决于该列已有的炮数。因此我们可以定义动态规划的状态dp[i][j][k]表示处理完前i行后有j列目前有1个炮有k列目前有2个炮那么有(m - j - k)列目前有0个炮。为什么这样定义是可行的因为每一行最多放两个炮当我们决定在第i1行如何放炮时我们只需要根据当前列的分类0炮列、1炮列、2炮列来决策并更新这些分类的数量。这样我们将一个与棋盘规模M相关的指数级状态压缩到了与M^2相关的多项式级别因为j和k的范围都是0到m状态总数约为O(N * M^2)。当M100时状态数约为100 * 100 * 100 1e6在可接受范围内。状态设计是这道题最精妙也最困难的部分。它跳出了“记录每个位置”的微观视角采用了“统计类别数量”的宏观视角这正是动态规划优化中“状态压缩”和“维度削减”思想的体现。理解了这个状态定义就理解了这道题80%的难点。3. 状态转移方程的推导组合数学的运用有了状态dp[i][j][k]我们需要推导出状态转移方程。考虑从第i行转移到第i1行。在第i1行我们可以选择放置0个、1个或2个炮。关键在于炮必须放在不同的列并且放置后要更新j有1个炮的列数和k有2个炮的列数。我们设当前状态为(i, j, k)即前i行处理后有j列有1炮k列有2炮m-j-k列有0炮。 现在考虑在第i1行放置a个炮a0,1,2。我们需要从不同类别的列中选择列来放置这些炮。情况一在第i1行放置0个炮。这是最简单的放置后各列炮数不变。 转移dp[i1][j][k] dp[i][j][k]情况二在第i1行放置1个炮。这个炮可以放在三种类型的列上放在一个当前有0个炮的列上。放完之后这个列就变成了有1个炮的列。所以我们需要从(m-j-k)个0炮列中选1个。 方案数为C(m-j-k, 1) m-j-k转移后状态变化j增加1因为多了一个1炮列k不变。 转移dp[i1][j1][k] dp[i][j][k] * (m-j-k)放在一个当前有1个炮的列上。放完之后这个列就变成了有2个炮的列。所以我们需要从j个1炮列中选1个。 方案数为C(j, 1) j转移后状态变化j减少1因为一个1炮列变成了2炮列k增加1。 转移dp[i1][j-1][k1] dp[i][j][k] * j情况三在第i1行放置2个炮。这两个炮必须放在不同的列。有几种子情况两个炮都放在当前有0个炮的列上。 需要从(m-j-k)个0炮列中选2个。 方案数为C(m-j-k, 2)转移后状态变化j增加2因为多了两个1炮列k不变。 转移dp[i1][j2][k] dp[i][j][k] * C(m-j-k, 2)两个炮都放在当前有1个炮的列上。 需要从j个1炮列中选2个。 方案数为C(j, 2)转移后状态变化j减少2因为两个1炮列都变成了2炮列k增加2。 转移dp[i1][j-2][k2] dp[i][j][k] * C(j, 2)一个炮放在当前有0个炮的列另一个炮放在当前有1个炮的列。 先从(m-j-k)个0炮列中选1个再从j个1炮列中选1个。 方案数为(m-j-k) * j转移后状态变化放在0炮列的那个变成了1炮列j1放在1炮列的那个变成了2炮列j-1k1。综合来看j不变1-10k增加1。 转移dp[i1][j][k1] dp[i][j][k] * ((m-j-k) * j)一个炮放在当前有0个炮的列另一个炮放在当前有0个炮的列这已经包含在情况3.1里了选两个0炮列。一个炮放在当前有1个炮的列另一个炮放在当前有1个炮的列这已经包含在情况3.2里了选两个1炮列。绝对不能把炮放在已经有2个炮的列上因为那样该列炮数就变成3违反约束。注意所有转移都要在方案数有效即组合数C(n, m)中nm的前提下进行否则该项转移为0。初始状态dp[0][0][0] 1表示没有处理任何行时所有列都是0炮列这是一种方案。 最终答案处理完所有N行后将所有合法的dp[N][j][k]j, k任意满足0j,km且 jk m求和即为总方案数。4. 实现细节与优化技巧理论推导完成后我们来看代码实现和优化。这里有几个关键的实现细节和技巧。4.1 滚动数组优化空间我们的状态转移只依赖于前一行i的状态来更新i1行的状态。因此我们可以使用滚动数组来将空间复杂度从O(N * M^2)降低到O(M^2)。通常使用两个二维数组dp_cur[j][k]和dp_next[j][k]或者使用一个三维数组但只保留i1的维度。// 伪代码示例 vectorvectorlong long dp_cur(m1, vectorlong long(m1, 0)); vectorvectorlong long dp_next(m1, vectorlong long(m1, 0)); dp_cur[0][0] 1; // 初始化 for (int i 0; i n; i) { // 遍历每一行 // 清空 dp_next for (int j 0; j m; j) { fill(dp_next[j].begin(), dp_next[j].end(), 0); } for (int j 0; j m; j) { for (int k 0; j k m; k) { long long val dp_cur[j][k]; if (val 0) continue; // 剪枝无效状态跳过 int zero m - j - k; // 0炮列的数量 // 转移1: 放0个炮 dp_next[j][k] (dp_next[j][k] val) % MOD; // 转移2: 放1个炮 if (zero 1) { dp_next[j1][k] (dp_next[j1][k] val * zero) % MOD; } if (j 1) { dp_next[j-1][k1] (dp_next[j-1][k1] val * j) % MOD; } // 转移3: 放2个炮 if (zero 2) { dp_next[j2][k] (dp_next[j2][k] val * (zero * (zero - 1) / 2)) % MOD; } if (j 2) { dp_next[j-2][k2] (dp_next[j-2][k2] val * (j * (j - 1) / 2)) % MOD; } if (zero 1 j 1) { dp_next[j][k1] (dp_next[j][k1] val * zero * j) % MOD; } } } swap(dp_cur, dp_next); // 滚动 } // 最终答案在 dp_cur 中求和4.2 组合数的处理与取模题目通常要求对一个大质数如9999973取模。我们需要在计算过程中及时取模防止溢出。注意组合数C(n, 2) n * (n-1) / 2这里的除法在模运算下需要用到乘法逆元。但因为模数9999973是质数且2与它互质所以可以直接计算(n * (n-1) / 2) % MOD在C等语言中整数除法在取模前进行即可或者使用(n * (n-1) / 2) % MOD由于n和n-1中必有一个偶数先除2再乘不会溢出long long如果n100。更稳妥的方法是使用(n * (n-1) / 2) % MOD或(n * (n-1) * inv2) % MOD其中inv2是2关于MOD的乘法逆元。4.3 循环边界与无效状态剪枝在遍历j和k时必须满足j k m因为不可能有炮的列数超过总列数。在循环中加上这个条件可以避免访问无效内存和无效计算是一个重要的优化。另外在进入内层循环对val dp_cur[j][k]进行转移前先判断val是否为0。如果为0说明这个状态不可达可以直接continue跳过后续所有转移计算。这个剪枝在状态稀疏时效果显著。4.4 初始化与答案统计初始化dp[0][0][0]1其他为0。最终答案是对所有处理完N行后的状态求和sum(dp[N][j][k]) for all j, k where jkm。注意最终状态j和k可以是任意满足约束的值因为只要没有行再被处理列的状态就固定了都是合法的。5. 从解题到举一反三这类DP问题的模式解决P2051中国象棋问题不仅仅是AC一道题更重要的是掌握这一类动态规划问题的思考模式。我们可以总结出以下几个关键点识别约束的“维度”和“可聚合性”问题的约束是行和列的数量限制。当约束是针对行、列、对角线等“线”性区域的计数时就要考虑能否用状态来记录这些“线”的当前情况。如果每一行/列的约束是独立的且状态有限如本题每列炮数只有0,1,2三种就具备了聚合的基础。设计“计数型”状态当直接记录每个位置不可行时考虑记录满足某种条件的“线”的数量。本题的状态dp[i][j][k]本质是前i行使得有j列处于“A状态”1炮k列处于“B状态”2炮。这是一种非常经典的“将具体分布抽象为数量统计”的思想在众多计数DP问题中都有应用例如某些铺砖问题、染色问题。用组合数学进行转移状态转移的本质是在当前宏观统计j个A类列k个B类列下进行一系列操作放棋子并计算操作后落到哪种新的宏观统计j’, k’的方案数。这个方案数通常用组合数来计算从哪几类列中选多少个出来进行操作。这就要求我们具备扎实的组合数学基础能够清晰地分类讨论。滚动数组是空间优化的好朋友对于这种线性按行或按列推进的DP如果当前状态只依赖于上一阶段的状态一定要想到滚动数组。这几乎成了标准操作能极大降低空间开销避免内存超限。模运算下的细节对于计数问题答案通常很大需要取模。要特别注意中间计算过程的溢出以及组合数中除法在模意义下的处理使用逆元。我个人在初次接触这类问题时最容易犯的错误是在状态转移的分类讨论中出现遗漏或重复。我的经验是像本章节第3部分那样严格按照“操作对象炮的数量”和“操作对象来自的类别”这两个维度画出清晰的决策树。例如放2个炮就先固定是2个然后枚举这两个炮来自的列类别组合(0,0), (0,1), (1,1)。这样就能系统性地覆盖所有情况避免思维混乱。另一个实践中的技巧是在代码编写时可以将组合数计算封装成函数比如C2(x)返回x*(x-1)/2 % MOD这样让转移方程看起来更清晰也减少了出错概率。同时对于取模操作建议使用(a b) % MOD(a * b) % MOD这样的写法并在关键步骤后检查是否会出现负数特别是在做减法时要(a - b MOD) % MOD这些都是竞赛中常见的“坑点”。这道题的价值在于它把一个看似复杂的棋盘问题通过巧妙的状态定义转化为了一个关于“类”的计数DP。掌握这种思想再遇到类似“每行每列不超过K个”、“相邻不能相同”等带有行列约束的计数问题时你就能有一个清晰的思考方向尝试用状态记录每行/每列当前已达到某种条件的数量从而进行动态规划。