Apuntes DAM

Ordenación topológica

Ordena los nodos de un grafo dirigido para que cada uno vaya después de todos los que necesita. El algoritmo de Kahn quita nodos sin dependencias pendientes y detecta los ciclos.

nivel intermedioTambién: topological sort, algoritmo de Kahn, orden de dependencias, grafo acíclico dirigido, DAG

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.

Ordenación topológica

Escribe las dependencias de un grafo dirigido («A>C»: A va antes que C) y el algoritmo de Kahn sacará un orden que las respete todas.

Como «A>C, B>C»: hasta 9 nodos y 16 flechas
  • esperando (le llegan flechas)

Paso 1

Primero se cuenta el grado de entrada de cada nodo: cuántas flechas le llegan, es decir, cuántas cosas tienen que ir antes que él.

1static List<String> ordenTopologico(Map<String, List<String>> g) {
2    Map<String, Integer> entrada = new LinkedHashMap<>();
3    for (String u : g.keySet()) entrada.put(u, 0);
4    for (List<String> vs : g.values())
5        for (String v : vs) entrada.merge(v, 1, Integer::sum);  // entrada = A=0 B=0 C=2 D=1 E=2 F=1 G=2, cola = []
6    Deque<String> cola = new ArrayDeque<>();
7    for (String u : g.keySet()) if (entrada.get(u) == 0) cola.add(u);
8    List<String> orden = new ArrayList<>();
9    while (!cola.isEmpty()) {
10        String u = cola.poll();
11        orden.add(u);
12        for (String v : g.get(u))
13            if (entrada.merge(v, -1, Integer::sum) == 0) cola.add(v);
14    }
15    return orden.size() == g.size() ? orden : null;
16}

Variables

entrada
A=0 B=0 C=2 D=1 E=2 F=1 G=2
cola
[]

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

La idea

Muchos problemas son listas de dependencias: una asignatura exige haber aprobado otras, un módulo no compila sin los que importa, una tarea no puede empezar hasta que acaben otras. Se representan con un grafo dirigido en el que la flecha A → B significa «A va antes que B». Una ordenación topológica es un orden de todos los nodos en el que todas las flechas apuntan hacia delante.

Solo existe si el grafo no tiene ciclos (es un DAG, *directed acyclic graph*): si A necesita B y B necesita A, ninguno puede ir primero. Y casi nunca es única: si dos tareas no dependen la una de la otra, cualquiera puede ir antes.

El algoritmo de Kahn lo calcula como lo haría una persona: cuenta cuántas flechas le llegan a cada nodo (su grado de entrada), pone en una cola los que tienen 0 (no necesitan nada) y repite: saca uno, lo añade al orden y quita sus flechas salientes. Los nodos que se quedan sin flechas entrantes pasan a la cola. Si al final no se han colocado todos, los que faltan forman un ciclo o dependen de uno.

La otra forma es con DFS: un nodo se añade al principio de la lista cuando termina su recorrido, es decir, después de todos los que van detrás de él. Kahn tiene la ventaja de detectar el ciclo de forma natural y de poder elegir entre los disponibles (por ejemplo, el menor alfabéticamente con una cola de prioridad). Además, los nodos que están libres a la vez se pueden hacer en paralelo.

Cuándo usarlo

  • Orden de compilación o de construcción de módulos (Maven, Gradle, npm, Make).
  • Planes de estudios con requisitos y planificación de proyectos (con duraciones: el camino crítico).
  • Orden de creación de tablas con claves ajenas, de migraciones o de arranque de servicios que dependen unos de otros.
  • Hojas de cálculo: recalcular cada celda después de las que usa.

Cuándo no

  • Si el grafo tiene ciclos que no se pueden romper: no hay orden posible (Kahn lo detecta, pero no lo arregla).
  • Si las relaciones no tienen dirección (amistades, carreteras de doble sentido): no tiene sentido.
  • Si solo quieres saber si B depende de A, basta un recorrido desde A.

