Apuntes DAM
Volver al inicio

Recorrido en profundidad (DFS)

AlgoritmosGrafosNivel intermedioTambién: DFS, depth-first search, búsqueda en profundidad

Recorre un grafo yendo lo más lejos posible por cada camino antes de volver atrás. Con recursividad o una pila, sirve para contar componentes, detectar ciclos y explorar laberintos.

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

Recorrido en profundidad (DFS)

Escribe las aristas de un grafo y el nodo de salida: DFS baja por un camino hasta el fondo y solo entonces vuelve atrás, con la pila de llamadas.

Como «A-B, A-C»: hasta 9 nodos y 16 aristas
  • la llamada en curso

Paso 1

Primera llamada, dfs(A): se marca A como visitado (es el 1º) y se apunta en el orden.

1static void dfs(Map<String, List<String>> g, String u, Set<String> vistos, List<String> orden) {
2    vistos.add(u);  // u = A, orden = A
3    orden.add(u);
4    for (String v : g.get(u))
5        if (!vistos.contains(v)) dfs(g, v, vistos, orden);
6}

Variables

u
A
orden
A

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

El recorrido en profundidad (DFS, *depth-first search*) hace lo contrario que BFS: desde el nodo actual se va a un vecino sin visitar, y desde ese a otro, y así hasta que no queda por dónde seguir. Solo entonces vuelve atrás al último nodo que tenía vecinos pendientes y sigue por ahí. Es lo que hace alguien que explora un laberinto sin soltar la mano de la pared.

Lo más natural es escribirlo con recursividad: dfs(u) marca u y llama a dfs(v) por cada vecino v sin visitar. La pila de llamadas del lenguaje guarda el camino por el que se ha bajado y volver de una llamada es la vuelta atrás. También se puede escribir con una pila explícita, lo que evita el StackOverflowError en grafos con caminos de decenas de miles de nodos.

DFS no encuentra caminos más cortos, pero su forma de recorrer da mucha información. Cada vez que se arranca un DFS desde un nodo aún sin visitar se descubre una componente conexa entera. Y en un grafo dirigido, si se llega a un nodo que todavía está en la pila de llamadas (empezado pero sin terminar), hay un ciclo: se ha vuelto a un antepasado.

Para esto último se usan tres colores: blanco (sin visitar), gris (en curso) y negro (terminado). Una arista hacia un gris es un ciclo; hacia un negro, no. Y el orden en que terminan los nodos es justo el orden en que hay que construir unas dependencias: lo que no usa nada termina primero. Así detectan Maven, Gradle o npm las dependencias circulares.

Cuándo usarlo

  • Contar o etiquetar componentes conexas: islas en un mapa, grupos de amigos, zonas de una imagen (el «bote de pintura»).
  • Detectar ciclos: dependencias circulares entre módulos, interbloqueos entre procesos.
  • Ordenar dependencias (orden topológico) y otros análisis de grafos dirigidos.
  • Explorar todas las posibilidades: es el esqueleto del backtracking (laberintos, sudokus, combinaciones).

Cuándo no

  • Para el camino más corto: el primer camino que encuentra DFS puede ser larguísimo; usa BFS o Dijkstra.
  • Recursivo sobre caminos muy profundos (una cadena de un millón de nodos): desborda la pila; usa la versión con pila explícita.
  • Si interesa lo cercano primero (amigos de amigos, radio de alcance): BFS.

Paso a paso

  1. Visitar. Marca el nodo actual como visitado y haz con él lo que toque: apuntarlo, contarlo, compararlo.
  2. Bajar. Por cada vecino sin visitar, llama a dfs con él: se baja un nivel antes de mirar el resto de vecinos.
  3. Volver atrás. Cuando no quedan vecinos sin visitar, la llamada termina y se sigue en el nodo anterior por donde se había quedado.
  4. Repetir para lo que falte. Para recorrer todo el grafo (no solo lo alcanzable), lanza un DFS desde cada nodo que siga sin visitar: cada lanzamiento es una componente.

El código

DFS recursivo y con pila explícita

Los dos dan el mismo orden: con la pila hay que meter los vecinos al revés, para que el primero quede en la cima, y marcar cada nodo al sacarlo.

