1. 问题背景与理解
棋盘上的过河卒问题是一个经典的动态规划练习题。题目描述通常为:在棋盘上有一个过河卒,需要从起点(0,0)移动到目标点(n,m)。卒子只能向右或向下移动,且棋盘上存在若干障碍点(通常包含一个"马"的位置及其控制点)。我们需要计算出卒子从起点到终点的所有可能路径数。
这个问题源自中国象棋的基本规则:
- 卒子过河前只能前进
- 过河后可以前进、左、右移动
- 但在这个简化问题中,我们通常只考虑向右和向下两个方向
2. 解题思路分析
2.1 动态规划基本思路
这是一个典型的计数型动态规划问题。我们可以定义dp[i][j]表示从起点(0,0)到点(i,j)的路径数量。状态转移方程为:
code复制如果(i,j)不是障碍点:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
否则:
dp[i][j] = 0
边界条件:
- dp[0][0] = 1(起点)
- 第一行和第一列需要特殊处理,因为它们的路径只能来自一个方向
2.2 障碍点处理
题目中通常会给出一个"马"的位置,以及马控制的8个点(中国象棋中马的"日"字走法位置)。这些点都是不能经过的障碍点。我们需要:
- 标记所有障碍点
- 在动态规划过程中,遇到障碍点就直接跳过(路径数为0)
2.3 边界情况考虑
需要特别注意几种边界情况:
- 起点或终点本身就是障碍点
- 马的控制点超出棋盘范围
- 棋盘只有一行或一列的特殊情况
3. 详细实现步骤
3.1 初始化棋盘和障碍
cpp复制const int N = 25;
long long dp[N][N]; // 路径数可能很大,用long long
bool obstacle[N][N]; // 标记障碍点
int n, m; // 目标点坐标
int hx, hy; // 马的位置
// 初始化障碍
void init_obstacle() {
// 马的位置和8个控制点
int dx[] = {-2, -1, 1, 2, 2, 1, -1, -2};
int dy[] = {1, 2, 2, 1, -1, -2, -2, -1};
obstacle[hx][hy] = true;
for (int i = 0; i < 8; i++) {
int nx = hx + dx[i];
int ny = hy + dy[i];
if (nx >= 0 && nx <= n && ny >= 0 && ny <= m) {
obstacle[nx][ny] = true;
}
}
}
3.2 动态规划实现
cpp复制long long solve() {
if (obstacle[0][0] || obstacle[n][m]) {
return 0; // 起点或终点是障碍
}
dp[0][0] = 1;
// 处理第一行
for (int j = 1; j <= m; j++) {
if (obstacle[0][j]) {
dp[0][j] = 0;
} else {
dp[0][j] = dp[0][j-1];
}
}
// 处理第一列
for (int i = 1; i <= n; i++) {
if (obstacle[i][0]) {
dp[i][0] = 0;
} else {
dp[i][0] = dp[i-1][0];
}
}
// 处理其他点
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (obstacle[i][j]) {
dp[i][j] = 0;
} else {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
}
return dp[n][m];
}
4. 优化与注意事项
4.1 空间优化
我们可以将二维dp数组优化为一维数组,因为每次计算只需要左边和上边的值:
cpp复制long long dp_optimized[N];
long long solve_optimized() {
if (obstacle[0][0] || obstacle[n][m]) {
return 0;
}
dp_optimized[0] = 1;
// 初始化第一行
for (int j = 1; j <= m; j++) {
dp_optimized[j] = obstacle[0][j] ? 0 : dp_optimized[j-1];
}
for (int i = 1; i <= n; i++) {
// 处理每行的第一个元素
dp_optimized[0] = obstacle[i][0] ? 0 : dp_optimized[0];
for (int j = 1; j <= m; j++) {
if (obstacle[i][j]) {
dp_optimized[j] = 0;
} else {
dp_optimized[j] += dp_optimized[j-1];
}
}
}
return dp_optimized[m];
}
4.2 常见错误与调试技巧
-
数组越界:确保所有数组访问都在有效范围内,特别是马的控制点可能超出棋盘
- 调试方法:打印整个棋盘和障碍标记
-
整数溢出:路径数可能非常大,使用long long而不是int
- 检查方法:计算小规模测试用例的预期结果
-
边界条件处理不当:特别注意第一行和第一列的处理
- 测试用例:尝试1x1、1xn、nx1的棋盘
-
障碍点标记错误:确保马的控制点计算正确
- 验证方法:手动计算几个点并与程序输出比较
5. 完整代码示例
cpp复制#include <iostream>
#include <cstring>
using namespace std;
const int N = 25;
long long dp[N][N];
bool obstacle[N][N];
int n, m, hx, hy;
void init_obstacle() {
int dx[] = {-2, -1, 1, 2, 2, 1, -1, -2};
int dy[] = {1, 2, 2, 1, -1, -2, -2, -1};
obstacle[hx][hy] = true;
for (int i = 0; i < 8; i++) {
int nx = hx + dx[i];
int ny = hy + dy[i];
if (nx >= 0 && nx <= n && ny >= 0 && ny <= m) {
obstacle[nx][ny] = true;
}
}
}
long long solve() {
if (obstacle[0][0] || obstacle[n][m]) {
return 0;
}
dp[0][0] = 1;
for (int j = 1; j <= m; j++) {
dp[0][j] = obstacle[0][j] ? 0 : dp[0][j-1];
}
for (int i = 1; i <= n; i++) {
dp[i][0] = obstacle[i][0] ? 0 : dp[i-1][0];
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
dp[i][j] = obstacle[i][j] ? 0 : dp[i-1][j] + dp[i][j-1];
}
}
return dp[n][m];
}
int main() {
cin >> n >> m >> hx >> hy;
memset(obstacle, 0, sizeof(obstacle));
init_obstacle();
cout << solve() << endl;
return 0;
}
6. 复杂度分析与变种问题
6.1 时间复杂度与空间复杂度
- 时间复杂度:O(n*m),需要遍历整个棋盘
- 空间复杂度:
- 原始版本:O(n*m)
- 优化版本:O(m)
6.2 问题变种与扩展
- 不同移动规则:如果卒子可以向上、左移动,问题会变成有环的图,需要用其他算法
- 带权路径:如果每个格子有不同的权重,求最小/最大权重路径
- 三维棋盘:扩展到三维空间中的移动
- 存在传送点:某些特殊格子可以传送到其他位置
7. 测试用例设计
好的测试用例应该包含以下情况:
-
常规情况:
code复制输入:6 6 3 3 预期输出:6 -
起点或终点被阻挡:
code复制输入:1 1 0 0 预期输出:0 -
只有一行或一列:
code复制输入:0 5 0 2 预期输出:0 -
马的控制点超出棋盘:
code复制输入:2 2 0 0 预期输出:2 -
最大规模测试(验证时间和空间):
code复制输入:20 20 10 10 预期输出:较大的数(验证不溢出)
8. 实际应用与总结
过河卒问题虽然看似简单,但它很好地展示了动态规划的基本思想:
- 定义子问题(到每个点的路径数)
- 建立状态转移方程
- 处理边界条件
- 按正确顺序计算
在实际编程竞赛中,这类题目考察的是:
- 对问题的建模能力
- 边界条件的处理
- 代码实现的准确性
建议练习时:
- 先在小棋盘上手动计算,验证思路
- 逐步增加棋盘大小,测试程序的正确性
- 尝试空间优化版本
- 思考问题的各种变种