在编程里,大多数问题可以用循环搞定——重复执行一段代码直到条件不满足。但有一类问题,它的结构本身就是嵌套的、层层递进的,用循环处理起来会让人觉得别扭。这时候,递归提供了一种思路更自然的解法。
递归就是函数在自己内部调用自己。 每次调用都会产生一个新的函数实例,带着不同的参数去解决一个更小的同类问题,直到问题小到可以直接解决(这个终止条件叫基准情形),然后逐层返回结果。
不过递归不是没有代价。PHP对递归深度有限制,建议控制在200层以内。超过这个层数,栈空间可能耗尽,导致脚本崩溃。所以用递归之前,心里要对数据规模有个预判。
递归的工作原理
一个递归函数通常包含两个部分:
-
基准情形(Base Case):递归的出口。当条件满足时,函数不再调用自己,而是直接返回一个确定的值。
-
递归情形(Recursive Case):函数用不同的参数调用自己,把原问题拆成一个更小的同类问题。
用伪代码表示就是这样的结构:
function 递归函数(参数) {
if (达到基准情形) {
return 基准值; // 停止递归,开始回溯
} else {
// 做一些处理
递归函数(更小的参数); // 继续拆解问题
}
}
调用链是单向深入的——从较大的问题开始,一层层往下拆,直到触及基准情形,然后逐层返回计算结果。整个过程像一个先钻到底再冒上来的过程。
实例1:计算阶乘
阶乘是一个理解递归的经典案例。n的阶乘(写作n!)等于1×2×3×...×n。用递归的方式来描述就是:
-
基准情形:0的阶乘等于1。
-
递归情形:n的阶乘等于n乘以(n-1)的阶乘。
<?php
function factorial($n) {
// 基准情形:0! = 1
if ($n == 0) {
return 1;
}
// 递归情形:n! = n × (n-1)!
return $n * factorial($n - 1);
}
echo "5的阶乘 = " . factorial(5);
?>
输出:5的阶乘 = 120
调用过程拆解:
factorial(5)
= 5 × factorial(4)
= 5 × (4 × factorial(3))
= 5 × (4 × (3 × factorial(2)))
= 5 × (4 × (3 × (2 × factorial(1))))
= 5 × (4 × (3 × (2 × (1 × factorial(0)))))
= 5 × (4 × (3 × (2 × (1 × 1))))
= 5 × 4 × 3 × 2 × 1 × 1 = 120
当$n减到0时,factorial(0)直接返回1,不再继续调用自己。然后计算结果从最底层一层层往回传递,上一层拿到返回值后和当前的$n相乘,再往上传,最终factorial(5)拿到120。
个人经验分享: 阶乘是理解递归原理的好例子,但项目开发中如果需要算阶乘,用for循环往往更直观,也不会有栈溢出风险。递归主要用在那些“数据结构本身就是递归定义”的场景——比如树形目录、组织架构、嵌套评论等。这些场景里,递归代码的结构和数据本身的嵌套结构是对应的,写起来思路更顺畅。
实例2:遍历多层目录结构
文件系统是一个典型的树形结构:一个文件夹里可能有文件和子文件夹,子文件夹里又可能有文件和子子文件夹,层层嵌套。这种结构用递归来形容是比较贴切的——读取文件夹内容的逻辑,对每一层子文件夹都适用。
下面是一个递归遍历目录的例子,它把一个指定路径下所有文件和子文件夹以嵌套列表的形式展示出来。
<?php
// 定义递归函数,遍历指定路径
function listDirectory($dirPath) {
// 尝试打开目录
if (!($handle = opendir($dirPath))) {
echo "无法打开目录:{$dirPath}";
return;
}
$entries = [];
// 读取目录内容,忽略 . 和 ..
while (($item = readdir($handle)) !== false) {
if ($item !== '.' && $item !== '..') {
// 给目录项后面加个斜杠标记
if (is_dir("{$dirPath}/{$item}")) {
$item .= '/';
}
$entries[] = $item;
}
}
closedir($handle);
// 按字母排序
sort($entries);
// 输出当前层级的列表
echo "<ul>";
foreach ($entries as $entry) {
echo "<li>{$entry}</li>";
// 如果是目录(以/结尾),递归调用自己读取子目录内容
if (substr($entry, -1) === '/') {
// 去掉末尾斜杠得到真实的子目录名
$subDirName = substr($entry, 0, -1);
echo "<ul>";
listDirectory("{$dirPath}/{$subDirName}");
echo "</ul>";
}
}
echo "</ul>";
}
// 调用:遍历名为 "photos" 的目录
$targetDir = "photos";
echo "<h2>目录 '{$targetDir}' 的内容:</h2>";
listDirectory($targetDir);
?>
假设photos目录的结构如下:
photos/
├── image1.jpg
├── image2.png
├── summer/
│ ├── beach.jpg
│ ├── sunset.png
│ └── trip/
│ ├── hotel.jpg
│ └── food.png
└── winter/
├── snow.jpg
└── ski.png
运行后输出的HTML结构大致是:
目录 'photos' 的内容:
• summer/
• beach.jpg
• sunset.png
• trip/
• hotel.jpg
• food.png
• winter/
• snow.jpg
• ski.png
• image1.jpg
• image2.png
代码执行流程分析:
listDirectory('photos')第一次被调用时:
-
打开
photos目录,读取所有内容。 -
发现
summer/是个目录,把它放进列表。 -
发现
winter/是个目录,放进去。 -
发现
image1.jpg和image2.png是文件,放进去。 -
排序后,先处理
summer/——检测到末尾有斜杠,调用listDirectory('photos/summer')。此时外层函数的执行暂停,内存里保存着当前状态,进入内层递归。 -
内层
listDirectory('photos/summer')重复同样的流程,发现trip/目录,再次递归调用listDirectory('photos/summer/trip')。 -
trip/目录里只有文件,没有子目录。foreach走完,没有触发新的递归调用,当前实例结束,控制权返回给summer那一层。 -
summer层继续处理剩余条目,完成后返回给photos层。 -
photos层继续处理winter/,触发新的递归调用,winter/里没有子目录,处理完返回。 -
最外层
photos完成,整个脚本结束。
基准情形在这里不是显式的一个if判断,而是“当前目录没有子文件夹”。当目录里只有文件时,foreach里的递归调用条件substr($entry, -1) === '/'不成立,函数不会继续调用自己,执行完就自然返回。
本节课程知识要点
递归用好了很精妙,用不好会踩坑。以下几个要点值得你在使用递归时注意:
-
基准情形是递归的命门。没有它,函数会无限调用自己直到内存耗尽。写递归函数时,先把“什么情况下停止”想清楚,再写递归调用部分。
-
每次递归调用都应该让问题规模变小。参数要朝着基准情形逼近。如果参数没变化,递归同样会无限进行。
-
PHP递归深度建议控制在200层以内。超过这个限制可能导致栈溢出。处理深层嵌套数据时,要先预估深度,必要的话考虑用循环替代。
-
递归消耗的内存比循环大。每次函数调用都会在调用栈上分配新的内存空间。处理大规模数据时,迭代通常更节省资源。
-
树形结构遍历是递归的天然用武之地。像目录遍历、分类树、菜单嵌套、评论回复链这类“一个东西里面套着同类东西”的数据,递归在思路上更贴合数据结构本身。
递归和循环怎么选
很多递归能做的事,循环也能做。选择上有个粗略的原则:
-
数据是扁平的(比如一个索引数组),用循环更直接。
-
数据是嵌套的、层级不确定的(比如多层目录、组织架构、JSON的深层解析),用递归更自然。
-
问题可以通过数学递推公式定义(比如斐波那契数列),递归写法贴近数学表达,但性能可能不如循环,需要权衡。
主观建议: 在代码号学习编程的过程中,递归是一个值得花时间理解透彻的概念。不过在项目里,我用递归用得相对克制。遇到树形结构遍历时它确实是个比较顺手的工具,但如果数据来源不可控(比如用户上传的深层嵌套zip文件),我会加一个深度计数器,到达一定层级就强制返回并记录警告,避免递归失控。这是一种比较务实的防御性编程习惯。
递归是PHP函数里一个比较有特色的能力。它让函数可以用一种“自己解决自己”的方式去攻克嵌套结构的问题。理解了基准情形和递归情形的配合,以及递归调用的内存模型,你在遇到树形数据或递推问题时,就多了一种解法思路。