Java
1import java.util.*;
2
3public class Main {
4    static final Map<String, List<String>> grafo = new LinkedHashMap<>();
5
6    static void arista(String a, String b) {
7        grafo.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
8        grafo.computeIfAbsent(b, k -> new ArrayList<>()).add(a);
9    }
10
11    static void dfs(String u, Set<String> vistos, List<String> orden) {
12        vistos.add(u);
13        orden.add(u);
14        for (String v : grafo.get(u))
15            if (!vistos.contains(v)) dfs(v, vistos, orden);   // baja ya; el resto de vecinos, a la vuelta
16    }
17
18    static List<String> dfsConPila(String origen) {
19        Set<String> vistos = new HashSet<>();
20        List<String> orden = new ArrayList<>();
21        Deque<String> pila = new ArrayDeque<>();
22        pila.push(origen);
23        while (!pila.isEmpty()) {
24            String u = pila.pop();
25            if (!vistos.add(u)) continue;                      // ya se visitó por otro camino
26            orden.add(u);
27            List<String> vecinos = grafo.get(u);
28            for (int i = vecinos.size() - 1; i >= 0; i--)      // al revés: el primero queda en la cima
29                if (!vistos.contains(vecinos.get(i))) pila.push(vecinos.get(i));
30        }
31        return orden;
32    }
33
34    public static void main(String[] args) {
35        String[][] aristas = {{"A", "B"}, {"A", "C"}, {"B", "D"}, {"C", "D"}, {"C", "E"}, {"D", "F"}, {"E", "F"}, {"F", "G"}};
36        for (String[] e : aristas) arista(e[0], e[1]);
37        List<String> orden = new ArrayList<>();
38        dfs("A", new HashSet<>(), orden);
39        System.out.println("Recursivo: " + String.join(" ", orden));
40        System.out.println("Con pila:  " + String.join(" ", dfsConPila("A")));
41    }
42}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Recursivo: A B D C E F G
Con pila:  A B D C E F G

Componentes conexas y ciclos con tres colores

Un DFS por cada nodo sin visitar cuenta las componentes. En un grafo dirigido, una flecha hacia un nodo «en curso» delata un ciclo.

Java
1import java.util.*;
2
3public class Main {
4    static void marcar(Map<String, List<String>> g, String u, Set<String> vistos, List<String> grupo) {
5        vistos.add(u);
6        grupo.add(u);
7        for (String v : g.get(u))
8            if (!vistos.contains(v)) marcar(g, v, vistos, grupo);
9    }
10
11    /** color: 0 sin visitar, 1 en curso (en la pila de llamadas), 2 terminado. */
12    static boolean hayCiclo(Map<String, List<String>> g, String u, Map<String, Integer> color) {
13        color.put(u, 1);
14        for (String v : g.get(u)) {
15            int c = color.getOrDefault(v, 0);
16            if (c == 1) return true;                           // flecha hacia un nodo en curso: ciclo
17            if (c == 0 && hayCiclo(g, v, color)) return true;
18        }
19        color.put(u, 2);
20        return false;
21    }
22
23    static boolean tieneCiclo(Map<String, List<String>> g) {
24        Map<String, Integer> color = new HashMap<>();
25        for (String u : g.keySet())
26            if (color.getOrDefault(u, 0) == 0 && hayCiclo(g, u, color)) return true;
27        return false;
28    }
29
30    static Map<String, List<String>> grafo(String[] nodos, String[][] aristas, boolean dirigido) {
31        Map<String, List<String>> g = new LinkedHashMap<>();
32        for (String n : nodos) g.put(n, new ArrayList<>());
33        for (String[] e : aristas) {
34            g.get(e[0]).add(e[1]);
35            if (!dirigido) g.get(e[1]).add(e[0]);
36        }
37        return g;
38    }
39
40    public static void main(String[] args) {
41        Map<String, List<String>> red = grafo(new String[]{"A", "B", "C", "D", "E", "F", "G", "H"},
42                new String[][]{{"A", "B"}, {"B", "C"}, {"A", "C"}, {"D", "E"}, {"F", "G"}}, false);
43        Set<String> vistos = new HashSet<>();
44        int n = 0;
45        for (String u : red.keySet()) {
46            if (vistos.contains(u)) continue;
47            List<String> grupo = new ArrayList<>();
48            marcar(red, u, vistos, grupo);                     // un DFS por cada nodo que siga sin visitar
49            System.out.println("Componente " + (++n) + ": " + String.join(" ", grupo));
50        }
51
52        String[] tareas = {"compilar", "enlazar", "probar", "desplegar"};
53        String[][] cadena = {{"compilar", "enlazar"}, {"enlazar", "probar"}, {"probar", "desplegar"}};
54        String[][] vuelta = {{"compilar", "enlazar"}, {"enlazar", "probar"}, {"probar", "desplegar"}, {"desplegar", "enlazar"}};
55        System.out.println("Tareas en cadena: " + (tieneCiclo(grafo(tareas, cadena, true)) ? "hay un ciclo" : "sin ciclos"));
56        System.out.println("Con desplegar → enlazar: " + (tieneCiclo(grafo(tareas, vuelta, true)) ? "hay un ciclo" : "sin ciclos"));
57    }
58}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Componente 1: A B C
Componente 2: D E
Componente 3: F G
Componente 4: H
Tareas en cadena: sin ciclos
Con desplegar → enlazar: hay un ciclo

