ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Objetivo de la unidad Introducción a la asignatura Desglose de temas Glosario MANUAL DEL DOCENTE ESTRUCTURA DE DATOS I SECRETARÍA DE EDUCACIÓN PÚBLICA SUBSECRETARÍA DE EDUCACIÓN SUPERIOR E INVESTIGACIÓN CIENTÍFICA SUBSISTEMA DE UNIVERSIDADES TECNOLÓGICAS COORDINACIÓN GENERAL DE UNIVERSIDADES TECNOLÓGICAS UNIVERSIDAD TECNOLÓGICA DE IZÚCAR DE MATAMOROS ELABORÓ: APROBÓ: LIC. IVAN ANTONIO FLORES TRUJILLO LIC. ALEJANDRO SALVADOR VARGAS CUERPO COLEGIADO TIC-SI REVISÓ: CUERPO COLEGIADO TIC-SI FECHA DE ENTRADA EN VIGOR: SEPTIEMBRE 2004 ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE I. DIRECTORIO Dr. Reyes Taméz Guerra Secretario de Educación Pública Dr. Julio Rubio Oca Subsecretario de Educación Superior e Investigación Científica DR. Arturo Nava Jaimes Coordinador General de Universidades Tecnológicas Reconocimientos Universidad Tecnológica Izúcar de Matamoros División de TIC-SI Estructuras de Datos D.R. 2005 ESTA OBRA, SUS CARACTERÍSTICAS Y DERECHOS SON PROPIEDAD DE LA: COORDINACIÓN GENERAL DE UNIVERSIDADES TECNOLÓGICAS (CGUT) FRANCISCO PETRARCA No. 321, COL. CHAPULTEPEC MORALES, MÉXICO D.F. LOS DERECHOS DE PUBLICACIÓN PERTENECEN A LA CGUT. QUEDA PROHIBIDA SU REPRODUCCIÓN PARCIAL O TOTAL POR CUALQUIER MEDIO, SIN AUTORIZACIÓN PREVIA Y POR ESCRITO DEL TITULAR DE LOS DERECHOS. ISBN (EN TRÁMITE) IMPRESO EN MÉXICO. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE ÍNDICE Preámbulo…………………………………………………………………………………………………………5 1. Antecedentes……………………………………………………………………………9. 1.1. Introducción a la orientación a objetos……………………………………………9 1.2. Tipos de datos abstractos…………………………………………………………...11 1.3. Definición de estructuras de datos………………………………………………….14 1.4. Acceso directo y Secuencial a los datos………………………………… 16 1.5. Iteradores……………………………………………………………………………………………… 16 1.6. Apuntadores o punteros…………………………………………………………...16 1.7. Plantillas (Templates)……………………………………………………………..34 1.8. La biblioteca STL…………………………………………………………………38 2. Arreglos………………………………………………………………………………..34 2.1. Introducción…..…………………………………………………………………...34 2.2. Arreglos dinámicos………………………………………………………………..36 2.3. La clase VECTOR………………………………………………………………...36 3. Listas…………………………………………………………………………………...44 3.1. Definición de lista…………………………………………………………………44 3.2. Operaciones básicas con listas…………………………………………………….45 3.2.1. Insertar un elemento en la lista……………………………………………..45 3.2.2. Localizar un elemento en la lista…………………………………………...47 3.2.3. Eliminar elementos de la lista……………………………………………...48 3.2.4. Moverse a través de una lista………………………………………………50 3.2.5. Borrar una lista completa…………………………………………………..50 3.2.6. Ejemplo de lista Ordenada………………………………………………....50 3.2.7. Ejemplo de lista en C++ usando clases…………………………………….53 3.3. La clase LIST de STL……………………………………………………………..54 4. Pilas…………………………………………………………………………………….58 4.1. Definición de pilas……………………………………………………………...…58 4.2. Operaciones básicas con pilas……………………………………………………..59 4.2.1. Push, insertar elemento…………………………………………………….59 4.2.2. Pop, leer y eliminar un elemento…………………………………………...60 4.3. Implementación de pilas…………………………………………………………..60 4.3.1. Ejemplo de pilas en C++ usando clases……………………………………61 4.4. La clase STACK de STL………………………………………………………….62 5. Colas…...………………………………………………………………………………65 5.1. Definición de colas………………………………………………………………..65 5.2. Operaciones básicas con colas…………………………………………………….66 5.2.1. Añadir un elemento………………………………………………………...66 5.2.2. Leer un elemento…………………………………………………………..67 5.3. Implementación de colas………………………………………………………….69 5.3.1. Ejemplo de una cola en C++ usando clases………………………………..70 5.4. La clase QUEUE de STL………………………………………………………….71 6. Árboles…….…………………………………………………………………………..73 6.1. Definición de árboles……………………………………………………………...73 Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 6.2. Árboles binarios…………………………………………………………………...75 6.3. Árboles de Búsqueda binaria……………………………………………………...76 6.3.1. Operaciones en ABB. ……………………………………………………..76 6.3.1.1. Buscar un elemento…………………………………………………77 6.3.1.2. Insertar un elemento………………………………………………...77 6.3.1.3. Eliminar un elemento……………………………………………….78 6.3.2. Ejemplos de eliminación en un ABB………………………………………79 6.4. Aplicaciones de árboles…………………………………………………………...81 Bibliografía……………………………...…………………………………...……………82 Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Preámbulo. Resumidamente, el ANSI define un conjunto de reglas. Cualquier compilador de C o de C++ debe cumplir esas reglas, si no, no puede considerarse un compilador de C o C++. Estas reglas definen las características de un compilador en cuanto a palabras reservadas del lenguaje, comportamiento de los elementos que lo componen, funciones externas que se incluyen, etc. Un programa escrito en ANSI C o en ANSI C++, podrá compilarse con cualquier compilador que cumpla la norma ANSI. Se puede considerar como una homologación o etiqueta de calidad de un compilador. Todos los compiladores incluyen, además del ANSI, ciertas características no ANSI, por ejemplo librerías para gráficos. Pero mientras no usemos ninguna de esas características, sabremos que nuestros programas son transportables, es decir, que podrán ejecutarse en cualquier ordenador y con cualquier sistema operativo. Este curso es sobre C++, con respecto a las diferencias entre C y C++, habría mucho que hablar, pero no es este el momento adecuado. Pero para comprender muchas de estas diferencias necesitarás cierto nivel de conocimientos de C++. Probablemente este es el lugar más adecuado para explicar cómo se obtiene un fichero ejecutable a partir de un programa C++. Para empezar necesitamos un poco de vocabulario técnico. Veremos algunos conceptos que se manejan frecuentemente en cualquier curso de programación y sobre todo en manuales de C y C++. Archivos fuente y programa o código fuente: Los programas C y C++ se escriben con la ayuda de un editor de textos del mismo modo que cualquier texto corriente. Los ficheros que contiene programas en C o C++ en forma de texto se conocen como ficheros fuente, y el texto del programa que contiene se conoce como programa fuente. Nosotros siempre escribiremos programas fuente y los guardaremos en ficheros fuente. Archivos objeto, código objeto y compiladores: Los programas fuente no pueden ejecutarse. Son ficheros de texto, pensados para que los comprendan los seres humanos, pero incomprensibles para los ordenadores. Para conseguir un programa ejecutable hay que seguir algunos pasos. El primero es compilar o traducir el programa fuente a su código objeto equivalente. Este es el trabajo que hacen los compiladores de C y C++. Consiste en obtener un fichero Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE equivalente a nuestro programa fuente comprensible para el ordenador, este fichero se conoce como fichero objeto, y su contenido como código objeto. Los compiladores son programas que leen un fichero de texto que contiene el programa fuente y generan un fichero que contiene el código objeto. El código objeto no tiene ningún significado para los seres humanos, al menos no directamente. Además es diferente para cada ordenador y para cada sistema operativo. Por lo tanto existen diferentes compiladores para diferentes sistemas operativos y para cada tipo de ordenador. Librerías: Junto con los compiladores de C y C++, se incluyen ciertos ficheros llamados librerías. Las librerías contienen el código objeto de muchos programas que permiten hacer cosas comunes, como leer el teclado, escribir en la pantalla, manejar números, realizar funciones matemáticas, etc. Las librerías están clasificadas por el tipo de trabajos que hacen, hay librerías de entrada y salida, matemáticas, de manejo de memoria, de manejo de textos, etc. Hay un conjunto de librerías muy especiales, que se incluyen con todos los compiladores de C y de C++. Son las librerías ANSI o estándar. Pero también hay librerías no estándar, y dentro de estas las hay públicas y comerciales. En este curso sólo usaremos librerías ANSI. Archivos ejecutables y enlazadores: Cuando obtenemos el fichero objeto, aún no hemos terminado el proceso. El fichero objeto, a pesar de ser comprensible para el ordenador, no puede ser ejecutado. Hay varias razones para eso: 1. Nuestros programas usaran, en general, funciones que estarán incluidas en librerías externas, ya sean ANSI o no. Es necesario combinar nuestro fichero objeto con esas librerías para obtener un ejecutable. 2. Muy a menudo, nuestros programas estarán compuestos por varios ficheros fuente, y de cada uno de ellos se obtendrá un fichero objeto. Es necesario unir todos los ficheros objeto, más las librerías en un único fichero ejecutable. 3. Hay que dar ciertas instrucciones al ordenador para que cargue en memoria el programa y los datos, y para que organice la memoria de modo que se disponga de una pila de tamaño adecuado, etc. La pila es una zona de memoria que se usa para que el programa intercambie datos con otros programas o con otras partes del propio programa. Veremos esto con más detalle durante el curso. Existe un programa que hace todas estas cosas, se trata del "link", o enlazador. El enlazador toma todos los ficheros objeto que componen nuestro programa, los combina con los ficheros de librería que sea necesario y crea un fichero ejecutable. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Una vez terminada la fase de enlazado, ya podremos ejecutar nuestro programa. Errores: Por supuesto, somos humanos, y por lo tanto nos equivocamos. Los errores de programación pueden clasificarse en varios tipos, dependiendo de la fase en que se presenten. Errores de sintaxis: son errores en el programa fuente. Pueden deberse a palabras reservadas mal escritas, expresiones erróneas o incompletas, variables que no existen, etc. Los errores de sintaxis se detectan en la fase de compilación. El compilador, además de generar el código objeto, nos dará una lista de errores de sintaxis. De hecho nos dará sólo una cosa o la otra, ya que si hay errores no es posible generar un código objeto. Avisos: además de errores, el compilador puede dar también avisos (warnings). Los avisos son errores, pero no lo suficientemente graves como para impedir la generación del código objeto. No obstante, es importante corregir estos avisos, ya que el compilador tiene que decidir entre varias opciones, y sus decisiones no tienen por qué coincidir con lo que nosotros pretendemos, se basan en las directivas que los creadores del compilador decidieron durante su creación. Errores de enlazado: el programa enlazador también puede encontrar errores. Normalmente se refieren a funciones que no están definidas en ninguno de los ficheros objetos ni en las librerías. Puede que hayamos olvidado incluir alguna librería, o algún fichero objeto, o puede que hayamos olvidado definir alguna función o variable, o lo hayamos hecho mal. Errores de ejecución: incluso después de obtener un fichero ejecutable, es posible que se produzcan errores. En el caso de los errores de ejecución normalmente no obtendremos mensajes de error, sino que simplemente el programa terminará bruscamente. Estos errores son más difíciles de detectar y corregir. Existen programas auxiliares para buscar estos errores, son los llamados depuradores (debuggers). Estos programas permiten detener la ejecución de nuestros programas, inspeccionar variables y ejecutar nuestro programa paso a paso. Esto resulta útil para detectar excepciones, errores sutiles, y fallos que se presentan dependiendo de circunstancias distintas. Errores de diseño: finalmente los errores más difíciles de corregir y prevenir. Si nos hemos equivocado al diseñar nuestro algoritmo, no habrá ningún programa que nos pueda ayudar a corregir los nuestros. Contra estos errores sólo cabe practicar y pensar. Propósito de C/C++ ¿Qué clase de programas y aplicaciones se pueden crear usando C y C++? Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE La respuesta es muy sencilla: TODOS. Tanto C como C++ son lenguajes de programación de propósito general. Todo puede programarse con ellos, desde sistemas operativos y compiladores hasta aplicaciones de bases de datos y procesadores de texto, pasando por juegos, aplicaciones a medida, etc. Oirás y leerás mucho sobre este tema. Sobre todo diciendo que estos lenguajes son complicados y que requieren páginas y páginas de código para hacer cosas que con otros lenguajes se hacen con pocas líneas. Esto es una verdad a medias. Es cierto que un listado completo de un programa en C o C++ para gestión de bases de datos (por poner un ejemplo) puede requerir varios miles de líneas de código, y que su equivalente en Visual Basic sólo requiere unos pocos cientos. Pero detrás de cada línea de estos compiladores de alto nivel hay cientos de líneas de código en C, la mayor parte de estos compiladores están respaldados por enormes librerías escritas en C. Nada te impide a ti, como programador, usar librerías, e incluso crear las tuyas propias. Una de las propiedades de C y C++ es la reutilización del código en forma de librerías de usuario. Después de un tiempo trabajando, todos los programadores desarrollan sus propias librerías para aquellas cosas que hacen frecuentemente. Y además, raramente piensan en ello, se limitan a usarlas. Además, los programas escritos en C o C++ tienen otras ventajas sobre el resto. Con la excepción del ensamblador, generan los programas más compactos y rápidos. El código es transportable, es decir, un programa ANSI en C o C++ podrá ejecutarse en cualquier máquina y bajo cualquier sistema operativo. Y si es necesario, proporcionan un acceso a bajo nivel de hardware sólo igualado por el ensamblador. Otra ventaja importante, C tiene más de 30 años de vida, y C++ casi 20 y no parece que su uso se debilite demasiado. No se trata de un lenguaje de moda, y probablemente a ambos les quede aún mucha vida por delante. Sólo hay que pensar que sistemas operativos como Linux, Unix o incluso Windows se escriben casi por completo en C. Por último, existen varios compiladores de C y C++ gratuitos, o bajo la norma GNU, así como cientos de librerías de todo propósito y miles de programadores en todo el mundo, muchos de ellos dispuestos a compartir su experiencia y conocimientos. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 1. Antecedentes 1.1. Introducción a la orientación a objetos. La programación orientada a objetos (POO) es una nueva manera de enfocar la programación. Desde sus comienzos, la programación ha estado gobernada por varias metodologías. En cada punto crítico de la evolución de la programación se creaba un nuevo enfoque para ayudar al programador a manejar programas cada vez más complejos. Los primeros programas se crearon mediante un proceso de cambio de los conmutadores del panel frontal de la computadora. Obviamente, este enfoque solo es adecuado para programas pequeños. A continuación se invento el lenguaje ensamblador que permitió escribir programas más largos. El siguiente avance ocurrió en los años 50 cuando se invento el primer lenguaje de alto nivel (FORTRAN). Mediante un lenguaje de alto nivel, un programador estaba capacitado para escribir programas que tuvieran una longitud de varios miles de líneas. Sin embargo, el método de programación usado en el comienzo era un enfoque adhoc que no solucionaba mucho. Mientras que esto esta bien para programas relativamente cortos, se convierte en “código espagueti” ilegible y difícil de tratar cuando se aplica a programas más largos. La eliminación del código espagueti se consiguió con la creación de los lenguajes de programación estructurados en los años sesenta. Estos lenguajes incluyen ALGOL y PASCAL. En definitiva, C es un lenguaje estructurado, y casi todos los tipos de programas que se han estado haciendo se podrían llamar programas estructurados. Los programas estructurados se basan en estructuras de control bien definidas, bloques de código, la ausencia del GOTO, y subrutinas independientes que soportan recursividad y variables locales. La esencia de la programación estructurada es la reducción de un programa a sus elementos constitutivos. Mediante la programación estructurada un programador medio puede crear y mantener programas de una longitud superior a 50,000 líneas. Aunque la programación estructurada nos ha llevado a excelentes resultados cuando se ha aplicado a programas moderadamente complejos, llega a fallar en algún punto cuando el programa alcanza un cierto tamaño. Para poder escribir programas de mayor complejidad se necesitaba de un nuevo enfoque en la tarea de programación. A partir de este punto nace la programación orientada a objetos (POO). La POO toma las mejores ideas incorporadas en la programación estructurada y las combina con nuevos y potentes conceptos que permiten organizar los programas de forma más efectiva. La POO permite descomponer un problema en subgrupos relacionados. Cada subgrupo pasa a ser un objeto autocontenido que contiene sus propias instrucciones y datos que le relacionan con ese objeto. De esta manera, la complejidad se reduce y el programador puede tratar programas más largos. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Todos los lenguajes de POO comparten tres características: encapsulación, polimorfismo y herencia. Encapsulación. La encapsulación es el mecanismo que agrupa el código y los datos que maneja y los mantiene protegidos frente a cualquier interferencia y mal uso. En un lenguaje orientado a objetos, el código y los datos suelen empaquetarse de la misma forma en que se crea una “caja negra” autocontenida. Dentro de la caja son necesarios tanto el código como los datos. Cuando el código y los datos están enlazados de esta manera, se ha creado un objeto. En otras palabras, un objeto es el dispositivo que soporta encapsulación. En un objeto, los datos y el código, o ambos, pueden ser privados para ese objeto o públicos. Los datos o el código privado solo los conoce o son accesibles por otra parte del objeto. Es decir, una parte del programa que esta fuera del objeto no puede acceder al código o a los datos privados. Cuando los datos o el código son públicos, otras partes del programa pueden acceder a ellos, incluso aunque este definido dentro de un objeto. Normalmente, las partes públicas de un objeto se utilizan para proporcionar una interfaz controlada a las partes privadas del objeto. Para todos los propósitos, un objeto es una variable de un tipo definido por el usuario. Puede parecer extraño que un objeto que enlaza código y datos se pueda contemplar como una variable. Sin embargo, en programación orientada a objetos, este es precisamente el caso. Cada vez que se define un nuevo objeto, se esta creando un nuevo tipo de dato. Cada instancia específica de este tipo de dato es una variable compuesta. Polimorfismo. Polimorfismo (del Griego, cuyo significado es “muchas formas”) es la cualidad que permite que un nombre se utilice para dos o más propósitos relacionados pero técnicamente diferentes. El propósito del polimorfismo aplicado a la POO es permitir poder usar un nombre para especificar una clase general de acciones. Dentro de una clase general de acciones, la acción específica a aplicar está determinada por el tipo de dato. Por ejemplo, en C, que no se basa significativamente en el polimorfismo, la acción de valor absoluto requiere tres funciones distintas: abs(), labs() y fabs(). Estas tres funciones calculan y devuelven el valor absoluto de un entero, un entero largo y un valor real, respectivamente. Sin embargo, en C++, que incorpora polimorfismo, a cada función se puede llamar abs(). Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE El tipo de datos utilizado para llamar a la función determina que versión específica de la función se esta usando, es decir, es posible usar un nombre de función para propósitos muy diferentes. Esto se llama sobrecarga de funciones. De manera general, el concepto de polimorfismo es la idea de “una interfaz, múltiples métodos”. Esto significa que es posible diseñar una interfaz genérica para un grupo de actividades relacionadas. Sin embargo, la acción específica ejecutada depende de los datos. La ventaja del polimorfismo es que ayuda a reducir la complejidad permitiendo que la misma interfaz se utilice para especificar una clase general de acción. Es trabajo del compilador seleccionar la acción específica que se aplica a cada situación. El programador no necesita hacer esta selección manualmente, solo necesita recordar y utilizar la interfaz general. El polimorfismo se puede aplicar tanto a funciones como a operadores, prácticamente todos los lenguajes de programación contienen una aplicación limitada de polimorfismo cuando se relaciona con los operadores aritméticos, por ejemplo, en C, el signo + se utiliza par añadir enteros, enteros largos, caracteres y valores reales. En estos casos, el compilador automáticamente sabe que tipo de aritmética debe aplicar, en C++, se puede ampliar este concepto a otros tipos de datos que se definan, este tipo de polimorfismo se llama sobrecarga de operadores. Herencia. La herencia es el proceso mediante el cual un objeto puede adquirir las propiedades de otro. Mas en concreto, un objeto puede heredar un conjunto general de propiedades a alas que puede añadir aquellas características que son específicamente suyas. La herencia es importante porque permite que un objeto soporte el concepto de clasificación jerárquica. Mucha información se hace manejable gracias a esta clasificación, por ejemplo, la descripción de una casa. Una casa es parte de una clase general llamada edificio, a su vez, edificio es una parte de la clase mas general estructura, que es parte de la clase aun más general de objetos que se puede llamar obra-hombre. En cualquier caso, la clase hija hereda todas las cualidades asociadas con la clase padre y le añade sus propias características definitorias. Sin el uso de clasificaciones ordenadas, cada objeto tendría que definir todas las características que se relacionan con él explícitamente. Sin embargo, mediante el uso de la herencia, es posible describir un objeto estableciendo la clase general (o clases) a las que pertenece, junto con aquellas características específicas que le hacen único. 1.2 Tipos de datos abstractos. Los tipos de datos abstractos (TDA) encapsulan datos y funciones que trabajan con estos datos. Los datos no son visibles para el usuario en un tipo de Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE dato abstracto y el acceso a los datos es exclusivamente bajo el llamado a funciones, también llamadas métodos. Así, el tipo de dato abstracto es especificado por los métodos, no por los datos. En C++, los tipos de datos abstractos son representados por clases, las cuales presentan a pequeña deficiencia: el dato que representa el estado de un objeto de este tipo de dato abstracto es visible (algunas veces no accesible) en la parte private de la clase declarada para cada programa, la clase es reconocida mediante la vía # include. Ejemplos de tipos de datos abstractos son: stack, queue, etc. Los TDA por lo general manejan memoria dinámica, esto es, la asignación dinámica de memoria es una característica que le permite al usuario crear tipos de datos y estructuras de cualquier tamaño de acuerdo a las necesidades que se tengan en el programa, para ello se emplean funciones típicas como malloc y free. 1.3 Definición de estructuras de datos. En programación, una estructura de datos es una forma de organizar un conjunto de datos elementales (un dato elemental es la mínima información que se tiene en el sistema) con el objetivo de facilitar la manipulación de estos datos como un todo y/o individualmente. Una estructura de datos define la organización e interrelacionamiento de estos, y un conjunto de operaciones que se pueden realizar sobre él. Las operaciones básicas son: Alta, adicionar un nuevo valor a la estructura. Baja, borrar un valor de la estructura. Búsqueda, encontrar un determinado valor en la estructura para se realizar una operación con este valor, en forma SECUENCIAL o BINARIO (siempre y cuando los datos estén ordenados). Otras operaciones que se pueden realizar son: Ordenamiento, de los elementos pertenecientes a la estructura. Apareo, dadas dos estructuras originar una nueva ordenada y que contenga a las apareadas. Cada estructura ofrece ventajas y desventajas en relación a la simplicidad y eficiencia para la realización de cada operación. De esta forma, la elección de la estructura de datos apropiada para cada problema depende de factores como las frecuencias y el orden en que se realiza cada operación sobre los datos. Algunas estructuras de datos utilizadas en programación son: Arrays (Arreglos) Estructura de Datos. UTIM ESTRUCTURA DE DATOS I o o MANUAL DEL DOCENTE Vectores Matrices Listas Enlazadas o Listas Simples o Listas Dobles o Listas Circulares Pilas (stack) Colas (queue) Árboles o Árboles Binarios Árbol binario de búsqueda Árbol binario de búsqueda autoajustable Árboles Biselados (Árboles Splay) o Árboles Multicamino (Multirrama) Árboles B Árboles B+ Árboles B* Conjuntos (set) Grafos Montículos (o heaps) 1.4 Acceso Directo y Secuencial a los datos Secuencial. Para acceder a un objeto se debe acceder a los objetos almacenados previamente en el archivo. El acceso secuencial exige elemento a elemento, es necesario una exploración secuencial comenzando desde el primer elemento. Directo o Aleatorio. Se accede directamente al objeto, sin recorrer los anteriores. El acceso directo permite procesar o acceder a un elemento determinado haciendo una referencia directamente por su posición en el soporte de almacenamiento. 1.5 Iteradores Un iterador es una especie de puntero utilizado por un algoritmo para recorrer los elementos almacenados en un contenedor. Dado que los distintos algoritmos necesitan recorrer los contenedores de diversas maneras para realizar diversas operaciones, y los contenedores deben ser accedidos de formas distintas, existen Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE diferentes tipos de iteradores. Cada contenedor de la Librería Estándar puede generar un iterador con funcionalidad adecuada a la técnica de almacenamiento que utiliza. Es precisamente el tipo de iterador requerido como argumento, lo que distingue que algoritmos STL pueden ser utilizados con cada clase de contenedor. Por ejemplo, si un contenedor solo dispone de iteradores de acceso secuencial, no pueden utilizarse con algoritmos que exijan iteradores de acceso aleatorio. 1.6 Apuntadores o punteros. Un puntero es una variable destinada a contener una dirección de memoria. Esta dirección generalmente corresponde a otra variable, decimos entonces que el puntero contiene la dirección de la variable o que “apunta” a ésta. En el siguiente esquema suponemos que la variable puntero está cargada con el número 4A20. Decimos, entonces, que la dirección 0x4A20 es apuntada por el puntero. Usos y ventajas de los punteros Permiten el acceso a cualquier posición de la memoria, para ser leída o para ser escrita (en los casos en que esto sea posible). Permiten la transferencia de argumentos a las funciones, de modo que puedan retener un valor nuevo, que resulta de aplicarles la función. Permiten solicitar memoria que no fue reservada al inicio del programa. Esto es el uso de memoria dinámica. Son el soporte de enlace que utilizan estructuras avanzadas de datos en memoria dinámica como las listas, pilas, colas y árboles. Operan más eficientemente en los arrays, en comparación con el uso de subíndices. Declaración del tipo puntero Sintaxis: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE tipo * nombre_puntero; nombre_puntero es el identificador de la variable. El * es el operador de indirección que nos indica que la variable es de tipo puntero. tipo indica el tipo de la variable apuntada por el puntero (se lo denomina tipo base). Ejemplo 1: En la siguiente declaración, p es un puntero a float, i es una variable tipo int y q es un puntero a int. int i, *q; float *p; Operadores para punteros Operador &: Es un operador unario que devuelve la dirección de memoria de su operando. A través de este operador podemos relacionar a los punteros con las variables a ser apuntadas por los mismos. No puede aplicarse a expresiones, constantes o variables tipo registro. Operador *: Es el operador de indirección. También es unario y es relativamente complementario del anterior. Da acceso a la variable señalada por el puntero. Ejemplo 2: int A, *p; p = &A; A partir de esta última sentencia, p se carga con la dirección de A, es decir, p apunta a A. Y al suceder esto, *p es el contenido de lo apuntado por p, o sea, *p es equivalente a A. Recordar siempre que en la declaración “int A, *p” se está expresando que la variable A corresponde a entero y que *p también corresponde a entero. Cuando se declara un puntero, éste contiene “basura”y por lo tanto no apunta a nada. Es necesario inicializarlo con el valor adecuado. Asignación de punteros Un puntero puede ser asignado de 3 maneras: A través de otro puntero. Ejemplo 3: int A, *p, *q; p = &A; q = p; Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE En esta última sentencia, p apunta a A y a q se le carga el valor que contiene p (que es justamente la dirección de memoria de A), por lo tanto *p es equivalente a *q. Con la dirección de una variable. Es el caso de la segunda línea del ejemplo anterior. Directamente con la dirección que deba contener. Esta asignación se realiza entregándole un valor entero. Por ejemplo, al realizar la asignación p = 10; p contendrá 0x000A (si la expresamos en el sistema de numeración hexadecimal). Lo habitual es entregarle la dirección expresada directamente en hexadecimal, aunque esto es sólo por comodidad, pues las direcciones son de por sí números binarios. Si realizamos la asignación p = 0x0B75; p contendrá 0x0B75 Punteros y argumentos de funciones Los punteros son útiles a la hora de hacer un paso de argumentos a una función, en caso de ser necesario que la función modifique el valor de la variable transferida. Esto es similar a lo que sería, en otros lenguajes, un paso por referencia. Ejemplo 10: La siguiente función pretende intercambiar los valores de dos variables de tipo float: void swap(float x, float y) { int aux; aux = x; x = y; y = aux; } La forma de invocar esta función podría ser swap(a, b), siendo a y b las variables cuyos valores queremos intercambiar. De todas maneras, la función no altera los valores de a y de b porque sólo intercambia “copias” de estas variables. Hace un pasaje de argumentos por valor. Por lo tanto, esto no es correcto. Ejemplo 11: Luego de analizar el ejemplo anterior, observemos la siguiente función. void swap(float *x, float *y) { int aux; Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE aux = *x; *x = *y; *y = aux; } Con esta función sí es posible intercambiar los valores de dos variables, y la forma de invocarla es swap(&a, &b). Dentro de esta función, los parámetros se declaran para ser punteros, y al invocarla se pasan las direcciones de a y de b. Tipos de datos apuntados por punteros Los tipos base apuntados pueden ser todos los provistos de manera standard por el C++: int, char, long, float, double. También puede apuntar a un tipo no especificado de dato como void, o a otro puntero. Los punteros también pueden apuntar a tipos de datos creados por el programador como cadenas, estructuras y uniones. No pueden apuntar a campos de bit pues no pueden contener direcciones no enteras. Punteros a caracteres Sintaxis: char * nombre_cadena; Ejemplo 13: El siguiente programa imprime en pantalla la frase “color azul”. char * frase = “color azul”; printf (“%s” , frase); En su representación interna, las cadenas (y también los arreglos) terminan con el carácter nulo „\0‟,de tal manera que sea posible encontrar el fin. Punteros a estructura Sintaxis: struct tipo_estructura * nombre_estructura; tipo_estructura es un rótulo de una estructura. nombre_estructura es el identificador del puntero. Ejemplo 17: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Se crea un tipo de dato estructura con el nombre fecha. Luego se declara una variable de tipo estructura-fecha con el nombre hoy, y finalmente un puntero a estructura-fecha llamado F. struct fecha { int día; int mes; int anio; }; struct fecha hoy; struct fecha * F; F todavía no apunta a una dirección válida. Hacemos que F apunte a hoy, cargándolo con la dirección de esa variable tipo estructura-fecha. F = &hoy; Observe la presencia del operador &. Es necesario no confundir una estructura con un arreglo. En el caso de las estructuras, el nombre es el identificador de la variable y representa a la variable misma, no a su dirección. Por eso usamos el operador mencionado. 1.7 Plantillas (Templates). La generalidad es una propiedad que permite definir una clase o una función sin tener que especificar el tipo de todos o alguno de sus miembros. Esta propiedad no es imprescindible en un lenguaje de programación orientado a objetos y ni siquiera es una de sus características. Esta característica del C++ apareció mucho más tarde que el resto del lenguaje, al final de la década de los ochenta. Esta generalidad se alcanza con las plantillas (templates). La utilidad principal de este tipo de clases o funciones es la de agrupar variables cuyo tipo no esté predeterminado . Así el funcionamiento de una pila, una cola, una lista, un conjunto, un diccionario o un array es el mismo independientemente del tipo de datos que almacene (int, long, double, char, u objetos de una clase definida por el usuario). En definitiva estas clases se definen independientemente del tipo de variables que vayan a contener y es el usuario de la clase el que debe indicar ese tipo en el momento de crear un objeto de esa clase. Plantillas de funciones Supóngase que se quiere crear una función que devolviese el mínimo entre dos valores independientemente de su tipo (se supone que ambos tienen el mismo tipo). Se podría pensar en definir la función tantas veces como tipos de datos se puedan presentar (int, long, float, double, etc.). Aunque esto es posible, éste es un Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE caso ideal para aplicar plantillas de funciones. Esto se puede hacer de la siguiente manera: // Declaración de la plantilla de función template <class T> T minimo( T a, T b); En ese caso con <classT> se está indicando que se trata de una plantilla cuyo parámetro va a ser el tipo T y que tanto el valor de retorno como cada uno de los dos argumentos va a ser de este tipo de dato T. En la definición y declaración de la plantilla puede ser que se necesite utilizar mas de un tipo de dato e incluido algún otro parámetro constante que pueda ser utilizado en las declaraciones. Por ejemplo, si hubiera que pasar dos tipos a la plantilla, se podría escribir: // Declaración de la plantilla de función con dos tipos de datos template <class T1, class T2> void combinar(T1 a, T2 b); Podría darse el caso también de que alguno de los argumentos o el valor de retorno fuese de un tipo de dato constante y conocido. En ese caso se indicaría explícitamente como en una función convencional. La definición de la plantilla de función es como sigue: // Definición de la plantilla de función template <class T> T minimo(T a, T b) { if(a <= b) return a; else return b; } A continuación se presenta un programa principal que utiliza la plantilla de función recién definida: #include <iostream.h> template <class T> T minimo(T a, T b); void main(void) { int euno=1; int edos=5; cout << minimo(euno, edos) << endl; long luno=1; long ldos=5; cout << minimo(luno, ldos) << endl; char cuno='a'; char cdos='d'; cout << minimo(cuno, cdos) << endl; double duno=1.8; Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE double ddos=1.9; cout << minimo(duno, ddos) << endl; } La ejecución del programa anterior demuestra que el tipo de los argumentos y el valor de retorno de la función minimo() se particularizan en cada caso a los de la llamada. Es obvio también que se producirá un error si se pasan como argumentos dos variables de distinto tipo, por lo que el usuario de la plantilla de función debe ser muy cuidadoso en el paso de los argumentos. Seguidamente se presenta un nuevo ejemplo de función para permutar el valor de dos variables: #include <iostream.h> template <class S> void permutar(S&, S&); void main(void) { int i=2, j=3; cout << "i=" << i << " " << "j=" << j << endl; permutar(i, j); cout << "i=" << i << " " << "j=" << j << endl; double x=2.5, y=3.5; cout << "x=" << x << " " << "y=" << y << endl; permutar(x, y); cout << "x=" << x << " " << "y=" << y << endl; } template <class S> void permutar(S& a, S& b) { S temp; temp = a; a = b; b = temp; } Plantillas de clases De una manera semejante a como se hace para las funciones se puede generalizar para el caso de las clases por medio de plantillas de clases. Se definirá un parámetro que indicará el tipo de datos con los que más adelante se crearán los objetos. Se presenta a continuación un ejemplo completo de utilización de plantillas de clases basado en una pila muy simple (sin listas vinculadas y sin reserva dinámica de memoria): // fichero Pila.h template <class T> // declaración de la clase class Pila { public: Pila(int nelem=10); // constructor Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE void Poner(T); void Imprimir(); private: int nelementos; T* cadena; int limite; }; // definición del constructor template <class T> Pila<T>::Pila(int nelem) { nelementos = nelem; cadena = new T(nelementos); limite = 0; }; // definición de las funciones miembro template <class T> void Pila<T>::Poner(T elem) { if (limite < nelementos) cadena[limite++] = elem; }; template <class T> void Pila<T>::Imprimir() { int i; for (i=0; i<limite; i++) cout << cadena[i] << endl; }; El programa principal puede ser el que sigue: #include <iostream.h> #include "Pila.h" void main() { Pila <int> p1(6); p1.Poner(2); p1.Poner(4); p1.Imprimir(); Pila <char> p2(6); p2.Poner('a'); p2.Poner('b'); p2.Imprimir(); } En este programa principal se definen dos objetos p1 y p2 de la clase Pila. En p1 el parámetro T vale int y en p2 ese parámetro vale char. El funcionamiento de todas las variables y funciones miembro se particulariza en cada caso para esos tipos de variable. Es necesario recordar de nuevo que el usuario de este tipo Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE de clases debe poner un muy especial cuidado en pasar siempre el tipo de argumento correcto. Puede pensarse que las plantillas y el polimorfismo son dos utilidades que se excluyen mutuamente. Aunque es verdad que el parecido entre ambas es grande, hay también algunas diferencias que pueden hacer necesarias ambas características. El polimorfismo necesita punteros y su generalidad se limita a jerarquías. Recuérdese que el polimorfismo se basa en que en el momento de compilación se desconoce a qué clase de la jerarquía va a apuntar un puntero que se ha definido como puntero a la clase base. Desde este punto de vista las plantillas pueden considerarse como una ampliación del polimorfismo. Una desventaja de las plantillas es que tienden a crear un código ejecutable grande porque se crean tantas versiones de las funciones como son necesarias. 1.8 La biblioteca STL. La Biblioteca Estándar de Patrones, comúnmente conocida en inglés por sus siglas STL (Standard Template Library) es una biblioteca de C++ que incluye la mayoría de algoritmos y estructuras de datos que se suelen utilizar en Informática. Básicamente está compuesta de: clases contenedoras, es decir, patrones (templates) que permiten almacenar objetos de muy diversos tipos, algoritmos de uso frecuente, e iteradores, que nos permitirán recorrer los elementos incluidos en los contenedores. También incluye "objetos función", que son generalizaciones de funciones (clases útiles por el procesamiento que realizan y no por los datos que contienen), y "adaptadores", que modifican el interfaz de ciertos contenedores e iteradores de forma puedan ser manejados más fácilmente. Esta biblioteca es genérica en tanto sus componentes están altamente parametrizados, ya que casi todos son patrones, lo que permite su instancia con cualquier otro tipo de objeto, lo que hace que la STL se configure como una gran herramienta para la programación de aplicaciones en C++. Los tipos de contenedores que nos podemos encontrar en la biblioteca son los siguientes: Secuenciales: o Vectores: contienen elementos contiguos almacenados al estilo de un array o vector del lenguaje C++. o Listas: secuencias de elementos almacenados en una lista enlazada. o Deques: contenedores parecidos a los vectores, excepto que permiten inserciones y borrados en tiempo constante tanto al principio como al final. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Adaptadores: o Colas: contenedores que ofrecen la funcionalidad de listas " primero en entrar, primero en salir". o Pilas: contenedores asociados a listas " primero en entrar, último en salir". o Colas con prioridad: en este caso, los elementos de la cola salen de ella de acuerdo con una prioridad (que se estableció en la inserción). Asociativos. o Conjuntos de bits: contenedor para almacenar bits. o Mapas: almacenan pares "clave, objeto", es decir, almacenan objetos referidos mediante un identificador único. o Multimapas: mapas que permiten claves duplicadas. o Conjuntos: conjuntos ordenados de objetos únicos. o Multiconjuntos: conjuntos ordenados de objetos que pueden estar duplicados. Todas estas clases de objetos tienen la posibilidad de poder ser "recorridas" utilizan iteradores. Para ello, cada una de ellas tiene una subclase (o varias subclases) que nos permiten colocarnos en el primer elemento de un objeto, avanzar al siguiente elemento, ver el elemento (o modificarlo) y comprobar si ya hemos llegado hasta el final. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I 2. MANUAL DEL DOCENTE Arreglos 2.1 Introducción. Los arreglos (arrays) permiten agrupar datos usando un mismo identificador. Todos los elementos de un array son del mismo tipo, y para acceder a cada elemento se usan subíndices. Sintaxis: <tipo> <identificador>[<núm_elemen>][[<núm_elemen>]...]; Los valores para el número de elementos deben ser constantes, y se pueden usar tantas dimensiones como queramos, limitado sólo por la memoria disponible. Cuando sólo se usa una dimensión se suele hablar de listas o vectores, cuando se usan dos, de tablas. Ahora podemos ver que las cadenas de caracteres son un tipo especial de arrays. Se trata en realidad de arrays de una dimensión de objetos de tipo char. Los subíndices son enteros, y pueden tomar valores desde 0 hasta <número de elementos>-1. Esto es muy importante, y hay que tener mucho cuidado, por ejemplo: int Vector[10]; Creará un array con 10 enteros a los que accederemos como Vector[0] a Vector[9]. Como subíndice podremos usar cualquier expresión entera. En general C++ no verifica el ámbito de los subíndices. Si declaramos un array de 10 elementos, no obtendremos errores al acceder al elemento 11. Sin embargo, si asignamos valores a elementos fuera del ámbito declarado, estaremos accediendo a zonas de memoria que pueden pertenecer a otras variables o incluso al código ejecutable de nuestro programa, con consecuencias generalmente desastrosas. Ejemplo: int Tabla[10][10]; char DimensionN[4][15][6][8][11]; ... DimensionN[3][11][0][4][6] = DimensionN[0][12][5][3][1]; Tabla[0][0] += Tabla[9][9]; Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Cada elemento de Tabla, desde Tabla[0][0] hasta Tabla[9][9] es un entero. Del mismo modo, cada elemento de DimensionN es un carácter. Inicialización de arrays. Los arrays pueden ser inicializados en la declaración. Ejemplos: float R[10] = {2, 32, 4.6, 2, 1, 0.5, 3, 8, 0, 12}; float S[] = {2, 32, 4.6, 2, 1, 0.5, 3, 8, 0, 12}; int N[] = {1, 2, 3, 6}; int M[][3] = { 213, 32, 32, 32, 43, 32, 3, 43, 21}; char Mensaje[] = "Error de lectura"; char Saludo[] = {'H', 'o', 'l', 'a', 0}; En estos casos no es obligatorio especificar el tamaño para la primera dimensión, como ocurre en los ejemplos de las líneas 2, 3, 4, 5 y 6. En estos casos la dimensión que queda indefinida se calcula a partir del número de elementos en la lista de valores iniciales. En el caso 2, el número de elementos es 10, ya que hay diez valores en la lista. En el caso 3, será 4. En el caso 4, será 3, ya que hay 9 valores, y la segunda dimensión es 3: 9/3=3. Y en el caso 5, el número de elementos es 17, 16 caracteres más el cero de fin de cadena. Operadores con arrays Ya hemos visto que se puede usar el operador de asignación con arrays para asignar valores iniciales. El otro operador que tiene sentido con los arrays es sizeof. Aplicado a un array, el operador sizeof devuelve el tamaño de todo el array en bytes. Podemos obtener el número de elementos dividiendo ese valor entre el tamaño de uno de los elementos. #include <iostream> using namespace std; int main() { int array[231]; Estructura de Datos. UTIM ESTRUCTURA DE DATOS I } MANUAL DEL DOCENTE cout << "Número de elementos: " << sizeof(array)/sizeof(int) << endl; cout << "Número de elementos: " << sizeof(array)/sizeof(array[0]) << endl; cin.get(); return 0; Las dos formas son válidas, pero la segunda es, tal vez, más general. 2.2 Arreglos dinámicos. Si al iniciar un programa no se sabe el número de elementos del que va a constar el array, o no se quiere poner un límite predetermiado, lo que hay que hacer es definir el array dinámicamente. Para hacer esto, primero se define un puntero, que señalará la dirección de memoria del primer elemento del array: tipo_de_elemento *nombre_de_array; y luego se utiliza la función malloc (contenida en stdlib.h) para reservar memoria: nombre_de_array=(tipo_de_elemento *)malloc(tamaño); donde tamaño es el número de elementos del array por el tamaño en bytes de cada elemento. La función malloc devuelve un puntero void, que indica la posición del primer elemento. Antes de asignarlo a nuestro puntero, hay que convertir el puntero que devuelve el malloc al tipo de nuestro puntero (ya que no se pueden igualar punteros de distintos tipos). Para arrays bidimensionales, hay que hacerlo dimensión a dimensión; primero se define un puntero de punteros: int **mapa; Luego se reserva memoria para los punteros: mapa=(int **)malloc(sizeof(int *)*N1); y, por último, para cada puntero se reserva memoria para los elementos: for(i1=0;i1<N1;i1++) mapa[i1]=(int *)malloc(sizeof(int)*N2); Ya se puede utilizar el array normalmente. Para arrays de más de dos dimensiones, se hace de forma similar. 2.3 La clase VECTOR. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Aunque cada uno de los contenedores que ofrece la STL tienen características diferentes, en esta sección presentaremos el contenedor vector. El contenedor vector permite almacenar cero o más objetos del mismo tipo, pudiendo acceder a ellos individualmente mediante un índice, es decir, acceso aleatorio. En este sentido, es una extensión del vector o array que ofrece C++, aunque en este caso el número de elementos de un objeto vector puede variar dinámicamente. La gestión de la memoria se hace de manera totalmente transparente al usuario. Se define como una clase patrón, lo que implica que puede albergar objetos de cualquier tipo. En cuanto a las operaciones más frecuentes, ofrece un tiempo constante en inserción y borrados de elementos al final, y lineal al comienzo o en la mitad del vector. La declaración más común de un objeto de tipo vector se realiza de la siguiente manera: vector<tipo> objeto; donde tipo puede ser cualquier tipo o clase de los que ofrece C++, así como cualquier otra clase implementada por un usuario. Así, podríamos declarar los siguientes vectores: vector<double> vectorReales; // De números reales. vector<string> vectorCadenas; // De cadenas de caracteres. vector<MiClase> vectorObj; // Contendrá objetos de una clase construida por un usuario. Una declaración: vector <int> vectorEnteros(10); crea un vector de diez enteros. Si deseamos inicializarlo a algún valor en concreto, entonces añadimos un argumento más al constructor: vector <int> vectorEnteros(10,-1); De manera general las funciones miembro y operadores manejados por vector mas utilizadas son: size size_type size() const; Devuelve el número de elementos almacenados en el vector. El tipo size_type es un entero sin signo. empty bool empty() const; Devuelve true si el número de elementos es cero y false en caso contrario. void push_back(const T& x); Añade un elemento x al final del vector. T push_back es el tipo de dato de los elementos del vector. vector<int> a; a.push_back(5); Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE begin iterator begin(); Devuelve un iterador que referencia el comienzo del vector. end iterator end(); Devuelve un iterador que referencia la posición siguiente al final del vector. erase void erase(iterator first, iterator last); Borra los elementos del vector que estén situados entre los iteradores first y last. Por ejemplo, para borrar todos los elementos de un vector, se puede utilizar: vector<int> a; a.erase(a.begin(),a.end()); // Se borran todos los elementos entre la primera y la última posición. capacity size_type capacity() const; Devuelve el número de elementos con que se ha creado el vector. Siempre es mayor o igual que size. void clear (); Borra todos los elementos de un vector. clear vector<int> a; a.clear(); // Se borran todos los elementos. Algunos de los operadores de esta clase son: = El operador de asignación sustituye el contenido de un vector por el de otro. vector<int> a; vector<int> b; a.push_back(5); a.push_back(10); b.push_back(3); b = a; // El vector b contiene dos elementos: 5 y 10 (los mismos que contenía el vector b). == Comprueba si dos vectores contienen los mismos elementos. Para ello lleva a cabo una comparación elemento a elemento. [] El operador de subíndice devuelve una referencia a un elemento del vector. Una referencia con un subíndice igual a cero devuelve el primer elemento del vector. Así, el rango del subíndice debe estar entre cero y size()-1. vector<double> vec; vec.push_back(1.2); vec.push_back(4.5); vec[1] = vec[0] + 5.0; vec[0] = 2.7; // El vector contiene ahora los elementos 2.7, 6.2 Veamos algunos ejemplos. El siguiente programa muestra dos constructores simples: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE #include <iostream> #include <cassert> #include <vector> using namespace std; int main() { cout << "Demostrando los constructores más simple del vector" << endl; vector<char> vector1, vector2(3, 'x'); assert (vector1.size() == 0); assert (vector2.size() == 3); assert (vector2[0] == 'x' && vector2[1] == 'x' && vector2[2] == 'x'); assert (vector2 == vector<char>(3, 'x') && vector2 != vector<char>(4, 'x')); cout << " --- Ok." << endl; return 0; } Otro constructor, el de copia, crea un vector a partir de un "trozo" de un array u otro vector, o de un vector completo. Veamos el siguiente ejemplo: #include <iostream> #include <cassert> #include <vector> using namespace std; int main() { cout << "Demostrando el constructor de copia del vector." << endl; int numeros[] = {0,1, 2, 3, 4, 5, 6, 7, 8, 9}; vector<int> otroVector(&(numeros[0]),&(numeros[6])); vector<int> tercerVector(otroVector.begin(), otroVector.end()); assert (tercerVector == otroVector); vector<int> hijito1(otroVector); assert (hijito1 == otroVector); vector<int> hijito2 = otroVector; assert (hijito2 == tercerVector); return 0; } Este otro programa muestra el uso de algunas de las funciones miembro anteriormente descritas: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE #include <vector> #include <iostream> using namespace std; int main() { vector<int> v(5); int x; int cont = 0; cout << "tam v:" << v.size() << " y tam.max.: " << v.capacity() << endl; do { cin >> x; if (x!=-1) { // Lo añadimos if (cont<v.size()) v[cont] = x; else { if (v.size()>=v.capacity()) //Hacemos v + grande v.reserve(2*v.capacity()); v.push_back(x); } cont++; cout << "Añadido: " << v[cont-1] << ", tam v:" << v.size() << " y tam.max.: " << v.capacity() << endl; } } while (x!=-1); } cout << "Vector: "; for (vector<int>::const_iterator i= v.begin(); i!=v.end(); i++) cout << *i << " "; cout << endl; return 0; Por último, veamos algunos algoritmos que pueden ser útiles: find -> Busca un elemento, pasado como tercer argumento, en los elementos de un contenedor incluidos en el rango especificado por un iterador de inicio y otro de fin (dos primeros argumentos). Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE #include <iostream> #include <cassert> #include <vector> #include <algorithm> // Biblioteca para find using namespace std; int main() { vector<char> vectorCar; vectorCar.push_back('h'); vectorCar.push_back('o'); vectorCar.push_back('l'); vectorCar.push_back('a'); vectorCar.push_back(' '); vectorCar.push_back('q'); vectorCar.push_back('u'); vectorCar.push_back('e'); vectorCar.push_back(' '); vectorCar.push_back('t'); vectorCar.push_back('a'); vectorCar.push_back('l'); cout << "Demostración de la función find con un vector de caracteres. " << endl; // Búsqueda del 5.: vector<char>::iterator donde = find(vectorCar.begin(), vectorCar.end(), 'q'); assert (*donde == 'q' && *(donde + 1) == 'u'); cout << " --- Ok." << endl; return 0; } reverse -> Invierte los elementos de un contenedor contenidos en un rango determinado por dos iteradores, uno de inicio y otro de final. #include <iostream> #include <vector> #include <cassert> #include <algorithm> // reverse using namespace std; int main() { vector<char> vectorCar; Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE vectorCar.push_back('h'); vectorCar.push_back('o'); vectorCar.push_back('l'); vectorCar.push_back('a'); vector<char> vectorCarAlReves; vectorCarAlReves.push_back('a'); vectorCarAlReves.push_back('l'); vectorCarAlReves.push_back('o'); vectorCarAlReves.push_back('h'); cout << "Invirtiendo un vector. " << endl; reverse(vectorCar.begin(), vectorCar.end()); assert (vectorCar == vectorCarAlReves); cout << " --- Ok." << endl; return 0; } sort -> ordena de manera creciente los elementos de un contenedor incluidos en el rango establecido por un iterador inicial y otro final, pasados como argumentos. #include <iostream> #include <algorithm> #include <vector> #include <cassert> using namespace std; int main() { vector<int> v(1000); for (int i = 0; i < 1000; ++i) v[i] = 1000 - i - 1; sort(v.begin(), v.end()); for (int i = 0; i < 1000; ++i) assert (v[i] == i); cout << " --- Ok." << endl; return 0; } forma: También tenemos la opción de ordenador en orden inverso de la siguiente Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE #include <iostream> #include <algorithm> #include <vector> #include <cassert> using namespace std; bool descendente(int a, int b){ return a < b; } int main() { vector<int> v(1000); for (int i = 0; i < 1000; ++i) v[i] = i; sort(v.begin(), v.end(), descendente); } for (int i = 999; i >= 0; --i) assert (v[i] == i); cout << " --- Ok." << endl; return 0; copy -> Recibe tres argumentos: dos iteradores indicando principio y fin del vector origen y un tercero que indica el inicio en el destino. #include <iostream> #include <cassert> #include <algorithm> #include <vector> #include <string> #include <iostream> using namespace std; int main() { cout << "Ejemplo de copia genérica." << endl; string s("abcdefghihklmnopqrstuvwxyz"); vector<char> vector1(s.begin(), s.end()); vector<char> vector2(vector1.size()); //Con el mismo tamaño que vector1 // Copy vector1 to vector2: copy(vector1.begin(), vector1.end(), vector2.begin()); assert (vector1 == vector2); Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE cout << " --- Ok." << endl; return 0; } 3 Listas 3.1 Definición de lista Una lista es un conjunto de elementos del mismo tipo. Una propiedad importante con la que se caracteriza a las listas es que su longitud puede aumentar o disminuir, según se requiera. Es más, podemos insertar o eliminar elementos en cualquier posición de una lista. Las listas se pueden implementar de 2 modos distintos: mediante vectores o mediante estructuras dinámicas enlazadas por apuntadores. El trabajo de listas con vectores tiene una limitante: las listas tienen una longitud variable (dinámicas) y los vectores tienen longitud estática. No podríamos definir un arreglo de tamaño N donde solamente utilicemos los primero N-m cajones. O viceversa, declarar un arreglo demasiado pequeño para nuestras necesidades. Esto es el porque de las estructuras dinámicas de datos. La forma más simple de estructura dinámica es la lista. En esta forma los nodos se organizan de modo que cada uno apunta al siguiente, y el último no apunta a nada, es decir, el puntero del nodo siguiente vale NULL. En las listas abiertas existe un nodo especial: el primero. Normalmente diremos que nuestra lista es un puntero a ese primer nodo y llamaremos a ese nodo la cabeza de la lista. Eso es porque mediante ese único puntero podemos acceder a toda la lista. Cuando el puntero que usamos para acceder a la lista vale NULL, diremos que la lista está vacía. El nodo típico para construir listas tiene esta forma: struct nodo { int dato; struct nodo *siguiente; }; En el ejemplo, cada elemento de la lista sólo contiene un dato de tipo entero, pero en la práctica no hay límite en cuanto a la complejidad de los datos a almacenar. Normalmente se definen varios tipos que facilitan el manejo de las listas, la declaración de tipos puede tener una forma parecida a esta: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE typedef struct _nodo { int dato; struct _nodo *siguiente; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Lista; Donde: tipoNodo es el tipo para declarar nodos. pNodo es el tipo para declarar punteros a un nodo. Lista es el tipo para declarar listas. Como puede verse, un puntero a un nodo y una lista son la misma cosa. En realidad, cualquier puntero a un nodo es una lista, cuyo primer elemento es el nodo apuntado, por ejemplo: Es muy importante que un programa nunca pierda el valor del puntero al primer elemento, ya que si no existe ninguna copia de ese valor, y se pierde, será imposible acceder al nodo y no podremos liberar el espacio de memoria que ocupa. 3.2 Operaciones básicas con listas. Con las listas tendremos un pequeño repertorio de operaciones básicas que se pueden realizar: Añadir o insertar elementos. Buscar o localizar elementos. Borrar elementos. Moverse a través de una lista, anterior, siguiente, primero. Cada una de estas operaciones tendrá varios casos especiales, por ejemplo, no será lo mismo insertar un nodo en una lista vacía, o al principio de una lista no vacía, o la final, o en una posición intermedia. 3.2.1 Insertar elementos en una lista. Insertar un elemento en una lista vacía: Este es, evidentemente, el caso más sencillo. Partimos de que ya tenemos el nodo a insertar y, por supuesto un puntero que apunte a él, además el puntero a la lista valdrá NULL: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE El proceso es muy simple, bastará con que: 1. nodo->siguiente apunte a NULL. 2. Lista apunte a nodo. Insertar un elemento en la primera posición de una lista: Podemos considerar el caso anterior como un caso particular de éste, la única diferencia es que en el caso anterior la lista es una lista vacía, pero siempre podemos, y debemos considerar una lista vacía como una lista. De nuevo partimos de un nodo a insertar, con un puntero que apunte a él, y de una lista, en este caso no vacía: El proceso sigue siendo muy sencillo: 1. Hacemos que nodo->siguiente apunte a Lista. 2. Hacemos que Lista apunte a nodo. Insertar un elemento en la última posición de una lista: Este es otro caso especial. Para este caso partimos de una lista no vacía: El proceso en este caso tampoco es excesivamente complicado: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 1. Necesitamos un puntero que señale al último elemento de la lista. La manera de conseguirlo es empezar por el primero y avanzar hasta que el nodo que tenga como siguiente el valor NULL. 2. Hacer que nodo->siguiente sea NULL. 3. Hacer que ultimo->siguiente sea nodo. Insertar un elemento a continuación de un nodo cualquiera de una lista: De nuevo podemos considerar el caso anterior como un caso particular de este. Ahora el nodo "anterior" será aquel a continuación del cual insertaremos el nuevo nodo: Suponemos que ya disponemos del nuevo nodo a insertar, apuntado por nodo, y un puntero al nodo a continuación del que lo insertaremos. El proceso a seguir será: 1. Hacer que nodo->siguiente señale a anterior->siguiente. 2. Hacer que anterior->siguiente señale a nodo. 3.2.2 Localizar elementos en una lista. Muy a menudo necesitamos recorrer una lista, ya sea buscando un valor particular o un nodo concreto. Las listas abiertas sólo pueden recorrerse en un sentido, ya que cada nodo apunta al siguiente, pero no se puede obtener, por ejemplo, un puntero al nodo anterior desde un nodo cualquiera si no se empieza desde el principio. Para recorrer una lista procederemos siempre del mismo modo, usaremos un puntero auxiliar como índice: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 1. Asignamos al puntero índice el valor de Lista. 2. Abriremos un bucle que al menos debe tener una condición, que el índice no sea NULL. 3. Dentro del bucle asignaremos al índice el valor del nodo siguiente al índice actual. Por ejemplo, para mostrar todos los valores de los nodos de una lista, podemos usar el siguiente bucle: typedef struct _nodo { int dato; struct _nodo *siguiente; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Lista; ... pNodo indice; ... indice = Lista; while(indice) { printf("%d\n", indice->dato); indice = indice->siguiente; } ... Supongamos que sólo queremos mostrar los valores hasta encontremos uno que sea mayor que 100, podemos sustituir el bucle por: que ... indice = Lista; while(indice && indice->dato <= 100) { printf("%d\n", indice->dato); indice = indice->siguiente; } ... Si analizamos la condición del bucle, tal vez encontremos un posible error: ¿Qué pasaría si ningún valor es mayor que 100, y alcancemos el final de la lista?. Podría pensarse que cuando indice sea NULL, si intentamos acceder a indice>dato se producirá un error. En general eso será cierto, no puede accederse a punteros nulos. Pero en este caso, este acceso está dentro de una condición y forma parte de una expresión "and". Recordemos que cuando se evalúa una expresión "and", se comienza por la izquierda, y la evaluación se abandona cuando una de las Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE expresiones resulta falsa, de modo que la expresión "indice->dato <= 100" nunca se evaluará si indice es NULL. Si hubiéramos escrito la condición al revés, el programa nunca funcionaría bien. Esto es algo muy importante cuando se trabaja con punteros. 3.2.3 Eliminar elementos en una lista. Nuevamente podemos encontrarnos con varios casos, según la posición del nodo a eliminar. Eliminar el primer nodo de una lista abierta: Es el caso más simple. Partimos de una lista con uno o más nodos, y usaremos un puntero auxiliar, nodo: 1. Hacemos que nodo apunte al primer elemento de la lista, es decir a Lista. 2. Asignamos a Lista la dirección del segundo nodo de la lista: Lista>siguiente. 3. Liberamos la memoria asignada al primer nodo, el que queremos eliminar. Si no guardamos el puntero al primer nodo antes de actualizar Lista, después nos resultaría imposible liberar la memoria que ocupa. Si liberamos la memoria antes de actualizar Lista, perderemos el puntero al segundo nodo. Si la lista sólo tiene un nodo, el proceso es también válido, ya que el valor de Lista->siguiente es NULL, y después de eliminar el primer nodo la lista quedará vacía, y el valor de Lista será NULL. De hecho, el proceso que se suele usar para borrar listas completas es eliminar el primer nodo hasta que la lista esté vacía. Eliminar un nodo cualquiera de una lista abierta: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE En todos los demás casos, eliminar un nodo se puede hacer siempre del mismo modo. Supongamos que tenemos una lista con al menos dos elementos, y un puntero al nodo anterior al que queremos eliminar. Y un puntero auxiliar nodo. El proceso es parecido al del caso anterior: 1. Hacemos que nodo apunte al nodo que queremos borrar. 2. Ahora, asignamos como nodo siguiente del nodo anterior, el siguiente al que queremos eliminar: anterior->siguiente = nodo->siguiente. 3. Eliminamos la memoria asociada al nodo que queremos eliminar. Si el nodo a eliminar es el último, es procedimiento es igualmente válido, ya que anterior pasará a ser el último, y anterior->siguiente valdrá NULL. 3.2.4 Moverse a través de una lista. Sólo hay un modo de moverse a través de una lista abierta, hacia delante. Aún así, a veces necesitaremos acceder a determinados elementos de una lista abierta. Veremos ahora como acceder a los más corrientes: el primero, el último, el siguiente y el anterior. Primer elemento de una lista: El primer elemento es el más accesible, ya que es a ese a que apunta el puntero que define la lista. Para obtener un puntero al primer elemento bastará con copiar el puntero Lista. Elemento siguiente a uno cualquiera: Supongamos que tenemos un puntero nodo que señala a un elemento de una lista. Para obtener un puntero al siguiente bastará con asignarle el campo "siguiente" del nodo, nodo->siguiente. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Elemento anterior a uno cualquiera: Ya hemos dicho que no es posible retroceder en una lista, de modo que para obtener un puntero al nodo anterior a uno dado tendremos que partir del primero, e ir avanzando hasta que el nodo siguiente sea precisamente nuestro nodo. Último elemento de una lista: Para obtener un puntero al último elemento de una lista partiremos de un nodo cualquiera, por ejemplo el primero, y avanzaremos hasta que su nodo siguiente sea NULL. Saber si una lista está vacía: Basta con comparar el puntero Lista con NULL, si Lista vale NULL la lista está vacía. 3.2.5 Borrar una lista completa. El algoritmo genérico para borrar una lista completa consiste simplemente en borrar el primer elemento sucesivamente mientras la lista no esté vacía. 3.2.6 Ejemplo de lista ordenada. Supongamos que queremos construir una lista para almacenar números enteros, pero de modo que siempre esté ordenada de menor a mayor. Para hacer la prueba añadiremos los valores 20, 10, 40, 30. De este modo tendremos todos los casos posibles. Al comenzar, el primer elemento se introducirá en una lista vacía, el segundo se insertará en la primera posición, el tercero en la última, y el último en una posición intermedia. Insertar un elemento en una lista vacía es equivalente a insertarlo en la primera posición. De modo que no incluiremos una función para asignar un elemento en una lista vacía, y haremos que la función para insertar en la primera posición nos sirva para ese caso también. Algoritmo de inserción: 1. El primer paso es crear un nodo para el dato que vamos a insertar. 2. Si Lista es NULL, o el valor del primer elemento de la lista es mayor que el del nuevo, insertaremos el nuevo nodo en la primera posición de la lista. 3. En caso contrario, buscaremos el lugar adecuado para la inserción, tenemos un puntero "anterior". Lo inicializamos con el valor de Lista, y avanzaremos mientras anterior->siguiente no sea NULL y el dato que Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE contiene anterior->siguiente sea menor o igual que el dato que queremos insertar. 4. Ahora ya tenemos anterior señalando al nodo adecuado, así que insertamos el nuevo nodo a continuación de él. Algoritmo para borrar un elemento: Después probaremos la función para buscar y borrar, borraremos los elementos 10, 15, 45, 30 y 40, así probaremos los casos de borrar el primero, el último y un caso intermedio o dos nodos que no existan. Recordemos que para eliminar un nodo necesitamos disponer de un puntero al nodo anterior. 1. Lo primero será localizar el nodo a eliminar, si es que existe. Pero sin perder el puntero al nodo anterior. Partiremos del nodo primero, y del valor NULL para anterior. Y avanzaremos mientras nodo no sea NULL o mientras que el valor almacenado en nodo sea menor que el que buscamos. 2. Ahora pueden darse tres casos: 3. Que el nodo sea NULL, esto indica que todos los valores almacenados en la lista son menores que el que buscamos y el nodo que buscamos no existe. Retornaremos sin borrar nada. 4. Que el valor almacenado en nodo sea mayor que el que buscamos, en ese caso también retornaremos sin borrar nada, ya que esto indica que el nodo que buscamos no existe. 5. Que el valor almacenado en el nodo sea igual al que buscamos. 6. De nuevo existen dos casos: 7. Que anterior sea NULL. Esto indicaría que el nodo que queremos borrar es el primero, así que modificamos el valor de Lista para que apunte al nodo siguiente al que queremos borrar. 8. Que anterior no sea NULL, el nodo no es el primero, así que asignamos a anterior->siguiente la dirección de nodo->siguiente. 9. Después de 7 u 8, liberamos la memoria de nodo. void Borrar(Lista *lista, int v) { pNodo anterior, nodo; nodo = *lista; anterior = NULL; while(nodo && nodo->valor < v) { anterior = nodo; nodo = nodo->siguiente; } if(!nodo || nodo->valor != v) return; else { /* Borrar el nodo */ if(!anterior) /* Primer elemento */ Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE *lista = nodo->siguiente; else /* un elemento cualquiera */ anterior->siguiente = nodo->siguiente; free(nodo); } } 3.2.7 Ejemplo de lista en C++ usando clases. Usando clases el programa cambia bastante, aunque los algoritmos son los mismos. Para empezar, necesitamos dos clases, una para nodo y otra para lista. Además la clase para nodo debe ser amiga de la clase lista, ya que ésta debe acceder a los miembros privados de nodo. class nodo { public: nodo(int v, nodo *sig = NULL) { valor = v; siguiente = sig; } private: int valor; nodo *siguiente; }; friend class lista; typedef nodo *pnodo; class lista { public: lista() { primero = actual = NULL; } ~lista(); void Insertar(int v); void Borrar(int v); bool ListaVacia() { return primero == NULL; } void Mostrar(); void Siguiente(); void Primero(); void Ultimo(); bool Actual() { return actual != NULL; } int ValorActual() { return actual->valor; } Estructura de Datos. UTIM ESTRUCTURA DE DATOS I }; MANUAL DEL DOCENTE private: pnodo primero; pnodo actual; Hemos hecho que la clase para lista sea algo más completa que la equivalente en C, aprovechando las prestaciones de las clases. En concreto, hemos añadido funciones para mantener un puntero a un elemento de la lista y para poder moverse a través de ella. 3.3 La clase LIST de STL El contenedor de decencia LIST cuenta con una eficiente combinación para las operaciones de inserción y eliminación en cualquier posición del contenedor. La clase LIST se implementa como una lista doblemente enlazada: cada nodo en una lista contiene un apuntador al nodo anterior y al nodo siguiente de esta lista. Esto permite a la clase LIST soportar iteradotes bidireccionales que permiten que el contenedor se recorra tanto hacia delante como hacia atrás. Además de las funciones miembro de todos los contenedores de la STL y de las funciones miembro comunes de todos los contenedores de secuencia, la clase LIST cuenta con otras ocho funciones miembro: splice: elimina elementos de una estructura y los almacena en otra, utiliza distintos formatos. remove: elimina el elemento especificado de la lista. unique: elimina elementos duplicados de una lista. merge: elimina elementos de una estructura y los almacena en otra, de manera ordenada. reverse: invierte los elementos de una lista. sort: ordena los elementos de la lista. push_front: inserta un elemento en la parte frontal de la lista. pop_front: elimina un elemento de la parte frontal de la lista. 4 Pilas 4.1 Definición de pilas Una pila es un tipo especial de lista abierta en la que sólo se pueden insertar y eliminar nodos en uno de los extremos de la lista. Estas operaciones se Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE conocen como "push" y "pop", respectivamente "empujar" y "tirar". Además, las escrituras de datos siempre son inserciones de nodos, y las lecturas siempre eliminan el nodo leído. Estas características implican un comportamiento de lista LIFO (Last In First Out), el último en entrar es el primero en salir. La más sencilla de las estructuras dinámicas de datos es la pila, las pilas son utilizadas sobre todo por los sistemas operativos y los controladores de los lenguajes de alto nivel. Una pila es dinámica: crece y encoge a medida que es necesario. Una manera de ver esta estructura es pensar en las pilas como si fuese una pila de bandejas de un autoservicio. Las bandejas se ponen en la pila por arriba, la bandeja de arriba se la lleva de la pila un cliente que este en la cola. Este escenario se denomina modelo del último en llegar-primero en salir: la última bandeja que se ponga en la pila será la primera que se lleven. El nodo típico para construir pilas es el siguiente: struct nodo { int dato; struct nodo *siguiente; }; Los tipos que se definen normalmente para manejar pilas son casi los mismos que para manejar listas, tan sólo se cambian algunos nombres de la siguiente manera: typedef struct _nodo { int dato; struct _nodo *siguiente; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Pila; Donde: tipoNodo es el tipo para declarar nodos, evidentemente. pNodo es el tipo para declarar punteros a un nodo. Pila es el tipo para declarar pilas. Así una manera grafica de ver la estructura sería: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Es evidente, a la vista del gráfico, que una pila es una lista abierta. Así que sigue siendo muy importante que un programa nunca pierda el valor del puntero al primer elemento, igual que pasa con las listas abiertas. Es necesario tener en cuenta que las inserciones y eliminaciones en una pila se hacen siempre en un extremo, es decir, lo que consideramos como el primer elemento de la lista es en realidad el último elemento de la pila. 4.2 Operaciones básicas con pilas. Las pilas tienen un conjunto de operaciones muy limitado, sólo permiten las operaciones de "push" y "pop": Push: Añadir un elemento al final de la pila. Pop: Leer y eliminar un elemento del final de la pila. 4.2.1. Push, insertar elemento: Las operaciones con pilas son muy simples, no hay casos especiales, salvo que la pila esté vacía. Push en una pila vacía: Partiremos de que ya tenemos el nodo a insertar y, por supuesto un puntero que apunte a él, además el puntero a la pila valdrá NULL: El proceso es muy simple, bastará con que: 1. nodo->siguiente apunte a NULL. 2. Pila apunte a nodo. Push en una pila no vacía: Podemos considerar el caso anterior como un caso particular de éste, la única diferencia es que podemos y debemos trabajar con una pila vacía como con una pila normal. De nuevo partiremos de un nodo a insertar, con un puntero que apunte a él, y de una pila, en este caso no vacía: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE El proceso sigue siendo muy sencillo: 1. Hacemos que nodo->siguiente apunte a Pila. 2. Hacemos que Pila apunte a nodo. 4.2.2. Pop, leer y eliminar un elemento. Ahora sólo existe un caso posible, ya que sólo podemos leer desde un extremo de la pila. Partiremos de una pila con uno o más nodos, y usaremos un puntero auxiliar, nodo: 1. Hacemos que nodo apunte al primer elemento de la pila, es decir a Pila. 2. Asignamos a Pila la dirección del segundo nodo de la pila: Pila>siguiente. 3. Guardamos el contenido del nodo para devolverlo como retorno, recuerda que la operación pop equivale a leer y borrar. 4. Liberamos la memoria asignada al primer nodo, el que queremos eliminar. Si la pila sólo tiene un nodo, el proceso sigue siendo válido, ya que el valor de Pila->siguiente es NULL, y después de eliminar el último nodo la pila quedará vacía, y el valor de Pila será NULL. 4.3 Implementación de pilas. Supongamos que queremos construir una pila para almacenar números enteros. Algoritmo de la función "push": 1. Creamos un nodo para el valor que colocaremos en la pila. 2. Hacemos que nodo->siguiente apunte a Pila. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 3. Hacemos que Pila apunte a nodo. void Push(Pila *pila, int v) { pNodo nuevo; /* Crear un nodo nuevo */ nuevo = (pNodo)malloc(sizeof(tipoNodo)); nuevo->valor = v; } /* Añadimos la pila a continuación del nuevo nodo */ nuevo->siguiente = *pila; /* Ahora, el comienzo de nuestra pila es en nuevo nodo */ *pila = nuevo; Algoritmo de la función "pop": 1. Hacemos que nodo apunte al primer elemento de la pila, es decir, a Pila. 2. Asignamos a Pila la dirección del segundo nodo de la pila: Pila>siguiente. 3. Guardamos el contenido del nodo para devolverlo como retorno. 4. Liberamos la memoria asignada al primer nodo, el que queremos eliminar. int Pop(Pila *pila) { pNodo nodo; /* variable auxiliar para manipular nodo */ int v; /* variable auxiliar para retorno */ } /* Nodo apunta al primer elemento de la pila */ nodo = *pila; if(!nodo) return 0; /* Si no hay nodos en la pila retornamos 0 */ /* Asignamos a pila toda la pila menos el primer elemento */ *pila = nodo->siguiente; /* Guardamos el valor de retorno */ v = nodo->valor; /* Borrar el nodo */ free(nodo); return v; 4.3.1 Ejemplo de pila en C++ usando clases. Las clases para pilas son versiones simplificadas de las mismas clases que usamos para listas. Para empezar, necesitaremos dos clases, una para nodo y otra para pila. Además la clase para nodo debe ser amiga de la clase pila, ya que ésta debe acceder a los miembros privados de nodo. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE class nodo { public: nodo(int v, nodo *sig = NULL) { valor = v; siguiente = sig; } private: int valor; nodo *siguiente; friend class pila; }; typedef nodo *pnodo; class pila { public: pila() : ultimo(NULL) {} ~pila(); void Push(int v); int Pop(); private: pnodo ultimo; }; 4.4 La clase STACK de STL La clase STACK permite insertar a, y eliminar de, la estructura de datos subyacente en un extremo. Una pila puede implementarse con cualquiera de los contenedores de secuencia; vector, list y deque. De manera predeterminada, una pila se implementa con un deque. Las operaciones de la pila son: push para insertar un elemento en la parte superior, pop para eliminar el elemento de la pila, top para obtener una referencia al elemento de la pila, empty para determinar si la pila se encuentra vacía o no y size para obtener el número de elementos de la pila. Para poder hacer uso de la clase STACK es necesario incluir el archivo de encabezado <stack>, por ejemplo, el siguiente codigo crea tres pilas de enteros utilizando cada uno de los contenedores de secuencia de la STL como estructura de datos subyacente para representar al adaptador STACK. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE // Programa de prueba para el adaptador stack de la Biblioteca estándar. #include <iostream> using std::cout; using std::endl; #include <stack> // definición del adaptador stack #include <vector> // definición de la plantilla de clase vector #include <list> // definición de la plantilla de clase list // prototipo de la plantilla de función sacarElementos template< class T > void sacarElementos( T &stackRef ); int main() { // pila con deque subyacente predeterminado std::stack< int > intDequePila; // pila con vector subyacente std::stack< int, std::vector< int > > intVectorPila; // pila con lista subyacente std::stack< int, std::list< int > > intListaPila; // meter los valores 0-9 en cada pila for ( int i = 0; i < 10; ++i ) { intDequePila.push( i ); intVectorPila.push( i ); intListaPila.push( i ); } // fin de instrucción for // mostrar y eliminar elementos de cada pila cout << "Sacando de intDequePila: "; sacarElementos( intDequePila ); cout << "\nSacando de intVectorPila: "; sacarElementos( intVectorPila ); cout << "\nSacando de intListaPila: "; sacarElementos( intListaPila ); cout << endl; return 0; } // fin de main Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE // sacar elementos del objeto pila al que hace referencia stackRef template< class T > void sacarElementos( T &stackRef ) { while ( !stackRef.empty() ) { cout << stackRef.top() << ' '; // ver elemento superior stackRef.pop(); // eliminar elemento superior } // fin de instrucción while } // fin de la función sacarElementos 5 Colas 5.1 Definición de colas Una cola es un tipo especial de lista abierta en la que sólo se puede insertar nodos en uno de los extremos de la lista y sólo se pueden eliminar nodos en el otro. Además, como sucede con las pilas, las escrituras de datos siempre son inserciones de nodos, y las lecturas siempre eliminan el nodo leído. Este tipo de lista es conocido como lista FIFO (First In First Out), el primero en entrar es el primero en salir. Un ejemplo cotidiano es una cola para comprar, por ejemplo, las entradas del cine. Los nuevos compradores sólo pueden colocarse al final de la cola, y sólo el primero de la cola puede comprar la entrada. El nodo típico para construir colas es el mismo para la construcción de listas y pilas: struct nodo { int dato; struct nodo *siguiente; }; Los tipos que se definen normalmente para manejar colas son casi los mismos que para manejar listas y pilas, tan sólo se cambian algunos nombres: typedef struct _nodo { int dato; struct _nodo *siguiente; } tipoNodo; Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE typedef tipoNodo *pNodo; typedef tipoNodo *Cola; Donde: tipoNodo es el tipo para declarar nodos. pNodo es el tipo para declarar punteros a un nodo. Cola es el tipo para declarar colas. De manera similar a las pilas, una cola se puede representar gráficamente como: Es evidente que una cola es una lista abierta. Así que sigue siendo muy importante que un programa nunca pierda el valor del puntero al primer elemento, igual que pasa con las listas abiertas. Además, debido al funcionamiento de las colas, también se debe mantener un puntero para el último elemento de la cola, que será el punto donde insertemos nuevos nodos. Teniendo en cuenta que las lecturas y escrituras en una cola se hacen siempre en extremos distintos, lo más fácil será insertar nodos por el final, a continuación del nodo que no tiene nodo siguiente, y leerlos desde el principio, hay que recordar que leer un nodo implica eliminarlo de la cola. 5.2 Operaciones básicas con colas. De nuevo nos encontramos ante una estructura con muy pocas operaciones disponibles. Las colas sólo permiten añadir y leer elementos: Añadir: Inserta un elemento al final de la cola. Leer: Lee y elimina un elemento del principio de la cola. 5.2.1 Añadir un elemento. Las operaciones con colas son muy sencillas, prácticamente no hay casos especiales, salvo que la cola esté vacía. Añadir elemento en una cola vacía: Partimos de que ya tenemos el nodo a insertar y, por supuesto un puntero que apunte a él, además los punteros que definen la cola, primero y ultimo que valdrán NULL: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE El proceso es muy simple, bastará con que: 1. nodo->siguiente apunte a NULL. 2. Y que los punteros primero y ultimo apunten a nodo. Añadir elemento en una cola no vacía: Nuevamente partimos de un nodo a insertar, con un puntero que apunte a él, y de una cola, en este caso, al no estar vacía, los punteros primero y ultimo no serán nulos: El proceso sigue siendo muy sencillo: 1. Hacemos que nodo->siguiente apunte a NULL. 2. Después que ultimo->siguiente apunte a nodo. 3. Y actualizamos ultimo, haciendo que apunte a nodo. Añadir elemento en una cola, caso general: Para generalizar el caso anterior, sólo necesitamos añadir una operación: 1. 2. 3. 4. Hacemos que nodo->siguiente apunte a NULL. Si ultimo no es NULL, hacemos que ultimo->siguiente apunte a nodo. Y actualizamos ultimo, haciendo que apunte a nodo. Si primero es NULL, significa que la cola estaba vacía, así que haremos que primero apunte también a nodo. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 5.2.2 Leer un elemento. Ahora, también existen dos casos, que la cola tenga un solo elemento o que tenga más de uno. Leer un elemento en una cola con más de un elemento: Usamos un puntero a un nodo auxiliar: 1. Hacemos que nodo apunte al primer elemento de la cola, es decir a primero. 2. Asignamos a primero la dirección del segundo nodo de la pila: primero>siguiente. 3. Guardamos el contenido del nodo para devolverlo como retorno. 4. Liberamos la memoria asignada al primer nodo, el que queremos eliminar. Leer un elemento en una cola con un solo elemento: También necesitamos un puntero a un nodo auxiliar: 1. Hacemos que nodo apunte al primer elemento de la pila, es decir a primero. 2. Asignamos NULL a primero, que es la dirección del segundo nodo teórico de la cola: primero->siguiente. 3. Guardamos el contenido del nodo para devolverlo como retorno. 4. Liberamos la memoria asignada al primer nodo, el que queremos eliminar. 5. Hacemos que ultimo apunte a NULL, ya que la lectura ha dejado la cola vacía. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Leer un elemento en una cola caso general: 1. Hacemos que nodo apunte al primer elemento de la pila, es decir a primero. 2. Asignamos a primero la dirección del segundo nodo de la pila: primero>siguiente. 3. Guardamos el contenido del nodo para devolverlo como retorno. 4. Liberamos la memoria asignada al primer nodo, el que queremos eliminar. 5. Si primero es NULL, hacemos que ultimo también apunte a NULL, ya que la lectura ha dejado la cola vacía. 5.3 Implementación de colas. Supóngase que construimos una cola para almacenar números enteros. Algoritmo de la función "Añadir": 1. 2. 3. 4. 5. Creamos un nodo para el valor que colocaremos en la cola. Hacemos que nodo->siguiente apunte a NULL. Si "ultimo" no es NULL, hacemos que ultimo->siguiente apunte a nodo. Actualizamos "ultimo" haciendo que apunte a nodo. Si "primero" es NULL, hacemos que apunte a nodo. void Anadir(pNodo *primero, pNodo *ultimo, int v) { pNodo nuevo; /* Crear un nodo nuevo */ nuevo = (pNodo)malloc(sizeof(tipoNodo)); nuevo->valor = v; /* Este será el último nodo, no debe tener siguiente */ nuevo->siguiente = NULL; /* Si la cola no estaba vacía, añadimos el nuevo a continuación de ultimo */ Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE if(*ultimo) (*ultimo)->siguiente = nuevo; /* Ahora, el último elemento de la cola es el nuevo nodo */ *ultimo = nuevo; /* Si primero es NULL, la cola estaba vacía, ahora primero apuntará también al nuevo nodo */ if(!*primero) *primero = nuevo; } Algoritmo de la función "leer": 1. Hacemos que nodo apunte al primer elemento de la cola, es decir a primero. 2. Asignamos a primero la dirección del segundo nodo de la cola: primero>siguiente. 3. Guardamos el contenido del nodo para devolverlo como retorno. 4. Liberamos la memoria asignada al primer nodo, el que queremos eliminar. 5. Si primero es NULL, haremos que último también apunte a NULL, ya que la cola habrá quedado vacía. int Leer(pNodo *primero, pNodo *ultimo) { pNodo nodo; /* variable auxiliar para manipular nodo */ int v; /* variable auxiliar para retorno */ /* Nodo apunta al primer elemento de la pila */ nodo = *primero; if(!nodo) return 0; /* Si no hay nodos en la pila retornamos 0 */ /* Asignamos a primero la dirección del segundo nodo */ *primero = nodo->siguiente; /* Guardamos el valor de retorno */ v = nodo->valor; /* Borrar el nodo */ free(nodo); /* Si la cola quedó vacía, ultimo debe ser NULL también*/ if(!*primero) *ultimo = NULL; return v; } 5.3.1 Ejemplo de cola en C++ usando clases. Ya hemos visto que las colas son casos particulares de listas abiertas, pero más simples. Como en los casos anteriores, veremos ahora un ejemplo de cola usando clases. Para empezar, y como siempre, necesitamos dos clases, una para nodo y otra para cola. Además la clase para nodo debe ser amiga de la clase cola, ya que ésta debe acceder a los miembros privados de nodo. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE class nodo { public: nodo(int v, nodo *sig = NULL) { valor = v; siguiente = sig; } private: int valor; nodo *siguiente; friend class cola; }; typedef nodo *pnodo; class cola { public: cola() : ultimo(NULL), primero(NULL) {} ~cola(); void Anadir(int v); int Leer(); private: pnodo primero, ultimo; }; 5.4 La clase QUEUE de STL. La clase QUEUE permite inserciones en la parte final de la estructura de datos subyacentes y eliminaciones en la parte inicial de la misma. Una cola puede implementarse con la estructura de datos list o deque de la STL, de manera predeterminada una cola se implementa con deque. Las operaciones comunes de un adaptador queue son push para insertar un elemento en su parte final, pop para eliminar el elemento en la parte inicial de la cola, front para obtener una referencia al primer elemento de la cola, back para obtener una referencia al último elemento de la cola, empty para determinar si la cola esta o no vacía y size para obtener el número de elementos de la cola. El archivo de encabezado <queue> debe incluirse para poder utilizar esta clase. Por ejemplo, el siguiente codigo muestra el uso de la clase queue. // Programa de prueba para el adaptador queue de la Biblioteca estándar. #include <iostream> Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE using std::cout; using std::endl; #include <queue> // definición del adaptador queue int main() { std::queue< double > valores; // meter elementos en la cola valores valores.push( 3.2 ); valores.push( 9.8 ); valores.push( 5.4 ); cout << "Sacando de valores: "; while ( !valores.empty() ) { cout << valores.front() << ' '; // ver elemento inicial valores.pop(); // eliminar elemento } // fin de instrucción while cout << endl; return 0; } // fin de main Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 6 Árboles 6.1 Definición de árboles Un árbol es una estructura no lineal en la que cada nodo puede apuntar a uno o varios nodos. También se suele dar una definición recursiva: un árbol es una estructura en compuesta por un dato y varios árboles, la forma gráfica se puede apreciar como sigue: En relación con nodos se pueden definir conceptos como: Nodo hijo: cualquiera de los nodos apuntados por uno de los nodos del árbol. En el ejemplo, 'L' y 'M' son hijos de 'G'. Nodo padre: nodo que contiene un puntero al nodo actual. En el ejemplo, el nodo 'A' es padre de 'B', 'C' y 'D'. En cuanto a la posición dentro del árbol, encontramos: Nodo raíz: nodo que no tiene padre. Este es el nodo que usaremos para referirnos al árbol. En el ejemplo, ese nodo es el 'A'. Nodo hoja: nodo que no tiene hijos. En el ejemplo hay varios: 'F', 'H', 'I', 'K', 'L', 'M', 'N' y 'O'. Nodo rama: aunque esta definición apenas la usaremos, estos son los nodos que no pertenecen a ninguna de las dos categorías anteriores. En el ejemplo: 'B', 'C', 'D', 'E', 'G' y 'J'. Un árbol en el que en cada nodo o bien todos o ninguno de los hijos existe, se llama árbol completo. Los árboles se parecen al resto de las estructuras que hemos visto: dado un nodo cualquiera de la estructura, podemos considerarlo como una estructura independiente, es decir, un nodo cualquiera puede ser considerado como la raíz de un árbol completo. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Existen otros conceptos que definen las características del árbol, en relación a su tamaño: Orden: es el número potencial de hijos que puede tener cada elemento de árbol. De este modo, diremos que un árbol en el que cada nodo puede apuntar a otros dos es de orden dos, si puede apuntar a tres será de orden tres, etc. Grado: el número de hijos que tiene el elemento con más hijos dentro del árbol. En el árbol del ejemplo, el grado es tres, ya que tanto 'A' como 'D' tienen tres hijos, y no existen elementos con más de tres hijos. Nivel: se define para cada elemento del árbol como la distancia a la raíz, medida en nodos. El nivel de la raíz es cero y el de sus hijos uno. Así sucesivamente. En el ejemplo, el nodo 'D' tiene nivel 1, el nodo 'G' tiene nivel 2, y el nodo 'N', nivel 3. Altura: la altura de un árbol se define como el nivel del nodo de mayor nivel. Como cada nodo de un árbol puede considerarse a su vez como la raíz de un árbol, también podemos hablar de altura de ramas. El árbol del ejemplo tiene altura 3, la rama 'B' tiene altura 2, la rama 'G' tiene altura 1, la 'H' cero, etc. Los árboles de orden dos son bastante especiales, de hecho les dedicaremos varios capítulos. Estos árboles se conocen también como árboles binarios. Frecuentemente, aunque no es estrictamente necesario, para hacer más fácil el moverse a través del árbol, se añade un puntero a cada nodo que apunte al nodo padre. De este modo podremos avanzar en dirección a la raíz, y no sólo hacia las hojas. Es importante conservar siempre el nodo raíz ya que es el nodo a partir del cual se desarrolla el árbol, si perdemos este nodo, perderemos el acceso a todo el árbol. El nodo típico de un árbol difiere de los nodos que hemos visto hasta ahora para listas, aunque sólo en el número de nodos. Veamos un ejemplo de nodo para crear árboles de orden tres: struct nodo { int dato; struct nodo *rama1; struct nodo *rama2; struct nodo *rama3; }; O generalizando más: #define ORDEN 5 struct nodo { int dato; struct nodo *rama[ORDEN]; Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE }; Para C y C++, y basándonos en la declaración de nodo que hemos visto anteriormente, se pueden definir los siguientes tipos: typedef struct _nodo { int dato; struct _nodo *rama[ORDEN]; } tipoNodo; typedef tipoNodo *pNodo; typedef tipoNodo *Arbol; Al igual que con las listas, declaramos un tipo tipoNodo para declarar nodos, y un tipo pNodo para es el tipo para declarar punteros a un nodo. Arbol es el tipo para declarar árboles de orden ORDEN. El movimiento a través de árboles será siempre partiendo del nodo raíz hacia un nodo hoja. Cada vez que lleguemos a un nuevo nodo podremos optar por cualquiera de los nodos a los que apunta para avanzar al siguiente nodo. Operaciones básicas con árboles Salvo que trabajemos con árboles especiales, como los que veremos más adelante, las inserciones serán siempre en punteros de nodos hoja o en punteros libres de nodos rama. Con estas estructuras no es tan fácil generalizar, ya que existen muchas variedades de árboles. De nuevo tenemos casi el mismo repertorio de operaciones de las que disponíamos con las listas: Añadir o insertar elementos. Buscar o localizar elementos. Borrar elementos. Moverse a través del árbol. Recorrer el árbol completo. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Los algoritmos de inserción y borrado dependen en gran medida del tipo de árbol que estemos implementando, de modo que por ahora los pasaremos por alto y nos centraremos más en el modo de recorrer árboles. Recorridos por los árboles El modo evidente de moverse a través de las ramas de un árbol es siguiendo los punteros, del mismo modo en que nos movíamos a través de las listas. Esos recorridos dependen en gran medida del tipo y propósito del árbol, pero hay ciertos recorridos que usaremos frecuentemente. Se trata de aquellos recorridos que incluyen todo el árbol. Hay tres formas de recorrer un árbol completo, y las tres se suelen implementar mediante recursividad. En los tres casos se sigue siempre a partir de cada nodo todas las ramas una por una. Supongamos que tenemos un árbol de orden tres, y queremos recorrerlo por completo. Partiremos del nodo raíz: RecorrerArbol(raiz); La función RecorrerArbol, aplicando recursividad, será tan sencilla como invocar de nuevo a la función RecorrerArbol para cada una de las ramas: void RecorrerArbol(Arbol a) { if(a == NULL) return; RecorrerArbol(a->rama[0]); RecorrerArbol(a->rama[1]); RecorrerArbol(a->rama[2]); } Lo que diferencia los distintos métodos de recorrer el árbol no es el sistema de hacerlo, sino el momento que elegimos para procesar el valor de cada nodo con relación a los recorridos de cada una de las ramas. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Los tres tipos son: Pre-orden: En este tipo de recorrido, el valor del nodo se procesa antes de recorrer las ramas: void PreOrden(Arbol a) { if(a == NULL) return; Procesar(dato); RecorrerArbol(a->rama[0]); RecorrerArbol(a->rama[1]); RecorrerArbol(a->rama[2]); } Si seguimos el árbol del ejemplo en pre-orden, y el proceso de los datos es sencillamente mostrarlos por pantalla, obtendremos algo así: ABEKFCGLMDHIJNO In-orden: En este tipo de recorrido, el valor del nodo se procesa después de recorrer la primera rama y antes de recorrer la última. Esto tiene más sentido en el caso de árboles binarios, y también cuando existen ORDEN-1 datos, en cuyo caso procesaremos cada dato entre el recorrido de cada dos ramas (este es el caso de los árboles-b): void InOrden(Arbol a) { if(a == NULL) return; RecorrerArbol(a->rama[0]); Procesar(dato); RecorrerArbol(a->rama[1]); RecorrerArbol(a->rama[2]); } Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Si seguimos el árbol del ejemplo en in-orden, y el proceso de los datos es sencillamente mostrarlos por pantalla, obtendremos algo así: KEBFALGMCHDINJO Post-orden: En este tipo de recorrido, el valor del nodo se procesa después de recorrer todas las ramas: void PostOrden(Arbol a) { if(a == NULL) return; RecorrerArbol(a->rama[0]); RecorrerArbol(a->rama[1]); RecorrerArbol(a->rama[2]); Procesar(dato); } Si seguimos el árbol del ejemplo en post-orden, y el proceso de los datos es sencillamente mostrarlos por pantalla, obtendremos algo así: KEFBLMGCHINOJDA Eliminar nodos en un árbol El proceso general es muy sencillo en este caso, pero con una importante limitación, sólo podemos borrar nodos hoja: El proceso sería el siguiente: 1. 2. 3. 4. Buscar el nodo padre del que queremos eliminar. Buscar el puntero del nodo padre que apunta al nodo que queremos borrar. Liberar el nodo. padre->nodo[i] = NULL;. Cuando el nodo a borrar no sea un nodo hoja, diremos que hacemos una "poda", y en ese caso eliminaremos el árbol cuya raíz es el nodo a borrar. Se trata de un procedimiento recursivo, aplicamos el recorrido PostOrden, y el proceso será borrar el nodo. El procedimiento es similar al de borrado de un nodo: 1. 2. 3. 4. Buscar el nodo padre del que queremos eliminar. Buscar el puntero del nodo padre que apunta al nodo que queremos borrar. Podar el árbol cuyo padre es nodo. padre->nodo[i] = NULL;. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE En el árbol del ejemplo, para podar la rama 'B', recorreremos el subárbol 'B' en postorden, eliminando cada nodo cuando se procese, de este modo no perdemos los punteros a las ramas apuntadas por cada nodo, ya que esas ramas se borrarán antes de eliminar el nodo. De modo que el orden en que se borrarán los nodos será: KEFyB Árboles ordenados A partir del siguiente capítulo sólo hablaremos de árboles ordenados, ya que son los que tienen más interés desde el punto de vista de TAD, y los que tienen más aplicaciones genéricas. Un árbol ordenado, en general, es aquel a partir del cual se puede obtener una secuencia ordenada siguiendo uno de los recorridos posibles del árbol: inorden, preorden o postorden. En estos árboles es importante que la secuencia se mantenga ordenada aunque se añadan o se eliminen nodos. Existen varios tipos de árboles ordenados, que veremos a continuación: árboles binarios de búsqueda (ABB): son árboles de orden 2 que mantienen una secuencia ordenada si se recorren en inorden. árboles AVL: son árboles binarios de búsqueda equilibrados, es decir, los niveles de cada rama para cualquier nodo no difieren en más de 1. árboles perfectamente equilibrados: son árboles binarios de búsqueda en los que el número de nodos de cada rama para cualquier nodo no difieren en más de 1. Son por lo tanto árboles AVL también. árboles 2-3: son árboles de orden 3, que contienen dos claves en cada nodo y que están también equilibrados. También generan secuencias ordenadas al recorrerlos en inorden. árboles-B: caso general de árboles 2-3, que para un orden M, contienen M1 claves. 6.2 Árboles binarios. Un árbol binario esta vacío o consta de un nodo denominado raíz junto con dos árboles binarios llamados subárbol izquierdo y subárbol derecho de la raíz. Además de que los árboles binarios se emplean para búsquedas, la recuperación de información es una de las aplicaciones más importantes, y para ello existen los árboles de búsqueda binaria. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE La única manera de construir un árbol binario con un nodo consiste en hacer que el nodo sea su raíz y que estén vacíos sus subárboles izquierdo y derecho (representan un apuntador a NULL), esto significa que se trata de un árbol ordinario. Con dos nodos en el árbol, uno de ellos será la raíz y el otro estará en un subárbol. Así, uno de los subárboles de la izquierda o derecha debe estar vacío y el otro contendrá un nodo. De ahí que haya dos árboles binarios diferentes con dos nodos. En el caso de un árbol binario con tres nodos, uno de estos será la raíz y los otros dos se dividirán entre los subárboles de la izquierda y derecha en una de las siguientes formas: 2 + 0, 1 + 1 y 0 + 2. Como hay dos árboles binarios con dos nodos y solo un árbol vacío, en el primer caso da dos árboles binarios. El tercero también lo hace. En el segundo caso, los subárboles izquierdo y derecho tienen un nodo, y solo hay un árbol binario con un nodo y por eso hay uno en el segundo. Así pues, en total existen cinco árboles binarios con tres nodos. Un árbol binario tiene una representación natural en el almacenamiento de listas ligadas. 6.3 Árboles de búsqueda binaria (ABB). Se trata de árboles de orden 2 en los que se cumple que para cada nodo, el valor del nodo raíz del subárbol izquierdo es menor que el valor del nodo raíz y que el valor del nodo raíz del subárbol derecho es mayor que el valor del nodo raíz. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 6.3.1 Operaciones en ABB. El conjunto de operaciones que se pueden realizar sobre un ABB es similar al que se realiza sobre otras estructuras de datos, más alguna otra propia de árboles: Buscar un elemento. Insertar un elemento. Eliminar un elemento. Movimientos a través del árbol: o Izquierda. o Derecha. o Raíz. Información: o Comprobar si un árbol está vacío. o Calcular el número de nodos. o Comprobar si el nodo es hoja. o Calcular la altura de un nodo. o Calcular la altura de un árbol. 6.3.1.1 Buscar un elemento. Partiendo siempre del nodo raíz, el modo de buscar un elemento se define de forma recursiva como: Si el árbol está vacío, terminamos la búsqueda: el elemento el árbol. Si el valor del nodo raíz es igual que el del elemento que terminamos la búsqueda con éxito. Si el valor del nodo raíz es mayor que el elemento que continuaremos la búsqueda en el árbol izquierdo. Si el valor del nodo raíz es menor que el elemento que continuaremos la búsqueda en el árbol derecho. Estructura de Datos. no está en buscamos, buscamos, buscamos, UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE El valor de retorno de una función de búsqueda en un ABB puede ser un puntero al nodo encontrado, o NULL, si no se ha encontrado. 6.3.1.2 Insertar un elemento. Para insertar un elemento nos basamos en el algoritmo de búsqueda. Si el elemento está en el árbol no lo insertaremos. Si no lo está, lo insertaremos a continuación del último nodo visitado. Para ello, se necesita un puntero auxiliar para conservar una referencia al padre del nodo raíz actual. El valor inicial para ese puntero es NULL. Padre = NULL nodo = Raiz Bucle: mientras actual no sea un árbol vacío o hasta que se encuentre el elemento. o Si el valor del nodo raíz es mayor que el elemento que buscamos, continuaremos la búsqueda en el árbol izquierdo: Padre=nodo, nodo=nodo->izquierdo. o Si el valor del nodo raíz es menor que el elemento que buscamos, continuaremos la búsqueda en el árbol derecho: Padre=nodo, nodo=nodo->derecho. Si nodo no es NULL, el elemento está en el árbol, por lo tanto salimos. Si Padre es NULL, el árbol estaba vacío, por lo tanto, el nuevo árbol sólo contendrá el nuevo elemento, que será la raíz del árbol. Si el elemento es menor que el Padre, entonces insertamos el nuevo elemento como un nuevo árbol izquierdo de Padre. Si el elemento es mayor que el Padre, entonces insertamos el nuevo elemento como un nuevo árbol derecho de Padre. Este modo de actuar asegura que el árbol sigue siendo ABB. 6.3.1.3 Eliminar un elemento. Para eliminar un elemento también nos basamos en el algoritmo de búsqueda. Si el elemento no está en el árbol no lo podremos borrar. Si está, hay dos casos posibles: 1. Se trata de un nodo hoja: en ese caso lo borraremos directamente. 2. Se trata de un nodo rama: en ese caso no podemos eliminarlo, puesto que perderíamos todos los elementos del árbol de que el nodo actual es padre. En su lugar buscamos el nodo más a la izquierda del subárbol derecho, o el más a la derecha del subárbol izquierdo e intercambiamos sus valores. A continuación eliminamos el nodo hoja. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Necesitamos un puntero auxiliar para conservar una referencia al padre del nodo raíz actual. El valor inicial para ese puntero es NULL. Padre = NULL Si el árbol está vacío: el elemento no está en el árbol, por lo tanto salimos sin eliminar ningún elemento. (*) Si el valor del nodo raíz es igual que el del elemento que buscamos, estamos ante uno de los siguientes casos: o El nodo raíz es un nodo hoja: Si 'Padre' es NULL, el nodo raíz es el único del árbol, por lo tanto el puntero al árbol debe ser NULL. Si raíz es la rama derecha de 'Padre', hacemos que esa rama apunte a NULL. Si raíz es la rama izquierda de 'Padre', hacemos que esa rama apunte a NULL. Eliminamos el nodo, y salimos. o El nodo no es un nodo hoja: Buscamos el 'nodo' más a la izquierda del árbol derecho de raíz o el más a la derecha del árbol izquierdo. Hay que tener en cuenta que puede que sólo exista uno de esos árboles. Al mismo tiempo, actualizamos 'Padre' para que apunte al padre de 'nodo'. Intercambiamos los elementos de los nodos raíz y 'nodo'. Borramos el nodo 'nodo'. Esto significa volver a (*), ya que puede suceder que 'nodo' no sea un nodo hoja. Si el valor del nodo raíz es mayor que el elemento que buscamos, continuaremos la búsqueda en el árbol izquierdo. Si el valor del nodo raíz es menor que el elemento que buscamos, continuaremos la búsqueda en el árbol derecho. 6.3.2 Ejemplos de eliminación en un ABB. Ejemplo 1: Eliminar un nodo hoja En el árbol de ejemplo, eliminar el nodo 3. 1. Localizamos el nodo a borrar, al tiempo que mantenemos un puntero a 'Padre'. 2. Hacemos que el puntero de 'Padre' que apuntaba a 'nodo', ahora apunte a NULL. 3. Borramos el 'nodo'. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Ejemplo 2: Eliminar un nodo rama con intercambio de un nodo hoja. En el árbol de ejemplo, eliminar el nodo 4. 1. Localizamos el nodo a eliminar (nodo raíz). 2. Buscamos el nodo más a la derecha del árbol izquierdo de 'raíz', en este caso el 3, al tiempo que mantenemos un puntero a 'Padre' a 'nodo'. 3. Intercambiamos los elementos 3 y 4. 4. Hacemos que el puntero de 'Padre' que apuntaba a 'nodo', ahora apunte a NULL. 5. Borramos el 'nodo'. Ejemplo 3: Eliminar un nodo rama con intercambio de un nodo rama. Para este ejemplo usaremos otro árbol. En éste borraremos el elemento 6. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE 1. Localizamos el nodo a eliminar (nodo raíz). 2. Buscamos el nodo más a la izquierda del árbol derecho de 'raíz', en este caso el 12, ya que el árbol derecho no tiene nodos a su izquierda, si optamos por la rama izquierda, estaremos en un caso análogo. Al mismo tiempo que mantenemos un puntero a 'Padre' a 'nodo'. 3. Intercambiamos los elementos 6 y 12. 4. Ahora tenemos que repetir el bucle para el nodo 6 de nuevo, ya que no podemos eliminarlo. 5. Localizamos de nuevo el nodo a eliminar (nodo raíz). 6. Buscamos el nodo más a la izquierda del árbol derecho de 'raíz', en este caso el 16, al mismo tiempo que mantenemos un puntero a 'Padre' a 'nodo'. 7. Intercambiamos los elementos 6 y 16. 8. Hacemos que el puntero de 'Padre' que apuntaba a 'nodo', ahora apunte a NULL. 9. Borramos el 'nodo'. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Este modo de actuar asegura que el árbol sigue siendo ABB. Movimientos a través de un árbol No hay mucho que contar. Nuestra estructura se referenciará siempre mediante un puntero al nodo Raíz, este puntero no debe perderse nunca. Para movernos a través del árbol usaremos punteros auxiliares, de modo que desde cualquier puntero los movimientos posibles serán: moverse al nodo raíz de la rama izquierda, moverse al nodo raíz de la rama derecha o moverse al nodo Raíz del árbol. Información Hay varios parámetros que podemos calcular o medir dentro de un árbol. Algunos de ellos nos darán idea de lo eficientemente que está organizado o el modo en que funciona. Comprobar si un árbol está vacío. Un árbol está vacío si su raíz es NULL. Calcular el número de nodos. Tenemos dos opciones para hacer esto, una es llevar siempre la cuenta de nodos en el árbol al mismo tiempo que se añaden o eliminan elementos. La otra es, sencillamente, contarlos. Para contar los nodos podemos recurrir a cualquiera de los tres modos de recorrer el árbol: inorden, preorden o postorden, como acción sencillamente incrementamos el contador. Comprobar si el nodo es hoja. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Esto es muy sencillo, basta con comprobar si tanto el árbol izquierdo como el derecho están vacíos. Si ambos lo están, se trata de un nodo hoja. Calcular la altura de un nodo. No hay un modo directo de hacer esto, ya que no nos es posible recorrer el árbol en la dirección de la raíz. De modo que tendremos que recurrir a otra técnica para calcular la altura. Lo que haremos es buscar el elemento del nodo de que queremos averiguar la altura. Cada vez que avancemos un nodo incrementamos la variable que contendrá la altura del nodo. Empezamos con el nodo raíz apuntando a Raiz, y la 'Altura' igual a cero. Si el valor del nodo raíz es igual que el del elemento que buscamos, terminamos la búsqueda y el valor de la altura es 'Altura'. Incrementamos 'Altura'. Si el valor del nodo raíz es mayor que el elemento que buscamos, continuaremos la búsqueda en el árbol izquierdo. Si el valor del nodo raíz es menor que el elemento que buscamos, continuaremos la búsqueda en el árbol derecho. Calcular la altura de un árbol. La altura del árbol es la altura del nodo de mayor altura. Para buscar este valor tendremos que recorrer todo el árbol, de nuevo es indiferente el tipo de recorrido que hagamos, cada vez que cambiemos de nivel incrementamos la variable que contiene la altura del nodo actual, cuando lleguemos a un nodo hoja compararemos su altura con la variable que contiene la altura del árbol si es mayor, actualizamos la altura del árbol. Iniciamos un recorrido del árbol en postorden, con la variable de altura igual a cero. Cada vez que empecemos a recorrer una nueva rama, incrementamos la altura para ese nodo. Después de procesar las dos ramas, verificamos si la altura del nodo es mayor que la variable que almacena la altura actual del árbol, si es así, actualizamos esa variable. 7.8 árboles degenerados Los árboles binarios de búsqueda tienen un gran inconveniente. Por ejemplo, supongamos que creamos un ABB a partir de una lista de valores ordenada: 2, 4, 5, 8, 9, 12 Difícilmente podremos llamar a la estructura resultante un árbol: Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE Esto es lo que llamamos un árbol binario de búsqueda degenerado, y en el siguiente capítulo veremos una nueva estructura, el árbol AVL, que resuelve este problema, generando árboles de búsqueda equilibrados. 6.4 Aplicaciones de árboles. Un ejemplo de estructura en árbol es el sistema de directorios y ficheros de un sistema operativo. Aunque en este caso se trata de árboles con nodos de dos tipos, nodos directorio y nodos archivo, podríamos considerar que los nodos hoja son archivos y los nodos rama son directorios. Otro ejemplo podría ser la tabla de contenido de un libro, por ejemplo de este mismo manual, dividido en capítulos, y cada uno de ellos en subcapítulos. Aunque el libro sea algo lineal, como una lista, en el que cada capítulo sigue al anterior, también es posible acceder a cualquier punto de él a través de la tabla de contenido. También se suelen organizar en forma de árbol los organigramas de mando en empresas o en el ejército, y los árboles genealógicos. Estructura de Datos. UTIM ESTRUCTURA DE DATOS I MANUAL DEL DOCENTE BIBLIOGRAFIA Como programar C++ Deitel & Deitel 4ª Ed. Pearson/Prentice Hall Desarrollo de algoritmos y sus aplicaciones en Basic, Cobol y C. Guillermo Correa Uribe. 3ª Ed. McGraw Hill. Estructuras de datos y algoritmos. Alfred V. Aho, John E. Hopcroft, Jeffrey D. Ullman Addison-Wesley Iberoamericana. Estructuras de datos. Algoritmos, abstracción y objetos. Luis Joyanes Aguilar, Ignacio Zahonero Martínez. McGraw Hill. Guia de Autoenseñanza C++ Herbert Schildt McGraw Hill. Manual de Estructuras dinámicas de datos. Salvador Pozo. Estructura de Datos. UTIM