# теория · шаг 4 из 8
Рекурсия или явный стек, и чем отличается обход в ширину
Коротко
Рекурсивный обход в глубину красив и короток, но глубина рекурсии доходит до V: на цепочке из
миллиона вершин программа падает по переполнению стека, а в Python упирается в лимит уже около
тысячи. Лечится переписыванием на явный стек. Простой вариант (сложить соседей в стек) даёт
тот же набор посещённых вершин, но другой порядок и требует осторожности с пометками; точная
копия рекурсии получается, если хранить на стеке пару «вершина + индекс следующего соседа» - тогда
воспроизводятся и tin, и tout. Если заменить стек на ОЧЕРЕДЬ, получится обход в ширину: он
идёт по уровням и даёт кратчайшие пути в невзвешенном графе - чего обход в глубину не умеет.
Память у них разная: в глубину - по длине пути, в ширину - по ширине уровня.
Если ты умеешь писать обход в глубину на явном стеке (включая вариант с индексом соседа), знаешь предел глубины рекурсии и понимаешь, чем обход в ширину отличается по результату и по памяти, листай до «Теперь сам».
Как думаешь, на каком графе упадёт рекурсия
Граф - простая цепочка: 0→1→2→...→N. Прежде чем читать дальше, прикинь, при каком N рекурсивный
обход перестанет работать в Python и при каком - в программе на C++ с восьмимегабайтным стеком.
Подсказка: кадр вызова занимает десятки-сотни байт, а Python по умолчанию ограничивает глубину
явно.
На что это похоже
Разница между тем, чтобы держать маршрут в голове, и тем, чтобы записывать его на бумаге. Пока вложенность небольшая, голова справляется. Но если надо запомнить миллион поворотов, голова кончится - а блокнот нет, у него другой предел. Переписывание рекурсии на явный стек - это ровно перенос маршрута из «головы» (системного стека вызовов) в «блокнот» (обычный массив в куче).
Механизм: три способа обойти один граф
# 1) рекурсия: коротко, но глубина ограничена системой
def dfs(v):
visited[v] = True
for u in adj[v]:
if not visited[u]:
dfs(u)
# 2) явный стек, простой вариант: тот же охват, другой порядок
def dfs_iter(start):
stack = [start]
while stack:
v = stack.pop()
if visited[v]: # вершина могла попасть в стек дважды
continue
visited[v] = True
for u in adj[v]:
if not visited[u]:
stack.append(u)
# 3) явный стек с индексом соседа: ТОЧНАЯ копия рекурсии, есть вход и выход
def dfs_exact(start):
stack = [(start, 0)]
visited[start] = True
tin[start] = tick()
while stack:
v, i = stack[-1]
if i < len(adj[v]):
stack[-1] = (v, i + 1) # сдвинули «указатель соседа»
u = adj[v][i]
if not visited[u]:
visited[u] = True
tin[u] = tick()
stack.append((u, 0))
else:
tout[v] = tick() # соседи кончились - это ВЫХОД из вершины
stack.pop()
Второй вариант проще, но у него две особенности. Во-первых, соседи выкладываются в стек и
достаются в обратном порядке - обход пойдёт «зеркально» рекурсии. Во-вторых, одна вершина может
попасть в стек несколько раз (её положили из разных соседей до того, как посетили), поэтому нужна
проверка при извлечении; иначе на плотном графе стек раздуется до O(E).
Третий вариант нужен там, где важны tin/tout или действие «при выходе» - а это все алгоритмы
следующих глав: топологическая сортировка, компоненты сильной связности, мосты.
Замена стека на очередь превращает обход в глубину в обход в ширину:
from collections import deque
def bfs(start):
dist = [-1] * V
dist[start] = 0
q = deque([start])
while q:
v = q.popleft() # единственное отличие: берём из НАЧАЛА
for u in adj[v]:
if dist[u] == -1:
dist[u] = dist[v] + 1
q.append(u)
return dist
Правило
Глубина рекурсивного обхода доходит до V: в Python предел около тысячи вызовов, в
компилируемых языках - сотни тысяч, поэтому на больших графах обход переписывают на явный стек.
Простой вариант («сложить соседей») даёт другой порядок и требует проверки посещённости при
извлечении; точная копия рекурсии хранит на стеке пару «вершина + индекс соседа» и умеет
отличать вход от ВЫХОДА. Стек, заменённый очередью, даёт обход в ширину: он посещает вершины по
уровням и находит кратчайшие пути в невзвешенном графе за O(V + E). Память: в глубину - O(длины
пути), в ширину - O(ширины уровня).
Разбор: где кончается рекурсия
Python по умолчанию ограничивает глубину рекурсии примерно тысячей вызовов: цепочки из 10 000
вершин хватит, чтобы получить RecursionError. Поднять лимит можно, но настоящий стек потока от
этого не растёт, и вместо понятного исключения легко получить аварийное завершение процесса.
В компилируемых языках считают иначе. Стек потока по умолчанию - около 8 МБ, кадр обхода занимает
порядка 50-100 байт (адрес возврата, локальные переменные, выравнивание). Делим: 8·10⁶ / 80 ≈
100 000 вложенных вызовов. То есть цепочка из ста тысяч вершин - уже граница, а из миллиона -
гарантированное падение.
И теперь главное: цепочка - не экзотика. Длинный путь получается на дорожной сети (шоссе), в графе зависимостей с длинной цепочкой включений, на сетке-лабиринте со змейкой из коридоров, в дереве каталогов с глубокой вложенностью. Поэтому правило простое: разовый скрипт - можно рекурсию; код, который увидит чужие данные, - явный стек.
Разбор: одинаковый охват, разный порядок и разный ответ
Граф: 0-1, 0-2, 1-3, 2-3, стартуем из 0.
Обход в ГЛУБИНУ (рекурсия, соседи по возрастанию): 0 → 1 → 3 → 2. Расстояния, если их считать
как длину пройденного пути: до 1 - один шаг, до 3 - два, до 2 - ТРИ (пришли через 1 и 3), хотя в
графе есть прямое ребро 0-2.
Обход в ШИРИНУ: из 0 кладём в очередь 1 и 2 (уровень 1), потом из 1 кладём 3 (уровень 2), из 2
смотрим 3 - уже размечена. Расстояния: dist = [0, 1, 1, 2]. Это и есть КРАТЧАЙШИЕ расстояния в
рёбрах, и обход в ширину даёт их всегда - потому что вершины выходят из очереди в порядке
неубывания расстояния, а каждая размечается при первом же попадании.
Тот же пример объясняет и разницу в памяти. В глубину на стеке лежит текущий путь - на цепочке это
V вершин, на широком «кусте» - всего пара. В ширину в очереди лежит целый уровень - на цепочке
это одна вершина, а на графе, где у корня миллион соседей, это миллион. Ни один не лучше:
выбирают по форме графа и по задаче.
Что ломается без этого
Первое: переписать рекурсию на стек, но помечать вершину только при извлечении и не проверять
повторов - стек раздувается, а вершины обрабатываются по нескольку раз. Второе: ожидать от
простого стекового варианта того же ПОРЯДКА, что у рекурсии: соседи достаются из стека с конца,
поэтому порядок другой. Укладка соседей в обратном порядке совпадение чаще всего восстанавливает,
но полагаться на это не стоит - вершина может попасть в стек несколько раз и всплыть не тогда,
когда ждёшь; если порядок важен, бери вариант с индексом соседа. Третье: пытаться получить tout в
простом варианте - его там нет, для этого нужен вариант с индексом соседа. Четвёртое: считать, что
обход в ширину даёт кратчайшие пути во ВЗВЕШЕННОМ графе - нет, там нужен Дейкстра; обход в ширину
работает, только когда все рёбра равноценны. Пятое: поднять лимит рекурсии в Python и считать
вопрос закрытым - системный стек от этого не вырос, и падение станет менее понятным.
Зачем это в работе
Переполнение стека на рекурсивном обходе - классическая авария в продакшне: код работает год, пока кто-то не пришлёт данные с длинной цепочкой. Поэтому в библиотеках обходы почти всегда итеративные, и уметь их писать - часть ремесла. Обход в ширину нужен там, где важно «ближайшее»: кратчайший путь в клетках (игры, роботы), число рукопожатий в соцсети, поиск ближайшего работающего сервера в топологии, послойное распространение (заражение, рассылка), а также двунаправленный поиск - запуск обхода с обоих концов, который сокращает перебор на порядки. Приём «стек против очереди» стоит помнить и шире: одна и та же схема обхода с разным контейнером даёт разное поведение, и если заменить очередь на очередь с ПРИОРИТЕТАМИ, получится алгоритм Дейкстры - ровно об этом следующий раздел.
Теперь сам
Граф-звезда: центр 0 соединён с вершинами 1..999999 (миллион рёбер). И граф-цепочка:
0-1-2-...-999999. На каком из них упадёт рекурсивный обход в глубину, а на каком - обход в
ширину по памяти? Сколько вершин будет лежать в стеке и в очереди в худший момент?
Ответ. На ЗВЕЗДЕ рекурсия не упадёт: глубина обхода равна двум (0 → сосед → возврат), на стеке максимум два кадра. А вот обход в ширину положит в очередь всех соседей центра сразу - миллион вершин, и если на вершину уходит хотя бы 8 байт номера плюс расходы контейнера, это десятки мегабайт; программа выживет, но память скакнёт. На ЦЕПОЧКЕ наоборот: обход в ширину держит в очереди одну-две вершины (у каждой один новый сосед), зато рекурсивный обход в глубину уйдёт на миллион вложенных вызовов и упадёт - в Python по лимиту рекурсии, в C++ по переполнению стека (миллион кадров по ~80 байт - это 80 МБ при стеке в 8 МБ). Вывод, ради которого сравнивали: память обхода определяется не размером графа, а его ФОРМОЙ - длиной пути для обхода в глубину и шириной уровня для обхода в ширину. Поэтому на цепочке спасает переписывание на явный стек (миллион пар в массиве - это десятки мегабайт в куче, а не в стеке потока), а на звезде - ничего не спасает, кроме понимания, что миллион соседей просто есть.
Главное
Глубина рекурсивного обхода доходит до числа вершин: в Python предел около тысячи вызовов, в
компилируемых языках - около ста тысяч при стандартном стеке, а цепочка из миллиона вершин роняет
программу. Поэтому в коде, который увидит чужие данные, обход пишут на ЯВНОМ стеке. Простой
вариант («сложить соседей, доставать с конца») обходит те же вершины, но в зеркальном порядке и
требует проверки посещённости при извлечении, иначе стек раздувается. Точная копия рекурсии хранит
пару «вершина + индекс следующего соседа»: только она умеет отличать вход от ВЫХОДА и давать
tin/tout, а они нужны всем алгоритмам следующих глав. Заменив стек очередью, получаем обход в
ширину: вершины выходят в порядке неубывания расстояния, поэтому он даёт кратчайшие пути в
НЕВЗВЕШЕННОМ графе (во взвешенном нужен Дейкстра). Память зависит от формы графа: в глубину - по
длине пути, в ширину - по ширине уровня; на цепочке падает первый, на звезде раздувается второй.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий