TL;DR
- El Problema: Optimización de complejidad temporal y espacial para estructuras de datos clave.
- El Enfoque: Problema estilo CTCI 2.8 para principiantes: dada una lista enlazada circular, devuelve el nodo donde empieza el bucle. Tortuga y liebre de Floyd, luego el truco de resetear al head, en Java claro.
- Complejidad: Relación óptima de tiempo y espacio con gestión de casos límite.
Corres por un camino que empieza recto y luego se une a un circuito circular del parque. No notas la unión hasta que ves el mismo árbol otra vez. Un amigo sale contigo y corre al doble de velocidad. Os encontraréis en algún punto de ese círculo. Lo interesante: cuando os encontráis, si tu amigo vuelve al inicio del camino y los dos camináis al mismo ritmo, volvéis a coincidir justo en la entrada del bucle. Eso es detección de bucle en una lista enlazada.
Este post es enseñanza original para principiantes en Java. Misma familia de problemas que las preguntas clásicas de ciclos en entrevista, no una copia de libro. Parte de la serie CTCI en Java. Aquí cierra el capítulo 2.
1. Analogía cotidiana
Piensa en una pista de running con un acceso:
- El camino de acceso es el prefijo sin bucle de la lista (desde
headhasta el primer nodo que también está en el ciclo). - La pista ovalada es el ciclo. Algún nodo apunta hacia atrás a un nodo anterior en lugar de terminar en
null. - Una tortuga avanza un paso cada vez. Una liebre avanza dos.
Si no hay óvalo, la liebre llega al final del camino (null) y listo: no hay bucle.
Si hay óvalo, la liebre acaba alcanzando a la tortuga en la pista. Chocan en algún nodo dentro del ciclo, no necesariamente el inicio. La fase dos encuentra el inicio: un corredor vuelve al principio del camino, el otro se queda en el punto de encuentro, ambos caminan de uno en uno. Su siguiente colisión es el comienzo del bucle.
2. Problema en palabras simples
Entrada: el head de una lista enlazada simple. La lista puede ser lineal o contener un ciclo (el next de algún nodo apunta a un nodo anterior).
Salida: el nodo al comienzo del bucle, o null si no hay bucle.
"Comienzo del bucle" es el primer nodo al que puedes volver siguiendo next para siempre. Es el nodo único que, en el dibujo del ciclo, tiene dos aristas de entrada: una desde el prefijo sin bucle (o desde sí mismo si todo el ciclo arranca en el head), y otra desde el nodo anterior del ciclo.
Forma del nodo:
class Node {
int data;
Node next;
Node(int data) {
this.data = data;
}
}
Ejemplos (las letras son identidad de nodo, no solo valores):
| Forma de la lista | El bucle empieza en | Por qué |
|---|---|---|
A → B → C → D → E → C (E apunta a C) |
C |
primer nodo del ciclo |
A → B → C → null |
ninguno (null) |
lista lineal |
A → A (auto-bucle) |
A |
ciclo de un solo nodo |
A → B → C → A |
A |
el ciclo incluye el head |
null |
null |
lista vacía |
Aclara antes de codificar:
- ¿Lista simple? (Sí.)
- ¿Espacio extra O(1)? (Floyd lo logra. Un HashSet de nodos visitados es más simple pero usa O(N).)
- Devuelve el objeto nodo, no solo su valor.
- ¿Se permite auto-bucle? (Sí.)
3. Pensar primero (HashSet, luego Floyd)
Instinto bruto: recordar cada nodo visitado
Recorre desde el head. Mete cada referencia Node en un HashSet. Si next ya está en el set, ese nodo es el inicio del bucle. Si llegas a null, no hay bucle.
Tiempo O(N), espacio O(N). Válido en producción. En entrevista suelen querer la versión de espacio constante.
Floyd: tortuga y liebre (detectar, luego localizar)
Fase 1, detectar un punto de encuentro.
slow = head,fast = head- Bucle:
slow = slow.next(1 paso),fast = fast.next.next(2 pasos) - Si
fastofast.nextesnull, no hay ciclo → devuelvenull - Cuando
slow == fast, se encontraron dentro del ciclo
Fase 2, encontrar el inicio del bucle.
- Deja
slow(ofast) en el nodo de encuentro - Pon el otro puntero otra vez en
head - Avanza ambos un paso cada vez hasta que sean iguales
- Ese nodo es el comienzo del bucle
Por qué funciona el reset (intuición breve)
Sea:
μ= número de nodos antes de que empiece el bucle (longitud del acceso)λ= longitud del ciclo (el óvalo)- Al encontrarse,
slowha caminado una distanciaμ + a(apasos pasada la entrada, con0 ≤ a < λ)
Como fast va al doble, la distancia extra que recorrió es un número entero de vueltas. Eso fuerza una identidad modular limpia: la distancia restante desde el punto de encuentro, alrededor del ciclo, hasta la entrada, equivale a μ módulo λ.
Así, si un puntero vuelve al head y ambos caminan μ pasos a velocidad 1, llegan juntos a la entrada. En el código no hace falta conocer μ ni λ. Basta la igualdad de los dos punteros.
No necesitas una demostración formal en la pizarra. Sí necesitas la historia: chocar en el óvalo, luego carrera desde head y punto de encuentro a la misma velocidad, colisión en la puerta.
4. Solución en Java
/**
* Returns the node at the start of the cycle, or null if the list is acyclic.
* Floyd cycle detection: meet with tortoise/hare, then reset one pointer to head.
*/
Node findLoopStart(Node head) {
if (head == null) {
return null;
}
Node slow = head;
Node fast = head;
// Phase 1: do they ever meet?
boolean met = false;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
met = true;
break;
}
}
if (!met) {
return null; // no loop
}
// Phase 2: one pointer back to head; both step once until equal.
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow; // beginning of the loop
}
Recorrido para A → B → C → D → E → C:
| Fase | Evento |
|---|---|
| Inicio | slow y fast en A |
| Pasos | la liebre se adelanta; ambos entran al final en C-D-E |
| Encuentro | colisionan en algún nodo de {C, D, E} (depende de las longitudes) |
| Reset | pon slow en A, deja fast en el nodo de encuentro |
| Mismo ritmo | ambos avanzan un nodo cada vez |
| Fin | quedan juntos en C |
Para un auto-bucle A → A: la fase 1 se encuentra en A tras el primer par de movimientos. La fase 2 pone slow = head, que también es A, así que slow == fast al instante. Devuelve A.
5. Tabla de complejidad
| Enfoque | Tiempo | Espacio extra | Notas |
|---|---|---|---|
| HashSet de nodos visitados | O(N) | O(N) | Simple; el primer nodo re-visto es el inicio |
| Floyd (tortuga / liebre) | O(N) | O(1) | Dos fases; respuesta habitual en entrevista por espacio |
| Marcar nodos (campo mutado) | O(N) | O(1) | Necesita un campo escribible; malo si la lista se comparte |
N es el número de nodos distintos hasta reentrar al ciclo (o la longitud completa si es lineal). Floyd no reserva un set, así que gana cuando la memoria aprieta o prohíben buffers.
6. Casos límite y errores comunes
Los entrevistadores tocan estos:
- Sin bucle → la fase 1 llega a
nullconfastofast.next. Devuelvenull. No entres en la fase 2. - Un solo nodo, sin auto-bucle (
A → null) →fast.nextes null en la primera comprobación. Sin bucle. - Un solo nodo con auto-bucle (
A → A) → el inicio esA. La fase 2 es igualdad inmediata tras el reset. - El ciclo incluye el head (
A → B → C → A) → el inicio esA. - Lista vacía → devuelve
nullal inicio. - Ciclo de dos nodos (
A → B → A) → sigue funcionando; no hagas un caso especial. - Prefijo largo y bucle minúsculo, o al revés → el mismo algoritmo. El tiempo sigue lineal en N.
Errores comunes:
- Comparar valores
dataen lugar de identidad de nodo. Dos nodos pueden llevar el mismointsin ser el mismo objeto. Usa==en referencias. - Mover ambos punteros sin mirar
fast.next. Siempre guardafast != null && fast.next != nullantes defast.next.next. - Olvidar la fase 2. El encuentro prueba que hay ciclo. No prueba que el nodo de encuentro sea el inicio.
- Avanzar a distinta velocidad en la fase 2. Ambos deben moverse un paso. La matemática solo cierra a ritmo igual tras el reset.
- Devolver el punto de encuentro de la fase 1 como respuesta. Incorrecto casi siempre, salvo suerte con las longitudes.
Entrada mínima segura con null:
Node findLoopStartSafe(Node head) {
return findLoopStart(head);
}
7. Resumen para contárselo a un amigo
Loop detection pregunta: si una lista enlazada simple tiene un ciclo, ¿en qué nodo empieza?
- La tortuga da un paso, la liebre dos. Si la liebre se cae al final, no hay ciclo.
- Si se encuentran, existe un ciclo en algún sitio a partir del head (o en el head).
- Pon un puntero otra vez en el head. Camina ambos de uno en uno. Donde se encuentran es el inicio del bucle.
- Por qué: la longitud del prefijo sin bucle y el desfase alrededor del ciclo se alinean cuando ambos van a la misma velocidad tras el reset. Obtienes la puerta del óvalo sin contar μ ni λ a mano.
- Lista vacía y listas lineales devuelven null. Un auto-bucle de un nodo devuelve ese nodo.
Si puedes decirlo en treinta segundos, dibujar las dos fases y no confundir "punto de encuentro" con "inicio del bucle", dominas el problema 2.8.
Serie
- Guía: Guía de la serie CTCI
- Anterior: Intersection
- Siguiente: Three in One
