Ir al contenido
wannacode

Dijkstra

En resumen: El algoritmo de Dijkstra encuentra los caminos más cortos desde un vértice en un grafo con pesos no negativos, tomando el vértice más cercano de una cola de prioridad: O((V + E) log V).

Problema

Menos calles no es lo más rápido

Un mensajero sale del almacén hacia un cliente. La app tiene que darle la ruta más rápida. Cada calle tiene un tiempo en minutos: el bulevar es ancho pero está atascado, y las callejuelas son rápidas.

La ciudad es un grafo, y las calles son sus aristas en las dos direcciones (mira cómo se representa un grafo):

static void AddStreet(Dictionary<string, List<Street>> city,
string a, string b, int minutes)
{
if (!city.ContainsKey(a)) city[a] = [];
if (!city.ContainsKey(b)) city[b] = [];
city[a].Add(new Street(b, minutes));
city[b].Add(new Street(a, minutes));
}
var city = new Dictionary<string, List<Street>>();
AddStreet(city, "Almacén", "Puente", 2);
AddStreet(city, "Almacén", "Plaza", 5);
AddStreet(city, "Almacén", "Mercado", 15);
AddStreet(city, "Puente", "Plaza", 1);
AddStreet(city, "Puente", "Estación", 8);
AddStreet(city, "Plaza", "Estación", 3);
AddStreet(city, "Plaza", "Mercado", 9);
AddStreet(city, "Estación", "Cliente", 3);
AddStreet(city, "Mercado", "Cliente", 2);

La primera idea es BFS de la lección anterior: encuentra el camino más corto. Pero BFS cuenta calles, no minutos:

static Dictionary<string, string> FewestStreets(
Dictionary<string, List<Street>> city, string start)
{
var prev = new Dictionary<string, string>();
var seen = new HashSet<string> { start };
var queue = new Queue<string>([start]);
while (queue.TryDequeue(out var place))
{
foreach (Street street in city[place])
{
if (seen.Add(street.To))
{
prev[street.To] = place;
queue.Enqueue(street.To);
}
}
}
return prev;
}
var city = new Dictionary<string, List<Street>>();
AddStreet(city, "Almacén", "Puente", 2);
AddStreet(city, "Almacén", "Plaza", 5);
AddStreet(city, "Almacén", "Mercado", 15);
AddStreet(city, "Puente", "Plaza", 1);
AddStreet(city, "Puente", "Estación", 8);
AddStreet(city, "Plaza", "Estación", 3);
AddStreet(city, "Plaza", "Mercado", 9);
AddStreet(city, "Estación", "Cliente", 3);
AddStreet(city, "Mercado", "Cliente", 2);
var r = Route(FewestStreets(city, "Almacén"), "Cliente");
Console.WriteLine($"Menos calles: {string.Join(" → ", r)} ({r.Count - 1} calles, {Minutes(city, r)} min)");
Salida
Menos calles: Almacén → Mercado → Cliente (2 calles, 17 min)

Dos calles, pero 17 minutos: 15 por el bulevar hasta el mercado y 2 hasta el cliente. Por el puente, la plaza y la estación son cuatro calles y solo 9 minutos. BFS ni siquiera mira los minutos.

La idea

Primero el lugar más cercano

BFS avanza por número de calles: primero todo lo que está a una calle, luego a dos. Aquí tienes que avanzar por minutos: primero el lugar al que llegas más rápido, luego el siguiente.

Eso es el algoritmo de Dijkstra (Edsger Dijkstra, 1956). Para cada lugar guarda el mejor tiempo hasta ahora, dist, y una cola de entradas «minutos, lugar».

  1. Al principio dist es 0 para el almacén, y cualquier otro lugar está sin alcanzar (∞).
  2. Toma de la cola la entrada con menos minutos. Es una cola de prioridad: saca la menor en O(log n).
  3. Para cada calle de ese lugar, prueba: ¿llega al vecino más rápido que antes? Si es así, anota el nuevo tiempo, recuerda de dónde viniste (prev) y mete una entrada nueva en la cola. Este probar se llama relajación (relaxation).
  4. Repite hasta que la cola quede vacía.

¿Por qué el lugar que sale de la cola ya tiene su tiempo definitivo? Todas las demás entradas tienen al menos tantos minutos, y cada calle suma minutos. Ningún camino que pase por ellas puede llegar antes.

Míralo

Dijkstra por dentro

