Iteradores en Java — ejercicios con playlists, títulos repetidos y un rango con paso (y el «Faro» que nunca se iba)

Me pidieron quitar los títulos repetidos de una lista y lo hice con un for de índices: si el elemento i ya estaba antes, remove(i) y a seguir. Con los datos de ejemplo funcionaba de maravilla. Con los datos reales, donde un mismo título salía dos veces seguidas, siempre se quedaba uno colado. Java no se quejó en ningún momento. El problema era mío: al borrar el elemento i, el siguiente pasa a ocupar su sitio y mi i++ lo saltaba sin mirarlo.

Los iteradores existen, entre otras cosas, para que eso no pase. Hoy practicamos con tres programas de dificultad creciente, tres fragmentos para depurar (uno de ellos, un cursor que se pasa de largo) y un chuletario para llevarte.

Estos ejercicios usan la teoría de iteradores y repasan lo de la práctica. Sin ellas puedes hacerlos, pero te costará más.

Cómo usar este artículo

  1. Lee el enunciado de un nivel y no mires la solución todavía.
  2. Escribe tu versión, compílala y compara tu salida con la esperada.
  3. Si te atascas, mira solo la 🔵 pista y vuelve a intentarlo.
  4. Cuando termines, compara con la solución y fíjate en por qué está hecha así, no solo en que dé la misma salida.

Ningún ejercicio usa Scanner. Los datos van fijos en el código para que tu salida sea idéntica a la esperada y puedas comprobarla de un vistazo.

Nivel 1 · Básico: recorrer una playlist con Iterator

Enunciado. Usa este record para representar las canciones (un record es una clase de datos con constructor y getters ya hechos; titulo() y segundos() son sus métodos de acceso):

public record Cancion(String titulo, int segundos) {
    @Override
    public String toString() {
        return titulo + " (" + segundos / 60 + ":" + String.format("%02d", segundos % 60) + ")";
    }
}

En una clase N1 con main, crea una lista con estas cinco canciones:

  • Amanecer, 215 segundos
  • Carretera, 180 segundos
  • Tormenta, 240 segundos
  • Faro, 135 segundos
  • Marea, 200 segundos

Recórrela con un **Iterator escrito a mano** (nada de for-each) y, para cada canción, imprime su número de orden y la propia canción. Al terminar, muestra la duración total de la playlist con el formato m:ss y cuál es la canción más larga.

Salida esperada:

1. Amanecer (3:35)
2. Carretera (3:00)
3. Tormenta (4:00)
4. Faro (2:15)
5. Marea (3:20)
Duración total: 16:10
La más larga: Tormenta (4:00)

Dentro del while (it.hasNext()), una sola llamada a next() y guarda el resultado en una variable. Con ella puedes imprimirla, sumar sus segundos y comparar con la más larga hasta ahora. Para la duración total, total / 60 son los minutos y total % 60 los segundos (con String.format("%02d", …) te salen siempre dos cifras).

Nivel 2 · Intermedio: títulos sin repetir

Enunciado. Tienes esta lista de títulos, con repeticiones:

Faro, Marea, Faro, Amanecer, Marea, Faro

En N2, déjala con solo la primera aparición de cada título, manteniendo el orden, y cuenta cuántos has quitado. Tiene que ser con un Iterator y su remove(). Usa un HashSet<String> auxiliar (un conjunto: no guarda repetidos, y su método add devuelve false si el elemento ya estaba).

Imprime la lista antes, la lista después y el número de títulos quitados.

Salida esperada:

Antes:   [Faro, Marea, Faro, Amanecer, Marea, Faro]
Después: [Faro, Marea, Amanecer]
Quitados: 3

Por cada título que te da next(), prueba a añadirlo al conjunto. Si add devuelve false, es que ya lo habías visto: it.remove() y cuenta uno más. Recuerda crear la lista con new ArrayList<>(List.of(...)), porque List.of da una lista que no se puede modificar.