Paso a paso

  1. Contar grados de entrada. Para cada nodo, cuántas flechas le llegan: cuántas cosas tienen que ir antes.
  2. Cola de libres. Mete en una cola los nodos con grado 0: no necesitan nada.
  3. Sacar y liberar. Saca uno, añádelo al orden y resta 1 al grado de cada nodo al que apunta; el que llegue a 0 entra en la cola.
  4. Comprobar. Si el orden tiene todos los nodos, es una ordenación topológica. Si faltan, hay un ciclo entre los que quedan (o dependen de uno).

El código

Kahn: en qué orden construir los módulos

Además del orden se calcula la tanda de cada módulo (una más que la de los que necesita): los de la misma tanda se pueden construir en paralelo. Después se añade una dependencia circular.

Java
1import java.util.*;
2
3public class Main {
4    static String ordenar(List<String> nodos, String[][] flechas) {
5        Map<String, List<String>> sale = new LinkedHashMap<>();
6        Map<String, Integer> entrada = new LinkedHashMap<>();
7        Map<String, Integer> tanda = new HashMap<>();
8        for (String n : nodos) {
9            sale.put(n, new ArrayList<>());
10            entrada.put(n, 0);
11            tanda.put(n, 1);
12        }
13        for (String[] f : flechas) {                           // {antes, después}
14            sale.get(f[0]).add(f[1]);
15            entrada.merge(f[1], 1, Integer::sum);
16        }
17        Deque<String> cola = new ArrayDeque<>();
18        for (String n : nodos) if (entrada.get(n) == 0) cola.add(n);
19        List<String> orden = new ArrayList<>();
20        while (!cola.isEmpty()) {
21            String u = cola.poll();
22            orden.add(u);
23            for (String v : sale.get(u)) {
24                tanda.put(v, Math.max(tanda.get(v), tanda.get(u) + 1));   // después de todo lo que necesita
25                if (entrada.merge(v, -1, Integer::sum) == 0) cola.add(v);
26            }
27        }
28        if (orden.size() < nodos.size()) {
29            List<String> atascados = new ArrayList<>(nodos);
30            atascados.removeAll(orden);
31            return "Hay un ciclo: " + String.join(", ", atascados) + " nunca quedan libres";
32        }
33        StringBuilder sb = new StringBuilder("Orden: " + String.join(" → ", orden));
34        for (int t = 1; t <= Collections.max(tanda.values()); t++) {
35            List<String> juntos = new ArrayList<>();
36            for (String n : orden) if (tanda.get(n) == t) juntos.add(n);
37            sb.append("\n  tanda ").append(t).append(": ").append(String.join(", ", juntos));
38        }
39        return sb.toString();
40    }
41
42    public static void main(String[] args) {
43        List<String> modulos = List.of("util", "config", "modelo", "datos", "servicio", "web", "tests");
44        String[][] deps = {{"util", "modelo"}, {"modelo", "datos"}, {"util", "datos"}, {"datos", "servicio"},
45                {"modelo", "servicio"}, {"servicio", "web"}, {"servicio", "tests"}, {"util", "tests"}, {"config", "web"}};
46        System.out.println(ordenar(modulos, deps));
47        String[][] conCiclo = Arrays.copyOf(deps, deps.length + 1);
48        conCiclo[deps.length] = new String[]{"tests", "util"};     // los tests no pueden ir antes que util
49        System.out.println(ordenar(modulos, conCiclo));
50    }
51}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Orden: util → config → modelo → datos → servicio → web → tests
  tanda 1: util, config
  tanda 2: modelo
  tanda 3: datos
  tanda 4: servicio
  tanda 5: web, tests
Hay un ciclo: util, modelo, datos, servicio, web, tests nunca quedan libres

La otra forma: DFS en postorden

Cada módulo se añade al principio de la lista cuando terminan todos los que van detrás de él. Sale otro orden, también válido: lo comprueba la última línea.

