Capítulo 2 Tipos abstractos de datos (TAD) Objetivo: Introducir al alumno las estructuras de datos mediante el concepto de tipo abstracto de datos. En este capítulo se presenta una primera implementación del TAD Conjunto y se irá mejorando a lo largo del mismo incluyendo un análisis de la complejidad de los métodos que lo forman. 2.1 Introducción Un tipo de datos es una abstracción que determina un conjunto de valores que deben tener los datos de cierto tipo. Por ejemplo en los lenguajes de programación se tienen el tipo entero, real y Booleano. A partir de eso se puede determinar, por ejemplo, que el 5 es un número entero, que true es un valor Booleano, etc. Además, los datos tienen ciertas operaciones y propiedades, por ejemplo sabemos que para números se tienen las operaciones aritméticas y para booleanos las operaciones lógicas. Entre las propiedades de estos datos se cuenta con que los datos forman conjuntos cerrados, por ejemplo una operación entre dos Booleanos devuelve un booleano. Al considerar tanto datos como operaciones se tiene un tipo abstracto de datos. Un tipo abstracto de datos (TAD) es un conjunto de datos con sus operaciones. Se denominan TAD porque nada en la definición indica el cómo se implementan las operaciones (por cierto, puede haber más de una implementación), por tanto en Java para definir TAD se recomienda usar interfaces. 1
2.2. EL TAD CONJUNTO 2 Para completar la descripción de un TAD se debe proporcionar además de la interfaz con las operaciones que se pueden realizar, una descripción de las propiedades que deben tener tales operaciones. Un mecanismo útil para describir estos aspectos no-sintácticos es el uso de pre/post condiciones. Una pre-condición es aquella propiedad que debe satisfacerse antes de la ejecución de un método. Un post-condición especifica propiedades que se deben esperar como consecuencia de la ejecución de un método. Por su parte, las estructuras de datos tradicionales son mecanismos útiles para almacenar y recuperar (en/de memoria) colecciones de datos. En Java representan una implementación de un TAD particular. 2.2 El TAD Conjunto Un conjunto puede verse como un tipo de datos donde se puede almacenar cualquier cantidad de datos sin importar el orden y sin posibilidad de repetición de valores. Las operaciones básicas con conjuntos son: almacenar un dato en él, eliminar un dato, verificar si contiene algún dato particular, determinar si el conjunto está vacío o su tamaño. Además podrían estar las operaciones clásicas de intersección, unión y diferencia. Una forma bonita de definir un TAD es mediante una interfaz, así que para los conjuntos se puede tener la siguiente: public interface Conjuntable { public void agregarelemento(object elem); public void eliminarelemento(object elem); public boolean contieneelemento (Object elem); public boolean estávacío(); public int tama~no();... //Operaciones clásicas con conjuntos. Quedan como ejercicio Propiedades que se esperan de un conjunto: agregarelemento. Si el elemento no existe en el conjunto lo agrega al conjunto, aunque no está definida la posición del nuevo elemento. Si el elemento ya se encuentra en el conjunto se dispara una excepción. En caso de ser exitosa la operación el tamaño del conjunto crece en una unidad.
2.2. EL TAD CONJUNTO 3 contieneelemento. Regresa true si el elemento está en el conjunto y false en otro caso. Este método no cambia el estado del conjunto. eliminarelemento. Si el elemento existe en el conjunto lo elimina, en caso contrario dispara la excepción NoSuchElementException. En caso de que la operación sea exitosa se reduce el tamaño del conjunto en una unidad. estávacio. Devuelve true si el conjunto está vacío y false en otro caso. tama~no. Devuelve un entero que indica la cantidad de elementos en el conjunto. La interfaz es independiente de la implementación. A continuación se presenta una implementación usando arreglos. /** * Programa que implementa el tipo de datos abstracto conjunto. * @author Amparo López Gaona * @version Agosto 2007. public class Conjunto implements Conjuntable { private Object[] datos; private int indicefinal; /** Constructor por omisión, crea un conjunto con una capacidad máxima de * 20 elementos public Conjunto() { datos = new Object[20]; indicefinal = 0; /** * Crea un arreglo con capacidad para un conjunto de un máximo determinado * por el usuario o 20 en caso de proporcionar un número negativo. * @param tam - entero positivo que determina el tama~no máximo del conjunto
2.2. EL TAD CONJUNTO 4 public Conjunto(int tam) { datos = (tam <= 0)? new Object [20] : new Object [tam] ; indicefinal = 0; /** * Método que determina si un conjunto no tiene elementos. * @return boolean - true si el conjunto está vacío y false en otro caso. public boolean estávacío(){ return (indicefinal == 0); /** * Método que determina la cantidad de elementos en el conjunto * @return int - cantidad de elementos en el conjunto. public int tama~no(){ return indicefinal; /** * Método que determina si un elemento dado está en el conjunto * @param elem - Objeto a buscar * @return boolean - true si el objeto se encuentra en el conjutno y * false en otro caso public boolean contieneelemento (Object elem){ if (!estávacío()) for (int i = 0; i < indicefinal; i++) if (elem.equals(datos[i])) return true; return false; /** * Método que permite borrar un elemento del conjunto * @param elem - Objeto a eliminar
2.2. EL TAD CONJUNTO 5 public void eliminarelemento(object elem){ int i = 0; boolean borrado = false; while (i < indicefinal &&! borrado) if (elem.equals(datos[i])){ for (int j = i; j < indicefinal -1; j++) datos[j] = datos[j+1]; indicefinal--; borrado = true; else i++; // recorre los elemntos del arreglo /** * Método que permite agregar un elemento del conjunto, si aún no está * en él * @param elem - Objeto a insertar en el conjunto. public void agregarelemento(object elem){ if (! contieneelemento(elem) && indicefinal < datos.length) datos[indicefinal++] = elem; 2.2.1 Análisis de algoritmos En general interesa conocer qué tan eficientes son los métodos programados, es decir estimar el tiempo requerido para ejecutar cada uno. Porque no es deseable ejecutar un programa que tarde un año en su ejecución o que requiera más memoria de la disponible. A primera vista parece sencillo medir la efectividad de un algoritmo en términos de tiempo, basta que al ejecutar el programa se tome el tiempo que tarda en correr. Ya sea con un reloj en mano o bien desde el programa. Esto no es muy deseable porque depende de la máquina, aún del compilador, en que se haya ejecutado el programa. El análisis de algoritmos es un técnica que permite caracterizar la ejecución de algoritmos de manera independiente de cualquier plataforma, com-
2.2. EL TAD CONJUNTO 6 pilador o lenguaje. En lugar de medir el tiempo de ejecución de un programa se mide el del algoritmo, el cual estará programado en uno o más métodos, así que la medida se basa en el examen del tiempo de ejecución para la invocación de un solo método. Al hacer más rápido un método se puede mejorar la velocidad de ejecución de un programa entero. Generalmente se calcula el tiempo de ejecución en el peor caso, porque el mejor caso no aporta nada y el caso promedio refleja el comportamiento típico. Así que con el peor caso se tiene un estimado de ejecución con cualquier entrada posible. Si el método para calcular el tamaño de un conjunto fuera el siguiente: for (int i = 0; i <datos.length; i++) if (datos[i] == null) return i; return datos.length; y el conjunto tuviera 100 elementos, sería necesario hacer 100 asignaciones y si cada una se realiza en un tiempo constante c, entonces el método tarda c 100 unidades de tiempo en realizarse. Si el tamaño del arreglo cambia a n, se requiere que se hagan n asignaciones. En este caso se dice que el tiempo de ejecución del método depende de la cantidad de datos de entrada, o más formalmente que se tiene un algoritmo de complejidad O(n) (O grande de n). En caso de que el método estuviera programado como sigue: public int tama~no(){ return indicefinal; En este caso con una sola instrucción se puede determinar el tamaño del conjunto. Este algoritmo es mucho mejor que el anterior, porque no depende de la cantidad de datos de entrada, por lo tanto toma un tiempo constante en su ejecución, así que se dice que es de orden O(1). En el análisis de la complejidad de un algoritmo no se incluyen constantes, es decir, no se dice O(2n 2 ) ni O(n 2 + n), en ambos casos la forma correcta es O(n 2 ). Por ejemplo, si se desea bajar un archivo de n Kbytes que está en Internet. Si se tarda 3 segundos en establecer la conexión, luego la carga se realiza a 1.5 Kb/seg, el tiempo requerido se describe como T (n) = n/1,5 + 3, por tanto se tiene una función lineal. Si n = 1500 entonces T(n) = 1003 y
2.2. EL TAD CONJUNTO 7 T(750) = 503, que es casi la mitad. Luego si se duplica el tiempo de inicio se tiene que T(1500) = 1006 y T(750) = 506, es decir sigue siendo casi la mitad sin importar la constante, por tanto se ignoran las constantes. Funciones comunes Las funciones más comunes para rango de crecimiento son: Función c log n log 2 n n n n log n n n nombre Constante Logarítmica Log-cuadrática Raíz cuadrada Lineal n 2 Cuadrática n 3 Cubica n d, d > 3 Polinomial 2 n Exponencial n! Factorial Ejercicio: Cuál es el tiempo de ejecución de una función de cada uno de los grados, si se tiene n = 10 5 datos de entrada, y se realizan 10 6 operaciones por segundo. Respuesta: Qué analizar? Función Nombre Tiempo de ejecución log n Logarítmica 1.2 *10 5 seg n Lineal 0.1 seg n log n 1.2 seg n 2 Cuadrática 2.8 horas n 3 Cubica 31.7 años 2 n Exponencial más de un siglo Si se tiene el siguiente método para calcular la suma de los primeros n números al cubo y se desea calcular su complejidad.
2.2. EL TAD CONJUNTO 8 public static int sumancubos(int n) { int suma; suma = 0; for (int i=1; i < n; i++) suma += i*i*i; return suma; El tiempo requerido para resolver las declaraciones no cuenta, las asignaciones toman una unidad de tiempo, el cuerpo del for toma cuatro (dos multiplicaciones, una suma y una asignación) por n veces que se ejecuta da un total de 4n unidades. El encabezado del for toma una unidad para la inicialización, n + 1 para las comparaciones y n para el incremento, lo cual da un total de 2n + 2. Sumado todo nos da un total de 6n + 4, es decir se tiene un método que orden O(n) o sea lineal. Para evitar hacer esos cálculos tan detallados se tienen las siguientes reglas generales: 1. Asignaciones, declaraciones. Para efectos de cálculos se asume que todas las operaciones sobre tipos primitivos, las asignaciones, las secuencias de asignaciones toman un tiempo constante. 2. Proposiciones consecutivas. Se suman. 3. Condicionales. Se toma el tiempo de la condición más el mayor de las dos alternativas. if (a > 0) { suma += a; datos++; else a*= (-1); 4. Ciclos. El tiempo de ejecución de un ciclo es el tiempo de ejecución de su cuerpo por el número de veces que se realiza. El caso más sencillo es cuando están fijos los valores de entrada y de salida y el cuerpo toma un tiempo constante. Ejemplo: for (int i=0; i < n; i++) if (valores[i] < mínimo) mínimo = valores[i];
2.2. EL TAD CONJUNTO 9 Este ciclo se realiza n veces y el cuerpo toma un tiempo constante cada vez, por tanto el tiempo de ejecución de este ciclo es O(n). Cuál es el tiempo de ejecución del siguiente ciclo? public boolean esprimo (int n) { for (int i=2; i*i <= n; i++) if (0 == n %i) return false; return true; Aquí el ciclo se hace mientras la i sea menor o igual a la raíz cuadrada de n, es decir el número máximo de iteraciones es n, como el cuerpo del ciclo toma un tiempo constante, se tiene que el algoritmo es O( n). Recordemos que estas medidas representan la ejecución en el peor caso. 5. Ciclos anidados. El tiempo de ejecución de una proposición dentro de un grupo de ciclos anidados es el tiempo de ejecución de la proposición multiplicado por el producto de los tamaños de todos los ciclos. Ejemplo: for (int i = 0; i <n ; i++) for (int j=0; j < n ; j++) k++; Este algoritmo es de orden n 2, porque cada ciclo se realiza n veces y el cuerpo es de orden uno. for (int i = 0; i <n ; i++) for (int j=0; j < n ; j++) { c[i][j] = 0; for (int k=0; k < n ; k++) c[i][j] += a[i][k] * b[k][j]; El tiempo de ejecución de este ciclo es O(n 3 ). Cuando los límites de las iteraciones están relacionados con los índices externos, el análisis se vuelve más complejo.
2.2. EL TAD CONJUNTO 10 for (int i = n-1; i > 0; i--) for (int j = 0; j < i; j++) if (v[j] > v[j+1]) { double temp = v[j]; v[j] = v[j+1]; v[j+1] = temp; Para determinar el tiempo de ejecución de este algoritmo notamos que la iteración interna se realiza n 1 veces, la segunda n 2 veces, etc. Así el ciclo interno se realiza (n 1) + (n 2) +.. + 1 = ((n 1)n)/2 = (n 2 n)/2 por tanto el algoritmo es O(n 2 ). 6. Llamadas a métodos. El tiempo se calcula como el necesario para realizar tal método. public void imprimeprimos(int n) { for (int i =2; i <=n; i++) if (esprimo(i)) System.out.println("valor "+ i + "es primo."); else System.out.println("valor "+ i + "no es primo."); El método esprimo tiene un tiempo de ejecución O( n) (como se vió antes) por tanto imprimeprimo es de orden O(n n) Cuando se suma complejidad algorítmica, el valor mayor es el que predomina y se conserva para el resultado. Por ejemplo O(n 2 )+O(n) = O(n 2 +n) = O(n 2 ). La tabla es la que da las prioridades. 2.2.2 Iteradores Es tarea común, recorrer una colección o estructura de datos para obtener todos los elementos de ella, ya sea para imprimirlos o bien realizar una operación sobre todos ellos (por ejemplo, incrementar el salario de todos los empleados, dar de alta un conjunto de alumnos en un curso particular, etc.) Para ello se usa, en general, un for.
2.2. EL TAD CONJUNTO 11 for (int i = 0; i < miarreglo.tama~no(); i++) { Object v = miarreglo.obtenerelemento(i);... Sólo que esta forma no es genérica pues depende de obtenerelemento y de que se sabe que en la implementación se utiliza un arreglo. Otra forma de hacerlo es implementando la interfaz Iterator del paquete java.util presentada a continuación: public interface Iterator { public boolean hasnext(); public Object next() throws NoSuchElementException; public void remove() throws IllegalStateException, NoSuchElementException; Así que normalmente las clases que implementan o tienen colecciones incluyen un método que devuelve un conjunto de elementos de una clase que implementa la interfaz Iterador. El objeto cliente no requiere conocer la organización de los datos. En general, se define como una clase interna, como se verá en el siguiente ejemplo. import java.util.*; public class Conjunto implements Conjuntable { // Misma estructura y métodos que antes más los métodos siguientes: public Iterator elementos () { return new datosindexados(); private class datosindexados implements Iterator { private int pos = 0; //Posición inicial public datosindexados(){ Constructor por omisión public boolean hasnext() { return (pos < nelems); // Determina si aún hay elementos en // el conjunto
2.3. ARREGLOS EXPANDIBLES 12 public Object next() throws NoSuchElementException { if (hasnext()) // Devuelve el siguiente elemento en el conjunto return datos[pos++]; throw new NoSuchElementException(); public void remove() throws IllegalStateException, NoSuchElementException { throw new NoSuchElementException(); public static void main( String[] xx) {... System.out.println("Voy a imprimir: "); for (Iterator it = c.elementos(); it.hasnext();) System.out.print((Integer) it.next() + " "); Definir una clase dentro de otra permite establecer que hay una estrecha relación entre ambas clases. Una clase interna tiene acceso, sin calificativo, a todos los métodos y estructura del objeto que la encierra (aún los privados). Se recomienda que la clase interna sea muy sencilla. La clase externa proporciona la mayoría de la funcionalidad y la interna, sólo funcionalidad asociada a la externa pero subordinada a ella. 2.3 Arreglos expandibles El arreglo es la única estructura de datos que proporciona Java. La ventaja de los arreglos es que toma el mismo tiempo acceder a cualquier elemento de él, sin embargo no pueden crecer, es costoso insertar o suprimir elementos de una colección almacenada en un arreglo sin dejar huecos. Es decir, aunque son sumamente útiles no son apropiados para todos los problemas de almacenamiento de datos.
2.3. ARREGLOS EXPANDIBLES 13 interface ArregloExpandible { public boolean estávacio (); public int tama~no (); public void agregarelemento(object val, int pos); public Object obtenerelemento(int pos); public void eliminarelemento(int pos); public void reemplazarelemento(object v, int pos); public void ajustartama~no(int nuevotama~no) ; La forma de trabajar de los objetos de la clase que implemente la interfaz ArregloExpandible es la siguiente: estávacio. Devuelve true si el arreglo no tiene elementos y false en otro caso. tama~no. Devuelve un entero que indica la cantidad de elementos almacenados en el arreglo. agregarelemento. Agrega un elemento al arreglo en la posición indicada (ésta debe tener un valor menor o igual al tamaño del arreglo). Para insertar un elemento, se hace un espacio en el arreglo recorriendo los elementos hacia le final del arreglo. Si el arreglo está lleno, se aumenta su capacidad. El tamaño del arreglo se incrementa en una unidad. Notar que en este arreglo se tienen dos conceptos, tama~no que es la cantidad de elementos en él y capacidad que es la cantidad de localidades que contiene físicamente el arreglo. Este último dato lo desconoce el usuario. tamano() 0 1 2 9 Capacidad (datos.length) datos Figura 2.1: Arreglo expandible
2.3. ARREGLOS EXPANDIBLES 14 obtenerelemento. Devuelve el elemento almacenado en la posición indicada, siempre y cuando sea ésta sea válida. Si la posición es inválida se dispara la excepción ArrayIndexOutOfBoundsException. Este método no cambia el estado del objeto. eliminarelemento. Elimina el elemento almacenado en la posición indicada, siempre y cuando sea ésta sea válida. Si la posición es inválida se dispara la excepción ArrayIndexOutOfBoundsException. En caso de que la operación sea exitosa se reduce el tamaño del arreglo en una unidad. reemplazarelemento. Sustituye el elemento almacenado en la posición indicada por el elemento indicado. Si la posición es invalida dispara la excepción. ArrayIndexOutOfBoundsException. El método no cambia el tamaño del arreglo. ajustartama~no. Aumenta la capacidad del arreglo en el número de unidades indicado. Este método no altera la cantidad de elementos almacenados en el arreglo.