Me lo contaron y lo olvidé; lo vi y lo entendía; lo hice y lo aprendí.

Home Algoritmos y Estructuras de Datos (VJ1215)

Curso 2024/2025

2. Listas, pilas y colas mediante listas enlazadas

Horas de clases presenciales: 8 (4 teoría + 4 prácticas)
Horas de trabajo no presencial: 8

Recursos

El tema 2 de la asignatura VJ1215 es un complemento del Módulo 3 "Estructuras de datos básicas con memoria dinámica: listas enlazadas, pilas, colas, diccionarios" de la asignatura Programación II. Supondremos, aunque lo repasaremos, que conoces el contenido de esa asignatura, cuyos resultados de aprendizaje incluyen "implementar las siguientes estructuras de datos cumpliendo ciertas especificaciones: pila, cola, lista enlazada" y "utilizar implementaciones estándar de las siguientes estructuras de datos: pila, cola, lista enlazada, diccionario". Quienes no aprendieron eso en primer curso, deberán dedicar tiempo extra a la asignatura para aprenderlo ahora.

En este tema utilizaremos dos referencias:

  1. Para presentar los conceptos teóricos, nos basaremos en los siguientes apartados del capítulo 3 "Lists, Stacks and Queues" del libro "Data Structures and Algorithm Analysis in C++" (2014) de M. A. Weiss. En ese libro el énfasis se pone en la eficiencia, comparando diferentes formas de implementar cada uno de los tres Tipos Abstractos de Datos con el fin de aprender a elegir soluciones de coste O(1) y descartar soluciones de coste O(n) cuando es posible:

    • 3.1 Abstract Data Types (ADTs)
    • 3.2 The List ADT
      • 3.2.1 Simple Array Implementation of Lists
      • 3.2.2 Simple Linked Lists
    • 3.6 The Stack ADT
      • 3.6.1 Stack Model
      • 3.6.2 Implementation of Stacks
    • 3.7 The Queue ADT
      • 3.7.1 Queue Model
      • 3.7.2 Array Implementation of Queues
  2. Además de aprender a elegir soluciones eficientes dependiendo de las operaciones que se quieran realizar, en los ejercicios de este tema veremos con detalle cómo trabajar con listas enlazadas para implementar Tipos Abstractos de Datos con C++.

    Los siguientes capítulos del libro "TADs, estructuras de datos y resolución de problemas con C++" (2006) de L. R. Nyhoff te pueden ayudar a entender eso, en estos subapartados dedicados a las implementaciones con listas enlazadas (no veremos sus implementaciones con vectores):

    • 6. Listas
      • 6.4 Introducción a las listas enlazadas
      • 6.5 Una implementación de listas enlazadas basada en punteros en C++
    • 7. Pilas
      • 7.3 Pilas enlazadas
    • 8. Colas
      • 8.3 Colas enlazadas

    Las explicaciones de este libro sobre cómo implementar constructor de copia, operador de asignación y destructor pueden ser interesantes para quien quiera aprender a programar en C++ pero no forman parte del temario de la asignatura VJ1215 y no se exigirán.

    Ten en cuenta que en este libro, por ser anterior a C++11, se utiliza 0 donde nosotros utilizaremos nullptr para representar el valor nulo de un puntero. También cambió con C++11 la notación de las listas de inicialización en los constructores (antes se utilizaban paréntesis donde ahora utilizaremos llaves).

En la última clase de teoría del curso finalizaremos el tema 1 introduciendo el concepto de coste amortizado y aplicándolo a analizar el coste de duplicar el tamaño de la memoria reservada cuando se llena en las implementaciones con vectores. Esto se complementará con otros ejemplos de coste amortizado que involucrarán algoritmos que veremos en temas posteriores.

Introducción a los ejercicios

En este tema contamos con 8 horas de trabajo no presencial, a repartir como creas conveniente para completar tanto los ejercicios de prácticas que no puedas completar en clase como el resto de ejercicios. Esa estimación de tiempo no es válida si no conoces el contenido propio de la asignatura Programación II, completo, hasta el último módulo inclusive.

Vamos a hacer, entre otros, varios ejercicios de implementación de Tipos Abstractos de Datos en C++ empleando internamente listas enlazadas (unas simplemente enlazadas, otras doblemente enlazadas; unas con acceso directo al primer nodo y al último, otras con acceso directo a uno de ellos; etc.). Ello servirá no solo para poner en práctica los conceptos del Tema 2 sino también para adquirir una base para entender y realizar implementaciones más complejas en temas posteriores.