Java
1import java.util.*;
2
3public class Main {
4    static final Map<String, List<String>> sale = new LinkedHashMap<>();
5    static final Set<String> vistos = new HashSet<>();
6    static final LinkedList<String> orden = new LinkedList<>();
7
8    static void visitar(String u) {
9        vistos.add(u);
10        for (String v : sale.get(u))
11            if (!vistos.contains(v)) visitar(v);
12        orden.addFirst(u);                    // al TERMINAR: todo lo que va después ya está en la lista
13    }
14
15    public static void main(String[] args) {
16        List<String> modulos = List.of("util", "config", "modelo", "datos", "servicio", "web", "tests");
17        String[][] deps = {{"util", "modelo"}, {"modelo", "datos"}, {"util", "datos"}, {"datos", "servicio"},
18                {"modelo", "servicio"}, {"servicio", "web"}, {"servicio", "tests"}, {"util", "tests"}, {"config", "web"}};
19        for (String m : modulos) sale.put(m, new ArrayList<>());
20        for (String[] d : deps) sale.get(d[0]).add(d[1]);
21        for (String m : modulos) if (!vistos.contains(m)) visitar(m);
22        System.out.println("Orden con DFS: " + String.join(" → ", orden));
23
24        Map<String, Integer> pos = new HashMap<>();
25        for (int i = 0; i < orden.size(); i++) pos.put(orden.get(i), i);
26        boolean valido = true;
27        for (String[] d : deps)
28            if (pos.get(d[0]) > pos.get(d[1])) valido = false;    // una flecha hacia atrás lo estropearía
29        System.out.println(valido ? "Válido: todas las flechas van hacia delante" : "No válido");
30    }
31}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Orden con DFS: config → util → modelo → datos → servicio → tests → web
Válido: todas las flechas van hacia delante

Traza: Kahn con las dependencias del visualizador

SaleFlechas que se quitanQuedan libresCola después
AA→C—B
BB→C, B→DC, DC D
CC→E—D
DD→E, D→FE, FE F
EE→G—F
FF→GGG
G——vacía

A y B empiezan libres. C necesita a A y a B, así que no entra en la cola hasta que se quita la segunda flecha (B→C).

Complejidad

AlgoritmoTiempoMemoria
Kahn (cola normal)O(V + E)O(V)
Kahn con cola de prioridad (el menor primero)O(V log V + E)O(V)
DFS en postordenO(V + E)O(V)
Comprobar que un orden es válidoO(V + E)O(V)

Contar los grados recorre todas las aristas una vez; después, cada nodo sale de la cola una vez y cada arista se quita una vez.

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): contar grados recorre las aristas una vez y cada nodo sale de la cola una vez, quitando sus flechas. Las curvas grises son las demás clases, para comparar.

En la práctica

  • Maven y Gradle ordenan los módulos de un proyecto y fallan si hay dependencias circulares.
  • Los gestores de paquetes (npm, pip, apt) instalan las dependencias antes que lo que las usa.
  • Las hojas de cálculo recalculan las celdas en orden topológico; una «referencia circular» es un ciclo.
  • Los orquestadores de tareas (Airflow, los *pipelines* de integración continua) describen los trabajos como un DAG.
  • Al crear las tablas de una base de datos, las que son referenciadas por claves ajenas van antes.

Errores típicos

  • No detectar el ciclo y devolver un orden incompleto como si fuera bueno.
  • Contar mal los grados con flechas repetidas (contarlas en un sitio y no en el otro).
  • Olvidar los nodos aislados, que no aparecen en ninguna flecha pero también van en el orden.
  • Confundir el sentido de la flecha: «A depende de B» es B → A.
  • Esperar un único orden correcto: suele haber muchos; si se quiere uno concreto hay que fijar el criterio (por ejemplo, alfabético).

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. Plan de estudios

Cada línea A > B dice que la asignatura A hay que cursarla antes que B; una línea con un solo nombre es una asignatura sin requisitos. Escribe un orden en el que cursarlas todas: cuando haya varias disponibles a la vez, primero la menor alfabéticamente. El main ya cuenta los requisitos de cada asignatura: completa ordenar.

  • Entrada: PRO > AD, BD > AD, FOL… (los espacios alrededor de > no importan). Una línea repetida cuenta una vez.
  • Salida: 1. BD, 2. FOL, 3. PRO… una por línea.
  • Con requisitos circulares: Imposible: hay requisitos circulares. Sin ordenar: AD, DI (las que no se han podido colocar, en orden alfabético). Línea con dos > o sin nombre: Línea no válida: «…».
JavaPlan de estudiosMedio

Ejemplo