static Paths Dijkstra(
Dictionary<string, List<Street>> city, string start)
{
var dist = new Dictionary<string, int> { [start] = 0 };
var prev = new Dictionary<string, string>();
var queue = new PriorityQueue<string, int>();
queue.Enqueue(start, 0);
while (queue.TryDequeue(out var place, out var minutes))
{
if (minutes > dist[place]) continue; // obsoleta
foreach (Street street in city[place])
{
int t = minutes + street.Minutes;
if (!dist.TryGetValue(street.To, out int old)
|| t < old)
{
dist[street.To] = t;
prev[street.To] = place;
queue.Enqueue(street.To, t);
}
}
}
return new Paths(dist, prev);
}
PriorityQueue<string, int> guarda el lugar, y la prioridad son los minutos. TryDequeue devuelve las dos cosas. Si TryGetValue no encuentra el lugar, aún no se ha alcanzado: eso es ∞.

prev guarda la ruta al revés: para cada lugar, de dónde vino el camino más rápido. Empiezas por el cliente, sigues prev hasta el almacén y le das la vuelta:

static List<string> Route(Dictionary<string, string> prev, string end)
{
var places = new List<string>();
for (string? at = end; at != null; at = prev.GetValueOrDefault(at))
{
places.Add(at);
}
places.Reverse();
return places;
}
var paths = Dijkstra(city, "Almacén");
var r = Route(paths.Prev, "Cliente");
Console.WriteLine($"Más rápido: {string.Join(" → ", r)} ({r.Count - 1} calles, {Minutes(city, r)} min)");
Salida
Más rápido: Almacén → Puente → Plaza → Estación → Cliente (4 calles, 9 min)

static Paths Dijkstra(
Dictionary<string, List<Street>> city, string start)
{
var dist = new Dictionary<string, int> { [start] = 0 };
var prev = new Dictionary<string, string>();
var queue = new PriorityQueue<string, int>();
queue.Enqueue(start, 0);
while (queue.TryDequeue(out var place, out var minutes))
{
if (minutes > dist[place]) continue; // obsoleta
foreach (Street street in city[place])
{
int t = minutes + street.Minutes;
if (!dist.TryGetValue(street.To, out int old)
|| t < old)
{
dist[street.To] = t;
prev[street.To] = place;
queue.Enqueue(street.To, t);
}
}
}
return new Paths(dist, prev);
}
Paso a paso, en palabras
  1. El mensajero sale de Almacén: 0 minutos. Todos los demás lugares están en ∞, nada ha llegado todavía a ellos. La cola tiene una sola entrada: 0 Almacén.
  2. Se toma la entrada más pequeña, 0 Almacén. Cada calle desde ahí da un tiempo: Puente 2, Plaza 5, Mercado 15. Todos son mejores que ∞: se anotan y entran en la cola.
  3. Sigue 2 Puente. Pasando por ahí, Plaza cuesta 2 + 1 = 3, mejor que 5: cambian el tiempo y prev, y entra 3 Plaza. La entrada 5 Plaza se queda en la cola, ya está obsoleta. Estación recibe 10.
  4. 3 Plaza: definitivo, nada en la cola puede hacerlo más rápido. Pasando por ahí, Estación baja de 10 a 6 y Mercado de 15 a 12.
  5. Arriba está 5 Plaza, pero Plaza ya tiene 3. La entrada está obsoleta: se salta. Es el borrado perezoso: en vez de buscar la entrada vieja en la cola, la tiras cuando sale.
  6. 6 Estación: definitivo. Cliente recibe 6 + 3 = 9. Volver a Puente con 14 y a Plaza con 9 no mejora nada.
  7. 9 Cliente: definitivo, 9 minutos. Pasando por Cliente, Mercado baja de 12 a 11. La frontera crece por tiempo, no por número de calles.
  8. El resto: 10 Estación está obsoleta, 11 Mercado es definitiva y no mejora nada, 12 y 15 Mercado están obsoletas. La cola queda vacía.
  9. prev da la ruta al revés: Cliente ← Estación ← Plaza ← Puente ← Almacén. Al darle la vuelta: Almacén → Puente → Plaza → Estación → Cliente, 9 minutos, cuatro calles. La ruta de dos calles por Mercado tarda 17.

Los lugares se vuelven definitivos en el orden de su tiempo: Almacén 0, Puente 2, Plaza 3, Estación 6, Cliente 9, Mercado 11. El mercado, a una calle del almacén, es el último.

Pruébalo

Predice la salida

¿Cuántos minutos hasta el mercado da Dijkstra?

var city = new Dictionary<string, List<Street>>();
AddStreet(city, "Almacén", "Puente", 2);
AddStreet(city, "Almacén", "Plaza", 5);
AddStreet(city, "Almacén", "Mercado", 15);
AddStreet(city, "Puente", "Plaza", 1);
AddStreet(city, "Puente", "Estación", 8);
AddStreet(city, "Plaza", "Estación", 3);
AddStreet(city, "Plaza", "Mercado", 9);
AddStreet(city, "Estación", "Cliente", 3);
AddStreet(city, "Mercado", "Cliente", 2);
Console.WriteLine(Dijkstra(city, "Almacén").Dist["Mercado"]);
Respuesta

