Графы. DFS

В этом уроке ты
  • Узнаешь что такое поиск в глубину (DFS) и где его применять.
  • Решишь задачу с собеседования, используя поиск в глубину (DFS).
  • Поймешь плюсы и минусы рекурсивной реализации DFS.
Поиск в глубину (DFS)

Поиск в глубину (или DFS, от английского Depth First Search— это способ обхода графа или двумерной структуры (например, матрицы или сетки клеток).

Принцип работы достаточно простой: мы «идём» как можно глубже по одному направлению, а если дальше идти нельзя (закончились соседние непосещённые клетки или узлы), мы «возвращаемся назад» и пробуем другой путь.

В этом уроке мы рассмотрим задачу, где DFS помогает решать задачу раскраски «по номерам». Вместе с кодом ты увидишь, как алгоритм на практике обходит все клетки матрицы.
Задача с собеседования
Условие

Дана раскраска по номерам (двумерный массив colors). Нужно определить минимальное количество заливок, необходимых, чтобы «закрасить» всю картину.

Когда мы делаем «заливку» (например, в графическом редакторе), мы выбираем клетку, и одновременно «заливаются» все соседние клетки того же цвета. Соседними считаем клетки сверху, снизу, слева и справа.

Пример
Ввод: colors=
[[1,1,1,1,1,1,1,1]
,[1,2,2,1,1,2,2,1]
,[1,2,1,2,2,1,2,1]
,[1,2,2,2,2,2,2,1]
,[1,1,1,1,1,1,1,1]]
Вывод: 4

Ниже — код, который решает эту задачу. Прочитай его сначала сам, обрати внимание на главную функцию draw. Затем мы разберём детали.

using System;
using System.Collections.Generic;

public class Solution
{
    // Проверяет, находятся ли индексы i и j в пределах матрицы
    public static bool InBound(int i, int j, List<List<int>> colors)
    {
        return 0 <= i && i < colors.Count && 0 <= j && j < colors[0].Count;
    }

    // Вспомогательная функция для обхода графа
    public static void Dfs(int i, int j, int needsColor, List<List<bool>> used, List<List<int>> colors)
    {
        // Если текущая клетка:
        // - выходит за границы массива colors
        // - или имеет цвет отличный от needsColor
        // - или была уже закрашена ранее
        // то возвращаемся к предыдущей клетке
        if (!InBound(i, j, colors) || needsColor != colors[i][j] || used[i][j])
        {
            return;
        }
        // Закрашиваем текущую клетку
        used[i][j] = true;
        // Пробуем закрасить клетку СНИЗУ, СВЕРХУ, СПРАВА, СЛЕВА
        Dfs(i + 1, j, needsColor, used, colors);
        Dfs(i - 1, j, needsColor, used, colors);
        Dfs(i, j + 1, needsColor, used, colors);
        Dfs(i, j - 1, needsColor, used, colors);
    }

    // Основная функция, которая вернет результат
    public static int Draw(List<List<int>> colors)
    {
        // Если used[i][j] = true, значит мы уже закрасили клетку colors[i][j]
        var used = new List<List<bool>>();
        for (int i = 0; i < colors.Count; i++)
        {
            used.Add(new List<bool>());
            for (int j = 0; j < colors[0].Count; j++)
            {
                used[i].Add(false);
            }
        }

        int result = 0;
        // Пробуем сделать заливку из каждой клетки
        for (int i = 0; i < colors.Count; i++)
        {
            for (int j = 0; j < colors[i].Count; j++)
            {
                // Если уже закрасили клетку, то пропускаем
                if (!used[i][j])
                {
                    Dfs(i, j, colors[i][j], used, colors);
                    result++;
                }
            }
        }
        return result;
    }
}
  1. В draw создаём двумерный массив used, где все значения изначально false.
  2. Начинаем обходить каждую клетку:
  • если видим, что клетка не была закрашена, запускаем на неё DFS.
  • Внутри DFS мы «заливаем» (помечаем used[i][j] = true) все соседние клетки такого же цвета, в глубину — пока не дойдём до границы или до клетки другого цвета.
  • Когда DFS завершает обход, мы увеличиваем result — это значит, что мы начали новую заливку.

3. В итоге в result хранится количество заливок, необходимое для покраски всей раскраски.

Упрощаем решение

Ниже — чуть более компактный вариант, где мы используем цикл для обхода соседей, а не четыре отдельных вызова.

using System;
using System.Collections.Generic;

public class Solution
{
    public static bool InBound(int i, int j, List<List<int>> colors)
    {
        return 0 <= i && i < colors.Count && 0 <= j && j < colors[0].Count;
    }

    public static void Dfs(int i, int j, int needsColor, bool[][] used, List<List<int>> colors)
    {
        if (!InBound(i, j, colors) || needsColor != colors[i][j] || used[i][j])
        {
            return;
        }
        used[i][j] = true;

        // Используем цикл вместо нескольких вызовов функции
        var nextSteps = new (int, int)[] { (i, j + 1), (i, j - 1), (i + 1, j), (i - 1, j) };
        foreach (var nextStep in nextSteps)
        {
            Dfs(nextStep.Item1, nextStep.Item2, needsColor, used, colors);
        }
    }

    public static int Draw(List<List<int>> colors)
    {
        var used = new bool[colors.Count][];
        for (int i = 0; i < used.Length; i++)
        {
            used[i] = new bool[colors[0].Count];
        }
        int result = 0;

        for (int i = 0; i < colors.Count; i++)
        {
            for (int j = 0; j < colors[i].Count; j++)
            {
                if (!used[i][j])
                {
                    Dfs(i, j, colors[i][j], used, colors);
                    result++;
                }
            }
        }
        return result;
    }
}

Суть алгоритма не меняется: мы так же рекурсивно обходим все соседние клетки такого же цвета.

Паттерн DFS

При решении похожих задач часто можно использовать такой «каркас» кода:

using System.Collections.Generic;

public class Solution
{
    // Проверка, можно ли зайти в клетку
    public static bool InBound(int i, int j, List<List<int>> matrix)
    {
        return 0 <= i && i < matrix.Count && 0 <= j && j < matrix[0].Count;
    }

    // Могут быть и другие аргументы
    public static void Dfs(int i, int j, bool[][] used /* другие аргументы */)
    {
        // Проверяем, можно ли зайти в клетку
        if (!InBound(i, j, matrix) || used[i][j])
        {
            return;
        }

        // Помечаем, что посетили клетку
        used[i][j] = true;

        // Рекурсивно идём в соседние клетки
        // ...
    }
}

Большинство задач по типу «Обойти всю область» (в двумерных массивах) сводятся к похожему подходу:

  1. Проверить, не вышли ли за границы (inBound).
  2. Проверить, не посещали ли уже эту клетку (массив used).
  3. Если всё хорошо — запомнить, что мы “обработали” данную клетку и рекурсивно пойти к её соседям.
Оценка сложности
  • Временная сложность: O(n * m), где n — количество строк, m — количество столбцов. Каждая клетка посещается не более одного раза.
  • Пространственная сложность: O(n * m) для стека рекурсии.
Плюсы рекурсивного DFS
  • Простота реализации: легко запомнить и писать на собеседованиях.
  • Компактность кода: меньше строк по сравнению с итеративным решением.
Минусы рекурсивного DFS
  • Ограничения по глубине рекурсии: при очень больших матрицах можно получить ошибку Stack Overflow.
  • Работает медленнее итеративных решений из-за затрат на вызов функций.
  • Без дополнительных настроек (увеличения стековой области памяти) невозможно обрабатывать большие объёмы данных.
Неочевидные выводы
На практике рекурсивный DFS достаточно быстр и удобен. На собеседованиях рекурсивную версию обычно и ждут, так что умение её быстро написать — хорошее преимущество.

Если ты хочешь удивить интервьюера, можешь использовать итеративное решение, которое мы разберём в следующих модулях. А пока попрактикуйся в написании рекурсивного DFS, чтобы у тебя всегда было решение, которое ты можешь без ошибок написать с первого раза.

Что дальше

Теперь попробуй решить эту задачку самостоятельно, чтобы закрепить полученные навыки!