Перейти до основного вмісту

Пошук у глибину (DFS)

Пошук у глибину (DFS) — один із основних алгоритмів на графах.

Пошук у глибину знаходить лексикографічно перший шлях у графі від початкової вершини uu до кожної вершини. Пошук у глибину також знаходить найкоротші шляхи в дереві (бо там існує лише один простий шлях), але для довільних графів це вже не так.

Алгоритм працює за час O(m+n)O(m + n), де nn — кількість вершин, а mm — кількість ребер.

Коли підходить цей алгоритм?

Опис алгоритму

Ідея DFS полягає в тому, щоб заходити в граф якомога глибше, і повертатися назад, щойно ми опиняємося у вершині, де немає невідвіданих сусідніх вершин.

Алгоритм дуже легко описати / реалізувати рекурсивно: Ми починаємо пошук в одній вершині. Відвідавши вершину, ми далі виконуємо DFS для кожної сусідньої вершини, яку ще не відвідували. Так ми відвідуємо всі вершини, досяжні зі стартової вершини.

Подробиці дивіться в реалізації.

  • Знайти будь-який шлях у графі від початкової вершини uu до всіх вершин.

  • Знайти лексикографічно перший шлях у графі від початкової вершини uu до всіх вершин.

  • Перевірити, чи є вершина в дереві предком якоїсь іншої вершини:

    На початку та в кінці кожного виклику пошуку ми запам'ятовуємо «час» входу та виходу для кожної вершини. Тепер можна знайти відповідь для будь-якої пари вершин (i,j)(i, j) за O(1)O(1): вершина ii є предком вершини jj тоді й лише тоді, коли entry[i]<entry[j]\text{entry}[i] < \text{entry}[j] і exit[i]>exit[j]\text{exit}[i] > \text{exit}[j].

  • Знайти найнижчого спільного предка (LCA) двох вершин.

  • Топологічне сортування:

    Запускаємо серію пошуків у глибину так, щоб відвідати кожну вершину рівно один раз за час O(n+m)O(n + m). Потрібний топологічний порядок — це вершини, відсортовані за спаданням часу виходу.

  • Перевірити, чи є заданий граф ациклічним, і знайти цикли в графі. (Як зазначено нижче — підрахувавши зворотні ребра в кожній компоненті зв'язності.)

  • Знайти компоненти сильної зв'язності в орієнтованому графі:

    Спочатку робимо топологічне сортування графа. Потім транспонуємо граф і запускаємо ще одну серію пошуків у глибину в порядку, заданому топологічним сортуванням. Для кожного виклику DFS компонента, яку він створює, є компонентою сильної зв'язності.

  • Знайти мости в неорієнтованому графі:

    Спочатку перетворюємо заданий граф на орієнтований, запустивши серію пошуків у глибину та орієнтуючи кожне ребро в міру проходження через нього — у тому напрямку, в якому ми йшли. По-друге, знаходимо компоненти сильної зв'язності в цьому орієнтованому графі. Мости — це ребра, кінці яких належать різним компонентам сильної зв'язності.

Класифікація ребер графа

Ми можемо класифікувати ребра графа GG, використовуючи час входу та виходу кінцевих вершин uu і vv ребер (u,v)(u,v). Ці класифікації часто застосовують у задачах на кшталт пошуку мостів та пошуку точок зчленування.

Ми виконуємо DFS і класифікуємо ребра, що трапляються, за такими правилами:

Якщо vv не відвідано:

  • Деревне ребро — Якщо vv відвідано після uu, то ребро (u,v)(u,v) називається деревним ребром. Іншими словами, якщо vv відвідується вперше, а uu відвідується зараз, то (u,v)(u,v) називається деревним ребром. Ці ребра утворюють дерево DFS — звідси й назва «деревні ребра».

Якщо vv відвідано раніше за uu:

  • Зворотні ребра — Якщо vv є предком uu, то ребро (u,v)(u,v) є зворотним ребром. vv є предком саме тоді, коли ми вже увійшли у vv, але ще не вийшли з нього. Зворотні ребра замикають цикл, бо існує шлях від предка vv до нащадка uu (у рекурсії DFS) і ребро від нащадка uu до предка vv (зворотне ребро), отже, утворюється цикл. Цикли можна виявляти за допомогою зворотних ребер.

  • Прямі ребра — Якщо vv є нащадком uu, то ребро (u,v)(u, v) є прямим ребром. Іншими словами, якщо ми вже відвідали vv і вийшли з нього, і entry[u]<entry[v]\text{entry}[u] < \text{entry}[v], то ребро (u,v)(u,v) утворює пряме ребро.

  • Перехресні ребра: якщо vv не є ні предком, ні нащадком uu, то ребро (u,v)(u, v) є перехресним ребром. Іншими словами, якщо ми вже відвідали vv і вийшли з нього, і entry[u]>entry[v]\text{entry}[u] > \text{entry}[v], то (u,v)(u,v) є перехресним ребром.

Теорема. Нехай GG — неорієнтований граф. Тоді виконання DFS на GG класифікує кожне ребро, що трапляється, або як деревне ребро, або як зворотне ребро, тобто прямі та перехресні ребра існують лише в орієнтованих графах.

Припустимо, що (u,v)(u,v) — довільне ребро графа GG, і, без втрати загальності, uu відвідано раніше за vv, тобто entry[u]<entry[v]\text{entry}[u] < \text{entry}[v]. Оскільки DFS обробляє ребра лише один раз, існує тільки два способи, якими ми можемо обробити ребро (u,v)(u,v) і тим самим класифікувати його:

  • Уперше ми досліджуємо ребро (u,v)(u,v) у напрямку від uu до vv. Оскільки entry[u]<entry[v]\text{entry}[u] < \text{entry}[v], рекурсивна природа DFS означає, що вершина vv буде повністю досліджена, а отже, з неї вийдуть, перш ніж ми зможемо «піднятися назад стеком викликів», щоб вийти з вершини uu. Тому вершина vv має бути невідвіданою, коли DFS уперше досліджує ребро (u,v)(u,v) від uu до vv, бо інакше пошук дослідив би (u,v)(u,v) від vv до uu ще до виходу з вершини vv, оскільки вершини uu і vv є сусідами. Отже, ребро (u,v)(u,v) є деревним ребром.

  • Уперше ми досліджуємо ребро (u,v)(u,v) у напрямку від vv до uu. Оскільки ми виявили вершину uu раніше за вершину vv, а ребра обробляємо лише один раз, єдиний спосіб дослідити ребро (u,v)(u,v) у напрямку від vv до uu — це якщо існує інший шлях від uu до vv, який не використовує ребро (u,v)(u,v), що робить uu предком vv. Тоді ребро (u,v)(u,v) замикає цикл, бо йде від нащадка vv до предка uu, з якого ми ще не вийшли. Отже, ребро (u,v)(u,v) є зворотним ребром.

Оскільки існує лише два способи обробити ребро (u,v)(u,v) — з двома випадками та відповідними їм класифікаціями, описаними вище, — виконання DFS на GG класифікує кожне ребро, що трапляється, або як деревне ребро, або як зворотне ребро, тобто прямі та перехресні ребра існують лише в орієнтованих графах. Це завершує доведення.

Реалізація

vector<vector<int>> adj; // граф, представлений списком суміжності
int n; // кількість вершин

vector<bool> visited;

void dfs(int v) {
visited[v] = true;
for (int u : adj[v]) {
if (!visited[u])
dfs(u);
}
}

Це найпростіша реалізація пошуку у глибину. Як описано в застосуваннях, може бути корисно також обчислювати час входу та виходу й колір вершини. Ми будемо фарбувати всі вершини кольором 0, якщо ми їх не відвідували, кольором 1, якщо ми їх відвідали, і кольором 2, якщо ми з вершини вже вийшли.

Ось узагальнена реалізація, яка додатково обчислює це:

vector<vector<int>> adj; // граф, представлений списком суміжності
int n; // кількість вершин

vector<int> color;

vector<int> time_in, time_out;
int dfs_timer = 0;

void dfs(int v) {
time_in[v] = dfs_timer++;
color[v] = 1;
for (int u : adj[v])
if (color[u] == 0)
dfs(u);
color[v] = 2;
time_out[v] = dfs_timer++;
}

Задачі для практики

Відеоматеріали