Entrada (lo que se escribe por teclado)
PRO > AD
BD > AD
PRO > PSP
PRO > DI
LM > DI
AD > PMDM
SI
FOL
Salida esperada
1. BD
2. FOL
3. LM
4. PRO
5. AD
6. DI
7. PMDM
8. PSP
9. SI
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    /** Para cada asignatura, las que la necesitan (las que van después). */
5    static final Map<String, List<String>> despues = new TreeMap<>();
6    /** Cuántas asignaturas necesita cada una. */
7    static final Map<String, Integer> requisitos = new TreeMap<>();
8
9    /** Un orden en el que cada asignatura va después de las que necesita; entre las que se pueden cursar a
10        la vez, primero la menor alfabéticamente. Si hay requisitos circulares, solo las que se han podido ordenar. */
11    static List<String> ordenar() {
12        Map<String, Integer> faltan = new HashMap<>(requisitos);
13        PriorityQueue<String> libres = new PriorityQueue<>();      // saca la menor alfabéticamente
14        for (String a : faltan.keySet()) if (faltan.get(a) == 0) libres.add(a);
15        List<String> orden = new ArrayList<>();
16        while (!libres.isEmpty()) {
17            String a = libres.poll();
18            orden.add(a);
19            for (String b : despues.get(a))
20                if (faltan.merge(b, -1, Integer::sum) == 0) libres.add(b);
21        }
22        return orden;
23    }
24
25    public static void main(String[] args) {
26        Scanner sc = new Scanner(System.in);
27        while (sc.hasNextLine()) {
28            String linea = sc.nextLine().trim();
29            if (linea.isEmpty()) continue;
30            String[] p = linea.split(">", -1);
31            if (p.length > 2 || p[0].isBlank() || (p.length == 2 && p[1].isBlank())) {
32                System.out.println("Línea no válida: «" + linea + "»");
33                continue;
34            }
35            String a = p[0].trim();
36            despues.putIfAbsent(a, new ArrayList<>());
37            requisitos.putIfAbsent(a, 0);
38            if (p.length == 2) {
39                String b = p[1].trim();
40                despues.putIfAbsent(b, new ArrayList<>());
41                requisitos.putIfAbsent(b, 0);
42                if (!despues.get(a).contains(b)) {               // las líneas repetidas no cuentan dos veces
43                    despues.get(a).add(b);
44                    requisitos.merge(b, 1, Integer::sum);
45                }
46            }
47        }
48        List<String> orden = ordenar();
49        if (orden.size() < requisitos.size()) {
50            List<String> resto = new ArrayList<>(requisitos.keySet());
51            resto.removeAll(orden);
52            System.out.println("Imposible: hay requisitos circulares. Sin ordenar: " + String.join(", ", resto));
53            return;
54        }
55        for (int i = 0; i < orden.size(); i++) System.out.println((i + 1) + ". " + orden.get(i));
56    }
57}

Con una cola normal el orden también sería válido, pero dependería del orden de llegada. La cola de prioridad fija el criterio y da siempre el mismo resultado: el orden topológico lexicográficamente menor.

El ciclo no hace falta buscarlo aparte: si algo nunca llega a 0 requisitos, no se coloca, y el orden sale más corto que la lista de asignaturas.

2. ¿Cuánto dura el proyecto?

Cada línea es una tarea con su duración en días y las tareas que tienen que haber acabado antes de empezarla. Las tareas sin dependencias entre sí se hacen en paralelo. Calcula el primer día en que puede empezar cada tarea y la duración total del proyecto. El main ya lee y valida las tareas: completa calcular con un orden topológico.

  • Entrada: tejado 4 muros (nombre, días y dependencias separadas por espacios). Las dependencias pueden declararse más abajo.
  • Salida, en el orden de la entrada: tejado: del día 12 al 16, y al final Duración total: 18 días.
  • Errores (y se termina): Línea no válida: «…», Tarea repetida: x, Dependencia desconocida: x (la necesita y) o Imposible: hay dependencias circulares.
Java¿Cuánto dura el proyecto?Difícil

Ejemplo