Traza: DFS desde A en el grafo del visualizador

Pila de llamadasQué pasa
Aentra en A (el 1º visitado)
A › Bentra en B (el 2º visitado)
A › B › Dentra en D (el 3º visitado)
A › B › D › Centra en C (el 4º visitado)
A › B › D › C › Eentra en E (el 5º visitado)
A › B › D › C › E › Fentra en F (el 6º visitado)
A › B › D › C › E › F › Gentra en G (el 7º visitado)
A › B › D › C › E › F › GG no tiene más vecinos sin visitar: vuelve a F
A › B › D › C › E › FF no tiene más vecinos sin visitar: vuelve a E
A › B › D › C › EE no tiene más vecinos sin visitar: vuelve a C
A › B › D › CC no tiene más vecinos sin visitar: vuelve a D
A › B › DD no tiene más vecinos sin visitar: vuelve a B
A › BB no tiene más vecinos sin visitar: vuelve a A
AA no tiene más vecinos sin visitar: fin

La pila de llamadas es el camino desde A hasta el nodo actual: crece al bajar y se vacía al volver atrás. D se visita antes que C porque se llega a él bajando por B.

Complejidad

VersiónTiempoMemoria extra
DFS recursivoO(V + E)O(V): la profundidad de la pila de llamadas
DFS con pila explícitaO(V + E)O(E) en el peor caso: un nodo puede estar apilado varias veces
Componentes conexasO(V + E)O(V)
Detección de ciclos (tres colores)O(V + E)O(V)

Como BFS: cada nodo se visita una vez y cada arista se mira una vez por extremo. Lo que cambia es el orden, no el coste.

0102030405015101520tamaño de la entrada (n)operacionesO(n!)O(2ⁿ)O(n²)O(n log n)O(1)O(log n)O(n)
  • Mejor caso: O(n)
  • Caso medio: O(n)
  • Peor caso: O(n)

O(V + E), como BFS: cada nodo se visita una vez y cada arista se mira desde sus dos extremos. Lo que cambia es el orden, no el coste. Las curvas grises son las demás clases, para comparar.

En la práctica

  • Las herramientas de construcción (Maven, Gradle, npm, Make) recorren las dependencias en profundidad y avisan de las circulares.
  • El «bote de pintura» de los editores de imagen rellena la zona conectada del mismo color (*flood fill*).
  • Los sistemas operativos y las bases de datos buscan ciclos en el grafo de esperas para detectar interbloqueos.
  • Los generadores de laberintos clásicos son un DFS que va tirando paredes al azar.
  • Los compiladores recorren el árbol sintáctico en profundidad para analizarlo y generar código.

Errores típicos

  • Olvidar marcar el nodo antes de bajar a los vecinos: en un grafo con ciclos, recursividad infinita.
  • Usar un solo «visitado» para detectar ciclos en un grafo dirigido: hay que distinguir «en curso» de «terminado» (tres colores).
  • En un grafo no dirigido, tomar la arista de vuelta al padre como un ciclo: hay que ignorar al nodo del que se viene.
  • Recursividad demasiado profunda: con decenas de miles de nodos en fila, StackOverflowError.
  • Lanzar DFS solo desde un nodo y creer que se ha recorrido todo el grafo: si no es conexo, faltan componentes.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Islas en un mapa