Nivel 3 · Desafío: un rango con paso

Enunciado. Crea una clase Rango que sea Iterable<Integer> y que genere los números desde inicio hasta fin (este no incluido) de paso en paso. El paso puede ser negativo (cuenta atrás). Debe poder usarse directamente en un for-each:

for (int n : new Rango(0, 10, 3)) { ... }   // 0 3 6 9

Si el paso es 0, el constructor lanza IllegalArgumentException("el paso no puede ser 0"). Cada llamada a iterator() debe dar un recorrido independiente, y next() debe lanzar NoSuchElementException cuando se acaben los números.

Para probar tu clase, usa este main (clase N3):

import java.util.List;

public class N3 {
    public static void main(String[] args) {
        System.out.print("De 0 a 10 de 3 en 3:");
        for (int n : new Rango(0, 10, 3)) {
            System.out.print(" " + n);
        }
        System.out.println();

        System.out.print("De 10 a 0 de 4 en 4:");
        for (int n : new Rango(10, 0, -4)) {
            System.out.print(" " + n);
        }
        System.out.println();

        System.out.print("De 5 a 5:");
        for (int n : new Rango(5, 5, 1)) {
            System.out.print(" " + n);
        }
        System.out.println(" (vacío)");

        List<Cancion> lista = List.of(
                new Cancion("Amanecer", 215), new Cancion("Carretera", 180),
                new Cancion("Tormenta", 240), new Cancion("Faro", 135),
                new Cancion("Marea", 200));
        System.out.println("Posiciones pares de la playlist:");
        for (int i : new Rango(0, lista.size(), 2)) {
            System.out.println("  " + i + ": " + lista.get(i));
        }

        try {
            new Rango(0, 5, 0);
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Salida esperada:

De 0 a 10 de 3 en 3: 0 3 6 9
De 10 a 0 de 4 en 4: 10 6 2
De 5 a 5: (vacío)
Posiciones pares de la playlist:
  0: Amanecer (3:35)
  2: Tormenta (4:00)
  4: Marea (3:20)
Error: el paso no puede ser 0

El iterador necesita un único dato propio, el número actual, que empieza en inicio. Para saber si quedan números, mira el signo del paso: con paso positivo sigues mientras actual < fin; con paso negativo, mientras actual > fin. Y next() devuelve el valor actual antes de sumarle el paso.

¿Qué falla aquí?

Tres fragmentos con un fallo cada uno. Antes de leer la explicación, haz dos cosas: predice si compila y, si compila, qué imprime (o qué pasa al ejecutarlo). Los tres usan el mismo record Cancion del nivel 1.

Fragmento 1

F1.java

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class F1 {
    public static void main(String[] args) {
        List<Cancion> lista = new ArrayList<>(List.of(
                new Cancion("Amanecer", 215), new Cancion("Carretera", 180),
                new Cancion("Faro", 135)));

        Iterator<Cancion> it = lista.iterator();
        while (it.hasNext()) {
            System.out.println(it.next().titulo() + " dura " + it.next().segundos() + " s");
        }
    }
}

Piénsalo antes de seguir: ¿compila? ¿qué imprime? Quería ver cada título con su duración.

Explicación. Compila y arranca bien, pero lo que imprime es falso y, además, acaba mal:

Amanecer dura 180 s
Exception in thread "main" java.util.NoSuchElementException
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1052)
	at F1.main(F1.java:13)

En cada vuelta hay dos llamadas a next(), y cada una avanza el cursor. En la primera vuelta, la primera llamada devuelve Amanecer y la segunda devuelve Carretera: el programa mezcla el título de una canción con la duración de la siguiente (y 180 s no son los de Amanecer, sino los de Carretera). En la segunda vuelta, hasNext() ve que queda Faro, pero la primera llamada a next() ya se lo lleva, y la segunda se encuentra el final de la lista: NoSuchElementException.

Llamar a next() varias veces dentro de la misma vuelta del bucle. hasNext() solo garantiza un elemento más, y cada next() consume uno. Con un número impar de elementos, además, salta la excepción.

Arreglo. Una sola llamada a next(), guardada en una variable, y ya puedes usarla las veces que quieras:

Iterator<Cancion> it = lista.iterator();
while (it.hasNext()) {
    Cancion c = it.next();          // una sola llamada a next() por vuelta
    System.out.println(c.titulo() + " dura " + c.segundos() + " s");
}
Amanecer dura 215 s
Carretera dura 180 s
Faro dura 135 s

Regla de oro del iterador: **un next() por cada hasNext()**. Si necesitas el elemento más de una vez, guárdalo en una variable local.

Fragmento 2

F2.java

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class F2 {
    public static void main(String[] args) {
        List<Cancion> lista = new ArrayList<>(List.of(
                new Cancion("Amanecer", 215), new Cancion("Carretera", 180),
                new Cancion("Faro", 135), new Cancion("Marea", 200)));

        Iterator<Cancion> it = lista.iterator();
        while (it.hasNext()) {
            Cancion c = it.next();
            System.out.println("Reviso " + c.titulo());
            if (c.segundos() < 150) {
                lista.remove(c);
            }
        }
        System.out.println("Quedan " + lista.size() + ": " + lista);
    }
}

¿Compila? ¿qué imprime? Quiero revisar todas las canciones y borrar las de menos de 150 segundos. Fíjate en cuál de ellas se borra y qué pasa con la que va detrás.

Explicación. Compila y no lanza ninguna excepción. Y sin embargo falta algo en la salida:

Reviso Amanecer
Reviso Carretera
Reviso Faro
Quedan 3: [Amanecer (3:35), Carretera (3:00), Marea (3:20)]

Marea nunca se revisa. Se borra Faro con lista.remove(c) (por la lista, no por el iterador) siendo la penúltima canción: la lista pasa de 4 a 3 elementos y el cursor del iterador vale justo 3. Entonces hasNext(), que solo compara «¿el cursor es distinto del tamaño?», responde false y el bucle termina sin llamar a next() otra vez. Y es next() quien comprueba si la lista ha cambiado, así que **no salta ninguna ConcurrentModificationException**. El fallo pasa de puntillas: el programa «funciona» y se salta una canción.

Modificar la lista por fuera del iterador. A veces explota con una excepción, y a veces, como aquí, deja un resultado equivocado sin avisar. Es peor lo segundo.

Arreglo. El borrado se hace a través del iterador:

Iterator<Cancion> it = lista.iterator();
while (it.hasNext()) {
    Cancion c = it.next();
    System.out.println("Reviso " + c.titulo());
    if (c.segundos() < 150) {
        it.remove();                // borra a través del iterador
    }
}
Reviso Amanecer
Reviso Carretera
Reviso Faro
Reviso Marea
Quedan 3: [Amanecer (3:35), Carretera (3:00), Marea (3:20)]

Con it.remove() ya se revisan las cuatro canciones y el resultado es el mismo que antes en cuanto a lo borrado. La diferencia es que ahora el recorrido es correcto, y no por casualidad.

Fragmento 3

Playlist.java

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class Playlist {
    private final List<Cancion> canciones = new ArrayList<>();

    public void anadir(Cancion c) {
        canciones.add(c);
    }

    public Iterator<Cancion> iterator() {
        return canciones.iterator();
    }
}

F3.java

public class F3 {
    public static void main(String[] args) {
        Playlist lista = new Playlist();
        lista.anadir(new Cancion("Amanecer", 215));
        lista.anadir(new Cancion("Faro", 135));

        for (Cancion c : lista) {
            System.out.println(c);
        }
    }
}

*¿Compila? ¿qué imprime? La clase tiene un método iterator(), ¿no? Entonces debería valer para un for-each.*

Explicación. No compila:

F3.java:7: error: for-each not applicable to expression type
        for (Cancion c : lista) {
                         ^
  required: array or java.lang.Iterable
  found:    Playlist
1 error

El for-each no busca un método llamado iterator(): exige que el tipo sea Iterable (o un array). Tener el método no basta, hay que declarar que cumples el contrato con implements Iterable<Cancion>. Java comprueba el tipo, no la forma.

Escribir el método iterator() pero olvidar implements Iterable<T> en la cabecera de la clase. El mensaje habla de «array or java.lang.Iterable», y es justo eso lo que falta.

Arreglo. Declara la interfaz (y marca el método con @Override, que te avisa si te equivocas en la firma):

public class Playlist implements Iterable<Cancion> {
    private final List<Cancion> canciones = new ArrayList<>();

    public void anadir(Cancion c) {
        canciones.add(c);
    }

    @Override
    public Iterator<Cancion> iterator() {
        return canciones.iterator();
    }
}
Amanecer (3:35)
Faro (2:15)

Pon siempre @Override en iterator(), hasNext() y next(). Si te equivocas en una letra, el compilador protesta en el acto en lugar de dejarte con un método que nadie llama.

Soluciones

Mira esta sección solo cuando hayas intentado el ejercicio. Si tu código es distinto pero da la misma salida y cumple los contratos, también es una solución válida.

Solución del Nivel 1

N1.java

import java.util.Iterator;
import java.util.List;

public class N1 {
    public static void main(String[] args) {
        List<Cancion> lista = List.of(
                new Cancion("Amanecer", 215), new Cancion("Carretera", 180),
                new Cancion("Tormenta", 240), new Cancion("Faro", 135),
                new Cancion("Marea", 200));

        Iterator<Cancion> it = lista.iterator();
        int numero = 0;
        int total = 0;
        Cancion larga = null;
        while (it.hasNext()) {
            Cancion c = it.next();
            numero++;
            total += c.segundos();
            System.out.println(numero + ". " + c);
            if (larga == null || c.segundos() > larga.segundos()) {
                larga = c;
            }
        }
        System.out.println("Duración total: " + total / 60 + ":" + String.format("%02d", total % 60));
        System.out.println("La más larga: " + larga);
    }
}

Por qué está hecho así:

  • Un único it.next() por vuelta, guardado en c. Con esa variable imprimimos, sumamos los segundos y comparamos.
  • larga == null || c.segundos() > larga.segundos(): en la primera vuelta no hay «la más larga hasta ahora», así que la primera canción ocupa ese puesto; a partir de ahí, solo cambia si la nueva es más larga.
  • numero++ antes de imprimir, para que la numeración empiece en 1.
  • Los segundos del total se calculan con total % 60 y se imprimen con %02d, para que 10 sea 10 y 5 sea 05.

Solución del Nivel 2

N2.java

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

public class N2 {
    public static void main(String[] args) {
        List<String> titulos = new ArrayList<>(List.of(
                "Faro", "Marea", "Faro", "Amanecer", "Marea", "Faro"));
        System.out.println("Antes:   " + titulos);

        Set<String> vistos = new HashSet<>();
        Iterator<String> it = titulos.iterator();
        int quitados = 0;
        while (it.hasNext()) {
            String t = it.next();
            if (!vistos.add(t)) {       // add() devuelve false si ya estaba
                it.remove();
                quitados++;
            }
        }
        System.out.println("Después: " + titulos);
        System.out.println("Quitados: " + quitados);
    }
}

Por qué está hecho así:

  • vistos.add(t) hace dos cosas a la vez: añade el título y devuelve false si ya estaba. El ! convierte «ya estaba» en «bórralo».
  • it.remove() borra la última canción que devolvió next(), así que el siguiente elemento no se salta aunque haya repeticiones seguidas (justo el fallo del bucle con índices de la anécdota).
  • quitados cuenta los borrados: son 3 porque Faro aparece 3 veces (quita 2) y Marea 2 (quita 1).
  • La lista es new ArrayList<>(List.of(...)). Con List.of a secas, it.remove() lanzaría UnsupportedOperationException.

Solución del Nivel 3

Rango.java

import java.util.Iterator;
import java.util.NoSuchElementException;

public class Rango implements Iterable<Integer> {
    private final int inicio;
    private final int fin;
    private final int paso;

    public Rango(int inicio, int fin, int paso) {
        if (paso == 0) {
            throw new IllegalArgumentException("el paso no puede ser 0");
        }
        this.inicio = inicio;
        this.fin = fin;
        this.paso = paso;
    }

    @Override
    public Iterator<Integer> iterator() {
        return new Iterator<>() {
            private int actual = inicio;

            @Override
            public boolean hasNext() {
                return paso > 0 ? actual < fin : actual > fin;
            }

            @Override
            public Integer next() {
                if (!hasNext()) {
                    throw new NoSuchElementException();
                }
                int valor = actual;
                actual += paso;
                return valor;
            }
        };
    }
}

Por qué está hecho así:

  • El constructor valida paso == 0 y lanza IllegalArgumentException: es mejor fallar al crear el rango que quedarse en un bucle infinito después.
  • hasNext() comprueba el signo del paso con un operador ternario: paso > 0 ? actual < fin : actual > fin. Con inicio == fin ninguna de las dos condiciones se cumple, y el rango sale vacío.
  • Aquí el iterador es una clase anónima (new Iterator<>() { ... }): hace lo mismo que la clase interna con nombre de la teoría, pero escrita en el sitio porque solo se usa una vez. actual se inicializa en inicio, así que cada llamada a iterator() empieza de cero.
  • next() comprueba hasNext() y lanza NoSuchElementException cuando no quedan números: es lo que promete el contrato de Iterator.
  • El for (int n : new Rango(...)) funciona porque Rango es Iterable<Integer>, y Java convierte cada Integer en int automáticamente.
  • En la prueba de la playlist, new Rango(0, lista.size(), 2) genera los índices 0, 2 y 4: así se reutiliza el rango para saltar de dos en dos.

Míralo paso a paso en Python Tutor

El fallo del fragmento 1 es perfecto para verlo en vivo, porque ocurre en una sola línea. Entra en pythontutor.com/java.html, elige Java y pega este programa. Es el mismo fallo, con un iterador casero que lanza la excepción a la vista:

public class Tutor {
    static class Cursor {
        private final String[] datos;
        private int posicion = 0;

        Cursor(String[] datos) { this.datos = datos; }

        boolean hasNext() { return posicion < datos.length; }

        String next() {
            if (posicion >= datos.length) {
                throw new IllegalStateException("no quedan canciones");
            }
            return datos[posicion++];
        }
    }

    public static void main(String[] args) {
        Cursor it = new Cursor(new String[]{"Amanecer", "Carretera", "Faro"});
        while (it.hasNext()) {
            String titulo = it.next();
            String siguiente = it.next();       // ¡segunda llamada en la misma vuelta!
            System.out.println(titulo + " / " + siguiente);
        }
    }
}

Salida esperada:

Amanecer / Carretera
Exception in thread "main" java.lang.IllegalStateException: no quedan canciones
	at Tutor$Cursor.next(Tutor.java:12)
	at Tutor.main(Tutor.java:22)

Qué observar, paso a paso:

  1. En la primera vuelta, it.hasNext() es true y la variable posicion del cursor vale 0. Tras titulo = it.next(), vale 1 y titulo es "Amanecer".
  2. La segunda llamada, siguiente = it.next(), deja posicion en 2 y siguiente es "Carretera": la vuelta ha consumido dos títulos. Se imprime Amanecer / Carretera.
  3. En la segunda vuelta, hasNext() sigue siendo true (2 < 3), pero la primera llamada a next() ya deja posicion en 3. Cuando entras en la segunda llamada, el if comprueba que posicion >= datos.length y es cierto.
  4. Se ejecuta el throw: el programa se detiene en esa línea y siguiente nunca llega a tener valor. Compara con el fragmento 1: es el mismo recorrido, solo que aquí ves el cursor a cada paso.

Chuletario: Iteradores

Lo esencial en 30 segundos

  • **Iterator**: el cursor. hasNext() mira, next() avanza y devuelve, remove() borra el último devuelto.
  • **Iterable**: algo que sabe dar un iterador con iterator(). El for-each solo lo exige a él.
  • **Un next() por vuelta**, guardado en una variable.
  • **Borrar mientras recorres = it.remove()**, nunca lista.remove().
  • **Un Iterable propio devuelve un iterador nuevo** en cada llamada a iterator().
  • **ListIterator**: solo en listas; añade previous(), set() y add().

Sintaxis

Quiero…CódigoResultado
Recorrer sin índicesfor (Cancion c : lista) { … }Funciona con todo Iterable
Pedir un iteradorIterator<Cancion> it = lista.iterator();Cursor nuevo, al principio
Recorrer a manowhile (it.hasNext()) { Cancion c = it.next(); … }Un next() por vuelta
Borrar mientras recorroit.remove();Tras next(), una vez por next()
Borrar con una condiciónlista.removeIf(c -> c.segundos() < 150);Atajo
Hacer iterable mi claseclass A implements Iterable<T> { … }Y un método iterator()
Iterador propioreturn new IteradorA();Clase interna con su cursor
Ir hacia atrásListIterator<T> it = lista.listIterator(); it.previous();Solo para listas
Sustituir o insertarit.set(x); / it.add(x);Actúa sobre la posición del cursor
Aceptar cualquier colecciónstatic int contar(Iterable<?> c)Vale para lista, conjunto…

Plantillas de los ejercicios

Recorrido manual con acumulador (Nivel 1):

Iterator<Cancion> it = lista.iterator();
int total = 0;
while (it.hasNext()) {
    Cancion c = it.next();       // un solo next() por vuelta
    total += c.segundos();
}

Quitar con un conjunto auxiliar (Nivel 2):

Set<String> vistos = new HashSet<>();
Iterator<String> it = titulos.iterator();
while (it.hasNext()) {
    if (!vistos.add(it.next())) {    // add() es false si ya estaba
        it.remove();
    }
}

Iterable con iterador anónimo (Nivel 3):

public class Rango implements Iterable<Integer> {
    @Override
    public Iterator<Integer> iterator() {
        return new Iterator<>() {
            private int actual = inicio;         // cursor propio

            @Override
            public boolean hasNext() { return paso > 0 ? actual < fin : actual > fin; }

            @Override
            public Integer next() {
                if (!hasNext()) throw new NoSuchElementException();
                int valor = actual;
                actual += paso;
                return valor;
            }
        };
    }
}

Errores que más se repiten

SíntomaCausa probable
ConcurrentModificationExceptionModificas la lista (remove/add) fuera del iterador mientras la recorres
Un elemento se queda sin visitar tras borrar, sin excepciónBorrado con lista.remove() en lugar de it.remove() (pasa con el penúltimo)
NoSuchElementExceptionnext() sin hasNext(), o dos next() por vuelta
IllegalStateException al borrarremove() antes del primer next(), o dos seguidos
UnsupportedOperationException al borrarLista inmutable (List.of) o iterador sin remove()
«for-each not applicable to expression type»La clase no declara implements Iterable<T>
El segundo for-each no recorre nadaiterator() devuelve siempre el mismo iterador

Antes de entregar, pregúntate

  1. ¿Hago **un solo next()** por vuelta y guardo el resultado en una variable?
  2. ¿Borro con **it.remove()** y no con el remove de la lista?
  3. ¿Mi clase declara **implements Iterable<T> y devuelve un iterador nuevo** en cada iterator()?
  4. ¿Mi next() lanza **NoSuchElementException** cuando no quedan elementos?

Si quieres repasar el tema desde la base, vuelve a la teoría de iteradores.

Sigue aprendiendoTodos los artículos de Java

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *