面试题12:矩阵中的路径
从任意起点按四邻接匹配字符串,以访问标记限制单条路径,并在分支失败时撤销选择完成回溯。
学习目标
- 能用回溯法在矩阵中沿四邻接格子匹配字符串路径
- 能解释访问标记只属于当前路径、分支失败时撤销选择
- 能处理空字符串、单字符与路径复用约束等边界
从 BFCE 路径为何成立开始
先预测:在作者的3行4列矩阵中,B→F→C→E每一步如何移动?目标ABFB前三个字符可以走A→B→F,为什么最后一个B不能回到已经经过的格子?
A B T G
C F C S
J D E H“矩阵中的路径”要求从任意格开始,每一步只能进入上下左右相邻格子,路径依次包含目标字符串全部字符,同一个格子在一条路径中不能重复进入。BFCE可从第一行第二列B开始,向下到F、向右到C、向下到E,所以存在。
路径不是矩阵内存的一段连续字符,也不能走对角线。矩阵在作者接口中按行展开为一维数组,坐标(row,col)对应row * cols + col;邻接关系仍由行列坐标判断,不能拿一维下标加减1后忘记跨行边界。
从候选起点匹配
遍历每个格子作为起点,若与字符串首字符相等则继续沿四邻接匹配。
搜索状态必须包含三个维度
一次递归需要知道当前行列、即将匹配的字符串下标,以及哪些格子已经被本条路径使用。这三部分构成↡。只记录坐标不知道要匹配哪个字符,只记录字符串下标不知道从哪里继续,只记录二者又可能错误复用旧格子。
作者从矩阵每个格子尝试作为起点,因为目标首字符可能出现多次,真正可行的路径不一定从第一次出现的位置开始。核心函数依次拒绝越界、字符不等和已访问格;匹配成功后推进目标下标,并尝试左、上、右、下四个方向。
这种“选择一个候选、继续搜索、失败后恢复再试下一候选”的方法叫。
| 状态 | 判断 | 动作 | 访问标记 |
|---|---|---|---|
| 越界 | 当前坐标不在矩阵内 | 返回false | 不改变状态 |
| 字符不匹配 | 格子字符不是word[index] | 返回false | 不改变状态 |
| 格子已访问 | 本条路径已经使用它 | 返回false | 禁止复用 |
| 匹配且未访问 | 标记后尝试四个邻居 | 任一成功就成功 | 全部失败时撤销 |
| index到达长度 | 所有字符均已匹配 | 返回true | 找到完整路径 |
终止条件必须和下标语义一致。若index表示当前待匹配字符,可以在确认当前格等于word[index]后,用index + 1 == word.size()判断完成;也可以像作者一样先递增pathLength,在下一层看到字符串结束符时返回成功。两种写法都正确,混用会产生少匹配或越界读取。
↡只属于当前路径
匹配当前格后,要设置。它防止ABFB沿A→B→F后回到同一个B,也防止两个格之间来回移动无限延长路径。
若四个方向都无法完成剩余字符串,当前选择失败,返回上一层前必须执行:把当前格恢复为未访问。否则第一个失败起点会永久封禁格子,后续起点和兄弟分支即使有合法路径也无法使用它。
成功分支可以立即返回,但工程实现仍可在返回前统一撤销,使求解器对象保持干净,便于重复调用、调试和枚举所有路径。访问限制的语义是“同一条候选路径不能复用”,不是“整次搜索中一个格只能看一次”。
下面用std::vector<unsigned char>管理标记内存。它在所有返回路径上自动释放,避免作者源码找到路径后提前return true而跳过delete[] visited造成的泄漏。
#include <span>
#include <string_view>
#include <vector>
class MatrixPathSolver {
public:
bool hasPath(std::span<const char> matrix,
std::size_t rows,
std::size_t cols,
std::string_view word) {
if (rows == 0 || cols == 0 ||
matrix.size() != rows * cols) {
return false;
}
if (word.empty()) return true;
if (word.size() > matrix.size()) return false;
matrix_ = matrix;
rows_ = rows;
cols_ = cols;
word_ = word;
visited_.assign(matrix.size(), 0);
for (std::size_t row = 0; row < rows_; ++row) {
for (std::size_t col = 0; col < cols_; ++col) {
if (dfs(static_cast<int>(row),
static_cast<int>(col), 0)) {
return true;
}
}
}
return false;
}
private:
bool dfs(int row, int col, std::size_t index) {
if (row < 0 || col < 0 ||
row >= static_cast<int>(rows_) ||
col >= static_cast<int>(cols_)) {
return false;
}
const auto pos =
static_cast<std::size_t>(row) * cols_ +
static_cast<std::size_t>(col);
if (visited_[pos] || matrix_[pos] != word_[index]) {
return false;
}
if (index + 1 == word_.size()) return true;
visited_[pos] = 1;
const bool found =
dfs(row, col - 1, index + 1) ||
dfs(row - 1, col, index + 1) ||
dfs(row, col + 1, index + 1) ||
dfs(row + 1, col, index + 1);
visited_[pos] = 0;
return found;
}
std::span<const char> matrix_;
std::string_view word_;
std::vector<unsigned char> visited_;
std::size_t rows_ = 0;
std::size_t cols_ = 0;
};这里先用有符号row/col做四方向运算,再检查非负,避免无符号下标从0减1后绕回极大值。只有通过边界检查才转换为size_t并计算线性位置。
正确性来自穷举所有合法下一步
外层循环覆盖每个可能起点。对一个匹配起点,递归只进入边界内、字符匹配且未被当前路径使用的格子,因此产生的每条候选都满足路径规则。每层枚举左、上、右、下四种合法方向,任何完整路径的下一步必在其中,不会漏解。
当字符串最后一个字符匹配时返回真,说明路径坐标序列长度等于目标长度,每个位置字符对应且无重复格;所以不会误报。若所有起点及其所有合法分支都失败,因为搜索已经穷举所有可能路径,所以矩阵中不存在目标路径。
设矩阵有R×C个格,目标长度为L。起点最多R C个,第一步后因为前一格已访问,内部节点至多继续三个未立即回退方向,粗略上界可写O(RC · 3^(L-1)),更宽松也常写O(RC · 4^L)。边界、字符不匹配和访问限制通常会大幅剪掉分支,但最坏仍是指数级。
访问数组占O(RC)空间,递归深度最多L,调用栈O(L)。若允许临时修改矩阵,可把当前字符替换为哨兵并在返回前恢复,省去访问数组,但会改变输入、要求字符域中存在安全哨兵,而且并发读取不再安全。
↡必须保持答案集合不变
不改变正确性的提前排除称为。最直接的必要条件是目标长度不能超过格子总数,因为路径不能复用格子。还可以比较字符频次:若目标中某字符需求量超过矩阵拥有量,必定无解。
为了减少分支,可比较目标首尾字符在矩阵中的频次;若末字符更少,反转目标字符串后再搜索。路径可以反向行走,↡关系对称,所以存在正向路径当且仅当存在反向路径。让稀有字符作为起点能减少外层命中和早期分叉,但这只是性能优化,不改变返回值。
#include <algorithm>
#include <array>
#include <string>
bool frequencyAllows(std::span<const char> matrix,
std::string_view word) {
std::array<std::size_t, 256> available{};
std::array<std::size_t, 256> required{};
for (unsigned char ch : matrix) ++available[ch];
for (unsigned char ch : word) ++required[ch];
for (std::size_t i = 0; i < available.size(); ++i) {
if (required[i] > available[i]) return false;
}
return true;
}
std::string rarerEndFirst(std::span<const char> matrix,
std::string word) {
const auto firstCount =
std::count(matrix.begin(), matrix.end(), word.front());
const auto lastCount =
std::count(matrix.begin(), matrix.end(), word.back());
if (lastCount < firstCount) {
std::reverse(word.begin(), word.end());
}
return word;
}频次满足只是必要条件,不是充分条件;矩阵可能有足够字符却无法按邻接顺序连接。不能因为计数通过就直接返回真,仍要↡验证空间排列。
接口契约决定空字符串答案
作者接口先拒绝空矩阵、非法行列和空字符串指针;非空矩阵配空C字符串时,核心函数看到结束符会返回真,但官方测试没有覆盖这一点。现代接口应明确约定:通常空字符串由空路径匹配,返回真;空矩阵寻找非空字符串返回假;矩阵尺寸与扁平存储长度不一致返回错误或假。
空字符串与空指针不是同一概念。string_view{}是长度0的合法目标,const char* == nullptr是没有字符串对象。把二者都写成“空”会让调用者无法预期行为。若业务要求路径至少含一个格,可以明确把空目标定义为假,但测试必须锁定该决定。
作者使用new bool[rows * cols]并在普通失败结束时释放,却在找到路径后从循环内直接返回,未释放数组。RAII 容器不仅简化代码,更重要的是让成功、失败、异常每个出口都遵守相同资源生命周期。
官方测试覆盖路径长度与复用约束
官方Test1验证BFCE成功,Test2验证另一矩阵中的SEE成功,Test3用ABFB验证同一B格不能复用。5行8列矩阵再测试两条长路径成功,以及字符近似但邻接不成立、目标多一个尾字符时失败。
全A的3行4列矩阵对长度12返回真,对长度13返回假,精确卡住“每格最多用一次”的容量边界。单格A找A为真、找B为假,最后空指针与0尺寸返回假。
#include <cassert>
#include <string_view>
void testMatrixPath() {
MatrixPathSolver solver;
constexpr std::string_view board = "ABTGCFCSJDEH";
assert(solver.hasPath(
std::span(board.data(), board.size()), 3, 4, "BFCE"));
assert(!solver.hasPath(
std::span(board.data(), board.size()), 3, 4, "ABFB"));
constexpr std::string_view allA = "AAAAAAAAAAAA";
assert(solver.hasPath(
std::span(allA.data(), allA.size()), 3, 4,
"AAAAAAAAAAAA"));
assert(!solver.hasPath(
std::span(allA.data(), allA.size()), 3, 4,
"AAAAAAAAAAAAA"));
constexpr std::string_view one = "A";
assert(solver.hasPath(
std::span(one.data(), one.size()), 1, 1, "A"));
assert(!solver.hasPath(
std::span(one.data(), one.size()), 1, 1, "B"));
}属性测试可以在小矩阵上用显式路径枚举器作为预言机,并随机生成目标。还应重复调用同一个求解器,确保上次成功或失败没有残留访问标记;这能直接发现“早返回不清理”或“成员数组未重置”的状态污染。
短路求值与返回路径的状态管理
四个递归调用用逻辑或连接时会短路:左方向成功后,上、右、下不再执行。这正是“只判断是否存在”所需的提前结束,但撤销当前格不能只写在if (!found)里;若求解器对象会复用,成功返回也应恢复本层标记。示例把四方向结果先存入found,随后无条件清除visited[pos],再返回结果,因此成功和失败出口对外状态一致。
作者代码只在!hasPath时撤销。对一次性布尔查询,它找到答案后立即退出,逻辑结果正确;但访问数组也因外层提前返回而泄漏。把资源所有权与搜索状态恢复分开看更清楚:RAII 负责内存必释放,无条件撤销负责对象可重复调用,两者解决不同问题。
若接口还要返回实际坐标路径,可维护一个vector<Position> path:匹配格子时push_back,失败回退时pop_back,完整匹配时复制或移动该路径给调用者。若要枚举所有路径,成功后也不能停止,更不能保留访问标记;应记录当前答案后继续撤销并探索兄弟分支。
返回一条路径与只返回布尔值的渐进搜索上界相同,但输出所有路径可能本身就是指数数量,任何算法都必须为这些结果付出相应时间和存储。需求先限定“存在、任意一条、最短一条还是全部”,才能决定何时短路以及保存多少状态。
本章练习
练习
问题 1: 回溯法为什么需要撤销访问标记?
问题 2: 搜索状态包含哪三个维度?
问题 3: 空字符串或单字符路径应返回什么?
概念说明
本章核心概念包括:回溯法,访问标记与撤销选择。理解这些概念是掌握解题方法的关键,需要结合具体示例与边界条件反复练习。
本章回顾
- 矩阵中的路径可从任意格开始,每步只能进入上下左右相邻格子。
- 搜索状态由当前坐标、目标字符串下标和本路径访问集合共同组成。
- 回溯法先匹配并标记格子,再递归尝试四方向,失败时撤销选择。
- 访问标记与撤销选择保证同一路径不复用格子,同时不阻塞其他分支。
- 最坏时间随目标长度指数增长,访问数组为
O(RC),递归栈为O(L)。 - 长度、字符频次和稀有端点优化都是必要条件剪枝,不能代替邻接搜索。
- 空字符串、空矩阵与尺寸不匹配必须在接口契约中分别定义。
- RAII 标记容器修复作者成功早返回路径上的手工内存泄漏风险。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 回溯法
- 深度优先搜索的一种,失败时回退并撤销选择,穷举所有可行解。
- 访问标记
- 记录当前路径已占用格子的标志,防止重复经过。
- 剪枝
- 提前排除不可能的分支,保持答案集合不变。
- 搜索状态
- 位置、已匹配下标与访问标记的组合,决定下一步移动。
- 四邻接
- 上下左右四个相邻格子,是路径移动的唯一方向集。
- 路径复用
- 同一条路径不得重复经过已用格子,由访问标记限制。