1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8

# теория · шаг 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, а они нужны всем алгоритмам следующих глав. Заменив стек очередью, получаем обход в ширину: вершины выходят в порядке неубывания расстояния, поэтому он даёт кратчайшие пути в НЕВЗВЕШЕННОМ графе (во взвешенном нужен Дейкстра). Память зависит от формы графа: в глубину - по длине пути, в ширину - по ширине уровня; на цепочке падает первый, на звезде раздувается второй.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.