Fundamentos y Estructuras de Programación

Anuncio
Fundamentos y Estructuras de Programación
Gerardo M. Sarria M.
Borrador de 21 de julio de 2011
Índice general
1. Introducción
9
2. Noción de Problema
2.1. Comienzos . . . . . . . . . . . . . .
2.1.1. Cálculo Lambda . . . . . .
2.1.2. Máquina de Turing . . . . .
2.2. Problemas Tratables e Intratables .
2.3. Solución de Problemas . . . . . . .
2.4. Estrategias de Implementación . .
3. Noción de Lenguaje
3.1. Historia . . . . . . . . . . . .
3.2. Estructura . . . . . . . . . . .
3.3. Compiladores . . . . . . . . .
3.4. Máquinas Virtuales . . . . . .
3.5. Depuración . . . . . . . . . .
3.6. Excepciones . . . . . . . . . .
3.7. Interfaces Gráficas de Usuario
3.8. Referencias y Apuntadores . .
3.9. Declaraciones y Tipos . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
11
11
12
14
16
26
31
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
35
35
38
41
44
46
52
57
60
66
4. Noción de Tipo Abstracto de Datos
4.1. Tipos Abstractos de Datos . . . . . . . . . . . . . . . . .
4.2. Listas . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.2.1. Diseño . . . . . . . . . . . . . . . . . . . . . . . .
4.2.2. Implementaciones . . . . . . . . . . . . . . . . .
4.2.3. Análisis de Complejidad de las Implementaciones
4.2.4. Utilización . . . . . . . . . . . . . . . . . . . . .
4.2.5. Variantes . . . . . . . . . . . . . . . . . . . . . .
4.3. Pilas . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.3.1. Diseño . . . . . . . . . . . . . . . . . . . . . . . .
4.3.2. Implementaciones . . . . . . . . . . . . . . . . .
4.3.3. Utilización . . . . . . . . . . . . . . . . . . . . .
4.4. Colas . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.5. Tablas Hash . . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
73
73
81
82
83
96
96
99
100
102
104
104
104
104
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
(Eventos)
. . . . . .
. . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
3
Índice general
4.6. Árboles Binarios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104
4.7. Árboles N-arios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104
4.8. Grafos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104
Índice de nociones
4
105
Índice de figuras
2.1. Máquina de Turing . . . . . . . . .
2.2. Estado inicial del sudoku . . . . .
2.3. Estado final del sudoku . . . . . .
2.4. Búsqueda lineal . . . . . . . . . . .
2.5. Búsqueda lineal bidireccional . . .
2.6. Un ejemplo de búsqueda binaria .
2.7. Búsqueda con tabla hash . . . . . .
2.8. Ventana de una Calculadora . . . .
2.9. Creación de funciones en el modelo
2.10. Creación de funciones en el modelo
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
top-down .
bottom-up
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
14
26
27
28
28
29
30
32
33
33
37
40
41
42
43
45
46
49
50
69
70
3.1. Evolución de los lenguajes de alto nivel . .
3.2. Análisis para la asignación . . . . . . . . . .
3.3. Componentes superficiales de un compilador
3.4. Componentes intermedios de un compilador
3.5. Ejemplo de las fases de compilación . . . . .
3.6. Jerarquı́a de las máquinas virtuales . . . . .
3.7. Primer bug encontrado . . . . . . . . . . . .
3.8. Data Display Debugger . . . . . . . . . . .
3.9. Editor Eclipse . . . . . . . . . . . . . . . . .
3.10. Diagrama de contorno . . . . . . . . . . . .
3.11. Diagrama de contorno . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
4.1.
4.2.
4.3.
4.4.
4.5.
4.6.
4.7.
4.8.
4.9.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. 83
. 84
. 88
. 88
. 89
. 90
. 91
. 93
. 101
Nodo con encadenamiento simple . . .
Lista con encadenamiento simple . . .
Nodo con doble encadenamiento . . .
Lista con doble encadenamiento . . . .
Lista circular encadenada simple . . .
Lista circular doblemente encadenada
Lista implementada con un vector . .
Lista implementada con cursores . . .
Ejemplo real de una pila . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
5
Índice de algoritmos
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
Imprime los números del 1 al 100 . . . . . . . . . . . . . . . . . . . . . . . .
Imprime los números del 1 al n . . . . . . . . . . . . . . . . . . . . . . . . . .
Imprime los números del n al 1 dividiendo por dos cada vez . . . . . . . . . .
Suma los elementos impares de un vector de enteros . . . . . . . . . . . . . .
Ordenamiento por el método burbuja . . . . . . . . . . . . . . . . . . . . . .
Descubre si el procesador tiene error . . . . . . . . . . . . . . . . . . . . . . .
Divide el número 10 entre un número dado por el usuario en C++ . . . . . .
Divide el número 10 entre un número dado por el usuario, usando un condicional en C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Divide el número 10 entre un número dado por el usuario, usando una aserción
en C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Divide el número 10 entre un número dado por el usuario, usando un manejador de excepciones en C++ . . . . . . . . . . . . . . . . . . . . . . . . . . .
Divide el número 10 entre un número dado por el usuario, usando dos manejadores de excepciones en C++ . . . . . . . . . . . . . . . . . . . . . . . . . .
Divide el número 10 entre un número dado por el usuario, garantizando la
continuación, alternativa 1 en C++ . . . . . . . . . . . . . . . . . . . . . . .
Divide el número 10 entre un número dado por el usuario, garantizando la
continuación, alternativa 2 en C++ . . . . . . . . . . . . . . . . . . . . . . .
Imprime ’HolaMundo!’ en una ventana usando Gtk+ . . . . . . . . . . . . .
Imprime ’HolaMundo!’ por lı́nea de comandos usando curses . . . . . . . . .
Asigna cuatro variables en Python (análisis de referencias) . . . . . . . . . .
Declara, asigna e imprime un entero y un apuntador a entero en C++ . . . .
Asigna tres variables en Python (análisis de tipos) . . . . . . . . . . . . . . .
Asigna tres variables en C++ (análisis de tipos) . . . . . . . . . . . . . . . .
Cambio de tipo de una variable en Python . . . . . . . . . . . . . . . . . . .
Definición de dos bloques de ejecución en Pascal . . . . . . . . . . . . . . . .
Definición de dos funciones que comparten una variable en Python . . . . . .
Asignación simple en Python . . . . . . . . . . . . . . . . . . . . . . . . . . .
Dos identificadores con la misma referencia en una función en Python . . . .
17
18
19
21
22
48
53
53
54
54
55
56
57
59
59
60
66
67
67
68
70
71
72
72
7
1 Introducción
9
2 Noción de Problema
Un problema existe cuando el estado en el que se encuentran las cosas difiere del estado
en que se desea que estén. La solución al problema es una serie de pasos que llevan del
estado en que están al estado que se desean.
Existen muchos problemas en el mundo (más de los que el hombre puede resolver).
Una gran cantidad de dichos problemas pueden resolverse usando el computador como
herramienta.
En este capı́tulo se mostrará cómo identificar problemas que son solucionables por medio
del computador y estrategias para resolverlos.
2.1.
Comienzos
El estudio de los problemas que se pueden resolver por medios computacionales tiene sus
comienzos en la década de 1930, cuando D. Hilbert pretendı́a crear un sistema matemático
formal completo y consistente, en el que todos los problemas pudieran plantearse con
precisión. Además deseaba encontrar un algoritmo para determinar si una proposición,
dentro del sistema, era verdadera o falsa. Con este sistema cualquier problema bien definido
se resolverı́a aplicando dicho algoritmo.
Después de varias investigaciones K. Gödel demostró que el sistema planteado por Hilbert
no era posible construirlo. Para ello publicó el famoso Teorema de Incompletitud1 .
Unos años después se mostró que existı́an problemas que eran indecidibles , es decir,
no hay algoritmos que resuelvan dichos problemas (A. Church y A. Turing probaron que
el problema de Hilbert era indecidible). Aquı́ los problemas se dividieron en dos tipos:
tratables e intratables.
Los estudios teóricos de los problemas siguieron cuando Church introdujo a las matemáticas una notación formal para las funciones calculables, que denominó cálculo lambda. La
idea era transformar todas las fórmulas matemáticas a una forma estándar, de tal manera
que la demostración de teoremas se convertirı́a en la transformación de cadenas de sı́mbolos
siguiendo un conjunto de reglas como en un sistema lógico (véase [21]).
Por otro lado, Turing argumentó que el problema de Hilbert podı́a ser atacado con la
ayuda de una máquina. La Máquina de Turing podı́a ser usada por una persona para
ejecutar un procedimiento bien definido, mediante el cambio del contenido de una cinta
1
Problema
Teorema de incompletitud : Ningún sistema deductivo que contenga los teoremas de la aritmética, y con
los axiomas recursivamente enumerables puede ser consistente y completo a la vez.
11
Indecidibilidad
2 Noción de Problema
ilimitada, dividida en cuadros que pueden contener un solo sı́mbolo de un conjunto dado
(el alfabeto).
A continuación se mostrará un poco más en detalle los dos estudios anteriores.
2.1.1.
Cálculo λ
Cálculo Lambda
El cálculo lambda es un formalismo para especificar funciones que calculan valores a
partir de sus argumentos. Las funciones se definen con el sı́mbolo λ seguido de su argumento (funciones con múltiples argumentos pueden verse como la aplicación de funciones
a funciones).
La notación λx.P muestra una función cuyo cuerpo es P y cuyo argumento es x, de
manera que la aplicación de esta función con un argumento n se reduce a sustituir x por
n en P . Lo anterior quiere decir que si se tiene
λx.f x
y se aplica con el argumento n, se tendrá
(λx.f x)n = f n
La sintaxis abstracta del cálculo puede verse en el cuadro 2.1. Se tienen solo variables,
términos lambda aplicados y abstracciones de términos.
M ::= x
| M1 M2
| λx.M
(variables)
(aplicación)
(abstracción)
Cuadro 2.1: Sintaxis del cálculo lambda
Con el cálculo lambda, las funciones calculables (i.e. la idea de computabilidad) pueden
ser expresadas [16]. En el ejemplo 2.1.1 se muestra la función suma de números naturales
en el cálculo lambda.
Ejemplo 2.1.1
Supongamos que queremos usar el cálculo lambda para sumar números naturales, es decir,
saber si la suma de números naturales es una función computable (i.e. puede ser implementada en un computador). Lo primero que debemos hacer es representar los números
en este cálculo ya que como se vió en el cuadro 2.1 no hay números, pero ¿cómo crear
una representación, dentro de un sistema que solo soporta sı́mbolos (y no números), que
permita contar, sumar, multiplicar y hacer todo lo que se puede hacer con números?
12
2.1 Comienzos
La idea es crear una representación funcional lo más cercana posible a los números
naturales. Representar el número cero y crear una función sucesor para encontrar los demás
números, es básico.
Los números naturales en el cálculo lambda pueden ser representados como una función
con dos parámetros (Números de Church):
λf.λx.halgoi
El primer parámetro f , es la función sucesor. El segundo parámetro, x, es el valor que
representa el cero. De allı́ que el 0 sea representado como:
0 ≡ λf.λx.x
Cuando la función anterior es aplicada, siempre retornará el valor que representa el cero.
El número de Church para el uno aplica la función sucesor al valor que representa el cero,
exactamente una vez:
1 ≡ λf.λx.f x
Los números de Church siguentes se encuentran aplicando la función sucesor más veces:
2
3
4
5
≡
≡
≡
≡
..
.
λf.λx.f (f x)
λf.λx.f (f (f x))
λf.λx.f (f (f (f x)))
λf.λx.f (f (f (f (f x))))
n ≡ λf.λx.f n x
Para representar la suma se tomará la aproximación más sencilla: sumar dos números n
y m, es tomar m y sumarle uno (1) n veces, es decir, encontrar el n-avo sucesor de m; ası́,
3 + 5 es hallar el 3er sucesor de 5. Si el conjunto de sucesores de 5 es {6, 7, 8, 9, . . .}, el
tercer sucesor es 8.
De manera más formal, y siguiendo con la idea de que solo se puede representar el cero, y
los demás números se trabajan como sucesores del cero. La función sucesor podrı́a definirse
ası́:
S ≡ λn.λf.λx.f (nf x)
De esta manera el número 2 surge de aplicar la función sucesor al número 1.
S 1
≡
→
→
→
≡
(λn.λf.λx.f (nf x)) (λf.λx.f x)
λf.λx.f ((λz.λw.zw)f x)
λf.λx.f ((λw.f w)x)
λf.λx.f (f x)
2
13
2 Noción de Problema
Entonces la suma se representarı́a de esta manera:
+ ≡ λn.λmλf.λx.f n (f m x)
+ n m ≡ λf.λx.nf (mf x)
Ası́, la suma de 2 + 1 será:
+ 2 1
≡
→
→
→
λf.λx.(λf2 .λx2 .f2 (f2 x)) f ((λf1 .λx1 .f1 x) f x)
λf.λx.f (f ((λf1 .λx1 .f1 x) f x)
λf.λx.f (f (f x)
3
? ? ?
2.1.2.
Máquina de Turing
Máquina de Turing
Una máquina de Turing es un concepto abstracto creado para demostrar las limitaciones
de la computación. Ella trabaja por medio de estados, en el que cada estado es un paso
donde se realiza una acción. Aunque la máquina de Turing es un concepto abstracto, en la
figura 2.12 puede verse una representación de ella.
Máquina
Cabeza
Símbolos
Cinta
Figura 2.1: Máquina de Turing
2
Para comprender mejor el concepto de la máquina de Turing se ha creado un software llamado JFLAP
que puede descargarse gratis de http://www.jflap.org/
14
2.1 Comienzos
Una máquina de Turing está conformada por:
1. Un cinta infinita donde serán escritos o leı́dos los sı́mbolos del alfabeto.
2. Una cabeza para realizar la lectura y la escritura.
3. Un tabla de acciones que muestra las posibles transiciones que se pueden realizar.
4. Un registro de estados que almacena lo que ha pasado en la máquina.
En el ejemplo 2.1.2 se muestra una máquina de Turing.
Ejemplo 2.1.2
Se tiene un alfabeto {0, 1 y #}, siendo # el sı́mbolo de espacio.
La cinta de la máquina arranca ası́ (la cabeza está ubicada en el elemento subrayado):
#
#
1
1
1
#
es decir, arranca con el número 7 en binario, y se espera que se forme el número 10 en
binario, es decir, el número 1010.
El conjunto de estados es {s1, s2}, y el estado inicial es s1. La tabla de acciones es la
siguiente:
Estado
Actual
-----s1
s1
s1
s2
s2
s2
Simbolo
Leido
------#
0
1
#
0
1
->
->
->
->
->
->
Simbolo
a Escribir
----------1
1
0
#
0
1
Movimiento
---------Der.
Der.
Izq.
Izq.
Der.
Der.
Nuevo
Estado
-----s2
s2
s1
s1
s2
s2
El cómputo de esta máquina de Turing podrı́a resumirse en el siguiente registro de estados
(la cabeza está ubicada en la posición del sı́mbolo subrayado):
15
2 Noción de Problema
Paso
---1
2
3
4
5
6
7
8
9
10
11
12
13
Estado Cinta
------ ----s1
##111#
s1
##110#
s1
##100#
s1
##000#
s2
#1000#
s2
#1000#
s2
#1000#
s2
#1000#
s1
#1000#
s2
#1001#
s1
#1001#
s1
#1000#
s2
#1010#
-- para -? ? ?
Turing usó su máquina para demostrar que existen funciones que no son calculables por
medio de métodos definidos y en particular, que el problema de Hilbert era uno de esos
problemas. Además, demostró la equivalencia entre lo que se podı́a calcular mediante una
máquina de Turing y lo que se podı́a calcular con un sistema formal en general.
El resultado de las investigaciones de Church-Turing arrojó la existencia de algoritmos
que con determinadas entradas nunca terminan (funciones totales) y ha servido como punto
de partida para la investigación de los problemas que se pueden resolver mediante un
algoritmo.
2.2.
Tratabilidad
Complejidad
Problemas Tratables e Intratables
Los problemas denominados intratables son aquellos que, con entradas grandes, no pueden ser resueltos por ningún computador, no importa lo rápido que sea, cuanta memoria
tenga o cuanto tiempo se le de para que lo resuelva. Lo anterior sucede debido a que
los algoritmos que existen para solucionar estos problemas tienen una complejidad muy
grande.
La complejidad de un algoritmo mide el grado u orden de crecimiento que tiene el tiempo
de ejecución del algoritmo dado el tamaño de la entrada que tenga.
Existen dos maneras rápidas de hallar la complejidad de un algoritmo (métodos más
profundos, formales y detallados pueden verse en [9]):
1. por conteo, ó
16
2.2 Problemas Tratables e Intratables
2. por inspección o tanteo.
Para encontrar la complejidad de un algoritmo por conteo se debe tomar cada lı́nea de
código y determinar cuántas veces se ejecuta. Luego se suman las cantidades encontradas
y la complejidad será del orden del resultado dado. Esta complejidad es una aproximación
de cuánto se demorarı́a todo el algoritmo en ejecutarse.
Ejemplo 2.2.1
Un primer ejemplo sencillo es el algoritmo 1 para imprimir los 100 primeros números
naturales.
void imprime100()
{
int i = 1;
while(i <= 100)
{
printf("%d",i);
i++;
}
}
Algoritmo 1: Imprime los números del 1 al 100
El conteo de lı́neas se puede realizar utilizando una tabla donde se numeren las lı́neas de
código y se determine el número de veces que se ejecuta cada una:
Número
de lı́nea
1
2
3
4
5
6
7
8
9
Lı́nea de código
void imprime100()
{
int i = 1;
while(i <= 100)
{
printf("%d",i);
i++;
}
}
Número de
ejecuciones
1
101
100
100
La lı́nea 3 se ejecuta una sola vez. La guarda del while (lı́nea 4) se ejecuta 101 veces
debido a que verifica las 100 veces que se imprime el número más una vez adicional donde
se determina que el ciclo ha terminado. Las lı́neas internas del ciclo se ejecutan 100 veces.
La suma de las cantidades encontradas es3 :
3
La lı́nea 1 no se tiene en cuenta ya que corresponde a los datos de referencia de la función (tipo de
17
2 Noción de Problema
Numero Total de Ejecuciones = 1 + 101 + 100 + 100 = 302
De allı́ que la complejidad del algoritmo es del orden de O(302). Por ser 302 una constante, la complejidad se puede aproximar a O(1), esto es, afirmar que es una complejidad
constante.
? ? ?
Ejemplo 2.2.2
El ejemplo anterior se puede generalizar modificando la función de manera que tenga como
parámetro la cantidad de números naturales a imprimir, es decir, hasta qué número natural
se quiere escribir. Este nueva función se puede ver en el algoritmo 2.
void imprimeN(int n)
{
int i = 1;
while(i <= n)
{
printf("%d",i);
i++;
}
}
Algoritmo 2: Imprime los números del 1 al n
Se numeran las lı́neas y se procede a contabilizar.
Número
de lı́nea
1
2
3
4
5
6
7
8
9
Lı́nea de código
void imprimeN(int n)
{
int i = 1;
while(i <= n)
{
printf("%d",i);
i++;
}
}
Número de
ejecuciones
1
n+1
n
n
Al igual que el ejemplo 2.2.1, la lı́nea 3 se ejecuta una sola vez, la guarda del while (lı́nea
4) se ejecuta n + 1 veces y las lı́neas internas del ciclo (lı́neas 6 y 7) se ejecutan n veces.
retorno, nombre y parámetros formales). Las lı́neas 2, 5, 8 y 9 tampoco se tienen en cuenta ya que son
simplmente delimitadores de bloque.
18
2.2 Problemas Tratables e Intratables
Ahora la suma de las cantidades encontradas es:
Numero Total de Ejecuciones = 1 + (n + 1) + n + n = 3n + 2
En el caso en que n fuera un número extremadamente grande, se puede ver que
3n + 2 ≈ n
De esta manera la complejidad del algoritmo anterior es del orden de n, es decir, es O(n).
? ? ?
Ejemplo 2.2.3
Otro ejemplo cuyo código es muy simple pero su análisis es de cuidado es el algoritmo 3.
void imprime_mitad(int n)
{
int i = n;
while(i >= 0)
{
printf("%d",i);
i = i / 2;
}
}
Algoritmo 3: Imprime los números del n al 1 dividiendo por dos cada vez
En primera instancia se podrı́a decir que el programa deberı́a imprimir los números desde
n hasta 0, pero dentro del ciclo el contador i se divide entre 2. Entonces en realidad se
imprimirán los números n, n/2, n/4, n/8, . . .. De allı́ que el número de veces que se ejecutan
las instrucciones dentro del ciclo van disminuyendo exponencialmente:
En la iteración 1 se disminuye en 2.
En la iteración 2 se disminuye en 4, es decir, 22 .
En la iteración 3 se disminuye en 8, es decir, 23 .
En la iteración 4 se disminuye en 16, es decir, 24 .
..
.
En la iteración k se disminuye en 2k .
Como se necesita saber cuántas veces se repite el ciclo y el contador cambia su valor
desde 0 a n, entonces se requiere llegar al punto donde n = 2k , siendo k el número que
indica en qué iteración está la ejecución del algoritmo.
19
2 Noción de Problema
Para hallar el valor de k, se utilizan las propiedades de los logaritmos:
n = 2k
log2 n = log2 2k
log2 n = k
Luego el número de iteraciones que se realizan es log2 n.
Número
de lı́nea
1
2
3
4
5
6
7
8
9
Lı́nea de código
void imprime_mitad(int n):
{
int i = n;
while(i >= 0)
{
cout << i;
i = i / 2;
}
}
Número de
ejecuciones
1
log2 n + 1
log2 n
log2 n
La suma de las cantidades encontradas es:
Numero Total de Ejecuciones = 1 + (log2 n + 1) + log2 n + log2 n
= 2 + 3log2 n
≈ log2 n
Por lo tanto, la complejidad del algoritmo es O(log2 n), que normalmente se expresa como
O(log n).
? ? ?
Ejemplo 2.2.4
Cuando analizamos algoritmos con condicionales hay que tener en cuenta que el conteo
se hace considerando si las guardas de los condicionales se cumplen o no. La complejidad
en estos algoritmos se halla en el peor de los casos (cuando se asume que las guardas de
los condicionales siempre se cumplen), el caso promedio (cuando se asume que las guardas
algunas veces se cumplen y otras veces no) y el mejor de los casos (cuando se asume que
las guardas no se cumplen).
20
2.2 Problemas Tratables e Intratables
El algoritmo 4 suma los elementos impares de un vector de enteros.
int sumaVector(int *v, int n)
{
int i = 0;
int sum = 0;
while(i < n)
{
if(v[i] % 2 != 0)
sum = sum + v[i];
i++;
}
return sum;
}
Algoritmo 4: Suma los elementos impares de un vector de enteros
Se numeran las lı́neas y se procede a contabilizar.
Número
de lı́nea
1
2
3
4
5
6
7
8
9
10
11
12
Lı́nea de código
int sumaVector(int *v, int n)
{
int i = 0;
int sum = 0;
while(i < n)
{
if(v[i] % 2 != 0)
sum = sum + v[i];
i++;
}
return sum;
}
Número de
ejecuciones
1
1
n+1
n
?
n
La cantidad de veces que se ejecuta la lı́nea 8 es indefinida debido a que depende de si
la guarda del condicional es verdadera o falsa. Por esta razón tenemos que analizar esta
situación desde los tres casos:
En el mejor de los casos ningún elemento del vector es impar por lo que la lı́nea 8 no
se ejecutarı́a nunca.
En el caso promedio, aproximadamente la mitad de los elementos será impar y la
otra mitad par. En este caso la lı́nea se ejecutarı́a n/2 veces.
21
2 Noción de Problema
En el peor de los casos todos los elementos del vector son impares por lo que siempre
que se ejecute la lı́nea 7 se ejecutará la lı́nea 8. Luego esta lı́nea se ejecutará n veces.
La suma de las cantidades encontradas es entonces:
En el mejor de los casos:
Numero Total de Ejecuciones = 1 + 1 + (n + 1) + n + 0 + n
= 3 + 3n
En el caso promedio:
Numero Total de Ejecuciones = 1 + 1 + (n + 1) + n + n/2 + n
= 3 + 7(n/2)
En el peor de los casos:
Numero Total de Ejecuciones = 1 + 1 + (n + 1) + n + n + n
= 3 + 4n
Por lo tanto, la complejidad del algoritmo es, en este algoritmo particular, O(n).
? ? ?
Ejemplo 2.2.5
Otro ejemplo, un poco más complejo, es un algoritmo de ordenamiento de un vector de
enteros:
void burbuja(int *v, int n)
{
int i = 0;
while(i < n)
{
int j = i + 1;
while(j < n)
{
if(v[i] > v[j])
{
int temp = v[i];
v[i] = v[j];
v[j] = temp;
}
j++;
}
i++;
}
}
Algoritmo 5: Ordenamiento por el método burbuja
22
2.2 Problemas Tratables e Intratables
Se numeran las lı́neas y se procede a contabilizar.
Número
de lı́nea
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Lı́nea de código
void burbuja(int *v, int n)
{
int i = 0;
while(i < n)
{
int j = i+1;
while(j < n)
{
if(v[i] > v[j])
{
int temp = v[i];
v[i] = v[j];
v[j] = temp;
}
j++;
}
i++;
}
}
Número de
ejecuciones
1
n+1
Pnn
k=1 k
P
( nk=1 k) − n
P
(Pnk=1 k) − n
(Pnk=1 k) − n
( nk=1 k) − n
P
( nk=1 k) − n
n
La lı́nea 3 se ejecutará una sola vez. Solamente asigna el valor 0 a la variable i.
Si el tamaño del vector es n, entonces la guarda del ciclo externo (lı́nea 4) va ejecutarse
n + 1 veces, ya que la variable i comienza el ciclo con el valor 0 y se incrementa en 1 cada
iteración hasta que llegue al valor n, cuando se termina el ciclo. Sin embargo las lı́neas 6
y 17 se ejecutan n veces, es decir, uno menos que la lı́nea 4. Lo anterior debido a que se
debe verificar la guarda del ciclo una vez adicional para saber que ya no debe entrar más
al ciclo.
La guarda del ciclo interno (lı́nea 7) se ejecutará un número de veces que depende de
la variable i, que en el último ciclo tendrá el valor del tamaño del vector menos uno. Más
en detalle, en la primera iteración del ciclo externo la variable i es igual a 0, por lo que
la guarda del ciclo interno se ejecuta n veces; en la segunda iteración del ciclo externo la
variable i es igual a 1, por lo que la lı́nea 7 se ejecuta n − 1 veces; ası́ sucesı́vamente hasta
la última iteración del ciclo externo, donde la variable i es igual a n − 1 y la guarda del
ciclo interno se ejecuta 1 vez. Todo esto da como resultado una sumatoria del número de
ejecuciones de la lı́nea 7, desde 1 hasta n.
Si consideramos el peor de los casos, al igual que pasó con la lı́nea 6, la guarda del if
y las asignaciones internas (lı́neas 11–13) se ejecutan una vez menos que la guarda el ciclo
23
2 Noción de Problema
interno, es decir, la sumatoria del número de ejecuciones desde 1 hasta n menos n (en cada
iteración dichas lı́neas se ejecutan 1 vez menos y en total son n iteraciones).
Por teorı́a matemática, se tiene que
n
X
i=1
i=
n × (n + 1)
2
De allı́ que es posible expresar el número de ejecuciones sólo en términos de n:
Numero Total de Ejecuciones = (1) + (n + 1) + (n) + n×(n+1)
+ ...
2
− n + (n)
. . . + 5 n×(n+1)
2
= 2 + n + 3n2
≈ n2
Y ası́ se puede decir que la complejidad del algoritmo burbuja es del orden de n2 , es
decir, es O(n2 ).
? ? ?
Por otro lado, hallar la complejidad por inspección o tanteo, es más rápida pero imprecisa
y, si no se cuenta con la suficiente experiencia, poco confiable.
Simplemente se mira la estructura del algoritmo y se siguen las tres siguientes reglas:
1. La complejidad de una asignación es O(1).
2. La complejidad de un condicional es 1 más el máximo entre la complejidad del cuerpo
del condicional cuando la guarda es positiva y el cuerpo del condicional cuando la
guarda es negativa.
3. La complejidad de un ciclo es el número de veces que se ejecuta el ciclo multiplicado
por la complejidad del cuerpo del ciclo.
En el algoritmo 5 (burbuja) se puede observar que el ciclo externo tiene n iteraciones
(siendo n el tamaño del vector), el ciclo interno, en la primera iteración del ciclo externo,
tiene n iteraciones, y el condicional tiene como cuerpo tres asignaciones. Por todo esto, la
complejidad del algoritmo serı́a
O(1 + n × (2 + n × ((1 + 3) + 1))) = O(1 + 2n + 5n2 ) ≈ O(n2 ).
Ahora, si las asignaciones internas y la condición del if (las lı́neas 5-8 juntas) tomaran un
segundo en ejecutarse, en el peor de los casos (cuando siempre se ejecute lo que está dentro
del if) se tendrı́a:
24
2.2 Problemas Tratables e Intratables
Tamaño del vector
10
20
50
100
1000
Tiempo de ejecución (seg.)
100
400
2500
10000
1000000
El cuadro anterior muestra que un algoritmo con complejidad O(n2 ) puede ser rápido
para tamaños de entrada pequeños, pero a medida que crece la entrada, se va volviendo
ineficiente.
En el cuadro 2.2 se muestran algunos ejemplos de complejidades que pueden encontrarse
en los análisis de algoritmos, para un problema de tamaño n, desde la más rápida hasta
la más ineficiente. Se dice que un problema es tratable si su complejidad es polinomial o
menor.
Complejidad
O(1)
O(log n)
O(n)
O(n log n)
O(n2 )
O(n3 )
O(nc ), c > 3
O(2n )
O(3n )
O(cn ), c > 3
O(n!)
O(nn )
Nombre
Constante
Logarı́tmica
Lineal
Cuadrática
Cúbica
Polinomial
Exponencial
Factorial
Cuadro 2.2: Complejidades
A partir de una complejidad O(2n ), los problemas para los cuales hay un algoritmo con
dicha complejidad son intratables.
Existen otros problemas llamados “NP-Completos”, cuya complejidad es desconocida.
Se dice que son “NP” ya que se presume que los algoritmos que los solucionen son NoPolinomiales; sin embargo, no existe ninguna prueba que demuestre lo contrario, es decir,
que sean “P” (Polinomiales)4 . Los cientı́ficos creen que los problemas NP-Completos son
intratables, debido a que si existiera un algoritmo que resolviera alguno en un tiempo
polinomial, entonces todos los problemas NP-Completos podrı́an ser resueltos en un tiempo
4
Aunque Vinay Deolalikar de los laboratorios de Hewlett Packart en el segundo semestre del 2010 compartió una versión preliminar de una prueba [10].
25
NP-Completitud
2 Noción de Problema
polinomial. Lo anterior llevó a los investigadores a plantear una de las más importantes
preguntas en las ciencias de la computación [8]:
¿P = NP?
Es decir, ¿las clases de complejidades Polinomiales y las No-Polinomiales son equivalentes? El instituto de Matemáticas Clay, en Cambridge, está ofreciendo 1 millón de dólares
a quien de una demostración formal de que P = NP ó que P 6= NP.
2.3.
Solución de Problemas
Un problema en ciencias de la computación se puede definir como un vacı́o, que no ha
sido llenado, entre un estado inicial y un estado objetivo. Es decir, se está en una situación
y se quiere llegar a otra, pero no se conoce el camino a ella. El ejemplo 2.3.1 muestra un
problema cotidiano.
Ejemplo 2.3.1
Se tiene un estado inicial del juego Sudoku 5 , como en la figura 2.2.
6
8
1
4
3
5
5
6
2
1
8
4
7
6
7
6
3
9
1
2
6
5
8
4
5
2
7
4
9
7
Figura 2.2: Estado inicial del sudoku
El sudoku tiene una sola regla: Llenar la grilla de tal manera que cada fila, columna y
cuadro de 3 × 3 contenga los dı́gitos del 1 al 9.
Aunque hay números en la grilla, no hay matemática envuelta. El problema se podrı́a
resolver con razonamiento y lógica.
El estado final del problema serı́a el mostrado en la figura 2.3.
? ? ?
Entonces surge la pregunta ¿cómo solucionar un problema?. La primera aproximación
es: “¡ como se pueda !”. Aunque esta respuesta es un poco rápida y brusca, es muy usada
5
http://www.sudoku.com/
26
2.3 Solución de Problemas
9
6
3
1
7
4
2
5
8
1
7
8
3
2
5
6
4
9
2
5
4
6
8
9
7
3
1
8
2
1
4
3
7
5
9
6
4
9
6
8
5
2
3
1
7
7
3
5
9
6
1
8
2
4
5
8
9
7
1
3
4
6
2
3
1
7
2
4
6
9
8
5
6
4
2
5
9
8
1
7
3
Figura 2.3: Estado final del sudoku
en estudiantes principiantes en la programación quienes apenas reciben el problema lo
primero que hacen es prender el computador y empezar a programar. Lo malo de esta
aproximación es que puede resultar en algoritmos muy ineficientes (con una complejidad
de O(2n ) o mayor).
G. Polya en [17] introdujo cuatro fases para solucionar un problema, y aunque fueron
concebidos para resolver problemas matemáticos, no hay duda en la relación directa que
hay entre las matemáticas y la computación y su interés común en la resolución de muchos
problemas. Las cuatro fases son:
1. Entender el problema
2. Diseñar un plan
3. Ejecutar el plan
4. Recapitular
Para entender el problema se deben hacer preguntas, pensar e investigar. Se debe preguntar por ejemplo quién propuso el problema, por qué, de donde salió, a donde se quiere
llegar, cuál es el objetivo. Se debe pensar para tener una idea mejor del problema como un
todo y empezar a divisar la solución. Por último se debe investigar para saber quien más
ha trabajado en ese problema o quien está trabajado en algo parecido, hay que leer libros,
revistas y artı́culos, pero en la solución hay que referenciar todo lo que se investigó.
Ya entendido el problema se debe abordar el problema y diseñar un plan para solucionarlo. Si no es evidente un plan, se pueden seguir las siguientes estrategias:
Dividir el problema en subproblemas y atacar cada subproblema. Estrategia llamada
dividir y conquistar, original de los romanos.
Si el problema es muy abstracto, tratar de examinar un ejemplo concreto (e.g. si no
se sabe cuantas veces se ejecuta una lı́nea de código para una entrada n, se le puede
dar un valor: n = 10).
27
2 Noción de Problema
Tratar de resolver un problema más general. Estrategia llamada la paradoja del inventor : entre más ambicioso sea el plan más opciones de éxito.
Relajar un poco el problema de manera que se pueda encontrar una solución fácilmente aunque no lleve a un plan correcto. El plan resultante será una heurı́stica, es
decir, una aproximación que puede dar ideas o puede dar una solución muy cercana
a la correcta.
Una vez tenga un plan para resolver el problema, es necesario buscar alternativas, es
decir, otros planes para llegar a la solución. Usualmente existen varias alternativas de
solución, solo que es necesario analizar el problema de diferentes maneras (véase el ejemplo
2.3.2). Muchas veces la alternativa más ingenua puede ser la más rápida y consisa. Después
de tener una baraja de planes se puede escoger el más acertado (la mejor solución).
Ejemplo 2.3.2
Se desea encontrar un número e en una lista ordenada de números. ¿Cómo lograrlo?
La aproximación más ingenua es recorrer toda la lista, desde el primer elemento hasta
el final, buscando el número e dado (véase la figura 2.4). Si el tamaño de la lista es
n, se puede ver que la implementación de este método tomarı́a un tiempo de O(n)
en el peor de los casos (cuando el número dado sea el último elemento de la lista o
no esté en ella).
3
6
1
2
...
13 16
...
45 57
n-1
n
Figura 2.4: Búsqueda lineal
Se puede optimizar la idea anterior haciendo dos recorridos, uno desde el principio
y otro desde el final de la lista (véase la figura 2.5). De esta manera, el peor de los
casos serı́a cuando el número estuviera en la mitad de la lista. La búsqueda entonces
no mejorarı́a mucho, serı́a O(n/2), que en el caso en que n fuera extremadamente
grande serı́a equivalente a O(n).
3
6
1
2
...
13 16
...
45 57
n-1
n
Figura 2.5: Búsqueda lineal bidireccional
28
2.3 Solución de Problemas
Una idea mucho más eficiente es la de hacer una búsqueda binaria. Se parte la lista
en dos (por la mitad), quedando una sublista con los números menores o iguales a un
k y otra sublista con los números mayores a k. Entonces se hace la pregunta ¿e < k?
Si la respuesta es positiva se procede a partir la sublista con los menores a k; de lo
contrario se hace lo mismo pero con la sublista con los mayores a k. De esta forma
se tiene una lista con una logitud igual a la mitad de la lista original y un nuevo
número k correspondiente al elemento de la mitad de la nueva lista. Se realiza el
proceso anterior hasta que quede una lista con un solo elemento, el número e que
estaba buscando.
Un ejemplo de una búsqueda binaria puede verse gráficamente como en la figura 2.6.
Se búsca el número 8.
1
1
1
3
3
5
3
6
5
8
5
6
6
9 10 14 16 20 30 45
9
6
6
8
10 14 16 20 30 45
8
8
9
9
8
Figura 2.6: Un ejemplo de búsqueda binaria
Al partir la lista a la mitad cada vez, se reduce la búsqueda primero en 2, luego en 4,
en 8, en 16, y ası́ sucesivamente. Es decir que la complejidad del algoritmo termina
siendo O(log n).
Un último método (de los muchos métodos posibles que hay y que no se verán aquı́)
para hacer la búsqueda es convirtiendo la lista en una estructura de datos un poco
más compleja donde se manejen ı́ndices para que, por medio de una llave, se llegue a
un valor. Esta estructura de datos es llamada tabla hash y se verá más a fondo en el
29
2 Noción de Problema
capı́tulo 4.
Cada valor va a tener un ı́ndice asociado. Dicho ı́ndice es encontrado mediante una
llave. Lo anterior indica que teniendo una función (denominada función hash) y
aplicándola con la llave como argumento se devuelve el valor correspondiente, como se muestra en la figura 2.7.
Llave
64
5
80
Índice
Valor
0
30
1
5
.
.
.
.
.
.
19
64
20
45
.
.
.
.
.
.
56
80
57
16
Figura 2.7: Búsqueda con tabla hash
Para hacer más fácil el entendimiento de este método, es bueno pensar en cómo se
busca una empresa en las páginas amarillas del directorio telefónico. Teniendo la
llave (que en este caso es el nombre de la empresa) se búsca en el ı́ndice la página correspondiente al negocio de la empresa (aseguradoras de riesgos profesionales,
por ejemplo) y llendo a dicha página, allı́ se encuentra rápidamente los datos de la
empresa.
La complejidad de los algoritmos de búsqueda por medio de tablas hash es de O(1),
es decir, es constante. Esto es debido a que la operación para hallar el dato solamente aplica la función hash, esta halla el ı́ndice, quien apunta directamente al dato
requerido.
? ? ?
Finalmente se debe transformar la solución potencial en un resultado mediante la ejecución del plan y se debe recapitular para saber, entre otras cosas, cómo se puede mejorar
la solución descrita, si esta solución puede usarse en otro problema, ó para conocer las
debilidades de la solución.
30
2.4 Estrategias de Implementación
Dentro de la teorı́a de ingenierı́a de software, cada una de las cuatro fases tiene una
etapa asociada en el ciclo de vida del software (véase [20]):
1. Análisis – Donde se levantan los requerimientos que debe satisfacer el sistema, se
debe estudiar la viabilidad del proyecto, y formalizar un acuerdo con el cliente.
2. Diseño – Donde se divide el problema en subproblemas o funciones, se identifican las
soluciones tecnológicas para cada una de las funciones, se asignan recursos ellas, y se
ajustan las especificaciones.
3. Implementación – Donde se genera la solución, se integran las funciones y se valida
la solución mediante unas pruebas.
4. Mantenimiento – Donde se asegura el uso del sistema y se realiza la conservación del
software.
2.4.
Estrategias de Implementación
Una vez se hace el análisis y el diseño de los algoritmos, la implementación de los mismos
puede ser abordado de dos maneras distintas:
Top-Down
Bottom-Up
En el modelo top-down se hace un manejo completo del sistema sin entrar en detalles.
Se comienza sistema como un todo y cada una de sus partes son “cajas negras” que deben
ser abiertas poco a poco, a medida que se vaya internando en los detalles especı́ficos del
proyecto. La gran ventaja de este modelo es que se divide el proyecto en subproyectos desde
el inicio de la implementación. La desventaja es que no se pueden hacer pruebas a ninguna
parte del proyecto casi que hasta el final.
En contraste, el modelo bottom-up las partes más especiı́ficas del sistema son abordadas
desde el comienzo, y ellas, al irse enlazando, van forman el sistema completo. Al contrario
del modelo anterior, la ventaja de bottom-up es que puede hacerse un plan de pruebas desde
el inicio de la implementación. La desventaja es que puede perderse de vista el objetivo
final del proceso.
El ejemplo 2.4.1 muestra el trabajo de realizar una aplicación para la enseñanza del
teorema del binomio desde los dos modelos.
Ejemplo 2.4.1
Se desea crear una calculadora básica, es decir, con las operaciones aritméticas fundamentales. A continuación se verá el proceso de implementación de la aplicación desde los dos
modelos:
31
2 Noción de Problema
Top-Down
Se comienza mirando la aplicación como un todo. Si se piensa hacer una GUI (interfaz
gráfica de usuario), ésta será el paso inicial a realizar. Una posible interfaz gráfica
puede verse en la figura 2.8.
X
Calculadora
Archivo Ayuda
12345.67890
C
7
8
9
/
4
5
6
*
1
2
3
-
.
+
=
0
Figura 2.8: Ventana de una Calculadora
Se debe pensar en la especificación del comportamiento esperado del programa: qué tipo de interfaz va a tener (gráfica o texto), cuáles serán las entradas al problema, cuáles
menús habrán en la ventana, si se hace click en algún lado de la ventana qué va a
pasar, cómo va a estar dividida la ventana (si se piensa en dividirla como en la figura),
qué tipos de mensajes sacará la aplicación (para comunicarse con el usuario), cómo
realizará las operaciones matemáticas, etc.
La estructura de los módulos de la aplicación, a grosso modo, podrı́a ser como en la
figura 2.9.
Puede verse que el módulo principal de la aplicación contiene la función para crear la
ventana. La ventana tiene las funciones de creación de una barra de tı́tulo, una barra
de menús y un área de trabajo. Y continua profundizando hasta llegar a la función
más básica: MostrarTexto.
Bottom-Up
Se comienza mirando cuales son las funciones más sencillas que ayudarán a construir
funciones más complejas.
Se debe pensar en las primitivas de bajo nivel y posibles operaciones a nivel de
hardware que sean importantes y relevantes al problema: la función MostrarTexto es
la operación más básica en este problema (dejando a un lado las operaciones gráficas
básicas), las operaciones aritméticas, la creación de botones, la visualización de los
mismos, los componentes de la ventana, las interrupciones de hardware y software,
etc.
32
2.4 Estrategias de Implementación
Principal
CrearVentana
CrearBarradeTitulo
CrearBarradeMenu
CrearEspaciodeTrabajo
CrearMenuArchivo
CrearMenuAyuda
CrearMenuItemSalir
MostrarAyuda
CrearCampodeTexto
CrearBotones
CrearBoton1
Salir
...
CrearBoton+
...
OperacionSuma
CrearBoton=
CalcularResultado
MostrarTexto
Figura 2.9: Creación de funciones en el modelo top-down
La estructura de los módulos de la aplicación, sin mucho detalle, podrı́a ser como en
la figura 2.10
MostrarTexto
CrearCampodeTexto
Mostrar1
...
Mostrar9
CrearBoton1
...
CrearBoton9
OperacionSuma
CalcularResultado
CrearBoton+
Salir
OperacionDivisión
...
...
...
CrearBoton+
CrearBoton=
CrearMenuItemSalir
MostrarAyuda
CrearBotones
CrearMenuArchivo
CrearBarradeTitulo
CrearMenuAyuda
CrearBarradeMenu
CrearEspaciodeTrabajo
CrearVentana
Principal
Figura 2.10: Creación de funciones en el modelo bottom-up
? ? ?
33
3 Noción de Lenguaje
“Un computador es un conjunto integrado de algoritmos y estructuras de
datos capaz de almacenar y ejecutar programas.”
En la definición anterior (tomada de [18]), la cual es un poco diferente a la definición de
computador que todos conocen, se destacan tres conceptos que forman la famosa ecuación
de Wirth:
programas = algoritmos + estructuras de datos
Un programa es la implementación de un algoritmo dados unos datos de entrada estructurados. Dicho algoritmo es desarrollado en un lenguaje de programación.
Un lenguaje de programación , al igual que cualquier lenguaje, es un mecanismo de comunicación compuesto por un vocabulario y un conjunto de reglas gramaticales. Su propósito
es ordenarle al computador que realice una tarea especı́fica.
Los lenguajes de programación pueden hacer el desarrollo de programas más fácil o más
difı́cil, dependiendo del nivel de abstracción que requiera y la cantidad de conocimiento del
trabajo interno del computador que sea necesario para escribir los programas. Entre más
familiar sea el lenguaje que se use para resolver los problemas, su nivel será más alto.
En este capı́tulo se presentará una descripción de la teorı́a de los lenguajes que permiten
la especificación de la solución de los problemas en términos relativamente más cercanos a
los usados por las personas, y en particular el lenguaje C++, el cual será usado en el resto
del documento.
3.1.
Historia
La historia de los lenguajes de programación se ha dividido en cuatro generaciones1 :
Primera Generación Lenguajes de máquina, que se reduce a secuencias de números binarios.
Segunda Generación Lenguajes ensambladores, que tienen instrucciones de bajo nivel,
bastante básicos pero menos abstracto que el lenguaje de máquina. (en la figura 3.5
se muestra un ejemplo de código en lenguaje ensamblador).
1
Algunos autores hablan de una quinta generación de lenguajes, los cuales son usados para resolver problemas mediante la especificación de programas con restricciones o fórmulas lógicas en vez de algoritmos.
Ejemplos de estos lenguajes son: MoZArt, Prolog y Mercury.
35
Programa
Lenguaje de Programación
3 Noción de Lenguaje
Tercera Generación Lenguajes de alto nivel, con sintaxis más cercana al lenguaje natural.
Algunos de estos lenguajes pueden verse en la figura 3.1.
Cuarta Generación Lenguajes diseñados para desarrollar aplicaciones requeridas en ambientes empresariales y de negocios. SQL, Oracle Reports, Mathematica, MATLAB,
son algunos de estos lenguajes.
En los años 50’s, cuando la tecnologı́a permitió el desarrollo de computadores más familiares, J. Backus creó el lenguaje de programación FORTRAN (FORmula TRANslation).
Este lenguaje es considerado el primer lenguaje de alto nivel y aún es usado por matemáticos y cientı́ficos.
La evolución de los lenguajes de programación se ha debido a cinco influencias principales
[18]:
1. El hardware y los sistemas operativos – El hardware es cada vez más fácil de usar
(PC’s y Tablets) y los sistemas operativos más gráficos y amigables (basados en
ventanas).
2. Las aplicaciones – Desde las militares, cientı́ficas, de negocios e industriales, hasta
los juegos, personales y de todo tipo de actividades humanas.
3. Las metodologı́as – Para desarrollar programas más complejos y con nuevos diseños.
4. Los estudios teóricos – Nuevos métodos formales matemáticos que soporten las caracterı́sticas de los lenguajes.
5. Las estandarizaciones – La posibilidad de implementar los lenguajes en cualquier
sistema y permitir transportar los programas de un computador a otro.
En la figura 3.12 se puede apreciar la evolución de los más conocidos lenguajes de programación de alto nivel, desde FORTRAN hasta C#.
B. Kinnersley ha hecho un listado de más de 25003 lenguajes de programación, que han
sido desarrollados a lo largo de la historia. Muchos de estos lenguajes ya no se usan mientras
que otros como FORTRAN y LISP siguen siendo utilizados constantemente. Terrece Pratt
en [18] describe las caracterı́sticas que enmarcan los lenguajes en usados y no usados:
Claridad – La sintaxis del lenguaje debe ser fácil para leer, escribir, probar, entender
y modificar los programas que se escriban en él.
Aplicación – El lenguaje debe proveer estructuras de datos, operaciones y estructuras
de control para resolver uno o varios tipos especı́ficos de problemas.
2
Una versión más detallada de la evolución de los lenguajes ha sido creada por Éric Lévénez y puede verse
en http://www.levenez.com/lang/history.html.
3
El listado puede consultarse en http://people.ku.edu/~nkinners/LangList/Extras/langlist.htm.
36
3.1 Historia
FORTRAN
LISP
1954
ALGOL
1958
COBOL
1959
SNOBOL
1962
SIMULA
BASIC
1964
LOGO
1968
FORTH
SMALLTALK
SH
1969
PASCAL
PROLOG
1971
C
1973
ML
SCHEME
MS BASIC
1975
MODULA
ICON
1977
1978
AWK
ADA
1979
POSTSCRIPT
MIRANDA
1982
C++
1983
COMMON
LISP
1984
1986
EIFFEL
HASKELL
1970
CAML
1987
PERL
TURBO
PASCAL
1988
TCL/TK
CLOS
OZ
VISUAL
BASIC
1991
PYTHON
1993
RUBY
DELPHI
1989
JAVA
JAVASCRIPT
1995
PHP
1996
OCAML
MOZART
C#
1999
2000
Figura 3.1: Evolución de los lenguajes de alto nivel
37
3 Noción de Lenguaje
Soporte – Se debe ayudar al programador a solucionar problemas mediante el lenguaje
con API’s (interfaces para programar aplicaciones) y grupos de desarrollo.
Verificación – La posibilidad de verificar la correctitud de un programa mediante
varias técnicas.
Ambiente – Un ambiente de programación (con editor y paquetes de depuración)
puede acelerar la creación de programas.
Costo – De uso, ejecución, traducción, creación, pruebas y mantenimiento de los
programas.
Portabilidad – El transporte de los programas, del computador donde fue creado a
otros sistemas.
3.2.
Estructura
Los lenguajes de programación de alto nivel tienen seis caracterı́sticas:
1. Datos - Tipos de datos
2. Operaciones primitivas
3. Secuencias de control
4. Datos de control
5. Almacenamiento
6. Interacción con el ambiente
Al igual que cualquier lenguaje (como el español), los lenguajes de programación tienen
una sintaxis y una semántica. La sintaxis es la forma en que los programas son escritos
mientras que la semántica es el significado dado a las construcciones sintácticas.
Cada lenguaje de programación tiene una sintaxis y una semántica particular. Por ejemplo, mientras en Pascal la defición de una variable de tipo lista de reales es
var V: array [1..10] of real;
en C es
float V[10];
Sintaxis
La sintaxis de un lenguaje, está definida entonces como la escongencia y organización de
varios elementos sintácticos básicos. Dichos elementos pueden ser caracteres, identificadores, operadores, palabras reservadas, comentarios, espacios en blanco y delimitadores.
38
3.2 Estructura
Los elementos forman expresiones, declaraciones y, en general, la estructura de un programa. Formalmente, la organización de esos elementos construyen la gramática del lenguaje,
que consta de un conjunto de definiciones (llamadas reglas o producciones) que especifican el orden particular en que deben estar ubicados los elementos para que un programa
esté bien escrito.
La forma más usada para especificar la gramática de un lenguaje es la BNF (BackusNaur Form), desarrollada por J. Backus en 1960. La BNF define un lenguaje de una manera
directa. Por ejemplo para describir una variable o identificador en C++, se lista la estructura4 :
identificador ::= (letra | "_")
| identificador (letra | "_")
| identificador digito
letra
::= mayusculas | minusculas
minusculas
::= "a"..."z"
mayusculas
::= "A"..."Z"
digito
::= "0"..."9"
Un identificador en C++ es la composición de una letra o una raya (el sı́mbolo ” ”,
también llamado underscore), con cero o varias letras, digitos o rayas. Una letra puede ser
mayúscula o minúscula. Las minúsculas, mayúsculas y los dı́gitos están allı́.
Si se fuera a definir una asignación simple (solo con enteros y expresiones aritméticas
básicas), se tendrı́a:
asignacion
::= identificador "=" exp_aritmetica ";"
exp_aritmetica
::=
|
|
|
|
entero
::= digitos_sin_cero digito* | "0"
entero | identificador
exp_aritmetica "+" exp_aritmetica
exp_aritmetica "-" exp_aritmetica
exp_aritmetica "*" exp_aritmetica
exp_aritmetica "/" exp_aritmetica
digitos_sin_cero ::= "1"..."9"
4
La descripción completa de la BNF de C++ puede encontrarse en http://www.nongnu.org/hcb/
39
3 Noción de Lenguaje
Al igual que en cualquier lenguaje, para asegurar que una expresión está bien escrita,
lo que se debe hacer es seguir la BNF de forma estricta. El ejemplo 3.2.1 muestra una
asignación y su análisis correspondiente.
Ejemplo 3.2.1
¿La expresión W = Y * 10 + V; es sintácticamente correcta?
La figura 3.2 muestra el análisis dada la gramática anterior.
asignacion
exp_aritmetica
exp_aritmetica
exp_aritmetica
exp_aritmetica
identificador
W
entero
identificador
=
Y
digito_sin_cero
*
exp_aritmetica
digito
10
identificador
+
V
;
Figura 3.2: Análisis para la asignación
Por lo tanto, sı́ es correcta la expresión.
? ? ?
Semántica
Por otro lado, la semántica del lenguaje hace otro tipo de verificación. Ella chequea
principalmente:
1. Los tipos – Se verifica que los operadores sean aplicados a los operandos correctos;
por ejemplo, la división entre una lista y un entero no tiene sentido.
40
3.3 Compiladores
2. El control de flujo – Se asegura que los comandos que causan un rompimiento en el
flujo de control (como el comando break) transfieran el flujo a otro lugar.
3. La unicidad – No deben haber variables y etiquetas diferentes con el mismo identificador.
3.3.
Compiladores
Los lenguajes de programación se implementan mediante traductores ó compiladores que
convierten el código fuente en un código objetivo (que puede ser un código intermedio o
código de máquina). En la figura 3.3 (figura tomada de [1]) se puede ver la forma superficial
de un compilador.
código
fuente
compilador
código
objetivo
mensajes
de error
Figura 3.3: Componentes superficiales de un compilador
Para hacer la traducción del programa, varias etapas deben ser superadas, las cuales
pueden verse como los componentes intermedios del compilador. Dichas etapas son el preprocesamiento, el compilador como tal, el ensamblador y los enlazadores y cargadores.
Como el código fuente puede estar dividido en varios archivos diferentes o módulos, y
pueden haber macros y extensiones necesarias para el programa, todos estos componentes
deben ser recolectados para tener un único programa fuente. Esta etapa es denominada
preprocesamiento.
El código ensamblador es una versión menos abstracta del código de máquina, que es
el entendido por el procesador del computador. Este código trabaja directamente con direcciones de memoria de la RAM, los registros del procesador, la pila del programa y las
interrupciones del sistema operativo. Cada procesador tiene un conjunto de instrucciones
que constituyen el lenguaje ensamblador. En [4] puede verse las instrucciones para los
procesadores Intel desde el 8086 hasta el 80486 (las generaciones antes de los Pentium).
Los enlazadores y cargadores realizan las funciones de cargar y enlazar código intermedio
y librerı́as ya creadas (e.g. archivos *.dll en windows, o *.so y *.a en linux) al programa
que se está traduciendo.
41
Compilador
3 Noción de Lenguaje
El sistema de procesamiento de compilación (los componentes intermedios del compilador) puede verse en la figura 3.4 (figura adaptada de [1]).
módulos de programa fuente
preprocesador
programa fuente
compilador
programa ensamblador
ensamblador
código objeto
enlazador
cargador
librerías
código objeto
código de máquina
Figura 3.4: Componentes intermedios de un compilador
Una vez pasado el ensamblador, el código objeto generado puede usarse como librerı́a
para otros programas. Esto es posible debido a que los componentes intermedios del compilador son separables, es decir, el proceso de compilación puede hacerse paso a paso, tomando
cada uno de los componentes como aplicaciones separadas y trabajandolos manualmente.
Conceptualmente, el proceso de transformar el programa fuente en un programa ensamblador se puede descomponer en seis fases: análisis léxico, análisis sintáctico, análisis
semántico, generación de código intermedio, optimización y generador de código.
42
3.3 Compiladores
W = Y * 10 + V;
Analizador Léxico
id1 = id2 * 10 + id3
Analizador Sintáctico
=
id1
+
*
id2
id3
10
Analizador Semántico
=
id1
+
*
id2
id3
10
Generador de Código Intermedio
temp1 = 10
temp2 = id2 * temp1
temp3 = id3 + temp2
id1 = temp3
Optimizador
temp = id2 * 10
id1 = id3 + temp
Generador de Código
MOV AL, id2
MOV BL, 10
MUL BL
MOV CL, id3
ADD AL, CL
MOV id1, AL
Figura 3.5: Ejemplo de las fases de compilación
43
3 Noción de Lenguaje
En el análisis léxico se hace un escaneo de los caracteres que tiene el programa en búsca de
sı́mbolos que no hacen parte del alfabeto del lenguaje. Una vez que un grupo de caracteres
que conforman un elemento sintáctico básico es escaneado y aprobado, dicho elemento es
pasado como un token al analizador sintáctico. Los espacios en blanco que separan los
tokens son eliminados en esta fase.
El analizador sintáctico se encarga de crear un árbol con cada uno de los tokens para
comparar las expresiones con la BNF del lenguaje. Un ejemplo de árbol es el de la figura
3.2.
Como se vio anteriormente la fase de análisis semántico chequea el programa fuente para
encontrar errores de tipos de datos, de control de flujo y de unicidad.
Una vez las fases de análisis son superadas, algunos compiladores generan un código
intermedio. Este código sirve para que el compilador decida el orden de operación de las
lı́neas de código y para generar nombres temporales que mantengan su valor calculado por
cada instrucción.
La fase de optimización intenta mejorar el código intermedio para obtener mejores resultados en rendimiento.
La última fase es la generación del código objetivo, que usualmente consiste en código
ensamblador.
La figura 3.5 (adaptada de [1]) muestra un ejemplo de una compilación de la expresión
del ejemplo 3.2.1.
Comienza el analizador léxico transformando la expresión inicial en una expresión basada en tokens, luego el analizador sintáctico construye el árbol de sintaxis, en seguida el
analizador semántico hace la verificación (en este caso no hay errores semánticos por lo
que queda igual), entonces el generador de código intermedio pone los nombres temporales
y ordena las operaciones, acto seguido el optimizador reduce el número de operaciones, y
por último el generador de código retorna el código ensamblador correspondiente.
3.4.
Máquina Virtual
Máquinas Virtuales
El código objetivo de un compilador es ejecutado en interpretadores los cuales pueden
ser hardware o máquinas reales, ó software en cuyo caso son llamadas máquinas virtuales.
Las máquinas virtuales son ambientes de ejecución que emulan o actúan como interfaz de
un computador o programa. Ellas proveen las instrucciones para comunicarse directamente
con el computador o programa que estén emulando.
Los interpretadores tienen una máquina virtual del computador local donde ejecutan los
programas sin necesidad de crear archivos ejecutables. De esta manera, la máquina virtual
se encarga de tomar el código intermedio y hacer la traducción al código de máquina del
computador fı́sico y devolver el resultado. Ası́ las máquinas virtuales podrı́an verse como
wrappers o funciones envolventes que encapsulan las instrucciones reales de los computadores o programas que emulan.
44
3.4 Máquinas Virtuales
Algunos lenguajes de programación interpretados son Java, Forth, Fortran, Perl, Lisp,
Scheme, Smalltalk y Python.
Por otro lado, lenguajes de programación como Java poseen una máquina abstracta pero
al nivel del sistema operativo5 . Esto da pie al concepto de portabilidad. Una de las grandes
ventajas de Java es que el código fuente que se escribe en él puede ser compilado y ejecutado
en cualquier sistema operativo, ya sea Linux, Windows, MacOS, Solaris, o algunos otros.
La desventaja es que el hecho de que ya sean dos capas que tienen que emularse (sistema
operativo y hardware) tiene repercusiones en el rendimiento de los programas. De allı́ que
muchos programadores evitan programar en Java, ya que los programas les corren más
lento que en lenguajes compilados como C.
Datos de
Entrada
Datos de
Salida
Programa
Lenguaje de
Programación
Sistemas Operativos
Computador Físico (Hardware)
Figura 3.6: Jerarquı́a de las máquinas virtuales
La figura 3.6 (adaptada de [18]) muestra la jerarquı́a de máquinas virtuales. Además de
las máquinas virtuales al nivel del hardware y el sistema operativo, los lenguaje de progra5
Existen programas que emulan todo el sistema operativo. En Linux, por ejemplo, programas como wine
(http://www.winehq.org/) y crossover (http://www.codeweavers.com/) pueden correr programas hechos en Windows, mientras que vmware (http://www.vmware.com/) puede lanzar todo el sistema operativo Windows encima de Linux.
45
3 Noción de Lenguaje
mación y los programas que se escriben pueden verse también como máquinas virtuales. El
lenguaje de programación funciona como interfaz entre lo que el programador quiere hacer
y el sistema operativo, mientras que el programa funciona como interfaz entre el usuario y
el lenguaje de programación.
3.5.
Depuración
Depuración
Depuración es el proceso de encontrar y reducir el número de bugs, es decir, errores,
faltas, fallas o equivocaciones, en un programa.
El término bug tiene su origen en la marina de Estados Unidos en 1945, cuando se
encontró una polilla que causaba un corto circuito en las pruebas que se le hacı́an al panel
de un computador electromecánico. Los operadores incluyeron el insecto en su bitácora de
pruebas que puede verse en la figura 3.7.
Figura 3.7: Primer bug encontrado
46
3.5 Depuración
B. Beizer en [3] categoriza los bugs en:
1. Suaves – Solo ofenden estéticamente. Mala indentación, mala ortografı́a, entre otros.
2. Moderados – Salidas redundantes que generan un impacto leve en el rendimiento del
sistema.
3. Molestos – El comportamiento del sistema, por culpa del bug, es desagradable.
4. Perturbantes – Se rechazan operaciones normales.
5. Serios – Se pierde el rastro de las operaciones.
6. Muy serios – El bug causa que el sistema haga operaciones erradas.
7. Extremos – Los problemas anteriores ocurren frecuentemente y arbitrariamente, y no
solo en casos aislados.
8. Intolerables – La base de datos empieza a tener datos corruptos e irreparables. Se
considera seriamente bajar el sistema.
9. Catastróficos – El sistema falla completamente.
10. Infecciosos – La falla del sistema tiene repercusiones en otros sistemas.
El proceso de depuración puede dividirse en cinco etapas:
Reconocer que el bug existe:
Si el error causa que el programa termine de forma abrupta, entonces es obvia la existencia del bug. Sin embargo, a medida que el error sea menos serio, la dificultad de detectarlo es
mucho mayor, llegando al punto de pasar desapercibido. Por ejemplo, en 1994, T. Nicely de
la Universidad de Lynchburg descubrió un error en los procesadores Intel. Él se dió cuenta
que algunas divisiones siempre devolvı́an un valor errado. Inicialmente Intel negó el error,
pero otras personas confirmaron el problema rápidamente y más tarde Intel tuvo que sustituir todos los procesadores defectuosos (algunos modelos del Pentium con una frecuencia
47
3 Noción de Lenguaje
de menos de 100 MHz). Para comprobar el error se puede ejecutar el algoritmo 6.
#include <stdio.h>
int main(void)
{
float x = 8391667.0;
float y = 1572863.0;
if(x - (x / y) * y != 0)
printf("Procesador con el error de division del Pentium.");
else
printf("Procesador sin el error de division del Pentium.");
return 0;
}
Algoritmo 6: Descubre si el procesador tiene error
Se deben identificar entonces, los sı́ntomas del bug, observar el problema y bajo qué condiciones es detectado.
Aislar la fuente del bug :
Se debe identificar qué parte del código genera el bug. Esto puede resultar muy difı́cil
de hacer debido a que, por ejemplo, creyendo que una lı́nea de código tiene el problema,
puede que dicha lı́nea genera el problema como resultado de errores en una función que
está en otro módulo del programa.
Los programadores menos experimentados deben seguir la ejecución del programa paso
a paso, lı́nea a lı́nea, reconociendo en el flujo del programa una discontinuidad, comportamiento errado, o discrepancia. Los programadores hábiles pueden reconocer a priori en
qué área del código puede estar el problema (basado en previas situaciones similares).
Para lograr aislar el bug se debe mirar el código como algo nuevo. Un error común que
cometen los programadores es pasar por alto secuencias, asignaciones, etc. ya que conocen
muy bien el código y asumen la correctitud de ciertas partes. Además, el uso de instrucciones como print, assert y el cambiar pequeños detalles del programa pueden ayudar
bastante (hay que tener en cuenta que se debe cambiar una cosa a la vez y volver atrás los
cambios que no tengan efecto).
Identificar la causa del bug :
Si ya se sabe dónde está el bug, se debe investigar la causa del mismo. El buen conocimiento del programa, tanto es su funcionamiento como en su estructura interna es muy
importante para descubrir la causa del bug. Un programador que no esté familiarizado con
el código puede gastar muchas horas inútilmente mientras el creador del programa podrı́a
decidir rápidamente que la causa es externa al código.
Existen herramientas para ayudar a descubrir las causas de un bug. Una de ellas es DDD6 ,
6
http://www.gnu.org/software/ddd/
48
3.5 Depuración
un software que actúa como front-end para depuradores de diferentes lenguajes como C,
Perl, Python, y Java. La idea de este tipo de debuggers es usar breakpoints o puntos clave
del programa donde se necesite hacer un seguimiento detallado de variables, funciones o
estructuras (este seguimiento se hace mediante watchers). La figura 3.8 (figura tomada de
http://www.gnu.org/software/ddd/) muestra la ventana de DDD con un programa para
manejar listas hecho en C.
Figura 3.8: Data Display Debugger
Idealmente se debe prevenir cualquier posibilidad de bugs, es decir, diagnosticar y deter-
49
3 Noción de Lenguaje
minar los errores pre-mortem. Para ello es bueno trabajar con bitácoras (archivos log), de
manera que se tenga un rastro de los cambios realizados. También es recomendable usar
un sistema de control de versiones (CVS7 o Subversion8 , por ejemplo) para que ası́ sea
posible regresar a versiones anteriores estables cuando se requiera. Adicionalmente, para la
mayorı́a de los compiladores actuales existen editores que ayudan a la prevención de errores
sintácticos y semánticos, mediante el coloreo de palabras reservadas, variables, funciones
y demas estructuras, el chequeo automático e incluso la opción de depuración. Un editor
muy usado para Java y C/C++ es Eclipse9 (que puede verse en la figura 3.9).
Figura 3.9: Editor Eclipse
Determinar una corrección para el bug :
La tarea de encontrar cómo corregir un bug no es sencilla por varias razones:
7
http://www.nongnu.org/cvs/
http://subversion.tigris.org/
9
http://www.eclipse.org/
8
50
3.5 Depuración
Se puede alterar significativamente el sistema, tanto en su funcionamiento como en
su rendimiento.
Se pueden destapar errores mucho más profundos o complicados.
Se pueden crear nuevos errores.
Los errores lógicos son los más sencillos de corregir debido a que son equivocaciones en
la implementación. Los errores que son resultado de un mal diseño del software pueden
acarrear no una corrección sino una reimplementación parcial o completa del programa.
En programas que son hechos de forma modular o con carga dinámica, es posible crear
los denominados patches. Gracias a ellos no es necesario recompilar todo el programa sino
simplemente un módulo o librerı́a. De esta manera, lo que se hace es reemplazar los archivos necesarios sin alterar todo el sistema.
Aplicar la corrección y hacer pruebas:
Cuando se aplica la corrección al problema es necesario crear un plan de pruebas riguroso
y llevarlo a cabo para asegurarse que dicha corrección ha tratado el bug de forma correcta.
Se pueden usar tres diferentes aproximaciones para demostrar que un programa está libre
de bugs [3]:
Prueba Funcional - Se debe pensar en todas las entradas posibles y blindar el programa
para que soporte las entradas y produzca la salida correcta para cada una de ellas
(un resultado o un mensaje de texto). Desafortunadamente, incluso teóricamente,
es imposible conocer todas las entradas posibles ya que son infinitas, de allı́ que no
es posible realizar una prueba funcional completa, por lo que se debe minimizar el
número de entradas que se dejen por fuera de la prueba.
La prueba funcional es también llamada prueba de caja negra, ya que el ingeniero de
pruebas solo tiene acceso al software mediante las mismas interfaces que el usuario
normal.
Prueba Estructural - Se miran los detalles de implementación, es decir, el estilo de programación, los métodos de control, el código fuente, el diseño de la base de datos, y
la estructura general.
La prueba estructural es denominada prueba de caja blanca. El desarrollador tiene
acceso al código fuente y puede modificar el código para realizar dichas pruebas.
Prueba de Correctitud - Los requerimientos del programa son declarados en un lenguaje
formal (matemático) de manera que puedan hacerse demostraciones inductivas para
producir los resultados de todas las posibles entradas.
Cada función en el programa tiene una precondición y un postcondición, expresadas
en términos lógicos. Adicionalmente, en los ciclos se tiene una invariante (un conjunto
de caracterı́sticas que nunca cambia mientras se desarrolla el ciclo). Lo que se hace
51
3 Noción de Lenguaje
es que se toma la precondición y, mediante una serie de pasos, la entrada dada y las
invariantes que puedan existir, se debe llegar a la postcondición (para más detalles
se puede ver [7]).
Las caracterı́sticas de las pruebas y la madurez del programa llevan a dos fases o versiones
en el ciclo de vida del software antes de ser liberado: alpha y beta.
Mientras los desarrolladores están creando el programa y hacen pruebas solo ellos, el
programa está en su versión alpha. Las pruebas que se realizan aquı́ normalmente son de
caja blanca, aunque por inspección también se hacen pruebas de caja negra. En esta etapa,
el software es peligroso para usuarios finales.
Una vez se tenga cierta estabilidad en el programa, el desarrollo entra en la fase beta. Se
llaman a los ingenieros de prueba a que realicen pruebas de caja negra. Adicionalmente,
un grupo de personas es escogido (usuarios finales pero con un nivel un poco más alto)
para que usen el programa y reporten bugs.
Algunas personas han considerado una tercera fase denominada gamma. En esta fase
el software tiene la madurez para ser liberado pero puede contener errores (aún está en
pruebas). De hecho muchas aplicaciones son lanzadas en etapa gamma ya sea porque se cree
que está libre de bugs o porque los mismos programadores han escogido a sus compradores
y clientes para que prueben el software mientras lo usan. Algunas de las grandes empresas
de software han usado esta última estrategia para sus productos.
3.6.
Excepción
Excepciones
Los manejadores de excepciones son construcciones diseñadas para tratar la ocurrencia de
situaciones que cambian el flujo normal de la ejecución de un programa. Las excepciones son
la analogı́a en software de lo que son las interrupciones10 en hardware. Ellas se dividen en
sı́ncronas y ası́ncronas. Las excepciones sı́ncronas son aquellas que son planeadas, mientras
las ası́ncronas son aquellas inesperadas.
Las excepciones más comunes en programación son la división por cero, los nombres no
definidos, la incompatibilidad de tipos y la ausencia de archivos, directorios o páginas web.
El algoritmo 7 en C++ muestra un pequeño programa que divide el número 10 entre un
número dado por el usuario.
10
Las interrupciones son señales que emiten algunos dispositivos y que causan que el procesador haga una
pausa en la ejecución, salve el estado y comience una nueva ejecución.
52
3.6 Excepciones
#include <iostream>
using namespace std;
int main(){
int numero,resultado;
cout << "Entre el divisor: ";
cin >> numero;
resultado = 10/numero;
cout << resultado << endl;
}
return 0;
Algoritmo 7: Divide el número 10 entre un número dado por el usuario en C++
Si el usuario ingresa el cero, entonces la división se vuelve imposible y una excepción
surgirá. La primera forma posible para evitar este inconveniente es usar un condicional. El
algoritmo 8 muestra como quedarı́a.
#include <iostream>
using namespace std;
int main(){
int numero,resultado;
cout << "Entre el divisor: ";
cin >> numero;
if(numero != 0) {
resultado = 10/numero;
cout << resultado << endl;
}
else {
cerr << "Division por cero" << endl;
return 1;
}
}
return 0;
Algoritmo 8: Divide el número 10 entre un número dado por el usuario, usando un
condicional en C++
También podemos hacer uso de las aserciones. El algoritmo 9 muestra su uso. La macro
assert no retorna nada pero tiene un argumento de tipo entero que representa la prueba
que se realizará. Si la prueba falla entonces un mensaje de error surge y el programa
53
3 Noción de Lenguaje
termina, de lo contrario el flujo de ejecución continúa normalmente.
#include <iostream>
#include <cassert>
using namespace std;
int main(){
int numero,resultado;
cout << "Entre el divisor; ";
cin >> numero;
assert(numero != 0);
resultado = 10/numero;
cout << resultado << endl;
}
return 0;
Algoritmo 9: Divide el número 10 entre un número dado por el usuario, usando una
aserción en C++
#include <iostream>
using namespace std;
int main(){
int numero,resultado;
cout << "Entre el divisor: ";
cin >> numero;
try {
if(numero == 0)
throw 1;
resultado = 10/numero;
cout << resultado << endl;
}
catch(int) {
cerr << "Division por cero" << endl;
}
}
return 0;
Algoritmo 10: Divide el número 10 entre un número dado por el usuario, usando un
manejador de excepciones en C++
Sin embargo contemplar todos los posibles errores que puedan suceder (no solo de este
tipo) es una tarea dispendiosa que atrasa la programación y desvı́a al programador de su
objetivo. Además, los métodos anteriores no permiten una gestión apropiada de los errores
generados. Ası́ que el código también podrı́a ser modificado con mecanismos de manejo de
54
3.6 Excepciones
excepciones, como en el algoritmo 10.
El bloque try ... catch ... define un espacio de prueba y una alternativa de salida
a una falla. Si el código que está en el bloque try no lanza una excepción (por medio del
comando throw) entonces el programa continua saltándose el catch; En caso contrario, se
desecha lo que se hizo dentro del try, se ejecuta el bloque del catch y continúa el programa.
La pregunta natural que surge entonces es ¿cuándo se usan las excepciones? Aunque los
manejadores de excepciones pueden ser usados para tratar con errores normales e incluso
para depuración, muchos programadores están deacuerdo en que a menos que no se tenga
una muy buena razón para atrapar una excepción, no se haga. Esto es debido a dos razones,
primero se supone que las excepciones son “excepcionales”, lo que implica que no se debe
llenar el código de excepciones, y segundo, cuando se ejecuta el código, cada vez que se
implementa un manejador de excepciones, el procesador guarda el estado del programa en
ese momento para poder continuar la ejecución de cualquier manera.
#include <iostream>
using namespace std;
int main(){
int numero,resultado;
cout << "Entre el divisor: ";
cin >> numero;
try {
if(numero == 0)
throw 1;
if(numero == 134514992) // No es un numero
throw string("letra");
resultado = 10/numero;
cout << resultado << endl;
}
catch(int) {
cerr << "Division por cero" << endl;
}
catch(string) {
cerr << "No entro un numero" << endl;
}
}
return 0;
Algoritmo 11: Divide el número 10 entre un número dado por el usuario, usando dos
manejadores de excepciones en C++
Otras razones para tener en cuenta cuando se usen las excepciones son:
Muchas veces los errores que atrapan los manejadores de excepciones pueden ser
corregidos cuando se está escribiendo el programa. Por ejemplo, si un archivo va a
ser modificado pero éste es de solo lectura, una excepción ocurrirá en el programa.
55
3 Noción de Lenguaje
Este error es fácilmente corregible simplemente cambiando los permisos del archivo.
Se debe proveer la mayor información posible cuando una excepción ocurre. Por
ejemplo, cuando se intenta acceder a una página web y falla, se puede dar detalles
acerca de por qué falló: DNS inválido, time out, usuario no autorizado.
Es bueno implementar manejadores de excepciones especı́ficos. De esta manera el
compilador está optimizado para tratar con la excepción dada: de valor, de ejecución,
de tipo, de nombre, de entrada/salida, etc. Si en el ejemplo de la división por cero
se entra una cadena o un letra (representando un identificador) en vez de un número
habrán otros tipos de excepciones que pueden ser manejados separadamente como en
el algoritmo 11.
Hay que asegurarse de que el código siga ejecutandose aún si ocurren errores. En el
código de la división entre cero puede asegurarse la continuación de dos maneras:
creando un ciclo que pida el número de nuevo cada vez que se entre un cero, ó sustituyendo el cero por un número por defecto. Los algoritmos 12 y 13 muestran las dos
alternativas.
// Alternativa 1:
#include <iostream>
using namespace std;
void division() {
int numero,resultado;
}
cout << "Entre el divisor: ";
cin >> numero;
try {
if(numero == 0)
throw 1;
resultado = 10/numero;
cout << resultado << endl;
}
catch(int) {
cerr << "Division por cero" << endl;
division();
}
int main() {
division();
return 0;
}
Algoritmo 12: Divide el número 10 entre un número dado por el usuario, garantizando
la continuación, alternativa 1 en C++
56
3.7 Interfaces Gráficas de Usuario (Eventos)
// Alternativa 2:
#include <iostream>
using namespace std;
int main() {
int numero,resultado;
cout << "Entre el divisor: ";
cin >> numero;
try {
if(numero == 0)
throw 1;
}
catch(int) {
cout << "Division por cero. Se reemplazo el 0 por 1." << endl;
numero = 1;
}
resultado = 10/numero;
cout << resultado << endl;
}
return 0;
Algoritmo 13: Divide el número 10 entre un número dado por el usuario, garantizando
la continuación, alternativa 2 en C++
3.7.
Interfaces Gráficas de Usuario (Eventos)
Para trabajar con un computador es necesario tener control y poder realizar operaciones
sobre los estados del sistema computacional. Lo anterior es logrado mediante una interfaz ,
es decir, un espacio donde ocurre la interacción entre el sistema y el usuario.
Desde los años 50’s, cuando se diseñaron los primeros teclados para computador, hasta
finales de los años 80’s el método de interacción más usado eran los comandos (antes de
los 50’s se usaban las tarjetas perforadas). Ellos son ejecutados escribiendo en el shell 11 el
nombre del comando y pulsando la tecla enter.
Los ejemplos de código que se han visto hasta ahora han sido creados con interfaces
por lı́nea de comandos (CLI). Cada vez que se ejecutan estos programas se pide al usuario
que se entre un dato, éste debe escribirlo y pulsar enter. El flujo del programa en la
programación por comandos es único y solo es cambiado de curso en algunos puntos.
Desde los años 80’s los sistemas operativos gráficos (como Windows y MacOS) y los
entornos como el X Window System hicieron que muchos programadores cambiaran su pa11
Un shell puede verse como la interfaz entre un programa y el usuario. Este programa puede ser un sistema
operativo, un interpretador o una aplicación. Ejemplos de shells incluyen los Unix (sh, csh, bash, zsh,
tcsh, etc.) el de DOS (command.com), el de Python y el de Tcl/Tk (wish).
57
Interfaz
3 Noción de Lenguaje
radigma para crear interfaces gráficas de usuario (GUI). Una GUI representa la información
y acciones que están disponibles al usuario. Los componentes de una GUI son comúnmente
agrupados en el llamado WIMP (siglas en inglés de Ventana, Icono, Menú, Dispositivo
apuntador).
Existen muchas librerı́as conocidas para crear GUIs. Algunas de ellas están diseñadas
para un sistema operativo especı́fico, por ejemplo el Windows API se usa en Windows,
Cocoa se usa en MacOS y Motif se usa en Unix. Otras librerı́as para varias plataformas
incluyen:
GTK+
Página oficial: http://www.gtk.org/
Algunas aplicaciones hechas con Gtk+: Google Chrome, Gimp, Abiword, Gnumeric.
wxWidgets (antes llamado wxWindows)
Página oficial: http://wxwidgets.org/
Algunas aplicaciones hechas con wxWidgets: BitTorrent, Audacity.
Qt
Página Oficial: http://www.qtsoftware.com/products
Algunas aplicaciones hechas con Qt: Adobe Photoshop, Google Earth, KDE, Mathematica, VLC.
Swing:
Página oficial: http://java.sun.com/javase/6/docs/technotes/guides/swing/
Algunas aplicaciones hechas con Swing: Limewire, Netbeans, Morpheus, JM Studio.
Un ejemplo del uso de estas librerı́as puede verse en el algoritmo 14, el cual crea el famoso
“Hola Mundo” con Gtk+ en el lenguaje C.
Algunas CLIs proveen funcionalidades que en las GUIs son muy difı́ciles de expresar.
Por ejemplo, en los shells de DOS y Unix, los resultados de la ejecución de un comando
pueden ser usados por otro comando (utilizando el caracter pipe ’|’). Lo que hacen los
programadores que solo utilizan CLIs es usar una librerı́a para consola (llamada ncurses
en Linux y PDCurses en Windows), con la cual pueden embellecer la interfaz texto mediante el uso de caracteres especiales. El algoritmo 15 muestra el “Hola Mundo” con curses.
58
3.7 Interfaces Gráficas de Usuario (Eventos)
#include <gtk/gtk.h>
int main(int argc, char *argv[])
{
GtkWidget *window;
GtkWidget *label;
gtk_init(&argc, &argv);
window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
gtk_window_set_title (GTK_WINDOW (window), " ");
g_signal_connect (G_OBJECT (window), "delete-event",
gtk_main_quit, NULL);
label = gtk_label_new ("Hola Mundo!");
gtk_container_add (GTK_CONTAINER (window), label);
gtk_widget_show_all (window);
gtk_main();
}
return 0;
Algoritmo 14: Imprime ’HolaMundo!’ en una ventana usando Gtk+
#include <ncurses.h>
int main()
{
initscr();
printw("Hola Mundo!");
refresh();
getch();
endwin();
}
return 0;
Algoritmo 15: Imprime ’HolaMundo!’ por lı́nea de comandos usando curses
Como los programas con GUI no pueden esperar a que el usuario pulse enter para
ejecutar operaciones, ya que las operaciones pueden llamarse por medio de otros dispositivos
de entrada como el mouse, la interacción se realiza por medio de eventos . Los eventos son
hechos que suceden y son generados por interrupciones tanto de software como de hardware.
Ejemplos de eventos son: tarjetas de red requiriendo un servicio, la presión de un botón
del mouse, el cron del sistema cuando llega a algún momento especı́fico, un drag-anddrop en el sistema operativo, el pulso de un botón del teclado. El funcionamiento de un
programa creado por eventos está basado en un proceso llamado el event-loop, quien
59
Evento
3 Noción de Lenguaje
es el encargado de estar pendiente de los eventos que sucedan y llamar al manejador de
eventos respectivo (despacha el evento). En el código de arriba el event-loop se llama con
la función gtk-main().
En la actualidad los dispositivos móviles como PDAs, tablets y celulares están cambiando
las GUI, debido a que las interfaces WIMP no son óptimas para trabajar con programas
interactivos que tengan un continuo flujo de señales de entrada o con programas interactivos 3D. Las nuevas GUIs son llamadas post-WIMP. Ejemplos de dispositivos que usan
post-WIMPs son los iPods, iPads, las nuevos cajeros automáticos y los celulares que corren
sistemas como Android.
3.8.
Referencia
Referencias y Apuntadores
Implı́citamente cuando se hace una asignación a una variable, en realidad lo que se
realiza por debajo es tomar un identificador y denotar en él la dirección de una ubicación
mutable en la memoria. Dicha dirección es llamada una referencia , y es el contenido de
dicha referencia el que es modificado por la asignación de la variable [12].
a
b
c
b
=
=
=
=
5
a
7
3
Algoritmo 16: Asigna cuatro variables en Python (análisis de referencias)
Suponga que se tiene el algoritmo 16. La primera lı́nea crea una referencia a una ubicación
donde estará contenido el número 5. La dirección de dicha ubicación la tiene la variable a.
En la segunda lı́nea, una nueva referencia a la ubicación que contiene el 5 es creada con la
variable b. En la tercera lı́nea se crea otra referencia con la asignación de c. Por último, al
hacer la asignación de b se cambia la referencia que tenı́a dicha variable a otra ubicación
cuyo contenido será 3.
En el ejemplo 3.9.1 se ve gráficamente los cambios en las referencias de otro código en
Python. Al lado izquierdo de cada figura puede verse la evolución del programa mientras
que en el lado derecho se muestra las referencias que se van creando o modificando y sus
respectivas ubicaciones con los valores a los cuales hacen referencia.
Ejemplo 3.9.1
Primero se crea la variable s y se le asigna el valor "MURCIELAGO".
>>> s = "MURCIELAGO"
>>>
Luego se crea la variable t y se le asigna el valor "LAGO".
60
s
"MURCIELAGO"
3.8 Referencias y Apuntadores
>>> s = "MURCIELAGO"
>>> t = "LAGO"
>>>
s
"MURCIELAGO"
t
"LAGO"
Después se crea la variable i y se le asigna el resultado de aplicar la función find a la
variable s con t como parámetro. Inmediatamente después se imprime el valor de i. Esta
última instrucción (print i) no altera las referencias ni los valores de las ubicaciones.
>>>
>>>
>>>
>>>
6
>>>
s = "MURCIELAGO"
t = "LAGO"
i = s.find(t)
print i
s
"MURCIELAGO"
t
"LAGO"
i
6
Acto seguido se asigna t a s. Esto significa que la referencia que tenı́a t a la segunda
ubicación de nuestra memoria se pierde. t ahora hace referencia a la primera ubicación, al
igual que s.
>>>
>>>
>>>
>>>
6
>>>
>>>
s = "MURCIELAGO"
t = "LAGO"
i = s.find(t)
print i
s
"MURCIELAGO"
t
"LAGO"
t = s
i
6
En el momento en que se pierde la referencia a la segunda ubicación, el garbage collector
(proceso que elimina la información que no se volverá a usar) libera ese espacio para un
posterior uso.
>>>
>>>
>>>
>>>
6
>>>
>>>
s = "MURCIELAGO"
t = "LAGO"
i = s.find(t)
print i
s
t = s
i
"MURCIELAGO"
t
6
Si en este momento se quiere conocer el valor asociado a una variable que no ha sido
asignada previamente, sale un error. Sin embargo, la memoria del ejemplo no cambia, no
se hace una reserva anterior, es decir, no se tiene en cuenta que dicha variable puede ser
declarada posteriormente.
61
Garbage Collector
3 Noción de Lenguaje
>>> s = "MURCIELAGO"
>>> t = "LAGO"
>>> i = s.find(t)
>>> print i
6
>>> t = s
>>> a
Traceback (most recent call last):
File "<pyshell#16>", line 1 , in -toplevela
NameError: name 'a' is not defined
>>>
s
"MURCIELAGO"
t
i
6
Ahora se asigna la variable s al dato "CIELO". Esto hace que se cree una nueva referencia a
la segunda ubicación de la memoria del ejemplo. No obstante, la referencia de t no se pierde,
y al hacer un print a dicha variable, el resultado será, efectivamente, "MURCIELAGO".
>>> s = "MURCIELAGO"
>>> t = "LAGO"
>>> i = s.find(t)
>>> print i
6
>>> t = s
>>> a
Traceback (most recent call last):
File "<pyshell#16>", line 1 , in -toplevela
NameError: name 'a' is not defined
>>> s = "CIELO"
>>> print t
"MURCIELAGO"
>>>
s
"MURCIELAGO"
t
"CIELO"
i
6
En el momento en que se borre la variable t, la referencia y el dato en la memoria también
son eliminados.
>>> s = "MURCIELAGO"
>>> t = "LAGO"
>>> i = s.find(t)
>>> print i
6
>>> t = s
>>> a
Traceback (most recent call last):
File "<pyshell#16>", line 1 , in -toplevela
NameError: name 'a' is not defined
>>> s = "CIELO"
>>> print t
"MURCIELAGO"
>>> del t
>>>
62
s
"CIELO"
i
6
3.8 Referencias y Apuntadores
Por último, en el momento en que se intente acceder a la variable t de nuevo, un error
surgirá, tal y como pasó con la variable a.
>>> s = "MURCIELAGO"
>>> t = "LAGO"
>>> i = s.find(t)
>>> print i
6
>>> t = s
>>> a
Traceback (most recent
File "<pyshell#16>",
a
NameError: name 'a' is
>>> s = "CIELO"
>>> print t
"MURCIELAGO"
>>> del t
>>> print t
Traceback (most recent
File "<pyshell#16>",
t
NameError: name 't' is
>>>
s
"CIELO"
i
6
call last):
line 1 , in -toplevelnot defined
call last):
line 1 , in -toplevelnot defined
? ? ?
El manejo de variables con listas y otras estructuras de datos (que se verán en el próximo
capı́tulo) es un poco más complicado. Lo anterior debido a que las referencias llevan toda
la información sobre lo que se referencian y el conjunto de operaciones que son posibles con
ella. Esto hace que si se igualan dos variables que hacen referencia a la misma estructura
de datos, el cambio al dato de una de ellas afecta al dato de la otra.
En el ejemplo 3.9.2 se muestra un manejo de listas en Python y los cambios que van
sucediendo.
Ejemplo 3.9.2
Primero se crea la variable L1 y se le asigna el valor [2,4].
>>> L1 = [2, 4]
>>>
L1
[2, 4]
Luego se agrega un nuevo elemento a la lista L1.
63
3 Noción de Lenguaje
>>> L1 = [2, 4]
>>> L1.append(5)
>>>
L1
[2, 4, 5]
Después se crea una nueva variable L2 y se le asigna la misma referencia de L1.
>>> L1 = [2, 4]
>>> L1.append(5)
>>> L2 = L1
>>>
L1
[2, 4, 5]
L2
Si se agrega un elemento nuevo a la lista L2, se ve afectada también la lista L1, ya que ambas
variables son referencias a la misma lista. La impresión de ambas variables será idéntica.
>>>
>>>
>>>
>>>
>>>
[2,
>>>
[2,
>>>
L1 = [2, 4]
L1.append(5)
L2 = L1
L2.append(7)
print L1
4, 5, 7]
print L2
4, 5, 7]
L1
[2, 4, 5, 7]
L2
La eliminación de un elemento en una de las variables afecta ambas.
>>>
>>>
>>>
>>>
>>>
[2,
>>>
[2,
>>>
>>>
L1 = [2, 4]
L1.append(5)
L2 = L1
L2.append(7)
print L1
4, 5, 7]
print L2
4, 5, 7]
del L2[1]
L1
[2, 5, 7]
L2
Sin embargo, es posible hacer copias de la lista para que las dos variables L1 y L2 no se
afecten mutuamente cuando se realice un cambio a alguna de ellas.
64
3.8 Referencias y Apuntadores
>>> L1 = [2, 4]
>>> L1.append(5)
>>> L2 = L1
>>> L2.append(7)
>>> print L1
[2, 4, 5, 7]
>>> print L2
[2, 4, 5, 7]
>>> del L2[1]
>>> L2 = L1[:]
>>> L1 == L2
True
>>>
L1
[2, 5, 7]
L2
[2, 5, 7]
Se cambia la lista L1, no se afecta la otra lista (no son la misma lista).
>>> L1 = [2, 4]
>>> L1.append(5)
>>> L2 = L1
>>> L2.append(7)
>>> print L1
[2, 4, 5, 7]
>>> print L2
[2, 4, 5, 7]
>>> del L2[1]
>>> L2 = L1[:]
>>> L1 == L2
True
>>> L1.reverse()
>>> L1 == L2
False
>>>
L1
[7, 5, 2]
L2
[2, 5, 7]
? ? ?
Los apuntadores , al igual que las referencias, son variables que contienen la dirección
de memoria de un dato. Sin embargo, ellos se diferencian de las referencias en que son
más flexibles y más generales. Adicionalmente, los apuntadores no permiten un proceso
automático de garbage collection.
El manejo de apuntadores y referencias está ligado al lenguaje de programación. Por
ejemplo, Python y Java manejan referencias, mientras que C y C++ manejan apuntadores.
El algoritmo 17 muestra un ejemplo de cómo se manejan apuntadores en C++.
Si se compilara y ejecutara el programa anterior se mostrarı́a por pantalla los valores de
las variables algunNumero y ptrAlgunNumero. El primero es 12345 mientras que el segundo
es un número hexadecimal12 que es, en efecto, la dirección de memoria donde se encuentra
el valor de algunNumero.
12
No se puede decir exactamente el valor porque éste depende de muchos factores entre los cuales se
encuentra momento en que se compile el programa, el estado de la memoria y el compilador que se use.
65
Apuntadores
3 Noción de Lenguaje
#include <iostream>
using namespace std;
int main(){
int algunNumero = 12345;
int *ptrAlgunNumero = &algunNumero;
cout << "algunNumero = " << algunNumero << endl;
cout << "ptrAlgunNumero = " << ptrAlgunNumero << endl;
}
return 0;
Algoritmo 17: Declara, asigna e imprime un entero y un apuntador a entero en C++
Muchos programadores afirman que el manejo de referencias es mucho más seguro que
el manejo de apuntadores (en cuanto a errores y uso malicioso). Sobre esto hay que hacer
notar que la seguridad depende mucho de la implementación de los tipos de datos y del
compilador del lenguaje de programación.
3.9.
Declaraciones y Tipos
Los lenguajes de programación de alto nivel permiten crear y usar identificadores para
los nombres de funciones y variables, es decir, permiten introducir las funciones y variables
como nombres para algún valor. En el caso especial de las variables, ellas tienen cuatro
caracterı́sticas:
Tipo de dato
Tipos de datos:
Las variables que pueden declararse en un algoritmo son asignadas a valores. Dichos
valores están dentro de un dominio de valores y los dominios son llamados los tipos de
datos . Por ejemplo, cuando una variable x es de tipo entero, esto quiere decir que el
dominio de valores que puede asignarsele a x es el de los números enteros. Además, las
funciones a las cuales puede aplicarse la variable x deben tener una semántica sensible a los
enteros (e.g. función abs(), que devuelve el valor absoluto de un número, tiene significado
al aplicarse con la variable x, sin embargo la función strlen(), que devuelve el tamaño de
una cadena no puede tener como argumento a x).
Los lenguajes que no restringen los dominios de las variables son denominados lenguajes
no tipados. En estos lenguajes las variables no tienen tipo, lo que quiere decir que se les
puede asignar cualquier valor. Los lenguajes tipados pueden ser explı́citos, cuando los tipos
son parte de la sintaxis del lenguaje, ó implı́citos. Python, por ejemplo, es un lenguaje de
programación tipado implı́cito.
El sistema de tipos de un lenguaje determina, en gran parte, el comportamiento de un
66
3.9 Declaraciones y Tipos
programa, es decir, la presencia o ausencia de errores.
Existen dos clases de errores de tipos:
Atrapados. Los que causan que la ejecución pare inmediatamente, por ejemplo la
división por cero.
No atrapados. Los que permiten que siga la ejecución pero puede llevar a comportamientos inesperados, por ejemplo un salto a una dirección de memoria desconocida.
Un programa es seguro si no presenta errores no atrapados [6]. Un lenguaje es seguro
si no permite crear programas con un mal comportamiento, es decir, solo permite crear
programas seguros. La seguridad tiene un costo en tiempo debido a que tiene que hacer
varios chequeos y análisis que pueden ser complejos. El ejemplo 3.8.1 muestra la diferencia
entre lenguajes seguros e inseguros.
Ejemplo 3.8.1
El algoritmo 18 muestra un programa hecho en Python.
x = 5
y = "37"
z = x + y
Algoritmo 18: Asigna tres variables en Python (análisis de tipos)
El algoritmo 18 sacará un error en Python ya que en realidad se está tratando de sumar
un entero con una cadena de caracteres. Tal vez fue una equivocación del programador y
por eso lenguajes como Visual Basic tienen definidas funciones para hacer un casting de
datos, es decir, un cambio de tipos para asegurar que el flujo de ejecución continue. Ahora,
el algoritmo 19 muestra un programa hecho en C++.
#include <iostream>
using namespace std;
int main(void){
int x = 5;
char y[] = "37";
char *z = x + y;
}
return 1;
Algoritmo 19: Asigna tres variables en C++ (análisis de tipos)
El algoritmo 19 no suma las variables x y y. Por el contrario asigna a z la dirección
de memoria 5 ubicaciones después de la dirección de memoria de y. Como no se sabe que
67
3 Noción de Lenguaje
datos hay en esa dirección, el uso de la variable z es peligroso y puede llevar un terminación
brusca del programa.
? ? ?
Los lenguajes de programación que no permiten la ejecución de una operación con tipos
errados son llamados lenguajes fuertemente tipados. En el ejemplo 3.8.1 se mostró que
Python es un lenguaje fuertemente tipado mientras que Visual Basic no lo es.
El chequeo de tipos puede ser estático o dinámico. En el chequeo estático, el análisis
es realizado en tiempo de compilación mientras que en el chequeo dinámico, se realiza en
tiempo de ejecución. En el chequeo dinámico las variables pueden tener un tipo dependiendo
de la dirección del flujo de ejecución
En Python es posible un programa como el del algoritmo 20, donde primero se asigna a
la variable m un valor de tipo entero y luego se le asigna un valor de tipo lista de enteros.
m = 5
m = [1, 2, 3]
Algoritmo 20: Cambio de tipo de una variable en Python
Sin embargo, lenguajes como C/C++ no permitirı́an un programa ası́ ya que en tiempo
de compilación se tendrı́a que declarar la variable m como entero, asignarle el valor 5 y en
la siguiente lı́nea se le asignarı́a un valor diferente de tipo lista.
El cuadro 3.1 muestra algunos de los lenguajes de programación que se vieron en la
figura 3.1 y su caracterización: si son de chequeo estático o dinámico, fuerte o débilmente
tipados, y seguros o inseguros.
Alcance
Alcance:
El alcance de una variable es la región dentro de la cual las referencias a la variable se
asocian con el identificador de la variable. La región excluye cualquier otra región interior
que contengan declaraciones que usan el mismo identificador.
El concepto de alcance tiene sentido cuando se piensa en la cantidad reducida de identificadores que se usan para las variables (e.g. x es el nombre de variable por excelencia,
mientras que i es el identificador tı́pico para los ı́ndices de una lista y para los contadores).
La manera de manejar varias ocurrencias de variables se determina de dos maneras:
Alcance Estático – La variable siempre se refiere a su contorno más cercano. Los contornos
son cuadros que delimitan las regiones. La figura 3.10 muestra un programa en Python
donde la variable x que se encuentra en el contorno más interior se refiere a la x que
se pasa como argumento a la función f3, mientras que la x de la última lı́nea se refiere
a la x que se pasa como argumento a la función f1.
La figura 3.11 muestra un programa en Python un poco más complejo. Se definen dos
contornos al interior del contorno exterior que son independientes entre ellos. Puede
68
3.9 Declaraciones y Tipos
Lenguaje
ADA
BASIC
C
C++
C#
FORTRAN
HASKELL
JAVA
JAVASCRIPT
LISP
ML
PASCAL
PERL
PHP
PYTHON
RUBY
SCHEME
SMALLTALK
VISUAL BASIC
Estático/Dinámico
Estático
Estático
Estático
Estático
Estático
Estático
Estático
Estático
Dinámico
Dinámico
Estático
Estático
Dinámico
Dinámico
Dinámico
Dinámico
Dinámico
Dinámico
Hı́brido
Fuerte/Débil
Fuerte
Débil
Débil
Fuerte
Fuerte
Fuerte
Fuerte
Fuerte
Débil
Fuerte
Fuerte
Fuerte
Débil
Débil
Fuerte
Fuerte
Débil
Fuerte
Hı́brido
Seguro/Inseguro
Seguro
Seguro
Inseguro
Inseguro
Ambos13
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Seguro
Cuadro 3.1: Lenguajes de programación y tipos de datos
def f1(x):
def f2(y):
def f3(x):
print x+y
return x
Figura 3.10: Diagrama de contorno
verse también que los nombres de las funciones, por ser identificadores, pueden pasarse
como parámetros de otras funciones.
Alcance Dinámico – Se tiene una pila14 para cada identificador en la que se introducen las
variables con un nombre especı́fico cada vez que se encuentra una. De esta manera
14
El concepto de pila se puede ver en el siguiente capı́tulo.
69
3 Noción de Lenguaje
def f5(z):
def f6(a, b, c):
def f7(a):
return a+c
a(f7, b)
def f8(f, x):
f(z, x)
Figura 3.11: Diagrama de contorno
cuando el flujo de ejecución sale de los alcances se van eliminando los elementos de
la pila (la variable que se evalúa siempre es la que está en el tope de la pila).
Para crear un alcance dinámico usualmente se definen bloques que permiten delimitar claramente un alcance. Cada lenguaje de programación tiene su forma de definir
bloques: en Python los bloques se identifican por la indentación que tengan las expresiones, en C se tienen las llaves { } para los bloques, mientras que en Pascal se
tienen las palabras reservadas begin y end.
procedure Principal();
var
x : integer;
begin
x := 1;
while x <= 10 do
begin
writeln(x);
x := x + 1;
end;
end;
Algoritmo 21: Definición de dos bloques de ejecución en Pascal
El algoritmo 21, escrito en Pascal, define dos bloques: el bloque de la definición de la
función y el bloque de la estructura while.
Ligadura
Ligadura:
Ligadura se refiere a la asociación de valores con identificadores. Mientras que una asignación cambia el valor asociado a un identificador, la ligadura crea dicha asociación.
70
3.9 Declaraciones y Tipos
Cuando un identificador está ligado a un valor se dice que es una referencia a ese valor.
Dicha referencia puede ser considerada como la dirección de memoria donde se encuentra
alojado el valor asociado al identificador, es decir, es un dato que contiene la llave para
llegar a otro dato.
Al crear una variable, se reserva un espacio en la memoria donde se alojará el valor
asociado al identificador de la variable. El tamaño del espacio depende del tipo de dato de
la variable.
Cuando una ligadura es compartida por varias funciones, un cambio que realice una función puede ser vista por todas las demás. En el algoritmo 22 se muestran dos funciones par
e impar, escritas en Python, que comparten la variable x. Puede verse que la comunicación
entre las dos funciones no se realiza pasando datos explı́citamente sino cambiando el estado
de la variable que comparten.
def par(impar):
if x == 0:
return True
else:
return impar(x-1)
def impar(x):
if x == 0:
return False
else:
return par(impar, x-1)
Algoritmo 22: Definición de dos funciones que comparten una variable en Python
El tiempo durante el cual se caracteriza la ligadura de una variable es llamado el tiempo
de ligadura. Existen cuatro diferentes tiempos de ligadura [18]:
1. Tiempo de ejecución
2. Tiempo de compilación
3. Tiempo de implementación del lenguaje
4. Tiempo de definición del lenguaje
Cuando se hace una simple asignación en Python como en el algoritmo 23, se pueden
identificar los diferentes tiempos de ligadura para las caracterı́sticas de la variable. El
conjunto de posibles tipos para la variable x (e.g. entero, flotante, binario, caracter) se
fijan en el tiempo de definición del lenguaje. El tipo de la variable x se fija en el tiempo
de compilación o ejecución dependiendo del lenguaje (para Python es en ejecución). El
conjunto de posibles valores para x en el tiempo de implementación del lenguaje. El valor
de x se define en el tiempo de ejecución. La representación de la constante 10 se hace en el
71
3 Noción de Lenguaje
tiempo de definición del lenguaje. Por último, la propiedades para el operador + se escogen
en el tiempo de definición del lenguaje.
x = x + 10
Algoritmo 23: Asignación simple en Python
Visibilidad
Visibilidad:
Una variable es visible en un subprograma si el identificador asociado a ella es parte del
ambiente de ese subprograma. Si el identificador existe pero no es parte del ambiente del
subprograma que se encuentra actualmente en ejecución, se dice entonces que la variable
asociada está escondida de ese subprograma.
Durante su “tiempo de vida” una variable puede tener más de un nombre o identificador;
esto es, existen muchas asociaciones en diferentes ambientes, cada una con un diferente
nombre para la variable. El algoritmo 24 es un programa que muestra un caso en el que
en una misma función (fun1) se tienen dos identificadores que hacen referencia a la misma
variable (j por ser parámetro e i por ser variable global).
i = 0
def fun1(j):
print j
def fun2():
fun1(i)
fun2()
Algoritmo 24: Dos identificadores con la misma referencia en una función en Python
El paso de una variable como parámetro a una función se puede realizar de dos formas:
por valor o por referencia. En el paso de parámetros por valor se crea un nueva referencia
para cada parámetro formal de la función, es decir, las asignaciones dentro de la función
son locales y no afectan la variable fuera del alcance de la función. Por el contrario, el
paso de parámetros por referencia implica que la variable con la que se hace la llamada
a la función pueda cambiar su contenido. Lo anterior significa que en los llamados por
referencia se envı́a la dirección de memoria de la variable mientras que en los llamados por
valor, como su nombre lo indica, se envı́a el contenido de la dirección de memoria. En el
programa anterior, la variable i dentro de la función fun2 la variable i es pasada por valor
a fun1.
72
4 Noción de Tipo Abstracto de Datos
4.1.
Tipos Abstractos de Datos
Un tipo abstracto de datos (TAD), es la conjunción de variables, operaciones y aserciones
(además de documentación) que modela un dominio de datos.
Un TAD se diferencia de un tipo de dato en que es especificado de forma precisa y
diseñado independiente de cualquier implementación (en algunos casos los TADs no pueden
ser implementados en un hardware o software especı́fico).
Los lenguajes de programación traen de forma nativa un conjunto de tipos que son útiles
pero desafortunadamente insuficientes para resolver todo tipo problemas. Por ejemplo, si
se quiere tomar los datos de todos los empleados de una empresa y realizar consultas y
reportes, resulta muy ineficiente (y dispendioso) crear una variable para cada dato de cada
empleado (e.g. nombre, cédula, código, cargo, etc.)
La definición de un TAD debe ser independiente de un lenguaje, no obstante ella es
muy descriptiva y se ajusta bastante a las necesidades del diseñador. Por ello no es raro
encontrar diferentes definiciones de listas, colas, árboles, etc. Todas las definiciones deben
tener tres componentes comunes: la estructura del TAD (la representación), una colección
de operadores y un conjunto de axiomas (para el TAD y cada una de las operaciones).
Un tipo abstracto de datos se especifica formalmente de la siguiente manera1 :
TAD hnombrei
hObjeto Abstractoi
{inv : hInvariante del T ADi}
Operaciones Primitivas:
• hOperacion 1i :
•
...
• hOperacion ni :
hentradasi
→ hsalidai
hentradasi
→ hsalidai
El TAD debe llamarse con un nombre único que lo identifique plenamente; debe expresarse el objeto que se está modelando de una manera matemática o gráfica (entre más formal
mejor), pero que muestre claramente el objeto y que pueda ser usado para referenciarlo en
las notaciones y formalismos de las operaciones; debe establecerse una serie de condiciones que no varı́an nunca al interior del TAD; y deben listarse las operaciones que pueden
1
Esta es la misma aproximación de J. Villalobos en [23].
73
4 Noción de Tipo Abstracto de Datos
realizarse con los objetos del tipo del TAD. Las operaciones se especifican con las entradas
a ellas y la salida que retornará el proceso (se escribe el tipo de dato de cada entrada y
de la salida, tal como el contrato que se da como paso inicial de la receta para diseñar
programas en [11]). Adicionalmente para cada una de las operaciones se debe escribir su
comportamiento a manera de aserciones: una para mostrar qué se debe cumplir antes de
ejecutar la operación (precondición) y otra para decir cómo queda el mundo después de
terminar el proceso (postcondición).
hprototipo de la operacioni
”hbreve descripcion de la operacioni”
{pre : . . .}
{post : . . .}
La precondición y la postcondición deben ser lo más formal posible por dos razones:
1. El formalismo describe el propósito de la operación sin lugar a ambigüedades y con
mucha exactitud.
2. La formalidad acerca el diseño a la implementación, es decir, entre más formal sea el
diseño del TAD más fácil será concretizarlo en algún lenguaje de programación.
Los siguientes ejemplos muestran diferentes TADs y sus especificaciones.
Ejemplo 4.1.1
Suponga que una compañı́a tiene la información de Nombre, Foto, Documento de identidad,
Cargo, y Sueldo por cada empleado. Si se quisiera almacenar estos datos serı́a desastroso
usar una variable por cada dato o empleado (imagine una empresa con 1500 empleados).
Una forma eficiente es crear un tipo de dato Empleado para guardar la información:
TAD Empleado
Gráficamente:
Foto
Nombre
Cédula
Cargo
Sueldo
Textualmente:
Empleado = {N ombre : hnombrei, Cedula : hcedulai,
Cargo : hcargoi, Sueldo : hsueldoi, F oto : hf otoi}
74
4.1 Tipos Abstractos de Datos
{inv : Empleado.Sueldo ≥ 535600}
Operaciones Primitivas:
•
•
•
•
•
•
•
•
•
CrearEmpleado:
AgregarNombre:
AgregarCedula:
CambiarSalario:
CambiarCargo:
CambiarFoto:
InfoSalario:
InfoCargo:
TieneFoto:
→ Empleado
→ Empleado
→ Empleado
→ Empleado
→ Empleado
→ Empleado
→ Entero
→ T exto
→ Booleano
Empleado × T exto
Empleado × T exto
Empleado × Entero
Empleado × T exto
Empleado × Imagen
Empleado
Empleado
Empleado
CrearEmpleado()
“Crea un nuevo empleado con los datos vacı́os”
{pre : TRUE}
{post : emp = {N ombre : ””, Cedula : ””, Cargo : ””, Sueldo : 535600,
F oto = 2}}
AgregarNombre(emp, n)
“Asigna un nombre a un empleado sin nombre”
{pre : emp = {N ombre : ””, . . .},
{post : emp.N ombre = n}
n ∈ T exto}
AgregarCedula(emp, c)
“Asigna una cédula a un empleado sin cédula”
{pre : emp = {. . . , Cedula : ””, . . .},
{post : Emp.Cedula = c}
c ∈ T exto}
CambiarSalario(emp, s)
“Cambia el salario de un empleado”
{pre : emp = {. . . , Salario : hsalarioi, . . .},
535600}
{post : emp.Salario = s}
s ∈ Entero,
s ≥
CambiarCargo(emp, c)
“Cambia el cargo de un empleado”
{pre : emp = {. . . , Cargo : hcargoi, . . .},
{post : emp.Cargo = c}
c ∈ T exto}
75
4 Noción de Tipo Abstracto de Datos
CambiarFoto(emp, f )
“Cambia la foto de un empleado”
{pre : emp = {. . . , F oto : hf otoi},
{post : emp.F oto = f }
f ∈ Imagen}
InfoSalario(emp)
“Retorna el salario de un empleado”
{pre : emp = {. . . , Salario : hsalarioi, . . .}}
{post : hsalarioi}
InfoCargo(emp)
“Retorna el cargo de un empleado”
{pre : emp = {. . . , Cargo : hcargoi, . . .}}
{post : hcargoi}
TieneFoto(emp)
“Informa si un empleado tiene foto”
{pre : emp = {. . . , F oto : hf otoi} ó emp = {. . . , F oto : 2}}
{post : True si emp.F oto = hf otoi ∨ False si emp.F oto = 2}
? ? ?
Ejemplo 4.1.2
Se quiere diseñar un tipo abstracto de datos para modelar un conjunto de valores binarios
y sus operaciones principales.
TAD Binario
b1 b2 . . . bn
{inv : bi ∈ {1, 0},
76
n ≥ 1}
4.1 Tipos Abstractos de Datos
Operaciones Primitivas:
•
•
•
•
•
•
•
•
•
•
CrearBin:
CorrimientoDer:
CorrimientoIzq:
Not:
And:
Or:
SumarBin:
Complementoa2:
bin2Dec:
esBin:
Entero
Binario
Binario
Binario
Binario × Binario
Binario × Binario
Binario × Binario
Binario
Binario
Binario
→ Binario
→ Binario
→ Binario
→ Binario
→ Binario
→ Binario
→ Binario
→ Binario
→ Entero
→ Booleano
CrearBin(e)
“Construye un número binario a partir de un número entero”
{pre : e ∈ Entero+ }
{post : b1 b2 . . . bn−1 bn |
(b1 × 2n ) + (b2 × 2n−1 ) + . . . + (bn−1 × 21 ) + (bn × 20 ) = e}
CorrimientoDer(bin)
“Realiza un corrimiento a la derecha (un solo bit) de un número binario”
{pre : bin = b1 b2 . . . bn−1 bn }
{post : bin = b1 b2 . . . bn−1 }
CorrimientoIzq(bin)
“Realiza un corrimiento a la izquierda (un solo bit) de un número binario”
{pre : bin = b1 b2 . . . bn−1 bn }
{post : bin = b1 b2 . . . bn−1 bn x | x = 0}
Not(bin)
“Modifica un número binario con su negación”
{pre : bin = b1 b2 . . . bn }
{post : bin =
b01 b02 . . . b0n
| ∀i
b0i
=
0 si bi = 1
1 si bi = 0
1 ≤ i ≤ n}
77
4 Noción de Tipo Abstracto de Datos
And(bin1, bin2)
“Retorna la conjunción de dos números binarios”
{pre : bin1 = b1 b2 . . . bn , bin2 = c1 c2 . . . cn }
{post : bin1 and bin2 = d1 d2 . . . dn |
1 si bi = ci = 1
∀i di =
1 ≤ i ≤ n}
0 en otro caso
Or(bin1, bin2)
“Retorna la disjunción de dos números binarios”
{pre : bin1 = b1 b2 . . . bn , bin2 = c1 c2 . . . cn }
{post : bin1 or bin2 = d1 d2 . . . dn |
0 si bi = ci = 0
∀i di =
1 ≤ i ≤ n}
1 en otro caso
SumarBin(bin1, bin2)
“Retorna la suma de dos números binarios”
{pre : bin1 = b1 b2 . . . bn , bin2 = c1 c2 . . . cn }
{post : bin1 + bin2 = d0 d1 d2 . . . dn |
∀i di = bi + ci + carryi+1
donde se cumple
 que
0 (carryi = 0) si bn



1 (carryi = 0) si bn
bi + ci =
 1 (carryi = 0) si bn


0 (carryi = 1) si bn
= cn = 0
= 1 ∧ cn = 0
= 0 ∧ cn = 1
= cn = 1
Complementoa2(bin)
“Retorna el complemento a 2 (la suma de su negación con el número 1)
de un número binario”
{pre : bin = b1 b2 . . . bn−1 bn }
{post : Not(bin) + 1}
bin2Dec(bin)
“Retorna el número entero correspondiente un número binario”
{pre : bin = b1 b2 . . . bn−1 bn }
{post : e | e = (b1 × 2n ) + (b2 × 2n−1 ) + . . . + (bn−1 × 21 ) + (bn × 20 )}
78
4.1 Tipos Abstractos de Datos
esBin(bin)
“Informa si un número es binario”
{pre : bin}
{post : True si bin = b1 b2 . . . bn ∧ ∀i bi = {0, 1} 1 ≤ i ≤ n
False de lo contrario}
? ? ?
Las operaciones primitivas se dividen en dos grupos: principales y secundarios. El grupo
principal está compuesto por las operaciones:
Constructoras Encargadas de crear las estructuras internas del tipo abstracto de datos. En
los ejemplos anteriores las operaciones CrearEmpleado y CrearBin son constructoras.
Modificadoras Son aquellas operaciones que alteran el estado de los elementos del TAD.
Las operaciones modificadoras en los ejemplos son:
AgregarNombre, AgregarCedula, CambiarSalario, CambiarCargo, CambiarFoto,
para el TAD Empleado
CorrimientoDer, CorrimientoIzq y Not, para el TAD Binario
Analizadoras Operaciones que consultan el estado de los elementos y retornan información
(no cambian los estados). Las operaciones analizadoras en los ejemplos son:
InfoSalario, InfoCargo y TieneFoto, para el TAD Empleado
And, Or, SumarBin, Complementoa2, bin2Dec y esBin, para el TAD Binario
El grupo secundario está compuesto por las operaciones:
Destructoras Son operaciones que eliminan por completo los elementos del objeto del tipo
del TAD. Luego de ejecutar operaciones destructoras los objetos no pueden volver a
utilizarse.
Persistencia Con ellas se puede guardar en un dispositivo de memoria secundaria (disco
duro, CD/DVD, USB, entre otros) la información de los objetos.
En los ejemplos 4.1.1 y 4.1.2 no hay operaciones destructoras ni de persistencia.
Cualquier operación que no sea primitiva es considerada una operación adicional y no
pertenece al TAD. Las operaciones adicionales deben construirse a partir de las operaciones
primitivas.
A partir de la siguiente sección se mostrarán los diseños, implementaciones en lenguaje C
y ejemplos de uso de los principales tipos abstractos de datos. El diseño de estos TADs son
modificaciones de los diseños propuestos por J. Villalobos en [23]. La implementación de
los TADs utilizará dos archivos diferentes, uno llamado el archivo de encabezado (header
79
4 Noción de Tipo Abstracto de Datos
file que comunmente tiene extensión .h), y otro llamado el archivo fuente (source file con
extensión .c).
Los archivos de encabezado proveen una interfaz de las funciones y estructuras de datos
especı́ficas que la librerı́a incluye. El siguiente es un ejemplo de un archivo de encabezado
para la librerı́a libreria.h (se excluyó la mayor parte de la documentación, es decir,
aquella que muestra el autor de la librerı́a, la versión, forma de uso, changelog, etc):
#i f n d e f
#define
LIBRERIA H
LIBRERIA H
/∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗
∗ LIBRERIAS NECESARIAS
∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗/
#include <s t d i o . h>
/∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗
∗ ESTRUCTURAS DE DATOS
∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗/
struct a l g o {
i n t uno ;
f l o a t dos ;
};
/∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗
∗ OPERACIONES DEL TAD
∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗ ∗/
/∗ CONSTRUCTORAS ∗/
void o p e r a c i o n 1 ( ) ;
/∗ MODIFICADORAS ∗/
int operacion2 ( int ) ;
/∗ ANALIZADORAS ∗/
float operacion3 ( float ) ;
#endif
Las rutinas de preprocesamiento
#ifndef LIBRERIA H
#define LIBRERIA H
#endif
son necesarias para evitar las múltiples declaraciones y errores potenciales con diferentes
archivos fuente. En cada librerı́a se debe definir (si no está definido) el nombre único de ella.
Las librerı́as necesarias son aquellas requeridas por algunas funciones o que van a ser usadas;
se incluyen por medio de la rutina de preprocesamiento #include. Las estructuras de datos
declaran la armadura o configuración de los datos, tı́picamente con la noción de registro
llamada struct. Por último las operaciones del TAD son las funciones que manejan las
estructuras de datos. En este archivo solo se especificará las declaraciones de las funciones
80
4.2 Listas
y no la definición de ellas, esto quiere decir que solo se coloca el prototipo de la operación
como una expresión (finaliza con punto y coma “;”).
El archivo fuente contiene las definiciones de las funciones declaradas en el archivo de
encabezado. En este archivo solo se debe incluir el archivo de encabezado. El siguiente es
un ejemplo de un archivo fuente libreria.c para libreria.h:
#include ” l i b r e r i a . h”
void o p e r a c i o n 1 ( )
{
p r i n t f ( ” s e c o n s t r u y o e l TAD” ) ;
}
int operacion2 ( int x )
{
return x∗x ;
}
float operacion3 ( float y)
{
return y / 2 ;
}
4.2.
Listas
Una lista es una colección lineal de elementos del mismo tipo. Cada elemento se encuentra
almacenado en una ubicación llamada nodo. Las ubicaciones están numeradas de 1 hasta
n, siendo n la longitud de la lista, es decir, el número de elementos que tiene.
Muchos lenguajes de programación como Python, Ruby y Lisp, tienen incorporado el
tipo de dato Lista de forma nativa. Sin embargo, en esta sección se mostrará cómo diseñar
el TAD Lista de manera que se acomode a las necesidades del programador.
81
4 Noción de Tipo Abstracto de Datos
4.2.1.
Diseño
TAD Lista
he1 , . . . , en i
∀i ei ∈ Elemento, n ≥ 0
Operaciones Primitivas:
•
•
•
•
•
•
•
crearLista:
anxLista:
insLista:
elimLista:
infoLista:
longLista:
vaciaLista:
Lista × Elemento
Lista × Elemento × Entero
Lista × Entero
Lista × Entero
Lista
Lista
→ Lista
→ Lista
→ Lista
→ Lista
→ Elemento
→ Entero
→ Booleano
crearLista()
“Construye una nueva lista, inicialmente vacı́a”
{pre : TRUE}
{post : hi}
anxLista(lst, elem)
“Inserta un elemento al final de la lista”
{pre : lst = hi ó lst = he1 , . . . , en i,
elem ∈ Elemento}
{post : lst = helemi ó lst = he1 , . . . , en , elemi, respectivamente }
insLista(lst, elem, pos)
“Inserta un elemento en la posición dada”
{pre : lst = he1 , . . . , epos−1 , epos , . . . , en i,
elem ∈ Elemento,
1 ≤ pos ≤ n}
{post : lst = he1 , . . . , epos−1 , elem, epos , . . . , en i}
elimLista(lst, pos)
“Elimina el elemento de la posición dada”
{pre : lst = he1 , . . . , epos−1 , epos , epos+1 , . . . , en i,
{post : lst = he1 , . . . , epos−1 , epos+1 , . . . , en i}
82
1 ≤ pos ≤ n}
4.2 Listas
infoLista(lst, pos)
“Retorna el dato correspondiente a la posición dada”
{pre : lst = he1 , . . . , epos , . . . , en i,
{post : epos }
1 ≤ pos ≤ n}
vaciaLista(lst)
“Informa si la lista está vacı́a (i.e. no contiene elementos)”
{pre : lst = hi ó lst = he1 , . . . , en i}
{post : True si lst = hi
False si lst = he1 , . . . , en i}
longLista(lst)
“Retorna la longitud de la lista (i.e. número de elementos)”
{pre : lst = hi ó lst = he1 , . . . , en i}
{post : 0 si lst = hi
n si lst = he1 , . . . , en i}
4.2.2.
Implementaciones
Estructuras Encadenadas Simples
La manera más común de implementar una lista es con estructuras encadenadas. En
esta aproximación una lista es una colección de nodos encadenados de manera simple por
medio de referencias.
Cada nodo consta de la información del elemento (de cualquier tipo) y una referencia
(o apuntador para algunos lenguajes de programación) al siguiente nodo. Gráficamente un
nodo puede verse como la figura 4.1.
Información
Figura 4.1: Nodo con encadenamiento simple
El código para definir un nodo cuya información es un dato tipo entero es el siguiente :
struct nodo {
i n t dato = 0 ;
struct nodo ∗ s i g = NULL;
};
83
4 Noción de Tipo Abstracto de Datos
La lista tiene un apuntador al primer nodo, y cada nodo “apunta” al siguiente hasta que
el último nodo apunta a un elemento nulo (NULL). Un ejemplo de una lista de enteros lst
representada con estructuras encadenadas simples puede verse en la figura 4.2.
lst
12
24
36
48
Figura 4.2: Lista con encadenamiento simple
Como resulta incómodo referirse a la lista como un struct nodo*, es recomendado (mas
no es un requerimiento) realizar una definición de un nuevo apuntador al tipo de dato
struct nodo*. Lo anterior se logra mediante el comando typedef de la siguiente manera:
typedef struct nodo ∗ L i s t a ;
De esta forma siempre podremos referirnos a un dato de tipo struct nodo* como una
Lista.
Para crear las listas se desarrolla una función constructura que solamente creará la
variable lst que apunta al primer nodo de la lista. Esta variable será inicializada en NULL,
ya que la lista está vacı́a cuando se crea. La función será ası́:
Lista crearLista ()
{
Lista l s t ;
l s t = NULL;
return l s t ;
}
La operación para anexar un elemento al final de la lista se logra creando un nodo nuevo
donde está contenido el nuevo dato y si la lista está vacı́a se actualiza la variable lst
apuntando a nuevo, de lo contrario se crea un apuntador temporal que se moverá hasta el
último nodo de la lista y se modifica haciendo el siguiente de este último igual al nuevo
nodo.
L i s t a a n x L i s t a ( L i s t a l s t , i n t elem )
{
L i s t a nuevo , tmp ;
nuevo = ( L i s t a ) m a l l o c ( s i z e o f ( struct nodo ) ) ;
nuevo −> dato = elem ;
nuevo −> s i g = NULL;
84
4.2 Listas
i f ( l s t == NULL)
l s t = nuevo ;
else
{
tmp = l s t ;
while ( tmp −> s i g != NULL)
tmp = tmp −> s i g ;
tmp −> s i g = nuevo ;
}
return l s t ;
}
Para crear un nuevo nodo se debe reservar la memoria necesaria para que todos los
elementos de la estructura tengan un espacio. La función malloc permite realizar esta
operación pasándole como parámetro el tamaño de la memoria que se va a reservar (en
este caso es el tamaño de la estructura nodo). Dicha función retorna un apuntador al
espacio de memoria. Como este apuntador que retorna es de tipo void* es deseable que
se realice un casting, es decir, un cambio al tipo de dato que se hará referencia, de allı́ que
antes del llamado a la función malloc se escriba “(Lista)”.
Insertar un elemento en una lista es posible creando un nodo nuevo donde esté contenido
el nuevo dato y dependiendo de la posición a insertar se realiza la inserción. Si la posición
a insertar es la primera entonces simplemente el nodo nuevo apuntará al primer nodo de
la lista y luego se actualiza la variable lst para apuntar al nodo nuevo. Si la posición es
diferente a la primera posición de la lista, entonces se usa un apuntador temporal para
llegar hasta la posición y actualizar los apuntadores de los nodos entre dicha posición.
L i s t a i n s L i s t a ( L i s t a l s t , i n t pos , i n t elem )
{
L i s t a nuevo , tmp ;
nuevo = ( l i s t a ) m a l l o c ( s i z e o f ( struct nodo ) ) ;
nuevo −> dato = elem ;
nuevo −> s i g = NULL;
i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) )
{
i f ( pos == 1 )
{
nuevo −> s i g = l s t ;
l s t = nuevo ;
}
else
{
tmp = l s t ;
f o r ( i n t i = 0 ; i < pos − 2 ; i ++)
tmp = tmp −> s i g ;
nuevo −> s i g = tmp −> s i g ;
85
4 Noción de Tipo Abstracto de Datos
tmp −> s i g = nuevo ;
}
}
return l s t ;
}
Es de notar que si se quiere insertar un elemento en la última posición de una lista
con elementos, ó en una lista vacı́a, debe usarse la operación anxLista ya que usando la
operación insLista es necesario tener una posición válida (e.g. en listas vacı́as no hay
posiciones válidas, en listas con elementos la última posición hará que se inserte como
penúltimo elemento).
Eliminar un elemento es la operación contraria a insertar. Solo se verifica si la posición
es la primera u otra distinta y se actualizan los apuntadores de manera similar a insLista.
L i s t a e l i m L i s t a ( L i s t a l s t , i n t pos )
{
L i s t a tmp ;
i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) )
{
i f ( pos == 1 )
{
l s t = l s t −> s i g ;
}
else
{
tmp = l s t ;
f o r ( i n t i = 0 ; i < pos − 2 ; i ++)
tmp = tmp −> s i g ;
tmp −> s i g = tmp −> s i g −> s i g ;
}
}
return l s t ;
}
Para encontrar la información de un elemento dada su posición en la lista primero se
garantiza que la posición dada sea una posición correcta y luego con un apuntador temporal,
se llega a la posición y se retorna el dato.
i n t i n f o L i s t a ( L i s t a l s t , i n t pos )
{
L i s t a tmp ;
tmp = l s t ;
f o r ( i n t i =1; i <pos ; i ++)
tmp = tmp −> s i g ;
return tmp −> dato ;
86
4.2 Listas
}
Saber si una lista está vacı́a es tan sencillo como preguntar si la variable lst es igual a
NULL, es decir, tiene su valor por defecto.
int v a c i a L i s t a ( L i s t a l s t )
{
return l s t == NULL;
}
Es importante conocer la longitud de una lista para realizar ciertas operaciones adicionales. La longitud depende de si la lista está vacı́a o no. Si está vacı́a su longitud es cero,
de lo contrario se debe realizar un ciclo con un apuntador temporal hasta que el nodo
correspondiente a dicho apuntador no tenga siguiente, es decir, su variable sig sea igual a
NULL.
int l o n g L i s t a ( L i s t a l s t )
{
L i s t a tmp ;
int cont ;
tmp = l s t ;
cont = 0 ;
while ( tmp −> s i g != NULL)
{
tmp = tmp −> s i g ;
c o n t ++;
}
return c o n t ;
}
Estructuras Doblemente Encadenadas
Otra forma de implementar listas es con estructuras doblemente encadenadas. A diferencia de las listas encadenadas simples, en esta aproximación cada nodo consta de la
información del elemento y dos referencias: una al siguiente nodo y otra al nodo anterior.
Gráficamente puede verse como la figura 4.3.
Al igual que las listas encadenadas simples, las listas doblemente encadenadas tienen un
apuntador al primer nodo. Lo diferente está en que cada nodo “apunta” al siguiente y al
anterior de la lista. El primer nodo en su apuntador al anterior tendrá un elemento nulo
(NULL), al igual que último nodo en su apuntador al siguiente. Un ejemplo de una lista
doblemente encadenada puede verse en la figura 4.4.
El código para definir un nodo cambia. Se le agrega la variable apuntador al anterior.
87
4 Noción de Tipo Abstracto de Datos
Información
Figura 4.3: Nodo con doble encadenamiento
lst
1
1
2
3
5
8
Figura 4.4: Lista con doble encadenamiento
struct nodo {
i n t dato = 0 ;
struct nodo ∗ s i g = NULL;
struct nodo ∗ ant = NULL;
};
Ni la función constructora de las listas, ni las operaciones analizadoras cambian en esta
aproximación. Solo las operaciones modificadoras tienen unos pequeños ajustes para asegurar que los apuntadores a los nodos anteriores se mantengan. A continuación se muestra
la operación para insertar un elemento en una posición dada, ya que es la operación más
representativa (las otras dos operaciones se dejan como ejercicio para el estudiante):
L i s t a i n s L i s t a ( L i s t a l s t , i n t pos , i n t elem )
{
L i s t a nuevo , tmp ;
nuevo
nuevo
nuevo
nuevo
= ( l i s t a ) m a l l o c ( s i z e o f ( struct nodo ) ) ;
−> dato = elem ;
−> s i g = NULL;
−> ant = NULL;
i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) )
{
i f ( pos == 1 )
{
nuevo −> s i g = l s t ;
l s t −> ant = nuevo ;
l s t = nuevo ;
}
else
{
88
4.2 Listas
tmp = l s t ;
f o r ( i n t i = 0 ; i < pos − 2 ; i ++)
tmp = tmp −> s i g ;
nuevo −> s i g = tmp −> s i g ;
nuevo −> ant = tmp ;
tmp −> s i g −> ant = nuevo ;
tmp −> s i g = nuevo ;
}
}
return l s t ;
}
Es de notar que la secuencia de pasos para insertar un elemento (las últimas 4 lı́neas
del anterior algoritmo) no puede ser cualquiera. Si se realizan los pasos equivocados puede
resultar en la pérdida de elementos de la lista. Por ejemplo, si primero se realiza la asignación tmp ->sig = nuevo, entonces no habrá forma de llegar a los elementos siguientes
a tmp y el garbage collector eventualmente los eliminará. Por esta razón primero se deben
hacer las asignaciones del nodo nuevo y luego las de tmp.
Estructuras Circulares
En esta representación, las listas (ya sean encadenadas simples o doblemente encadenadas) tienen la particularidad de no tener nodos con apuntadores a NULL.
En las listas circulares encadenadas simples el último elemento de la lista en su variable
sig tiene un apuntador al primer elemento de la lista. La figura 4.5 muestra un ejemplo
de una lista circular encadenada simple.
lst
2
4
8
16
Figura 4.5: Lista circular encadenada simple
Adicionalmente al apuntador del último elemento al primero de las listas circulares encadenadas simples, en las listas circulares doblemente encadenadas el primer elemento de
la lista en su variable ant tiene un apuntador al último elemento. La figura 4.6 muestra un
ejemplo de una lista circular doblemente encadenada.
Cuando las listas son circulares, la definición de los nodos y las operaciones constructoras
no cambian. No obstante, la mayor parte de las demás funciones cambian de manera que
89
4 Noción de Tipo Abstracto de Datos
lst
5
4
3
2
1
0
Figura 4.6: Lista circular doblemente encadenada
no se alteren los apuntadores en los casos de inserción y eliminación de los extremos de
la lista, y para que no se quede en un ciclo infinito en el caso de anexar un elemento a la
lista o saber cuántos elementos tiene la lista. A continuación se mostrará la operación para
eliminar un elemento en una lista circular doblemente encadenada.
L i s t a e l i m L i s t a ( L i s t a l s t , i n t pos )
{
L i s t a tmp ;
i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) )
{
i f ( pos == 1 )
{
tmp = l s t ;
l s t = l s t −> s i g ;
}
else
{
i f ( pos == l o n g L i s t a ( l s t ) )
{
tmp = l s t −> ant ;
}
else
{
tmp = l s t ;
f o r ( i n t i = 0 ; i < pos − 2 ; i ++)
tmp = tmp −> s i g ;
}
}
tmp
tmp
tmp
tmp
−>
−>
−>
−>
sig
ant
sig
ant
−> ant = tmp −> ant ;
−> s i g = tmp −> s i g ;
= NULL;
= NULL;
}
return l s t ;
}
Para eliminar un elemento en una lista circular doblemente encadenada se debe realizar
lo mismo que en una lista no circular doblemente encadenada con modificaciones cuando
90
4.2 Listas
se elimine el primero ó el último elemento.
Las dos últimas lı́neas del anterior algoritmo en teorı́a no son necesarias ya que se el nodo
que se elimina queda inaccesible por la lista entonces eventualmente el garbage collector la
borrará de la memoria. Sin embargo por motivos de seguridad de la lista es mejor colocarlas.
Vectores
Un vector es un arreglo unidimensional de elementos con una longitud constante. Los
elementos de un vector pueden ser accesados directamente mediante un ı́ndice.
Un ejemplo de una lista implementada con vectores puede verse en la figura 4.7.
0
1
2
'b'
'a'
'p'
3
4
MAX-1
...
Figura 4.7: Lista implementada con un vector
MAX es una constante cuyo valor es el número máximo de elementos que puede contener la
lista. Los elementos en el vector están indexados por medio de un número entre 0 y MAX-1.
En el caso del ejemplo anterior puede verse que a partir de la posición 3 no hay elementos.
Esta es una caracterı́stica importante en la implementación de listas con vectores, los
elementos siempre deben estar agrupados en las primeras posiciones (i.e. no deben haber
posiciones vacı́as entre dos elementos).
Debido a que el vector de por sı́ es una estructura completa, en esta aproximación no
se tienen nodos. La lista consta del vector y la constante MAX. Un vector es en realidad un
apuntador a un espacio de memoria con un tipo de dato particular, de allı́ que podemos
usar el operador typedef para seguir usando el tipo de dato Lista, por ejemplo para una
lista de enteros se tendrı́a:
typedef i n t ∗ L i s t a ;
La constante MAX se puede definir mediante la rutina de preprocesamiento #define. Por
ejemplo se podrı́a definir MAX con una valor de 1000 ası́:
#define MAX 1000
La operación constructora creará el vector y definirá la constante2 :
2
Algunos lectores se preguntarán de qué tamaño debe crearse el vector ó si se debe pedir al usuario dicho
tamaño. Las respuestas a esas preguntas son sencillas: el tamaño depende del problema que se piense
91
4 Noción de Tipo Abstracto de Datos
Lista crearLista ()
{
Lista l s t ;
l s t = ( L i s t a ) m a l l o c (MAX∗ s i z e o f ( i n t ) ) ;
f o r ( i n t i = 0 ; i < MAX; i ++)
l s t [ i ] = NULL;
return l s t ;
}
En la implementación anterior, se definió el tamaño máximo de la lista como 1000. Nótese
que los elementos vacı́os son aquellos en los cuales las ubicaciones en el vector contienen
un NULL. De esta manera resulta muy fácil la implementación de varias operaciones: en la
operación vaciaLista, sólo hay que verificar si la primera posición contiene un elemento
nulo, en las operaciones para anexar un elemento y para saber la longitud de la lista, se
debe recorrer el vector hasta encontrar un NULL, y realizar las acciones respectivas.
Para insertar un elemento se deben correr todos los elementos desde el final hasta llegar
hasta la posición donde se va a insertar y luego copiar elemento. A continuación se muestra
el código:
L i s t a i n s L i s t a ( L i s t a l s t , i n t pos , i n t elem )
{
i f ( l o n g L i s t a ( l s t ) != MAX)
{
int i = l o n g L i s t a ( l s t ) − 1 ;
while ( i > pos −2)
{
l s t [ i +1] = l s t [ i ] ;
i −−;
}
l s t [ i +1] = elem ;
}
return l s t ;
}
Es de notar que el ciclo para correr todos los elementos arranca en longLista(lst)-1 y
termina en pos-2. Lo anterior es debido a que los vectores a diferencia de las listas que se
han diseñado cuentan sus elementos de 0 a n − 1 y por lo tanto hay que hacer la diferencia.
La operación para eliminar un elemento puede verse como el opuesto de la operación
anterior, es decir, se deben correr los elementos hacia la posición del elemento que se debe
borrar. El lector debe estar en capacidad de desarrollarla.
resolver con la lista y no se le debe preguntar al usuario porque no se diseñó la operación constructora
con un parámetro más y por lo tanto hacer dicho pedido irı́a en contra de la precondición de la operación.
92
4.2 Listas
Cursores
Para evitar el desplazamiento de los elementos en la implementación con vectores se
puede separar la información en dos vectores: uno con los datos y otro con los ı́ndices o
cursores que muestran la ubicación de los siguientes elementos. La gran ventaja está en que
insertar o borrar elementos se reduce a cambiar algunos cursores, sin necesidad de mover
elementos. La figura 4.8 muestra una lista representada con cursores.
DATOS
CURSORES
0
1
2
'z'
'k'
'p'
0
1
2
2
6
1
3
4
5
'q'
3
4
3
6
7
'x'
5
6
MAX-1
...
7
4
MAX-1
...
Figura 4.8: Lista implementada con cursores
El vector de cursores contiene el número ı́ndice de los siguientes elementos. Por ejemplo,
para la figura 4.8, si el primer elemento está en la posición 0, entonces el segundo elemento
está en la posición 2, el tercero está en la posición 1, el cuarto está en la posición 6 y el
quinto está en la posición 4. Lo anterior quiere decir que la lista representada en dicha
figura es la siguiente:
h ’z’, ’p’, ’k’, ’x’, ’q’ i
Nótese que el útimo elemento de la lista (’q’), quien se encuentra en la posición 4, tiene en
el vector de cursores el número 3. Esto significa que el siguiente elemento que se anexará a
la lista quedará en la posición 3 del vector de datos.
Al igual que la aproximación con vectores, la constante MAX contiene el máximo número
de elementos que puede contener la lista. De esta manera, las listas con cursores tendrán
los dos vectores, la constante MAX, y la posición del primer elemento. Adicionalmente,
para hacer más eficiente la implementación se tendrá la posición del último elemento y
la longitud de la lista como caracterı́ticas almacenadas. Por lo tanto, una lista de enteros
será una estructura como la siguiente:
struct l i s t a
{
i n t d a t o s [MAX] , c u r s o r e s [MAX] ;
i n t primero , u l t i m o , l o n g i t u d ;
};
typedef struct l i s t a L i s t a ;
93
4 Noción de Tipo Abstracto de Datos
La operación constructora inicializará todos los elementos de la estructura y las operaciones vaciaLista y longLista son evidentes.
Lista crearLista ()
{
Lista l s t ;
f o r ( i n t i = 0 ; i < MAX; i ++)
{
l s t . d a t o s [ i ] = NULL;
l s t . c u r s o r e s [ i ] = −1;
}
l s t . primero = 0 ;
l s t . ultimo = 0;
l s t . longitud = 0;
return l s t ;
}
Una función interesante es la inserción de un elemento en la posición p. Lo que se debe
hacer es buscar una posición vacı́a diferente a la que hace referencia el último elemento,
copiar allı́ el nuevo elemento, colocar como su ı́ndice la posición del elemento de la lista
donde se va a insertar (pos) y actualizar el ı́ndice del elemento anterior (pos − 1). Por
ejemplo, si a la lista de la figura 4.8 se le va a insertar el elemento ’w’ en la posición 4, el
proceso se puede ver gráficamente en el siguiente ejemplo:
Ejemplo 4.2.1
Tenemos la siguiente lista:
DATOS
CURSORES
0
1
2
'z'
'k'
'p'
0
1
2
2
6
1
3
4
5
'q'
3
4
6
7
'x'
5
3
6
MAX-1
...
7
4
MAX-1
...
Para insertar el elemento ’w’ en la posición 4 primero se inserta el elemento en una posición
vacı́a.
DATOS
CURSORES
94
0
1
2
'z'
'k'
'p'
0
1
2
2
6
1
3
3
4
5
6
'q'
'w'
'x'
4
5
6
3
4
7
MAX-1
...
7
MAX-1
...
4.2 Listas
Luego se busca el cursor del elemento cuya posición es 3 (marcado en la gráfica con color
rojo).
DATOS
CURSORES
0
1
2
'z'
'k'
'p'
0
1
2
2
6
1
3
3
4
5
6
'q'
'w'
'x'
4
5
6
3
7
MAX-1
...
7
4
MAX-1
...
Luego se coloca como ı́ndice el mismo número del cursor del elemento 3.
DATOS
CURSORES
0
1
2
'z'
'k'
'p'
0
1
2
2
6
1
3
3
4
5
6
'q'
'w'
'x'
4
5
6
3
6
4
7
MAX-1
...
7
MAX-1
...
Por último se actualiza el ı́ndice de la posición 3.
DATOS
CURSORES
0
1
2
'z'
'k'
'p'
0
1
2
2
5
1
3
3
4
5
6
'q'
'w'
'x'
4
5
6
3
6
4
7
MAX-1
...
7
MAX-1
...
? ? ?
El código de la función es el siguiente:
L i s t a i n s L i s t a ( L i s t a l s t , i n t pos , i n t elem )
{
i f ( pos >= 1 && pos <= l o n g L i s t a ( l s t ) )
{
f o r ( i n t i = 0 ; i < l o n g L i s t a ( l s t ) − 1 ; i ++)
{
i f ( l s t . d a t o s [ i ] == NULL && l s t . c u r s o r e s [ l s t . u l t i m o ] != i )
{
l s t . d a t o s [ i ] = elem ;
break ;
}
95
4 Noción de Tipo Abstracto de Datos
}
i n t postemp = l s t . p r i m e r o ;
f o r ( i n t j = 0 ; j < pos − 2 )
postemp = l s t . c u r s o r e s [ postemp ] ;
l s t . c u r s o r e s [ i ] = l s t . c u r s o r e s [ postemp ] ;
l s t . c u r s o r e s [ postemp ] = i ;
}
return l s t ;
}
Esta función puede hacerse más eficiente si se tiene una estructura de datos donde se
encuentren las posiciones vacı́as de la lista y, por lo tanto, no se necesite buscar una. Las
operaciones de anxLista y elimLista se dejan como ejercicio para el estudiante.
4.2.3.
Análisis de Complejidad de las Implementaciones
Al observar las diferentes aproximaciones que existen (las de las sección anterior y muchas otras) la pregunta que surge es: ¿cuál usar? La respuesta a esta pregunta depende
mucho del computador donde se usará el TAD, el lenguaje de programación en el que se
implementará y el uso que se le dará (su aplicación).
Si se asume que se tiene una máquina con mucha memoria, es decir, no es importante esta
condición, y el lenguaje de programación es C, o sea que disponemos de las herramientas
para desarrollar cualquier aproximación, entonces la solución solo depende del uso que se le
de al TAD. De lo anterior se puede ver que el problema se reduce a analizar cada una de las
operaciones. Es claro que las diferentes implementaciones del TAD Lista poseen diferentes
algoritmos, lo que conlleva a diferentes complejidades.
Encadenadas Simple
Doblemente Encadenadas
Circulares Simple
Circulares Dobles
Vectores
Cursores
crearL
O(1)
O(1)
O(1)
O(1)
O(n)
O(n)
anxL
O(n)
O(n)
O(n)
O(1)
O(n)
O(1)
insL
O(n)
O(n)
O(n)
O(n/2)
O(n)
O(n)
elimL
O(n)
O(n)
O(n)
O(n/2)
O(n)
O(n)
infoL
O(n)
O(n)
O(n)
O(n/2)
O(1)
O(n)
longL
O(n)
O(n)
O(n)
O(n)
O(n)
O(1)
vaciaL
O(1)
O(1)
O(1)
O(1)
O(1)
O(1)
Cuadro 4.1: Comparación de complejidades en las implementaciones del TAD Lista
4.2.4.
Utilización
En esta sección se mostrarán varios ejemplos de desarrollo de funciones adicionales haciendo uso de las operaciones primitivas del TAD Lista. Estas operaciones adicionales
resuelven problemas utilizando las operaciones de listas (no importa qué implementación
ya que como se vió, todas las funciones tienen el mismo prototipo) y, aunque no hacen
parte del TAD, enriquecen el uso de las listas.
96
4.2 Listas
Ejemplo 4.2.2 (Buscar un elemento)
Se desea buscar un elemento en una lista y retornar su posición. Una forma de resolver
este problema es realizando un recorrido por la lista desde el primer elemento hasta que se
encuentre o se termine la lista (en este último caso se retornará -1 haciendo entender que
no se encuentra en la lista). En caso de haber elementos repetidos, se retorna la posición
del primer elemento que se encuentre. La complejidad de este algoritmo es O(n) donde n
es el tamaño de la lista.
{pre : lst = he1 , . . . , ei , . . . , en i, elem ∈ Elemento}
{post : i si ∃i | ei = elem, −1 de lo contrario}
i n t b u s c a r L i s t a ( L i s t a l s t , i n t elem )
{
i n t pos = −1;
f o r ( i n t i = 1 ; i <= l o n g L i s t a ( l s t ) ; i ++)
{
i f ( i n f o L i s t a ( l s t , i ) == elem )
{
pos = i ;
break ;
}
}
return pos ;
}
? ? ?
Ejemplo 4.2.3 (Invertir una lista)
El problema de invertir una lista requiere recorrer la lista de entrada desde el último elemento hasta el primero anexando cada elemento en otra lista. Su complejidad es O(n),
donde n es el tamaño de la lista.
{pre : lst = he1 , e2 , . . . , en i}
{post : hen , . . . , e2 , e1 i}
Lista invertirLista ( Lista l s t )
{
L i s t a tmp = c r e a r L i s t a ( ) ;
f o r ( i n t i = l o n g L i s t a ( l s t ) ; i > 0 ; i −−)
tmp = a n x L i s t a ( tmp , i n f o L i s t a ( l s t , i ) )
return tmp ;
}
? ? ?
Ejemplo 4.2.4 (Equivalencia de listas)
97
4 Noción de Tipo Abstracto de Datos
Saber si dos listas son equivalentes requiere hacer un ciclo verificando si cada par de elementos de las dos listas, en la misma posición, son iguales. Esto trae consigo la condición
de que las listas deben tener el mismo tamaño. Su complejidad es O(n), donde n es el
tamaño de la lista.
{pre : lst1 = he1 , . . . , ei , . . . , en i ∧ lst2 = hf1 , . . . , fi , . . . , fm i}
{post : True si n = m ∧ ∀i ei = fi , 1 ≤ i ≤ n}
int i g u a l e s L i s t a s ( L i s t a l s t 1 , L i s t a l s t 2 )
{
i f ( l o n g L i s t a ( l s t 1 ) == l o n g L i s t a ( l s t 2 ) )
{
f o r ( i n t i = 1 ; i <= l o n g L i s t a ( l s t 1 ) ; i ++)
{
i f ( i n f o L i s t a ( l s t 1 , i ) != i n f o L i s t a ( l s t 2 , i ) )
return 0 ;
}
return 1 ;
}
else
return 0 ;
}
? ? ?
Ejemplo 4.2.5 (Palı́ndromes)
Una lista es palı́ndrome si puede leerse igual de derecha a izquierda o de izquierda a derecha. Existen diversas formas de conocer si una lista es palı́ndrome. En este ejemplo se
usarán las operaciones que se han desarrollado anteriormente, es decir, primero invirtiendo
la lista y luego preguntando si la inversa es igual a la original. Su complejidad es O(2n),
donde n es el tamaño de la lista.
{pre : lst1 = he1 , . . . , ei , . . . , en i}
{post : True si ∀i ei = e(n+1)−i , 1 ≤ i ≤ n}
int palindrome ( L i s t a l s t )
{
L i s t a tmp = i n v e r t i r L i s t a ( l s t ) ;
i f ( i g u a l e s L i s t a s ( tmp , l s t ) )
return 1 ;
else
return 0 ;
}
? ? ?
98
4.2 Listas
4.2.5.
Variantes
En esta sección se presentará una variante del TAD Lista más especializada llamada
TAD Lista Ordenada.
Lista Ordenada
Una lista ordenada es una lista que cumple con la condición de que para cada elemento de
la lista, el siguiente es mayor y el anterior es menor. Adicionalmente no existen elementos
repetidos. A continuación se muestra el diseño del TAD (la implementación queda como
ejercicio al estudiante).
TAD Lista Ordenada
e1 , . . . , e n ∀i ei < ei+1 , ei ∈ Elemento
Operaciones Primitivas:
•
•
•
•
•
•
•
crearListaOrd:
anxListaOrd:
elimListaOrd:
infoListaOrd:
estaListaOrd:
longListaOrd:
vaciaListaOrd:
1 ≤ i < n,
n≥0
Lista × Elemento
Lista × Elemento
Lista × Entero
Lista × Elemento
Lista
Lista
→ Lista
→ Lista
→ Lista
→ Elemento
→ Booleano
→ Entero
→ Booleano
crearListaOrd()
“Construye una nueva lista ordenada, inicialmente vacı́a”
{pre : TRUE}
{post :}
anxListaOrd(lst, elem)
“Inserta un elemento en la lista ordenada”
{pre : lst = ó lst = e1 , . . . , en ,
elem ∈ Elemento}
{post : lst = elem ó lst = e1 , . . . , ei , elem, ei+1 , . . . , en ei < elem < ei+1 , respectivamente }
99
4 Noción de Tipo Abstracto de Datos
elimListaOrd(lst, elem)
“Elimina el elemento dado de la lista ordenada”
{pre : lst = e1 , . . . , ei−1 , ei , ei+1 , . . . , en }
{post : lst = e1 , . . . , ei−1 , ei+1 , . . . , en si ei = elem}
infoListaOrd(lst, pos)
“Retorna el dato correspondiente a la posición dada”
{pre : lst = e1 , . . . , epos , . . . , en ,
{post : epos }
1 ≤ pos ≤ n}
estaListaOrd(lst, elem)
“Informa si un elemento está en la lista ordenada”
{pre : lst = e1 , . . . , ei , . . . , en ,
{post : True si ∃i | ei = elem
False de lo contrario }
1 ≤ i ≤ n}
vaciaListaOrd(lst)
“Informa si la lista está vacı́a (i.e. no contiene elementos)”
{pre : lst = ó lst = e1 , . . . , en }
{post : True si lst =
False si lst = e1 , . . . , en }
longListaOrd(lst)
“Retorna la longitud de la lista (i.e. número de elementos)”
{pre : lst = ó lst = e1 , . . . , en }
{post : 0 si lst =
n si lst = e1 , . . . , en }
4.3.
Pilas
Una pila es una estructura de datos lineal tipo LIFO (Last In, First Out) ya que el orden
de la secuencia de sus elementos es análogo a una pila de platos en una cafeterı́a donde
el último plato en ser colocado en la pila es usualmente el primero en ser usado. Entonces
en una pila, a diferencia de las listas, los elementos son adicionados y eliminados en un
extremo, el cual es llamado el tope de la pila. Este tope es el único elemento accesible de
la pila.
100
4.3 Pilas
Las pilas son bastante usadas en computación. Entre sus usos tenemos: para mantener
un registro de acciones en muchos programas y poder “deshacer” algunas o todas ellas
(usualmente con el comando Ctrl-Z), en navegadores de internet con las direcciones recientemente visitadas, para implementar recursión y backtracking, para evaluar de expresiones
aritméticas y en la ejecución de algoritmos para mantener un rastro de los llamados a
funciones y retornos y conocer el alcance de las variables.
Para visualizar mejor esta estructura de datos, se puede pensar en un recipiente como
R
(ver figura 4.9). En dicho recipiente la única papa visible es la
el de las papas Pringles
que está en el tope y ella es la única que puede sacarse de la pila. Si se introduce una
papa al recipiente ella se convertirá en el tope. De lo anterior se deduce que no es posible
conocer la cantidad de elementos que contiene una pila, para saberlo hay que sacar todos
los elementos y contarlos.
Figura 4.9: Ejemplo real de una pila
A continuación se muestra el diseño formal del TAD Pila.
101
4 Noción de Tipo Abstracto de Datos
4.3.1.
Diseño
TAD Pila
en
..
.
e1
∀i ei ∈ Elemento, n ≥ 0
Operaciones Primitivas:
•
•
•
•
•
crearPila:
Push:
Pop:
Peek:
vaciaPila:
P ila × Elemento
P ila
P ila
P ila
crearPila()
“Construye una nueva pila, inicialmente vacı́a”
{pre : TRUE}
{post : pil =
}
Push(pil, elem)
“Inserta un elemento en el tope de la pila”
en
{pre : pil = ... ,
e1
elem
en
{post : pil =
}
..
.
e1
102
elem ∈ Elemento}
→ P ila
→ P ila
→ P ila
→ Elemento
→ Booleano
4.3 Pilas
Pop(pil)
“Elimina el elemento tope de la pila”
en
en−1
{pre : pil =
}
..
.
e1
en−1
..
{post : pil =
}
.
e1
Peek(pil)
“Retorna el dato correspondiente al tope de la pila”
en
{pre : pil = ... }
e1
{post : en }
vaciaPila(pil)
“Informa si la pila está vacı́a (i.e. no contiene elementos)”
{pre : pil =
en
ó pil = ... }
e1
{post : True si pil =
False de lo contrario }
103
4 Noción de Tipo Abstracto de Datos
4.3.2.
Implementaciones
4.3.3.
Utilización
4.4.
Colas
4.5.
Tablas Hash
4.6.
Árboles Binarios
4.7.
Árboles N-arios
4.8.
Grafos
104
Índice alfabético
Alcance, 68
Apuntadores, 65
Cálculo λ, 12
Compilador, 41
Complejidad, 16
Depuración, 46
Evento, 59
Excepción, 52
Garbage Collector, 61
Indecidibilidad, 11
Interfaz, 57
Lenguaje de Programación, 35
Ligadura, 70
Máquina de Turing, 14
Máquina Virtual, 44
NP-Completitud, 25
Problema, 11
Programa, 35
Referencia, 60
Semántica, 40
Sintaxis, 38
Tipo de dato, 66
Tratabilidad, 16
Visibilidad, 72
105
Bibliografı́a
[1] Alfred V. Aho, Ravi Sethi, and Jeffrey D. Ullman. Compilers: Principles, Techniques,
and Tools. Addison Wesley, 1986.
[2] David M. Beazley. Python Essential Reference. Sams, 3rd edition, 2006.
[3] Boris Beizer. Software Testing Techniques. Van Nostrand Reinhold Company, 1983.
[4] Barry B. Brey. The Intel Microprocessors 8086/8088, 80186, 80286, 80386 and 80486.
Architecture, Programming and Interfacing. Prentice Hall, 3rd edition, 1994.
[5] Osvaldo Cairó and Silvia Guardati. Estructuras de Datos. McGraw-Hill, 3rd edition,
2006.
[6] Luca Cardelli. Type Systems, chapter 103. Handbook of Computer Science and Engineering. CRC Press, 1997.
[7] Rodrigo Cardoso. Verificación y Desarrollo de Programas. Ediciones Uniandes, 1991.
[8] Stephen A. Cook. The complexity of theorem-proving procedures. In 3rd Annual
ACM Symposium on the Theory of Computing, pages 151–158, 1971.
[9] Thomas H. Cormen, Charles E. Leiserson, and Ronald L. Rivest. Introduction to
Algorithms. Mc Graw Hill, 1990.
[10] Vinay Deolalikar. P 6= NP. HP Research Labs, Palo Alto, August 2010.
[11] Matthias Felleisen, Robert Bruce Findler, Matthew Flatt, and Shriram Krishnamurthi.
How to Design Programs: An Introduction to Programming and Computing. The MIT
Press, 2001.
[12] Daniel P. Friedman, Mitchell Wand, and Christopher T. Haynes. Essentials of Programming Languages. The MIT Press, second edition, 2001.
[13] Yedidyah Langsam, Moshe J. Augenstein, and Aaron M. Tenenbaum. Data Structures
Using C and C++. Prentice Hall, 2nd edition, 1995.
[14] Zbigniew Michalewicz and David B. Fogel. How To Solve It: Modern Heuristics.
Springer, 2000.
107
Bibliografı́a
[15] Bradley N. Miller and David L. Ranum. Problem Solving With Algorithms And Data
Structures Using Python. Franklin, Beedle & Associates, Inc., 2006.
[16] Roger Penrose. The Emperor’s New Mind. Oxford University Press, 1989.
[17] George Polya. How to Solve It: A New Aspect of Mathematical Method. Princeton
University Press, 1945.
[18] Terrence W. Pratt and Marvin V. Zelkowitz. Programming Languages: Design and
Implementation. Prentice Hall, 1984.
[19] Bruno R. Preiss. Data Structures and Algorithms with Object-Oriented Design Patterns in Java. Wiley, 1999.
[20] Roger S Pressman. Software Engineering: A Practitioner’s Approach. McGraw-Hill,
2004.
[21] Stuart M. Shieber. Course notes for cs152 principles of programming languages, November 1995.
[22] Guido van Rossum. Python Tutorial. Python Software Foundation, Release 2.5, 19th
September 2006.
[23] Jorge A. Villalobos. Diseño y Manejo de Estructuras de Datos en C. Mc Graw Hill,
1996.
108
Descargar