Глава 20 · С нуля → практика
Рекурсия: категории, меню и обход деревьев
Решаем реальную задачу вложенности, показываем стек вызовов и защищаемся от циклов и чрезмерной глубины.
Рекурсивный результат: от готового списка к дереву категорий
Рекурсия нужна, когда часть данных имеет ту же форму, что весь набор: подраздел содержит подразделы, папка — папки, массив — массивы. В первом маршруте мы уже получили из дерева обычный список этикеток через collectNames и пошаговый возврат. Если параметр и return ещё непонятны, сначала пройдите этот опыт: он показывает значение в месте вызова.
- Внешний вызов ждёт готовый результат всей структуры.
- Вызов для ветви получает только её данные и имеет свои местные переменные.
- Условие остановки завершает конечную ветвь.
- Результат ветви возвращается в ожидающее выражение; обход продолжает соседей.
- В конце внешний код получает итог либо описанную ошибку, а не молчаливо оборванную часть.
Дальше задачи сложнее: дерево категорий может храниться плоскими строками с parent_id, в данных может быть цикл, глубину нужно ограничить, а вместо рекурсивных вызовов можно использовать явный стек. Эти случаи добавляют договоры и проверки; они не меняют базовый маршрут аргумент → местный параметр → тело → return → вызывающее выражение.
Когда функция вызывает себя
Вложенные категории похожи на дерево папок
Нужно вывести весь каталог, хотя заранее неизвестно, сколько уровней подкатегорий у него будет.
- Инструмент
Обрабатываем эту категорию и затем её дочерние категории.
- Ручной
Лист дерева: молотки, отвёртки. Дальше спускаться некуда.
- Электрический
Ветка может содержать дрели и их подкатегории.
- Крепёж
После возврата из ветки «Инструмент» переходим к соседней.
- Саморезы
Тот же алгоритм применяется к очередной дочерней категории.
Рекурсивная функция вызывает себя для дочернего узла. Пустой список детей — базовый случай завершения. Возврат продолжает работу родителя. Для внешних данных нужны предел глубины и защита от циклических связей.
Ранее неизвестная глубина была уже записана вложенными PHP-массивами. Здесь другая исходная форма: каждая категория — отдельная строка с id и номером родителя parent. Нужно получить читаемое меню «Каталог → Одежда → Куртки; Обувь». Порядок входных строк может начинаться с ребёнка, поэтому сначала строим быстрый список детей каждого родителя.
Каталог магазина содержит категории, у каждой могут быть подкатегории такой же формы. Вместо заранее написанных пяти вложенных foreach удобно определить один шаг: обработать текущую категорию, затем применить то же правило к детям. У рекурсии обязательно есть база завершения — отсутствие детей или достигнутый предел. Каждый вызов имеет свои локальные переменные; после окончания дочернего вызова программа возвращается к следующему ребенку родителя.
- Каталог: вывести
- Войти в Одежда: вывести
- Войти в Куртки: вывести, детей нет
- Вернуться в Одежда, затем в Каталог
- Войти в Обувь: вывести
Таблица базы обычно хранит плоские записи id, parent_id, title. Это еще не готовое дерево. Можно один раз сгруппировать записи по parent_id, а затем обходить индекс. Не делайте SQL-запрос для каждого узла: при тысяче категорий получите около тысячи обращений вместо одного ограниченного чтения или подходящего рекурсивного SQL. Порядок исходных строк не обязан ставить родителей раньше детей.
Индекс детей и проверка пути
Исходная строка ['id' => 3, 'parent' => 2, 'title' => 'Куртки'] говорит: категория 3 называется Куртки и находится у родителя 2. Это ещё не вложенная категория. Условный parent = 0 обозначает корень; это договор наших данных, не правило PHP.
<?php
$rows = [
['id' => 3, 'parent' => 2, 'title' => 'Куртки'],
['id' => 1, 'parent' => 0, 'title' => 'Каталог'],
['id' => 2, 'parent' => 1, 'title' => 'Одежда'],
['id' => 4, 'parent' => 1, 'title' => 'Обувь'],
];
$children = [];
foreach ($rows as $row) {
$parent = $row['parent'];
$children[$parent][] = $row;
}
echo 'Ребёнок родителя 0: ', $children[0][0]['title'], "\n";
echo 'Первый ребёнок родителя 1: ', $children[1][0]['title'], "\n";
echo 'Второй ребёнок родителя 1: ', $children[1][1]['title'], "\n";
echo 'Ребёнок родителя 2: ', $children[2][0]['title'], "\n";
Ожидаемый результат
Ребёнок родителя 0: Каталог
Первый ребёнок родителя 1: Одежда
Второй ребёнок родителя 1: Обувь
Ребёнок родителя 2: Куртки
Первый foreach рассматривает каждую строку один раз. Children[parent] — список детей этого родителя, [] добавляет туда целую row. В children[1][1][title] выбираются родитель 1, его второй ребёнок, затем название. Куртки могли прийти раньше Одежды: индекс не требует предварительной сортировки id.
| Параметр printCategories | Значение первого вызова | Что меняет вложенный вызов |
|---|---|---|
| array children | Весь готовый индекс | Читается тот же набор детей; функция его не переписывает. |
| int parent | 0: искать корневые категории | Становится id текущей категории: теперь ищем её детей. |
| array path = [] | Пока нет предков | Копия пути получает текущий id; повтор id в этой ветви означает цикл. |
| int depth = 0 | Корень без отступа | Увеличивается на 1 для следующего уровня; ограничен максимумом 20. |
| : void | Полезного возвращаемого значения нет | Каждый вызов печатает свою часть отчёта; выполнение затем возвращается родителю. |
$children[$parent] ?? [] даёт пустой список, если детей нет: foreach тогда не запускает тело, вызов заканчивается. str_repeat(' ', $depth) возвращает строку из повторённых пар пробелов для отступа; echo сразу использует её. isset($path[$id]) проверяет, встречали ли текущего предка в этой ветви. Исключение останавливает неверный путь; оно не исправляет данные.
<?php
$rows = [
['id' => 3, 'parent' => 2, 'title' => 'Куртки'],
['id' => 1, 'parent' => 0, 'title' => 'Каталог'],
['id' => 2, 'parent' => 1, 'title' => 'Одежда'],
['id' => 4, 'parent' => 1, 'title' => 'Обувь'],
];
$children = [];
foreach ($rows as $row) {
$children[$row['parent']][] = $row;
}
function printCategories(array $children, int $parent, array $path = [], int $depth = 0): void
{
if ($depth > 20) {
throw new RuntimeException('Слишком глубокое дерево');
}
foreach ($children[$parent] ?? [] as $category) {
$id = $category['id'];
if (isset($path[$id])) {
throw new RuntimeException('Цикл категорий');
}
echo str_repeat(' ', $depth), $category['title'], "\n";
$nextPath = $path;
$nextPath[$id] = true;
printCategories($children, $id, $nextPath, $depth + 1);
}
}
printCategories($children, 0);
echo "Меню закончено\n";
Ожидаемый результат
Каталог
Одежда
Куртки
Обувь
Меню закончено
Первый вызов печатает Каталог, затем вложенный ищет детей 1. Он печатает Одежда и входит к детям 2; там печатает Куртки и возвращается из пустых детей 3. После возврата foreach родителя 1 продолжает следующего ребёнка Обувь. Перед вложенным вызовом нет return: иначе соседние ветви были бы потеряны. Итоговый echo после самого первого вызова подтверждает возвращение наружу.
- У Курток детей нет: foreach по [] не делает ни одного прохода.
- Заканчивается вызов для parent = 3; продолжается тело вызова parent = 2 после рекурсивной строки.
- Дети Одежды закончились; вызов parent = 2 возвращается в обход детей Каталога.
- Тот foreach берёт следующий элемент Обувь. После всех возвратов выполняется «Меню закончено».
Договор примера: id уникальны, parent ссылается на существующую категорию или корень 0, title является строкой. Проверка текущего пути ловит только достижимые циклы. Сироты и отдельная циклическая компонента без связи с 0 не будут обойдены; нужно отдельно сопоставить все id с посещёнными и валидировать каждую компоненту. Если нужна вся готовая строка или массив до вывода, накапливайте результат и возвращайте его: этот void-вариант мог уже напечатать часть меню перед поздним исключением. Для HTML название экранируют по контексту.
Рекурсивно извлечь числа из ограниченной структуры
Теперь полезный результат рекурсии — одно число, которое после вызова позволит принять заказ. Вся структура должна содержать только остатки одного и того же товара на разных складах. Нельзя складывать так произвольный заказ с id, ценой и количеством: одинаковый тип int ещё не означает одинаковый смысл.
| Вход sumStocks | Зачем нужен |
|---|---|
| array node | Поддерево складских остатков; каждый лист — неотрицательный int. |
| int depth = 0 | Текущая глубина; вложенный вызов получает depth + 1. |
| : int | Сумма всех допустимых листьев текущего поддерева; возвращается вызывающему коду. |
<?php
function sumStocks(array $node, int $depth = 0): int
{
if ($depth > 10) {
throw new InvalidArgumentException('Превышена вложенность');
}
$sum = 0;
foreach ($node as $value) {
if (is_array($value)) {
$sum += sumStocks($value, $depth + 1);
} elseif (is_int($value) && $value >= 0) {
$sum += $value;
} else {
throw new InvalidArgumentException('Остаток должен быть неотрицательным int');
}
}
return $sum;
}
$stocksOfTea = ['north' => ['shelf1' => 3, 'shelf2' => 0], 'south' => ['shelf1' => 4]];
$available = sumStocks($stocksOfTea);
$requested = 6;
echo 'Всего чая: ', $available, ' уп.', "\n";
if ($available >= $requested) {
echo 'Можно принять заказ на ', $requested, ' уп.', "\n";
echo 'После заказа останется: ', $available - $requested, ' уп.', "\n";
}
Ожидаемый результат
Всего чая: 7 уп.
Можно принять заказ на 6 уп.
После заказа останется: 1 уп.
Вложенный северный вызов складывает 3 и 0, возвращает 3 в += внешнего sum. Южный возвращает 4 в следующий +=. Внешний sum стал 7 и возвращён в available. Следующая строка программы сравнивает 7 с запросом 6 и считает остаток 1. Пример лишь рассчитывает возможность: фактическое резервирование склада требует отдельной согласованной операции.
- Корневой sum сначала 0.
- Северный sum — другая местная переменная: 0 → 3 → 3; return 3.
- Корневой sum получает 0 + 3.
- Южный местный sum: 0 → 4; return 4.
- Корневой sum получает 3 + 4; return 7 → available.
Глубина ограничена десятью переходами от корня 0. Пустой массив возвращает 0, потому что foreach ничего не добавил. Строка "3", отрицательное число, null или объект нарушают договор и вызывают исключение. Лимит глубины ограничивает и самоссылочный массив, но не сообщает точную причину цикла. Для внешней огромной структуры нужны также лимит числа узлов и проверка переполнения общей суммы до сложения.
Когда нужен явный стек
Глубокий пользовательский документ может исчерпать память стека. Итеративный обход хранит очередь/стек в массиве или SplQueue и делает предел явным. Он не устраняет циклы автоматически: все равно нужны visited или текущий путь в зависимости от задачи. Для настоящего дерева однократный обход посещает каждый узел; повторный поиск детей полным сканированием всех строк на каждом уровне может стать квадратичным. Индекс детей требует дополнительной памяти, но предотвращает повторный поиск.
В графе узел может быть достижим двумя путями. Глобальный visited означает «обработать узел однажды», путь предков означает «запретить цикл, но разрешить повтор в другой ветви». Для меню категорий обычно ожидается один родитель; если API допускает много родителей, сначала решите, должны ли товары появляться в нескольких ветвях. Не выбирайте структуру проверки случайно.
Попробуйте сами. В данные добавили parent=3 для категории 1. Почему вызов от parent0 может ничего не заметить?
Разбор решения
Циклическая компонента потеряла связь с корнем 0 и не будет достигнута. После обхода сравните набор посещенных ID сос всеми ID; оставшиеся — сироты или отдельные компоненты. Для полной валидации запускайте DFS каждой непосещенной компоненты с цветами состояния либо проверяйте предков каждого узла.
Рекурсивный вызов можно заменить явным списком ожидающей работы. Ниже стек хранит ещё не обработанные массивы. array_pop($pending) удаляет последний элемент и возвращает его; это значение занимает место вызова в присваивании node. Пока pending не пуст, работа продолжается. Здесь нет рекурсивного вызова самой функции.
<?php
$stocksOfTea = ['north' => ['shelf1' => 3, 'shelf2' => 0], 'south' => ['shelf1' => 4]];
$pending = [$stocksOfTea];
$total = 0;
$visitedValues = 0;
while ($pending !== []) {
$node = array_pop($pending);
foreach ($node as $value) {
$visitedValues++;
if ($visitedValues > 100) {
throw new RuntimeException('Слишком много значений');
}
if (is_array($value)) {
$pending[] = $value;
} elseif (is_int($value) && $value >= 0) {
$total += $value;
} else {
throw new InvalidArgumentException('Ожидается неотрицательный целый остаток');
}
}
}
echo 'Доступно чая по всем складам: ', $total, ' уп.', "\n";
echo 'Резервный список работы пуст: ', $pending === [] ? 'да' : 'нет', "\n";
Ожидаемый результат
Доступно чая по всем складам: 7 уп.
Резервный список работы пуст: да
После первого прохода в pending находятся северный и южный массивы; array_pop берёт последний, поэтому юг обрабатывается раньше севера. Для суммы это не меняет итог 7, но для печати меню порядок важен. Проверка 100 значений ограничивает работу этого маленького опыта, включая бесконечный обход циклических данных, однако не определяет структуру цикла и не контролирует переполнение суммы. Явный стек использует память для ожидающих ветвей.