El mapa es una cuadrícula de # (tierra) y . (agua). Una isla es un grupo de casillas de tierra unidas por los lados (en diagonal no cuentan). Escribe cuántas islas hay y sus tamaños de mayor a menor. El main lee el mapa y llama a inundar en cada casilla de tierra sin contar: complétala con un DFS recursivo.

  • Entrada: el mapa, una fila por línea, todas del mismo ancho.
  • Salida: 5 islas; tamaños de mayor a menor: 3, 2, 2, 2, 1 (con una, 1 isla; …), o No hay islas.
  • Si una fila tiene otro ancho u otros caracteres: Mapa no válido en la fila 2: «…».
☕JavaIslas en un mapaMedio

Ejemplo

Entrada (lo que se escribe por teclado)
##..#
#...#
..#..
.....
##.##
Salida esperada
5 islas; tamaños de mayor a menor: 3, 2, 2, 2, 1
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
⏳
Test oculto #6
⏳
Test oculto #7
0/7 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    static char[][] mapa;            // '#' tierra, '.' agua
5    static int filas, cols;
6
7    /** Si (f, c) es tierra sin contar, la marca y sigue por sus cuatro vecinas; devuelve cuántas casillas ha marcado. */
8    static int inundar(int f, int c) {
9        if (f < 0 || f >= filas || c < 0 || c >= cols || mapa[f][c] != '#') return 0;
10        mapa[f][c] = 'x';                                      // contada: no se vuelve a entrar
11        return 1 + inundar(f - 1, c) + inundar(f + 1, c) + inundar(f, c - 1) + inundar(f, c + 1);
12    }
13
14    public static void main(String[] args) {
15        Scanner sc = new Scanner(System.in);
16        List<String> lineas = new ArrayList<>();
17        while (sc.hasNextLine()) {
18            String l = sc.nextLine().trim();
19            if (!l.isEmpty()) lineas.add(l);
20        }
21        if (lineas.isEmpty()) {
22            System.out.println("Mapa vacío");
23            return;
24        }
25        filas = lineas.size();
26        cols = lineas.get(0).length();
27        mapa = new char[filas][];
28        for (int f = 0; f < filas; f++) {
29            String l = lineas.get(f);
30            if (l.length() != cols || !l.matches("[#.]+")) {
31                System.out.println("Mapa no válido en la fila " + (f + 1) + ": «" + l + "»");
32                return;
33            }
34            mapa[f] = l.toCharArray();
35        }
36        List<Integer> islas = new ArrayList<>();
37        for (int f = 0; f < filas; f++)
38            for (int c = 0; c < cols; c++)
39                if (mapa[f][c] == '#') islas.add(inundar(f, c));
40        if (islas.isEmpty()) {
41            System.out.println("No hay islas");
42            return;
43        }
44        islas.sort(Comparator.reverseOrder());
45        StringJoiner sj = new StringJoiner(", ");
46        for (int t : islas) sj.add(String.valueOf(t));
47        System.out.println(islas.size() + (islas.size() == 1 ? " isla" : " islas") + "; tamaños de mayor a menor: " + sj);
48    }
49}

Cada llamada de main a inundar sobre una casilla # es el arranque de un DFS nuevo: descubre una componente entera (una isla) y la deja marcada, así que el número de arranques es el número de islas.

Marcar en el propio mapa ahorra un boolean[][] de visitados. Con mapas enormes, la recursividad puede desbordar la pila: entonces se usa una pila explícita o BFS.

2. Dependencias circulares