Entrada (lo que se escribe por teclado)
cimientos 5
muros 7 cimientos
tejado 4 muros
electricidad 3 muros
fontaneria 2 muros
pintura 2 tejado electricidad fontaneria
jardin 6
Salida esperada
cimientos: del día 0 al 5
muros: del día 5 al 12
tejado: del día 12 al 16
electricidad: del día 12 al 15
fontaneria: del día 12 al 14
pintura: del día 16 al 18
jardin: del día 0 al 6
Duración total: 18 días
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 final Map<String, Integer> duracion = new LinkedHashMap<>();    // en el orden de la entrada
5    static final Map<String, List<String>> necesita = new HashMap<>();     // las que tienen que acabar antes
6    static final Map<String, Integer> inicio = new HashMap<>();            // el primer día en que puede empezar
7
8    /** Rellena inicio con el día más temprano en que puede empezar cada tarea (las que no necesitan nada,
9        el día 0). Devuelve false si hay dependencias circulares. */
10    static boolean calcular() {
11        Map<String, Integer> faltan = new HashMap<>();
12        Map<String, List<String>> desbloquea = new HashMap<>();
13        for (String t : duracion.keySet()) {
14            faltan.put(t, necesita.get(t).size());
15            desbloquea.put(t, new ArrayList<>());
16            inicio.put(t, 0);
17        }
18        for (String t : duracion.keySet())
19            for (String d : necesita.get(t)) desbloquea.get(d).add(t);
20        Deque<String> cola = new ArrayDeque<>();
21        for (String t : duracion.keySet()) if (faltan.get(t) == 0) cola.add(t);
22        int hechas = 0;
23        while (!cola.isEmpty()) {
24            String t = cola.poll();
25            hechas++;
26            int fin = inicio.get(t) + duracion.get(t);
27            for (String s : desbloquea.get(t)) {
28                inicio.put(s, Math.max(inicio.get(s), fin));      // empieza cuando acaba la ÚLTIMA que necesita
29                if (faltan.merge(s, -1, Integer::sum) == 0) cola.add(s);
30            }
31        }
32        return hechas == duracion.size();
33    }
34
35    public static void main(String[] args) {
36        Scanner sc = new Scanner(System.in);
37        while (sc.hasNextLine()) {
38            String linea = sc.nextLine().trim();
39            if (linea.isEmpty()) continue;
40            String[] p = linea.split("\\s+");
41            if (p.length < 2 || !p[1].matches("\\d{1,4}")) {
42                System.out.println("Línea no válida: «" + linea + "»");
43                return;
44            }
45            if (duracion.containsKey(p[0])) {
46                System.out.println("Tarea repetida: " + p[0]);
47                return;
48            }
49            duracion.put(p[0], Integer.parseInt(p[1]));
50            necesita.put(p[0], new ArrayList<>(Arrays.asList(p).subList(2, p.length)));
51        }
52        for (String t : duracion.keySet())
53            for (String d : necesita.get(t))
54                if (!duracion.containsKey(d)) {
55                    System.out.println("Dependencia desconocida: " + d + " (la necesita " + t + ")");
56                    return;
57                }
58        if (!calcular()) {
59            System.out.println("Imposible: hay dependencias circulares");
60            return;
61        }
62        int total = 0;
63        for (String t : duracion.keySet()) {
64            int fin = inicio.get(t) + duracion.get(t);
65            total = Math.max(total, fin);
66            System.out.println(t + ": del día " + inicio.get(t) + " al " + fin);
67        }
68        System.out.println("Duración total: " + total + " días");
69    }
70}

El inicio de una tarea solo depende de los finales de las que necesita, así que basta con calcularlas en un orden en el que esas ya estén hechas: un orden topológico. Es el método del camino crítico (CPM) de la gestión de proyectos.

La duración total es el final más tardío. Las tareas de la cadena que llega a ese final forman el camino crítico: si cualquiera se retrasa, se retrasa todo el proyecto.

Test

Test: Ordenación topológica

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.¿Cuándo existe una ordenación topológica de un grafo dirigido?

  2. 2.En el algoritmo de Kahn, ¿qué nodos entran en la cola al principio?

  3. 3.Kahn termina y el orden tiene menos nodos que el grafo. ¿Qué significa?

  4. 4.Con las flechas A → C y B → C, ¿cuántas ordenaciones topológicas hay?

  5. 5.¿Qué coste tiene el algoritmo de Kahn?

Relacionado