Como estamos aprendiendo C++ gradualmente y éste no es un curso completo de C++, no implementaremos clases con genericidad, sobrecarga de operadores, constructores de copia, etc., aunque con ello nuestros ejemplos y soluciones sean mejorables. Quien esté interesado puede consultar los libros recomendados para profundizar en esos temas y consultar al profesor.

Una planificación recomendable sería:

Opcionalmente, en los ejercicios de refuerzo sobre recursión propuestos al principio del curso puedes encontrar más problemas con vectores que puedes resolver ahora con listas enlazadas si quieres seguir practicando recursión.

Ejercicios

    1. Clase de teoría TE1 19/09
      Clase de prácticas LA2 y LA4 24/09, LA1 y LA3 26/09

      Los ficheros que puedes encontrar en esta carpeta Pila contienen una implementación del TAD Pila en la que se emplea una lista simplemente enlazada para representar internamente los datos.

      Estudia esa implementación de la clase Pila, con ayuda de las explicaciones que se darán en clase y de cualquier otro recurso que consideres adecuado.

      Ayuda con C++: reserva de memoria

      Esto:

      Nodo n(25, nullptr);

      serviría para reservar en tiempo de compilación el espacio de memoria para un objeto de tipo Nodo, que tendría 25 en n.dato y nullptr en n.siguiente. Pero, en tiempo de compilación, no sabemos cuántos nodos necesitará la pila. Por ello, los creamos en tiempo de ejecución cuando los necesitamos.

      Esto:

      Nodo * n;

      lo que reserva en tiempo de compilación no es el espacio que ocupa un nodo sino el espacio que ocupa la dirección de un nodo.

      Esto:

      Nodo * n;
      n = new Nodo(25, nullptr);

      pide en tiempo de ejecución espacio para un nodo, lo inicializa con el constructor de Nodo, y guarda su dirección en n. Después de eso, n->dato vale 25 y n->siguiente vale nullptr.

      n->dato es equivalente a (*n).dato, y *n es el objeto que está en la dirección n (el objeto al que apunta n).

    2. Clase de prácticas LA2 y LA4 24/09, LA1 y LA3 26/09

      Descarga los tres ficheros que contiene la carpeta Pila. Compílalos y prueba el test como se indica a continuación:

      g++ Pila.cpp -c

      g++ Pila.o TestPila.cpp -o TestPila

      ./TestPila

      Con la opción -c le decimos a g++ que no tenemos función main y el resultado no será un ejecutable. En la primera compilación obtenemos Pila.o. En la segunda compilación lo unimos con el resultado de compilar TestPila.cpp. Con la opción -o podemos elegir el nombre del ejecutable si no nos gusta a.out.

      Aunque es recomendable compilar así cada parte por separado, también lo podríamos compilar y unir todo a la vez:

      g++ Pila.cpp TestPila.cpp -o TestPila

      ./TestPila

      También puedes probarlo con OnlineGDB: para ello, crea una pestaña por cada fichero de código fuente, como puedes ver aquí.

  1. Clase de prácticas LA2 y LA4 24/09, LA1 y LA3 26/09

    Piensa cómo utilizar una lista enlazada para realizar una implementación del TAD Cola de modo que los métodos encolar, desencolar y consultarPrimero tengan coste O(1). ¿Es necesario que sea doblemente enlazada?

    Después de haberlo pensado, estudia la implementación que puedes encontrar en esta carpeta: Cola. Observa lo que tiene en común y en qué se diferencia de la implementación anterior del TAD Pila. Es importante que entiendas lo que se ha hecho para conseguir los costes O(1), y en qué se diferencia de lo que se hizo en Pila.

    Observa que el método mostrar, que en la clase Pila del ejercicio 1 se implementó de forma iterativa, en esta clase Cola se ha implementado de forma recursiva. Se puede hacer de ambas formas, y ambas versiones tiene el mismo coste temporal O(n), siendo n la talla de la cola. Te puede servir como ayuda en los ejercicios en que se piden soluciones recursivas.

    Compíla y prueba el test que se proporciona. Lo puedes hacer con OnlineGDB así.

    La implementación anterior proporciona las operaciones básicas del TAD Cola, que son las que permiten encolar y desencolar elementos, y algunas más. En este ejercicio vas a enriquecerla con otras utilidades.

    Una vez entendida, añade a esa implementación métodos que resuelvan los siguientes problemas. Si lo deseas, puedes partir del código en OnlineGDB que se da en el enlace anterior y pulsar el botón "Fork this" para obtener una copia editable en la que puedes escribir tus soluciones.

    Con el objetivo de practicar mejor el uso de punteros en C++, que es probablemente lo más novedoso para ti, resuelve todos los apartados teniendo en cuenta que la clase Cola no dispone de ningún atributo que contenga la talla y suponiendo que no puedes añadírselo.

    En cada apartado, piensa cuál es el coste temporal en el peor caso y en el mejor caso de tu solución.

    1. Sin utilizar recursión, averiguar si un elemento aparece en la cola. En el caso particular de que la cola esté vacía, devuelve false.

      bool buscar(int) const;
      		

      Solución

    2. Resuelve el problema anterior utilizando recursión, sin utilizar ningún bucle.

      Solución

      Aprende más ayudando a otros estudiantes

    3. Sin utilizar recursión, eliminar un elemento de la cola (si aparece más de una vez, hay que eliminar solamente la primera aparición; si no aparece, la cola no debe modificarse).

      void eliminar(int);
      		

      Esto puede tener utilidad, por ejemplo, para eliminar de una cola de personas a una que decide abandonar antes de que le toque su turno.

      Solución

      Aprende más ayudando a otros estudiantes

    4. Resuelve el problema anterior utilizando recursión, sin utilizar ningún bucle.

      Solución

      Aprende más ayudando a otros estudiantes

    5. Sin utilizar recursión, averiguar la posición de la primera aparición de un elemento en la cola (contando desde cero; -1 indicará que no aparece).

      int buscarPosicion(int) const;
      		

      Solución

    6. Resuelve el problema anterior utilizando recursión, sin utilizar ningún bucle.

      Solución

      Aprende más ayudando a otros estudiantes

    7. Sin utilizar recursión, comprobar si dos colas son iguales, es decir, contienen los mismos elementos en las mismas posiciones. En el caso particular de que las dos colas estén vacías, considera que son iguales.

      bool colasIguales(const Cola &) const;
      		

      Solución

      Aprende más ayudando a otros estudiantes

    8. Resuelve el problema anterior utilizando recursión, sin utilizar ningún bucle.

      Solución

      Aprende más ayudando a otros estudiantes

  2. Utilizando una lista enlazada para contener los datos, realiza una implementación del TAD Cola de Prioridad (Priority Queue) que soporte las siguientes operaciones con los costes temporales que se indican:

    void insertar(int); // O(1)
    
    void eliminarMinimo(); // O(n)
    
    int consultarMinimo() const; // O(1)
    	    

    Por simplificar el problema, suponemos que la única información que se guarda de cada elemento es su prioridad, de tipo entero.

    Empieza de nuevo decidiendo qué tipo de lista enlazada utilizar en este caso y qué atributos poner en cada nodo y en cada cola de prioridad: ¿necesitas que la lista sea doblemente enlazada o basta con una lista simplemente enlazada? Etc.

    Solución

    Aprende más ayudando a otros estudiantes

    Prueba de la solución

    Opcionalmente, aquí puedes encontrar una implementación más completa en un curso de "Programación Avanzada" con C++, anterior a C++11.

  3. Para implementar el TAD Cola de Prioridad queremos utilizar una lista simplemente enlazada con datos de tipo real, posiblemente repetidos, que hacen el papel de prioridades. En cada cola de prioridad tendremos un único atributo, primero, para acceder al primer nodo de la lista. En cada nodo tendremos dos atributos: prioridad y siguiente. Con esa representación queremos soportar las siguientes operaciones, de modo que las dos últimas se ejecuten en tiempo O(1) en el peor caso:

    void insertar(float);
    
    void eliminarMinimo(); // O(1)
    
    float consultarMinimo() const; // O(1)
    	    

    Realiza esa implementación, completa. Implementa dos versiones de insertar: una no recursiva y otra recursiva. No puedes utilizar más atributos y tu solución debe ser compatible con el coste temporal de las dos operaciones que se ha indicado. ¿Cuál es el coste temporal de la operación de inserción?

    Solución (la versión 1 recursiva ilustra el uso de un puntero pasado por referencia; en el tema 3 volveremos a ver eso)

    Aprende más ayudando a otros estudiantes

    Prueba de la solución

  4. Clase de teoría TE1 26/09

    Considerando de nuevo el TAD Cola de Prioridad de los ejercicios anteriores, ¿cuál sería el coste temporal de cada una de las tres operaciones insertar, eliminarMínimo y consultarMínimo con cada una de las siguientes estructuras de datos?

    1. vector no ordenado
    2. vector ordenado
    3. lista enlazada no ordenada
    4. lista enlazada ordenada

    En los apartados (i) y (ii) supón que la talla máxima del vector es conocida a priori y no se redimensiona.

    Solución

    En los temas 3 y 4 veremos cómo implementar esas operaciones eficientemente empleando árboles binarios de búsqueda auto-balanceables (equilibrados) y empleando montículos.

  5. Clase de teoría TE1 26/09
    Clase de prácticas LA2 y LA4 01/10, LA1 y LA3 03/10

    En este ejercicio vas a realizar una implementación del TAD Cola de Prioridad de Doble Fin (Double-ended Priority Queue) que soporte las operaciones del siguiente recuadro con los costes temporales que se indican, utilizando una lista enlazada para almacenar los datos.

    ColaDePrioridadDeDobleFin(); // O(1)
    
    void insertar(int);          // O(n)
    
    void eliminar(int);          // O(n)
    
    void eliminarMinimo();       // O(1)
    
    int consultarMinimo() const; // O(1)
    
    void eliminarMaximo();       // O(1)
    
    int consultarMaximo() const; // O(1)
    
    int talla() const;           // O(1)
    
    void mostrar() const;        // O(n)
    	    

    Una cola de prioridad de doble fin permite realizar las siguientes operaciones básicas: insertar un elemento que tiene una cierta prioridad, extraer un elemento de mínima prioridad y extraer un elemento de máxima prioridad. A eso vas a añadir en este ejercicio la posibilidad de eliminar de la cola de prioridad de doble fin el elemento que nos indiquen y otras utilidades. Por simplificar el problema y centrarnos en lo esencial, suponemos que la única información que se guarda de cada elemento es su prioridad, de tipo entero. En caso de empate entre varios elementos con la misma prioridad, las tres operaciones de eliminación pueden elegir uno cualquiera de ellos.

    1. Antes de empezar a programar los métodos, empieza decidiendo qué tipo de lista enlazada utilizar para conseguir los costes del recuadro anterior: ¿necesitas que sea doblemente enlazada o basta con una lista simplemente enlazada? ¿Los datos deben estar ordenados de menor a mayor? ¿Cómo se puede consultar el mínimo en tiempo O(1)? ¿Cómo se puede eliminar el mínimo en tiempo O(1)? Etc.

      Una vez tengas claro eso, escribe la declaración de la clase en ColaDePrioridadDeDobleFin.h (a falta de añadir más adelante otros métodos privados si hace falta).

      Solución

    2. Implementa el método mostrar. Tenerlo disponible ya te ayudará a comprobar que tus implementaciones de los restantes métodos funcionan correctamente. Elige el formato que prefieras para ver el contenido de la cola de prioridad de doble fin.

      Sugerencia Muestra los datos de la cola recorriéndola de principio a fin utilizando los punteros siguiente, y vuélvelos a mostrar recorriéndola desde el final hacia el principio utilizando los punteros anterior. De ese modo, este método te servirá para comprobar, en ejercicios posteriores, que has dejado bien todos los punteros.

      Solución

    3. Implementa el método insertar. Puedes elegir hacerlo de forma recursiva o no recursiva: debes aprender a escribir ambas soluciones.

      Implementa a continuación los constructores que hacen las inicializaciones necesarias para que tu implementación de insertar funcione correctamente.

      Comprueba que por ahora todo funciona correctamente antes de seguir implementando otros métodos.

      Solución

      Aprende más ayudando a otros estudiantes

    4. Implementa el método eliminar. Puedes elegir hacerlo de forma recursiva o no recursiva: debes aprender a escribir ambas soluciones.

      Si se intenta eliminar un dato que no está en la cola de prioridad, lanza una excepción de tipo string.

      Comprueba que funciona correctamente antes de seguir.

      Solución

      Aprende más ayudando a otros estudiantes

    5. Completa la implementación de los restantes métodos públicos del recuadro anterior.

      Lanza también una excepción de tipo string si se intenta eliminar o consultar el mínimo o el máximo en una cola de prioridad vacía.

      Prueba de la solución

      Aprende más ayudando a otros estudiantes

    6. Imagina ahora que en la solución anterior, haciendo uso de cualquiera de estas dos implementaciones de eliminar, sustituyésemos el código del método eliminarMinimo por una llamada a eliminar(consultarMinimo()) y análogamente sustituyésemos el código del método eliminarMaximo por una llamada a eliminar(consultarMaximo()). ¿Serían buenos esos cambios, teniendo en cuenta el coste temporal?

      Solución

    7. Añade a tu implementación un método que permita eliminar todos los elementos de la cola.

      void vaciar();
      		

      Tu solución debe ser eficiente. Analiza su coste temporal.

      Solución

    8. Añade a tu implementación un método que permita obtener una nueva cola de prioridad de doble fin que contenga la unión de dos colas de prioridad de doble fin dadas.

      void unir(const ColaDePrioridadDeDobleFin &, const ColaDePrioridadDeDobleFin &);
      		

      Debes conseguir que c3.unir(c1, c2) deje en c3 el resultado de unir c1 y c2 y elimine lo que hubiera previamente en c3, sin que c1 y c2 se vean modificadas. Si un dato inicialmente aparece a veces en c1 y b veces en c2, aparecerá a + b veces en c3. Puede suceder que c1, c2 y/o c3 estén inicialmente vacías.

      Para que tu solución sea válida debe ser eficiente. Analiza su coste temporal.

      Resuelve este apartado suponiendo que la clase no dispone de ningún atributo que contenga la talla ni puedes añadírselo.

      Ayuda

      Solución

      Aprende más ayudando a otros estudiantes

      Prueba de la solución

  6. Consideremos que el TAD Conjunto (Set) y el TAD Multiconjunto (Multiset) se caracterizan por las siguientes operaciones básicas, diferenciándose en que el primero no admite elementos duplicados y el segundo sí:

    void insertar(float); // Inserta en el conjunto el dato que se le pasa
    
    void eliminar(float); // Elimina del conjunto el dato que se le pasa
    
    bool buscar(float) const;   // Dice si se encuentra en el conjunto el dato que se le pasa
    	    

    Para cada uno de los dos TADs, ¿cuál sería el coste temporal de cada una de esas tres operaciones con cada una de las siguientes estructuras de datos? Considera que al insertar un dato en un conjunto hay que comprobar que no estaba previamente.

    1. vector no ordenado
    2. vector ordenado
    3. lista enlazada no ordenada
    4. lista enlazada ordenada

    En los apartados (i) y (ii) supón que la talla máxima del vector es conocida a priori y no se redimensiona.

    Solución

    En el tema 3 compararemos esos costes temporales con los que se obtienen empleando internamente un árbol binario de búsqueda o un árbol AVL.

  7. Estamos utilizando una lista doblemente enlazada para realizar una implementación del TAD Lista con datos de tipo real. En cada lista tenemos tres atributos: talla, primero y ultimo. En cada nodo tenemos tres atributos: dato, siguiente y anterior. Todos los atributos tienen el significado habitual.

    Implementa los métodos que se piden en los siguientes apartados. Analiza el coste temporal, en el mejor y el peor caso, de cada solución.

    1. Realiza una implementación no recursiva del método

      void insertar(float dato, int i);
      	    

      que inserta el dato en la posición i-ésima de la lista, sabiendo que 0 ≤ i ≤ n, siendo n la talla de la lista antes de la inserción (si i vale 0, el dato se situará en la primera posición; si i vale n, el dato se situará en la última posición).

      Si necesitas algún constructor, impleméntalo también.

      Solución

    2. Resuelve el problema anterior de forma recursiva.

      Solución

    3. Realiza una implementación no recursiva del método

      void eliminar(int i);
      	    

      que elimina el dato en la posición i-ésima de la lista, sabiendo que 0 ≤ i < n, siendo n la talla de la lista antes de la eliminación (si i vale 0, se eliminará el dato de la primera posición; si i vale n - 1, se eliminará el dato de la última posición).

      Solución

    4. Resuelve el problema anterior de forma recursiva.

      Solución

    Prueba de la solución

Autoevaluación

Tras completar el trabajo del tema 2 deberías ser capaz de resolver los siguientes ejercicios de los exámenes del curso pasado. Observa que ello puede incluir aplicar lo visto en la primera parte del tema 1.