Cada línea dice qué módulos usa un módulo: app: core log. Si las dependencias tienen un ciclo, escribe el primero que encuentre el DFS; si no, el orden de compilación (cada módulo después de todos los que usa). El main lee la entrada (los módulos que solo aparecen a la derecha también existen, sin dependencias) y recorre los módulos en orden alfabético: completa visitar con los tres colores.

  • Entrada: módulo: dependencias separadas por espacios (puede no tener ninguna: log:). Línea sin : o sin nombre: Línea no válida: «…».
  • Sin ciclos: Sin ciclos. Orden de compilación: log, core, app, ui: el orden en que TERMINAN los módulos, recorriendo los módulos y las dependencias de cada uno en orden alfabético.
  • Con ciclo: Ciclo: b → d → e → b, desde el módulo en el que se cierra (el que ya estaba en el camino) hasta volver a él.
☕JavaDependencias circularesDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
app: core log
core: log
log:
ui: app
Salida esperada
Sin ciclos. Orden de compilación: log, core, app, ui
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
⏳
Test oculto #6
0/6 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    static final int BLANCO = 0, GRIS = 1, NEGRO = 2;       // sin visitar, en el camino actual, terminado
5    static final Map<String, TreeSet<String>> usa = new TreeMap<>();
6    static final Map<String, Integer> color = new HashMap<>();
7    static final List<String> camino = new ArrayList<>();    // los módulos del camino actual, en orden
8    static final List<String> orden = new ArrayList<>();     // los terminados: primero lo que no usa nada
9    static final List<String> ciclo = new ArrayList<>();
10
11    /** DFS desde u. Si encuentra un ciclo, lo deja en «ciclo» (empezando y acabando en el mismo módulo) y
12        devuelve true. Si no, al terminar u lo añade a «orden» y devuelve false. */
13    static boolean visitar(String u) {
14        color.put(u, GRIS);
15        camino.add(u);
16        for (String v : usa.get(u)) {
17            int c = color.get(v);
18            if (c == GRIS) {                                   // v está en el camino actual: se ha cerrado un ciclo
19                ciclo.addAll(camino.subList(camino.indexOf(v), camino.size()));
20                ciclo.add(v);
21                return true;
22            }
23            if (c == BLANCO && visitar(v)) return true;
24        }
25        camino.remove(camino.size() - 1);
26        color.put(u, NEGRO);
27        orden.add(u);                                          // todo lo que usa ya está en la lista
28        return false;
29    }
30
31    public static void main(String[] args) {
32        Scanner sc = new Scanner(System.in);
33        while (sc.hasNextLine()) {
34            String linea = sc.nextLine().trim();
35            if (linea.isEmpty()) continue;
36            int dos = linea.indexOf(':');
37            String nombre = dos < 0 ? "" : linea.substring(0, dos).trim();
38            if (!nombre.matches("[\\w.-]+")) {
39                System.out.println("Línea no válida: «" + linea + "»");
40                continue;
41            }
42            usa.computeIfAbsent(nombre, k -> new TreeSet<>());
43            String resto = linea.substring(dos + 1).trim();
44            if (!resto.isEmpty())
45                for (String m : resto.split("\\s+")) {
46                    usa.get(nombre).add(m);
47                    usa.computeIfAbsent(m, k -> new TreeSet<>());
48                }
49        }
50        for (String m : usa.keySet()) color.put(m, BLANCO);
51        for (String m : usa.keySet()) {
52            if (color.get(m) == BLANCO && visitar(m)) {
53                System.out.println("Ciclo: " + String.join(" → ", ciclo));
54                return;
55            }
56        }
57        System.out.println("Sin ciclos. Orden de compilación: " + String.join(", ", orden));
58    }
59}

Los tres colores son la clave: un módulo gris está en el camino actual, así que llegar a él desde abajo significa haber dado la vuelta. Llegar a un negro no: solo es una dependencia compartida.

El orden de terminación es una ordenación topológica al revés de las flechas «usa»: cada módulo termina después que todo lo que usa. Es lo que hacen Maven o Gradle para decidir qué compilar primero.

Test

Test: Recorrido en profundidad (DFS)

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Qué estructura de datos usa DFS, de forma explícita o con la recursividad?

  2. 2.¿Garantiza DFS encontrar el camino con menos aristas?

  3. 3.En un grafo dirigido, ¿qué indica una arista hacia un nodo que está «en curso» (gris)?

  4. 4.¿Cómo se cuentan las componentes conexas de un grafo no dirigido?

  5. 5.¿Qué riesgo tiene el DFS recursivo en un grafo que es una cadena de 100.000 nodos?

Relacionado