回溯算法本质上是一种深度优先搜索策略,它把问题的求解过程看成一棵决策树的遍历。每一步做出一个选择并递归深入,当发现当前路径无法产生正确答案时,就撤销上一次选择(也就是回溯),回到上一个决策点换一条路继续尝试。PHP虽然是 Web 开发中最常见的脚本语言,但其完整的递归支持使它完全可以用来实现回溯算法。本文将系统讲解回溯算法的原理框架,并用PHP逐一实现全排列、组合总和、N皇后这三个经典问题,同时分享剪枝优化的实用技巧。

一、回溯算法的核心原理与通用框架
回溯算法的核心思想可以概括为一句话:走不通就回头。它把解的构造过程分解为若干个阶段,每个阶段面临若干候选选择。算法依次尝试每个选择,进入下一层递归;如果递归返回后发现当前选择不可行,就撤销这个选择,换下一个候选。这个撤销的动作就是回溯名称的由来。
用PHP实现回溯算法时,通常采用一个递归函数配合两个变量:一个是当前路径(保存已经做出的选择),另一个是可用选择集合(记录哪些选项还没被使用)。递归函数的骨架大致如下:
function backtrack($path, $choices) {
if (满足终止条件) {
记录结果;
return;
}
foreach ($choices as $choice) {
做选择; // 将 choice 加入 path
backtrack($path, 新的choices);
撤销选择; // 将 choice 从 path 移除
}
}这个框架的关键在于做选择与撤销选择必须严格对称。PHP的数组提供了array_push、array_pop以及unset等函数,可以方便地维护路径状态。需要注意的是,PHP数组作为函数参数传递时是值拷贝,如果不小心直接把数组传进递归函数,每次递归都会复制一份数据,虽然这样天然实现了状态隔离,但会带来额外的内存开销。因此在性能敏感的场景下,可以显式使用引用符号传递路径数组,此时就必须保证撤销操作的正确性。
终止条件的设计同样重要。终止条件决定了何时把当前路径收集为答案,比如全排列问题中路径长度等于元素个数就收获一个解,而N皇后问题则是在放置完最后一行皇后时记录棋盘。条件写错往往导致死循环或者漏解,写代码前应先明确解的长度和形状。
二、实战例题一:用PHP求解全排列问题
全排列是最适合入门回溯的问题。给定一个不含重复数字的数组,输出其所有可能的排列。例如输入[1,2,3],输出六种排列。解空间树的第一层有三个分支,第二层有两个分支,依此类推,整棵树恰好枚举出所有排列。
function permute($nums) {
$result = [];
$path = [];
$used = array_fill(0, count($nums), false);
$backtrack = function () use (&$backtrack, &$result, &$path, &$used, $nums) {
if (count($path) === count($nums)) {
$result[] = $path; // 收获一个完整排列
return;
}
for ($i = 0; $i < count($nums); $i++) {
if ($used[$i]) {
continue; // 该元素已被使用,跳过
}
$path[] = $nums[$i]; // 做选择
$used[$i] = true;
$backtrack(); // 进入下一层
array_pop($path); // 撤销选择
$used[$i] = false; // 回溯
}
};
$backtrack();
return $result;
}
print_r(permute([1, 2, 3]));代码中用$used布尔数组标记元素是否已在路径中,比每次用in_array检查更高效,后者会让时间复杂度从常数级判断退化为线性扫描。由于PHP闭包内访问外部变量需要使用use引入引用,这里通过&$path的方式让闭包直接操作同一个数组,避免拷贝。
如果数组中包含重复元素,比如[1,1,2],直接套用上面的代码会得到重复排列。解决办法是先对数组排序,然后在循环中增加剪枝判断:当当前元素与前一个元素相同,且前一个元素在同一层还未被使用时,跳过当前分支。这一步剪枝能保证相同元素只按固定顺序被选取,从而去除重复解。
三、实战例题二:组合总和与子集问题
组合类问题与排列的区别在于选择顺序无关。以组合总和为例:给定无重复元素数组和一个目标值,找出所有使数字和等于目标值的组合,同一个数字可以被无限次选取。实现时每层递归的起始索引从当前索引开始,而不是从零开始,这样天然避免了同一组合的不同顺序版本被重复收集。
function combinationSum($candidates, $target) {
$result = [];
$path = [];
sort($candidates); // 排序便于剪枝
$backtrack = function ($start, $remain) use (&$backtrack, &$result, &$path, $candidates) {
if ($remain === 0) {
$result[] = $path;
return;
}
for ($i = $start; $i < count($candidates); $i++) {
if ($candidates[$i] > $remain) {
break; // 剪枝:剩余候选已大于目标值
}
$path[] = $candidates[$i];
$backtrack($i, $remain - $candidates[$i]); // 允许重复选,起点仍为 $i
array_pop($path);
}
};
$backtrack(0, $target);
return $result;
}
print_r(combinationSum([2, 3, 6, 7], 7));这段代码体现了两个重要优化。第一个是排序加剪枝:先对候选数组排序,循环中一旦发现当前候选值大于剩余目标,直接break,因为后面的值只会更大,整棵子树都可以被砍掉。第二个是允许重复选取时的递归调用传的是$i而非$i+1,表示当前元素在下一层仍可继续使用;如果题目要求每个数字只能用一次,改成$i+1即可。
子集问题是另一个变体:枚举数组所有可能的子集。它的回溯代码更简单,因为每个节点(不只是叶子节点)都是一个合法答案,进入递归时直接把当前路径加入结果集即可。这类问题的共同规律是:排列强调顺序要维护used数组,组合与子集不强调顺序则通过start起始索引去重。
四、进阶挑战:N皇后问题的PHP实现
N皇后问题要求在N乘N棋盘上放置N个皇后,使得任意两个皇后不在同一行、同一列或同一对角线上。这是检验回溯算法功力的经典题目,每行只放一个皇后,递归深度等于行号,列的选择构成分支。
function solveNQueens($n) {
$result = [];
$queens = []; // 键为行,值为列
$isValid = function ($row, $col) use ($queens) {
foreach ($queens as $r => $c) {
if ($c === $col || abs($r - $row) === abs($c - $col)) {
return false; // 同列或同对角线冲突
}
}
return true;
};
$backtrack = function ($row) use (&$backtrack, &$result, &$queens, $n, &$isValid) {
if ($row === $n) {
$board = [];
for ($i = 0; $i < $n; $i++) {
$line = array_fill(0, $n, '.');
$line[$queens[$i]] = 'Q';
$board[] = implode('', $line);
}
$result[] = $board;
return;
}
for ($col = 0; $col < $n; $col++) {
if ($isValid($row, $col)) {
$queens[$row] = $col; // 放置皇后
$backtrack($row + 1); // 处理下一行
unset($queens[$row]); // 撤销,回溯
}
}
};
$backtrack(0);
return $result;
}
print_r(solveNQueens(4));冲突检测集中在isValid中:同列判断直接比较列号,同对角线判断利用行列差绝对值相等的几何性质。这里没有使用二维数组模拟棋盘,而是用一维数组$queens记录每行皇后的列位置,既节省内存又让冲突检查更简洁,这是解决N皇后问题的常用技巧。
当N增大时解空间呈指数级增长,可以考虑用三个布尔数组分别记录列、主对角线、副对角线是否被占用,把冲突检测降为常数时间。对角线索引的映射规律为:主对角线用行号减列号加N减一,副对角线用行号加列号。这种空间换时间的思路在竞赛和面试中都非常有用。
五、回溯算法的性能优化与实战建议
回溯算法的时间复杂度通常是指数级或阶乘级,因此剪枝是提升效率的第一手段。剪枝分为两类:可行性剪枝指在递归前判断当前选择是否违反约束,直接跳过非法分支;最优性剪枝指在求最优解时预估当前路径的下界,如果已经不可能优于已知答案就提前返回。前面的组合总和代码中排序后提前break就是典型的可行性剪枝。
PHP实现回溯还需要注意几个语言层面的细节。第一,递归深度受配置项限制,深递归可能触发段错误,可以在脚本开头调用ini_set调整相关参数,或尽量改写为显式栈的迭代版本。第二,收集结果时务必使用$result[] = $path这种值拷贝方式保存快照,如果写成$result[] = &$path或后续引用传递,最终结果里所有条目都会指向同一个被清空的数组,这是最常见的坑。第三,对于大规模输入,回溯穷举可能不现实,应评估能否改用动态规划或记忆化搜索。
总结来说,PHP实现回溯算法的关键在于三步:明确决策树的每一层代表什么选择,写对称的做选择与撤销选择代码,设计准确的终止条件和剪枝判断。掌握了全排列、组合总和、N皇后这三个模板,绝大多数搜索类题目都可以套用同样的思路解决,也可以把这套方法应用到任务调度、路径规划等实际业务场景中。