La idea

Entradas obsoletas

Cuando la plaza recibe 3 minutos en vez de 5, la entrada «5 Plaza» ya está en la cola. ¿Por qué no cambiarla en su sitio?

Las colas integradas no pueden: PriorityQueue de C# no tiene «cambiar prioridad». Remove (desde .NET 9) busca el elemento de forma lineal, O(n). Así que el mejor tiempo entra como una entrada nueva. La vieja se queda, y cuando sale, la comprobación la tira: su tiempo es mayor que el de dist. Esto es el borrado perezoso (lazy deletion).

La cola puede tener más entradas que lugares, como máximo una por calle. El precio es un poco de memoria y algunas salidas vacías.

while (queue.TryDequeue(out var place, out var minutes))
{
    if () continue; // obsoleta

Pruébalo

Encuentra el error

Un compañero decidió que el primer tiempo encontrado es también el mejor, como en BFS:

static Paths Dijkstra(
Dictionary<string, List<Street>> city, string start)
{
var dist = new Dictionary<string, int> { [start] = 0 };
var prev = new Dictionary<string, string>();
var queue = new PriorityQueue<string, int>();
queue.Enqueue(start, 0);
while (queue.TryDequeue(out var place, out var minutes))
{
if (minutes > dist[place]) continue; // obsoleta
foreach (Street street in city[place])
{
int t = minutes + street.Minutes;
if (!dist.ContainsKey(street.To))
{
dist[street.To] = t;
prev[street.To] = place;
queue.Enqueue(street.To, t);
}
}
}
return new Paths(dist, prev);
}
var paths = Dijkstra(city, "Almacén");
var r = Route(paths.Prev, "Cliente");
Console.WriteLine($"Más rápido: {string.Join(" → ", r)} ({r.Count - 1} calles, {Minutes(city, r)} min)");
Salida
Más rápido: Almacén → Puente → Estación → Cliente (3 calles, 13 min)

13 minutos en vez de 9. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Cuánto cuesta

Cada calle se prueba dos veces (una desde cada extremo) y puede añadir una entrada. Cada entrada entra y sale una vez, a O(log V). En total O((V + E) log V) para V lugares y E calles.

Sin la cola, el lugar más cercano se busca con un bucle sobre todos los lugares que aún no son definitivos. Así se escribió originalmente el algoritmo:

static Dictionary<string, int> DijkstraScan(
Dictionary<string, List<Street>> city, string start)
{
var dist = city.Keys.ToDictionary(place => place, _ => int.MaxValue);
dist[start] = 0;
var done = new HashSet<string>();
while (done.Count < city.Count)
{
string? best = null;
foreach (string place in city.Keys)
{
if (done.Contains(place)) continue;
if (best == null || dist[place] < dist[best]) best = place;
}
if (dist[best!] == int.MaxValue) break;
done.Add(best!);
foreach (Street street in city[best!])
{
int t = dist[best!] + street.Minutes;
if (t < dist[street.To]) dist[street.To] = t;
}
}
return dist;
}
var city = new Dictionary<string, List<Street>>();
AddStreet(city, "Almacén", "Puente", 2);
AddStreet(city, "Almacén", "Plaza", 5);
AddStreet(city, "Almacén", "Mercado", 15);
AddStreet(city, "Puente", "Plaza", 1);
AddStreet(city, "Puente", "Estación", 8);
AddStreet(city, "Plaza", "Estación", 3);
AddStreet(city, "Plaza", "Mercado", 9);
AddStreet(city, "Estación", "Cliente", 3);
AddStreet(city, "Mercado", "Cliente", 2);
var dist = DijkstraScan(city, "Almacén");
Console.WriteLine(string.Join(", ", new[] { "Almacén", "Puente", "Plaza", "Estación", "Mercado", "Cliente" }
.Select(p => $"{p} {dist[p]}")));
Salida
Almacén 0, Puente 2, Plaza 3, Estación 6, Mercado 11, Cliente 9

La respuesta es la misma. Pero el bucle tiene V pasos, y se hace V veces: O(V²).

Ejecuta las dos versiones en un barrio donde los cruces forman una cuadrícula y cada uno está unido a su vecino de la derecha y al de abajo. Cada calle dura de 1 a 9 minutos. Se cuenta cada comparación de dos tiempos: al buscar el lugar más cercano, dentro de la cola y al probar una calle.

  • Sin cola: un bucle para el más cercano O(n²)
  • Con una cola de prioridad O(n log n)
Algoritmon = 100n = 1000n = 10.000n = 100.000
Sin cola: un bucle para el más cercano5410504.372≈ 50.437.200≈ 5.043.720.000
Con una cola de prioridad126917.121213.6752.554.618
El código se ejecuta en tu navegador, en JavaScript. La cantidad de operaciones es la misma en todos los lenguajes, porque el algoritmo es el mismo. El tiempo depende de la máquina, así que solo sirve para comparar. «≈» es una estimación a partir de la curva: un n tan grande no lo ejecutamos, para que la página no se congele.
static Paths Dijkstra(
Dictionary<string, List<Street>> city, string start)
{
var dist = new Dictionary<string, int> { [start] = 0 };
var prev = new Dictionary<string, string>();
var queue = new PriorityQueue<string, int>();
queue.Enqueue(start, 0);
while (queue.TryDequeue(out var place, out var minutes))
{
if (minutes > dist[place]) continue; // obsoleta
foreach (Street street in city[place])
{
int t = minutes + street.Minutes;
if (!dist.TryGetValue(street.To, out int old)
|| t < old)
{
dist[street.To] = t;
prev[street.To] = place;
queue.Enqueue(street.To, t);
}
}
}
return new Paths(dist, prev);
}

Con 1000 cruces, la versión sin cola hace 504 372 comparaciones, la que tiene cola 17 121. Con 100 000 cruces, el tamaño de una ciudad grande, la cola hace 2 554 618. Sin ella serían unos 5 mil millones: Bench no las ejecuta, las estima a partir de la curva.

Más precisamente, para curiosos

¿Cuándo es mejor O(V²)? Cuando el grafo es denso: cada lugar está unido a casi todos los demás, y E es más o menos V². Entonces la cola hace O(V² log V) y el bucle O(V²). En una red de carreteras cada cruce tiene 3 o 4 calles, el grafo es ralo y gana la cola.

Los pesos negativos rompen Dijkstra. Si una calle cuesta −5 (digamos, una bonificación de entrega en el camino), un lugar que ya es definitivo puede acercarse después por ella, y el algoritmo ya no lo mira. Para esos grafos existe Bellman-Ford: prueba cada calle V − 1 veces, O(V · E), y avisa si hay un ciclo que reduce el tiempo sin fin.

Una trampa en Java y C#: ∞ a menudo se escribe como int.MaxValue. Si le sumas minutos, el número desborda y se vuelve negativo: el lugar sin alcanzar parece el más cercano. Por eso dijkstraScan se detiene en cuanto el más cercano es ∞, y dijkstra ni siquiera guarda ∞: un lugar que falta es un lugar sin alcanzar.

La navegación de tu teléfono no ejecuta Dijkstra sobre todo un continente. Usa A*: un Dijkstra que suma a los minutos de la cola una estimación de cuánto falta en línea recta, y por eso avanza hacia la meta en vez de expandirse en todas las direcciones. OSPF, el protocolo con el que los routers de una red encuentran el camino de los paquetes, ejecuta Dijkstra de verdad.

Ejercicio

Una calle cerrada

Resuelve la tarea en tu editor.

Obras. La calle entre el puente y la plaza está cerrada. Quítala de la ciudad (en las dos direcciones) y muestra la ruta más rápida y los minutos hasta la estación, el cliente y el mercado:

Salida
Estación: Almacén → Plaza → Estación (8 min)
Cliente: Almacén → Plaza → Estación → Cliente (11 min)
Mercado: Almacén → Plaza → Estación → Cliente → Mercado (13 min)

El mercado sigue siendo más rápido pasando por el cliente: 13 minutos contra 14 por la plaza y 15 por el bulevar.

Más tareas

  • Para a tiempo. Si solo te interesa el cliente, para en cuanto salga de la cola. ¿Cuántas entradas quedan sin tomar?
  • Cuenta las salidas. Cuenta cuántas entradas salieron de la cola y cuántas de ellas estaban obsoletas.
  • Dos mensajeros. Dos almacenes, un cliente: ¿qué mensajero llega primero? Pista: mete los dos almacenes en la cola con 0 minutos.

Test

Ponte a prueba

1. ¿Por qué BFS no encuentra la ruta más rápida cuando las calles tienen minutos?
2. Un lugar sale de la cola con los menos minutos de todas las entradas. ¿Por qué su tiempo es definitivo?
3. En la cola hay 5 Plaza, y Plaza ya tiene 3 minutos. ¿Qué hace el algoritmo cuando sale 5 Plaza?
4. ¿Cuánto cuesta Dijkstra con un heap binario para V lugares y E calles?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·