int a=10; LCFI0:
0x100000f30
int b, c; movq
0x100000f40
b = 35;
LCFI1:
movl
$10, -4(%rbp) 0x100000f50
movl
$1, %eax
c = a + b; movl return 1; movl }
%rsp, %rbp
movl addl
0x100000f90:
0x000000f4
0xe51d8d4 c
0x41000000
0x90000000
0x0000006 8
0xffe6e900
0x0000001c 0x00000001 0x00000020 0x00000020 0x00000002 0x00000000 0x00000038 0x00000038 0x00001001
0x00000000 0x00000038 0x00000003 0x0003000c
leave ret
En la corta historia de la computación (corta en comparación con otras ciencias y áreas del conocimiento humano), han sido propuestos varios lenguajes, pero solo algunos cuantos han sido utilizados en realidad.2 En la figura 1.3 se observa una lista de lenguajes de programación, ordenados cronológicamente (en azul se desta3
can los lenguajes de descripción de datos más importantes y el protocolo fundamental de Internet): Algunos lenguajes, como ALG OL, para la programación, o SGML, para la descripción de los datos, fueron propuestos; sin embargo, técnicamente , nunca se desarrollaron el compilador ni las herramientas necesarias para trabajar con la versión completa. Estos lenguajes se consideran importantes por su incursión en la historia de la computación y porque constituyen el srcen de otros lenguajes de programación (como PASCAL) o HTML y XML. 3 Consultar la página http://oreilly.com/news/graphics/prog_lang_poster.pdf, para observar un esquema que aborda la historia de los lenguajes, sus versiones y su filiación. 2
7
FORTRAN 1954 1950 Lenguaje ensamblador
COBOL APL 1959 1962 1958 LISP ALGOL
1968 logo
1964 BASIC PL/I
PROLOG 1972 1969 SGPIL
1973 C
SQL 1978
Eiffel 1985
HTTP 1991
Ruby 1993
1983 Ada 1987 1991 C++ PERL Python Objective C Java
Java Script PHP XML 1995 1997
1994 Common Lisp
2000 2001 C# Kylix
Figura 1.3 Línea de tiempo de los lenguajes de programación.
Esta proliferación y riqueza de lenguajes de programación tiene su srcen en: El importante desarrollo de software, el cual, cada dos años, ofreció un poder de cálculo multiplicado y de almace-
namiento de datos por n, por el mismo precio. La diversificación de los campos de aplicación. En un principio, la mayor necesidad de los lenguajes de progra-
mación era tratar grandes volúmenes de datos e importantes cálculos numéricos; sin embargo, las necesidades cambiaron, por lo que después aparecieron aplicaciones de inteligencia artificial, de manejo de bases de datos, de tratamiento y de generación de imágenes. La teoría de la computación en un amplio sentido. Por ejemplo, los dos casos siguientes:
La teoría de Codd, de álgebras relacionales (creada en la década de 1970), que permitió el desarrollo del lenguaje SQL para el manejo de las bases de datos relacionales. El trabajo de MacCarthy (1956) sobre las funciones recursivas, que permitieron el desarrollo del lenguaje LISP. Las nuevas metodologías de ingeniería de software. Aquí, lo más importante es el uso extendido del paradigma
orientado a objetos. La implementación. El uso práctico de un lenguaje permite distinguir las limitaciones de uso e impulsa las nuevas
proposiciones para su mejoramiento.
Hoy en día, aún se trabaja en el desarrollo de lenguajes de programación, pero desde dos perspectivas básicas: proponer nuevas soluciones a los problemas actuales4 y mejorar algunos de los lenguajes actuales, proponiendo nuevos estándares. En la actualidad, el uso de un lenguaje de programación está condicionado por: El conocimiento del lenguaje en cuestión; es decir, su sintaxis y la semántica de los conceptos y las instrucciones
que lo componen. El tipo de problema a resolver. P or ejemplo, para consultar datos que se guardan en un formato espe cífico en
una base de datos o en una base de conocimiento se utilizan, comúnmente, los lenguajes de tipo declarativo, donde se caracterizan los datos qu e se esperan en salida, como SQL para la base de datos relacional, PROLOG para la base de conocimiento, XQuery y XSLT para colecciones de datos en el formato XML. En otro ejemplo, para dar las órdenes de instalación de software, es conveniente escribir programas en el shell del sistema operativo. El derecho y la posibilidad material de utilizar un compilador o intérprete de dicho lenguaje, ya que estos tipos de software (compilador, taller de desarrollo, intérprete) suelen tener un costo monetario o licencias restrictivas. La configuración física que está disponible. Por ejemplo, si está disponible una arquitectura multiprocesador, sería más conveniente utilizar un lenguaje de tipo C o FORTRAN, por medio de los cuales se abstendría de realizarse el cálculo paralelo, o emplear herramientas de paralelización automática. En el caso de que el programa tuviera que Por ejemplo, un grupo de trabajo de W3C aún trabaja en el desarrollo de un lenguaje de manejo y actualización de colecciones de archivos XML. 4
8
explorar y comunicar con una interfaz de un equipo raro, como una máquina de producción o un dispositivo de medición, es preferible escribirlo en un código del lenguaje ensamblador. La configuración del software que está disponible o que se impone por la construcción del programa y el uso ulte-
rior del producto finito. Por ejemplo, para aprender la programación es mejor iniciar con un lenguaje de alto nivel del paradigma imperativo de tipo C o PASCAL. En el caso de que el destinatario del programa utilizara el sistema operativo de plataforma móvil con sistema MAC OS, las herramientas para desarrollar aplicaciones imponen usar el framework COCOA o XCode y el lenguaje de programación Objective C. También es posible que al interior de un programa sean introducidas algunas otras funciones de diferente naturaleza, las cuales son escritas en otros lenguajes de programación o en fragmentos de códigos de otro lenguaje (por lo general, en un lenguaje declarat ivo de interrogación de base de datos). En un proyecto de desarrollo de programa, se elige al menos un lenguaje de prog ramación, pero resulta técnicament e posible elegir otro u otros lenguajes.
Transformación de un programa Un programa de usuario recorre el siguiente camino hasta su ejecución: Con un editor de texto se escribe el programa en el lenguaje elegido. En lenguaje de alto nivel, el código fuente se transforma en instrucciones para la máquina (código objeto o código
ejecutable). Un ejecutable se construye con códigos objeto (uno o más) y librerías de funciones, entre otros.
El resultado de este proceso es un código ejecutable directo para la máquina. Pero también existe el modopoco interpretación ejecución, en el cual cada frase, instrucción, orden consulta, escritos en código fuente, se transforma, a poco, endeórdenes, ya sea directamente por el procesador, por ootro software o por la máquina abstracta. Este es el caso del intérprete del lenguaje PROLOG, del Shell y del motor de resolución de consultas (SQL, por las bases de datos). En el mismo caso también se encuentra el lenguaje Java en modo interpretado, en donde el código transformado (clases o archivos) es interpretado por la “Máquina Virtual Java”. Editor Código fuente
Editor
Compilador (y más)
Código ejecutable
Código fuente Ejecución
Ejecución Figura 1.4
En gran parte de los casos, el compilador o el intérprete realiza algunas transformaciones a los programas (optimización de código, detecciones de fin de programa, paralelización de código, etc.), para obtener un código máquina más rápido o más adaptado a la máquina a la cual está destinado. 9
Para la mayoría de los lenguajes, hay herramientas completas que permiten, en ambientes amigables, la edición y la realización de todos los pasos hasta la construcción del ejecutable de una manera implícita. Es muy probable que un programa que se compila y se ejecuta por primera vez tenga errores de compilación. También es probable que, después de un tiempo de ejecución, el programa tenga errores lógicos de ejecución; en este caso, se regresa a la edición del código fuente inicial, con el fin de corregir los errores, y luego se desarrollan las otras etapas, hasta la construcción del ejecutable (véase figura 1.5). Editor Código fuente Compilador (y más)
Si hay errores
Código ejecutable
Ejecución Figura 1.5
A lo largo de este capítulo se presentan el pseudocódigo y los diagramas de flujo como herramientas para el diseño de los algoritmos. Por su parte, el lenguaje C, se aborda con amplitud más adelante en otros capítulos, ya que se trata de un lenguaje imperativo y estructurado, considerado un lenguaje de alto nivel.
1.2 Variables, tipos y expresiones El objetivo general de un programa es transformar datos en resultados útiles para el usuario. Los datos están almacenados en la memoria principal o en la memoria secundaria, ya sea de manera temporal (durante toda la ejecución del programa o durante una parte del tiempo de ejecución) o de manera permanente. En la mayoría de los lenguajes de programación, los datos son de diferentes tipos, aparecen en expresiones o en las llamadas de funciones y se manejan a través del uso de variables.
Variables El formato de representación y de estructuración de los datos depende del paradigma del lenguaje de programación y de la opción que el programador ha elegido para representar los datos. En el paradigma imperativo y en el caso de algunos otros paradigmas (por ejemplo, lenguaje PROLOG) existe una noción básica común para el manejo de los datos: la noLa tipo ventaja las variables que almacenan datos de de variable. entrada, de salida o intermedios. No obstante, ción delenguajes variable. de existen SQLde o XPath que noesimplementan la noción Por lo general, en cada programa aparece al menos una variable, lo que significa que en cada programa hay una zona de memoria con un tamaño fijo que contiene un valor de tipo preciso; por ejemplo, un entero representado en forma binaria de Ca25 sobre 4 bytes, o una cadena de caracteres de un tamaño máximo de 255. 5
10
Véase el apéndice 1 en el CD-ROM.
Cada variable debe tener: Un tamaño de memoria ocupada y un modo de representación interna. Por ejemplo, un punto flotante simple
precisión sobre 4 bytes o cadenas de caracteres de 100 + 1 caracteres. Un conjunto de operadores y de tratamientos específicos que pueden aplicarse a la variable. Si las variables son, por
ejemplo, de tipo lógico, se aplican operadores lógicos; pero, si las variables son numéricas, se aplican operadores de cálculo numérico (suma, producto, entre otros). El nombre de una variable debe ser único y no ambiguo. La unicidad del nombre de la variable durante su ciclo de vida, asegura una semántica correcta de las operaciones (expresiones, órdenes o proposiciones) que implican a la variable. De esta forma, el nombre de una variable es un identificador diferente de cualquier palabra clave utilizada en el lenguaje o nombre de una función externa. Generalmente, los nombres de las variables inician con una letra y son sucesiones de letras y cifras y el símbolo _ (guión bajo). Para la cualidad del programa, es preferible que el nombre de una variable sea sugestivo al tratamiento y de un largo de tamaño aceptable, ya que un nombre de variable muy largo puede generar errores de tecleo al momento de la edición del programa, lo que produce pérdidas de tiempo para su corrección. En la determinación del nombre de la variable, también se sugiere utilizar únicamente letras sin acento, para una mejor portabilidad del código o porque la sintaxis del lenguaje no lo permite. Algunos ejemplos de nombres de variables son los siguientes: a, a1, area, suma. También lo son: a1b159 y a2b158; sin embargo, la lectura de un programa con nombres de este tipo sería difícil. Por lo que respecta a la extensión del nombre, una variable llamada nueva_suma_valores_quantidades, tomaría mucho más tiempo escribirla. Se considera que variables de nombre i, j, k, indican variables enteras usadas para índices; en tanto, las variables de nombre a, b y c, por lo general se utilizan para valores numéricos reales (punto flotante); las variables llamadas p y q se emplean para apuntadores; las variables llamadas n y m son variables que contienen valores de tamaños de arreglos. No es obligatorio que la variable tenga un valor al saberse que la zona de memoria dedicada a la variable sea ocupada. En algunos momentos, es posible que la variable no tenga ningún valor; en estos casos, se dice que la variable es no-inicializada (por ejemplo, lenguaje PASCAL) o libre (por ejemplo, lenguaje PROLOG). Si la variable posee un valor en un instante T del programa, dicho valor solo es único para ese instante T. A lo largo de la vida de la variable, el valor que tenga esta puede cambiar; la única condición es que los valores guardados sean del mismo tipo de la variable. El cambio 6
de valordedeuna la variable alrededor operación asignación o por efecto secundario, como el cálculo función oseelhace tratamiento de de un una recurso externoexplícita de tipo de archivo. Las variables son de varios tipos; en la mayoría de los lenguajes de programación imperativa predominan los siguientes tipos: Variables simples. Son propias de los tipos básicos, para los datos enteros, flotantes, caracteres y lógicos (pero no
en el lenguaje C). Variables compuestas. La definición del tipo de una variable compuesta depende de la sintaxis del lenguaje de
programación y de su poder semántico. Arreglos de variables de tipo simple o tipo compuesto. Los arreglos sirven para almacenar una sucesión de
valores del tipo indicado. En la mayoría de los lenguajes en los que cada variable tiene una declaración, se indica el nombre y el tipo. En ocasiones, también se indica si la variable es estática o dinámica o si el acceso al contenido de la variable es público o privado (por lo general, en lenguajes orientados a objetos). Si la semántica del lenguaje impone la declaración de cualquier variable que se usa, la ausencia de dicha declaración genera un error de compilación. Del mismo modo, si existen variables que están declaradas, pero que no se utilizan, la mayoría de los compiladores envían mensajes explícitos de advertencia, que no son errores, pero sí informaciones realizadas por el programador. También existen casos de lenguajes en los que ninguna variable se declara; en estos lenguajes, a cada aparición de la variable se considera que dicha variable tiene un tipo por defecto. Este es el caso del lenguaje PROLOG, en el cual únicamente una variable sirve para la evaluación de expresiones lógicas o del lenguaje M. También hay lenguajes
6
Efecto secundario (side effect en inglés). 11
en los cuales si una variable se usa sin definición, se considera que es una variable simple de un tipo indicado por su nombre (por ejemplo, el lenguaje FORTRAN). La sintaxis de las declaraciones de variables es diferente de un lenguaje a otro; sin embargo, un elemento común es que en todos los casos se indican el tipo de la variable y su nombre.
Ejemplos integer A, I; significa dos variables de nombre A e I y de tipo entero. double Rayo;
significa una variable de nombre Rayo y de tipo punto flotante doble precisión.
La noción de variable es generalmente la misma para la mayoría de los lenguajes de programación de tipo imperativo; no obstante, de un lenguaje a otro, o de una computadora a otra, la implementación puede ser diferente. Por ejemplo, según la computadora, un tipoMATLAB, entero setodas implementa con tienen un tipoeldetipo representación binaria(64 y sobre en el lenguaje M, del software las variables de doble precisión bits).2, 4 u 8 bytes. Así, Una variable simple tiene un nombre único y posee un solo valor de tipo elemental; dicho tipo está declarado explícita o implícitamente. En el ejemplo anterior, las tres variables son simples. A una variable le corresponde una zona de memoria que contiene el valor, donde escribiendo el nombre de la variable se accede a su valor.
Ejemplo
Si después de las declaraciones precedentes se escribe A+I, esto representa una expresión aritmética que usa los valores de las variables A e I. Un arreglo es una variable que tiene un nombre y posee un cierto número de valores del mismo tipo (simple o compuesto), los cuales se encuentran almacenados, uno después del otro, en una zona de memoria contigua. El tipo de cada valor del arreglo también es implícito o explícito. En la declaración de un arreglo, más que el tipo de los elementos del arreglo y el nombre de este, se indica la dimensión.
Ejemplo integer YX[10]; significa un arreglo que contiene 10 valores enteros.
Según los lenguajes de programación, se puede trabajar o no con todo el arreglo en un solo comando o expresión, o (el caso más común) trabajar con un solo valor del arreglo a la vez.
Ejemplo Si se trabaja con el lenguaje M, sum(YX) significa la suma de todos los valores y 5*YX significa un arreglo temporario que contiene los valores del arreglo YX multiplicados por 5. En el lenguaje C, estas expresiones no significan nada, a menos que el usuario defina una función especial sum(...) capaz de tratar arreglos de tipo entero. Un valor que compone el arreglo se llama elemento. Un elemento se identifica con el nombre del arreglo y con su posición al interior del arreglo, llamada índice.
Ejemplo
Por la declaración precedente, YX[1] es el elemento de índice 1 del arreglo YX; este valor se puede utilizar en cualquier expresión aritmética o instrucción. Si tomamos en cuenta la definición de una variable entera I, entonces YX[I] es el elemento con el índice del valor de la variable I del arreglo YX. Según los lenguajes de programación, los índices de un arreglo empiezan en 1 (lenguaje M o FORTRAN o PASCAL), o en 0 (lenguaje C o Java). La discusión sobre las variables compuestas y los apuntadores está muy extendida y es muy dependiente del lenguaje de programación. En el caso del lenguaje C, que es el que se va a presentar en este texto, se trata con detalle en 12
los capítulos 2 y 3. En tanto, en el siguiente apartado se estudian los diferentes tipos de variables; normalmente, para cualquier tipo conocido por el programa se puede definir una variable. El ciclo de vida de una variable inicia en el momento de la , conforme a su definición. La asignación es realizada por el compilador, si la variable es global (es decir, si el lenguaje es compilado), o por el intérprete, durante la ejecución del programa, si la variable es local o dinámica. El tamaño de la zona de memoria asignada es, por lo general, el tamaño del tipo por las variables simples o el producto del tamaño del tipo del nombre de los elementos (es decir, la dimensión) del arreglo. En el caso de algunos lenguajes de programación que manejan colecciones de datos, el tamaño de memoria asignado es variable. Sin embargo, la reserva de memoria puede ser fija o variable, según el lenguaje de programación, el tipo de cálculo que se hace o el ambiente de ejecución. Si la variable es local o aparece en una parte de código que termina o si el programador lo indica (con una función free(X) en el lenguaje C, por ejemplo), se hace la liberación de la zona de memoria ocupada por la variable. Una variable es local si su contenido es accesible únicamente en una parte del programa (por ejemplo, un bloque en el lenguaje C, la resolución de un predicado en PROLOG o el cuerpo de una función que se ejecuta en la mayoría de los lenguajes). En sentido opuesto, también hay variables globales; así, una variable global es visible desde cualquier lugar del programa.
Ejemplo #include
}
int b; b = sizeof(int); printf(“Se necesita %d bytes para guardar el valor %d.\n”, b, x); printf(“Se necesita %d bytes para guardar el valor %d.\n”, (int)sizeof(x+a), x+a);
int main() { int b; b = 15; a = 25; funcion_impresion(b); }
Este programa en C tiene una variable global que es visible desde las dos funciones: main y funcion_impresion. También, hay dos variables locales con el mismo nombre, b, en cada una de las dos funciones; cada una de estas variables tiene un contenido diferente. Las variables locales pueden clasificarse en estáticas o dinámicas, pero esta clasificación únicamente aplica en algunos lenguajes de programación, por lo que su semántica es diferente de un lenguaje a otro, para indicar el modo de asignación de memoria. La noción se usa en el caso de funciones recursivas, por las cuales las variables estáticas son únicas para todas las llamadas de una misma función. Una variable estática tiene su espacio de memoria asignado fuera de las variables dinámicas en lenguajes como C, C++ o VisualBasic. En el ejemplo anterior todas las variables son dinámicas. Toda vez que una variable tiene su espacio de memoria asignado, su contenido puede ser consultado en lectura o en lectura escritura. La lectura del contenido de una variable se hace a cada aparición del nombre de la variable, pero si la variable no contiene nada (es decir, no fue inicializada), su lectura produce un error. 13
En la mayoría de los casos y de los lenguajes de programación (excepto en algunos lenguajes del paradigma declarativo), la escritura (cambio) de un contenido se hace con un operador de asignación de valor. Dicho operador de asignación tiene una aridad de dos y generalmente se expresa con la sintaxis siguiente: variable operador_asignacion expresión
El operador de asignación cambia según el lenguaje de programación del que se trate; así, es = para los lenguajes C, C++ o Java, e: = para los lenguajes PASCAL o SET y algunos lenguajes declarativos (por ejemplo, LISP, SCHEME, XSLT). En el operador de asignación, la parte izquierda (el primer operando) constituye la variable y la parte derecha es una expresión del mismo tipo o un tipo compatible por el cual el valor sería convertido al tipo de la variable, si la conversión es posible. Sin embargo, el funcionamiento es siempre el mismo: primero se evalúa la expresión y luego se hace la escritura del valor obtenido en la zona de memoria asignada por la variable. La expresión que aparece en una asignación debe ser correcta sintáctica y semánticamente (escritura correcta y uso correcto del tipo). Pero, esta verificación de corrección no es una garantía de que la expresión esté correcta al momento de la ejecución de la asignación. Los errores de evaluación pueden aparecer como la división con cero, un valor de índice que está fuera del rango permitido o una operación aritmética que se hace con desbordamiento aritmético. Según el lenguaje del que se trate, si el cálculo de la expresión se hace con errores o excepciones, es posible integrar un código general de tratamiento de la excepción o un código particular (lenguajes ADA, Java o Smalltalk). También es posible que el compilador que introduce verificaciones de corrección, paso a paso, durante la evaluación de la expresión de la parte derecha, señale explícitamente la causa del error, como en el lenguaje Java. Pero estos lenguajes también son considerados lenguajes sin ninguna verificación de este tipo y los errores de cálculo de la expresión pueden ser fatales, como la división con 0, o el cálculo continúa con un valor incorrecto. En el lenguaje C, si el error es fatal, la asignación se interrumpe y el programa también.
Tipos Un tipo informático es el atributo de cualquier dato (constante, variable o dato almacenado en la memoria interna o en la memoria externa) guardado por el programa de manera implícita o explícita. Por lo general, el tipo indica la forma física del contenido del dato. Así, un tipo induce naturalmente una representación interna de los datos; entonces, el tamaño también induce en la semántica del lenguaje un conjunto de operadores que se aplican a los valores pertenecientes a este tipo. Los tipos son características de los lenguajes de programación y se clasifican en: Tipos predefinidos. Tipos definidos por el usuario.
Un tipo predefinido es un tipo propuesto por el lenguaje con una semántica explícita y un conjunto preciso de operadores. Por su parte, un tipo predefinido puede ser: Un tipo básico, el cual traduce tipos de representación interna de los datos en lenguaje de programación, como
enteros, reales (con representación en punto flotante), lógicos, carácter (código ascii, Unicode, entre otros) y cadena de caracteres (menos frecuente). Un tipo complejo, el cual traduce un tipo abstracto de datos7 o un tipo de datos que responde a una necesidad
en el paradigma de programación; por ejemplo, el tipo enumerado, el semáforo (en programación concurrente), el mensaje en la programación distribuida asíncrona, etcétera.
Un tipo abstracto es un tipo de datos concebido de manera teórica explicitando la semántica del tipo: cómo funciona y cuáles son las operaciones con este tipo. Por ejemplo, podemos definir un tipo abstracto para modelar la noción matemática de conjunto. El tipo abstracto conjunto tiene definidos los operadores entre conjuntos (reunión, intersección y diferencia) y el operador de pertenecia. Un tipo abstracto se implementa después en su lenguaje de programación. 7
14
Ejemplo
En el lenguaje C, los tipos básicos predefinidos son enteros o reales:char, short, int, long, float, double. Un carácter se asimila como un entero representado en 1 byte, y los valores lógicos se consideran por interpretación de los valores que adquiere; es decir, cualquier valor diferente de 0 es verdad, ya que el valor 0 es falso. Las cadenas de caracteres se conciben como arreglos de caracteres con un carácter especial al final. Los tipos complejos parecen definidos en las librerías estándares: FILE*, para trabajar con archivos; clock y time, para trabajar con el tiempo del sistema o absoluto; socket para trabajar con los sockets, etcétera. Muchos lenguajes permiten al programador la definición de sus propios tipos, los cuales son más cercanos al problema que se pretende resolver. El tipo compuesto es un conjunto ordenado de variables con tipos conocidos (struct en C, record en PASCAL o PL/SQL). En el paradigma de la programación orientada a objetos, también hay nociones de tipo jerárquico y de tipo opaco con respecto a la visibilidad o la herencia. La comprobación de tipificación constituye la operación de verificación de compatibilidad de los tipos al interior de una expresión. La tipificación puede ser de dos tipos: Estática. Hecha al momento de la compilación. Dinámica. Hecha al momento de la ejecución del programa.
Esta fase de comprobación de los tipos es necesaria para garantizar la corrección del código y evitar los errores de desbordamiento. Cuando se hace una operación entre tipos diferentes, antes es posible hacer una conversión de un tipo a otro (por lo general, el más débil se convierte en el más fuerte), y luego se realiza la operación. La compatibilidad de los tipos está indicada en la parte de la semántica del lenguaje. La tipificación es fuerte cuando solo son aceptadas las transformaciones para el tipo más fuerte (por ejemplo, el lenguaje PASCAL); en caso contrario, la tipificación se considera débil.
Apuntadores Un puntero o apuntador es una variable capaz de referenciar una variable del programa o una dirección de memoria. Este se define como una variable, pero nada más se indica que el tipo es una referencia (a veces una ubicación) de un tipo conocido (estándar o definido por el programador) o de cualquier otro tipo. Este modo de acceso a un contenido, pasando primero por su dirección (ubicación de memoria), permite realizar tratamientos por los cuales los operandos no son conocidos completamente al momento de la ejecución del programa. No todos los lenguajes implementan esta noción; por ejemplo, M (del MATLAB), R o FORTRAN. La semántica y el uso de apuntadores son muy diferentes de un lenguaje a otro. Los lenguajes ensambladores implementan esta noción de manera natural, con el modo de direccionamiento indirecto por medio de un registro.
Ejemplo
A continuación se presenta un ejemplo de uso de apuntadores en C y en PASCAL, por el cual el contenido de una variable es accesible y se modifica usando un apuntador sin hacer referencia al nombre de la variable: #include
else p = &b; printf(“ El contenido inicial de mi variable preferida : %d\n”, *p); a = a + 67; printf(“ El contenido final de mi variable preferida : %d\n”, *p); *p = 100; printf(“El valor de a está ahora : %d.\n”, a); } PROGRAM codigo_apuntador_ejemplo; VAR a,b : integer; p : ^integer; BEGIN a := 12; b := 5; if a > b then p := @a else p := @b; writeln(‘El contenido inicial de mi variable preferida :’,p^); a := a + 67; writeln(‘El contenido final de mi variable preferida :’,p^); p^ = 100; writeln(‘El valor de a está ahora ::’,a);; END. Memoria Dirección A
Contenido A
Ensamblador: code_Op direccion A
Ensamblador: mover Direccion A, registro2 code_Op *registro2 0 1 Dirección A registro 2
Contenido A Registro operación
2 Contenido A Registro operación
Acceder al contenido de la variable A por una operación Op usando el acceso directo y el acceso indirecto
Figura 1.6 16
Expresiones En programación, una expresión es la traducción en lenguaje informático de un cálculo aritmético, lógico o de otra naturaleza. La noción de la expresión fue inspirada de la noción de expresión matemática, por lo que su semántica es similar: la evaluación de una expresión se hace tomando en cuenta los valores que intervienen y aplicando los operadores. En las expresiones, los operadores tienen un orden de evaluación y prioridades. Una expresión contiene, entonces: Valores constantes Variables Operadores Paréntesis
La escritura de una expresión en un código implica la evaluación de esta al momento de la ejecución del código. La evaluación se hace tomando en cuenta la prioridad de los operadores. Los operadores están definidos por la sintaxis del lenguaje, al tiempo que la parte de semántica indica el tipo de los operandos y el tipo del resultado. Por lo general, los operadores del lenguaje de programación son de aridad 1 (un solo operando) o de aridad 2 (la mayoría); el caso de aridad superior a 2 (es decir, de 3 o más operandos) es menos común. La mayoría de los lenguajes de programación usan la forma de infijo para la escritura de las expresiones, que es la escritura en el orden siguiente: operador_aridad_1 operando operando1 operador_aridad_2 operando2
Ejemplo 1 La expresión matemática 2 mv2 + mhg tiene como posible árbol de evaluación el siguiente (la operación de multiplicación es asociativa, entonces hay varias maneras de hacer el cálculo): +
*
*
*
/
1
*
m
v
m
v
*
h
g
2 Figura 1.7 Árbol de evolución de la expresión.
Esta expresión se escribe 1/2*m*v*v+m*h*g en la forma de infijo. En el lenguaje LISP se usa la forma polaca (o forma de prefijo): (* (/ 1 2) m v v) (* m h g)). Por los tipos numéricos, se usan las cuatro operaciones aritmética conocidas: 17
Suma (adición) + Diferencia (sustracción o resta) Producto (multiplicación) * División /
En algunos lenguajes hay un operador por el resto de la división entera (el módulo), % (en el lenguaje C), o un operador para la potencia, ^ (en el lenguaje BASIC). Por tipos que no son numéricos y según el lenguaje, también hay operadores; por ejemplo, por las cadenas de caracteres (si el lenguaje se considera cadena de caracteres como un tipo básico) hay un operador de concatenación (unir) para dos cadenas: + en el lenguaje C++ o | en el lenguaje SQL. Según la semántica de cada lenguaje de programación, los operadores que corresponden a operaciones aritméticas / lógicas o de transformación se aplican a operandos: de tipos similares o compatibles, obteniendo un resultado del mismo tipo (por ejemplo, las operaciones aritmé-
ticas se hacen entre elementos de tipo numérico) o de otro tipo (por ejemplo, el tipo lógico). de tipos diferentes; por ejemplo, en el lenguaje C, la adición y la sustracción de un apuntador y de un entero;
el resultado significa un nuevo apuntador para la dirección calculada, según el apuntador y el segundo operando. Otra clase de operadores son los operadores de orden, que sirven para comparar el orden de dos valores de tipos numéricos, con el fin de regresar un valor de tipo lógico. En matemáticas, los operadores de orden más comunes son: < , , > , , = , . En la mayoría de los lenguajes, estos operadores se traducen en programas con los siguientes símbolos: < , <=, >, > =, = y < > o ! =. Asimismo, en la mayoría de los lenguajes de programación, también se implementan los operadores lógicos de la lógica de primer orden: la negación (operación de aridad 1), la conjunción y la disyunción (operaciones de aridad 2). Estos operandos lógicos corresponden a las palabras “no”, “y”, “o”. En muchos de los lenguajes, corresponden a los operadores NOT, AND y OR. Las tablas de verdad de estos operadores son:
p V
NOT p F
F
V p
V
V
V
V
F
F
F
V
F
F
F
F
p
Q
V
V
V
V F
F V
V V
F
F
F
En programación, los operadores aritméticos tienen la misma prioridad que en matemáticas; así, las operaciones de * y / tienen la misma prioridad alta, que las operaciones de + y -. En las operaciones aritméticas, los operadores tienen 18
una prioridad mayor que los operadores de orden; en tanto, los operadores lógicos tienen una prioridad más baja que los otros. Por ejemplo, si las variables a, b y c tienen valores numéricos, para verificar que a, b y c pueden ser las aristas de un triángulo, en lenguaje matemático se impone que a, b y c serían valores positivos y que c ada número verifica la siguiente desigualdad triangular: x + y < z. En lenguaje de programación, estas seis condiciones lógicas que deben cumplirse se escriben con la expresión: a > 0 AND b > 0 AND c > 0 AND a < b + c AND b < a + c AND c < a + b
En la expresión anterior no son necesarios los paréntesis, sino que únicamente se utilizan para dar mayor claridad, por lo que cada operación de orden se puede escribir entre paréntesis, así: (a > 0) AND (b > 0) AND (c > 0) AND (a < b + c) AND (b < a + c) AND (c < a + b)
Funciones Se considera que una función es una parte de código capaz de realizar una tarea y/o de transformar valores para obtener otro valor. Una función se define por: El nombre. Este no debe ser ambigüo; según el lenguaje, el nombre debe ser único con respecto a las variables
globales y a otras funciones. El tipo de valor que la función regresa. El número fijo (o variable) de parámetros y la lista ordenada de tipo aceptable por los parámetros. El código. Este es único para cada función.
Cercanas a la noción de función (o idénticas por el lenguaje C), se encuentran las nociones de procedimiento, rutina o subrutina, las cuales significan una parte del programa encargada de realizar una tarea sin regresar expresamente un valor. Los valores calculados o transformados por el código se regresan en la lista de los parámetros. Si el lenguaje permite la redefinición de las funciones, por ejemplo, los lenguajes orientados a objetos, como C++ o Java, o que las funciones tengan varias listas de parámetros, el código de una función no es único. En el primer caso se toma en cuenta la última definición de la función, mientras que en el segundo se hace la correspondencia entre la lista de parámetros actuales y las listas de parámetros. Una vez que la función está definida (y si no tiene restricciones de acceso; por ejemplo, no es privada, como en el caso del lenguaje Java, o no es una función interna de otra función, como en el lenguaje C), en todo el código se pueden hacer una o varias llamadas a la función, con la única restricción de que la lista de los parámetros reales (especificados en la llamada) correspondan en nombre y tipo con los parámetros formales (que aparecen en la definición de la función). La llamada de la función se hace especificando: El nombre de la función. La lista de los parámetros reales, los cuales pueden ser expresiones que se evalúan o variables.
Por su parte, la sintaxis de una llamada de función es casi la misma para prácticamente todos los lenguajes (a excepción de algunos lenguajes funcionales, como LISP o SCHEME) y es inspirada en la notación matemática: Nombre_función(parametro1, parametro2, …) La sintaxis de la definición de una función varía considerablemente de un lenguaje a otro, al igual que la semántica de la definición y el modo de ejecución de las llamadas. En el caso de las llamadas de funciones, estas tienen varias semánticas, según el paradigma del lenguaje de programación. Por el paradigma imperativo y por las funciones que regresan valores, las llamadas se comportan como expresiones del tipo regresado. 19
De acuerdo con el lenguaje de programación, los parámetros pueden modificarse o no en el código de la función, donde el valor del parámetro a la salida de la función es cambiado. O se indica expresamente si los parámetros son de entrada (cuando sus valores no cambian) o de salida (por ejemplo, el lenguaje PL/SQL) si los valores van a cambiar. Un parámetro real que no es de salida puede ser cualquier expresión posible del parámetro formal. En el lenguaje C solo existe la noción de parámetro de entrada y de salida, lo cual depende de la forma en que se transmite: el valor indicado por una variable o una expresión; o un apuntador al contenido de una variable. Solo un apuntador transmitido como parámetro puede cambiar el contenido de la memoria. Hablamos de parámetros transmitidos por valor o por referencia.
Ejemplo
Una función que calcula la suma de los valores de dos elementos o de una lista de elementos. Por la suma de dos elementos, implantamos las funciones en los lenguajes C, PASCAL y PL/SQL, y por la suma de una lista realizamos las implementaciones en los lenguajes PROLOG y LISP. El concepto de lista no tiene un tipo predefinido en los tres lenguajes antes mencionados, por lo que es muy diferente en PROLOG y en LISP. Por su parte, en el lenguaje PROLOG no existe la noción de función regresando cualquier tipo de valor, sino que las funciones (llamadas predicados) regresan valores de verdad. Definición de la función suma
int sumaC1(int a, int b) { return a + b; }
int suma; printf(“suma1:%d\n”,sumaC1(12,56)); sumaC2(12,56, &suma); printf(“suma 2: %d\n”, suma);
void sumaC2(int a, int b, int *valor) { *valor = a + b; }
FUNCTION SUMA2(a : integer; b : INTEGER) : INTEGER; BEGIN SUMA2 := a+b; END;
CREATE OR REPLACE PROCEDURE SUMA2(a IN integer, b in INTEGER, s OUT INTEGER) BEGIN s := a+b; END;
suma(0, []). suma(X, [X]). suma(S,[X|L]):-suma(Y,L),S is X +Y.
20
VAR valor_suma : integer; BEGIN valor_suma := SUMA2(12, 67); WRITELN(‘La suma es:’, valor_suma); END.
DECLARE VAR VALOR_SUMA INTEGER; BEGIN SUMA2(12, 67, VALOR_SUMA); END.
?- suma(8, [1, 2, 4]). false. ?- suma(XX, [11, 2, 45]). XX = 58 .
>(suma ()) 0 >(suma ‘(1 2 3 4 5)) 15
(defun suma (lista) « Calculo de la suma de dos elementos de la lista» (if (null lista) 0 (+ (first lista) (suma (rest lista)) ) ))
En ocasiones, las funciones tienen efectos de bordo, transformando contenidos en otras zonas de la memoria o cambiando los estados de los dispositivos de entrada/salida. Por ejemplo, una función clear( ) sin parámetros en lenguaje C o la rutina ClrScr en PASCAL, que borra la pantalla de trabajo. En todos los lenguajes, más que las funciones definidas por el usuario, se utilizan funciones que provienen de librerías externas, estándares (anexadas al compilador o al intérprete) o funciones adicionales. La función citada, que borra la ventana de trabajo, proviene de una librería estándar para los dos lenguajes.
1.3 Pseudocódigo Un pseudocódigo (falso lenguaje) está formado por una serie de palabras con un formalismo muy sencillo, que permite describir el funcionamiento de un programa. Se usa tanto en la fase de diseño como en la fase de análisis. El pseudocódigo describe un algoritmo utilizando una mezcla de frases en lenguaje común, instrucciones de programación y palabras clave que definen las estructuras básicas. Su objetivo es permitir que el programador se centre en los aspectos lógicos de la solución de un problema. El pseudocódigo utiliza expresiones matemáticas, expresiones lógicas y la noción de variable (sencilla, arreglo, pila, cola, conjunto, etcétera). El pseudocódigo se puede extender para expresar tipos complejos y operaciones entre variables y constantes de este nuevo tipo.
Nociones básicas: variables, tipos y expresiones Una variable es un contenido de memoria que contiene un valor que podemos cambiar; es decir, que varía. Una variable tiene un nombre (fijo y único) y un valor (variable durante la ejecución del algoritmo). Las expresiones matemáticas contienen los operadores conocidos, constantes y funciones matemáticas. Por ejemplo: X − 1, 2 +16
18 +
,
sen(2)* cos(x)
Una expresión lógica contiene expresiones matemáticas, operadores de comparación y operadores lógicos. Los operadores de comparación son: =,≠,>,<,≤,≥. Los operadores lógicos son: AND, OR, NOT.
Ejemplo X − 1 = 4,
+
16
≠ 5, sen(x)* cos(x)≥0.2, x < y AND y < z
En este caso, las primeras tres expresiones contienen únicamente un operador de comparación, mientras que la última expresión es la traducción de la expresión matemática: x < y < z. Una variable contiene valor de entrada o de salida (resultados) o cálculos intermediarios. 21
Para cambiar o dar un valor a una variable, se utiliza una lectura o una asignación. La lectura de una variable se realiza de la siguiente forma: Lectura (variable)
El funcionamiento de una variable es conforme al siguiente orden: 1. El usuario del código entrega un valor de buen tipo y este valor se guarda en variable.
Ejemplos
Lectura (alfa). Se lee (entrega) un valor por la variable alfa. Lectura (alfa, beta). Se leen dos valores que se entregan luego: primero a la variable alfa, segundo a la variable beta. 2. Después de una lectura, y hasta un nuevo cambio de valor, el valor contenido en la memoria para una variable no cambia. La asignación de un valor a una variable se realiza de esta forma: variable ← expresión (matemática o lógica)
La expresión (matemática o lógica) puede contener la variable misma, mientras que su tipo es el mismo que el tipo de la variable. La significación de la asignación se efectúa realizando el cálculo de la expresión y, luego, el contenido de la variable se cambia con el valor de ese cálculo; por tanto, el valor anterior de la variable se pierde.
Ejemplos x ← 4 + 22 alfa ← beta × gamma i ← i + 1
En la primera asignación aparece el símbolo matemático , el cual posee una significación muy precisa, pero en el momento de la traducción del pseudocódigo en un lenguaje de programación, debemos buscar cómo se implementa o se puede implementar esta constante. Por su parte, la última asignación tiene el siguiente funcionamiento: con el valor actual de la variable i se hace el cálculo de i + l, cuyo valor sería guardado en la variable i. Por ejemplo, si la variable i vale 3, después de esta asignación el contenido de la variable es 4. La escritura de un contenido se hace de una manera muy simple: Escritura (variable) Escritura (constante)
El funcionamiento es evidente; el valor de la variable o de la constante se pone en la salida del pseudocódigo. La principal función del pseudocódigo consiste en decir qué debe hacer el código y cómo; por esta razón, la escritura es muy simple, sin indicar el formato de salida, y con un cierto número de cifras después del punto decimal; por ejemplo, con una política de caracteres o determinando en qué lugar de la pantalla se escribe.
Ejemplos Escritura (“Aquí termina el programa”) Escritura (i) Escritura (j)
La primera escritura, “Aquí termina el programa”, es una constante de tipo cadena de caracteres. Las siguientes escrituras se colocan en la salida de los valores de las variables i y j, en ese orden. 22
Para ser más específico, antes de una lectura o de una escritura, se puede poner una Escritura (mensaje).
Ejemplos Escritura (“Indica por favor el valor de a:”) Lectura (a) … Escritura (“Mi calculo final es:”) Escritura (X)
Estructura general del pseudocódigo Un pseudocódigo se escribe para dar las grandes líneas del cálculo; su objetivo es compartir con los demás programadores su visión de la resolución del problema. Hay dos principios en la escritura de un pseudocódigo: Al inicio se escriben todas las variables que se usan en pseudocódigo; cada una con su nombre y su tipo. Las líneas del pseudocódigo que siguen son órdenes (instrucciones o estructuras) que se ejecutan de arriba hacia
abajo; primero una orden y después otra, así sucesivamente. El pseudocódigo que se utiliza para la descripción de un algoritmo o para indicar los pasos de resolución de un problema contiene estructuras de control, las cuales se utilizan para describir las instrucciones de los algoritmos. Hay cuatro tipos de estructuras: Secuencial Selectiva Iterativa Anidamiento
Estructuras componentes del pseudocódigo Estructura secuencial
La estructura de control secuencial tiene la siguiente forma: instrucción^ 1 instrucción^ 2 … instrucción^ k
Las instrucciones se ejecutan en el orden indicado por los índices: de arriba hacia abajo y una después de la otra.
Ejemplo
Primer pseudocódigo Lectura de x y cálculo del cuadrado de x: Real x, y; Lectura (x) y ← x × x Escritura (y)
Observaciones: La primera línea contiene la declaración de las variables; x y y son las únicas variables y tienen el mismo tipo. 23
Las instrucciones se ejecutan en el siguiente orden: lectura, asignación, escritura. No hay ambigüedades en cada instrucción y pueden ejecutarse. Las variables que se usan tienen un valor correcto al inicio o al fin de cada instrucción y en todo el pseudocódigo.
La estructura secuencial es la base del pseudocódigo, pero no es suficiente para resolver todos los problemas. Estructura selectiva
Las estructuras selectivas permiten expresar las elecciones que se hacen durante la resolución del problema. Hay varios tipos de estructuras selectivas: Selectiva simple. Selectiva doble (alternativa). Selectiva múltiple. Selectiva casos (múltiple).
La estructura selectiva simple es de la siguiente forma: si expresión lógica entonces instrucciones fin si
En esta estructura, primero se hace el cálculo de la expresión lógica; si el valor de esta expresión es cierto (no falso) se ejecutan las instrucciones (puede ser una sola o más de una). Si el valor de la expresión lógica es falso, no se ejecuta nada. Las palabras si, entonces y fin si, son palabras clave que permiten estructurar y dar un sentido a las instrucciones. Por otro lado, es posible escribir la estructura anterior como: si expresión lógica entonces instrucciones
fin si
Esta escritura no es tan clara. ¿Dónde inicia y dónde termina la estructura si?, ¿dónde empiezan las instrucciones que se ejecutan cuando la expresión es cierta? Por estas razones, es mejor que las partes que componen una estructura (en nuestro caso selectiva, aunque también aplica para todas las estructuras selectivas e iterativas) se escriban con algunos espacios y que la parte si se alinee con la parte fin si. La estructura selectiva alternativa es de esta forma: si expresión lógica entonces instrucciones1
si no
instrucciones2
fin si
Primero, se hace el cálculo de la expresión lógica. Si el valor de esta expresión es cierto (no falso) se ejecutan las instrucciones1. Si no, se ejecutan las instrucciones2. 24
Ejemplo
Verificar si un número entero es o no divisible entre 3 . La entrada es el número n, la salida es un mensaje de tipo Sí o No. Integer N Lectura (N) resto N%3 si resto = 0 entonces Escritura (“SÍ”) sino Escritura (“NO”) fin si
La estructura selectiva múltiple es usada para anidar condiciones lógicas mutuamente excluyentes. Su forma es la siguiente: si expresión lógica1 entonces
instrucciones1 sino si expresión lógica2 entonces
instrucciones2 sino si expresión lógica3 entonces
instrucciones3
…
sino
instruccionesn fin si
Esta estructura se ejecuta de la siguiente manera: Se hace el cálculo de la expresión lógica1, si el resultado es cierto se ejecutan instrucciones1 y la instrucción selectiva se termina. Si no, se hace el cálculo de la expresión lógica2; si el resultado es cierto se ejecuten instrucciones2 y la instrucción selectiva se termina… Si todas las expresiones lógicas son falso, entonces se ejecutan instruccionesn.
Ejemplo
Resolver la ecuación de primer grado que tiene su forma matemática más general: ax + b = 0 La entrada está formada por los dos parámetros de la ecuación, a y b, que serán guardados en dos variables de tipo punto flotante. La salida será un mensaje sobre la raíz de la ecuación y, en algún caso, su valor. Los casos que pueden aparecer y que deben tratarse de manera diferente serán: a = 0 y b = 0: cualquier número real es una solución. A = 0 y b 0: no hay ninguna solución. a 0: la raíz es única y de valor –a/b.
Una proposición de pseudocódigo es la siguiente: Real a, b 25
Real x Lectura (a) Lectura (b) si a ≠ 0 entonces Escritura (“Hay una única raíz”) x b/a Escritura (x) sino si b ≠ 0 entonces Escritura (“No hay ninguna raíz”) sino Escritura (“Hay una infinidad de raíces”) fin si
Este pseudocódigo contiene una estructura selectiva múltiple con condiciones lógicas exclusivas. La resolución se puede hacer con un pseudocódigo que contiene dos estructuras selectivas alternativas, una añadida a la otra: Real a, b, x Lectura (a) Lectura (b) si a = 0 entonces si b = 0 entonces Escritura (“Hay una infinidad de raíces”) sino Escritura (“No hay ninguna raíz”) fin si sino Escritura (“Hay una única raíz”) x b/a Escritura (x) fin si
Las dos soluciones propuestas son semánticamente equivalentes, por lo que se realiza el mismo tratamiento; depende únicamente de la manera de escribir y leer el código y de que los participantes en el desarrollo de la solución del problema prefieran una u otra. En la segunda versión, en lugar de dos declaraciones de variables, también tenemos una sola para las tres variables del código; sin embrago, la primera versión permite nada más una separación “lógica” entre las variables de entrada y la única variable de salida. La estructura selectiva múltiple-casos se usa cuando un mismo valor se compara con varios valores. Su forma es la siguiente: seleccionar expresión caso valor1 instrucciones1 caso valor2 instrucciones2 … en otro caso instruccionesn fin seleccionar
La expresión puede ser una sola variable. Primero, se obtiene el valor de esta expresión y se compara con cada valori; si hay expresión = valori, se ejecutan las instruccionesi. Si ningún valor corresponde, se ejecuta la parte ‘ en otro caso’, instruccionesn. 26
Ejemplo
Según el valor de una variable de entrada, se escribe: “FALSO” si el valor de la variable es 0. “CIERTO” si el valor de la variable es 1. “INDEFINIDO” si el valor de la variable es −1. “ERROR” en otros casos.
El pseudocódigo es muy simple, está estructurado en dos partes: la lectura de la variable y una estructura selectiva de tipo “caso”: Integer x Lectura (x) seleccionar x caso 0 Escritura (“FALSO”) caso 1 Escritura (“CIERTO”) caso −1 Escritura (“INDEFINIDO”) en otro caso Escritura (“ERROR”) fin seleccionar Observación: El orden de tratamiento de los casos es libre; se puede poner cualquier permutación de los valores −1, 0 y 1, únicamente al final se pone “en otro caso”.
Estructura iterativa
Las abren laparte posibilidad de ejecutar grupo instrucciones más de una vez; es decir, sirven para estructuras iterativas ejecutar varias veces una misma de un código. Hay un varios tiposdede estas: Bucle mientras Bucle repetir Bucle para (cada)
La estructura iterativa mientras (while) tiene la siguiente forma: mientras expresión lógica hacer instrucciones fin mientras
Su ejecución es la siguiente: Se calcula la expresión lógica y, si su valor es cierto, se ejecutan lasinstrucciones y se hace un nuevo cálculo de la expresión lógica. Entones, en total, las instrucciones se ejecutan 0 o varias veces, dependiendo del valor de la expresión lógica.
Ejemplo
Buscar el entero m más pequeño, pero que sea mayor que un número real positivo x, con x 1.
componen la expresión lógica presión siempre sería cierta y la estructura no se terminaría nunca.
27
La entrada del problema es el número x y la salida es el número entero m. La idea es incrementar en 1 una variable m iniciada con el valor 0 hasta que la variable m sea más grande que la variable de entrada x. El pseudocódigo es: Real x Integer m Lectura (x) m
0
mientras m
m + 1
fin mientras Escritura (m)
Ejemplo Buscar el número entero más grande de forma 2 k que sea menor que un número real positivo x, con x
1.
La entrada del problema es el número x y la salida es el número m = 2k; asimismo, podemos considerar que la variable k es también una salida. La resolución de este problema es parecida a la del problema anterior: Se calcula la potencia más pequeña de 2(2 j), que sería más grande que x; después, se hace un paso detrás. El pseudocódigo es de la siguiente forma: Real x Integer m, k, j Lectura (x) m 1 j 0 mientras m ≤ x hacer m m*2 j j + 1 fin mientras m m/2 k j–1 Escritura (“El numero que se busca es:”) Escritura (m) Escritura (“es la potencia 2 de”) Escritura (k) tura repetitiva, durante las instrucciones componen la expresión lógica nera, el valor de la expresión no cambia y la estructura repetir de veces.
En este ejemplo de pseudocódigo, se muestra que antes y después de la estructura repetitiva mientras, entre las variables m y j, siempre existe la relación m = 2j. La estructura iterativa repetir (repeat) tiene la siguiente forma: repetir instrucciones hasta que expresión lógica
Su ejecución es la siguiente: Se ejecutan las instrucciones y se hace el cálculo de la expresión lógica. Si su valor es falso, se ejecutan de nuevo las instrucciones y se hace un nuevo cálculo de la expresión lógica. En resumen, lasinstrucciones se ejecutan una o másveces, dependiendo del valor de laexpresión lógica. 28
Una estructura repetir es equivalente a: instrucciones mientras NOT (expresión lógica)
instrucciones fin mientras
Ejemplo
Para un número real x entre 0 y 1 (0
El algoritmo de transformación de un número en base 10 en otra base se aborda en el apéndice 1; la idea es hacer multiplicaciones con la base b, tomando después la parte entera de cada multiplicación. Así, el pseudocódigo: Real x Integer Real y, Integer Lectura Lectura Lectura y x i 0
b, k p i, c (x) (b) (k)
repetir p y*b c parte_entera(p) y parte_fraccionaria(p) Escritura (c) i i + 1 hasta que i ≥ k
Este pseudocódigo reviste interés por varias razones: Hasta ahora las salidas de los algoritmos se han colocado al final de todos los cálculos; desde que se obtiene una cifra de la representación fraccionaria, esta cifra contenida en la variable c, se escribe y a cada paso se calcula un nuevo valor en la iteración siguiente. Se ejecutan exactamente k iteraciones; la variable i indica, de manera muy precisa, cuántas iteraciones se ejecutaron. La manera de trabajar con la variable i no es única, también se puede escribir así: …
i 1 repetir p y*b c parte_entera(p) y parte_fraccionaria(p) Escritura (c) i i + 1 hasta que i > k 29
El sentido de la variable i es ahora: se está ejecutando la iteración número i. Se requiere el cálculo de las partes entera y fraccionaria; entonces, se indica con claridad la “llamada” de funcio-
nes, que normalmente están implementadas en casi todos los lenguajes de programación. La variable p es intermediaria y sirve para hacer una sola vez la multiplicación de y por b. El uso de esta variable
no es obligatorio, se puede escribir directamente y en el siguiente orden: c
parte_entera(y*b)
y
parte_fraccionaria(y*b)
Nota: Nada más que la misma multiplicación se hace dos veces. Otra variable intermediaria es y, que guarda los valores intermediarios por el cálculo de la representación de x; al
inicio, esta variable toma el valor de x. Es posible usar directamente x en los cálculos; pero, al final de la estructura repetitiva, el valor de x se desnaturaliza y el valor inicial se pierde. Una estructura iterativa que toma en cuenta la noción de variable-contador es la estructura iterativa para (for), la cual tiene la siguiente forma: para i de inicio hasta fin [paso p] hacer
instrucciones fin para
Donde i es una variable (simple) einicio, fin, p, son valores numéricos. Si el paso no es declarado, su valor es 1. Su ejecución es repetitiva y su funcionamiento es el siguiente: La i recibe el valor inicio y se ejecutan las instrucciones; luego, i se incrementa el valor de p (el paso) y se reejecutan las instrucciones, si el valor de i es menor que fin. En resumen, las instrucciones se ejecutan 0 o varias veces, dependiendo de los valores de inicio, fin y el paso. El valor de la variable i se puede usar al interior de las instrucciones, pero no puede ser modificado. Esta estructura es equivalente a: i inicio mientras i ≤ fin hacer
instrucciones i
i + p
fin mientras
Ejemplo
Obtener todas las potencias de un número a desde a 1 hasta ak, donde a y k son valores de entrada; a es un número real y k es un entero positivo. El uso de una estructura repetitiva “para cada” es evidente: Real a, p Integer k, i Lectura (a) Lectura (k) p 1 para cada i de 1 hasta k p p*a Escritura (p) fin para 30
Es muy fácil verificar que al final de cada iteración la variable p contiene el valor de a k. El pseudocódigo funciona también por un número k entregado, que es 0 o un valor negativo; en este caso, no se ejecuta nada. Ejemplo Obtener la representación fraccionaria de un número. El pseudocódigo del ejemplo anterior se puede escribir con una estructura iterativa “para cada”: Real x Integer Real y, Integer Lectura
b, k p i, c (x)
Lectura Lectura (b) (k) y x para cada i de 1 hasta k paso 1 p y*b c parte_entera(p) y parte_fraccionaria(p) Escritura (c) fin para
En los últimos dos ejemplos, si se quieren guardar los valores de salida, las potencias de a y las cifras en base b del uso de las variables simples no son suficientes, por lo que es indispensable usar los arreglos.
Uso de los arreglos En la sección 1.2 presentamos las nociones de variable en general y de arreglo. Como se vio, un arreglo se caracteriza por un tipo básico (entero, cadena de caracteres, …), su nombre (como cualquier variable) y su tipo. En el pseudocódigo también es útil poder usar los arreglos. Así, en la parte de la declaración debemos establecer que la variable es un arreglo, indicando su dimensión. Por ejemplo: Integer B, A[10] Real X [200]
Aquí, podemos ver que B es una variable simple, A es un arreglo de valores enteros y X es un arreglo de valores en punto flotante. Los dos arreglos tienen una dimensión fija: 10 por el arreglo A y 200 por el arreglo X. También, es posible declarar arreglos con una dimensión variable, pero se debe tomar en cuenta, en la escritura de los programas, porque en algunos lenguajes de programación la definición de arreglo es más complicada que una simple declaración. Por ejemplo: Integer N Integer A[N]
En este caso, el arreglo A no se puede usar si la variable N no está definida; así, es preferible que durante la ejecución del código, el valor de esta variable no cambie.
Ejemplo La suma de los elementos de un arreglo que se lee de entrada: Integer N, suma, i Integer A[N] Lectura (N) para cada i de 1 hasta N hacer 31
Lectura (A[i]) fin para /* ahora el arreglo está lleno */ suma 0 para cada i de 1 hasta N hacer suma suma + A[i] fin para Escribir (suma)
Las partes entre /* y */ son comentarios, que ayudan al lector a entender mejor el código. El arreglo A tiene una dimensión variable. Como un consejo de programación podemos indicar que cuando la dimensión posible no es demasiado grande, conviene hacer la declaración con una constante, a fin de evitar trabajo de programación más difícil. Ejemplo El cálculo de las potencias de a, desde a 1 hasta ak. Sin reducir la calidad de la solución ni su generalidad, podemos suponer que k 30. En esta versión, los números de forma a i serían guardados en un arreglo C[30]. El pseudocódigo es, por tanto: Real a, C[30] Integer k, i Lectura (a) Lectura (k) p 1 para cada i de 1 hasta k p p*a C[i] p fin para para cada i de 1 hasta k Escritura (C[i]) fin para
Funciones y procedimientos Las funciones y los procedimientos son partes de un programa que van a ser ejecutadas, una o varias veces, con los valores transmitidos en los parámetros: Función. Recibe parámetros (uno o varios) y calcula un valor de regreso. Recibe parámetros, pero no regresa explícitamente ningún valor.
Los parámetros son de tipos conocidos (simples o arreglos) y pueden ser de entrada o de salida. Un parámetro de entrada sirve para introducir los valores necesarios al cálculo y un parámetro de salida va a cambiar su valor durante la ejecución de la función o del procedimiento, para entregar resultados. La definición de un parámetro es la siguiente: [IN/OUT] tipo_parametro nombre_parametro
Con la palabra “IN” se indica un parámetro de entrada y con la palabra “OUT” se indica un parámetro de salida. Los parámetros de entrada siempre deben tener valores; al contrario de los parámetros de salida. Las funciones y los procedimientos se declaran una sola vez y pueden ejecutarse varias veces. Una ejecución se denomina llamada. La declaración de un procedimiento es:
32
procedimiento nombre_procedimiento (lista de definiciones de parametros) inicio … fin procedimiento
La llamada de un procedimiento es de la siguiente forma: nombre procedimiento(parametro1, parametro2, …)
Donde los parametros1 son variables o arreglos, expresiones o constantes. Hay casos en los que los parámetros corresponden en número y tipo con la definición.
Ejemplo La definición de un procedimiento para la lectura de un arreglo: procedimiento lectura_arreglo (IN Integer dimension, OUT Integer X[]) inicio Integer i para cada i de 1 hasta dimension hacer Lectura (X[i]) fin para fin procedimiento Ejemplo de dos llamadas: lectura_arreglo(100, A) lectura_arreglo(N, X)
Una función se define como: función tipo nombre_funcion (lista de definiciones de parámetros) inicio ….
regresa valor fin función
La función tiene que regresar o devolver un valor del mismo tipo de la función. Una llamada de función es parecida a una llamada de procedimiento y puede ser escrita en una expresión.
Ejemplo La definición de una función para la suma de los elementos de un arreglo es la siguiente: función Integer suma_arreglo(IN Integer dimension, IN Integer X[ ]) inicio Integer i, ss ss 0 para cada i de 1 hasta dimension hacer ss ss + X[i] fin para regresa ss fin funcion
El programa completo para calcular la suma de los elementos de un arreglo puede ser el siguiente: 33
Integer N, suma Integer A[N] inicio Lectura (N) lectura_arreglo (N, A) Escribir(suma (N, A)) fin
1.4 Diagrama de flujo Los diagramas de flujo son comunes en varios dominios técnicos y se usan para poner en orden los pasos a seguir o las acciones a realizar. Su principal ventaja es que tienen la capacidad de presentar la información con gran claridad, además de que se necesitan relativamente pocos conocimientos previos para entender los procesos y/o el objeto del modelado. Por ejemplo, en el siguiente diagrama se presentan los pasos a seguir cuando alguien sale de vacaciones: Start
Cierre todas las ventanas
Cierre agua, gas y luz Cierre la puerta de entrada
NO
¿Hay un vecino de confianza?
SÍ
Deje un duplicado de las llaves al vecino
Stop
Figura 1.8 34
En la descripción de los algoritmos o de los programas existen varios formalismos. Pero, de una manera sintética, las reglas comunes a todos para expresar algoritmos, según el paradigma de la programación estructurada, son: Un diagrama de flujo se lee de arriba hacia abajo. Un diagrama se compone de bloques entre los cuales existen flechas que indican el sentido de lectura o de eje-
cución. Tanto al inicio como al final hay un solo bloque, “START” y “STOP”, respectivamente (véase figura 1.9).
Start
Stop
Figura 1.9 Para las operaciones de entrada o de salida se utilizan los bloques con la forma de un paralelogramo (véase figura
1.10). Lectura(x)
Escritura(x)
Figura 1.10 Los bloques para hacer asignaciones son rectangulares o cuadrados (véase figura 1.11).
variable
expresion
Figura 1.11 Una decisión tomada con base en una expresión lógica se expresa con un bloque en forma de rombo (véase figura
1.12).
NO
expresión lógica
SÍ
Figura 1.12
La mayoría de las estructuras de la programación estructurada presentadas tienen una transcripción evidente e inmediata en diagramas de flujo: La estructura selectiva simple y la estructura selectiva alternativa: 35
SÍ
NO
expresión lógica
instrucciones 1
instrucciones 2
Figura 1.13
Nota: Cualquier rama (SÍ o NO) puede dejarse vacía. Estructura iterativa “mientras”:
expresión
SÍ
lógica
NO instrucciones
Figura 1.14 Estructura iterativa “repetir”:
instrucciones
expresión lógica NO SÍ
Figura 1.15 36
En cada una de estas estructuras, “instrucciones” significa cualquier construcción correcta de diagrama de flujo formada para uno solo o más bloques. Las variables (simples, arreglo o de otro tipo) de un diagrama de flujo pueden considerarse implícitamente declaradas desde sus primeras apariciones, o bien pueden declararse de manera explícita y detallada en un documento anexo al diagrama. De cualquier forma, la primera aparición de una variable debe ser en bloque de entrada o escrita en el lado izquierdo de una asignación. Por ejemplo: Start
Start j
Lectura (i)
j
k + 3
Lectura (i)
i + 3
Stop
Stop
Figura 1.16
El primer diagrama de flujo es correcto, porque en el momento del cálculo de la variable j, la variable i es conocida y contiene un valor; pero en el segundo diagrama, el uso de la variable k del lado derecho de la primera asignación no es correcto, porque aquí la variable k aparece por primera vez y no contiene ningún valor. Los ejemplos de diagramas de flujo que aparecen enseguida, son la resolución de los problemas y ejercicios mostrados como ejemplos a lo largo de todo el capítulo. El diagrama de flujo es, en la mayoría de los casos, una traducción fiel del pseudocódigo. Los diagramas de flujo fueron concebidos y probados con el software Raptor.8 En el apéndice 2 que se encuentra en el CD-ROM, se presenta este software, la manera cómo hacer los diagramas de flujo y la forma de probarlos.
Ejemplo 1 Leer una variable x y calcular el cuadrado de x. Start
“Su variable:” GET x
y x * x
PUT “El cuadrado de “+ x +”es “+ y”
End
Figura 1.17 8
Raptor es un software libre disponible en: http://raptor.martincarlisle.com/
37
Este diagrama de flujo es lineal y muy simple: lectura, cálculo y escritura.
Ejemplo
Verificar si un número entero es o no divisible entre 3. Start
“Su variable:” GET I
resto
I%3
SÍ
NO resto = 0
PUT “SÍ, es divisible entre 3”
PUT “NO. No es divisible entre 3”
End
Figura 1.18
Ejemplo 3
Resolver la ecuación de primer grado que tiene su forma matemática más general: ax + b = 0 Start
“Su valor:” GET n
SÍ
PUT “FALSO”
x = 0 SÍ
NO
x = 1
NO SÍ
PUT “CIERTO”
“PUT INDEFINIDO”
End 38
Figura 1.19
x = −1
NO
PUT “ERROR”
Ejemplo 4
Según el valor de una variable de entrada, se escribe: “FALSO” si el valor de la variable es 0. “CIERTO” si el valor de la variable es 1. “INDEFINIDO” si el valor de la variable es −1. “ERROR” en otros casos. Start
“Su valor:” GET n
SÍ
PUT “FALSO”
n = 0
SÍ
NO
n = 1
NO
SÍ
PUT “CIERTO”
PUT “INDEFINIDO”
n = −1
NO
PUT “ERROR”
End
Figura 1.20
En el software Raptor no se dispone de formalismos para modelar la estructura selectiva “casos”; entonces,
se utiliza la estructura alternativa “clásica”; esto es, existe la libertad de poner los bloques de decisión en cualquier orden, aunque es mejor seguir el enunciado del problema. Ejemplo 5
Buscar el número entero más pequeño, m que es mayor que un número real positivo x, con x l.
39
Start
“Su variable real > l:” GET x
SÍ
x = l
NO
PUT “La entrada no es correcta”
m
0
Loop
SÍ
m > x NO m
m + l
PUT “Se obtiene: “ + m + l > “ + x
End
Figura 1.21 En el diagrama introducimos de manera suplementaria la prueba para certificar si la entrada es correcta; a
saber, si x l.
Ejemplo 6
Buscar el número entero más grande de forma 2 k que sea menor que un número real positivo x, con x
40
1.
Start
“Su valor x:” GET x
m
1
Loop m SÍ
m * 2
m > x NO m
m/2
PUT “m =” + m + “ < x = “ + x
End
Figura 1.22 Por razones de tamaño, en este diagrama de flujo hemos renunciando al cálculo de j con m = 2j.
Ejemplo 7
Para un número real x entre 0 y 1 (0 < x < 1), una base de numeración b (b 10) y un número entero positivo k, buscar las k primeras cifras después del punto decimal de la representación de x en base b.
41
Start
“Su número entre 0 y 1:” GET x “La base de numeración:” GET b
“Número cifras en kbase “ + b de + “: “ GET
y
x
i
0
Figura 1.23
Loop
p
c
y
y * b
floor (p)
p – floor (p)
PUT c
i
i + 1
SÍ i > = k NO
End
Figura 1.24 42
Con respecto al pseudocódigo, cambiamos el orden de obtención de c al interior de la estructura iterativa
por una razón pedagógica: disponemos de la función floor() para el cálculo de la parte entera inferior; entonces, obtenemos la parte fraccionaria por diferencia entre el número y su parte entera. Véase el CD-ROM de apoyo que acompaña este libro, donde se incluye una versión en la que se usa un arreglo para guardar las cifras en base b con una escritura más legible del resultado.
Ejemplo 8
Obtener todas las potencias de un número a, desde a 1 hasta ak, donde a y k son valores de entrada, a es un número real y k es un entero positivo. Start
“El número real:” GET a “El coeficiente máximo:” GET k
i
1
p
1
Figura 1.25
Loop
SÍ i > k NO p
c[i]
i
p * a
p
i + 1
Figura 1.26 43
i
1
Loop
PUT in + “ + c[i]
i
“
i + 1
SÍ i > k NO
End
Figura 1.27 El diagrama de flujo implementa (expresa) la versión con las potencias de a almacenadas en un arreglo
que se escribe al final, durante otra estructura repetitiva.
Ejemplo 9 Calcular la suma de los elementos de un arreglo que se lee de entrada. Start
lectura_arreglo (M, B)
suma (M, B, suma)
escribir (suma, “la suma de los elementos del arreglo”)
End
Figura 1.28 44
La herramienta que empleamos, únicamente nos permite el uso de procedimientos; entonces, la solución
del problema la expresamos como tres llamadas de procedimientos: 1. Por la lectura del arreglo:
Start (out N, out A)
“La dimensión del arreglo:” GET N
i
1
Loop
“Elemento “+i+“:“ GET A[1]
I
I + 1
SÍ i > N NO
End
Figura 1.29
45
2. Por el cálculo de la suma de los elementos del arreglo: Start (in N, in A, out s)
s
0
i
1
Loop
s
i
s + A [i]
i +1
SÍ i > N NO
End
Figura 1.30
3. Por la escritura de un valor y de un mensaje: Start (in valor, in mensaje)
PUT valor + “ mensaje
End
Figura 1.31
46
“
+
Aquí se puede observar que el nombre del arreglo es X y que el nombre de su dimensión es M. Estos
nombres se usan en las llamadas de los procedimientos de lectura y de cálculo de suma. Así, podemos definir los parámetros de los procedimientos con los nombres que deseamos; en este caso, N por el parámetro de dimensión y A por el parámetro que guarda el arreglo. Algunos de los parámetros son: de entrada, cuando se calcula la suma de los elementos del arreglo, o de salida, que es el valor calculado de esta suma.
Síntesis del capítulo La computadora siempre ejecuta órdenes en un formato inteligible para ella; dichas órdenes están agrupadas en un programa o software. Un programa está escrito en un lenguaje de programación de alto o bajo nivel y traducido en código ejecutable. Por su parte, un software es un conjunto de programas. El trabajo de realización de un software que resuelve un problema o que responde a una situación está basado en la elaboración de algoritmos. Un algoritmo sigue un proceso de elaboración que pasa por las siguientes fases:
1. Definición. Se especifica el propósito del algoritmo. 2. Análisis. Se analizan el problema y sus características; se determinan las entradas y las salidas del problema, y se elige la solución más conveniente, si hay varias, o se propone una nueva. 3. Diseño. Se plasma la solución del problema; aquí se emplea una herramienta de diseño: el diagrama de flujo y el pseudocódigo. 4. Implementación. Se realiza el programa y se hacen varias pruebas; el programa se edita con editores de texto y se compila o se interpreta a fin de crear el ejecutable o ejecutar el código. Los programas se escriben en un lenguaje de programación. Hay varios paradigmas de programación y una multitud de lenguajes de programación que tienen uno o varios paradigmas. La elección del lenguaje de programación depende principalmente del tipo de problema a resolver, de la computadora y de otros dispositivos físicos que se utilizarán. Los programas contienen variables. Una variable tiene un tipo y un nombre que debe ser único. Según los paradigmas el lenguaje, la asignación de una zona de memoria para la variable se hace en la memoria (memoria central, en la mayoría de los lenguaje) de manera estática o dinámica. El pseudocódigo y los diagramas de flujo son herramientas de diseño de algoritmos más o menos equivalentes, que tienen el paradigma de programación imperativa y estructurada. Las estructuras de paradigma que se conocen son: Estructura secuencial. Estructura alternativa. Estructura iterativa. Funciones y procedimientos.
Las variables que se usan en el pseudocódigo o en el diagrama de flujo pueden ser simples, de tipo arreglo o de otro tipo, descrito por el programador. Cada variable posee un nombre único y no ambiguo y tiene reservada una zona de memoria en la cual se almacena el valor de la variable.
47
Bibliografía Knuth, Donald, El arte de programar ordenadores, Vol. I, “Algoritmos fundamentales”, Editorial Reverté, Barcelona,
Bogotá, México, 1986. Cedano Olvera, Marco Alfredo y otros, Fundamentos de computación para ingenieros, Grupo Editorial Patria, Méxi-
co, 2010. Alfred V., Sethi, Ravi y Ullman, Jeffrey D., Compilers: Principles, Techniques, and Tools , Addison-Wesley, Estados
Unidos, 1986.
Ejercicios y problemas 1. ¿Cuál es la diferencia entre un programa y un algoritmo?
2. ¿Es posible escribir directamente un programa para la resolución de un problema? ¿Es útil?
3. Construir el árbol de evaluación y luego escribir la expresión en lenguaje informático en forma de infijo de: a) ab + bd b) a2 – 103 a−b
2
−ac4
c)
2b d) a + b < c y 2c < 4a + 3b
4. Escribir la expresión lógica que corresponde a la expresión a 0 y b2 – 4ac > 0 matemática siguiente:
5. Proponer nombres de variables para resolver una ecuación de segundo grado.
6. Si los coeficientes algebraicos de una ecuación de segundo grado son números reales, proponer tipos para las variables elegidas en el problema anterior. 7. Si los coeficientes de la ecuación x2 – by2 = 0 son números enteros, proponer nombres y tipos de variables para buscar soluciones enteras. 8. ¿Por qué la definición de una función parece tan diferente entre los lenguajes C, PASCAL y PL/SQL, por un lado, y PROLOG o LISP, por el otro? ¿Por qué los primeros ejemplos son tan parecidos (lenguajes C, PASCAL y PL/SQL)?
48
9. ¿Cuál paradigma de programación gobierna a los diagramas de flujo y al pseudocódigo?
10. Hacer una tabla con columnas asociadas a los paradigmas de programación y con líneas asociadas de al menos cinco lenguajes de programación citados en el capítulo. Indicar con el símbolo de paloma si el lenguaje sigue o no el paradigma enunciado.
11. ¿Cuál es el orden de ejecución de las operaciones en la expresión siguiente? A* 3000 + 45 > A+45 * 3000
12. Si las variables a, b y c tienen los valores 1, 2 y 3, respectivamente, ¿cuál es el valor de la siguiente expresión? NOT((a <= b) AND (a + b <= c))
13. de un círculo, cuando conocemos el radio del círculo.
14. cuando conocemos el diámetro del círculo.
15. cuando conocemos el radio del círculo y la altura.
h
r Figura 1.32
Queremos saber: El volumen. El área de la superficie cilíndrica.
49
16. Para un año entre 1900 y 2100 la fecha de Pascua se determina según las siguientes fórmulas:
M = 25, N = 4 constantes, AN es el año a = AN mod19, b = AN mod 4, c = AN mod7 d = (19a + M) mod 30, e = (2b + 4c + 6d + N) mod7 si d + e> 9, la fecha es el día de d + e − 9 de abril si no, la fecha es d + e + 22 de marzo. 17. “Lunes” o “Martes”, o..., cuando conocemos el número del día de la semana (1, 2, ..., 7).
18. El valor absoluto de un número es definido como:
a) Crear el pseudocódigo que lo calcula. b) Escribir una función con un parámetro de entrada y de regreso, del valor absoluto de su parámetro.
19. Conversión de una temperatura expresada en grados Fahrenheit a grados Celsius según la fórmula: °C = (5/9)(°F – 32) Hacer dos pseudocódigos: a) Uno para la conversión de grados Celsius a grados Fahrenheit. b) Uno para la conversión de grados Fahrenheit a grados Celsius.
20. Resolver una ecuación de segundo grado. Una ecuación de segundo grado, en su forma más general, es:
ax2 + bc + c = 0 21. Cálculo de N! con N entero, positivo y bastante pequeño (N 8).
22. Ecuación diofántica. Resolver la ecuación x2 – by2 = 0 con a y b números enteros. Aquí nos interesan únicamente las soluciones de parejas de números enteros y positivos.
50
23. Aplicar el algoritmo de Euclides para calcular el divisor común más grande. El algoritmo tiene dos versiones: con sustracciones sucesivas o con divisiones. 24. Leer un arreglo y construir su imagen en espejo: En otro arreglo. En el mismo arreglo.
25. Cálculo del más pequeño y el más grande de un número variable N de números.
26. Verificar si un arreglo contiene únicamente valores de tipo 2k, con K entero positivo.
27. Verificar si un arreglo tiene sus valores en orden creciente. Es decir que para un arreglo X de dimensión N:
X[ i ] X[i + 1], i = 1, N − 1 28. Si un arreglo tiene sus valores en orden creciente, calcular su valor mediano.
29. Decimos que un arreglo contiene una si tiene al menos dos valores sucesivos iguales. Por ejemplo, el arreglo siguiente tiene tres zonas planas: 1 2 3 33 4 4 5 6 7 8 20 20 20 20 20 20 que tienen los valores 3, 4 y 20. Si el arreglo contiene zonas planas, indicar el tamaño por la zona plana más amplia y el valor que se repite. Por ejemplo, en el arreglo indicado, la zona plana más amplia tiene el tamaño de 5 y contiene el valor 20.
51
2
Introducción a la programación
Contenido
Ecuación de primer grado Ternas pitagóricas Ejercicios y problemas
2.1 Introducción 2.2 Mi primer programa 2.3 Estructura de un programa Directivas Comentarios Declaraciones y definiciones 2.4 Variables y expresiones Identificadores Tipos y variables Constantes Expresiones con operadores 2.5 Control de flujo Proposiciones y bloques Estructuras alternativas Estructuras iterativas Otras proposiciones de control de flujo 2.6 Problemas resueltos 52
Objetivos
lenguaje C. 52
2.1 Introducción Un programa traduce un algoritmo en un lenguaje de programación; dicho algoritmo está muy claro en la mente del programador o se expresa en un pseudocódigo o con la ayuda de diagramas de flujo. Por tanto, para poder escribir un programa, necesitamos conocer y utilizar un lenguaje de programación. En este segundo capítulo comenzaremos con el aprendizaje de un lenguaje de programación de alto nivel: el lenguaje C, el cual fue propuesto al inicio de la década de 1970 por Dennis M. Ritchie y ha pasado por varias etapas de evolución. La más notable es la versión del lenguaje propuesta por Dennis M. Ritchie y Brian Kernighan en la primera edición del libro El lenguaje de programación C, que es la principal referencia para aprenderlo. La norma ANSI C o C89 (denominación exacta: ANSI X3.159-1989) es una extensión del C de Kernighan y Ritchie. En este texto vamos a usar la norma ANSI C, y el código que se presenta fue probado con el compilador gcc. El lenguaje C es un lenguaje de alto nivel (significativo para el programador), que sigue el paradigma de la programación imperativa y estructurada, lo que le permite trabajar las llamadas de funciones, la recursividad y mucho más en un nivel alto, al igual que funcionar a un nivel bastante bajo, tipo ensamblador, para trabajar directamente el contenido de la memoria, manejar entradas y salidas desde equipos especiales y realizar trabajo sobre la red.
2.2 Mi primer programa Un programa fácil de entender y realizar es el de calcular el área de un círculo cuando se conoce su radio. Se tiene la siguiente fórmula matemática:
área = Una versión de este código en lenguaje C es la siguiente:
×
2
radio
/* Area de un circulo */ #include
Este código se almacena en un archivo.c. Por ejemplo, primer.c; el archivo se escribe con un editor de texto y se procesa en un compilador, por ejemplo el gcc,1 utilizando la siguiente instrucción: gcc -o primer primer.c
Para ejecutar el programa primer en gcc se utiliza la siguiente instrucción: ./primer 1
Véase el apéndice 3 en el CD-ROM para más detalles sobre la compilación. 53
Introducción a la programación
El cual produce como salida: El area de un circulo de radio 10.500000 es 346.360596.
Nuestro primer programa C se compone de: Un comentario
/* Area de un circulo */ Una parte de directivas
#include
int main(int argc, char *argv[]) { float radio = 10.5; float area; area = PI*radio*radio; printf(“El area de un circulo de radio %f es\t %f.\n”, radio, area); exit(0); }
El comentario no incluye información para la ejecución misma del código, pero cuenta con datos importantes para el programador o un lector externo del código (otra persona). Las directivas que se usaron en el ejemplo anterior son de dos tipos: De inclusión de bibliotecas externas: #include De definición de valores constantes: #define, que es en realidad una sustitución que se hace al inicio de la com-
pilación (el preprocesamiento).
El bloque main contiene una parte de declaración de variables (las dos variables radio y area de tipo flotante): float radio = 10.5; float area;
y una parte de proposiciones: area = PI*radio*radio; printf(“El area de un circulo de radio %f es\t %f.\n”, radio, area); exit(0);
Dos de las tres proposiciones anteriores son funciones estándares: una para escribir un mensaje printf y la última para terminar el programa exit. La tercera proposición es una asignación, en la cual el valor inicial y desconocido de la variable area cambia con el valor del cálculo 3.141592653 * radio*radio. La asignación es la transposición en lenguaje de programación de la fórmula matemática presentada, excepto que para la constante matemática usamos 2
una aproximación. En el bloque main también aparecen los identificadores de variables (radio y area), la palabra clave del tipo de dato (float) para los valores flotantes de tipo numérico (10.5) y un mensaje textual entrecomillado (”El area ... n”).
Hay la posibilidad de usar la biblioteca externa math.h que contiene la constante M_PI definida por un valor más preciso de . En el CD-ROM se incluye el programa segundo .c con esta modificación. 2
54
2.3 Estructura de un programa Un programa en lenguaje C se compone de un conjunto de archivos que se almacenan con la extensión .c o .h. Los archivos con la extensión .h contienen declaraciones de variables y funciones globales. Los archivos .c contienen la declaración y las definiciones de las funciones. La presencia de la función main en uno de los archivos .c es la única restricción que se impone; por lo demás, el programador tiene la libertad de usar cualquier nombre y cualquier tipo para sus funciones y variables. La estructura de un archivo en el lenguaje C es la siguiente: # include.. ... # define.. ...
directivas por el procesador
int N; ... wid imp() ...
declaraciones de variables declaraciones de funciones definiciones de funciones
Por el momento trabajaremos con un programa formado por un solo archivo .c. En este caso la estructura es la siguiente: # include.. ... # define.. ...
directivas por el procesador
int N; ... voi d imp() ... int main()
declaraciones de variables declaraciones de funciones definiciones de funciones (main obligatoria)
Entonces, un archivo de C ( .c o .h) contiene los siguientes elementos: Directivas de preprocesador (opcional). Comentarios (opcional). Declaraciones de variables o de funciones. Bloques de definición de las funciones. Al interior de los bloques, se encuentran proposiciones simples u otros bloques.
Un archivo en lenguaje C se escribe únicamente usando caracteres que pertenecen al código ASCIII que no contiene ninguna letra con tilde. Las letras con tilde se pueden usar únicamente al interior de las cadenas de caracteres, pero no puede garantizarse que se impriman correctamente, esto dependerá del sistema operativo de la computadora y los parámetros del sistema donde se ejecute el código. Al interior de las cadenas de caracteres que inician y terminan con comillas dobles (mensajes textuales) se colocan, para efectos de impresión, los caracteres ASCII (o codificaciones) de la tabla 2.1. 3
Véase en el apéndice 1 la tabla de los caracteres que se encuentra en el CD-ROM. 55
Introducción a la programación
Tabla 2.1 Caracteres especiales para formato de impresión. \n
salto de línea
\\
el carácter \
\t
una tabulación horizontal
\v
una tabulación vertical
\”
el carácter “
\’
el carácter ’
\b
una señal audible
\ooo \xhh
un número en hexadecimal
un número en octal
Es importante resaltar que el lenguaje C es sensible a la diferencia entre las letras minúsculas y las mayúsculas. Por ejemplo, if es una palabra clave e IF es un identificador definido por el programador. Directivas
Las directivas indican reglas de sustitución o la inclusión de bibliotecas externas con declaración de variables y funciones globales que se utilizan en las funciones definidas por el usuario. La directiva de inclusión #include tiene dos formas: #include
Indica en ambos casos que se incluye el archivo de declaraciones archivo.h. La primera forma indica que la biblioteca es estándar y su ubicación es definida por el nivel de configuración de sistema; la segunda forma, indica que el archivo.h fue construido por el programador (o su grupo de trabajo) y que se ubica en la misma carpeta donde se encuentra el archivo actual, o bien, en otra carpeta, en cuyo caso se debe indicar la ruta relativa a dicho archivo. Ejemplos #include
Los archivos se encuentran en una carpeta estándar, en este caso: /usr/include para stdlib.h y /usr/include/sys para socket.h #include “bibliotecalocal.h” #include “grafo/impresion.h”
Aquí las bibliotecas se ubican en la carpeta de trabajo del usuario, al mismo nivel para la bibliotecalocal.h y en la subcarpeta grafo para impresion.h. Por su parte, las directivas de sustitución tienen la siguiente forma: #define NOMBRE valor-de-sustitucion
Donde define e include son palabras clave del lenguaje C, mientras que NOMBRE y valor-de-sustitucion son definidos por el usuario. El funcionamiento es el siguiente: cada vez que se encuentra el identificador NOMBRE se sustituye con valor-de-sustitucion.
Ejemplo En el primer programa presentado: 56
/*El Area de un circulo */ .. #define PI 3.141592653 int main(int argc, char *argv[]) { .. area = PI*radio*radio; ...
El preprocesador hace la sustitución siguiente: area = 3.141592653*radio*radio;
Nota: El valor de sustitución puede ser cualquier sucesión de caracteres o cadena de caracteres. Ejemplo #include
En el programa anterior se hacen dos sustituciones: PI y el identificador OP. El resultado de las sustituciones es: area = 3.141592653 * radio * radio;
Es posible también construir sustituciones con “parámetros” o “macrosustituciones”.
Ejemplo #include
En el programa anterior se hacen tres sustituciones: PI, PROD con 2 parámetros y CUADRADO con un solo parámetro. El resultado de las sustituciones es el siguiente: 57
Introducción a la programación
area = radio* radio* 3.141592653;
Nota: Las directivas se escriben en una sola línea.
Comentarios En cualquier lenguaje de programación, el papel de un comentario es ayudar a la comprensión de cualquiera que lea el código y no modifica en nada la ejecución del programa. En el lenguaje C, los comentarios se indican entre /* y */ y pueden escribirse en líneas sucesivas. También existe la forma //que indica el inicio del comentario que se termina al final de una sola línea. La forma // también se utiliza en C++. Un consejo es insertar comentarios al inicio del archivo en C para indicar qué hace el código y quién lo hizo, al lado de cada función del usuario, al interior de los bloques (para indicar qué se hace) o cada vez que el programador piense que es necesario.
Ejemplo El primer programa tiene un solo comentario. A continuación se presenta una versión con más comentarios: /* El area de un circulo */ /* fecha de creacion : 10 de marzo de 2011 autor : Mihaela Juganaru de Mathieu */ #include
Los ejecutables que se generen serán idénticos, sin importar la cantidad de comentarios. Nota: Al interior de los comentarios también se pueden usar caracteres del código ASCII (que incluyen letras con tilde).
Declaraciones y definiciones Otra parte de un archivo .c (la más larga) contiene las declaraciones de variables, las declaraciones de las funciones y las definiciones de estas funciones. La forma general de una declaración de función o de variable es: tipo nombre_entidad tipo nombre_entidad(parametros);
Donde tipo es un tipo estándar del lenguaje C o un tipo definido por el programador y nombre_entidad es un identificador a elección del usuario. 58
2.4 Variables y expresiones Como la mayoría de los lenguajes de programación imperativa, el lenguaje C implementa la noción de variable en un sentido simple como la zona de memoria que se asigna para contener un valor de un tipo preciso.
Identificadores Por identificador se entiende un nombre que es único en un ambiente preciso; este nombre es una sucesión de letras mayúsculas o minúsculas, cifras carácter _ (guión y nonopuede con una cifra. Por ejemplo: a, AA, A2_ tablero , _mi_constante sony el identificadores, pero bajo) 2sumas puedeiniciar ser un identificador. Un identificador sirve como nombre para las variables del programa, para las funciones y tipos definidos por el usuario y para las etiquetas de programas. El lenguaje C se basa en un conjunto de reglas de sintaxis que permite definir el programa mismo con sus declaraciones, definiciones y proposiciones. La sintaxis impone el uso de palabras clave, por esta razón no deben utilizarse los siguientes términos:
Tabla 2.2 Palabras reservadas del lenguaje C. sizeof do void int char switch
register if case struct enum while
break static else volatile long const
extern continue union signed
return float default unsigned
typedef short for
Existen también identificadores estándar definidos en las bibliotecas externas del lenguaje C y es mejor evitar el uso del mismo nombre. Por ejemplo, FILE, time y printf son nombres de tipos y de una función de varias bibliotecas estándares. Al interior de un bloque, los identificadores deben ser únicos. Por ejemplo, no es posible tener dos variables definidas con el mismo nombre, ni una etiqueta y una variable homónimas. Un consejo sería seleccionar identificadores nemónicos (nombres expresivos) para una lectura fácil y una escritura de código elegante.
Tipos y variables Una variable tiene un nombre (o identificador) y un tipo. Al momento de la compilación (y si el programa es correcto sintácticamente) o durante la ejecución del programa se hace la asignación de la zona de memoria para la variable. En lenguaje C las variables se declaran con alguna de las siguientes tres formas: tipo_datos identificador; 59
Introducción a la programación
tipo_datos identificador1, identificador2, ..., identificadorn; tipo_datos identificador = valor;
programa debe ser declarada antes.
La tercera de estas formas permite dar un primer valor a la variable al inicio del programa.
Nota: Un tipo de datos puede ser: tipo estándar o tipo definido por el usuario o que se encuentre en una biblioteca externa.
Los tipos estándar escalares (que además contienen un solo valor) son:
Palabrareservada int
Contenido
Tamaño
entero
2bytes
long
entero
4bytes
short
entero
1byte
char
CódigoASCII
1byte
float
flotante
4bytes
double
flotante
8bytes
Los enteros tienen una presentación binaria y guardan un valor (sin fracción decimal) con su signo. El tipo char puede almacenar un entero o un código ASCII. Además, los enteros pueden ser precedidos por la palabra unsigned, en cuyo caso su valor se considera positivo, aumentando el rango de valores que se pueden representar. El compilador gcc utiliza cuatro bytes para representar el tipo long y representa a los flotantes con la norma IEEE-754. Véase el apéndice 1 para los detalles de representación binaria de los tipos enteros y del tipo flotante. El lenguaje C no implementa un tipo particular para los valores lógicos: se considera que si un valor (el valor de una expresión) es cero, se asimila el valor lógico FALSO, de lo contrario es CIERTO.
Constantes Una constante aritmética se declara de dos maneras:
1. Como una variable precedida de la palabra const y seguida de la asignación de un valor (el cual no se puede modificar en el resto del código). Ejemplo const int limita = 10;
2. Con la directiva #define de sustitución al inicio del programa. Ejemplo #define limita 10
Los valores de las constantes pueden ser: 60
Enteros: Formados por cifras y, eventualmente, un signo.
Ejemplo 123, +45, −34 Flotantes: Formados por signo, parte entera, punto decimal, parte fraccionaria y, eventualmente, un exponente con
la letra e o E y un entero.
Ejemplo 0., 123., 0.89, 0.123E+3, 67.0e-23
Podemos usar también valores constantes sin ninguna declaración.
Expresiones con operadores Una expresión aritmética contiene: variables, constantes, operadores aritméticos y paréntesis. Los operadores aritméticos básicos del lenguaje C son los siguientes:
Tabla 2.3 Operadores aritméticos básicos. + − * / %
Suma Resta o el negativo de un número Multiplicación División Residuo de la división entera
Nota: La prioridad de los operadores es la misma que en las expresiones matemáticas, las expresiones entre paréntesis se evalúan primero y en orden descendente los operadores: *, / y %. + y −.
Además, si tenemos una sucesión de operadores de la misma prioridad, el cálculo se realiza de izquierda a derecha.
Ejemplo (1+2)*34%6 y (1+2*34)%6 son expresiones diferentes.
Los paréntesis se representan por dos y se abren y cierran en el orden normal.
Ejemplo
x+1 + y+z 2 3 . x+y En una expresión aritmética podemos tener valores enteros o flotantes. Dicha expresión se ejecuta como operación de enteros, si sus argumentos son enteros, o como operación de flotantes, si son flotantes. La expresión: ((x+1)/2+(y+2)/3)/(x+y)) es correcta y corresponde a la expresión matemática:
Ejemplo int i= 13, j = 5; printf(una division: %d, i/j); 61
Introducción a la programación
printf(otra division %f, 1.0*i/j); produce:
una division: 2 otra division 2.600000
Dado que la primera división i/j se hace entre enteros, entonces el resultado es entero. En el caso de la segunda expresión 1.0*i/j, como la primera operación es la multiplicación de un flotante con un entero, el entero se convierte en flotante y luego se hace la división entre dicho flotante y el entero, dando como resultado final una división exacta. Si queremos cambiar el tipo de las expresiones o variables, se utiliza una conversión de tipo (o casting): (tipo)variable (tipo)(expresion)
¡Cuidado! La conversión (float) (i/j) no produce como resultado la división exacta porque primero se calcula
Ejemplo En el ejemplo anterior la división exacta puede hacerse de la siguiente manera: (float)i/(float)j. Nota: Una conversión de tipo tiene una alta prioridad.
Ejemplo (float)6 produce 6.0 pero (char)3000 produce un valor falso porque un valor de tipo char contiene valores entre −128 y 127 (256 = 28 un byte).
En el lenguaje C, la asignación de un valor a una variable es considerada como un operador y tiene la siguiente forma: variable = expresión;
Esta operación se ejecuta de la siguiente manera: se calcula la expresión y su resultado se asigna a la variable. Si la expresión y la variable no tienen el mismo tipo, se hace la conversión del valor calculado de la expresión al tipo de la variable. A continuación se dan algunos ejemplos de asignación.
Ejemplo i x j a
= = = =
56; sqrt((float)i); (i=3); (i = 5) + (j = 6);
Las dos primeras asignaciones son fáciles de entender: el valor de i cambia a 56 y el valor de x al valor de. La tercera expresión contiene dos asignaciones: primero, el valor de i cambia a tres, y luego, el valor de j cambia a tres. La última expresión tiene tres asignaciones y, al final, los valores de las variables i, j, y a son cinco, seis y once, respectivamente. Por su parte, los operadores de relación del lenguaje C comparan los valores de la parte izquierda con el valor de la parte derecha. Estos operadores son: <, <=, >, >=, == y !=
El sentido es evidente: menor <, menor o igual ≤, mayor >, mayor o igual ≥, equivalencia = = y no equivalencia !=. La prioridad de los operadores de relación es más baja que la de los operadores aritméticos, esto significa que primero se evalúa la parte izquierda, seguida de la parte derecha, y luego se hace la comparación.
Ejemplo i < m+2
Primero se ejecuta la adición y luego se comparan los dos valores. 62
No hay valores específicos para el manejo de los valores lógicos de True (Verdad) o False (Falso). El lenguaje C toma el valor cero para Falso (expresión falsa) y cualquier valor diferente de cero como para Verdad (expresión verdadera). Existen también operadores lógicos. Estos son los siguientes: ! para la negación (NOT) -operador unario && para la conjunción (AND) -operador binario || para la disyunción (OR) -operador binario.
El operador ! convierte un valor que no es cero en cero y cero en uno. El operador && regresa cero, si al menos uno de los dos operandos vale cero (es falso) o un valor diferente de cero si no es así (los dos operandos son verdaderos). El operador || regresa cero, si los dos operandos valen cero (son falsos) o un valor diferente de cero si no es así (al menos un operando es verdadero). En una expresión lógica que contiene && y ||, se ejecutan primero las operaciones de && y luego las de ||, esto es, el operador && tiene una prioridad más alta. Una sucesión de operaciones de && o de || se ejecuta de izquierda a derecha, pero las evaluaciones de las expresiones se hacen una a una hasta que se conoce el resultado, es posible entonces que la evaluación de la expresión lógica inicial se haga sin evaluar todos los operandos. Es decir, para el operador && que regresa verdadero si todos los operandos son ciertos, si se encuentra que el primer operando es falso se termina la evaluación, sin calcular el segundo. Para el operador ||, si el primer valor es diferente de cero, equivalente a verdadero, no se evalúa más, porque toda la operación es verdadera.
Ejemplo
i
Si la expresión izquierda i
−−
Estos operadores se pueden usar de dos formas diferentes: como prefijo ++i o como sufijo i++. En ambos casos se cambia el valor de la variable i, pero si la expresión ++i o i++ ( --j o j--) aparece en una expresión más larga, es necesario tomar en cuenta los siguientes puntos: ++i (o --j) primero se hace la agregación/disminución en uno y luego se usa el resultado en el cálculo. i++ (o j--) primero se usa el valor de la variable en el cálculo y luego se hace la agregación/disminución en uno.
Ejemplo n = 3; m = 3; x = n++; y = ++m;
Al final, x vale tres, mientras que, y, n y m valen cuatro. 63
Introducción a la programación
El lenguaje C ofrece la posibilidad de trabajar directamente con el contenido de la memoria y cuenta con operadores para el manejo de bits. Estos operadores solo se aplican sobre valores de tipo entero ( char, short, int, long) signados o no (unsigned) y son los siguientes:
Tabla 2.4 Operadores para el manejo de bits. & | ^
ANDdebits OR inclusivo de bits OR exclusivo de bits
<< >> ~
corrimiento a la izquierda corrimiento a la derecha complemento a uno
Para &, | y ^ se trabaja con los bits de los dos operandos. El operador ~ es unario, y el resto son operadores binarios. Para los corrimientos, el código binario del operando de la izquierda se recorre el número de posiciones indicadas por el operando de la derecha.
Ejemplo x >> 3 hace un corrimiento de tres bits a la derecha por los bits del valor de x.
La operación ~ es unaria. Las operaciones de &, | y ^ son binarias; tanto la unaria como las binarias trabajan solo con operandos enteros.
Ejemplo
Para a=121 y b=15, las representaciones binarias y el efecto de las operaciones de manejo de bits son: Expresión a=121
Representacióndelniveldebits 0
0
0
0
0
0
0
0
0
1
1
1
1
0
0
1
~a
1
1
1
1
1
1
1
1
1
0
0
0
0
1
1
a<<3
00
a>>2
00
00
00
00
11
00
11
00
00
00
10
01
11
10
a=121
0
0
0
0
0
0
0
0
0
1
1
1
1
0
0
1
b=15
0
0
0
0
0
0
0
0
0
0
0
0
1
1
1
1
a&b
000000
000000
1001
a|b
000000
000111
1111
aˆb
000000
0001
1
0
00
10110
Además del operador de asignación = visto antes, en lenguaje C hay otros operadores de asignación. Estos son de tipo op=, donde op es un operador binario. La expresión: expr1 op = expr2
es equivalente a: (expr1) = (expr1) op (expr2) 64
El operador op puede ser: +, −, *, /, %, >>, <<, &, | o ^.
¡Cuidado! x*=y−10 significa x=x*(y−10) y no x=x*y−10.
Ejemplo i+=2 significa i = i+2
También, en el lenguaje C hay un operador ternario ?: (con tres operandos) que propone un cálculo alternativo. La expresión condicional se escribe como: expr1 ? expr2 : expr3
Misma que se evalúa de la siguiente manera: se evalúa primero el valor de expr1, si este es verdadero (diferente de cero) se lleva a cabo la expr2, de lo contrario, la expresión que se lleva a cabo es expr3. Ejemplo abs = (x>0) ? x : -x;
Al final, la variable abs contiene el valor absoluto de x. Para todos estos operadores (hemos presentado los que trabajan con valores numéricos), si tenemos una expresión con los mismos operadores varias veces o con una mezcla de ellos, la tabla 2.5 nos indica la precedencia y asociatividad de operadores. La asociatividad nos indica el orden en que se evalúa una expresión que contiene un solo operador. La precedencia (o prioridad) nos indica el orden en que se efectúan las operaciones cuando tenemos una sucesión de varios operadores. La lectura de la tabla 4 se hace de arriba hacia abajo, esto es, los operadores que aparecen primero tienen una prioridad más alta.
Tabla 2.5 Precedencia de operadores. Operadores () []-> ! ~ ++ --+ -* & (tipo) sizeof * /% + − <<= >>= == != & ^ | && || ?: = += -= *= %= /= &= ^= |= <<= >>= ,
Asociatividad izquierda a derecha derecha a izquierda izquierda a derecha izquierda a derecha izquierda a derecha izquierda a derecha izquierda a derecha izquierda a derecha izquierda a derecha izquierda a derecha izquierda a derecha derecha a izquierda derecha a izquierda izquierda a derecha
Se puede ver que la prioridad más baja es la del operador de coma, indicando que primero se evalúa todo lo que está entre comas. La asignación también tiene una prioridad muy baja indicando que primero se evalúa la parte derecha de la asignación (la parte izquierda no se evalúa porque debe ser una variable). En caso de duda y en ausencia de esta lista, se recomienda poner paréntesis para indicar el orden conveniente para el programa. 4
Por el momento no introducimos los operadores color azul, pero serán presentados en los capítulos que siguen. 65
Introducción a la programación
Ejemplo 4 < y + 5 && x > 8 * a
El orden de evaluación es el siguiente: la suma y + 5, la comparación 4 < el resultado de (y + 5), si este resultado es falso se termina el cálculo con el valor cero (falso), de lo contrario, se hace la multiplicación, la siguiente comparación x > el resultado de (8*a) y según el valor de esta última comparación se calcula el valor final de la expresión. En la escritura de las expresiones aritméticas y de comparación hay algunos errores frecuentes, en los cuales se confunden algunos operadores: La expresión de igualdad a == b con la expresión de asignación a = b. Las expresiones lógicas a && b y a || b con las expresiones de cálculo al nivel de bits a & b y a | b.
Escribir esto no genera errores de compilación, pero sí errores de ejecución del programa porque en la mayoría de los casos las expresiones lógicas se utilizan en estructuras alternativas o repetitivas y el efecto puede ser contrario. En el lenguaje C se tiene la ventaja de poder usar el operador de asignación como un operador que regresa un valor y de que cualquier valor se interpreta como un valor lógico. Sin embargo, en algunos casos la escritura puede parecer ambigua: if (x = y) printf(“ Cambie x con el valor de y que es cero”); else printf(“ Cambie el x con el valor de y que NO es cero”);
El lector del código puede imaginar (creer) que es un error de parte del programador y que sería correcto escribir x == y, pero primero se hace la asignación y la interpretación del resultado para distinguir los casos de y == 0 y su contrario. La escritura es correcta, pero para tener un programa más legible es mejor escribir: if ((x = y) ==0)
Una última observación: en la expresión ((x = y) ==0) los paréntesis son necesarios porque el operador == tiene una prioridad más alta que el operador de asignación =. En caso de tener dudas sobre la escritura de una expresión, se puede consultar la tabla de prioridades, o bien, se coloca entre paréntesis la parte de la expresión que se quiere evaluar primero.
2.5 Control de flujo El flujo de ejecución de un programa en lenguaje C es de arriba hacia abajo al interior del bloque de la función principal main. En dicho interior se pueden encontrar: Proposiciones y bloques Estructuras alternativas: if-else, else-if, switch. Estructuras repetitivas: while, for, do-while. break, continue, goto
Proposiciones y bloques Un bloque se compone de una sucesión de declaraciones y de proposiciones; a su vez, un bloque es un caso particular de proposición. 66
Una expresión se convierte en una proposición cuando va seguida de un punto y coma ;. A continuación se presenta un ejemplo de sucesión de proposiciones:
Ejemplo www=3; i++; printf(%d%d\n, www, i);
Nota: Una llamada a la función printf() es una expresión. Un bloque es una proposición compuesta que inicia con el símbolo de una llave de apertura { y finaliza con la llave de cierre }, dentro de las cuales hay declaraciones y proposiciones. Enseguida se muestra un ejemplo de bloque.
Ejemplo { www=3; i++; printf(%d%d\n, www, i); } Nota: En el primer programa que se presentó, el bloque de main es de esta forma.
Estructuras alternativas Las estructuras alternativas introducen la elección de procesamiento según el valor de una expresión o de una variable. Existen tres estructuras alternativas en el lenguaje C: if-else, else-if y switch.
Proposición if-else Esta proposición es para las decisiones según el valor de verdad de una expresión. Su forma es:
if (expresión) proposición1 else
proposición2
Donde la parte else es optativa. La proposición if-else se ejecuta de la siguiente manera: se hace la evaluación de la expresión, si es verdadera (no es cero) se ejecuta la proposición1, pero si la expresión es falsa, se ejecuta la proposición2. Las dos proposiciones, proposición1 y proposición2, pueden ser simples o compuestas (bloques). La expresión se puede escribir de la siguiente forma: if ( expresion )
en lugar de: if ( expresion != 0 )
Ejemplos if(x
min = x; else 67
Introducción a la programación
min = y; if (x) { z=10/x; teta = cos(z); } ¡Cuidado! Una rama else se asocia con el if abierto.
Una de las proposiciones alternativas puede ser otra proposición if. Hablamos entonces de una secuencia de if anidada.
Ejemplo if (n>0) if (m<0) z=m else z=n;
En esta caso, la asignación z=n se lleva a cabo cuando n>0 es verdadero y m<0 es falso. Si queremos hacer z=n únicamente si n>0 es falso, tenemos que forzar la asociación construyendo un bloque alrededor del if más interno: if (n>0) { if (m<0) z=m } else z=n; Proposición else-if
Esta proposición es para tratar decisiones múltiples, donde en la rama else de if hay otro if. Su forma es la siguiente:
if (expresión1) proposición1
else if (expresión2) proposición2
else if (expresión3) proposición3
... else proposiciónk
Las expresiones se ejecutan en el siguiente orden: si una expresión es verdadera, se ejecuta la proposición correspondiente y toda esta proposición else-if se termina. Si ninguna expresión es cierta, es decir, todas son falsas, se ejecuta la última proposiciónk. Ejemplo
Escribir el nombre del día que corresponde al número del día de la semana. if(dia==1) printf(“lunes\n”); 68
else if(dia==2) printf(“martes\n”); else if(dia==3) printf(“miercoles\n”); else if(dia==4) printf(“jueves\n”); else if(dia==5) printf(“viernes\n”); else if(dia==6) printf(“sabado\n”); else if(dia==7) printf(“domingo\n”); else printf(“error\n”); Proposición switch
Esta proposición también es para tomar decisiones múltiples donde el valor entero de la expresión se compara con expresiones de valores constantes enteros: switch (expresión) { case const_expresion1:
proposiciones1 case const_expresion2: proposiciones2
...
default:
proposicionesk
}
Cada etiqueta case contiene uno o más valores constantes enteros. Si el valor de la expresión es uno de estos valores, se ejecutan las proposiciones de la etiqueta y todas las que le siguen. Si el valor de la expresión no coincide con ninguna etiqueta case se ejecutan las proposiciones de la etiqueta default.
Ejemplo
El nombre del día cuando se conoce su número: switch(dia) { case 1: printf(“lunes\n”); break; case 2: printf(“martes\n”); break; case 3: printf(“miercoles\n”); break; case 4: printf(“jueves\n”); break; case 5: printf(“viernes\n”); break; 69
Introducción a la programación
case 6: printf(“sabado\n”); break; case 7: printf(“domingo\n”); break; default: printf(“error\n”); }
La proposición break produce una salida después del fin de switch. Esto es, en ausencia de break se ejecutan las siguientes proposiciones de otras etiquetas case.
Ejemplo
En el código siguiente: switch(dia) { case 1: printf(“lunes\n”); case 2: printf(“martes\n”); case 3: printf(“miercoles\n”); case 4: printf(“jueves\n”); case 5: printf(“viernes\n”); case 6: printf(“sabado\n”); case 7: printf(“domingo\n”); default: printf(“error\n”); }
Si el valor de la variable dia es 5, se ejecutan cuatro llamadas a la función printf() y como salida aparece: viernes sabado domingo error.
Estructuras iterativas Las estructuras iterativas se usan para los procedimientos que son repetitivos. En el lenguaje C se tienen tres estructuras iterativas: while, for y do-while. Proposición while
La forma de la proposición iterativa while es la siguiente: while (expresión) proposición
Se evalúa la expresión y, si su valor es verdadero (diferente de cero) se ejecuta la proposición. Luego, se reevalúa la expresión y se hace la prueba. La pro posición while termina cuando la expresión toma el valor de cero (falso). 70
Ejemplo
Cálculo de los cuadrados menores a un valor límite. /* programa : se calculan todos los cuadrados enteros hasta un limite*/ #include
El programa escribe: i=1, i=2, i=3, i=4, i=5,
cuadrado=1 cuadrado=4 cuadrado=9 cuadrado=16 cuadrado=25
Proposición for
for es similar a la proposición while, en la cual, antes de todo cálculo, se lleva a cabo una priLa proposición mera expresióniterativa (por lo general, es una parte de la inicialización), se evalúa otra expresión lógica y si esta es verdadera se ejecuta la proposición, al final se evalúa otra expresión y se itera con la evaluación de la expresión de condición. La forma de la proposición for es la siguiente: (expresion1; expresion2; expresion3) proposición for
La expresion1 es la expresion de inicialización, la expresion2 es la expresion de condición y la expresion3 se ejecuta al final de una iteración. Esta forma de for es equivalente a: expresion1; while (expresion2) { proposición expresion3; }
Las expresiones que componen la proposición for no son obligatorias (pueden faltar). Normalmente, expresión1 y expresión3 son llamadas o asignaciones y expresión2 es una expresión de relación.
Ejemplo Cálculo de los cuadrados menores a un valor límite con una proposición for: 71
Introducción a la programación
/* programa : calculo de todos los cuadrados enteros hasta un limite*/ #include
Ejemplo
Verificar si un número n es primo. Por definición, un número es primo si no tiene más divisores que el número 1 y él mismo. De tal manera que, si el número no es divisible entre ningún otro número desde 2 hasta n-1, podemos decir que es primo. Por otro lado, verificar hasta n-1 parece mucho; para evitar este posible inconveniente, podemos verificar únicamente hasta √n . Por otra parte, si el número no es divisible entre dos, entonces no sería divisible entre cualquier número par (4, 6, ...). Por tanto, es suficiente probar la divisibilidad con 2 y con todos los números impares de 3 hasta √n . El programa en lenguaje C es el siguiente: /* Programa que verifica si un numero es primo */ #include
72
En el capítulo siguiente vamos a introducir las funciones de entrada de un programa.
Proposición do-while
En la proposición iterativa do-while primero se ejecuta la parte del código que se encuentra entre do y while y luego se verifica la condición para salir (o no) de la estructura. La sintaxis de esta proposición iterativa es: do proposición while (expresión);
Primero se ejecuta la proposición, y luego se evalúa la expresión. Si la expresión tiene un valor diferente de cero (es verdadero) se ejecuta de nuevo la proposición y el cálculo de la expresión. La proposición do–while termina cuando la expresión se hace falsa (o cero).
Ejemplo n
∑i El cálculo de i=1 #include
La suma de los números de 1 hasta n se calcula de manera progresiva: a cada paso i que se aumenta la suma es parcial.
Otras proposiciones de control de flujo A veces necesitamos salir de una estructura, ya sea de ciclo (iterativa) o alternativa. Para hacerlo, tenemos dos proposiciones: break y continue. Además de la proposición goto que permite la continuación del flujo con cualquier otra proposición. Proposiciones break y continue
La proposición break produce la salida de la proposición que la contiene y el programa continúa con la siguiente proposición. Por su parte, la proposición continue se usa solo con una estructura de ciclo (iterativa) y su efecto es que se termina el paso corriente, pero se continúa con el siguiente paso de la estructura repetitiva que la contiene.
Ejemplo
La prueba de si un número es o no primo (dos versiones, una con break y otra con continue): 73
Introducción a la programación
#include
El efecto de break es visible porque cuando se detecta un divisor d de n se sale completamente del for. #include
En esta versión, cuando se detecta que d es un divisor se continúa directamente con la prueba de la expresión de relación. 74
Proposición goto y etiquetas
Esta proposición se utiliza cuando se necesita ir a una parte del programa que no es la proposición siguiente. goto etiqueta ; etiqueta :
...
donde etiqueta es un identificador del programa
Nota: Se debe hacer una sola definición de dicha etiqueta. Normalmente esta proposición se utiliza para el tratamiento de errores.
gir un programa con proposiciones goto
Ejemplo
La prueba del número primo usando la proposición goto. #include
Cuando se detecta un divisor, el salto se hace a la impresión del mensaje NO.
2.6 Problemas resueltos Ecuación de primer grado Una ecuación de primer grado tiene la siguiente forma general:
ax + b = 0 Esta ecuación admite una solución única cuando a 0. Si a = 0, la ecuación no tiene soluciones cuando b 0 tiene una infinidad de soluciones cuando el diagrama de flujo es presentado como en el capítulo 1 (ejemplo de la página 38, figura 1.19). 75
Introducción a la programación
El programa en C es el siguiente: /* programa para resolver ecuacion primer grado */ #include
traduce con el operador ==.
En la última rama else hay dos proposiciones a realizar: el cálculo de la raíz y la salida de mensaje: Solucion unica, entonces se forma un bloque o secuencia de más de una instrucción, de una proposición compuesta. Para los valores a=0.5 y b=-1.5 el programa escribe: Solución única 3.000000
Para otros valores de entrada ( a y b) se cambia el programa, se compila y se ejecuta.
Cálculo aproximado del número áureo El número áureo6 es un número que expresa una proporción perfecta. Ha sido muy estudiado por los matemáticos desde la antigüedad. Su valor exacto es: √ = 1+ 5 2 El programa en lenguaje C que utiliza esta fórmula es muy simple: /* calculo exacto del numero aureo */ #include
y produce el mensaje : El valor exacto del numero aureo es: 1.618034005165100098 76
El programa usa la función estándar sqrt de la biblioteca matemática math.h del lenguaje C.
Queremos obtener un valor aproximado del número áureo sin usar la función sqrt para obtener √5 . El valor de se puede calcular como una fracción infinita: =
1 1+ 1 1+ 1 1+1 …
porque es la raíz de la ecuación: 1 =
1+
La idea sería calcular valores sucesivos: k + 1 =
1+ 1
k
hasta que converja. El pseudocódigo de tal procesamiento sería: repetir 2
← 1 + 1/ dif ← |2 − | ← 2
hasta que dif ≤ε donde ε es un nivel de aproximación, por ejemplo ε = 10−10 si queremos diez decimales exactos. El programa en C es el siguiente: /* calculo aproximado del numero aureo */ #include
6
Véase la página de Wikipedia http://es.wikipedia.org/wiki/Número_áureo 77
Introducción a la programación
Tomamos un valor inicialfi = 2. En el bloque de la estructura do-while reconocemos el cálculo de 2, dif es la diferencia 2 − y div es el valor absoluto. La estructura C do-while tiene la condición de fin como la negación de la condición de la estructura repetir. El programa produce el resultado: El valor aproximado del numero aureo es: 1.618034005165100098
Cálculo de una raíz de ecuación de tercer grado Calcular la raíz de la siguiente ecuación:
x3 − 5x2 + 14x − 2 = 0 que se encuentra en el intervalo [0,1]. Para una ecuación de tercer grado, no conocemos fórmulas que se apliquen de forma directa para resolverla. Si consideramos la función f ( x)= x3− 5x2+14x− 2, es fácil verificar que f(0)< 0 y f(1)>0. Es decir que f (0) × f (1) < 0 . El cambio del signo de la función nos indica que seguramente hay una raíz en este intervalo. La idea del algoritmo es considerar un intervalo [iz, dr] en el cual la función cambia de signo. Tomamos x como la mitad del intervalo. Si f (x) = 0, es perfecto: encontramos la raíz, pero podemos aceptar también que x está muy cerca de la raíz: |f (x)| <
Si al contrario, |f ( x)| > , el intervalo [iz, dr] cambia: si f ( x) y f ( iz) tienen el mismo signo es iz lo que cambia en x, sino es el límite derecho dr lo que cambia en x. De esta manera se guarda la propiedad f(iz) × f(dr)< 0y el nuevo intervalo [iz, dr] es más estrecho. El programa que traduce esta idea es: /* busqueda de solucion entre 0 y 1 */ /* de la ecuacion x*x*x -5*x*x + 14*x -2 = 0 */ #include
if (fx * fiz < 0) { dr = x; fdr = fx; } else { iz = x; fiz = fx; } } while (1); printf( “ La raiz aproximada es : %15.10f\n”, x); exit(0); }
En la estructura iterativa do-while se hace una salida con break cuando se detecta un valor cerca de la solución. La condición de repetición es 1, esto significa que la estructura iterativa se termina únicamente con la proposición break. El programa calcula la raíz siguiente: La raiz aproximada es: 0.1507262737
Cálculo de la fecha del día siguiente Un problema muy simple de la vida diaria es calcular la fecha que sigue a una fecha conocida. Por ejemplo, para el 30 de abril de 2010, la fecha que sigue es el 1 de mayo de 2010. La primera pregunta sería cómo representamos una fecha en lenguaje C. Con los conocimientos que tenemos hasta hoy, una solución bastante simple es trabajar con tres variables enteras (de tipo int) que contienen respectivamente el número de la fecha en el mes, el número del mes y el año, por ejemplo: int dia = 30; int mes = 4; int annio = 2010;
La fecha que nos interesa se obtiene entonces por el cálculo: primero se aumenta en uno la variable dia, si se obtiene un número que no es coherente con el número de mes (por ejemplo 31 para la variable dia cuando la variable mes vale 6 pues el 31 de junio no existe) se aumenta en uno el número de mes. Si jamás se obtiene 13 para la variable mes, se modifica la variable annio. El programa debe tomar en cuenta todos los casos posibles para el valor de la variable mes. Además, se da por hecho que la fecha inicial es coherente. Una versión del programa es la siguiente; #include
Introducción a la programación
dia++; switch(mes) { case 1: case 3: case 5: case 7: case 8: case 10: if (dia == 32) { dia = 1; mes++; } break; case 2: if ((dia == 29 && annio%4 != 0) || (dia == 30 && annio %4 == 0)) { dia = 1; mes = 3; } break; case 4: case 6: case 9: case 11 : if (dia == 31) { dia = 1; mes++; } break; case 12 : if (dia == 32) { dia = 1; mes = 1; annio++; } break; } printf(“Dia siguiente: %d de %d de %d\n”, dia, mes, annio); exit(0); }
La salida de este programa (por los valores indicados de las variables dia, mes y annio) es: Dia inicial: 31 de 12 de 2010 Dia siguiente: 1 de 1 de 2011
En la escritura de esta versión del programa se prefiere una estructura alternativa switch porque esta estructura de programación es más compacta y legible, lo que permite el tratamiento común de los casos de meses de 31 días, excepto el mes de diciembre, para el cual se cambia también de año y el tratamiento común de los meses de 30 días. Otra versión con las mismas variables y la misma manera de escribir los mensajes para el usuario, se hizo con estructuras anidadas else-if y con proposiciones goto. #include
int main(int argc, char *argv[]) { int dia = 31; int mes = 12; int annio = 2010; printf(“Dia inicial : %d de %d de %d\n”, dia, mes, annio); dia++; if (mes ==1 || mes == 3 || mes == 5 || mes == 7 || mes == 8 || mes == 10) { if (dia == 32) { dia = 1; mes++; } goto fin; } else if (mes == 2) { if ((dia == 29 && ano%4 != 0) || (dia == 30 && ano %4 == 0)) { dia = 1; mes = 3; } goto fin; } else if (mes == 4 || mes == 6 || mes == 9 || mes ==11) { if (dia == 31) { dia = 1; mes++; } goto fin; } else if (dia == 32) { dia = 1; mes = 1; annio++; } fin: printf(“Dia siguiente : %d de %d de %d\n”, dia, mes, annio); exit(0); }
Ternas pitagóricas Una terna pitagórica es un triplo de números enteros positivos (x, y, z), que pueden ser las aristas de un triángulo rectángulo, los cuales, a saber, cumplen la siguiente condición: x2 + y2 = z2 La terna pitagórica más conocida es: (3, 4, 5). Otras ternas pitagóricas son: (8, 15, 17), (7, 24, 25), (9, 40, 41), (6, 8, 10). 81
Introducción a la programación
Hay una infinidad de ternas pitagóricas. Para hacer un programa capaz de generar ternas con x, y < D (con D c omo límite) una solución “brutal” sería: generar todas las ternas de enteros ( x, y, z) con x y y hasta D y z hasta D z . El programa se escribe de una manera muy simple: #include
terna terna terna terna terna
pitagorica pitagorica pitagorica pitagorica pitagorica
: : : : :
2288 2288 2288 2288 2288
4290 5166 6084 7260 7575
4862 5650 6500 7612 7913
Una terna pitagorica : 2289 2180 3161 Una terna pitagorica : 2289 3052 3815 Una terna pitagorica : 2289 5720 6161 ..
Para un valor D límite de 300 la respuesta es muy rápida, pero para D igual a 9000 el programa necesita más de una hora para ejecutarse (64 min 49 s), ¡es enorme! La explicación de este tiempo de ejecución es muy sencilla: se generan D32 ternas que es un valor muy alto. Para mejorar el desempeño del programa debemos tomar en cuenta dos observaciones: Si el programa genera una terna (x, y, z) como terna pitagórica, no es necesario generar también ( y, x, z).
Es suficiente generar ternas con x < y < z. Para dos valores de x y y, si la terna (x, y, z) es pitagórica, el valor de z es único y se calcula con :
z = x 2 + y2 La traducción de la primera condición x < y es que, en lugar de generar –y– desde dos hasta D, lo generamos desde x + 1 (el primer entero mayor que x) hasta D. Por construcción tenemos la segunda condición y < z. La segunda condición requiere que el valor de x2 + y2 sea entero. Para el cálculo de la raíz cuadrada usamos la 7 función sqrt que regresa un valor flotante que se convierte automáticamente a un valor entero (su parte entera). La nueva versión del programa es: 7
82
Véase la capítulo 3 para más detalles sobre esta función.
#include
Esta versión del programa tiene un tiempo de ejecución muy atractivo: 0.65 segundos8 para D = 9000 y 55.3 segundos para D = 90000, incomparable con el tiempo de la primera versión (64 minutos para D = 9000). Es simple ver que si ( x, y, z) es una terna pitagórica, entonces ( ax, ay, az) también lo es para cualquier valor a entero. Una terna pitagórica se dice primitiva si x y y son primos entre sí (co-primos): y el único divisor común es 1. Si queremos generar todas las ternas pitagóricas primitivas, podemos modificar la segunda versión para probar primero la co-primalidad (primalidad relativa) de los números generados x y y. Un problema secundario a resolver sería probar si x y y son primos entre sí. Una solución sería calcular el máximo común divisor con el algoritmo de Euclides y probar si es o no 1. Otra solución sería probar si hay algunos divisores posibles. Si trabajamos con la última solución, 9 generaremos todos los números entre 2 y m in (x, y,). Una mejora a esta prueba sería tomar el divisor 2 y luego los valores 3, 5, 7, … (todos los impares). El programa es el siguiente: /* programa que verifica si x y y son primos entre si */ #include
Estos tiempos de ejecución dependen de la computadora que se utilice pero la proporción es la misma. El algoritmo de Euclides se presenta en el capítulo 3. 83
Introducción a la programación
d = 3; else d += 2; } if ( primos == 0) printf(“ No, %d y %d no son primos entre ellos\n”, x, y); else printf(“ SI, %d y %d son primos.\n”, x, y); }
La variable dmax contiene el valor más grande posible para un divisor común de los dos números y la línea de inicialización de este valor corresponde al cálculo del mínimo. La condición de if traduce la verificación de la divisibilidad con d; el lenguaje C es muy conveniente porque si la primera condición es falsa, no se realiza el cálculo de la segunda. La salida del programa para los valores de x y y indicados es: SI, 299 y 29901 son primos entre si.
Para el problema de generación de las ternas pitagóricas primitivas, podemos integrar para cada generación de x y y la prueba de primalidad antes de verificar si el valor de z es entero. #include
primos = 1; dmax = x; for ( d = 2; d <= dmax; ) { if ( x %d == 0 && y %d == 0) { primos = 0; break; } if (d ==2) d = 3; else d += 2; } if ( primos == 0) { z = sqrt(x*x+y*y); ifprintf(“ ( x*x + Una y * terna y == z*z) pitagorica : %d %d %d\n”, x, y, z); }
} exit(0); }
Una parte de la salida es : 84
… Una Una Una Una Una Una Una
terna terna terna terna terna terna terna
pitagorica pitagorica pitagorica pitagorica pitagorica pitagorica pitagorica
: : : : : : :
705 707 708 711 712 715 715
992 1217 5076 5125 3445 3517 3080 3161 7905 7937 1428 1597 2052 2173
…
El tiempo de ejecución del programa para el límite D = 9000 es de 2 min 31 s, más que el tiempo de la versión de generación de todas las ternas pitagóricas primitivas o no, que fue de 55 segundos. El aumento del tiempo de ejecución se debe a las pruebas de co-primalidad. Otro método de generación de las ternas pitagóricas primitivas es utilizar algunos de los resultados de la teoría de los números que dicen que (x, y, z) es una terna pitagórica primitiva si y solo si existen dos números enteros m y n, m > n, que son primos entre sí y de paridad diferente (uno es par y el otro impar) que cumplen las siguientes tres relaciones:10
x = m 2 — n2 y = 2mn z2 = m2 + n2 El programa que se deduce de estas aserciones matemáticas toma en cuenta: la generación de y y la verificación de que uno es par y el otro impar. la prueba de primalidad entre m y n. el cálculo de y, sin verificar que x2 + y2 = z2. la verificación de que y < D.
Para verificar la primalidad utilizaremos el mismo código que precede, adaptado para las variables m y n. La generación de las parejas (n, m) se puede hacer aumentando a cada paso n en uno y tomando a desde +1 (para asegurar que > n) y aumentarlo de dos en dos; este aumento asegura que los dos números conserven diferente paridad. La última condición, y < D, sería verificada explícitamente dentro de la estructura for donde se genera el valor. El programa de generación de ternas pitagóricas primitivas que se apoya en este método de generación es el siguiente: #include
Dejamos como ejercicio al final del capítulo hacer la demostración matemática de esta proposición (directa e inversa). 85
Introducción a la programación
{ if ( n %d == 0 && m %d == 0) { primos = 0; break; } if (d ==2) d = 3; else d += 2; } if ( primos != 0) { z = m * m + n * n; printf(“ Una terna pitagorica : %d %d %d\n”, x, y, z); } } exit(0); }
Una parte de la salida de este programa es: … Una Una Una Una Una Una
terna terna terna terna terna terna
pitagorica pitagorica pitagorica pitagorica pitagorica pitagorica
: : : : : :
8277 364 8285 8645 372 8653 7 24 25 55 48 73 91 60 109 187 84 205
…
Las ternas pitagóricas generadas no están en el mismo orden que la primera versión, pero las dos versiones generan correctamente el mismo número de ternas pitagóricas primitivas: 1604 para el límite D = 9000. El tiempo de ejecución de esta última versión del programa es de 0.012 s, esta versión es más rápida que la primera. En conclusión de este ejemplo de generación de ternas pitagóricas, tenemos que el trabajo para obtener un programa puede requerir la escritura de varias versiones y que puede ser útil un análisis preliminar. El tiempo de ejecución de un programa es un criterio esencial para la calidad del trabajo de programación.
Juego de la búsqueda de un número Este juego lo jugamos todos cuando fuimos niños: en la mano o en un vasito se esconden algunas piedritas o granos de maíz y se pregunta al compañero de juego cuántas piedritas cree que hay guardadas. Una versión del juego la encontramos también hoy en día en la televisión con los juegos de “atínale al precio”, en los cuales el jugador indica un precio y una voz le indica: “correcto”, “es más” o “es menos”. El objetivo del juego es encontrar lo más rápido posible el valor exacto. Siempre hay dos jugadores: J1 y J2, por ejemplo, el jugador uno (J1) elige el número y el jugador dos (J2) intenta adivinarlo. Podemos suponer también que el número elegido siempre está en un intervalo de valores; de no ser así sería imposible terminar el juego en un número finito de pasos. 86
Ejemplo A continuación se presenta un ejemplo de diálogo entre jugadores: J1: Listo. J2: ¿Es 10? J1: Menos. J2: ¿Es 9? J1: Menos. J2:¿Es 2? J1: Más. J2: ¿Es 5? J1: Correcto.
El papel más “difícil” es para el J2, quien tiene que hacer las propuestas. Para J1 es bastante fácil, pues solo elige el número y retroalimenta cada propuesta de J2. Un programa para la computadora, ¿qué puede aportar a este juego? Existen varias posibilidades, el programa puede ser un jugador (J1 o J2) o bien, puede ayudar a uno de ellos. A continuación se presentan tres propuestas: El programa es el jugador uno (J1) que elige el número y que retroalimenta cada proposición del jugador humano
J2, con una de las tres respuestas posibles: correcto, menos o más. El programa es el jugador dos (J2) que hace las propuestas tomando en cuenta el intervalo de valores posibles y
también las respuestas conocidas. El programa es una ayuda para el J2 antes que haga una propuesta, le sugiere un valor o un intervalo de valores.
En el primer programa el principio de funcionamiento es: generar un número aleatorio y después, hasta que la respuesta del J2 sea correcta, leer y retroalimentar proposiciones. #include
Introducción a la programación
printf(“ Menos.\n”); } exit(1); }
En este programa la llamada de función srand(...) genera una serie de números aleatorios; dicha serie es diferente en cada ejecución del código. La generación del valor elegido se hace en el intervalo [ a, b]. La estructura repetitiva es una estructura while con la condición lógica siempre cierta. La salida de la estructura se hace con una proposición break cuando se acierta al número generado aleatoriamente. En la estructura iterativa while se encuentra por primera vez la llamada a una función que hace la lectura de un valor. Esta función se detallará en el capítulo 3. Un ejemplo de ejecución del programa es la siguiente: Busca el numero ! 100 Mas. 150 Menos. 125 Menos. 112 Menos. 106 Mas. 109 ¡Correcto !
Para la versión donde J2 es la computadora, el programa debe proponer un número y esperar la respuesta del jugador humano J1. Podemos utilizar los siguientes códigos para las respuestas posibles: 0 para “correcto” 1 para “El numero es mas grande” -1 para “El numero es menos grande” Una solución muy fácil sería proponer todos los números iniciando con 1 y, a cada paso, incrementarla en uno, con la desventaja de que si el intervalo es muy largo, se harán muchos pasos. Otra solución sería proponer al principio el valor central del intervalo (reduciendo así la cantidad de valores posibles). Si
p= a+b 2 es la propuesta y no es correcta, entonces el nuevo intervalo sería [ p, b]; si no, el intervalo sería [ a, p]. Podemos reducir un poco más los dos intervalos a [ p + 1, b] y [a, p — 1] porque sabemos que no es la solución. Para los siguientes pasos, se reduce cada vez el intervalo. El programa es el siguiente: #include
int a = 1, b = 200; int sa, sb, propuesta, respuesta; printf(“ Elige un numero !\n”); sa = a; sb = b; while(1) { propuesta = (sa + sb)/2; printf(“Mi propuesta :%d\n”, propuesta); scanf(“%d”, &respuesta); if (respuesta == 0) break; else if (respuesta == -1) sb = propuesta - 1; else sa = propuesta + 1; } exit(1); }
Las variables sa y sb contienen en cada paso los límites del intervalo. En la escritura del programa no tratamos ningún caso de respuestas erróneas y suponemos también que el jugador J1 siempre dice la verdad. Para la versión del programa en la cual la computadora ayuda al jugador J2 a buscar el número, se aplica el mismo principio de la reducción del intervalo, pero con respecto del valor propuesto por J2 y sin tomar en cuenta la sugerencia del programa. El programa es bastante parecido a la versión anterior, con dos modificaciones importantes: Hay dos variables: sugerencia, que es la mitad del intervalo posible, y propuesta, que es el valor propuesto
por el J2. El ajuste del intervalo se hace únicamente en el caso donde el valor de propuesta está en el intervalo [sa, sb].
El código del programa es el siguiente: #include
Introducción a la programación
sb = propuesta - 1 < sb ? propuesta -1 : sb; else sa = propuesta + 1 > sa ? propuesta +1 : sa; } exit(1); }
En este programa se utiliza el operador ternario (con tres operandos) de comparación ?: para el cálculo del mínimo entre propuesta -1 y sb y para el cálculo del máximo entre propuesta +1 y sa. Una ejecución del programa es la siguiente: Elige un numero ! Mi sugerencia :100 Su propuesta :77 La respuesta :1 Mi sugerencia :139 Su propuesta :130 La respuesta :-1 Mi sugerencia :103 Su propuesta :108 La respuesta :1 Mi sugerencia :119 Su propuesta :120 La respuesta :0
Síntesis del capítulo
El C es un lenguaje de programación deestructurado. alto nivel que respeta el paradigma imperativo y ofrece la posibilidad delenguaje escribir programas que respeten el paradigma Un programa se elabora basándose en un algoritmo de solución del problema a resolver. Dicho algoritmo debe estar muy claro en la mente del programador o expresarse con un diagrama de flujo o con un pseudocódigo. Un programa se escribe con caracteres del código ASCII (sin letras con tilde ni caracteres especiales) y respeta las reglas de sintaxis del lenguaje utilizado en su elaboración. Un programa tiene una parte de directivas al preprocesador (include y define), otra parte con las declaraciones de variables y funciones y, finalmente, la definición de la función principal main. Un bloque de programa es una proposición compuesta o una sucesión de proposiciones. El bloque inicia con el símbolo { y termina con }. Una proposición simple es una expresión que termina con el símbolo ;. Las proposiciones se escriben con variables y expresiones. Una variable tiene un nombre único (o identificador) y un tipo. Una expresión se compone de constantes, variables y operadores. Para los operadores hay reglas de asociatividad y de prioridad (o precedencia). Otras proposiciones válidas son: Estructuras alternativas: if, switch y else-if. Estructuras iterativas. break, continue y goto.
Mediante un algoritmo, el programa en lenguaje C puede escribirse de diferentes formas. 90
Bibliografía Kernighan, Brian W. y Ritchie, Dennis M. (1991) El lenguaje de programación C , 2a edición, Pearson Educación:
México.
Referencias de Internet Página de presentación del lenguaje en Wikipedia:
http://es.wikipedia.org/wiki/C_(lenguaje_de_programación)
Ejercicios y problemas 1. Hacer un programa que escriba en orden creciente los valores de tres variables a, b y c. Si por ejemplo tenemos los valores : int a = 15, b = 4, c = 6; El programa debe escribir: 4 6 15
2. Establecer el signo de la suma y del producto de dos números enteros m y n sin hacer las operaciones. Por ejemplo: para n = 12 y m = –15 la suma es negativa y el producto es negativo. 3. Hacer la tabla de conversión de grados Fahrenheit a grados Celsius según la fórmula: °C = (5/9)(°F − 32) Los valores para conversión serán de 0, 20, 40, ..., 360.
4. Hacer un programa de conversión de pesos mexicanos a euros y viceversa, introduciendo el valor exacto de conversión. El programa debe terminar cuando el usuario introduzca un valor negativo. Por ejemplo: Introduce el valor de cambio 1 peso -> euro : 0.11 Su valor para convertir : 2000 Vale 220 euros Su valor para convertir : 3 Vale 0.33 euros Su valor para convertir : -3 FIN La conversión 1 euro = 9.0909 pesos Su valor para convertir : 22 Vale 200.00 pesos Su valor para convertir : 333 Vale 3027.27 Su valor para convertir: -4 FIN
El valor de conversión tiene máximo cuatro cifras. Los valores en euros o en pesos tienen máximo dos cifras después del punto decimal.
91
Introducción a la programación
5. Hacer un programa que calcule el número de días que pasan entre el día corriente y el inicio del año (primero de enero). Por ejemplo: int dia = 22; int mes = 10;
Conociendo el día del primero de enero del año, añadir a su programa el cálculo del día de la semana (lunes, martes, …).
6. Crear un programa capaz de calcular el número de días entre dos fechas: int dia1, mes1, annio1; int dia2, mes2, annio2;
Es importante tomar en cuenta los años bisiestos.
7. Hacer un programa que tome en cuenta una fecha del calendario usando tres variables y calcule la fecha después de un número de días contenido en la variable intervalo, por ejemplo : int int int int
dia = 30; mes = 4; annio = 2010; intervalo = 20
El programa calculará los valores (20, 5, 2010).
8. Hacer un primer programa que simule el saldo de un depósito bancario durante un número N de años en una cuenta de ahorro en la cual se entrega una suma inicial. A este depósito bancario se aplica al final del año un aumento del saldo de X% y una deducción de 100 pesos por comisión bancaria. Hacer un segundo programa en el cual se supone que a este depósito bancario se le entrega cada mes una suma fija de M pesos con M variable. El aumento del fin de año corresponde al porcentaje proporcional al número de meses. 9. Resolver una ecuación de segundo grado en su forma más general que es:
ax2 + bx + c = 0 10. Resolver un sistema de ecuaciones de primer grado de la forma:
ax + by = c dx + ey = f para buscar las soluciones (x, y).
11. Calcular el valor de √a sin utilizar la función sqrt. Sugerencia: la sucesión matemática:
1 a x n+1 = 2 x n + x n con x1 = a es convergente a √a por cualquier a > 0.
12. Calcular los valores 2k, con k variable desde 1 hasta 25. 92
13. Verificar que un número entero n es de forma ak, con a constante entera positiva (por ejemplo a = 2). 14. Calcular cuántas cifras tiene un número en base 10. 15. Calcular los valores N! = 1 * 2 *…* (N — 1) con N variable desde 1 hasta 15. Sugerencia: para guardar el valor de N! utilizar el tipo entero long. 16. Verificar que un número entero n se puede escribir de la forma: n = k! (con N! = 1 * 2 *…* (K 1) * k) 17. Cálculo del máximo común divisor de dos números enteros. Aplicar el algoritmo de Euclides para calcular el más grande común divisor de dos números. El algoritmo tiene dos versiones: con sustracciones sucesivas o con divisiones; implementar una de las dos versiones.
18. Añadir al programa que verifica si un número es primo la escritura de uno de sus divisores (cuando no es primo). 19. Para un número entero n, escribir todas las parejas de sus divisores (d, d ’) con n = d × d ’. 20. Números perfectos. Se dice que un número es perfecto si es igual a la suma de sus divisores (se excluye el número 1). Por ejemplo, 6 y 28 son perfectos: 6 = 1 + 2 + 3 y 28 = 1 + 2+ 4 + 7 + 14. a) Verificar que un número entero n es perfecto. b) Generar todos los números perfectos entre 1 y 10 000.
21. Números amigos. Dos números se dicen amigos si la suma de los divisores de cada número es igual a la del otro número. Por ejemplo, 220 y 284 son números amigos: 220 tiene como divisores 1, 2, 4, 5, 10, 11, 20, 22, 44, 55 y 110, cuya suma es 284. 284 tiene como divisores 1, 2, 4, 71 y 142, cuya suma es 220.
Generar todas las parejas de números amigos menores que 4 000.
22. Valores de la serie armónica. Calcular para n (número variable) los valores de la serie armónica que se describe como: 1 1
1 2
n= + +…
1
n
¡Cuidado! Los valores de la serie son reales, en el programa elegir el tipo correct o para guardar dichos valores. 23. Cálculo del valor de in(x). El valor numérico de sen(x) se puede obtener calculando la serie siguiente: 2i + 1
x 1)! ∑ (–1)i (2 sen(x) = ∞ i+ i =0
equivalente a
93
Introducción a la programación
sen( x ) =
x 1
x3
x5
x7
- 3! + 5! - 7! …
El cálculo del seno se hace aumentando a cada paso con el valor (–1)i
x2 i +1 (2 i+1)!
hasta que el valor de (–1)i
x 2 i +1 (2 i+1)!
sea : (–1)i
x 2 i +1 (2 i+1)!
24. Mostrar el resultado matemático siguiente: Si m y n son números enteros positivos de paridades diferentes (uno es par y el otro es impar) con m > n y los números son primos entre sí, entonces la terna ( x, y, z) con
x = m2 — n2 y = 2 mn z = m2 + n2 es una terna pitagórica primitiva. Mostrar el resultado matemático recíproco: Si x, y, z esdiferentes, una ternatales pitagórica paridades que: primitiva, entonces existen y , > n, enteros positivos primos entre sí, de
x = m2 — n2 y = 2 mn z = m2 + n2 25. En el juego de la búsqueda del número, para una de las tres versiones presentadas, añadir una variable paso que cuente el número de pasos realizados. 26. En el juego de la búsqueda del número, añadir la restricción de hacer únicamente una cantidad máxima de propuestas. Si el jugador J2 hace dicha cantidad máxima de propuestas sin adivinar el número, pierde. 27. Hacer un programa en el cual el usuario tenga la posibilidad de jugar sucesivamente como J1 o como J2 y el otro jugador sea la computadora. El juego termina después de 11 partidas. 28. Operaciones. Hay tres variables y un valor contenido en una variable V. Verificar para una operación (adición, sustracción, producto o división) si hay dos variables que se pueden operar para obtener el valor V. Si no hay un resultado exacto escribir el resultado más cercano.
29. ¿Es la cuenta que se espera? Versión con tres variables y un valor objetivo. Con base en el programa realizado en el problema anterior, implementar el juego que consiste en obtener el valor objetivo operando en cualquier orden los tres valores variables con las operaciones aritméticas +, – y *. 94
3
Número en espejo
Contenido 3.1 Introducción 3.2 Variables y apuntadores Variables locales y variables globales Variables dinámicas y variables estáticas Apuntadores Tipo void
3.8 Apuntadores de funciones 3.9 Funciones con número variable de parámetros Síntesis del capítulo Bibliografía Referencias de Internet
3.3 Funciones Definición de una función Llamadas de funciones Prototipo de una función Un ejemplo completo: cálculo de máximo común divisor y de mínimo común múltiplo
Objetivos
Transmisión de los parámetros 3.4 Funciones estándares Funciones matemáticas
3.5 Funciones de entrada/salida 3.6 Recursividad
memoria para las variables y las funciones. parámetros de una función. 95
3.1 Introducción Para la lectura de este capítulo es imperativo que el usuario conozca y sepa utilizar las bases de la programación del lenguaje C. Así, para su estudio, este capítulo se divide en dos partes; la primera analiza las nociones de asignación de memoria, las variables globales y las variables locales, las variables dinámicas y las variables estáticas, así como los apuntadores; la segunda, por su parte, trata las funciones de un programa C, como las funciones del usuario y estándares; además, hay una parte dedicada a las funciones de entrada/salida estándar. Sin embargo, es importante resaltar que el concepto de función se utiliza a lo largo de todo el libro: Cada vez que necesitemos un algoritmo para ilustrar la resolución de un problema (de tamaño variable), vamos a escribir una función que traduzca el algoritmo. Para una gran variedad de nociones de la computación, como archivos y cadenas de caracteres, y para varios as-
pectos prácticos de la programación, como mensuración del tiempo, interfaz con entradas y salidas no estándares, sockets, etcétera, el compilador C (en nuestro caso gcc) ofrece una gran variedad de funciones estándares. Asimismo, en este capítulo, se trata un concepto muy importante de la programación: la recursividad; además de que también se estudia el mecanismo de asignación de la memoria que el compilador C usa y permite. La última parte de este capítulo está dedicada al estudio de una noción que es muy poco implementada en los lenguajes de programación: el apuntador de función. Para una mayor comprensión del tema, a lo largo del capítulo se presentan y comentan varios ejemplos de programas completos.
3.2 Variables y apuntadores En el capítulo anterior se estudió la forma de las declaraciones de las variables; se hizo énfasis en que, usualmente, estas declaraciones se encuentran/escriben al inicio del bloque demain. En realidad, las variables y las funciones se definen en varios lugares del archivo de programa.c, como en el exterior de cualquier bloque o al interior de un bloque de función (incluido el de main), o también en un bloque interior de otro bloque. Según el lugar de su definición y con base en otras opciones, el compilador asigna de manera diferente el espacio de memoria necesario en la variable. De cualquier manera, al momento de su uso, cada variable debe tener asignada una zona de memoria para su contenido.
Variables locales y variables globales Para cada variable, el compilador asigna la memoria que esta necesita en términos de tamaño (número de bytes) y ubicación de memoria conforme al tipo de la variable que se trate. Según el lugar en el cual aparece la definición de la variable, una variable se puede definir como: Variable global. Es aquella que se ubica al exterior de cualquier bloque de definición de funciones o de cualquier otro bloque. Variable local. Es aquella que se ubica al interior de un bloque.
96
Por ejemplo, véase el siguiente programa C: #include
En este caso, una variable globalvariable y la variable variable local, está definida fuera la delvariable bloque adeesmain y la segunda es localb aeslauna función main .1 Porporque tanto, lael primera programavariable produce la siguiente salida: La variable a: 33 La variable b: 5
Las variables definidas dentro de un mismo bloque se consideran variables locales a este bloque, por lo que deben tener identificadores diferentes, es decir, nombres distintos. Así, el código siguiente no es correcto y produce un error de compilación: #include
Error de compilación: variable2.c:8: error: conflicting definition of? b? was here
typesfor?b?
variable2.c:7:
error:
previous
Si dentro de un mismo bloque se requiere que las variables tengan identificadores diferentes, es posible tener dos variables con el mismo identificador, pero definidas en bloques diferentes; en este caso, el uso de este identificador (cronológicamente) se hace en su última definición de variable encontrada. En el programa siguiente se redefinen dos variables con el mismo nombre, y dentro del bloque más interior hay una última definición; es esta definición la que será considerada: #include
En el capítulo 4 se explica el funcionamiento de la función main y el uso de sus parámetros. 97
int b=5; printf(“---al incio de main \n”); printf(“ La variable a : %d\n”, a); printf(“ La variable b : %d\n”, b); { in ta= 100; // variables locales al bloque int b=77; printf(“ >>>> al interior del bloque\n”); printf(“ La variable a : %d\n”, a); printf(“ La variable b : %d\n”, b); } printf(“ >>>> al exterior del bloque\n”); printf(“ La variable a : %d\n”, a); printf(“ La variable b : %d\n”, b); }
Una vez que este bloque termina, se olvidan por completo todas las definiciones interiores del bloque y se toman en cuenta las definiciones variables antes de abrir el bloque. Entonces, el programa produce: --- al inicio de main La variable a : 33 La variable b : 5 >>>> al interior del bloque La variable a : 100 La variable b : 77 >>>> al exterior del bloque La variable a : 33 La variable b : 5
La portada de una variable global es extendida a todo el código; por ejemplo, en este código la variable a es visible en el bloque de main y en cualquier otro bloque interno del main; también es visible en el bloque de la función otro_ que_main: #include
Al interior de la función otro_que_main, se toma en cuenta el identificador a como la variable global. También se puede ver que si una variable es visible, entonces su contenido se puede cambiar. El resultado del programa es el siguiente: Al inicio la variable a : 33 En el main el interior del bloque 2 la variable a : 86 En otro_que_main la variable a : 34
Variables dinámicas y variables estáticas La asignación de la memoria para las variables se hace de manera dinámica, excepto en el caso de las variable globales y las variables locales estáticas. Las variables locales tienen su espacio de memoria en una zona que se asigna dinámicamente al momento de la llamada de la función; por esta razón, se les conoce con el nombre de variables dinámicas. Esta zona se borra (se libera) al fin del código de la función. Mientras la función no esté terminada, esta zona de memoria no se borra y todas sus variables locales y dinámicas están disponibles. Si la función es recursiva, 2 por cada llamada se asigna una nueva área en la zona de memoria dedicada a las variables de funciones. De manera contraria, también existe la posibilidad de declarar variables estáticas; en este caso, la asignación se hace una sola vez para cualquier llamada y el contenido no se borra al final de la ejecución de la función. Las variables estáticas pueden ser de cualquier tipo conocido (predefinido o definido por el usuario). En este caso, la declaración se hace usando la palabra static antes del tipo: static tipo_variable nombre_variable; static tipo_variable nombre_variable = valor;
Opcionalmente, es posible asignar un valor a la variable. Al contrario de las variables dinámicas, esta asignación se hace una sola vez, al momento de la primera asignación de memoria a la variable.
Ejemplo Factorización de números primos; acción que consiste en descomponer un número entero en divisores no triviales, los cuales, cuando se multiplican, dan el número srcinal.
En este caso, se necesita una función capaz de regresar lo más pequeño del divisor de un número entero positivo. Por tanto, utilizaremos esta función para escribir un número de entrada como un producto de factores de números primos.3 Una solución con solo variables dinámicas es la que se presenta a continuación: #include
99
{ if (n % k == 0) return k; else if (k == 2) k = 3; else k +=2; } return 0; } int main(int argc, char *argv[]) { int n; //el numero int d; // un divisor printf(“ Su numero a descomponer :”); scanf(“%d”, &n); do { d = menor_divisor(n); if (d == 0) break; else { printf(“ %d”, d); n = n / d; } } while(n > 1); if (n >1) printf(“ %d”, n); printf(“\n”); exit(0); }
La salida de este programa es, por ejemplo: Su numero a descomponer : 181800 2223355 101
En la función menor_divisor se determina el divisor más pequeño de un número, verificando, primero, la divisibilidad entre 2, luego entre 3, entre 5 y entre otro número impar. En el programa principal (main), se detecta un divisor no trivial d y luego se hace la división de n entre d; el proceso se repite hasta que el número es 1 o hasta que no se encuentran más divisores. Aquí es fácil observar que cuando se detecta un divisor no trivial del número, en la próxima llamada es mejor iniciar con el valor del último divisor encontrado. Así, hay dos soluciones: Introducir un parámetro con el valor del último divisor. Transformar la variable k en una variable estática con la asignación k = 2 hecha una sola vez.
La solución que utiliza k como variable estática es: 100
int menor_divisor(int n) { static int k = 2; //divisor potencial // para ver el valor de la variable k al inicio de la funcion: printf(“ llamada para n=%d k = %d.\n”, n, k); while (k <= sqrt(n)) { if (n % k == 0) return k; else if (k == 2) k = 3; else k +=2; } return 0; }
El programa principal queda idéntico. La salida es la siguiente: Su numero a descomponer : 181800 llamada para n=181800 k = 2. 2 llamada para n=90900 k = 2. 2 llamada para n=45450 k = 2. 2 llamada para n=22725 k = 2. 3 llamada para n=7575 k = 3. 3 llamada para n=2525 k = 3. 5 llamada para n=505 k = 5. 5 llamada para n=101 k = 5. 101
La función de escritura printf, en el cuerpo de la función menor_divisor, se utiliza solo para observar el valor de inicio de la variable k.
Apuntadores La declaración de cualquier variable, de cualquier tipo, se acompaña de la asignación de una zona de memoria coherente en tamaño, para guardar su valor. Por la declaración: int i;
se asigna una zona de memoria de 4 bytes, con una ubicación (dirección) de memoria múltiplo de 4. El operador unario, &, indica la dirección de memoria de la variable. Así, &i es la dirección de memoria de la variable i. Una dirección de memoria se llama apuntador. De esta forma, la expresión &i es un apuntador. El operador unario, &, como su nombre lo indica, se aplica a cualquier variable de cualquier tipo y nunca a una expresión compuesta (con más de una variable). Por su parte, el operador unario, *, indica el contenido de memoria de un apuntador. Este se aplica únicamente a variables y expresiones de tipo apuntador. El siguiente programa ilustra el uso de los operadores & y *: 101
#include
exit(0);
Este programa produce salidas diferentes, según el sistema operativo y el tipo de máquina. 4 La salida en una Apple MacBook es: valor i = 3, su direccion =0x7fff5fbffa2c contenido de la direccion &i =3
En tanto, la salida del mismo código desde una máquina Sun, con sistema operativo Unix, es: valor i =3 , su direccion =ffbffaf4 contenido de la direccion &i =3
También podemos declarar variables de tipo “dirección de” (apuntadores): tipo *nombre_variable; tipo * nombre_variable;
Para ilustrar, se presenta el siguiente programa: #include
su salida es: valor i =3, su direccion =0x7fff5fbffa2c valor idir=0x7fff5fbffa2c ,su direccion =0x7fff5fbffa20 el valor apuntado por idir =3
4
102
Las diferencias son visibles sobre la dirección de memoria de la variable.
Con el uso de apuntadores, es posible acceder al contenido de memoria. Asimismo, el tipo apuntador soporta algunas operaciones aritméticas como: Adición con un entero. Esta tiene por efecto calcular la ubicación de memoria que se encuentra n posiciones después; donde una posición vale un número de bytes equivalente al tipo de zona de memoria apuntada. Sustracción (resta) - operación inversa.
Ejemplo #include
produce (los cálculos están hechos en base 16 y la adición se hace con 2 ×4): Ubicacion de i : 0x7fff5fbffa2c Ubicacion apuntada por p : 0x7fff5fbffa34
Los apuntadores ofrecen la posibilidad de cambiar de una manera directa el contenido de la zona de memoria apuntada. En el siguiente programa, el valor de la variable i se cambia usando una asignación del valor apuntado por el apuntador x: #include
El programa escribe: Al inicio valor i =3 El nuevo valor i =15
El valor mismo (y no el valor apuntado) de una variable de tipo apuntador, se puede modificar como cualquier otra variable; este cambio se realiza en expresiones de tipo asignación. No obstante, debe cuidarse que el lado derecho sea del mismo tipo de apuntador que el lado izquierdo. 103
La noción de apuntador se aplica no solamente a un tipo escalar (int, float, etc.), también puede aplicarse a otros tipos. Por ejemplo: #include
En este programa, dobleap es de tipo **int, que significa un apuntador de apuntador de entero; en tanto, api es un apuntador de entero. Entonces, el código produce: valor i =3 , su direccion =0x7fff5fbff99c valor api =0x7fff5fbff99c , su direccion =0x7fff5fbff990 el valor apuntado 3 valor dobleapp =0x7fff5fbff990 , su direccion =0x7fff5fbff988 valor apuntado = 0x7fff5fbff99c valor apuntado por el apuntador = 3
La memoria prácticamente se llena, como se observa en la figura 3.1. Variable
Contenido
Dirección
dobleap
a20
a18
api
a2c
a20
i
3
a2c
Figura 3.1
Tipo void En el lenguaje C, hay un tipo de dato que no contiene ningún tipo: el tipo void. No hay variables que sean de tipo void, pero este tipo se usa para las funciones5 y para los apuntadores. 5
104
Véase la siguiente sección.
Un apuntador de tipo void es cualquier ubicación/dirección de memoria sin restricción.6 Por ejemplo, si en un programa hay la declaración: void a;
La compilación produce un error: test_void1.c:6: error: variable or field ?a? de clared void
Cualquier variable de tipo apuntador se puede convertir en tipo de apuntadorde void; sin embargo, en algunas ocasiones, la conversión en el otro sentido (de void* a otro tipo) se indica, en la fase de compilación, como una advertencia, y puede producir resultados erróneos en la ejecución. #include #include
La proposición de la línea 14 es un comentario, porque laescritura*a no es correcta; se trata de un contenido de tipovoid. Para acceder a un contenido apuntado por un apuntador de tipo void*, se necesita cambiar el tipo del apuntador y, luego, acceder a su contenido con el operador unario *. El programa escribe: El valor apuntado por apf 3.500000 El valor apuntado por apf 0.000000 El valor apuntado por a 0.000000
porque el valor en binario del número 35 representado en 2 bytes se interpreta como un valor en punto flotante; entonces, el resultado pierde cualquier sentido.
Ejemplo
Cálculo de una potencia ak con a como valor flotante y k como valor entero positivo o negativo. Para este ejercicio la potencia no siempre es un valor positivo, puede ser cualquier valor entero; entonces tenemos tres casos que debemos tomar en cuenta:
Nota del revisor: El tipo void es un tipo de dato especial del lenguaje de programación C; se utiliza para el manejo de espacios de memoria dinámica sin un tipo de dato en específico. 6
105
k = 0: el valor es 1. k > 0: se debe calcular de manera iterativa:
axax…xa k veces k < 0: esta significa k = −n con n entero positivo; se debe calcular de manera iterativa:
1 1 1 a × a ×… a n veces
Podemos hacer un programa con un análisis y un cálculo para cada caso, o poner en variables el valor que será multiplicado con el operador de producto, y el valor entero de la potencia efectiva, para conocer el número de multiplicaciones que se hace. El programa es el siguiente: #include
poder = −k; coeficiente = 1 / a; ap = &poder; ac = &coeficiente;
} else { ap = &k; ac = &a; } valor = 1; for ( i = 0; i < *ap; i++) valor = valor * *ac; printf( “ El valor de la potencia %f ^ %d : %f\n”, a, k, valor); exit(0); }
La proposición: valor = valor * *ac;
es una expresión de asignación, por lo cual el operador * aparece dos veces con significaciones distintas: es el operador de multiplicación y es el operador de contenido de apuntador, el segundo operador tiene una prioridad más alta, entonces no se necesitan paréntesis. 106
El programa escribe: El valor de la potencia 1.560000 ^ −5 : 0.108237
Ejemplo
Cálculo del recuento de permutaciones. Para los conjuntos de n objetos el recuento de las permutaciones posibles tiene el valor factorial de n (n!). Para los conjuntos de k objetos con k < n, el recuento vale: P (n , k ) = n ! (n – k)! (Este número se conoce también como ordenaciones o arreglos de n en k). Para calcular con una misma fórmula los dos recuentos n (n!) o P(n, k), podemos usar apuntadores que apuntan para los valores de límite bajo y límite alto, entre los cuales se hace la variación de factores. La expresión factorial de n significa: n! = 1 × 2 × ··· × ( n − 1) × n La expresión de P(n, k) significa: n! 1 x 2 x … x (n – 1) x n P(n, k) = (n – k)! = = (n – k + 1) x … x (n – 1) x n 1 x 2 …(n – k) El producto iterativo que se calcula tiene factores sucesivos de 1 hasta n o de n − k + 1 hasta n. En el programa, si tenemos que calcular el recuento de permutaciones k de n o permutaciones de n, el límite bajo va a apuntar sobre un valor que contiene n − k + 1 o 1, y el límite alto va a apuntar a n. #include
Para los valores de n = 8 y k_de_n = 0 (se quieren las permutaciones de n) la salida del programa es la siguiente: El recuento de permutaciones : 40320
Por los valores de n = 8, k = 2, k_de_n = 1 (permutaciones de n en k) la salida del programa es: El recuento de permutaciones : 56 107
3.3 Funciones En programación, una función designa una parte de código que se ejecuta al momento de las llamadas (una o varias llamadas). En algunos lenguajes de programación imperativos, como PASCAL, también se incorpora la noción de procedimiento, en el sentido de una parte de código que no regresa ningún valor. El lenguaje C implementa únicamente la noción de función; el procedimiento no es más que una función de tipo void.
Definición de una función Una función se define por: Su nombre, que debe ser un identificador único. Su tipo, que es el tipo de valor que la función regresa. La lista ordenada de sus parámetros, donde cada parámetro tiene un nombre y un tipo, debido a lo cual se dice
que los parámetros son formales. El código mismo.
Con base en lo anterior, cabe destacar que la lista de los parámetros puede estar vacía. En programación, el tipo de la función puede ser cualquiera: un tipo definido por el usuario 7 o un tipo estándar, incluso el tipo void. La declaración de una función se hace con: tipo nombre_funcion(lista parametros contipos){}
Por su parte, el código se presenta como un bloque entre {} y contiene declaraciones de variables (las cuales son variables locales a la función) y proposiciones. La última proposición que se ejecuta debe ser la proposición return, que tiene dos formas: return expresion; return;
La última proposición significa el fin de ejecución de la función y la primera se refiere al valor que la función regresa, el cual es el valor de la expresión. La forma return, se usa únicamente por las funciones de tipo void. A continuación se presentan algunos ejemplos de funciones: void nada() { } void nada2() { return; } void linea() {
7
108
Los tipos definidos por el usuario se estudian con detalle en el capítulo 4.
printf(“------------------------\n”); return; } int min(int a,int b) { if (a <=b) returna; else return b; }
En el ejemplo, las funciones nada y nada2 no tienen parámetros (es decir, la lista está vacía), por lo que no regresan ningún valor; tampoco hacen ningún está cálculo. La diferencia entre las dos consiste en la presencia de return; para la primera función, el bloque de código vacío. La función linea tampoco tiene parámetros y no calcula ningún valor, pero su objetivo es escribir una línea de - en la salida. La función min es interesante, ya que determina y regresa el mínimo entre dos números enteros; sus parámetros son dos enteros: a y b. En el código se encuentran dos proposiciones return, pero solo una de estas es ejecutada. La definición de funciones de un programa se hace: Al interior de un bloque; en este caso, la función sería una función local al bloque. Afuera de cualquier bloque, al mismo nivel que main ;8 en este caso, la función sería una función global.
En la mayoría de los casos, las funciones serían definidas de manera global, al mismo nivel que la función main. Es preferible concebir el programa con funciones globales solamente; de esta manera, una función se puede hacer con llamadas de cualquier otra función.
Llamadas de funciones Si la definición de una función debe ser única y la función, una vez definida, se puede llamar varias veces, cada vez que sea necesario, la llamada de una función siempre aparecerá en una expresión. La forma de una llamada es la siguiente: nombre_funcion(lista valor es parametros)
y puede aparecer como parte de cualquier expresión. En la llamada se indica el nombre de la función y la lista de sus parámetros dichos, actuales o reales. La llamada se define con “los parámetros formales del código de la función que los recibe, uno a uno, es decir, con los valores de la lista de parámetros actuales”; el código de la función se ejecuta con estos valores y se regresa el valor de la función. El valor regresado se usa como cualquier valor en el cálculo de la expresión donde aparece la llamada. Es importante resaltar que la lista de los valores de parámetros actuales debe corresponder en número y tipo con la lista de los parámetros formales.
Ejemplo
Para las funciones definidas en la sección precedente, las llamadas de estas funciones pueden ser:9 int main(int argc, char *argv[]) { El “código principal“, el bloque main, también es una función. Debido a su importancia, la función main se estudia con detalle en el capítulo 4. 9 Véase el programa funcions_simples.c en el CD-ROM que acompaña este libro. 8
109
int y, x=15; nada(); nada2(); linea(); y = min(x, x-3*5); printf(“ El valor que nos interesa : %d\n”, y); linea(); printf(“ Otro valor que nos interesa : %d\n”, min (5*7, 6*6) - 3); linea(); exit(0); }
Aquí hay llamadas sucesivas de nada y nada2, tres llamadas de la función linea y dos llamadas con la función min . Excepto la función min , las llamadas de otras funciones no tienen parámetros; sin embargo, los paréntesis () deben ser indicados. Las llamadas de la función min son diferentes; los parámetros actuales son una variable simple o expresiones. Las llamadas aparecen en expresiones complejas; ya sea, en una asignación o en una expresión aritmética. El programa produce el siguiente resultado: ------------------------El valor que nos interesa : 0 ------------------------Otro valor que nos interesa : 32 -------------------------
Si los parámetros actuales no corresponden en nombre con los parámetros formales, se indica un error al momento de la compilación. Por otra parte, si el tipo no corresponde, se hace una conversión del tipo de parámetro actual al tipo de definición del parámetro. Por ejemplo, si aparecen estas llamadas: y=min(x,x-3*5,17); min(5);
Los mensajes del compilador resultan explícitos. Así: funcions_simples_error.c: In function ?main?: funcions_simples_error.c:32: error:too many arguments to function ?min? funcions_simples_error.c:33: error: too few arguments to function ?min?
Prototipo de una función Para escribir correctamente la llamada de una función, se debe conocer: El tipo de la función. El nombre asignado a la función. La lista de los tipos de los parámetros.
110
El nombre de cada parámetro definido no es significativo para hacer una llamada. A veces, es muy posible que en el trabajo de programación se usen funciones estándares, funciones de las librerías o funciones escritas por algún otro miembro del equipo de trabajo. Como ya se mencionó, para hacer la llamada se debe conocer la definición completa, o al menos el prototipo de la función. El prototipo de una función se hace como: tipo nombre_funcion(lista de los tipos de los argumentos)
Por ejemplo, para las cuatro funciones definidas en el apartado anterior, los prototipos son: void nada(); void nada2(); void linea();a, int b); int min(int
Por lo que respecta a los parámetros, solo nos interesan sus tipos; no son necesarios sus nombres, pueden faltar. Las librerías .h, que están incluidas al inicio del código, contienen definiciones de tipo, de algunas variables globales, de algunas constantes y de una buena parte de prototipos de función. Por ejemplo, en la librería stdlib.h se distinguen todos los prototipos de funciones: void char long
free(void *); *getenv(const char*); labs(long) --pure2;
Es necesario destacar aquí, que el lenguaje C permite la definición y el uso de funciones con un número variable de parámetros, pero estas construcciones resultan muy técnicas, por lo que sobrepasan el objetivo de este libro.
Un ejemplo completo: cálculo de máximo común divisor y de mínimo común múltiplo Antes de iniciar el tratamiento de este tema, es necesario precisar algunas definiciones básicas para su comprensión: El máximo común divisor de dos números enteros positivos a y b, es otro número entero d, que es el mayor
divisor de a y de b; a saber, las divisiones de a entre d y de b entre d, no dejan residuo. El mínimo común múltiplo de dos números es el menor entero m, que tiene a y b por divisores.
De esta forma, introducimos las notaciones: d =mcd(a, b) m =mcm(a, b)
Nos interesa obtener los valores de d y m. Conocemos una relación matemática que liga los dos conceptos: m * d = a * b
Para resolver este doble problema, es imperativo contar con un algoritmo que determine el máximo común divisor. Desde el punto de vista de la programación, se impone escribir dos funciones que calculan, respectivamente, el máximo común divisor y el mínimo común múltiplo. Los prototipos de las funciones serían, entonces: int mcd(int, int); int mcm(int, int); 111
Con la fórmula descrita, la definición de la función C mcm es: int mcm(int x, int y)//minimo comun multiplo de 2 numero { return x * y / mcd(x, y); }
Matemáticamente, para calcular el máximo común divisor se conocen dos métodos: La descomposición de los dos números en factores primos. Por este método, se toman los factores en común. Por
ejemplo, en el caso de 90 = 2 × 3 2 × 5 y 162 = 2 × 3 4, el máximo común divisor es 2 × 32 = 18. La aplicación del algoritmo de Euclides de divisiones enteras sucesivas hasta que el residuo sea igual a 0. Por este método, el máximo común divisor es, entonces, el último divisor utilizado.
Ejemplo Para buscar el máximo común divisor de 90 y de 162, se hacen las siguientes divisiones enteras sucesivas (véase la tabla 3.1). Tabla 3.1 Dividendo(a)
Divisor(b)
Cociente
Resto
90
162
0
90
162
90
1
72
90
72
1
18
72
18
4
0
El primer método (descomposición de los dos números en factores primos) resulta difícil de implementar, ya que necesitamos conocer los números primos, un algoritmo por el cálculo de la potencia que aparece en la descomposición y otros algoritmos, para detectar los factores comunes y la potencia máxima de cada divisor primo. El segundo método (algoritmo de Euclides) se traduce en el siguiente pseudocódigo, donde a es el dividendo, b el divisor y r el residuo (el cociente no nos interesa): repetir r←a%b (residuo de la división entera) a←b b←r hasta que r =0 Escribir a
La traducción en el lenguaje C es: int mcd(int x,int y) //maximo comun divisor de 2 numeros { int r; r = 1; while(r !=0) { r= x% y; x= y; y= r; } return x; } 112
La parte principal, main, contiene la lectura de los números y las llamadas para calcular el máximo común divisor y el mínimo común múltiplo: int main (int argc, char *argv[]) { int a, b; printf(“ indica los dos numeros :”); scanf(“%d%d”, &a, &b); printf(“ los numeros son : %d %d\n”, a, b); printf(“ Su max comun divisor:%d\n”, mcd(a,b)); printf(“ Su min comun multiplo:%d\n”, mcm(a,b)); exit(0); }
Al momento de la ejecución del programa, si se introducen los valores 45 y 81, la salida es: los numeros son :4581 Su max comun divisor:9 Su min comun multiplo:405
Transmisión de los parámetros Si hacemos un análisis del orden de las llamadas de las funciones en el programa, por el cálculo del máximo común divisor, observamos que en el código de la función mcd, los valores de entrada en los parámetros cambian durante el cálculo, mientras que al regreso de esta llamada, se hace una llamada de la función mcm, y el valor regresa. Entonces, surgen algunas ¿Qué pasa con los valores introducidos en una función? ¿Los valores de los parámetros cambian o no apreguntas, la salida decomo: la función? Si cambiamos la parte de main, para observar los valores de a y b al fin de la llamada de la función mcd tenemos: printf(“ los numeros son :%d%d\n”,a, b); printf(“Su max comun divisor:%d\n”, mcd(a,b)); printf(“Los valores contenidos en a y b son: %d %d\n”, a, b);
Entonces, la salida del programa sería: Los numeros son: 56 777 Su max comun divisor:7 Los valores contenidos en a y b son:56 777
Como se puede observar, los valores de los parámetros están transformados al interior de la función; sin embargo, a la salida de la función (al final de la llamada), los valores de las variables no están modificados. En este caso, se dice que los parámetros están transmitidos por valor. Los parámetros son transmitidos por valor en el caso del tipo escalar y no del tipo apuntador. Si el tipo del parámetro es un apuntador, entonces, se dice que el parámetro es transmitido por dirección o transmitido por referencia; en este caso, el contenido de la zona de memoria apuntada puede ser modificado. La llamada de una función se realiza, prácticamente, colocando los parámetros (valores o apuntadores) en la pila de llamada, donde se construye el ambiente del bloque de la función con sus variables locales y se ejecuta el código. Si en la lista de los parámetros hay un apuntador, y si se cambia su valor apuntado, este cambio será efectivo y guardado después, al final de la llamada de la función. 113
Ejemplo
¿Cómo podemos cambiar/intercambiar el contenido de las variables enteras (véase la figura 3.2)? Inicial
a
aa
b
bbb
Final
a
bbb
a
aa
Figura 3.2
Este problema es equivalente al problema práctico de intercambiar el contenido de dos vasos llenos de diferentes líquidos, donde la solución sería usar un tercer vaso y hacer los cambios descritos en la figura 3.3. 1 2 a
3 b
c
Figura 3.3
La implementación en el lenguaje C podría ser: void cambio1(int a, int b) { int temp; temp = a; a = b; b = temp; return; }
Sin embargo, en esta versión, los parámetros están transmitidos por valor; entonces, los contenidos nunca podrán ser cambiados. La versión correcta necesita que los parámetros estén transmitidos por dirección: void cambio2(int *a, int *b) { int temp; temp = *a; *a = *b; *b = temp; return; }
Esta es la versión que funciona correctamente.10 En esta, la parte de la función principal de main expresa el uso de las dos versiones calculando: int main (int argc, char *argv[]) { int aa, bb; 10
114
El tema de intercambio de valores se estudia con profundidad en los capítulos sobre el ordenamiento de los valores.
printf(“ indica los dos numeros :”); scanf(“%d%d”, &aa, &bb); printf(“los numeros son : %d %d\n”, aa, bb); cambio1(aa, bb); printf(“despues la llamada de *cambio1* : aa=%d, bb = %d\n”, aa, bb); cambio2(&aa, &bb); printf(“despues la llamada de *cambio2* : aa=%d, bb = %d\n”, aa, bb); exit(0); } indica los dos numeros :101 44 losnumerosson:10144 despues la llamada de *cambio1* : aa=101, bb= 44 despues la llamada de *cambio1* : aa=44, bb= 101
Ejemplo
Es posible reescribir las funciones de máximo común divisor y mínimo común múltiplo en una sola función con dos parámetros transmitidos por valor, para los valores de entrada, y dos parámetros transmitidos por dirección, para los valores del divisor y del múltiplo: #include
exit(0); }
Ejemplo
La función min, que fue presentada antes, calcula el valor mínimo. Si queremos que una función regrese el mínimo de dos valores, pero no como un valor, sino como un apuntador por la variable que contiene el valor mínimo, entonces la función debe ser de tipo int*, el apuntador debe ser un apuntador de un int y los parámetros también deben ser de tipo int*. int* min_apuntado(int *a, int *b) { if (*a <= *b) return a; else return b; }
Para comparar el efecto de las funciones min y min_apuntado, se utiliza el siguiente código: int main(int argc, char *argv[]) { int x = 15, y = 30; int min_simple, *p; min_simple = min(x, y); printf(“El minimo de %p->%d y %p->%d es : %p->%d\n”, &x, x, &y, y, &min_simple, min);
p = min_apuntado(&x, &y); printf(“El minimo de %p->%d y %p->%d es : %p->%d\n”, &x, x, &y,y, p, *p); exit(1); }
El código anterior produce la siguiente salida: El minimo de 0x7fff5fbff99c->15 y 0x7fff5fbff998->30 es : 0x7fff5fbff994->15 El minimo de 0x7fff5fbff99c->15 y 0x7fff5fbff998->30 es : 0x7fff5fbff99c->15
Como se puede observar, la variable min_simple contiene una dirección de memoria diferente que a o b; en tanto, al contenido de p (que es de tipo apuntador) le corresponde la dirección de a. Si queremos una función de tipo void con tres parámetros, el código de una versión de la función sería: void min_apuntado_version2(int *a, int *b, int **pp) { if (*a <= *b) *pp = a; else *pp = b; return; }
El parámetro formal salida secambia. transmite referencia, perouna un apuntador tipo int*para no esuna suficiente, contenido mismo delde apuntador Porpor tanto, se requiere dirección dedeapuntador direcciónporque de intel, es decir, un apuntador de tipo int **. La llamada sería, entonces: int x, y, *p; ... min_apuntado_version2(&x, &y, &p); 116
3.4 Funciones estándares El lenguaje C (el compilador gcc) ofrece al usuario una larga colección de funciones en sus bibliotecas de funciones, las cuales se denominan funciones estándares. El uso de las funciones estándares permite indicar una directiva de inclusión, haciendo mención del nombre de la biblioteca que las contiene: #include<. h>
Funciones matemáticas de
nométricas. double asin(double x), double acos(double x), double atan(double x), para las funciones
trigonométricas inversas. double exp(double x), que significa ex. double log(double x), que significa el logaritmo natural ln(x),y double log10(double x), que es
el log10(x). double pow(x, y), que es la potencia xy. double sqrt(double x), que es la raíz cuadrada. double ceil(x) regresa parte entera superior y double floor(double x)parte entera inferior. double fabs(double x), que es el valor absoluto.
Para la compilación se usa la opción -lm.
Algunas de las funciones de
predefinida en la misma biblioteca). void srand(unsigned int n):que utiliza a la semilla n para generar una nueva secuencia de números
pseudoaleatorios.
Ejemplo
Un programa que genera tres valores aleatorios: un valor entre 0 y RAND_MAX, que es una constante de la biblioteca stdlib.h, y otros dos valores entre 0 y 30: /*programa : uso de rand y srand */ #include
{ int i, j; printf(“ el valor RAND_MAX es :%d\n”, RAND_MAX); i = rand(); printf(“ un valor aleatorio es : %d\n”, i); j = rand()%31; printf(“ un valor aleatorio entre 0 y 30 es : %d\n”, j);
}
srand(1979); j = rand()%31; printf(“ otro valor aleatorio entre 0 y 30 es : %d\n”, j); exit(0);
Este programa genera:11 el valor RAND_MAX es :2147483647 un valor aleatorio es :16807 un valor aleatorio entre 0 y 30 es :25 otro valor aleatorio entre 0 y 30 es :6
En la actualidad, existen muchas más bibliotecas de funciones y constantes útiles para el usuario, como para el manejo del tipo char, para el tipo cadenas de caracteres, para el trabajo con archivos y muchos más.12
3.5 Funciones de entrada/salida En la biblioteca
Donde el formato es una cadena de caracteres y argumentos que son expresiones (para printf) o apuntadores de variables (para scanf). Para la función printf, el formato contiene caracteres ASCII. En tanto, algunos formatos de conversión se componen del carácter ’%’ y de otro carácter (a veces, el tamaño). La cadena de caracteres del formato se escribe hasta el primer formato de conversión, donde el primer argumento de la lista se convierte, según sea la codificación; luego se continúa hasta el final del formato.
Ejemplo float promedio; int suma; ... printf(“ El promedio de los valores del arreglo es : %f\n y la suma es : %d\n”, promedio, suma);
Según los sistemas operativos, los números generados no serían los mismos. Como lo indicamos, las librerías (en el sistema Unix/Linux/Mac OS) se encuentran en el directorio /usr/include y si se necesitan detalles sobre una función, el sistema operativo permite el uso del comando man. Por ejemplo, man sqrt nos indica el prototipo, otras funciones “vecinas” y una explicación. 11 12
118
Este produce: El promedio de los valores del arreglo es :15.629630 y la suma es :422
El primer formato de conversión, %f, se sustituye con el valor de la variable flotante promedio, y el segundo formato, %d, se sustituye con el valor entero de la variable suma. La lista de los formatos de conversión se presenta en la tabla 3.2:
Tabla 3.2 Lista de formatos de conversión. Formato
%d%i %o %x%X %u
Tipoargumento
Conversión
Int
decimal con signo
Int
octal sin signo
Int
hexadecimal sin signo (abc o ABC)
Int
decimal sin signo
%c
Char
%s
char*
los caracteres almacenados al inicio del argumento hasta que se encuentren un ’\0’
%f
double
notación decimal con signo y punto decimal en la forma [−]mmm.ddd (la precisión por defecto es 6)
%e %E
double
notación decimal con escritura científica en la forma [−]m.dddddE_xx
%g %G
double
se usa %e o %E si el exponente es menor que −4o mayor que la precisión; si no se usa%f
%p
void*
un carácter sencillo
impresión apuntador (depende del sistema)
Para la función scanf, el formato contiene: Blancos o tabuladores que se ignoran. Caracteres ordinarios (excepto %) que se espera coincidan con los caracteres buscados en la entrada. Formatos de conversión (casi los mismos que printf).
El método de funcionamiento es el siguiente:
1. En la entrada (el teclado), se esperan cadenas de caracteres que correspondan con los caracteres ordinarios o con los formatos de conversión hasta un separador (o varios) que puede ser: una línea nueva, un tabulador o un espacio. 2. Para las dos funciones ( printf y scanf) es necesario indicar el mismo número de argumentos y de tipos correctos (compatibles) que los formatos de conversión. Ejemplo
En el siguiente programa hay lecturas y escrituras de valores. /*programa ejemplo de lectura */ #include
float d; scanf(“%d %d”, &a, &b); printf(“a=%d b=%d\n”, a, b); scanf(“%d %d”, &a, &b); printf(“a=%d b=%d\n”, a, b); scanf(“%f %d\n”, &d, &a); printf(“d=%f, a=%d\n”, d, a); scanf(“%f %d”, &d, &a); printf(“d=%f, a=%d\n”, d, a); exit(0); }
De este ejemplo se desprenden dos observaciones; la primera, no es fácil saber qué se debe introducir, si no hay un mensaje explicativo antes; la segunda, scanf espera, en la entrada, algunos espacios (a veces más de uno) para separar los valores introducidos. Con base en lo antes expuesto, es posible dar el siguiente consejo: en la cadena de formato de scanf usar únicamente descriptores de formato (por ejemplo, %f o %d), sin ningún otro carácter. En las cadenas de formatos, los caracteres especiales son:
Tabla 3.3 Caracteres especiales. \n
línea nueva
\\
el carácter \
\t
una tabulación horizontal
\”
el carácter “
\’
el carácter ’
\ooo
un número en octal ooo
\xhh
un número hexadecimal hh
Los formatos de conversión también se usan con la indicación precisa del tamaño del valor que se lee. Para los formatos %d y %s, se debe indicar el tamaño que se desea: %md, %ms, con un valor constante m . Si el valor de m es insuficiente, se usa el tamaño mínimo necesario.
Ejemplo 1 Formato %d #include
Su salida es: i = 786 i = 786 j = 909090
Se puede observar que para la variable j se toman más de cinco caracteres. Para el formato %f, se indican dos valores: m y n; es decir, por el número total de caracteres y por el número de caracteres de la parte decimal, respectivamente: %m.nf
El tamaño total incluye el punto decimal y, eventualmente, el signo del valor representado. Ejemplo
Uso de %f #include
El efecto de los formatos de conversión es el siguiente: f = = 786.12 786.123596 f f = 786.12360
Aquí se puede ver que se hace un redondeo de las cifras decimales. Algunas funciones muy útiles para las entradas y las salidas del programa son: Función system de la librería stdlib. Esta función toma como parámetro una cadena constante de caracteres
que contiene un comando para el sistema operativo, el cual se ejecuta en el sistema operativo. Funciones fflush y fpurge. Estas funciones vacían todos los flujos de entrada y/o salida al flujo indicado. Tam-
bién permiten hacer lecturas más claras para los valores.
Ejemplo # include
121
printf(“ introducir mas de un numero, pero unicamente el primer valor seria leido.\n”); scanf(“%d”, &i); fpurge(stdin);//si otros caracteres siguen despues del valor leido, seran borrados scanf(“%d”, &j); //se espera una nueva entrada exit(1); }
3.6 Recursividad La recursividad es un paradigma de concepción de algoritmos muy usado para la solución de diversos problemas. Su objetivo es describir la obtención de algunas soluciones, como la obtención preliminar de otra solución. Un problema clásico que necesita una resolución recursiva es el concerniente al juego de las torres de Hanoi, ya que este constituye un juego matemático individual, en el que se dispone de un tablero con tres estacas y ocho discos (en el caso general N), en orden creciente como se observa en la figura 3.4.
Torre
Torre
Torre
Figura 3.4 Torres de Hanoi.
El objetivo de este juego es mover todos los discos de una estaca a otra, respetando las reglas siguientes: Se mueve un solo disco a la vez. Se toma sistemáticamente el disco que está arriba de una estaca. La estaca de destino debe estar vacía o tener encima un disco más grande que el disco que se mueve.
Con estas reglas, a cada momento, las estacas que no están vacías tienen discos en un orden creciente. El problema de las torres de Hanoi se enuncia de la siguiente manera: “Si los N discos están todos en una estaca fuente f, y forman una torre, se requiere la lista de los movimientos, para obtener la configuración final con todos los discos sobre otra estaca d”.
122
en p
f
d
Figura 3.5
El programa que resuelve el problema de las torres de Hanoi, escribe un mensaje de tipo: mover el disco 1 de la estaca 1 a estaca 2 mover el disco 2 de la estaca 1 a estaca 3 mover el disco 1 de la estaca 2 a estaca 3 ...
Podemos suponer que los números de las estacas son: 1, 2 y 3, respectivamente; entonces, 1 f; d 3 y f d. En este caso, una observación muy útil es que con esta hipótesis la estaca diferente de d y de f, es siempre la estaca 6− f−d. Para buscar una solución, empezamos por analizar los casos más simples: f = d, no se hace nada. Si N =1 (hay un solo disco para mover), se escribe únicamente “mover el disco 1 de f a d” Si hay más de 2 discos (N 2) y f ≠ d), una solución sería mover los N − 1 discos de f a 6 − f − d, “mover el disco
1 de f a d”, mover los N − 1 discos de 6 − f − d a d (véase la figura 3.6).
3 1
6-f-
2
f
d
Figura 3.6
Aquí tenemos la solución para N y usamos las soluciones que podemos definir, sin detallar, para N−1. Entonces, se puede decir que aquí tenemos una solución recursiva. Para escribir la función en ellenguaje C, debemos indicar los parámetros y susignificado. De esta forma, el prototipo sería: void hanoi(int N, int d, int f) 123
Donde N es el número de discos, f la estaca inicial (fuente) y d la estaca destino. El cuerpo de la función es: void hanoi(int N, int f, int d) { if(f ==d )return; if(N == 1) { printf(“ mover el disco *%d* de la estaca %d a la estaca %d\n”, N, f, d); return; } hanoi(N-1,f, 6-f-d); printf(“ mover el disco *%d* de la estaca %d a la estaca %d\n”, N, f, d); hanoi(N-1, 6-f-d, d); }
El cuerpo del main contiene una sola llamada de la función hanoi: int main(int argc, char *argv[]) { intf, d; int N; printf(“¿Cuantos discos hay?N:”); scanf(“%d”,&N); printf(“La estaca fuente y la estaca destino : ”); scanf(“%d%d”, &f, &d); hanoi(N, f, d); }
La solución particular que se obtiene por N = 4 es: ¿Cuántos discos hay? N :4 La estaca fuente y la estaca destino mover el disco *1* de la estaca 1 a mover el disco *2* de la estaca 1 a mover el disco *1* de la estaca 3 a mover el disco *3* de la estaca 1 a mover el disco *1* de la estaca 2 a mover el disco *2* de la estaca 2 a mover el disco *1* de la estaca 1 a mover el disco *4* de la estaca 1 a mover el disco *1* de la estaca 3 a mover el disco *2* de la estaca 3 a mover el disco *1* de la estaca 2 a mover el disco *3* de la estaca 3 a mover el disco *1* de la estaca 1 a
: 12 la estaca la estaca la estaca la estaca la estaca la estaca la estaca la estaca la estaca la estaca la estaca la estaca la estaca
3 2 2 3 1 3 3 2 2 1 1 2 3
mover mover el el disco disco *2* *1* de de la la estaca estaca 1 3 a a la la estaca estaca 2 2
La primera parte de la función hanoi permite recordar las especificaciones de los casos más simples, en el caso de que el primer if sea una condición simple de ausencia de juego y el segundo if verifique el caso de un único disco. De lo contrario, el código continúa haciendo dos llamadas de la función hanoi. El árbol de las primeras ocho llamadas de la función recursiva es el siguiente (véase la figura 3.7): 124
llamada 1 hanoi (4, 1, 2)
llamada 2
hanoi (3, 1, 3)
llamada 8
= 8 = mover disco 4 : 1 –> 2
llamada 3
llamada 6 = 4 =
hanoi (2, 1, 2) llamada 4
= 1 =
mover disco 3 : 1 –> 3 llamada5
hanoi (1, 3, 2)
hanoi (1, 1, 3) = 2 =
mover disco 2 : 1 –> 2
mover disco 1 = 3 = : 1 –> 3
= 5 =
hanoi (3, 3, 2)
hanoi (2, 2, 3)
llamada7 hanoi (1, 2, 1) = 6 =
mover disco 1 : 3 –> 2
llamada8 hanoi (1, 1, 3) mover disco 2 : 2 –> 3
mover disco 1 : 2 –> 1
= 7 =
mover disco 1 : 1 –> 3
Figura 3.7 Árbol de las primeras ocho llamadas de la función recursiva.
En concreto, a cada llamada de la función (hanoi en nuestro caso, o cualquier otra función) se colocan los parámetros actuales en una estructura llamada pila de llamadas y se crea el “ambiente de la función” con los valores de los parámetros. Si al interior de la función existe alguna otra llamada de la misma función, se pone otra llamada en la pila de llamadas. Al final de la función, dicha llamada se saca de la pila de los parámetros y se regresa a la última llamada, encima de la pila. Este mecanismo de pila nos permite regresar a la función. Una imagen de la memoria que las funciones usan es:
Zona de variables estáticas
Zona de variables
Zona de variables del main f1
contexto
f2
contexto
…
Pila de llamadas Figura 3.8 Memoria de un programa. 125
Por ejemplo, hasta la primera escritura de un mensaje de movimiento en el problema de las torres de Hanoi, la pila de las llamadas se compone de: hanoi(1, 1, 3), hanoi(2, 1, 2), hanoi(3, 1, 3), hanoi(4, 1, 2)
Al final de la llamada de hanoi (1, 1, 3), se deja la pila con solo: hanoi(2, 1, 2), hanoi(3, 1, 3), hanoi(4, 1, 2)
Después, la segunda escritura de mensaje de la pila aumenta con la llamada de hanoi (1, 3, 2). El principio de una pila de llamada es que este se inserta enfrente, al igual que la extracción, que también se hace enfrente. Cuando una función de esta llamada se inserta en la pila, solo se extrae de la pila hasta que la función termina. En cualquier lenguaje, una regla muy importante de concepción de funciones recursivas es: “En el caso o los casos donde no se usa la recursión, sino una solución simple, la función debe empezar con el tratamiento del caso de terminación”. Los casos de recursividad se describen más adelante, pero es importante resaltar que estos requieren que el programador esté seguro de que el número de llamadas es finito hasta encontrar un caso “simple” sin recursividad. Por otro lado, hay técnicas de transformación de una función recursiva en una función no recursiva, que realiza el mismo cálculo (véase el libro de Jacques Arsac, citado en la bibliografía de este capítulo).
Ejemplo
El algoritmo de Euclides, expresado para la búsqueda del máximo común divisor de dos números a y b, tiene un par de aspectos explícitos de recursividad: Si a%b = 0, entonces b es el máximo común divisor. Si no, mcd(a,b)= mcd(b,a%b). Por otro lado, es claro que la función recursiva debe empezar con el cálculo de r = a%b y que la condición de separación del caso simple con el caso recursivo es r = 0(r = 0 en C). Para escribir la recursión, desde un punto de vista matemático, hay mcd(x,y) = mcd(y,x) para cualquier pareja de (x, y); entonces, ¿cuál de las llamadas usamos: mcd(r,b) o mcd(b,r)? Para obtener una respuesta a esta pregunta, primero debemos observar que siempre hay b > r, porque r es el resto de la división entera entre b, y, luego, debemos analizar qué pasa cuando es la llamada a < b. De esta forma, si a < b, entonces r = a%b = a; esto es, si las llamadas se hacen con mcd(r,b), están los mismos parámetros que la llamada corriente y todas las llamadas van a tener los mismos valores: a y b (en este orden).
mcd (a, b) r <- a llamadas 2, 3, ...
llamada 1
mcd (r, b) Figura 3.9 Esquema de una llamada. 13
La solución correcta es: int mcd(int x,int y) //maximo comun divisor de 2 numeros { Véase también la solución mcd_recursiva_error.c, la cual produce un error de ejecución porque la recursión no está correctamente escrita. 13
126
int r; r = x % y; if (r == 0) return y; else return mcd(y, r); }
En este caso, la llamada es exactamente la misma que la de la solución no recursiva: mcd(a,b). En la figura 3.10 se observan dos esquemas de llamadas de esta versión correcta de la función para valores de entrada diferente: llamada1 mcd (224, 36)
llamada1 mcd (36, 80)
llamada2 mcd (36, 8)
llamada2 mcd (80, 36)
llamada3 mcd(8,4)
llamada3 mcd(36,8) llamada 4 mcd (8, 4)
Figura 3.10 Esquema de llamadas para diferentes valores.
3.7 Ejemplos de uso de funciones recursivas En esta sección presentamos tres problemas que resolvemos con la ayuda de las funciones. Dos de estos problemas admiten fácilmente una solución recursiva; sin embargo, en el tercero la solución recursiva no es tan natural al escribir; en este caso la solución secuencial, no recursiva, es más clara y más elegante.
Escritura de un número entero positivo en base 2 Queremos leer un número entero y luego escribir su representación en base 2. Como ya se sabe, las cifras de la represen14 tación de cualquier número en base 2son una sucesión de 0 (ceros)y 1 (unos), que se obtienen en divisiones sucesivas. 14
El algoritmo de divisiones sucesivas es presentado con detalle en el apéndice 1 que se encuentra en el CD-ROM. 127
Por ejemplo: para n = 25 en base 10, queremos obtener el número en base 2: 11001 La idea de este algoritmo es hacer divisiones con 2 (la base de representación) hasta que se obtiene el 0 (cero). En cada división se guarda el residuo, que es una cifra de la representación en base 2, y el cociente, para la próxima división. Para el caso del número 25 se hacen las siguientes divisiones sucesivas:
Tabla 3.4 Valor
Cociente
Resto
25
12
1
12
6
0
6
3
0
3
1
1
1
0
1
La representación está formada por la cifra de la última columna, tomada en orden inverso de obtención, es decir, de abajo hacia a arriba, entonces tenemos: 11001. Si hacemos la primera división, dicha división sería la última si el cociente que se obtiene es 0. Por otra parte, si el cociente obtenido no es 0, se hacen los mismos cálculos para este valor del cociente. Es evidente que primero se escriben la cifras obtenidas para el cociente y luego la cifra de la primera división. La función en su forma recursiva que calcula la representación binaria es la siguiente: void cifras_entero_base_2(int x) { int y; int r; r = x%2; y = x/2; if (y != 0) cifras_entero_base_2(y); printf(“%1d”, r); return; }
La función es de tipovoid, porque el cálculo más importante es la escritura de la cifra, la cual sehace después de una eventual llamada (recursiva). La escritura se efectúa conla función estándarprintf y el formato %1d, ya que es una sola cifra. El programa principal es muy sencillo: int main() { int n; printf(“ Cual es su numero :”); scanf(“%d”, &n); printf(“ Su representacion en base 2 es : ”); cifras_entero_base_2(n); printf(“\n”); } 128
Este programa produce, por ejemplo: Cual es su numero :1028 Su representacion en base 2 es : 10000000100
Por el momento, una solución no-recursiva es más difícil, porque se requiere almacenar la cifra de los residuos, hasta que se obtienen todas las cifras. En el siguiente capítulo introducimos la noción de arreglo, que permite la resolución del problema de guardar un número variable de valores. El número de las llamadas es igual al número de cifras en base 2 del número.
Escritura de un número fraccionario en base 2 Este problema es semejante al problema anterior; aquí se busca la representación en base 2 de un número, pero el número es fraccionario, es decir que tiene el intervalo [0.0, 1.1]. En el apéndice 1 (véase el CD-ROM) se presenta un algoritmo que trabaja con multiplicaciones sucesivas hasta obtener un número suficiente de cifras. La idea de este algoritmo es que el número se multiplique por 2, que es el valor de la base, y que el resultado de la multiplicación se almacene: la parte entera que resulta ser la cifra de la representación y la parte fraccionaria que resulta ser el operando de otra multiplicación realizada con la base 2. Por ejemplo, para x = 0:1356 y 8 cifras después del punto decimal, los cálculos son los siguientes:
Tabla 3.5 Valor
Multiplicación por 2
Parte entera
Parte fraccionaria
0.1356
0.2712
0
0.2712
0.2712
0.5424
0
0.5424
0.5424
1.0848
1
0.0848
0.0848
0.1696
0
0.1696
0.1696
0.3392
0
0.3392
0.3392
0.6784
0
0.6784
0.6784
1.3568
1
0.3568
0.3568
0.7136
0
0.7136
La representación se forma con las cifras de la última columna, tomadas en orden inverso de obtención; es decir, de arriba hacia abajo. Así: 0.00100010. La función recursiva calcula la escritura del número en cuestión en base 2, de acuerdo con las siguientes etapas: Calcular el producto con 2. Tomar la parte entera que es una cifra de la representación. Llamar a la función por la parte fraccionaria restante.
Estas etapas se repiten con el número de cifras que se desea. void cifras_fraccionario_base_2(float x, int n) { 129
int c; float y; if (n == 0) return; else { c = x * 2; y = x * 2 -c; printf(“%1d”, c); cifras_fraccionario_base_2(y, n -1); return; } }
La función tiene dos parámetros porque también se requiere saber cuándo terminan las llamadas. El primer parámetro es el número de cifras que se desea obtener y el segundo parámetro es el número de cifras de la parte fraccionaria. La parte del programa principal es la siguiente: int main() { float f; int n; printf(“ Cual es su numero :”); scanf(“%f”, &f); printf(“ Cuantas cifras despues el punto decimal :”); scanf(“%d”, &n); if (f > 1 || f < 0) { printf(“ El numero no es correcto (entre 0. y 1.0)\n”); exit(1); } printf(“ Su representacion en base 2 es : ”); printf(“ 0.”); cifras_fraccionario_base_2(f, n); printf(“\n”); }
El programa produce, por ejemplo: Cual es su numero :0.6755 Cuantas cifras despues el punto decimal :20 Su representacion en base 2 es : 0.10101100111011011001
Número en espejo En este apartado se plantea el problema de obtención de la imagen de espejo de un número entero positivo. Por ejemplo, para la entrada 234, el programa produce 432. Una observación muy simple es que resulta fácil obtener las cifras del número, adaptando el mismo cálculo aplicado en el apartado anterior, para el cálculo de las cifras en base 2: 130
void cifras_entero_base_10(int x) { int y; int r; r = x%10; y = x/10; if (y != 0) cifras_entero_base_10(y); printf(“%1d ”, r); return; }
Una llamada de esta función produce, por ejemplo: Cual es su numero :9887 Su representacion en base de 10 es : 9 8 8 7 Al interior de la función recursiva, cifras_entero_base_10, las cifras se obtienen de la última hasta el inicio, que corresponden a la representación del número en espejo. También, podemos modificar la función de extracción de las cifras y añadir el cálculo paso a paso del número en espejo. Una primera solución usa una variable global, la cual está modificada en el cuerpo de la función numero_espejo: int aux; //la variable global void numero_espejo(int x) { int y; int r; r = x%10; aux=aux*10 +r; y = x/10; if (y != 0) numero_espejo(y); return; } int main() { int n; //el numero int espejo; //el numero en espejo printf(« Cual es su numero :»); scanf(«%d», &n); aux = 0; numero_espejo(n); espejo = aux; printf(« El numero en espejo es : %d.\n», espejo); }
El uso de una variable global es delicado, porque si la función va a ser utilizada por otro programador, el código de la función no resulta suficiente; también se debe declarar la variable global y no olvidar la inicialización de esta variable, antes de una variable estática, que al interior de la función podría ser una solución. Sin embargo, existen dos desventajas: El contenido de la variable no es visible en el programa principal; entonces, hay que usar un parámetro. Para una primera llamada el resultado es válido, si la variable estática es inicializada con 0; pero, para la segunda
llamada y las siguientes el resultado es falso. 131
Una solución sería el uso de un parámetro como referencia que guarde un cálculo parcial del resultado esperado. Con base en lo antes expuesto, la nueva versión de la función sería: void numero_espejo_bis(int x, int *aux) { int y; int r; r = x%10; *aux=*aux* 10 + r; y = x/10; if (y != 0) numero_espejo_bis(y, aux); return; }
El segundo parámetro es un apuntador; entonces, en la asignación se usa su contenido y para la llamada recursiva se utiliza el mismo apuntador. Desde el programa principal, la primera llamada indica una dirección de memoria. En el código del programa principal main, están presentes las dos llamadas: int main() { int n; //el numero int aux, espejo; //el numero en espejo printf(“ Cual es su numero :”); scanf(“%d”, &n); // primera llamada aux = 0; numero_espejo_bis(n, &aux); espejo = aux; printf(“ El numero en espejo es : %d.\n”, espejo); printf(“ Cual es su segundo numero :”); scanf(“%d”, &n); // segunda llamada espejo = 0; numero_espejo_bis(n, &espejo); printf(“ El numero en espejo es : %d.\n”, espejo); }
En este caso, la segunda llamada, numero_espejo_bis, usa directamente la dirección de la variable espejo.
3.8 Apuntadores de funciones En las secciones anteriores de este capítulo vimos que podemos considerar apuntadores para cualquier tipo de variable. Asimismo, en el lenguaje C se permite la definición y el uso de apuntadores de función. Sabemos que int* es el tipo de un apuntador por una variable de tipo int, y que int ** es el tipo de un apuntador para una variable de tipo int*. El tipo de una función se puede leer en su prototipo, que es: tipo_ regreso(tipos_argumentos), para las funciones que regresan un valor, y void(tipos_argumentos), para las funciones que no regresan nada. 132
Naturalmente, un apuntador de función se declara como: tipo_regreso (*nombre_apuntador)(tipos_argumento); void (*nombre_apuntador)(tipos_argumento);
El operador de asignación que se aplica es: apuntador_funcion = nombre_funcion
Donde nombre_funcion es una función conocida por el programa. Después, la asignación del apuntador de función se usa como un nombre de función y una llamada de tipo: apuntador_funcion(lista_parametros_formales) nombre_funcion(lista_parametros_formales Esta es equivalente a la llamada de función apuntada: ). Ejemplo
En este código hay tres funciones globales: dos de tipo int(int, int) y una de tipo void(). Las dos funciones que regresan un entero calculan, respectivamente, el máximo y el mínimo de dos enteros. La función de tipo void escribe una línea en la pantalla. #include
} int max (int a, int b) { if (a > b) return a; else return b; } int main(int argc, char *argv[]) { int x = 15, y = 32; int (*p)(int, int); int (*q)(int, int); void (*li)(); p = min; printf(“El minimo de %d y %d es : %d\n”, x, y, p(x, y)); li = linea; li(); 133
q = max; printf(“ El maximo de %d y %d es : %d\n», x, y, q(x, y)); }
En el programa principal main, se usan tres apuntadores de función, uno para cada función. El programa produce la salida: El minimo de 15 y 32 es : 15 ---------------------------El maximo de 15 y 32 es : 32
Un apuntador de función también puede aparecer como parámetro actual de una función, solo se requiere que se especifique con detalle el tipo del parámetro formal. Ejemplo
Si se necesita el máximo o el mínimo de tres números,15 el código de las dos funciones es muy parecido: int min3(int a, int b, int c) { return min(a,min(b,c)); } int max3(int a, int b, int c) { return max(a,max(b,c)); }
Las funciones min3 y max3 usan, respectivamente, las funciones min y max anteriormente presentadas. Podemos construir una función genérica capaz de recibir por parámetro la función min o la función max. Su código sería el siguiente: int extremo3(int a, int b, int c, int (*compar)(int,int)) { return compar(a, compar(b,c)); }
El parámetro formal compar, es de tipo apuntador de función, con dos parámetros de tipo int regresando un valor de tipo int. En el código del programa principal se hacen dos llamadas de la función extremo3, indicando por un parámetro ya sea la función min o la función max. int main(int argc, char *argv[]) { int x = 15, y = 32, z = 17; //llamadas clasicas printf(«el minimo de %d, %d y %d es : %d\n», x, y, z, min3(x, y, z)); printf(«el maximo de %d, %d y %d es : %d\n», x, y, z, max3(x, y, z)); //llamadas de la misma funcion con un parametro de tipo apuntador de funcion printf(« El minimo : %d\n», extremo3(x, y, z, min)); printf(« El maximo : %d\n», extremo3(x, y, z, max)); } El problema general de búsqueda de mínimo o máximo por un número N de números, se tratará con detalle en el último capítulo de este libro. 15
134
La salida de este programa es: el minimo de 15, 32 y 17 es: 15 el maximo de 15, 32 y 17 es: 32 El minimo : 15 El maximo : 32
3.9. Funciones con número variable de parámetros El compilador C ofrece una biblioteca estándar que permite trabajar, definir y llamar funciones con un número variable de parámetros. El prototipo de una función con número variable de parámetros es el siguiente: tipo_funcion nombre_funcion(tipo_parametro1 parametro1, ...)
Solo se indica el primer argumento con su tipo seguido de tres puntos. La biblioteca estándar que se usa para tratar la función de estas características es stdarg.h. En esta biblioteca se encuentran: Una definición del tipo va_list es un apuntador que sirve para iterar los parámetros. Este imperativo indica que
en la función hay una variable de este tipo. Tres macro-directivasdefine16 con parámetros permiten moverse en la lista de parámetros: va_start que permite iniciar el recorrido de los argumentos (parámetros formales) de la función. Se usa con
dos parámetros: la variable local de tipo va_list apuntador y el nombre del primer argumento de la función. Se produce un valor del tipo tipo_parametro1. va_arg se aplica al apuntador y permite detectar el siguiente valor de argumento. Se usa con dos parámetros: el
apuntador y el tipo del argumento que puede ser int, char, char* o double o apuntadores de estos tipos.
va_end que se aplica al apuntador y permite liberar correctamente la zona de memoria ocupada por los argu-
mentos. La cuarta directiva va_copy crea una copia de un apuntador a otro, para el caso donde se necesita más de un recorrido de lista de argumentos. Existen dos filosofías para trabajar con funciones con un número variable de parámetros: El primer argumento es siempre un valor entero que indica el número de valores que siguen en la lista El último argumento es un valor especial llamado “centinela” y la detección de este valor indica que es el fin.
Si queremos calcular la medida de un número variable de valores flotantes (doble precisión) los prototipos de las funciones serian: double medida(int n, ...) double medida_centinela(double x, ...)
y las llamadas serian : medida(5, 2.2, 1.1, 3.3, 4.4, 6.6); medida_centinela(2.2, 1.1, 3.3, 4.4, 6.6, −1.1);
con −1.1 como valor centinela. 16
Ver el capítulo 2 sobre el funcionamiento de las macro directivas con parámetros. 135
El programa que define y utiliza estas funciones es: #include
medida_centinela(2.2, 1.1, 3.3, 4.4, 6.6, -1.1)); printf(“Medidas excepcionales %lf, %lf.\n”, medida(0), medida_centinela(-1.1)); exit(0); }
En las dos funciones hay el mismo tratamiento que en el caso donde se puede producir un error grave de cálculo: la división entre 0 cuando no hay valores para calcular. En las dos funciones el recorrido inicia con la macro-directiva va_start y termina con la macro-directiva va_end. Entre los dos macro-directivas hay una estructura iterativa (for o while) que permite de recorrer la lista de los argumentos. El programa produce: El primer parametro vale : 5 El valor del parametro es : 2.200000 El valor del parametro es : 1.100000 El valor del parametro es : 3.300000 El valor del parametro es : 4.400000 El valor del parametro es : 6.600000 La medida es 3.520000. La otra medida es 3.520000. El primer parametro vale : 0 Medidas excepcionales 0.000000, 0.000000.
En el siguiente ejemplo las funciones de medida regresan un solo valor y los parámetros son transmitidos por valor. Una función con números variables de parámetros también puede tener parámetros transmitidos por dirección. En este caso se construye con la llamada de va_arg, se construye un apuntador y se usa este apuntador para poner valor a la dirección referenciada. Ejemplo
En este programa hay una función regresa1 que pone 1 en todas las variables transmitidas por referencia. #include
int main(int argc, char *argv[]) { int a, b, c, d; regresa1(2, &a,&b); printf(« a= %d b=%d\n», a, b); regresa1(0); regresa1(4, &a, &b, &c, &d); printf(« a= %d b=%d c=%d d=%d\n», a, b, c, d); exit(0); }
Los parámetros transmitidos por referencia no tienen nombres explícitos, el acceso se hace al interior de la estructura iterativa: x = va_arg(ap, int*); *x = 1;
El apuntador x toma el valor transmitido por referencia que es una dirección de variable. El programa produce: a = 1 a = 1
b = 1 b = 1
c = 1
d = 1
Síntesis del capítulo El lenguaje de programación C permite el acceso directo a una celda de memoria con la implementación de la noción de apuntador de memoria. De esta forma, el apuntador permite cambiar directamente el contenido de memoria; asimismo, también permite a las funciones un modo de transmisión de los parámetros por valor. El lenguaje C implementa el procedimiento o el código de llamada desde el código corriente, que se traduce por la noción de función. El programador (el usuario) tiene la posibilidad de definir y utilizar sus propias funciones o utilizar funciones predefinidas que se encuentran en las librerías estándares. Estas funciones estándares abarcan operaciones corrientes u operaciones más especiales, como las entradas y salidas, el uso de algunos valores matemáticos calculados, la transformación de tipos y otras más. Las dos principales funciones de entrada y salida son las siguientes: scanf y printf. Las funciones definidas por el usuario tienen un nombre, un tipo del valor regresado y una lista ordenada de parámetros, de los cuales, cada parámetro tiene un tipo. Al momento de la llamada de la función, se indica el nombre de dicha función y los valores de los parámetros; estos valores deben corresponder en nombre y en tipo con la lista de parámetros de la definición de la función. Si el tipo de los parámetros es estático (un valor contenido en la memoria), dichos parámetros se transmiten por valor; en cambio, si el parámetro es un apuntador, la transmisión se hace por dirección. En el caso de los parámetros transmitidos por valor a la salida de la función, los valores no cambian. Los valores de los parámetros cambian únicamente si la transmisión de estos valores se hace por dirección. Para utilizar una función estándar o una función definida por el usuario, primero se debe conocer el prototipo de la función: su nombre, su tipo y la lista ordenada de los tipos de los parámetros. Al interior de una función es posible llamar otras funciones, incluso se puede llamar la función misma. En este caso, se habla de una función recursiva. La elaboración de funciones recursivas es más delicada porque se necesita tomar en cuenta todos los casos a tratar y estar seguro de que la función termina. 138
Bibliografía Kernighan, Brian W. y Ritchie, Dennis M.,El lenguaje de programación C, 2a. edición Pearson Educación, México, 1991. Arsac, Jacques, Las bases de la programación, Ediciones Omega, Barcelona. Foundations of Programming. Studies
in Data Processing, Academic Press Inc., Estados Unidos, 1985.
Referencias de Internet El software gcc se puede bajar desde: http://gcc.gnu.org/ Lista concentrada con los principales comandos de Unix/Linux: http://www.retronet.com.ar/linux.pdf Página de Wikipedia sobre la recursividad: http://es.wikipedia.org/wiki/Recursión Página de Wikipedia sobre el problema de las torres de Hanoi: http://es.wikipedia.org/wiki/Torres
Ejercicios y problemas 1. Usando variables globales, hacer un programa que lea un valor entero N y luego lea N valores flotantes y, después de cada lectura de un flotante, realice dos sumas que sirvan para calcular: El promedio de los N valores. La desviación estándar.
2. Escribir un programa que lea dos valores de tipo float, y que luego realice la suma de los dos, pero sin usar directamente una escritura de forma a + b, sino que use apuntadores para estos. 3. Hacer un programa que lea tres valores flotantes: x, y y z, y que calcule el siguiente valor: 3
2 × sen( x )cos( y )
El programa utiliza la librería math.h.
4. Escribir un programa que tome como entrada dos números flotantes y los interprete como la parte real y la parte imaginaria de un número complejo (representación binómica). El programa debe: Calcular el valor absoluto del número complejo y el argumento de la representación polar. Verificar si el número no es imaginario puro. Calcular el conjugado del número.
139
5. Escribir una función de conversión de temperatura de grados Fahrenheit a grados Celsius, según la fórmula: °C = (5/9)(°F−32) Escribir una segunda función que realice la conversión inversa: de grados Celsius a grados Fahrenheit.
Prototipos: float convFC(float gradosF) float convCF(float gradosC) Realizar, en el programa principal, la lectura de valores para la conversión y llamadas de las dos funciones. Introducir en el programa principal una prueba que verifique si una sucesión de dos conversiones convFC
(convCF(x)) o convCF (convFC(x)) deja su argumento constante o muy cerca de su valor inicial.
6. Escribir un programa que lea un número variable de valores flotantes y que para cada uno de estos valores calcule su parte entera y su parte fraccionaria. El programa debe escribir cada número y sus valores de parte entera y parte fraccionaria usando una escritura de forma: 12.45 = 12 + 0.45
7. Escribir una función que tome como entrada un número flotante positivo x y regrese el valor y = x – 2k con 0 y < 2. 8. Calcular, con la ayuda de una función, valores de tipo 2k, con k variable de 0 hasta 30. El prototipo de la función es el siguiente: int potencia2(int k); void (que una función de tipo no el regresa nada) y cambiar el llamada contenido dosfunción. enteros, si estos no 9. Escribir se encuentran en orden creciente. Escribir programa que hace una dede esta
10. Escribir una función que verifique si un número entero n es de forma ak, con a una constante entera positiva. La función regresa 0 o 1. 11. Escribir una función intprimo (int n) que verifique si el número n es primo o no. 12. Con la ayuda de la función del punto precedente, escribir una función int gemelos (int n, int m) que verifique si m y n son gemelos; donde cada uno es primo y m = n + 2. 13. Escribir una función int suma_divisores (int n) que regrese la suma de los divisores propios del número n incluido el 1 y excluido el n. Por ejemplo, la suma de los divisores propios de 54 es: 1 + 2 + 3 + 6 + 9 + 18 + 27 = 66. 14. Números amigos. Se dice que dos números son amigos si la suma de los divisores propios de cada número es igual al otro número.Por ejemplo, 220 y 284 son amigos: 1 + 2 + 4 + 5 + 10 + 11 + 20 + 22 + 44 + 55 + 110 = 284 1 + 2 + 4 + 71 + 142 = 220
140
Usando la función suma_divisores escribir la función:
int numeros_amigos(int a, int b);
que regresa 1, si los números son amigos, y 0, si no lo son. Verificar, por programa, si dos números son amigos o no.
15. Potencias. Solución recursiva. Proponer una solución recursiva de la función que calcula a N, basándose en las siguientes formulas:
a0 = 1 a 2K = a K × a K a2K+1 = a × aK × aK Dibujar una gráfica de llamadas por un valor a > 1 y N = 17. Introducir al código una variable global:int paso, que se incrementa en cada llamada de la función
potencia_recursiva y que al final se escribe el número total de llamadas. Escribir una solución óptima desde el punto de vista del número de las llamadas.17
16. Matemático. Con base en el problema de las torres de Hanoi: Demostrar que la solución propuesta realiza exactamente 2N –1 movimientos. Demostrar que este número es el número mínimo de movimientos necesarios en la solución. 17. De acuerdo con la solución propuesta de las torres de Hanoi, modificar la llamada de la función:
printf(“mover el disco *%d*de la estaca % da la estaca %d n”,N, f, d);
para dibujar el disco N con N caracteres ”*” o ”#”.
Sugerencia: Hacer dos funciones. Una función que escriba el texto nada más. Una función que sea capaz de imprimir un número variable (indicado por parámetro) de un carácter.
18. Una versión (menos conocida) del algoritmo de Euclides consiste en hacer sustracciones sucesivas del número más grande al número más pequeño, hasta que se obtiene 0. Los operandos (que son iguales) representan el máximo común divisor. Por ejemplo, para buscar el máximo común divisor de 90 y de 162 se realizan las sustracciones (diferencias) sucesivas (véase la tabla 3.6).
17
Se puede mostrar que este número es: log 2N.
141
Tabla 3.6 Más grande
Más pequeño
Diferencia
a
B
90
162
162
90
72
90
72
90
72
18
72
18
72
18
54
18
54
54
18
36
18
36
36
18
18
18
18
18
18
0
En este caso, el máximo común divisor es 18. Se pide: Escribir una función no recursiva que implemente este algoritmo. Escribir una función recursiva para el algoritmo. Hacer pruebas de corrección (varias ejecuciones con diferentes valores) del programa.
19. Utilizando una llamada de la función que calcule el máximo común divisor, hacer una función que compruebe si dos números enteros son primos entre ellos (considerando que únicamente tienen un común divisor: 1). El prototipo de la función es: int primos_entre_si(int a, int b); 20. Un número es un palíndromo si podemos hacer su lectura de izquierda a derecha y a la inversa, de derecha a izquierda, obteniendo el mismo valor. La representación decimal del palíndromo tiene un centro de simetría. Por ejemplo, 363, 10001 son palíndromos y 30306 no es un palíndromo. Escribir una función de prototipo: int palindromo (int n);
que regrese el valor 1 si el número es un palíndromo y 0 si no lo es.
21. Difícil. Adaptar el programa que calcula la factorización de números primos de un número entero, para obtener al final, en la salida, una escritura de forma: 2ˆ2
3ˆ3
5ˆ1
por el número 54, por ejemplo. Los números de3,la 8, sucesión describen crecimiento natural dematemáticas: varias formas de vida. La suce22. sión es: 1, 1, 2, 13, 21,de ... yFibonacci se describe con laselsiguientes definiciones
142
F1 = 1 F2 = 1 Fn+2 = Fn+1 + Fn Escribir una función recursiva de prototipo:
intfib(intn)
para el cálculo de un número de Fibonacci y escribir la parte de main que llame a esta función.
23. Escribir una nueva versión del programa del juego presentado en el capítulo 2, que se refiere a la búsqueda de un número, implementando cada funcionalidad por una función. Por ejemplo: Para la lectura de una propuesta del número. Para la escritura de respuestas. Para la escritura de una sugerencia. Para calcular la respuesta de 1, 0 o −1. Por el cálculo de la mitad del intervalo. Por el ajuste del intervalo.
24. Cálculo de Cnk. k
n
En álgebra, la notación Cn o (k ) corresponde a un coeficiente binomial que se calcula según la fórmula: n! (kn) = k! (n – k)! Donde: n y k son enteros positivos con k n. El triángulo de Pascal: 1 11 121 1331 14641 1 5 10 10 5 1 1 6 15 20 15 6 1 1 7 21 35 35 21 7 1 ...
143
es la expresión de la fórmula recursiva: =−1+−1−1 Sabemos que: 0 = = 1. Escribir una función recursiva que use estas fórmulas para el cálculo de (kn).
25. Hacer un programa que escriba el triángulo de Pascal con una altura de N (máximo 20), usando la función escrita anteriormente. La escritura debe hacerse de acuerdo con la forma presentada en el problema anterior (difícil) o en la forma más simple: 1 11 121 1331 14641 ...
26. Dibujar la gráfica de las llamadas para n = 6 y k = 3. ¿Cuál es el número total de llamadas de la función recursiva en caso general? 27. Resuelve los siguientes puntos: a) Escribir el prototipo de una función de nombre producto_entero que regrese un entero y tenga dos parámetros transmitidos por valor de tipo float. b) Escribir la definición de un apuntador de función con el nombre pfunc, a través del cual podamos hacer la asignación: pfunc = producto_entero;
c) Escribir el código de la función producto_entero que regrese el producto de las partes enteras de los valores de parámetros. d) Escribir el programa principal que haya dos llamadas de la función producto_entero: Una llamada clásica producto_entero(?). Una llamada usando el apuntador pfunc.
e) Realizar el ejercicio anterior, pero en lugar de producto_entero, trabajar con la función producto_entero_cambio con dos parámetros flotantes transmitidos por referencia, que haya también el producto de las partes enteras y, además, cambie el contenido de cada parámetro por su parte entera. 28. “Siempre vale 1” o Conjetura de Collatz. Efectuar el siguiente juego con dos jugadores. Al inicio del juego, el primer jugador propone un número N, N > 1, mientras que el otro hace el cálculo, con base en las siguientes reglas:
144
Si el número N es par, se calcula N/2. Si el número es impar, se calcula 3N + 1.
Si el segundo jugador obtiene 1 por su cálculo, gana. De lo contrario, toca el turno al otro jugador, que debe hacer el mismo cálculo; el juego continúa hasta que alguno de los jugadores obtiene 1.
a) Escribir una función no recursiva que calcule el siguiente valor. b) Escribir un programa para correr el juego (escribir la lista de los números sucesivos obtenidos) y el nombre del jugador que gana. c) Escribir una función recursiva: int juego_siempre_1(int N, int jugador)
que corre el juego hasta su término (obtener 1).
d) ”Siempre gano yo”. De acuerdo con el juego anterior, hacer un programa que calcule y escriba todos los números (hasta una límite L), con los cuales el jugador 1 siempre gane.
29. Función 91 de MacCarthy. Escribir un programa que contenga una función recursiva capaz de calcular la siguiente función matemática:
N − 10 si N >1 f91( N ) f91( f91( N + 11)), sino 30. Escribir una función con un número variable de parámetros (el último es siempre 0, valor centinela que no se toma en cuenta) para calcular el máximo común divisor de varios números enteros. 31. Escribir una función con un número variable de parámetros de prototipo: int recuento_permutaciones(int i, ...)
que regresa el recuento de las permutaciones de n o el recuento de las permutaciones de n en k ( k < n números enteros).
32. Escribir una función con número variable de parámetros de prototipo. int es_permutacion(int n, ...)
que verifica que los n argumentos entregados después de n forman o no una permutación de los números 1; 2; …, n, la función regresa 0 o 1.
33. Dados. Escribir una función de prototipo:
int dado()
145
que regresa un valor aleatorio entre 1 y 6 con la probabilidad 1/6. La función es una simulación de la caída de un dado en forma de cubo. Hacer un programa que realice un número importante de llamadas a su función dado y que verifique que
los valores generados son equiprobables. Escribir una función de prototipo:
int n_dado(int n, ...)
que regresa n valores de salida entre 1 y 6 (la función simula la caída simultánea de n dados).
34. Juego “Adivina equivalencia”. El juego es el siguiente: hay dos jugadores, dos vasos que pueden contener piedras (o granos). El jugador 1 elige en cada vaso dos números de piedras. El jugador 2 intenta adivinar proponiendo sacar k piedras de un vaso (izquierdo o derecho) y ponerlas en el otro vaso. El jugador 1 tiene cuatro respuestas posibles: “No hay”: la operación de transvase no es posible porque el número de piedras del vaso fuente no es
suficiente. “Equivalencia”: la operación de transvase se termina con éxito y hay igualdad. “Es más”: en el vaso destinación hay más piedras que en el vaso fuente. “Es menos”: en el vaso destinación hay menos piedras que en el vaso fuente.
El juego termina cuando el jugador 2 adivina la equivalencia antes de 10 pasos; si después de 10 pasos no adivina, jugador pierde. Se un número constante (100) de piedras por total, pero en los vasos el jugador 1el pone los2números detoma quiere.
a) Proponer y escribir funciones útiles por una interfaz del juego: para la lectura de una proposición. (¡Cuidado! hay que proponer un valor y un sentido). para la lectura de una respuesta de jugador que tiene los vasos. para el cálculo automático de una respuesta.
b) Escribir un programa donde la computadora es el jugador 1 con las piedras (el programa genera dos números aleatorios y las respuestas). c) Escribir un programa donde el jugador 2 es la computadora. La elección de la proposición se hace de forma aleatoria. d) Escribir un programa donde el jugador 2 es la computadora y siempre gana en un máximo de 10 pasos.
146
4
Contenido 4.1 Arreglos y matrices Arreglos unidimensionales Asignación dinámica de memoria para los arreglos Matrices Problemas resueltos Juego del gato 4.2 Caracteres y cadenas de caracteres Tipo carácter Cadenas de caracteres Funciones estándares para el manejo de caracteres y cadenas de caracteres Problema resuelto 4.3 La función main 4.4 Archivos
Síntesis del capítulo Bibliografía Ejercicios y problemas
Objetivos lenguaje C. cadenas de caracteres. main y escribir programas con parámetros en línea de comandos. caracteres.
4.1 Arreglos y matrices En el trabajo de programación es muy probable que se requiera tratar con conjuntos de datos de manera unitaria, donde el tamaño del conjunto resulta muy importante. El arreglo es una estructura de datos que la mayoría de los lenguajes imperativos implementa. En el lenguaje C se permite la definición y el uso de arreglos tanto unidimensionales como multidimensionales; también se permite la asignación de memoria, ya sea estática o dinámica. En esta sección presentamos, de manera gradual, la noción de arreglo estático de tipos escalares, las nociones de la asignación dinámica y la asignación estática, el uso de arreglos de apuntadores y la definición y el uso de las matrices (o arreglos multidimensionales).
Arreglos unidimensionales Declaración y utilización
Un arreglo unidimensional posee un nombre (un identificador), un tipo y una dimensión. El nombre de un arreglo es un identificador del programa, el cual debe ser el único al interior del bloque de definición. El tipo, por su parte, es un tipo escalar estándar, un tipo apuntador o cualquier otro tipo definido por el programador; en este caso, se excluye el tipo void. La dimensión es un número entero positivo que indica el tamaño del arreglo. Un arreglo es un conjunto de dimensión de celdas de memoria capaz de guardar valores del tipo indicado, los cuales son guardados en un espacio contiguo de memoria. Cada elemento del arreglo corresponde a una de las celdas y tiene un número de orden único entre 0 y dimension-1. La definición de un arreglo se hace de la misma manera que las definiciones de otras variables del programa, es decir, al inicio de las funciones y de los bloques de programas. La forma de la definición de un arreglo de una dimensión conocida es la siguiente: tipo nombre_arreglo[dimension]
El tipo debe ser un tipo estándar o un tipo definido por el usuario y conocido por el compilador al momento de la declaración. En tanto, la dimensión debe ser un valor constante de tipo entero (y positivo). Para acceder a los valores contenidos en el arreglo se escribe: nombre_arreglo[indice]
Donde indice debe ser una expresión entera que se evalúa por un valor entero de 0 hasta dimension-1. Un elemento de un arreglo puede aparecer en cualquier expresión (aritmética, de asignación o de cualquier otro tipo), como si fuera una variable del tipo indicado del arreglo.
Ejemplo int i, j; int itab[10]; float atab[10]; i = 0; itab[i] = 5; atab[i] = sqrt((float)itab[i if (atab[i] + 2 > itab[i]) printf(“...”); ... 148
itab es un arreglo de enteros representados sobre 4 bytes yatab es un arreglo de
flotantes de simple precisión.
Ejemplo #include
El arreglo pint contiene direcciones de enteros (apuntadores) que se tratan como cualquier apuntador. El programa produce: La direccion de a : 0x7fff5fbff99c El elemento 0 del arreglo : 0x7fff5fbff99c y el contenido apuntado : 5
Para acceder a la referencia de un elemento (por ejemplo, para utilizar un elemento de arreglo como parámetro transmitido por referencia o para introducir un valor con la función estándar scanf) se utiliza el apuntador del elemento: &nombre_arreglo[indice]
Ejemplo
En el siguiente programa existe la declaración de un arreglo de enteros de dimensión 10, asignaciones de valores a algunos de los elementos del arreglo y la llamada a una función scanf: /* programa con una declaracion de arreglo y varios indices */ #include
5; 1; El valor de indice %d es : %d\n”, 1, tab[1 El valor de indice %d es : %d\n”, i, tab[i
printf(“Introducir el valor de indice 8 :”); scanf(“%d”, &tab[8 149
printf(“ El valor de indice %d es : %d\n”, (i + 1) * (i + 3) + 5, tab[(i + 1) * (i + 3) + 5 }
La salida del programa es: El valor de indice 1 es : 1 El valor de indice 0 es : 5 Introducir el valor de indice 8 : 78 El valor de indice 8 es : 78
Como se puede observar en el ejemplo anterior, únicamente usamos los elementos por los cuales los valores fueron afectados, ya sea poreluna de asignación o porde la función lectura. Siesseunusa un elemento sin ningún valor afectado anteriormente, valoroperación que se obtiene de la celda memoriadeasignada valor indeterminado (desconocido) que puede inducir errores en el cálculo del programa.
Ejemplo
En el programa precedente se eliminan las dos asignaciones y la lectura del elemento tab[8], y solo se almacenan las escrituras: ... int tab[10]; int i=0; printf(“ El valor de indice %d es : %d\n”, 1, tab[1 printf(“ El valor de indice %d es : %d\n”, i, tab[i printf(“ El valor de indice %d es : %d\n”, (i + 1) * (i + 3) + 5, tab[(i+ 1) * (i + 3) + 5 ...
Por tanto, la salida del programa es: 1 El valor de indice 1 es : 0 El valor de indice 0 es : 0 El valor de indice 8 es : 1606416808
La causalidad provoca que algunos valores sean cero; no obstante, también existen otros valores además del cero. En este caso, siempre se utilizan elementos del arreglo que fueron inicializados con valores (ya sea por asignación o por lectura). Es posible hacer una asignación inicial de valores por todos los elementos del arreglo: tipo nombre_arreglo[dimension] = {valor, valor, ... valor};
En este tipo de asignación, los valores indicados se afectan uno a uno, empezando con el primer elemento, de índice 0.
Ejemplo
Por el programa siguiente: /* programa con una declaracion y asignacion inicial de arreglo */ #include
150
Esta salida es dependiente del sistema operativo y del momento de ejecución del programa.
int main(int argc, char *argv[]) { int i, j; int tab[10]={10, 20, 30, 40, 50, 60, 70, 80, 90, 10}; i = 0; printf(“ El printf(“ El printf(“ El (i + 1) *
valor de indice %d es : %d\n”, 5, tab[5 valor de indice %d es : %d\n”, i, tab[i valor de indice %d es : %d\n”, (i + 3) + 5, tab[(i+ 1) * (i + 3) + 5
}
La salida es la siguiente: El valor de indice 5 es : 60 El valor de indice 0 es : 10 El valor de indice 8 es : 90
La forma tab[10] = {10, 20, 30, 40, 50, 60, 70, 80, 90, 10} aparece únicamente en la parte de declaración de variables. Si durante el programa es necesario dar valores a los elementos del arreglo, solo se utiliza el operador de asignación: tab[0] = 10; tab[1] = 20; tab[2] = 30; ...
Si la lista de los valores para la asignación inicial es más corta que la dimensión, la asignación se hace hasta el último elemento del arreglo que puede ser inicializado. Pero, si la lista de asignación es más larga, el compilador señala con mensajes explícitos (warning, en inglés) y sin producir error, y la asignación no utiliza los últimos elementos de la lista.2 En la declaración del arreglo con una asignación inicial es posible no indicar la dimensión del arreglo: tipo nombre_arreglo[] = {valor, valor, ... valor};
En este caso, se calcula el número de valores de la lista de asignación y este valor (que es un número constante) se toma como la dimensión del arreglo. Por ejemplo: int itab[] = {10, 0, 10, 0, 10};
En este caso, el arreglo itab tiene cinco celdas. El índice que se utiliza para acceder a un elemento del arreglo, según la semántica del lenguaje, debe ser un valor entero entre 0 y dimension −1 (es decir, un valor constante). El compilador del lenguaje solo hace una verificación del tipo del valor de índice (tipos enteros: char, short, int, long), pero no realiza las verificaciones del rango.
Ejemplo Para un arreglo de dimensión 10, se usan varios índices (correctos o no semánticamente), los cuales son valores constantes al momento de la compilación o valores calculados. La compilación se realiza sin ningún mensaje de error o de advertencia: /* programa con una declaracion de arreglo y varios indices correctos o no */ #include
151
#include
}
i = 0; printf(“ printf(“ j = (i + printf(“ printf(“
El valor El valor 11) * (i El valor El valor
de indice %d es : %d\n”, 1, tab[1//indice correcto de indice %d es : %d\n”, i, tab[i//indice correcto + 3) + 5; de indice %d es : %d\n”, j, tab[j//indice incorrecto de indice %d es : %d\n”, -1, tab[-1//indice incorrecto
Por tanto, el programa produce: El El El El
valor valor valor valor
de de de de
indice indice indice indice
1 es : 3 0 es : 3 38 es : 1606417261 -1 es : 1
En un primer ejemplo completo de trabajo con un arreglo, se trabaja con un arreglo de cinco elementos, a fin de realizar la suma y escribir los valores de la suma y del promedio de los cinco valores. /* programa que calcula la suma y el promedio de 5 valores flotantes */ #include
En este caso, la variable suma se inicializa con 0 antes de la estructura iterativa. En la primera estructura iterativa, la variable suma contiene las sumas parciales de los elementos del arreglo. Al final, esta variable contiene la suma de todos los elementos. Por su parte, la segunda estructura iterativa sirve para escribir elemento por elemento. La salida del programa es la siguiente: Mi arreglo es : 2.000000 6.000000 18.000000 25.000000 9.000000 La suma de los valores es :60.000000 El promedio de los valores es :12.000000 Dimensión de un arreglo y su asignación de memoria
El arreglo del ejemplo anterior se define sin dimensión explícita, pero con una lista de asignación inicial. La dimensión del arreglo es de 5 y aparece explícitamente en las expresiones condicionales de las proposiciones for y el cálculo del promedio. Si deseamos trabajar con una dimensión de arreglo diferente, el programa necesita más de una modificación. Por otro lado, trabajar en el programa con el valor explícito de la dimensión puede generar errores de tecleo (o sea, errores de dedo). El consejo es utilizar una constante para introducir la dimensión del arreglo. Los dos tipos de constantes útiles son: Una constante definida con una directiva de # define. Una constante definida en una zona de memoria.
Nuestro programa de cálculo de suma y promedio es resultado de la versión con la constante de sustitución: #define DIMENSION 5 #include
Por su parte, la versión con una constante asignada en memoria es: 153
#include
La constante asignada en memoria no es de utilidad en la declaración del arreglo para indicar la dimensión. Por tanto, la dimensión de un arreglo puede ser: Una constante entera explícita: float a[10] Una constante entera definida con una directiva # define:
# define MAX 10 ... float a[MAX]; Un valor implícito igual al tamaño de la lista de inicialización (asignación inicial):
float a[]={1., 2., 2.2,0.5}; arreglo con 4 valores
La asignación de memoria para las variables simples del programa se hace al mismo tiempo que la asignación de memoria para el arreglo. En este caso, se asigna una zona de memoria contigua con un número de dimensión celdas; una de celda de memoria es capaz de guardar un valor del tipo del arreglo. Así, de acuerdo con el ejemplo anterior: int tab[10]={10, 20, 30, 40, 50, 60, 70, 80, 90, 10};
Se asignan 10 celdas de memoria para guardar enteros representados sobre 4 bytes. La imagen de la memoria es la siguiente: 154
dirección
valor
índice
ffbffad0
10
0
ffbffad4
20
1
ffbffad8
30
2
ffbffadc
40
3
ffbffae0
50
4
ffbffae4
60
5
ffbffae8
70
6
ffbffaec
80
7
ffbffaf0 ffbffaf4
90 10
8 9
La zona de memoria asignada por un arreglo que es contigua tiene, entonces, un tamaño de dimensión × tamaño (tipo). En el caso de que la dimensión real del arreglo no sea conocida al momento de la elaboración del programa, deberá tomarse en cuenta una dimensión máxima. En este caso queda claro que si la dimensión real es mucho más baja, esta limita al máximo el espacio de memoria asignado, hasta que el límite se pierde. En el siguiente programa se guardan, en un arreglo de tipo flotante, los valores de cos(i), para i de 1 hasta n; donde n es un valor introducido por el usuario; el programa también calcula el promedio de estos valores; así, se supone que n esté entre 1 y 100. /* programa que hace el promedio de los valores cos(i)/i, i = 1, N */ #include
/* escribir el promedio */ printf(“ El promedio de los valores del arreglo es :%f\n”, promedio); exit(0); }
La primera parte de este programa verifica si la dimensión respeta los límites impuestos. Arreglos como parámetros de función
En el ejemplo anterior se trata un problema que ya fue resuelto antes: el cálculo del promedio de un arreglo. Para simplificar el trabajo del programador, resulta conveniente escribir una función capaz de calcular el promedio de cualquier arreglo, donde lógicamente se debe transmitir la dimensión del arreglo y el arreglo. Para la transmisión de la dimensión, es posible, sin duda puede hacerse esto. alguna, transmitir un entero; en cambio, para la transmisión del arreglo, se debe determinar cómo Por otro lado, al momento de la asignación de la zona de memoria, por el arreglo, el identificador del arreglo resulta compatible con un apuntador del mismo tipo que el del arreglo y la escritura arreglo [indice] es equivalente con una operación: *(arreglo + indice): el contenido apuntado por un apuntador calculado.
Ejemplo En el siguiente programa se trata el arreglo como el apuntador al primer elemento y, luego, se trata la suma del arreglo con un entero como un apuntador. /* programa que trata un arreglo como un apuntador */ #include
Por tanto, el programa produce: El primer elemento 1.100000 El elemento de indice 3 es 4.400000
Es posible transmitir un arreglo como un parámetro de función; como el arreglo puede ser considerado un apuntador, la transmisión se realiza por referencia, ya que los elementos del arreglo pueden ser modificados al interior de la función y las modificaciones son definitivas. En el prototipo de la función que tiene como parámetro un arreglo, se indican el tipo y el nombre del arreglo, pero la mención de la dimensión no es obligatoria. Hay dos formas de indicar el tipo y el nombre del arreglo: tipo nombre_arreglo[dimension] tipo nombre_arreglo[]
Ambas formas son equivalentes. En la llamada a la función se indica únicamente, como parámetro actual, el nombre del arreglo u otra expresión equivalente a un arreglo. 156
Ejemplo
A continuación se presenta un ejemplo donde se observa la suma de los elementos de un arreglo de valores flotantes con una función que recibe el arreglo por valor. #include
En el prototipo de la función aparece el tipo del arreglo y la dimensión fija; en la llamada se indica, como parámetro actual, únicamente el nombre del arreglo. El prototipo de la función puede ser escrito también de la siguiente forma: float suma(float A[], int N)
Para la función suma, se sabe que el tamaño útil del arreglo que se utilizó fue únicamente de 4. Con esta misma función, es posible hacer la suma de los últimos dos elementos: suma(&f[2], 4)
La transmisión de los parámetros se hace claramente por referencia de una función que lee, uno a uno, los elementos de un arreglo: void lectura_arreglo(float X[], int N) { int i; for ( i = 0; i < N; i++) scanf(“%f”, &X[i }
Si se quiere que el contenido del arreglo no sea modificado a la salida de la función, se introduce la palabra clave const en la parte de declaración: const tipo nombre_arreglo[dimension] const tipo nombre_arreglo[]
En este caso, si al interior de la función los elementos del arreglo cambian por falta de un operador de asignación o una lectura de valor con scanf, aparecen mensajes implícitos de advertencia al momento de la compilación. 157
Ejemplo
Si cambiamos la función de lectura del arreglo, por ejemplo: void lectura_arreglo_const(const float X[], int N) { int i; for ( i = 0; i < N; i++) scanf(“%f”, &X[i }
Aparece un mensaje explícito de advertencia momento de la compilación en la línea que contiene el scanf. En manuales de referencia del lenguaje C, por lo general se indica que un tipo apuntador es completamente equivalente al tipo arreglo sin dimensión, entonces el prototipo de la función de lectura también se puede escribir como: lectura_arreglo(float *f, int N)
Pero, para la comprensión del código por parte de cualquier otro programador, es conveniente indicar la primera forma: lectura_arreglo(float f[], int N)
Asignación dinámica de memoria para los arreglos En la sección anterior se estudió cómo los arreglos se asignan en la memoria de forma estática. Pero, también es posible aplicar otra forma de asignación de memoria, llamada asignación dinámica. Si no se conoce el tamaño de un arreglo y el límite posible es muy variable o si el espacio dedicado al programa es limitado,3 tomar un límite superior posible no es realista. Por tanto, se debe trabajar con arreglos asignados de manera dinámica. Funciones para la asignación dinámica de memoria
Para cada tipo de datos, ya sea de tipo estándar o de tipo definido por el programador o definido en bibliotecas, corresponde un espacio (un lugar) de la zona de memoria con una paridad de su dirección, capaz de recibir un valor del tipo indicado. El lenguaje C ofrece un operador unario sizeof, que puede ser aplicado a las variables (simples o arreglos) y a los tipos y regresa el tamaño necesario para almacenarlo. El valor regresado por el operador sizeof es un entero sin signo representado sobre 4 u 8 bytes, según el compilador; su tipo está definido como size_t. El operador se escribe como sigue: sizeof(tipo) sizeof(variable)
A continuación se presenta un ejemplo de aplicación del operador sizeof: #include
3
158
int *p; int a; double f; int x[10];
Por ejemplo, para los programas elaborados para teléfonos móviles u otras plataformas de tipo Wifi.
printf(“sizeof(double) = %ld; sizeof(int) = %ld; sizeof(int*) = %ld\n”, sizeof(double), sizeof(int), sizeof(int*)); printf(“sizeof(f) = %ld; sizeof(p) = %ld, sizeof(a) = %ld, sizeof(x) = %ld\n”, sizeof(f), sizeof(p), sizeof(a), sizeof(x)); }
Este programa produce: sizeof(double) = 8; sizeof(int) = 4; sizeof(int*) = 8 sizeof(f) = 8; sizeof(p) = 8, sizeof(a) = 4, sizeof(x) = 40
Para el compilador gcc, el tipo regresado de sizeof es un tipo equivalente al tipo entero long. Según los sistemas y el tipo de procesador huésped, un apuntador se representa sobre 4 u 8 bytes; en nuestro caso, el sizeof del apuntador es de 8 bytes. El sizeof de un arreglo x es dimensión × sizeof (tipo). Un apuntador puede contener una dirección de memoria para una celda, la cual es capaz de almacenar un valor del tipo de apuntador. Esta celda está asociada a una variable; se puede tener acceso a esta celda por medio de un identificador conocido que corresponde a una variable. Asimismo, la celda puede ser asociada a una zona de memoria asignada de manera dinámica, al momento de la ejecución del programa. Para la asignación dinámica de una zona de memoria al momento de la ejecución del programa, existen varias funciones en la biblioteca estándar stdlib.h: malloc para una asignación dinámica. calloc parar una asignación dinámica acompañada por una inicialización con 0. realloc para una re-asignación.
En su caso, la función free libera zonas asignadas dinámicamente. A continuación se presenta un ejemplo del prototipo de la función malloc: void* malloc(size_t dimension)
La función malloc regresa un apuntador sin tipo t ( ipo void*) que referencia una zona de memoria contigua de tamaño dimension bytes (dimension es un entero sin signo). El apuntador puede serNULL, valor constante igual a cero, definido en la bibliotecastdlib.h, en el caso de que la asignación dinámica no haya funcionado. Antes de utilizar el apuntador asignado dinámicamente, se debe verificar que su valor no esNULL. Al final del programa, para todas las zonas de memoria asignadas dinámicamente, se aconseja liberar estas zonas con la función free. La función free tiene el siguiente prototipo: void free(void* apuntador)
La función free no regresa nada, tiene como efecto liberar la zona de memoria apuntada por el apuntador que está en el parámetro actual. Si el apuntador es NULL, no pasa nada. Enseguida se presenta el ejemplo de un programa que trabaja con un apuntador de tipo flotante: /* programa que trabaja con malloc */ #include
printf(“ El valor apuntado por ap_f %f\n”, *ap_f); ap_f = (float*)malloc(sizeof(float)); if (ap_f == NULL) { printf(“ Asignacion imposible.\n”); exit(0); } *ap_f = 25.5; printf(“ El nuevo valor apuntado por ap_f %f\n”, *ap_f); free(ap_f); exit(1); }
En la primera parte del main se usa el apuntador de manera “clásica”; esto es, manejando una variable del mismo tipo. En la segunda parte del programa se hace una asignación dinámica acompañada por una conversión de tipo. Si la asignación no sale bien, el programa se interrumpe. Al final del programa, la zona de memoria se libera con free. La salida del programa es: El valor apuntado por ap_f 1.200000 El nuevo valor apuntado por ap_f 25.500000 Arreglos dinámicos
Para un arreglo, es posible asignar la memoria de manera dinámica al momento de la ejecución, principalmente con la función malloc. En este caso, el arreglo se declara como un apuntador (arreglo sin dimensión): tipo *nombre_arreglo_dinamico;
En aras de una lectura más fácil del código, resulta mejor escribir un comentario indicando qué nombre_arreglo sería utilizado como un arreglo, no como un apuntador. La asignación dinámica de memoria se hace con la función malloc, indicando como parámetro actual la dimensión en nombre de bytes para el arreglo; también se hace una conversión de tipo para el tipo del arreglo. nombre_arreglo_dinamico= (tipo*)malloc(dimension_bytes);
Por razones de lectura y compatibilidad con varias plataformas, también es mejor indicar la dimensión en bytes, no solo como una constante, sino como una expresión, es decir, un producto entre el número de elementos del arreglo y el operador sizeof aplicado al tipo del arreglo: nombre_arreglo_dinamico= (tipo*)malloc(numero_elementos * sizeof(tipo));
Un arreglo dinámico puede ser asignado más de una vez durante la ejecución del programa, pero antes de una nueva asignación se aconseja liberar con la función free la zona de memoria ocupada.
Ejemplo
Realizar la suma de los elementos con un arreglo de dimensión desconocido al momento de la ejecución. Las funciones detalladas antes, lectura_arreglo, se almacenan porque son compatibles con el arreglo dinámico.4 La parte de la función main es: int main(int argc, char *argv[]) {
4
160
Los parámetros de tipo arreglo son transmitidos por referencia.
float *f; int i; int n; printf(“ La dimension del arreglo:”); scanf(“%d”, &n); if ( n <= 0) { printf(“ Dimension incorrecta !\n”); exit(0); } f = (float*)malloc(n * sizeof(float)); if (f == NULL) { printf(“ Asignacion imposible.\n”); exit(0); } lectura_arreglo(f, n); printf(“ La suma de los elementos del arreglo %f\n”, suma_por_referencia(f, n)); free(f); exit(1); }
A continuación se presenta un ejemplo de una salida del programa. La dimension del arreglo:7 1. 2. 3. 4. 5. 6. 7. La suma de los elementos del arreglo 28.000000
Tanto la asignación dinámica como la asignación estática no tienen control alguno sobre el contenido de la zona de memoria asignada; dicha zona contiene valores indeterminados.5 La función de asignación dinámica calloc pone por defecto, en toda la zona de memoria asignada, el valor 0; para ser más precisos, pone o en todos los bits. En los enteros y los flotantes, los valores son equivalentes al cero numérico. El prototipo de la función calloc es el siguiente: void* calloc(size_t numero_elementos, size_t dimension_tipo)
Entonces, la asignación dinámica de un arreglo se hace como sigue: nombre_arreglo = (tipo*)calloc(numero_elementos, sizeof(tipo));
En el siguiente ejemplo se muestra el programa presentado antes usando calloc, en lugar de malloc: f = (float*)calloc(n, sizeof(float));
Como se puede observar, aquí no se hace la llamada a la función de lectura de arreglo,6 ya que el programa produce una salida de forma: La dimension del arreglo:78
5 6
Estos valores indeterminados no son siempre 0 (véanse algunos ejemplos en el capítulo 3). El programa completo se puede consultar en el CD-ROM que acompaña este libro. 161
La suma de los elementos del arreglo despues la asignacion con calloc 0.000000 En cuanto a la función realloc, esta permite reasignar otra zona de memoria por un apuntador.
El contenido actual de la zona apuntada inicialmente no se pierde; primero se realiza la asignación de la nueva zona, después se hace la copia del contenido de la vieja zona a la nueva zona y, finalmente, se libera la zona inicial. Si al inicio el apuntador es NULL, simplemente se hace la asignación. El prototipo de la función realloc es similar al prototipo de malloc: void* realloc(size_t dimension)
Ejemplo
En este ejemplo se quiere hacer la suma de los elementos del arreglo introducidos uno a uno. Si la nueva dimensión del arreglo es mayor que la máxima dimensión asignada, entonces se hace una llamada a la función realloc. En este caso, todo el cálculo iterativo se hace al interior de una estructura while. Para utilizar directamente la primera asignación dinámica, la función realloc inicializa el apuntador del arreglo con NULL y la dimensión máxima dmax a 0. El programa principal (main) es el siguiente: int main(int argc, char *argv[]) { float *f; int i; int n, dmax; f = NULL; dmax = 0; while (1) { printf(“ La dimension del arreglo:”); scanf(“%d”, &n); if (n == 0) break; if ( n < 0) { printf(“ Dimension incorrecta !\n”); continue; } if (n > dmax) { // nueva re-asignacion f = (float*)malloc(n * sizeof(float)); dmax = n; if (f == NULL) { printf(“ Asignacion imposible.\n”); exit(0); } } lectura_arreglo(f, n); printf(“ La suma de los elementos del arreglo %f\n”, suma_por_referencia(f, n)); } free(f); exit(1); }
Aquí usamos la convención que afirma que si no hay más arreglos, se introduce la dimensión 0 para significar el final del cálculo. 162
Hay una sola llamada explícita de liberación de la memoria dinámica antes del final del programa, pero cada llamada de realloc también hace una liberación de memoria. La salida del programa es, por ejemplo: La dimension del arreglo:3 1.1 1.2 1.3 La suma de los elementos del arreglo 3.600000 La dimension del arreglo:7 9. 9. 0. 0. 0. 0. 0. La suma de los elementos del arreglo 18.000000 La dimension del arreglo:2 9.9 La9.9 suma de los elementos del arreglo 19.799999 La dimension del arreglo:0
Matrices En el tema anterior trabajamos con arreglos de tipos básicos (enteros o flotantes) y con arreglos de apuntadores. Como ya se dijo, un arreglo puede ser de cualquier tipo, incluso de tipo arreglo. El lenguaje C ofrece, entonces, la posibilidad de trabajar con arreglos de dos o más dimensiones, los cuales son tratados como arreglo de arreglos (matrices o arreglos bidimensionales) o como arreglos de arreglos de arreglos y más. La declaración se hace de la siguiente forma: tipo nombre_arreglo[dimension1][dimension2]..[dimensionn];
Por lo que respecta a las dimensiones mencionadas, estas tienen las mismas restricciones que las que vimos antes: deben ser constantes enteros. Cuando se trata de dos dimensiones, la declaración es entonces: tipo nombre_arreglo[dimension1][dimension2];
Esta declaración significa un arreglo con dimension1 elementos, donde cada elemento es un arreglo del tipo indicado con dimension2 elementos. Entonces, el primer elemento del arreglo es: nombre_arreglo[0][0]..[0]
Para cada dimensión j, los índices varían de 0 hasta dimensionj-1.
Ejemplo int A[2][3]; int U[3][3]={{1,0,0},{0,1,0},{0,0,1}}; float X[3][2] = {{1., 3.}, {5., 7.}, {9., 11.}}
Donde A es una matriz con dos líneas y tres columnas, U es una matriz con tres líneas y tres columnas, que es inicializada con valores 1 o 0 (es decir, es la matriz unidad), y X es una matriz con tres líneas y dos columnas. La matriz X tiene la siguiente estructura lógica:
163
columna0 1. 5. 9.
línea 0 línea1 línea 2
columna1 3. 7. 11.
En este caso, el elemento X[0][1] vale 3; el elemento X[2][0] vale 9. La escritura X[1] corresponde al arreglo 5., 7. En la memoria se asigna una zona de memoria contigua de largo 3 × 2 × sizeof (int) bytes; es decir, 6 celdas que contienen tipos flotantes de simple precisión. Tabla 4.1 X
Contenido Índices
1.
3.
[0][0]
[0][1]
5. [1][0]
7.
9. [1][1]
[2][0]
11. [2][1]
Los valores contenidos en los arreglos multidimensionales se acceden indicando los índices en cada dimensión y se utilizan de la misma manera que los elementos de los arreglos; esto es, como cualquier otra variable: sola en la parte izquierda del operador de asignación, en cualquier expresión compatible con su tipo, etcétera. Entonces, el identificador de la matriz se convierte en un apuntador de apuntador de entero. El acceso a los elementos se realiza indicando el rango en cada dimensión. De la misma manera que para los arreglos unidimensionales, también es posible usar una aritmética de apuntadores para acceder a los elementos (esta es la aritmética que el compilador utiliza): int main(int argc, char *argv[]) { float X[3][2] = {{1., 3.}, {5., 7.}, {9., 11.}}; printf(“ Otro El primer : %f\n”, X[0][0 printf(“ accesoelemento del primer elemento %f\n”, **X); printf(“X[2][0] =%f\n”, X[2][0 printf(“ Escritura equivalente *(*X + 2*2) : %f\n”, *(*X + 2*2)); printf(“X[1][1] =%f\n”, X[1][1 printf(“ Escritura equivalente *((*X + 1*2) + 1) : %f\n”, *((*X + 1*2) + 1)); exit(0); }
Este programa produce la siguiente salida: El primer elemento : 1.000000 Otro acceso del primer elemento 1.000000 X[2][0] =9.000000 Escritura equivalente : 9.000000 X[1][1] =7.000000 Escritura equivalente : 7.000000 Ejemplo
Trabajar con una matriz de enteros 5 × 5, inicializar todos sus elementos a 0, leer el elemento central (que se halla en el punto donde se cruzan la diagonal principal y la diagonal secundaria) y, finalmente, escribir la matriz en forma inteligible. /* programa que construye una matriz inicializada a cero con un centro */ #define N 5 164
#include
Una matriz u otro arreglo multidimensional puede aparecer como parámetro en las funciones; pero, si es el caso, se deben indicar explícitamente casi todas las dimensiones del arreglo, con el fin de poder acceder a los valores de elementos.
Ejemplo Para el programa precedente de trabajo con una matriz con la mayoría de valores a 0, podemos introducir dos funciones: una para la escritura de la matriz y otra para inicializar con 0 todos los valores. void inicializar_0(int X[N][N]) { int i, j; for (i = 0; i < N; i++) for (j = 0; j < N; j++) X[i][j] = 0; return; } void escribir_matriz(int AA[][N]) { int i, j; for (i = 0; i < N; i++) { // escritura de la linea i for (j = 0; j < N; j++) printf(“ %2d”, AA[i][j printf(“\n”); // un salto de linea } 165
return; } int main(int argc, char *argv[]) { int A[N][N]; // inicializar todo con 0 inicializar_0(A); // lectura del elemento central printf(“ Indicar el valor del elemento central : ”); scanf(“%d”, &A[N/2][N/2 // escritura de la matriz escribir_matriz(A); exit(0); }
Para la función inicializar_0, es posible indicar todas las dimensiones del arreglo; en cambio, para la función escribir_matriz únicamente se indica la segunda dimensión. Las llamadas a las dos funciones se hacen de la misma manera: indicando únicamente el identificador del arreglo.
Problemas resueltos Esta sección está dedicada a la presentación de la solución de dos aplicaciones usando arreglos unidimensionales o matrices. El primer ejemplo se considera más académico: trabajo con polinomios. El segundo ejemplo, por su parte, es la implementación de una interfaz del juego del gato o tres en línea (tic-tac-toe, en inglés). Trabajo con polinomios
En esta sección vamos a escribir un programa con varias funciones, capaz de trabajar con polinomios. La implementación de algunas operaciones aritméticas como ejercicio, la dejamos para el final de este capítulo. En álgebra, un monomio es un término de la siguiente forma: a × Xk
Este término une al producto de una constante y a una variable (matemática) elevada a una potencia entera. La constante a pertenece a un conjunto, por lo que se pueden realizar sumas, productos y otras operaciones algébricas.7 De forma particular, nosotros trabajamos con coeficientes reales, que se traducen en lenguaje C por medio de coeficientes flotantes de tipo float o double. La variable matemática X pertenece al mismo conjunto. Un polinomio se considera una suma de monomios:
P(X) = ak × xk + ak−1 × xk−1 + … + a1 × x + a0 Escritura equivalente con base en las propiedades de los operadores + y ×:
P(X) = a0 + a1 × X + … + ak−1 × Xk−1 + ak × Xk
1 Por ejemplo: P(X) = 3, Q(X) = X + 3, R(X) = X3 + 5 son polinomios; sin embargo, x + 3 y x2 + 1 no lo son. 7
166
En teoría, se puede concebir polinomios de coeficientes complejos, enteros, fraccionarios, entre otros.
Entonces, surge una primera pregunta para el programador: ¿Cómo se representa un polinomio en el programa? Para un polinomio son importantes sus monomios: la constante y la potencia. Por tanto, una primera idea sería guardar en una matriz con dos líneas y varias columnas, las parejas (constante multiplicativa y potencia); pero al guardarlas se debe hacer con un orden, para facilitar las operaciones. Como las potencias son números enteros positivos, solo podemos guardar las constantes (coeficientes) en un arreglo con el sentido: al índice i lo almacenamos en el coeficiente ai, de la potencia X i. Por ejemplo, para los polinomios del ejemplo anterior:
Índices P
0 3
1
2
3
Q R
3 5
1 0
0
1
El grado de un polinomio es la potencia más grande presente. Es evidente que para cada polinomio sería suficiente un arreglo de dimensión equivalente a su grado. Pero esta sería una información suplementaria. Entonces, podemos suponer que el grado máximo de los polinomios sería una constante GMAX.8 #define GMAX 10 .. float P[GMAX], Q[GMAX] = {3, 1}; float R[GMAX]; ..
Podemos hacer inicializaciones de los coeficientes del arreglo, pero si no indicamos todos los valores hasta el índice GMAX-1, no se conocerán los valores que ocupan la otra parte del arreglo. En este caso, es evidente que dos funciones serían muy útiles: la lectura y la escritura de un arreglo. Durante la lectura de un arreglo, primero se lee el grado, luego se leen sus coeficientes, uno a uno, y el resto de los coeficientes se inicializan a 0.0; por ejemplo: int lectura_polinomio(float A[]) { int N, i; // Leer el grado del polinomio printf(“ Indicar el grado de su polinomio:”); scanf(“%d”, &N); if ( N >= GMAX) { printf(“ El programa no permite trabajar con un grado tan alto.\n”); return 0; } // Leer los coeficientes printf(“ Introducir los coeficientes !\n”); for (i = 0; i <= N; i++) { printf(“ El coeficiente del indice %d :”, i); } scanf(“%f”, &A[i //Poner 0 por los otros coeficientes hasta el indice GMAX - 1 for (i= N + 1; i < GMAX; i ++) En el siguiente capítulo utilizamos el tipo de datos compuesto struct para guardar el arreglo de los coeficientes y el grado del polinomio en una misma estructura. 8
167
A[i] = 0.0; return 1; }
Para la escritura de un arreglo, podemos escribir todos los GMAX coeficientes; sin embargo, esta es una solución sin elegancia, ya que algunos coeficientes son cero. Por otra parte, también podemos escribir los coeficientes entre el índice 0 y el grado del polinomio. Una función muy útil que calcula el grado del polinomio, se muestra a continuación: int grado(float X[]) { int i; for (i = GMAX - 1; i >= 0; i --) if ( X[i] != 0.0) return i; return 0; }
Durante la escritura se corre el arreglo de coeficiente i de 0, hasta el grado del polinomio, y se escribe el valor del arreglo y el índice, que constituye la potencia del monomio de grado i. Una primera versión es: void printf_polinomio_v1(float X[]) { int N, i; N = grado(X); for ( i = 0; i <= N; i++) printf(“ %f * X^%d”, X[i], i); printf(“\n”); } 4
La salida de esta función es la siguiente por el polinomio X + 5: El polinomio es : 5.000000 * X^0 0.000000 * X^1 0.000000 * X^2 0.000000 * X^3 1.000000 * X^4 Si se quiere escribir el símbolo + entre los monomios e indicar solo los monomios significativos, debemos escribir una condición explícita sobre el coeficiente ≠ 0.0, y si no es el coeficiente del grado. A continuación se presenta una segunda versión de escritura del arreglo: void printf_polinomio_v2(float X[]) { int N, i, primer; N = grado(X); primer = 0; for ( i = 0; i <= N; i++) if (X[i] != 0.0 || i == N) { if (primer != 0) printf(“ + “); else primer = 1; printf(“%f”, X[i if ( i == 1) printf(“ * X”); if (i > 1) printf(“ * X^%d”, i); 168
} printf(“\n”); }
En este caso, la primera variable detecta si este es, o no, el primer monomio escrito. En el caso de que no sea el primero, se escribe el signo +. Por otra parte, si el índice es 0 se escribe simplemente el coeficiente, pero si el índice es 1 se escribe X nada más. Para el mismo polinomio X4 + 3X + 5 la escritura es: 5.000000 + 3.000000 * X + 1.000000 * X^4
Nos interesa calcular el valor de un polinomio en un punto matemático, esto significa sustituir la variable X por un valor concreto y realizar el cálculo. En el programa esto se traduce a una función de prototipo: float valor(float P[], float x);
Una primera opción sería hacer los cálculos en el orden indicado por la escritura:
P(X) = ak × Xk + ak−1 × Xk−1 + … + a1 × X + a0 O también por la escritura:
P(X) = a0 + a1 × X + … + ak−1 × Xk−1 + ak × Xk El defecto de un cálculo de este tipo sería el número total de operaciones. También podemos ver que el polinomio se puede factorizar de la siguiente manera:
P(X) = (… ((ak × X + ak−1) × X + ak−2) × X + … + a1) × X + a0 La función C que traduce este cálculo es: float valor(float P[], float x) { int i, N; float valor; valor = 0.0; N = grado(P); for (i = N; i >= 0; i--) valor = valor * x + P[i]; return valor; }
En este caso, el arreglo se corre de derecha a izquierda, y la última operación es la suma con el coeficiente de índice 0.
Ejemplo
A continuación se presenta un ejemplo de llamada. printf(“ El valor del polinomio en el punto %f es : %f\n”, xx, valor(P,xx));
Una operación algebraica que se utiliza con mucha frecuencia en el cálculo de polinomios es la suma. Así, para adicionar dos polinomios, se hace la suma de los coeficientes de los monomios del mismo grado. Por ejemplo: (X2 + 3) + (3X2 + 5X) = (1 + 3)X2 + 5X + 3. La función de la suma adiciona los coeficientes del mismo índice. A continuación se presentan dos versiones: void suma_v1(float X[], float Y[], float S[]) { 169
int i; for ( i = 0; i < GMAX; i++) S[i] = X[i] + Y[i]; } void suma_v2(float X[], float Y[], float S[]) { int nx, ny, n, i; nx = grado(X); ny = grado(Y); n = nx > ny ? nx :ny; for (i S[i] for (i S[i]
= = =
0; i <=n; i++) X[i] + Y[i]; n + 1; i < GMAX; i++) 0.0;
}
En ambas versiones se considera que los coeficientes de índice superior al grado del polinomio son inicializados correctamente a 0. Como se puede observar, en la primera versión se hace la suma de todos los índices, mientras que en la segunda versión se hace la suma únicamente de los índices de 0 hasta el máximo de los grados. Después, desde este índice hasta el índice GMAX-1 se llena con 0.0. Una operación (transformación) que se utiliza con mucha frecuencia en matemáticas es la derivada. La derivada de un monomio aXk, para k 1 es el monomio kaXk−1. Por tanto, la derivada de un polinomio es la suma de las derivadas de los monomios que la componen. Así, en el polinomio:
P(X) = a0 + a1 × X + … + ak−1 × Xk−1 + ak × Xk su derivada es: 1
P(X)’ = a1 + 2 × a2 × X + … + (k − 1) × La función C que traduce este cálculo es:
ak−1
k−2
×X
k−1
+ (k − 1) × ak × X
void derivada(float X[], float D[]) { int n, k; n = grado(X) - 1; for (k = 0; k <= n; k++) D[k] = (k+1) * X[k+1]; for (k = n + 1; k < GMAX; k++) D[k] = 0.0; }
Juego del gato El objetivo de este apartado es construir un programa completo que sirva de interfaz a dos jugadores. Al contrario de lo se hace el capítulo 3, donde seaproponen tres versiones en este caso únicamente unaque versión de en interfaz del juego, debido que proponer una ayudadeo un unajuego, solución automática sobrepasa proponemos un curso de introducción a la programación,9 que es el objetivo de este libro. La solución automática de algunos juegos se realiza con la construcción y la exploración de los árboles Alfa-Beta o MinMax. Esto constituye un dominio de la inteligencia artificial, una rama de la computación. Véase el libro Russell, Stuart J. y Norving, Peter (2004). Inteligencia Artificial. Un Enfoque Moderno. 2a edición. Prentice Hall, México. 9
170
El juego del gato o tres en línea ( tic-tac-toe, en inglés) es un juego muy simple que se juega con papel y lápiz o en un tablero, formado por tres líneas y tres columnas (3 × 3), que se entrelazan, y piedras de dos colores diferentes. Cuando se juega con lápiz y papel, se dibuja en papel el tablero de 3 × 3, y cada uno de los jugadores elige un símbolo para jugar: X u O (véase la figura 4.1).
Figura 4.1 Una configuración del juego; en la imagen toca el turno al jugador con el símbolo X.
Desde que inicia el juego, cada jugador escribe el símbolo que eligió en una posición libre del tablero hasta que haya tres símbolos iguales en línea, ya sea horizontal, vertical o en diagonal, hasta que no haya posiciones libres. Por tanto, el ganador es quien logre colocar tres símbolos iguales antes que su contrincante. En la figura 4.2 se pueden observar tres configuraciones ganadoras de este juego; no obstante, existen más.
Figura 4.2 Tres distintas configuraciones ganadoras.
En algunos casos, el juego termina sin ganador; es decir, se declara un empate (en este caso se dice que se hizo el gato).
Figura 4.3 Configuración de empate. 171
El objetivo de nuestro programa es hacer de la computadora un tablero de juego “inteligente” que entregue y guarde las posiciones que se juegan; así, para cada movimiento y posición colocada se verificaría si alguna casilla del tablero está libre o no y se detectaría el final de la partida, poniendo de manifiesto si hay un ganador o es un empate. Ante este reto, la primera pregunta es: ¿Cómo podemos representar el tablero de juego? La respuesta es inmediata: con una matriz de tipo entero (aunque short puede ser suficiente). Para determinar si la posición es libre o está ocupada con uno u otro de los símbolos, X y O, podemos escribir valores constantes. Una sugerencia es escribir el siguiente código: 0 por la posición libre. 1 por la posición ocupada por X. −1 por la posición ocupada por O.
Así, esta codificación sería traducida directamente en el programa con directivas definitorias: #define LIBRE 0 #define X 1 #define O −1
No obstante, también existen otros valores constantes necesarios en el programa, que definimos como constantes de sustitución: #define DIM 3 #define CIERTO 1 #define FALSO 0
El uso de estas directrices nos permite escribir un código más claro, como el del siguiente tipo: if (tablero[i][j] == LIBRE) .. tablero[k][j] = X;
En este caso, la matriz que representa el tablero se declara: short tablero[3][3];
La segunda pregunta para este reto, sería: ¿De qué manera se declara la matriz del tablero, como una variable local, al interior del bloque de main, o como una variable global visible para cualquier función? La respuesta está en la manera de crear el programa del juego. Si deseamos no usar funciones y que todo el tratamiento se realice en el bloque del main, la matriz del tablero puede ser local; de lo contrario (lo cual resulta más apropiado), la matriz se considera global. Si se trabaja de forma modular, con funciones que cumplen (realizan) algunos objetivos, la ventaja de usar una matriz global es que las listas de parámetros no se cargan con el identificador de esta matriz y el código resulta más fácil de entender. Por ejemplo, a continuación se presenta la declaración de la matriz y la función de inicialización con el valor de LIBRE (0): short tablero[DIM][DIM]; void inicializacion_tablero() { int i, j; for (i = 0; i < DIM; i++) for (j = 0; j < DIM; j++) tablero[i][j] = LIBRE; }
Una clave importante para lograr el éxito de cualquier programa, es la forma de las entradas y de las salidas. Por lo general, en la salida debería ser posible imprimir, en forma inteligible, el tablero y algunos mensajes de los jugadores, aun cuando la partida del juego termine o no. Por su parte, en la entrada se esperaría poder ver la nueva posición ocupada en el tablero por cualquiera de los jugadores. Con base en lo antes expuesto, para el juego del gato podemos proponer 172
una codificación de tipo juego de ajedrez (a1, b3, ...); no obstante, lo más simple y lo más cómodo que podemos hacer es preguntar la posición en la matriz tablero. Una salida clara del tablero de esta forma (aunque la forma no es la más bonita, sí es lo bastante clara para poder comprenderla) puede ser la siguiente: 0 1 2 -------------| | | | 0 | X | | 0 | -------------| | | | 1 | | | X | -------------| | | | 2 | 0 | 0 | X | -------------Las funciones que permiten este tipo de salida son las siguientes: void print_codigo(short valor) { switch(valor) { case X: printf(“ X”); break; case 0: printf(“ 0”); break; case LIBRE: printf(“ ”); break; } } void print_linea(int i) { int j; printf(“ %d |”, i); for (j=0; j < DIM; j++) { print_codigo(tablero[i][j if (j < 2) printf(“ |”); else printf(“ |\n”); } } void escritura_tablero() { int i; system(“clear”); printf(“\n”); printf(“ 0 1 2\n”); printf(“ -------------\n”); for (i = 0; i < DIM; i++) { printf(“ | | | |\n”); print_linea(i); 173
printf(“ -------------\n”); } printf(“\n”); }
La función print_codigo resulta útil para indicar quién es el jugador en turno (X u O). Esta salida es muy común, pero como ejercicio usted puede imaginar otras formas de representación del tablero. En el caso del juego del gato, tenemos dos jugadores (X y O), pero para el programa no hay ninguna diferencia; por tanto, cualquier jugador puede iniciar la partida; por ejemplo, puede ser X. Para indicar quién es el jugador en turno en el juego, introducimos en el programa una variable jugador que únicamente toma los valores 0 y 1. El jugador del próximo turno sería, por tanto, el valor 1. Para hacer notar el código que simbolo[2] que contiene los valores constantes X y O. Cuando la posición un jugador utiliza, introducimos arreglo asignación: introducida se ocupa, se hace laun siguiente tablero[i][j] = simbolo[jugador];
Una posición (i, j) es correcta si i y j son los índices correctos en el tablero (es decir, si se ubican entre 0 y DIM-1) y si el tablero es libre. La verificación se realiza con las siguientes funciones: int esta_libre(int i, int j) { if (tablero[i][j] == LIBRE) return CIERTO; else return FALSO; } int posicion_correcta(int i, int j) { if (i < 0 || i >= DIM || j < 0 ||j >= DIM) return FALSO; else return esta_libre(i, j); }
El juego en cuestión tiene una estructura iterativa; en el caso de que un jugador gane, el programa termina. Por el contrario, si con el movimiento actual no se ha ganado, el programa continúa, cambiando el jugador y aumentando el número de pasos (movimientos) hechos, por ejemplo: int main(int argc, char *argv[]) { short paso; short jugador; short simbolo[2] = {X, 0}; int i, j; // inicializacion inicializacion_tablero(); escritura_tablero(); jugador = 0; paso = 0; printf (“ El jugador que empieza : ”); print_codigo(simbolo[jugador // estructura repetitiva del juego while (paso < DIM * DIM) { 174
printf (“\n El jugador que juega : ”); print_codigo(simbolo[jugador // lectura de la posicion de entrada printf(“\n Posicion: ”); scanf(“%d%d”, &i, &j); if (posicion_correcta(i, j) == CIERTO) { // la posicion esta correcta (indices correctos y posicion libre en tablero) tablero[i][j] = simbolo[jugador]; escritura_tablero(); // se verifica si hay ganador if (hay_ganador(i,j) == CIERTO) { printf( “ El ganador es:”); print_codigo(simbolo[jugador break; } else { // el partido continua jugador = 1 -jugador; // el jugador cambia paso++; // un paso mas escritura_tablero(); } } else { // la posicion no esta correcta printf(“ La posicion no esta correcta.\n\n”); continue; } } printf(“ \n”); if (paso == DIM * if (paso == DIM * // fueron DIM*DIM printf( “ El gato exit(0);
DIM) DIM) pasos jugados sin ganar !\n”);
}
Si el juego termina después de nueve pasos ( DIM*DIM), se deduce que el juego termina en empate (es decir, se hace el gato). La verificación de un ganador no se hace recorriendo toda la matriz del tablero, sino únicamente las líneas, las columnas y las diagonales (si la posición pertenece a las diagonales). Para recorrer una línea, se utiliza el segundo índice variable (una variable k); para una columna, se usa el primer índice que es variable y el segundo que es fijo; por su parte, las diagonales se caracterizan por lo siguiente: La diagonal principal tiene, para cualquier elemento, el mismo valor para sus índices. La diagonal secundaria tiene la propiedad de que la suma de sus índices es un valor constante.
int hay_ganador(int i, int j) { int k, suma; // si la linea esta completa con el mismo simbolo for (suma= 0, k = 0; k < DIM; k++) suma += tablero[i][k]; 175
if (suma == DIM * tablero[i][j]) { printf(“ Linea completa!\n”); return CIERTO; } // si la columna esta completa con el mismo simbolo for (suma= 0, k = 0; k < DIM; k++) suma += tablero[k][j]; if (suma == DIM * tablero[i][j]) { printf(“ Columna completa!\n”); return CIERTO; } if (i == j) { // (i,j) es sobre la diagonal principal // verificar esta diagonal for (suma= 0, k = 0; k < DIM; k++) suma += tablero[k][k]; if (suma == DIM * tablero[i][j]) { printf(“ Diagonal principal completa!\n”); return CIERTO; } } if (i+j+1 == DIM) { // (i,j) es sobre la diagonal secundaria // verificar esta diagonal for (suma= 0, k = 0; k < DIM; k++) suma += tablero[k][DIM-1-k]; if (suma == DIM * tablero[i][j]) { printf(“ Diagonal secundaria completa!\n”); return CIERTO; } } return FALSO; }
Ejemplo Ejemplo de última salida del programa: 0 1 2 -------------| | | | 0 | | | X | -------------| | | | 1 | 0 | X | 0 | -------------| | | | 2 | X | | | -------------176
Diagonal secundaria completa! El ganador es: X
4.2 Caracteres y cadenas de caracteres Hasta ahora hemos manejado únicamente datos numéricos; el lenguaje C, como la mayoría de los lenguajes de programación, también permite tratar datos textuales. En esta sección presentamos los mecanismos para manejar caracteres, cadenas de caracteres y las funciones estándares que el lenguaje C ofrece para trabajar con datos de este tipo.
Tipo carácter La unidad de memoria más pequeña accesible de un programa es el byte, que en lenguaje C corresponde al tipo char con sus dos versiones: char unsigned char
El tipo char almacena valores entre −127 y 127, mientras que el tipounsigned char guarda valores entre 0 y 255. Un carácter imprimible o de control se codifica en ASCII 10 y se representa en la memoria en forma de variable de tipo char. Una constante de tipo char se representa de dos formas:
1. Por su valor numérico en el código ASCII, ya sea en base 10, 16 u 8. 2. Por el carácter (comilla) que se representa entre un par de símbolos ( ‘ ‘). Ejemplo
El 99 es un valor de tipo char, el cual también se puede escribir como 0 o 143, en octal; 0 × 63, en hexadecimal, o como ‘c’. En la siguiente tabla se presenta una lista de algunos caracteres especiales o que no son imprimibles:
Tabla 4.2 Caracteres especiales que no son imprimibles. RepresentaciónenC \n \0 \t \\ \ooo \’ \”
Significación Línea nueva (interlínea) Carácter NULL, que tiene su código 0 Tabulación Carácter \ carácter código ASCII equivalente a valor ooo en base 8 Carácter ’ (comilla) Carácter ” (doble comilla)
Para utilizar otros caracteres además del código ASCII, estos deben construirse con los tipos presentados (es decir con nuevos tipos capaces de soportar otros códigos sobre másde 1 byte) y desarrollar funciones que faciliten elmanejo de los datos con esta codificación. 10
177
Para imprimir un carácter se utiliza el formato de printf es %c; para ver el carácter imprimible y ver su valor numérico en decimal, octal o hexadecimal se utiliza o %d, %o, %x.
Ejemplo
Fragmento de programa en lenguaje C: char abc=’Z’; char zz = 99; printf(“ abc = %c\n”, abc); printf(“ zz = %c\n”, zz); produce :
abc = Z zz = c
A los diferentes valores (variables, constantes y expresiones) se pueden aplicar los llamados operadores de trabajo con valores numéricos, que se muestran a continuación: Los operadores *, /, % no tienen sentido. Los operadores + y − trabajan con el código ASCII. Los operadores de comparación < > <= >= == != se aplican también
¡Cuidado!, también hay “a” > “A”
sobre el código ASCII.
Ejemplo
A continuación se presenta un programa que aplica operadores de valores tipo char. /* programa que trabaja con caracteres */ /* usando algunos operadores */ #include
Este programa produce la siguiente salida: abc = f (el caracter) 102 (su valor entero) El caracter es una letra minuscula los 5 sucesores de f son ghijk 178
Cadenas de caracteres Manejar datos que corresponden, por ejemplo, a palabras, no resulta práctico si se debe trabajar carácter por carácter. El lenguaje C trabaja con cadenas de caracteres, que constituyen sucesiones de caracteres que terminan implícitamente con el carácter \’0’ o 0. Es muy importante tomar en cuenta que al inicio y al final de cada cadena de caracteres se usa el símbolo ”. Las cadenas de caracteres se almacenan en arreglos de caracteres de tamaño de al menos el largo más 1 (por el carácter de fin de cadena).
Ejemplo /* programa simple que trabaja con cadenas de caracteres */ #include
Este programa produce la siguiente salida: La primera cadena : cadena Las dos cadenas son : zz = cadena xx =cadena
El formato utilizado en la función printf es %s es para imprimir una cadena de caracteres. Una constante de cadena de caracteres se escribe entre los símbolos “ ” (doble comilla) e indica los caracteres que la componen.
Ejemplo ”abc”, ”a”, ”\141\142\143r”, ”abcr” son cadenas de caracteres; de estas, las dos últimas son idénticas.
En el programa del ejemplo anterior, la primera inicialización del arreglo de caracteres se realiza elemento por elemento, con cada carácter que compone la cadena, incluso hasta con el último carácter de fin de cadena, donde se usa la escritura ’x’. En la segunda inicialización se emplea la notación completa con toda la cadena de caracteres; de esta forma, esta cadena es una constante que inicia y termina con el símbolo ”. Una cadena de caracteres se declara con: char nombre_cadena1[dimension] 179
char nombre_cadena2[] char *nombre_cadena3
Pero, estas declaraciones no son equivalentes. En el primer caso, se asigna la zona de memoria de dimensión bytes capaz de almacenar el contenido de la cadena de caracteres. Por su parte, en el caso de nombre_cadena2, una inicialización con una constante de tipo cadena resulta imperativa para la asignación de memoria. En el último caso, y debido a la ausencia de una inicialización, no se asigna ninguna zona de memoria; nombre_ cadena3 es únicamente un apuntador de tipo carácter, el cual puede aparecer del lado izquierdo deuna asignación de tipo: nombre_cadena3 = yy;
Con yy, otra cadena de caracteres también es posible asignar memoria con la función malloc: nombre_cadena3 = (*char)malloc(dimension);
Las cadenas nombre_cadena1 y nombre_cadena2 jamás pueden aparecer en el lado izquierdo de un operador de asignación. Por su parte, la declaración: char *aa[10];
indica un arreglo de cadenas de caracteres o de apuntadores de caracteres.
Ejemplo
Enseguida se muestran las declaraciones de variables de tipo cadenas de caracteres con y sin inicialización. /* programa que trabaja con cadenas de caracteres */ #include
En el siguiente ejemplo se pueden observar dos cadenas declaradas con el tipo char*. Para la primera cadena, apcadena, usamos dos asignaciones, una con otra cadena y una con la función malloc. La segunda cadena, apsegundo, 180
apunta después a la asignación apsegundo = apcadena; la misma zona de memoria que apcadena. Así, cualquier modificación de apcadena está visible a través de apsegundo. /* programa apuntadores y cadenas */ #include
Así, la salida del programa es: despues una primera asignacion apcadena contiene : Una cadena. despues el malloc y el for apcadena contiene : abcdefghijklmnopqrstuvwxyz apsegundo apunta por : abcdefghijklmnopqrstuvwxyz apsegundo apunta por : xxxxxx La escritura de la cadena de caracteres se efectúa, por el momento, con la función general printf y con el formato %s. El funcionamiento es el siguiente:
1. Con base en el apuntador indicado como parámetro, se recorre la memoria, byte por byte. 2. Cada byte se interpreta con el código ASCII, hasta que se encuentra con el carácter NULL ’\0’ de fin de cadena. 3. La ausencia del carácter de fin de cadena provoca errores lógicos de programa o que el programa se detenga, porque no encuentra el fin de cadena y continúa recorriendo la memoria. 11 11
Véase el programa caracter5_errores.c en el CD-ROM que acompaña este libro. 181
La lectura de una cadena de caracteres permite utilizar la función conocida de scanf con el formato %s; sin embargo, la cadena que se lee no debe tener espacios u otros separadores. En la sección siguiente se presentan y analizan diversas funciones de entrada y salida, con mayor adaptación a los tipos de carácter y cadenas de caracteres. Como una cadena de caracteres es un apuntador, existen pocos operadores estándares que trabajan con las cadenas. En este caso, por ejemplo, la asignación se refiere al apuntador o al contenido del nivel de carácter, excepto en las declaraciones, donde no hay una asignación de forma cadena = ”mi cadena”. Entonces, se impone escribir funciones específicas con el tratamiento de las cadenas de caracteres o utilizar funciones de las bibliotecas estándares. El siguiente programa contiene dos funciones de trabajo con cadenas; la primera calcula el largo (tamaño) de una cadena introducida por parámetro; por su parte, la segunda función copia el contenido de una cadena (incluso el carácter de fin de cadena) en una cadena destinación. int largo (char *st) { int i; i = 0; while(1) { if (st[i] == ’\0’) break; i++; } return i; } void copia(char *f, char *d) { int i; for (i = 0; ; i++) { d[i] = f[i]; } if (d[i] == ’\0’) break; return; } int main(int argc, char *argv[]) { char ss[]=“esta es una cadena”; char *segunda; printf(“ss = *%s* el largo = %d \n”, ss, largo(ss)); printf(“ el largo de mi nombre %d\n”, largo(“Mihaela”)); segunda = (char*)malloc(largo(ss) + 1); copia(ss, segunda); printf( “ La segunda cadena es : %s\n”, segunda); exit(0); }
En el programa de la segunda cadena realizamos la asignación de una zona de memoria con una llamada de la función malloc. Así, la salida del programa es: ss = *esta es una cadena* el largo = 18 el largo de mi nombre 7 La segunda cadena es : esta es una cadena 182
Funciones estándares para el manejo de caracteres y cadenas de caracteres Varias bibliotecas estándares proponen funciones para el trabajo con caracteres y cadenas de caracteres. En la biblioteca ctype.c hay funciones que prueban la naturaleza de un carácter ASCII, ya sea que se trate de cifra, letra, signo de puntuación, carácter de control, entre otros. A continuación se presenta una lista con las principales funciones de esta biblioteca:
Tabla 4.3 int isupper(char c)
c es una letra mayúscula
int islower(char c) int isalpha(char c)
c es una letra minúscula isupper(c) o islower(c)
int isdigit(char c)
c es una cifra
int isalnum(char c)
c es letra o cifra
int isprint(char c)
c es carácter imprimible
int iscontrol(char c)
c es carácter de control
int ispunct(char c)
c es carácter de puntuación
En el caso de esta biblioteca, las funciones regresan el valor 0 si la condición es falsa, o un valor diferente de 0 si la condición es cierta. En la biblioteca stdlib.h hay tres funciones de conventir una cadena de caracteres en un valor numérico: double atof(char *s)
convierte la cadena
s en un flotante.
int atoi(char *s)
convierte s en un entero.
long atol(char *s)
convierte s en un entero long.
Si la conversión es posible, la función regresa el valor del tipo indicado; si no las funciones regresan el valor 0.
Ejemplo
A continuación se presenta un ejemplo de este código. char c1[ ]= “1234”; char c2[ ] = “abc”; printf(“atoi(c1) = %d\n”, atoi(c1)); printf(“atoi(c2) = %d\n”, atoi(c2));
Entonces, este produce: atoi(c1) = 1234 atoi(c2) = 0
La segunda cadena no se puede convertir en un valor numérico, ya que la función atoi regresa el valor 0. La biblioteca string.h contiene funciones especializadas en el manejo de cadenas de caracteres: char *strcpy(char *s, char *ct) char *strncpy(char *s,char *ct,int n) char *strcat(char *s, char *ct)
copia la cadena
ct en la cadena s.
copia n caracteres de la cadena ct en s. concatena la cadena
ct al final de la cadena s. 183
char *strncat(char *s,char *ct,int n)
concatena n caracteres de lact al final des.
int strcmp(char *cs, char *ct)
compara ct con cs, regresa <0 , 0 o >0, según cs < ct (contenidos de cadenas). compara los primeros n caracteres de cs y ct.
int strncmp(char *cs,char *ct,int n) int strlen(char *cs)
regresa la longitud (el largo) de
cs.
Las funciones anteriores suponen que todas las cadenas terminan con el carácter de fin de cadena. Las lecturas/escrituras pueden hacerse con printf y scanf (de la biblioteca stdio.h), respetando el siguiente formato: %c para los caracteres. %s para las cadenas de caracteres.
La primera lectura de un carácter toma en cuenta el primer carácter entregado y la segunda lectura puede iniciar con el próximo carácter del flujo. Por consiguiente, la lectura de una cadena de caracteres con scanf siempre toma en cuenta la sucesión de caracteres hasta el primer separador (que puede ser un espacio, una línea nueva o una tabulación). La función scanf también pone el carácter \0 o NULL al final de la cadena. De esta forma, no es posible que la cadena empiece o contenga un separador. Para una mejor comprensión, véase con cuidado el siguiente programa simple de lectura/escritura: /* programa 1 de lectura caracter y cadena */ #include
Como se puede observar en el programa anterior, las salidas son dependientes de las entradas. Entonces: Lectura de un caracter y de una cadena 3XYZ abc = #3# zz = +XYZ+ Lectura de un caracter y de una cadena 4 abcRTRTR = #4# zz = +RTRTR+ Lectura de un caracter y de una cadena 345 67 8989898 abc = #3# zz = +45+ 184
Las cadenas de caracteres en la función scanf indican el identificador de cadena, el cual constituye una dirección de memoria, ya que con base en sus caracteres se indica su dirección de memoria. Las funciones printf y scanf trabajan con los flujos estándares de salida y de entrada, respectivamente. En la biblioteca stdio.h también hay dos funciones parecidas: sprintf y sscanf, por esta razón, la escritura o la lectura no se hace en un flujo, sino en una cadena de caracteres. Los prototipos son: int sscanf(char *s, char *formato, elementos ...); int sprintf(char *s, char *formato, elementos ...);
Por su parte, en la biblioteca stdio.h hay otras funciones de lectura/escritura de caracteres y cadenas: char getchar() que lee y regresa un carácter del flujo estándar de entrada. char putchar(char c) char *gets(char *s, int n)
char puts(char *)
que escribe el carácter
c flujo estándar de salida.
que lee una cadena de caracteres s, desde el flujo de entrada hasta que se encuentra una línea nueva o que el largo leído está n; se pone al final de la cadena el carácter \0. que escribe la cadena
s y el carácter de fin de línea \n.
Ejemplo
En este ejemplo se desarrollan las mismas funcionalidades que en el programa anterior, solo que las entradas se hacen con otras funciones: /* programa 2 de lectura */ #include
Así, una salida del programa es: Lectura de caracter y cadena 345 67 8989898 abc = #3# zz = +45 67 8989898+ 185
Problema resuelto Supóngase que se desea leer un determinado número de párrafos escritos en español que contienen información acerca de científicos y extraer de ahí los nombres propios y los años citados; por ejemplo, el siguiente texto:12 “Sir Isaac Newton (1642 -1727), físico, filósofo, teólogo, inventor, alquimista y matemático inglés, comparte con Leibniz el crédito por el desarrollo del cálculo integral y diferencial, que más tarde Newton utilizó para formular sus leyes de la física. Entre sus principales hallazgos científicos, destaca el descubrimiento de que el espectro de color que se observa cuando la luz blanca pasa por un prisma es inherente a esa luz, en lugar de provenir del prisma (como había sido postulado por Roger Bacon, en el siglo xiii). Newton fue el primero en demostrar que las leyes naturales que gobiernan el movimiento en la Tierra y las que gobiernanloseltiempos, movimiento loscomo cuerpos celestes sonde laslamismas. A menudo, le califica como el científico más grande de todos y su de obra la culminación revolución científica.seCon base en su obra, el matemático y físico matemático Joseph Louis Lagrange (1736-1813), dijo de Newton que ‘fue el más grande genio que ha existido y también el más afortunado, debido a que solo se puede encontrar una vez un sistema que rija al mundo’. ” A partir de la información expuesta en los párrafos anteriores, vamos a desarrollar un programa que extraiga las dos siguientes listas: Isaac Newton, Roger Bacon, Newton, Tierra, Joseph Louis Lagrange, Newton. 1642, 1727,1736, 1813.
Entonces, la entrada del programa es un conjunto de cadenas de caracteres, donde cada cadena termina con una línea nueva. La salida está formada por las dos listas anteriores: la lista de nombres propios (que son cadenas de caracteres) y la lista de años, los cuales podemos considerar como valores enteros entre 1000 y 2010. El programa debe leer cadena por cadena. Para indicar el final del programa, al final debemos introducir la última cadena conteniendo solo un carácter especial, por ejemplo: *. La estructura del programa es bastante simple y con una proposición iterativa de tipo while. De esta forma, es posible almacenar toda la información y los datos que nos interesan (en este caso, los años y los nombres) en arreglos globales de tipo entero y cadenas de caracteres. Asimismo, como variables globales guardamos no_anos y no_nombres. El programa es entonces: #include
186
Extracto de Wikipedia.
printf(“ Introducir una a una las lineas con el texto:\n”); while(1) { gets(cadena); if (cadena[0] == ’*’) break; trata_cadena(cadena); } if (no_anos !=0) { printf(“ Los anos encontrados (%d): ”, no_anos); for (i = 0; %d”, i < no_anos; printf(“ anos[i i++) printf(“\n”); } if (no_nombres !=0) { printf(“ Los nombres encontrados (%d): ”, no_nombres); for (i = 0; i < no_nombres; i++) printf(“ %s”, nombres[i printf(“\n”); } exit(0); }
Por el momento, la función trata_cadena no se ha explicado, aunque es la parte más importante del tratamiento. En la cadena debemos buscar números que sean sucesiones de cifras, así como nombres propios que sean sucesiones de letras; de esta forma, la primera letra es mayúscula hasta que la cadena se termine o cuando encuentre una letra minúscula después de un separador. Para detectar el tipo de carácter, vamos a utilizar la función estándar isdigit, para verificar si este carácter es una cifra, y las funciones estándares isupper y islower, para comprobar si son letras, y la función isspace para los separadores. Si en la función trata_cadena se encuentra una cifra o una letra mayúscula, se realiza el siguiente tratamiento: En caso de una cifra, se recuperan todos los caracteres que siguen y que son cifras. Luego, se copian todos estos
caracteres en una cadena temporal; al final de esta cadena, se pone el carácter de fin de línea. La cadena relativa (variable numero) se convierte en número y si el número respeta el intervalo [1000, 2011] el valor de conversión se almacena en el arreglo global números. En caso de una mayúscula, el tratamiento es parecido al del punto anterior. Primero, se toman los caracteres que
son separadores y letras; es importante destacar que para las letras minúsculas no se permite que haya antes un separador. Una vez que la cadena se detecta completamente, se copia con la función estándar strcpy en el arreglo global nombres. Entonces, el código de la función es: void trata_cadena(char *s) { int n; int i, j, k, letra, valor; char numero[10], nombre[100]; n = strlen(s); i = 0; while ( i < n) 187
{ if (isdigit(s[i])) { k = 0; j = i; while (j < n && isdigit(s[j])) { numero[k] = s[j]; k++; j++; } numero[k] = ’\0’; valor = atoi(numero); if (valor >= 1000 && valor <= 2011) anos[no_anos++] = valor; i = j; continue; } if (isupper(s[i])) { nombre[0] = s[i]; k = 1; j = i+1; letra = 0; while ( j < n && (isupper(s[j]) || isspace(s[j]) || (islower(s[j]) && letra == k-1))) { nombre[k] = s[j]; if (islower(s[j]) || isupper(s[j])) letra = k; k++; j++; } nombre[letra+1] = ’\0’; strcpy(nombres[no_nombres++], nombre); i = j; continue; } i++; } return; }
Así, la salida del programa anterior es: Los anos encontrados (4): 1642 1727 1736 1813 Los nombres encontrados (8): Isaac Newton Newton Leibniz Roger Bacon Newton Tierra Joseph Louis Lagrange Newton
4.3 La función main En el momento de la ejecución del programa, en ocasiones sería más útil que el ejecutable corriera sin la intervención de un usuario (por ejemplo, para introducir datos). Pero, para que el ejecutable sea “parametrable”, esto es, que se puede indicar en línea de comando, más que el nombre del ejecutable, se requieren algunos parámetros en el programa. 188
El lenguaje C ofrece esta posibilidad a través de los parámetros de la función main. Hasta ahora, solo hemos dado a conocer los parámetros formales de la función main, sin indicar cuál de estos está en uso. Así, el prototipo de la función main es: int main(int argc, char *argv[
Por su parte, el parámetro formal argc es un entero que señala el número de “palabras indicadas en la línea de comando, incluso el nombre del ejecutable (argc vale al menos 1)”. El parámetro formal argv es un tipo de arreglo de cadenas de caracteres y su característica es que contiene los parámetros actuales del ejecutable. Los parámetros actuales del ejecutable se separan entre sí por espacio o tabulaciones y todo aparece sobre una misma línea. Ejemplo
El siguiente programa imprime sus propios parámetros del ejecutable. /* programa que cuenta los parametros en linea de comando */ #include
Si la compilación se hace con el comando, entonces: gcc main_ejemplo1.c -o tt
El nombre del ejecutable es tt y su ejecución se realiza con el comando Unix ./tt.
Ejemplo
Enseguida se presenta un ejemplo de ejecuciones: maquina> ./tt 34 67 556 RRR Hay 5 parametros Los parametros son :*./tt* *34* *67* *556* *RRR* maquina> ./tt Hay 1 parametros Los parametros son :*./tt*
En el caso de que los parámetros del ejecutable se deban tratar como números, entonces se aplican las funciones de conversión de tipo atoi y atof. 189
Ejemplo
En el siguiente programa se realiza la suma de los números entregados como parámetros en línea de comando. Es importante hacer notar que no hay más que 10. /* programa que hace la suma de sus parametros enteros */ #include
4.4 Archivos En computación y en el sentido más amplio posible, un archivo es un conjunto de bits que se encuentra en un dispositivo para almacenar datos. Para un manejo adecuado, un archivo se identifica con un nombre y con una ubicación en el dispositivo donde se encuentre, que puede ser la memoria principal o la memoria secundaria o en dispositivos externos. En un archivo es posible leer o escribir datos (a condición de tener los derechos). Para el caso que nos ocupa, podemos decir que el lenguaje C es capaz de trabajar con archivos del sistema operativo. De esta forma, existen dos formatos de archivos que este lenguaje sabe manejar en lectura y/o escritura: Archivos de tipo texto (formato texto). De este tipo de archivos nos interesan las sucesiones de bytes que se
forman, los datos que se introducen y que se leen en un formato de impresión.
Archivos de tipo binario(formato binario). Gracias a este tipo de archivos, el manejo de la información se realiza a
nivel de bits; en este caso, los números están escritos en el formato interno de representación. Un archivo de texto puede ser leído con cualquier editor de texto. Por lo general, durante la lectura/escritura de números flotantes, se pierde la precisión. Un mayor espacio de representación (con más cifras) puede ser una solución relativa. 190
Por su parte, un archivo en modo binario está dirigido únicamente a programas capaces de leerlo o escribirlo. Así, un número se escribe con su representación interna (representación binaria o punto flotante), mientras que un carácter se escribe en código ASCII. El lenguaje C trata la entrada/salida de los archivos en términos de flujo de datos, algo parecido a los canales a través de los cuales fluyen los datos. En este, podemos desplazarnos por el flujo, corriente arriba o corriente abajo, buscando un bit en particular o buscando el último bit o el penúltimo. Hasta el momento, solo hemos trabajado con el flujo estándar de lectura stdin (el teclado) y con el flujo estándar de salida stdout (la pantalla). Un archivo abierto produce un flujo en el programa, donde es posible abrir simultáneamente hasta FOPEN_MAX (valor 255) archivos a la vez. El tipo flujo se define como: FILE *nombre_flujo FILE es un tipo predefinido de la biblioteca stdlib.h, en el cual es posible encontrar todas las funciones de esta sección.
Se puede decir que dos funciones son indispensables:
1. Para abrir el flujo: fopen. 2. Para cerrar el flujo: fclose. La función fopen se escribe de la siguiente manera: FILE* fopen(char *nombre_archivo, char *modo)
Esta función abre un archivo nombrado y regresa un flujo; pero, si la apertura no es correcta, la función regresa al valor NULL. El nombre_archivo es una cadena de caracteres que contiene el nombre del archivo y, si es necesario, también el camino.
Ejemplos ”archivo1.dat”; si este archivo está en el mismo repertorio que el ejecutable. ”../datos/archivos2.dat”; si este archivo está en el repertorio relativo ../datos.
El modo de apertura es indicado en el segundo parámetro de la función, como una cadena de caracteres: ”r”; abre un archivo de texto para lectura. ”w”; crea un archivo de texto para escritura, descarta el contenido previo. ”a”; agrega (crea o abre para escribir al final). ”r+”; abre un archivo para actualización (lectura-escritura). ”w+”; crea un archivo para actualización; descarta el contenido previo. ”a+”; agrega, crea o abre para actualización y escribe al final.
Para trabajar con archivos de tipo binario, al final de la cadena se incluye la letra ”b”.
Ejemplo
Así, con base en lo antes expuesto, ”rb” indica la lectura de un archivo binario, y ”w+b” indica la actualización de un archivo binario. 191
La función: int fclose(FILE *fujo)
cierra el archivo y regresa EOF si cualquier error aparece, 0 si no. El valor EOF es una constante de tipo carácter que indica el final de un archivo; también se usa para indicar errores posibles en el uso del archivo. Ejemplo
Se abre un archivo de tipo texto y, si el archivo está disponible, se lee el primer carácter. /* programa que abre un archivo y lee el primer caracter */ #include
En este caso, si el archivo cascara.c existe o no, el programa produce lo siguiente en el directorio corriente: El primer caracter del archivo es / problemas de apertura del archivo cascara.c.
La función es la siguiente: feof(FILE *flujo)
Regresa un valor diferente de 0 si se encuentra al final del archivo y 0 si no está al final. Las funciones de lectura y de escritura en los archivos son similares a las funciones que trabajan con el flujo estándar de entrada o de salida:
1. int fprintf(FILE *flujo, char *formato, ...). Escribe, según el formato en el flujo indicado, los valores y regresa el número de caracteres escritos o un valor negativo si hay errores. 2. int fscanf(FILE *flujo, char *formato, ...). Del flujo, lee los argumentos indicados (que deben ser apuntadores !). Regresa EOF, si se encuentra al final del archivo durante la lectura, o un error. Regresa el número de artículos de entrada leídos y convertidos. 3. int fgetc(FILE *flujo). Regresa el siguiente carácter del flujo o EOF. 192
4. char *fgets(char *s, int n, FILE *flujo). Lee, en la cadena s, el máximo n-1 caracteres o hasta el carácter de la nueva línea que lo incluye; la cadena se termina con ’\0’. Regresa a la cadena s o el valor NULL, si existe error o es el final del archivo. 5. int fputc(int c, FILE *flujo). Escribe el carácter c en el flujo. Asimismo, regresa el carácter c o EOF, si hay error. 6. int fputs(char *s, FILE *flujo). Escribe la cadena s en el flujo y el carácter ’\n’; regresa un valor no negativo o EOF, si existen errores. Los flujos estándares de entrada, de salida y de error son accesibles también como flujo de usuario abierto. Sus nombres están definidos en la biblioteca stdio.h y son: stdin, stdout, stderr. La apertura y el cierre se realizan automáticamente al inicio de la ejecución y al final del programa.
Ejemplo
Considérese un programa, cuyo nombre es cadena.dat, que lee un archivo, extrae y cuenta cuántas palabras hay (una palabra se considera una sucesión de caracteres imprimibles separados por separadores, ya sea un espacio, una tabulación o una línea nueva). /* programa que abre un archivo y lee palabras separadas por separadores o linea nueva */ #include
Si en este programa no hay problemas de apertura, el archivo se recorre en una estructura iterativa while. Si se encuentra EOF, en el caso de lectura errónea, se sale y también usa una salida. 193
Ejemplo
Considérese la construcción de un archivo que contiene valores flotantes entre 0 y pi, y valores de seno y coseno trigonométricos: /* programa que escribe en un archivo valores flotantes y valores de las funciones trigonometricas */ #include
Si el archivo se puede abrir, el programa no escribe nada en el flujo stdout y crea un archivo con 315 líneas de valores. Ejemplo En ocasiones, la partida de un juego puede interrumpirse y reanudarse posteriormente, cierto tiempo después. La mayoría de los juegos diseñados para computadora ofrece dos funciones principales, las cuales son capaces de: 1. Guardar una configuración corriente del juego en un archivo. 2. Cargar un archivo con una configuración no inicial y proponer que el juego continúe.
Con base en lo antes expuesto, queremos proponer estas dos funciones para el juego del gato que fue desarrollado en una sección anterior. Con ese fin, vamos a tomar un nombre por defecto, para cargar las configuraciones; por ejemplo, gato.dat. El juego del gato que se desarrolló antes tiene su configuración definida con base en tres elementos: la matriz del tablero (tablero[3][3]), el jugador que tiene que hacer un movimiento y el número de movimientos realizados. El jugador que sigue en turno, se puede calcular analizando la matriz tablero; si el número de X es mayor que el número de 0, es el jugador 0 quien juega, pero si no es el jugador X. El número de movimientos se detecta haciendo la suma de los valores absolutos del tablero. Entonces, para guardar una configuración del juego del gato es suficiente con guardar la matriz tablero. La función es muy simple:
int guarda_configuracion() { FILE *fgato; int i, j; fgato = fopen(“gato.dat”, “w”);
194
if (fgato == NULL) return FALSO; for ( i = 0; i < DIM; i++) { for ( j = 0; j < DIM; j++) fprintf(fgato, “ %d”, tablero[i][j fprintf(fgato, “\n”); } return CIERTO; }
Para detectar quién es el jugador que sigue en turno, una vez que la matriz está cargada, en el programa hay dos formas de hacerlo: 1. Se cuenta explícitamente el número de cada símbolo y se realiza la comparación. 2. Se hace la suma de todos los elementos de la matriz tablero. int carga_configuracion(short int *jugador, short int *paso) { FILE *fgato; int i, j, suma; fgato = fopen(“gato.dat”, “r”); if (fgato == NULL) return FALSO; for ( i = 0; i < DIM; i++) for ( j = 0; j < DIM; j++) fscanf(fgato, “%hd”, &tablero[i][j suma = 0; *paso == 0; for ( i = 0; i < DIM; i++) for ( j = 0; j < DIM; j++) { suma += tablero[i][j]; * paso += labs(tablero[i][j } if (suma == 0) *jugador = 0; else *jugador = 1; return CIERTO; }
Entonces, almacenamos aquí todas las declaraciones del tablero con su tipo short int, el cual no obliga a utilizar el formato %hd. En este caso, los parámetros se transmiten por referencia.
Síntesis del capítulo Para trabajar con un volumen importante de datos, ya sea de un tipo básico o de un tipo complejo, una buena solución es trabajar con arreglos unidimensionales o multidimensionales. En estos casos, los arreglos se guardan como una zona 195
de memoria contigua y la exploración de estas estructuras se realiza al nivel de elemento. El acceso a un elemento del arreglo se realiza indicando el nombre del arreglo y el índice del elemento al interior del arreglo. Un arreglo se considera como una variable de tipo apuntador del tipo de los elementos del arreglo; por lo general, el arreglo puede tener una o más dimensiones. De esta forma, para cada dimensión hay un índice que permite acceder al elemento; dicho índice es un valor entero entre 0 y dimensión -1. Asimismo, un arreglo puede ser un parámetro de una función. La asignación de la zona de memoria para los elementos de un arreglo se hace, generalmente, de manera estática, al momento de la declaración del arreglo. No obstante, también existe la posibilidad de asignar y reasignar dinámicamente la memoria para los arreglos con una sola dimensión. El tratamiento de las cadenas de caracteres se hace a través de arreglos de caracteres que contienen, en cada elemento, un carácter especial al final, como final de cadena. Para el manejo de los caracteres, existen varias funciones de las bibliotecas estándares. Si se conoce el manejo de arreglos y cadenas de caracteres, el lenguaje C ofrece la posibilidad de escribir programas con parámetros. Los parámetros actuales del ejecutable se tratan al inicio del programa, mientras que el bloque main se maneja a través del tratamiento de los parámetros formales argc y argv, que aparecen en el prototipo del main. Si los parámetros del ejecutable deben tratarse como números, entonces se aplican las funciones de conversión de tipo atoi y atof. El lenguaje C también maneja la noción de archivo texto o binario, tratándolo como un flujo en el cual se puede leer o escribir. Por su parte, el flujo se construye al momento de la apertura del archivo, la cual se realiza a través de una función estándar; siempre es obligatorio que el archivo se cierre.
Bibliografía
Ritchie, Dennis y Kernighan, Brian, El lenguaje de programación C, 2a. edición. Pearson Educación, México, 1991. Tondo, Clovis L. y Gimpel, Scott E., The C Answer Book - the Second Edition , Prentice Hall, Estados Unidos, 1983.
Ejercicios y problemas Para los ejercicios en los que se pide escribir una función, es indispensable escribir el programa principal (main), en el que se debe hacer al menos una llamada a su función.
1. Desarrollar un programa que lea un arreglo de máximo 100 valores enteros positivos y calcule el máximo común divisor de todos los valores introducidos. 2. Verificar si un número es o no la mediana de un conjunto de valores guardados en un arreglo. Los datos son los siguientes: Las entradas del programa son: el arreglo (entero o flotante) y el valor posible de la mediana (del
mismo tipo que el arreglo). 196
La salida es un mensaje “Sí” o “No”. El valor de entrada es o no el valor mediana del arreglo. La mediana es un valor mayor que la mitad de los valores y menor que la otra mitad. Si hay 2N valores, la mediana es mayor que N valores y menor que N otros valores. Si hay 2N+1 valores, la mediana que pertenece al arreglo y es mayor que N valores y menor que N
otros valores. Ejemplos: Para el arreglo 9 0 9 8 0, hay 8, que es el valor mediana. Para el arreglo 19 9 39 0, el valor de 8 puede ser el valor mediana del arreglo.
3. Crear un programa que lea un arreglo y luego: Verifique X de dimena) sión N: si el arreglo tiene sus valores en orden creciente. Es decir, que para un arreglo X[i] ≤ X[i + 1],
i = 1, N − 1
b) Si el arreglo es en orden creciente, calcule el valor de su mediana. 4. Escribir una función que calcule la desviación estándar de los valores de un arreglo. Su prototipo sería: float desviacion(float A[], int N)
5. Escribir un programa C que obtenga los dos valores más grandes de un arreglo de números enteros. Cada valor debe estar acompañado por su posición. Ejemplos Para el arreglo: 5, 8, 10, 13, 2, 5, los dos valores más g randes son 13 y 10, con las posiciones (índi-
ces) 3 y 2. yPara 5, no 5, 0,es1,única!): 0, 1, 2,los 3, valores 5, los dos más sonposiciones 5 y 5, con 1lasy 8. posiciones 0 arreglo: 1 o el (¡La solución másvalores grandes 5 ygrandes 5, con las
6. Escribir una función que calcule dos valores (el elemento mínimo y el elemento máximo de un arreglo). El prototipo debe ser: void min_max(float A[], int N, float *min, float *max)
7. Escribir una función que calcule el promedio olímpico de los valores de un arreglo. El promedio olímpico de un conjunto de valores se calcula tomando todos los valores, excepto los del elemento mínimo y el elemento máximo. 8. Escribir tres funciones para añadir un elemento a un arreglo, conforme a las siguientes posiciones: En la primera posición. En la última posición. En una posición intermedia.
Los prototipos serían de la forma: void anadir(int X[], int *N); void anadir_posicion(int X[], int *N, int pos)
197
9. Trabajo con polinomios. Enriquecer la colección de funciones que trabaja con polinomios escribiendo las siguientes funciones: a) Una función para la diferencia: void diferencia(float P[], float Q[], float R[
b) Una función para el producto: void producto((float P[], float Q[], float R[
c) Una función para la operación de división que regrese otros dos polinomios: void (float P1[], float P2[], float Q[], float R[
Donde: el polinomio P1 es el dividendo, P2 es el divisor, Q es el cociente y R es el residuo.
d) Una función que verifique que el polinomio es 0 (P(X) = 0) int es_cero(float P[
e) Una función que verifique si el valor x es o no una raíz del polinomio: int es_raiz(float P[], float x);
f) Una función que detecte si el valor de un polinomio en un punto es positivo, cero o negativo: int signo(float P[], float x);
La función regresará 1, 0 o −1.
g) Una función que genere una primitiva del polinomio (la primitiva de una función matemática tiene la propiedad de que su derivada es la función inicial): void primitiva(PI[], PR[
10. Desarrollar una función que construya, en un segundo arreglo, la imagen en espejo de un arreglo; el prototipo sería: void espejo(int X[], int Espejo[], int N);
11. Desarrollar una función que transforme el arreglo construyendo la imagen en espejo en el arreglo mismo; el prototipo sería: void espejo_transformacion(int X[], int N);
12. Escribir un programa que cuente el número de apariciones de un arreglo P (un patrón) al interior de un arreglo de trabajo A. La dimensión del arreglo A es M, mientras que N es la dimensión del arreglo P, con N mucho más pequeño que M. Por ejemplo: el patrón1, 2 se encuentra tres veces en el arreglo 0, 1, 2, 1, 1, 1, 2, 2, 1, 2. Y el patrón 1, 2, 1 se encuentra una sola vez en este mismo arreglo. 13. Escribir dos funciones para hacer los corrimientos de un arreglo a la izquierda y un arreglo a la derecha con un número de posiciones:
198
void corrimiento_dr(int X[], int dimension, int pos) void corrimiento_iz(int X[], int dimension, int pos);
14. Desarrollar una función que permute los elementos de un arreglo: int permutacion(int X[], int Y[], int N);
15. Justificar matemáticamente que la función del ejercicio anterior es correcta y analizar si la función puede generar cualquier permutación, con una misma probabilidad. 16. Generación de matrices particulares 5 × 7.Desarrollar un programa para cada una de las siguientes matrices: Una matriz que contiene el valor 0 para cada elemento, excepto la primera y la última líneas y la primera y la última columnas, las cuales contienen el valor 1. Una matriz que contiene los valores 0 y 1, de manera alternada, como un tablero de ajedrez. Una matriz formada por columnas sucesivas de 0 y 1.
17. Si el programa lee una matriz 3 × 3, llenar con 0, 1 y −1 en el sentido del juego del gato; verificar que la matriz pueda ser una configuración posible del juego; es decir, establecer si se cumplen las dos condiciones siguientes: a) Hay el mismo número de 1 y −1 o la diferencia entre los números de 1 y de −1 vale 1. b) Si existe un ganador, el ganador es único (en la matriz no hay tres en línea para X y tres en línea para Y). 18. Si se introduce una matriz 3 ´ 3, que es una matriz de configuración del juego del gato (que cumple con las dos condiciones del ejercicio anterior), hacer una función para cada una de las tres condiciones siguientes: a) Calcular qué jugador sigue o si la partida es finita. b) Verificar si existe una posición que un jugador pueda ocupar para ganar, por ejemplo:
Figura 4.4 Configuración con una posición que permite ganar al jugador a quien le toca el próximo turno.
c) Si el jugador no puede ganar inmediatamente, verificar si hay una posición que debe ocupar para no perder; por ejemplo:
199
Figura 4.5 Configuración con una posición que debe ser ocupada inmediatamente para que el jugador no
pierda.
19. Proponer otra forma de salida para el juego del gato, con un tablero más visible que ocupe, por ejemplo, una parte más grande de la ventana de la pantalla de la computadora. ¿Cuál es la función que debe ser modificada? Desarrollar completamente su función. 20. Desarrollar una versión menos sencilla para el juego del gato, conocida como el juego: “Cinco en línea”, donde el tablero es más grande (por lo menos de 10 × 10) y se requiere que el ganador tenga cinco puntos en línea (ya sea por línea, por columna o por diagonal). 21. Trabajo con una matriz dispersa. En matemáticas y en computación se considera como “dispersa” una matriz de gran tamaño, de la cual varios valores son 0. Por ejemplo, la siguiente matriz: 0 1 0 2 0 3 0 7 0 0 0 0 0 2 6
Con base en esta matriz dispersa:
a) Escribir una función que verifique si una matriz es dispersa o no (por ejemplo, si tiene más de 60% de elementos 0, la matriz se considera dispersa). b) Escribir una función que genere una matriz dispersa. c) Escribir una función que tome de entrada una matriz dispersa que genere tres arreglos de la misma dimensión, que contengan el valor de la matriz y sus índices. Por ejemplo, para la matriz que se presentó antes se genera: V {1 2 3 7 2 6} I {0 0 1 1 2 2} J {1 3 0 2 3 4}
22. Escribir una función que verifique si una cadena de caracteres contiene todos sus caracteres distintos (por ejemplo, (numero) y (nombre), tienen caracteres distintos, pero (programacion) no, porque la letra ’a’ y la letra ’o’ aparecen dos veces).
200
23. Utilizando la función anterior, escribir un programa en C que lea dos cadenas de caracteres, verificar si cada cadena de caracteres tiene caracteres distintos y si la concatenación de las dos cadenas también tiene caracteres distintos. Ejemplos: Cada una de las dos cadenas ”numero” y ”nombre” tiene caracteres distintos, pero su concatenación ”nu-
meronombre” no. ”123” y ”abcdefg” tienen caracteres distintos y su concatenación ”123abcdefg” también.
24. Leer una cadena de caracteres bastante grande que contenga letras, cifras, espacios y otros caracteres imprimibles. Calcular cuántas cifras y cuántos números hay. Recuérdese que número es una sucesión máxima de cifras. Ejemplo: Si la cadena de entrada es ”Hoy es 2 de diciembre de 2010. En el salón hay 38 alumnos y 1 profesor”, el programa debe indicar: 8 cifras, 4 números.
25. Escribir un programa que transforme una cadena de caracteres escritos en una base b, en un número en base 10. El programa debe realizar los siguientes pasos: Leer un valor entero b y verificar si este valor cumple la restricción: 2 < b < 16. Leer una cadena de caracteres y verificar si cada carácter es una cifra o una letra de a hasta f. Si las dos condiciones precedentes no se cumplen, el programa debe terminar. Si las dos condiciones se cumplen, la cadena leída contiene bien definidas las cifras de un número
representado en base de b; se debe calcular el número en base 10 con esta representación.
Ejemplos: si la cadena leída es: 1020 y la base b es 3, el numero representado es 1 × 33 + 2 × 3 1 = 33. Si la cadena es 1f1f y la base es 16 el número representado es: 163 + 15 × 162 + 16 + 15.
26. Escribir un programa que abra un archivo de texto y lea las líneas del archivo. En la salida el programa escribe las líneas del archivo precedidas del número de línea. 27. Escribir un programa que lea un archivo de texto que contenga líneas con información significativa y líneas de comentarios. Una línea de comentario inicia con el carácter #. El programa debe crear un nuevo archivo que no contienga las líneas de comentarios y parar las líneas significativas se eliminan los espacios y las tabulaciones de enfrente del primer carácter imprimible. Por ejemplo, para el archivo que contiene: # Familia Molina Ana Pedro Nicolas # Familia Perez Olga Rodrigo
El archivo de salida del programa debe ser: Ana
201
Pedro Nicolas Olga Rodrigo
28. Crear un programa de nombre que_hacer.c, cuyo ejecutable sería que_hacer que toma en la línea de entrada los nombres y números de teléfono y escribe: Para un nombre (sucesión de letras): escribir correo a … Para un número de teléfono (sucesión de cifras): llamar a … Para otros casos (otras sucesiones de caracteres): nada.
Por ejemplo, la salida de la línea es: ./que_hacer Liliana mama a2b3 1919 !! sería: escribir correo a Liliana escribir correo a mama llamar a 1919
29. Modificar el programa general del juego de gato para permitir que: Al inicio de un partido se cargue una configuarción (si los jugadores lo desean, una configuración cargada). En cualquier momento del partido, se guarde la configuración y termine el programa.
30. Hacer un programa mi_wc.c que funcione como la línea de comando Unix wc; es decir, que tome como argumento en la línea de comando un nombre de archivo y, si el archivo existe y es accesible, realizar la cuenta de los números de líneas, de palabras y de caracteres. Una salida de la línea de comando Unix wc es: > wc cascara.c 9 15 105 cascara.c
31. Escribir un programa en lenguaje C que realice las siguientes operaciones: Lea una cadena de caracteres (de máximo 200 caracteres); esta cadena deberá contener palabras
(sucesión de letras) separadas por uno o más espacios. Construya una cadena de caracteres que contenga únicamente las palabras de más de 2 caracteres y
separadas por un espacio. Escribir la cadena final en la pantalla.
Por ejemplo, si la cadena inicial es: mi
gato tiene
en su boca un raton
la cadena final debe ser:
gato,tiene,boca,raton
32. Escribir un programa que: Lea un archivo con el nombre datos.dat; el cual debe contener líneas con números y cada línea contiene un número variable de números enteros (entre 2 y 10 números separados por un espacio).
202
Escriba en un archivo calculos.dat, para cada línea del archivo de entrada, una línea con el nú-
mero de valores, el valor mínimo, el valor máximo, el promedio aritmético y el valor de la mediana (cuidado, los últimos dos valores son flotantes). Por ejemplo, el archivo de entrada siguiente: 2 10 12 3 3 3 3 3 3 3 0 9 8 7 6 5 4 3
Donde el archivo de salida sería: 3 2 10 8.0 10.0 7 3 3 3.0 3.0 8 0 9 5.25 5.5
203
Introducción a la programación
5
Contenido 5.1 Introducción 5.2 Tipos de datos definidos por el usuario Nombramiento de los tipos Tipos estructurados Definición de tipos estructurados Trabajo con variables de tipo estructurado Apuntadores de los tipos compuestos Tipos estructurados referenciados por otros tipos estructurados Tipos estructurados auto referenciados Tipo enumeración y tipo unión Problema resuelto 5.3 Estructuras de datos Arreglos Listas ligadas Listas circulares Listas doblemente ligadas 5.4 Tipos abstractos de datos Listas 204
Grafos 5.5 Problemas resueltos Problema de Josephus Síntesis del capítulo Bibliografía Ejercicios y problemas
Objetivos permite la definición de nuevos tipos de datos. creación y el uso de nuevos tipos de datos. para usarse. 204
5.1 Introducción El principal objetivo de este capítulo es presentar y desarrollar las nociones de tipo definido y trabajar con cada unos de estos tipos, con el fin de manejar estructuras de datos avanzadas y tipos abstractos de datos.
5.2 Tipos de datos definidos por el usuario El lenguaje C ofrece la posibilidad de trabajar con otros tipos de datos, además de los tipos estándares, como escalares, arreglos o apuntadores. Con estos nuevos tipos de datos, que están definidos por el usuario, se procede de la misma manera que con cualquier otra herramienta del lenguaje (variables, funciones, etiquetas, entre otras). El nuevo tipo se define mediante una sola declaración y luego se utiliza tantas veces como sea necesario. La visibilidad del tipo definido es la misma que la de la definición de las variables. Si las declaraciones del tipo se hacen al interior de un bloque, únicamente al interior de dicho bloque se puede usar el nuevo tipo; en caso contrario, la solución que se aplica, generalmente, es la siguiente: los nuevos tipos se declaran a nivel global, fuera del bloque main. Los tipos definidos por el usuario se pueden utilizar de la misma forma que los tipos estándares: en la declaración de variables, como tipo de función (tipo del valor regresado), como tipo de parámetros en funciones, para la definición de nuevos tipos nombrados o implícitos, etcétera.
Nombramiento de los tipos Los tipos estándares y los tipos definidos por el usuario pueden ser nombrados con otro nombre; el nombre otorgado constituye un identificador o un sinónimo de un tipo conocido; aunque también se utiliza para identificar un nuevo tipo. La introducción del nuevo nombre se hace con la declaración typedef: typedef tipo_inicial nombre_nuevo_tipo; typedef tipo_inical nombre_nuevo_tipo[constante]; typedef tipo_inicial *nombre_nuevo_tipo; . . .
Ya sea por un sinónimo de tipo, por un tipo arreglo o por un tipo apuntador, aquí typedef es una palabra clave, mientras que tipo_inicial es el tipo que produce el nuevo tipo nombrado: nombre_nuevo_tipo. El uso de la declaración typedef es muy parecido a la declaración de una variable, solo en el caso para el objeto que se construye con el nuevo tipo, por lo que no se afecta ninguna zona de la memoria.
Ejemplos typedef int entero; typedef entero arreglo10[10]; typedef entero entero (*funcion_bi_operandos)(entero, *apuntador_entero; typedef entero);
En esta parte: entero es un sinónimo del tipo estándar int. arreglo10 es el tipo que corresponde a un arreglo de dimensión 10 de elementos de tipo entero. 205
Introducción a la programación
apuntador_entero es un tipo apuntador por una zona de memoria que contiene un valor de tipo entero. funcion_bi_operandos es el tipo apuntador de una función que regresa un tipo entero y sus dos paráme-
tros también son de tipo entero. Los nombres o identificadores que se dan a los nuevos tipos son seleccionados por el usuario, solo se impone que sean identificadores únicos. No obstante, para mejorar la lectura y el trabajo con los programas se aconseja utilizar identificadores escritos solo con mayúsculas (por ejemplo, ENTERO, ARREGLO10, etc.) o identificadores de la forma Tnombretipo o de la forma T_nombretipo (por ejemplo, T_entero, T_arreglo10, …). En la práctica, los nuevos tipos se utilizan como cualquier otro tipo, solo se debe considerar hacer la conversión de tipo por asignaciones o por llamadas de funciones estándares.
Ejemplo En el siguiente programa se utilizan nuevos tipos derivados del tipo int, a los cuales se aplica la conversión de tipo (cast); a su vez, sobre estos tipos se aplica la función estándar sizeof y se hacen las asignaciones. /* programa que trabaja con nuevos tipos */ #include
= = = =
(T_entero)c; &a; (T_apuntador_entero)(&c); b;
for (i =0; i < 10; i++) V[i] = (T_entero)(i*i); d = &V[3]; printf(“ a = %d\n”, (int)a); printf(“ dimensiones : T_entero =%ld, T_arreglo10=%ld, T_apuntador_entero=%ld\n”, sizeof(T_entero), sizeof(T_arreglo10), sizeof(T_apuntador_entero)); printf(“ El valor apuntado por b :%d\n”, (int)(*b)); printf(“ El valor apuntado por d :%d\n”, (int)(*d)); exit(0); }
Este programa produce la siguiente salida: a = 17 dimensiones : T_entero = 4, T_arreglo10 =40, T_apuntador_entero=8 El valor apuntado por b :17 El valor apuntado por d :9 206
Ejemplo En el siguiente programa se utilizan el tipo de apuntador de función y la llamada de una función de tipo, ambos definidos por el usuario. /* programa con funcion de tipos definidos por el usuario y con tipo de apuntador de funcion */ #include
Este programa produce la siguiente salida: La suma de a y b es : 22 El doble de a es : 24
Tipos estructurados Los tipos estructurados (o compuestos) permiten conjuntar diversos datos e informaciones que se relacionan con un mismo concepto. De esta forma, un tipo estructurado se caracteriza por tener más de un valor de cualquier tipo definido, como un tipo estándar u otro tipo estructurado, e incluso otros tipos estructurados y apuntadores de cualquier tipo; un ejemplo de esto es cuando se requiere trabajar con fechas, ya que una fecha se define por el valor del día, el valor del mes y el valor del año. Si trabajamos con tipos conocidos, es posible trabajar con tres variables de tipo entero ( short o int) paravariables una solayfecha; noun obstante, este caso se presentan algunas desventajas dar concepto. nombre a las obtener númeroengrande de parámetros para las funciones que evidentes, trabajaríancomo con este Por ejemplo, en el caso de una función que debe comparar dos fechas para indicar si una fecha es mayor que otra, es decir, si la primera fecha es más reciente que la otra, esta tendría un prototipo con seis parámetros: int compare_fechas(int dia1, int mes1, int ano1, int dia2, int mes2, int ano2) 207
Introducción a la programación
Definición de tipos estructurados La noción de tipo estructurado o compuesto permite agrupar valores de varios (otros) tipos. Entonces, un tipo estructurado se define como: struct nombre_tipo_estructurado { tipo1 campo1; tipo2 campo2; ... }
Aquí, struct es una parabra clave; nombre_tipo_estructurado es un identificador que debe ser único; tipo1, tipo2 son tipos conocidos, estándares o definidos por el usuario al mismo nivel o a un nivel más alto, y campo1, campo2 son los identificadores que designan las componentes del nuevo tipo estructurado. Sin embargo, estos identificadores son únicos solo al interior de la definición del tipo. Es importante resaltar que un tipo estructurado siempre tiene al menos una componente, y no un límite para el número total de sus componentes. Esta definición de tipo tiene efecto a nivel del compilador; permite usar el tipo para definir variables simples, apuntadores, arreglos y otros tipos, entre otras cosas. La construcción struct indica este nuevo tipo definido por el usuario; sin embargo, no se hace ninguna asignación de memoria. La definición de un tipo estructurado puede estar seguida de la declaración de variables de este tipo o puede incluirse en una declaración typedef. Para el ejemplo de trabajo con fechas, podemos usar la siguiente definición: struct fecha { int dia; int mes; int ano; };
Las variables de tipo struct fecha se declaran de la siguiente manera: struct fecha f1, f2={12, 07, 2011};
En este caso, la variable f1 no está inicializada, al contrario de la variable f2 que sí lo está; la inicialización se realiza indicando los valores de cada campo. Al momento de la declaración de estas dos variables, se hace la asignación de memoria para cada una de estas variables; el espacio de memoria asignado tiene un tamaño de 12 bytes: 12 = 3 x sizeof(int) La función sizeof se aplica tanto a las variables del tipo compuesto como al mismo tipo. El código siguiente: printf(“ espacio-f1 = %ld
espacio-f2 = %ld\n”, sizeof(f1), size of(f2));
printf(“ espacio del tipo (struct fecha) = %ld\n”, sizeof(struct fecha)); produce: espacio-f1 = 12 espacio-f2 = 12 espacio del tipo (struct fecha) = 12
208
En este caso, también se pueden usar las siguientes declaraciones, las cuales son semánticamente equivalentes al ejemplo anterior: struct fecha { int dia; int mes; int ano; } f1, f2={12, 07, 2011};
O typedef struct fecha { int int dia; mes; int ano; } Tfecha; Tfecha f1, f2={12, 07, 2011};
Para el primer caso, las variables f1 y f2 están declaradas al mismo nivel que el tipo mismo. En cambio, en el segundo ejemplo, Tfecha es un nombre de tipo que es equivalente al tipo struct fecha. Por tanto, es posible que uno o más campos de un tipo estructurado sean de otro tipo estructurado. En el siguiente ejemplo, los tipos estructurados struct fecha y struct persona permiten manejar fechas y nombr es (nombre y apellido) de personas, ya que el siguiente tipo compuesto describe información sobre un curso en una universidad: # define MAX_CHAR 50 # define MAX_HORAS_CURSO 40 struct fecha { int dia; int mes; int ano; }; struct persona { char nombre[MAX_CHAR]; char apellido[MAX_CHAR]; }; struct curso { char intitulado[MAX_CHAR]; int numero_horas; struct persona profesor; struct persona ayudante; char salon[MAX_CHAR]; struct fecha fecha_inicio; struct fecha fecha_examen_global; struct fecha fecha_curso[MAX_HORAS_CURSO]; };
En el ejemplo anterior, dos campos son de tipo estructurado struct fecha; dos son de tipo struct persona, y uno es un arreglo de tipo struct fecha. Los campos intitulado, numero_horas y salon son de tipo estandar. 209
Introducción a la programación
Trabajo con variables de tipo estructurado El acceso a los campos de una variable de tipo estructurado se efectúa con el operador . (punto). Por su parte, la construcción de v.campo permite acceder al valor de campo de nombre campo, parte del tipo compuesto de la variable v. La construcción de v.campo se trata como una variable del tipo indicado en la definición del tipo estructurado, en las asignaciones, en los parámetros actuales de llamada de funciones, en las expresiones, etcétera.
Ejemplo Para las variables f1 y f2 de tipo struct fecha, las construcciones f1.ano y f2.ano indican el contenido
del tercer campo de cada variable y se tratan como variables de tipo int. Por struct curso, CC.intitulado una variable CC de tipotipo es dedetipo caracteres, CC.numero_horas es de int, CC.fecha_curso es un arreglo tipochar[] structcadena fechade , CC.fecha_
curso[1].mes es un valor de tipo int.
La inicialización al momento de la declaración de una variable de tipo estructurado sí es posible; esta se realiza de la misma manera que la inicialización de los arreglos, siguiendo los pasos que se describen a continuación:
1. La lista de valores de cada campo se indica entre corchetes. 2. La inicialización puede ser parcial, indicando solo algunos valores de campos. 3. Como en el caso de los arreglos no se pone ningún valor por defecto, el valor de los campos sin inicialización está no determinado. Ejemplos struct fecha f1, f2={12, 07, 2011}; struct curso CC = {“Algebra 2”, 25, {“Maria”, “Lopez”}, {“Mario”, “Rodriguez”}, “S.Azul”, {15, 9, 20011}, {12, 12, 2011}, {{}, {18, 19, 2011}}}, CYY = {“Geomet ria analitica”, 40, {“invitado”}};
En este caso, la variable f2 es completamente inicializada, mientras que la variable f1 no es inicializada. Por su parte, la variable CC es inicializada por la mayoría de los campos, excepto el campo fecha_curso, el cual es un arreglo; en tanto, el segundo elemento sí tiene una inicialización. La variable CYY es parcialmente inicializada, por los tres primeros campos, mientras que el campo profesor es parcialmente inicializado. El operador de asignación = funciona de manera global; esto significa que sí es posible aplicar este operador entre diversas variables de tipos compuestos, a excepción de una sola restricción: que la parte izquierda y la parte derecha sean del mismo tipo. También es posible poner en la parte izquierda de un operador de asignación cualquier campo de una variable de tipo estructurado, aunque siempre con la única restricción de equivalencia de tipos. Los efectos del operador de asignación de los tipos compuestos (estructurados) son exactamente los mismos que para los tipos escalares:1
1. El valor obtenido se copia en la parte derecha. 2. La dirección de memoria de la variable se copia en la parte izquierda. Ejemplo
En el código: 1
210
Se recuerda al lector que el operador de asignación no funciona con un arreglo en su parte izquierda.
struct fecha f1, f2={12, 07, 2011}; f1 = f2;
la variable f1 toma como valor el mismo valor que la variable f2. Ejemplo En el código: struct curso CC = {“Algebra 2”, 25, {“Maria”, “Lopez”}, {“Mario”, “Rodriguez”}, “S.Azul”, {15, 9, 20011}, {12, 12, 2011}, {{}, {18, 19, 2011}}}, CYY = {“Geomet ria analitica”, 40, {“invitado”}}; printf(“El curso %s inicia en el mes de %02d de %4d\n”, CC.intitulado, CC.fecha_inicio.mes, CC.fecha_inicio.ano); CC.fecha_curso[0] = CC.fecha_inicio; printf(“ Primera clase del profesor %s es %02d/%02d/%4d\n”, CC.profesor.apellido, CC.fecha_curso[0].dia, CC.fecha_curso[0].mes, CC.fecha_curso[0].ano); CYY.ayudante = CC.ayudante; printf(“El ayudante para el curso %s es %s %s\n”, CYY.intitulado, CYY.ayudante.nombre, CYY.ayudante.apellido); // CYY.profesor = {“John”, “Johnson”}; asignacion incorrecta strcpy(CYY.profesor. nombre, “John”); strcpy(CYY.profesor.apellido, “Johnson”); printf(“El profesor para el curso %s es %s %s\n”, CYY.intitulado, CYY.profesor.nombre, CYY.profesor.apellido);
se hacen varias asignaciones: La asignación tiene como objetivo hacer una copia del valor que se encuentra del lado derecho del signo igual (=). La asignación también es una asignación entre variables del mismo tipo compuesto struct persona, donde las
cadenas de los campos nombre y apellido de la parte derecha se copian en los campos del mismo nombre en la parte izquierda. La asignación como comentario no es correcta; la forma con el uso de corchetes se usa solo en el caso de la ini-
cialización, al momento de la declaración de variables. Para asignar valores a los campos componentes de CYY.profesor (a saber, CYY.profesor.nombre y CYY.
profesor.apellido) se usa la función strcpy, ya que estos son de tipo cadena de caracteres.
El fragmento del programa anterior produce: El curso Algebra Primera clase El ayudante para El profesor para
2 inicia en el mes de 09 de 20011 del profesor Lopez es 15/09/20011 el curso Geometria analitica es Mario Rodriguez el curso Geometria analitica es John Johnson
El lenguaje C permite utilizar los tipos compuestos como tipos de parámetros de función y también como tipos de regresoen delauna La transmisión los parámetros se hace valor odeportipo referencia; en el caso de transmisión por valor pilafunción. de llamadas, se copia de el contenido completo de lapor variable estructurado. Para el tipo struct fecha que fue definido antes, podemos decir, por ejemplo, que posee las siguientes funciones: void imprima_fecha(struct fecha f) { 211
Introducción a la programación
printf(“ %02d/%02d/%4d “, f.dia, f.mes, f.ano); } int equivalencia_fechas(struct fecha x1, struct fecha x2) { if (x1.ano == x2.ano && x1.mes == x2.mes && x2.dia == x2.dia) return 1; else return 2; } int compar_fechas(struct fecha f1, struct fecha f2) { if return (f1.ano1;< f2.ano) if (f1.ano > f2.ano) return -1; if (f1.mes < f2.mes) return 1; if (f1.mes > f2.mes) return -1; if (f1.dia < f2.dia) return 1; if (f1.dia > f2.dia) return -1; else return 0; } struct fecha inicio_ano(struct fecha f) { struct fecha aux = {1,1}; aux.ano = f.ano; return aux; }
En este caso, la función imprima_fecha realiza la impresión en un formato conveniente con solo una llamada a printf. Las funciones equivalencia_fechas y compar_fechas sirven para comparar valores de tipo estructurado. Por su parte, la función inicio_ano es una función que construye la fecha del 1 de enero del mismo año que el valor transmitido por el parámetro. A continuación se presenta el ejemplo de un programa que hace llamadas a estas funciones: struct fecha f1, f2={12, 07, 2011}, f3; int valor; f1 = f2; printf(“ La primera fecha :”); imprima_fecha(f1); printf(“\n”); if printf(“ (equivalencia_fechas(f1, f2)) Mismas fechas\n”); else printf(“ No\n”); imprima_fecha(f1); f3 = inicio_ano(f1); valor = compar_fechas(f1,f3); 212
switch(valor) { case 1 : printf(“ es antes de “); break; case -1 : printf(“ es despues de “); break; case 0 : printf(“ es misma que “); break; } imprima_fecha(f3); printf(“\n”);
La salida de este programa es: La primera fecha : 12/07/2011 Mismas fechas 12/07/2011 es despues de 01/01/2011
Apuntadores de los tipos compuestos El trabajo conapuntadores de tipo estructurado es similar al trabajo que se realiza con cualquier otro tipo apuntador. Por ejemplo, en el siguiente programa apf es un apuntador que, una vez que se realiza la asignación apf = &f2, permite modificar directamente el contenido de la variable f2. struct fecha f1, f2={12, 12, 2011}; struct fecha *apf; f1 = f2; printf(“ La primera fecha :”); imprima_fecha(f1); printf(“\n”); apf = &f2; (*apf).mes++; if ((*apf).mes == 13) { (*apf).mes = 1; (*apf).ano++; } printf(“ La segunda fecha transformada:”); imprima_fecha(f2); printf(“\n”);
La salida del programa es: La primera fecha :12/12/2011 La segunda fecha transformada:12/01/2012 La escritura (*apf).dia indica que primero se accede al contenido global de la zona de tipo estructurado y luego se extrae el campo dia.
En este caso, la novedad es que hay un operador de acceso -> (guión y mayor) que compone las dos operaciones: la de acceso al valor compuesto y la de extracción de un campo. 213
Introducción a la programación
Asimismo, el código anterior puede ser reescrito de la siguiente manera: apf->mes++; if (apf->mes == 13) { apf->mes = 1; apf->ano++; }
La construcción (*apf).mes es correcta; sin embargo, se acostumbra usar la construcción más leíble: apf->mes. El operador -> tiene la más alta prioridad; por tanto, en la expresión apf->mes++, se evalúa primero el operador -> de acceso y luego el operador ++ de incremento. El operador de acceso ., también tiene una prioridad alta con respecto a los operadores aritméticos, lógicos y relacionales.2 En los apuntadores de tipos estructurados también es posible utilizar la función asignación dinámica de memoria malloc, la cual permite asignar una zona en la memoria en donde se puede guardar un valor del tipo compuesto.Se aconseja liberar la zona de memoria con la función free al final del programa o al final del tratamiento de la zona de memoria asignada dinámicamente.
Ejemplo apf = (struct fecha*)malloc(sizeof(struct fecha)); apf->dia = 6; apf->mes = 6; apf->ano = 2011; printf(“ La fecha generada es :”); imprima_fecha(*apf); printf(“\n”); free(apf);
En el caso de las funciones con parámetros de tipo estructurado transmitidos por referencia, el acceso a los campos se hace con el operador de acceso ->. Ejemplo void transforma_manana(struct fecha *f) { f->dia++; if (f->mes ==1 || f->mes == 3 || f->mes == 5 || f->mes == 7 || f->mes == 8 || f->mes == 10) // mes de 31 dias excepto diciembre { if (f->dia == 32) { f->dia = 1; f->mes++; } return; } else if (f->mes == 2) // mes de febrero { if ((f->dia == 29 && f->ano%4 ! = 0) || (f->dia =30 && f->ano %4 ==0)) { f->dia = 1; f->mes = 3; } return; 2
214
Véase la tabla del capítulo 2 que contiene las prioridades y las reglas de asociatividad para todos los operadores del lenguaje.
} else if (f->mes == 4 || f->mes == 6 || f->mes == 9 || f->mes ==11) // mes de 30 dia { if (f->dia == 31) { f->dia = 1; f->mes++; } return; } else if (f->dia == 32) // mes de diciembre { f->dia = 1; f->mes = 1; f->ano++; return; } return; }
Este código es una función que transforma la fecha transmitida por parámetros incrementándola. Una llamada a esta función es la siguiente: apf = (struct fecha*)malloc(sizeof(struct fecha)); apf->dia = 31; apf->mes = 7; apf->ano = 2011; printf(“ La fecha generada es :”); imprima_fecha(*apf); printf(“\n”); printf(“ La fecha siguiente es :”); transforma_manana(apf); imprima_fecha(*apf); printf(“\n”);
Este código produce la siguiente salida: La fecha generada es :31/07/2011 La fecha siguiente es :01/08/2011
Tipos estructurados referenciados por otros tipos estructurados Un tipo estructurado puede contener un campo de tipo apuntador con cualquier tipo estructurado, incluso puede tener un apuntador con el tipo estructurado mismo.
Ejemplo
El tipo struct struct fecha. curso_ref posee como campos apuntadores a los tipos estructurados: struct persona y struct fecha { int dia; int mes; int ano; 215
Introducción a la programación
}; struct persona { char nombre[MAX_CHAR]; char apellido[MAX_CHAR]; }; struct curso_ref { char intitulado[MAX_CHAR]; int numero_horas; char salon[MAX_CHAR]; struct struct struct struct struct };
persona *profesor; persona *ayudante; fecha *fecha_inicio; fecha *fecha_examen_global; fecha *fecha_curso[MAX_HORAS_CURSO];
El tipo estructurado contiene campos de tipos clásicos: entero o cadenas de caracteres, pero también tiene cuatro apuntadores de otros tipos estructurados y un arreglo de apuntadores del tipo struct fecha. La figura 5.1 constituye un esquema de la estructura del tipo struct curso_ref: fecha_examen_global fecha_curso intituladonume ro_horas salon profesor ayudante fecha_inicio struct curso_ref
dia mes ano nombre apellidos struct persona nombre apellidos struct persona
struct fecha
dia mes ano struct fecha
dia mes ano struct fecha
dia mes ano struct fecha
Figura 5.1
En este caso, lo más difícil no es la declaración del tipo sino el trabajo con estos campos de tipo apuntador. Inicialmente, el apuntador tiene un valor indefinido, excepto si se inicializa a NULL; luego, el apuntador apunta sobre una zona de memoria que ya existe y que fue creada de otra forma, es decir con una llamada a la función malloc. En este último caso, se maneja el operador ->.
Ejemplo Si queremos llenar una estructura de tipo struct curso_ref, por lo común se utiliza una función de la siguiente forma: void lectura_fecha(char mensaje[], struct fecha *f) { printf(“ %s en la forma dia/mes/ano :”,mensaje); scanf(“%d/%d”, &f->dia, &f->mes, &f->ano); return; } 216
En el código del programa principal, para cada apuntador de struct fecha, primero se hace una asignación dinámica de memoria con malloc y luego se llama a la función creacion_lectura_fecha, la cual llena los campos de la estructura apuntada desde la entrada estándar; enseguida, se cambia el apuntador a esta nueva zona de memoria. int main(int argc, char *argv[]) { //struct curso_ref CC; struct curso_ref CYY = {“Geometria analitica”, 4,”Sala Z23”}; //struct fecha *aux; char cadena[MAX_CHAR]; int i; printf(“ Para el curso %s favor de indicar las fechas ¡\n”, CYY.intitulado); creacion_lectura_fecha(“inicio del curso”, &CYY.fecha_inicio); //mismas operaciones por una otra fecha creacion_lectura_fecha(“examen global”, &CYY.fecha_examen_global); // generacion de elementos for ( i = 0; i < CYY.numero_horas; i++) { sprintf(cadena, “ curso no %d “, i +1); creacion_lectura_fecha(cadena, &CYY.fecha_curso[i } // imprima los valores guardados printf(“El curso imprima_fecha(*CYY.fecha_inicio); printf(“ y termina con el examen global “); imprima_fecha(*CYY.fecha_examen_global); printf(“\n”); printf(“ curso no 3 : “); imprima_fecha(*CYY.fecha_curso[3 - 1 printf(“\n”); //libera la memoria por las zonas apuntadas free(CYY.fecha_inicio); free(CYY.fecha_examen_global); for ( i = 0; i < CYY.numero_horas; i++) free(CYY.fecha_curso[i exit(0); }
Al final del programa se liberan todas las zonas de memoria apuntadas asignadas dinámicamente. Así, el programa produce, por ejemplo: Para el curso Geometria analitica favor de indicar las fechas ! inicio del curso en la forma dia/mes/ano :12/09/2011 examen global en la forma dia/mes/ano :12/12/2011 curso no 1 en la forma dia/mes/ano :12/09/2011 curso no 2 en la forma dia/mes/ano curso no 3 en la forma dia/mes/ano curso no 4 en la forma dia/mes/ano El curso Geometria analitica inicia 12/12/2011 curso no 3 : 12/10/2011
:16/09/2011 :12/10/2011 :26/10/2011 12/09/2011 y termina con el examen global
217
Introducción a la programación
Como para todos los apuntadores del mismo tipo ( struct curso_ref ) siempre se hacen las mismas tres operaciones ( malloc , llamada a una función de lectura y asignación de apuntador), se puede pensar que sería mejor escribir una sola función que realice todas estas operaciones. Entonces, el parámetro de entrada sería un campo de tipo apuntador al tipo estructurado, por lo cual se haría la asignación dinámica y la lectura de los valores contenidos en la zona apuntada. En este caso, como el apuntador cambiaría, el tipo de parámetro sería un apuntador de apuntador: struct fecha ** . Por tanto, la función sería la siguiente: void creacion_lectura_fecha(char mensaje[], struct fecha **f) { *f = (struct fecha *)malloc(sizeof(struct fecha)); printf(“ %s en la forma dia/mes/ano :”, mensaje); scanf(“%d/%d/%d”, &(*f)->dia, &(*f)->mes, &(*f)->ano); return; }
Enseguida, se añade un parámetro más que se coloca antes de las lecturas con la función scanf, además de un mensaje indicando qué fecha debe entregarse. El contenido del apuntador de apuntador que cambia, recibe la dirección de la zona de memoria asignada dinámicamente con malloc. Para acceder a los valores que se leen, se aplica el operador -> al apuntador simple *f. Entonces, la función scanf se encuentra en espera de una dirección de memoria; por consiguiente, el acceso a un campo para lectura se hace con una expresión de tipo &(*f)->dia, la cual es equivalente a: &((*f)->dia). De esta forma, el programa principal en su parte media cambia y queda de la siguiente manera: printf(“ Para el curso %s favor de indicar las fechas ¡/n”, CYY.intitulado); creacion_lectura_fecha(“inicio del curso”, &CYY.fecha_inicio); //mismas operaciones por una otra fecha creacion_lectura_fecha(“examen global”, &CYY.fecha_examen_global); // generacion de elementos for ( i = 0; i < CYY.numero_horas; i++) { sprintf(cadena, “ curso no %d “, i + 1); creacion_lectura_fecha(cadena, &CYY.fecha_curso[i }
La salida es estrictamente idéntica a la del programa anterior.
Tipos estructurados auto-referenciados Es importante destacar que no hay ninguna restricción sobre el tipo posible de un apuntador que aparece en un tipo estructurado; entonces, es posible utilizar apuntadores del mismo tipo. Estos tipos estructurados reciben el nombre de auto-referenciados.
Ejemplo
Si tenemos una estructura para modelar la información sobre una persona, podemos añadir apuntadores por estructuras del mismo tipo para indicar los padres. struct persona { char nombre[MAX_CHAR]; char apellido[MAX_CHAR]; struct persona *madre; 218
struct persona *padre; };
En este caso, el esquema de la estructura del tipo struct persona es el siguiente: nombre
apellido
madre
padre
struct persona nombre
apellido
madre
padre
struct persona nombre
apellido
madre
padre
struct persona
Figura 5.2
Un ejemplo de código que trabaja con este tipo auto-referenciado es el siguiente: struct persona ANA = {“Ana”, “Garcia”, NULL, NULL}; struct persona *aux; aux = (struct persona *)malloc(sizeof(struct persona)); strcpy(aux->nombre, “Marisol”); strcpy(aux->apellido, “Garcia”); aux->madre = NULL; aux->padre = NULL; ANA.madre = aux; ANA.padre = (struct persona *)malloc(sizeof(struct persona)); strcpy(ANA.padre>nombre, “Pedro”); strcpy(ANA.padre->apellido, “Garcia”); ANA.padre->madre = NULL; ANA.padre->padre = NULL; printf(“ Los padres de %s %s son : %s %s, su madre, y %s %s, su padre.\n” ANA.nombre, ANA.apellido, ANA.madre->nombre, ANA.madre->apellido, ANA.padre->nombre, ANA.padre->apellido);
En este programa se inicializa una variable ANA con valores en los campos que no son apuntadores; en tanto, en los apuntadores se coloca NULL y luego se intenta colocar referencias en los apuntadores ANA.madre y ANA.padre. En el caso del apuntador ANA.madre, primero se asigna dinámicamente una zona de memoria por un apuntador aux, luego se llama a los campos de esta variable y, por último, se asigna a ANA.madre la variable aux. Para el caso del apuntador ANA.padre, primero se trata directamente con este apuntador la asignación dinámica de la memoria. Para llenar con valores concretos la estructura apuntada por ANA.padre se usan expresiones con dos operaciones de acceso. Así pues, este código produce la siguiente salida: Los padres de Ana Garcia son : Marisol Garcia, su madre, y Pedro Garcia, su padre. 219
Introducción a la programación
Tipo enumeración y tipo unión Los tipos enumeración y unión son tipos de datos mucho más simples de manejar que el tipo struct, además de que se tiene un control sobre los valores permitidos; no obstante, se usan poco. El tipo enumeración permite dar un nombre a un conjunto de identificadores que son constantes de valor entero. La definición de este tipo se hace con la palabra clave enum, como en el siguiente ejemplo: enum nombre_tipo_enumeracion {identificador1, identificador2, ...};
El nombre nombre_tipo_enumeracion es un identificador que indica este tipo de enumeración. Enseguida, se usa enum nombre_tipo_enumeracion como nombre del tipo. Los identificadores entre corchetes también son únicos y se tratan como valores enteros. De esta forma, estos identificadores se pueden indicar también con un valor identificador = valor_entero.
Ejemplo enum calificacion {A, B, C, D, E}; enum escala_dolor {AA = 20, BB = 15, CC = 10, DD = 5, EE = 0};
Aquí A vale 0, B vale 1,… E vale 4. La llamada siguiente a printf es: printf(“ A = %d C = %d 20*B + 3*E = %d\n”, A, C, 20 * B + 3 * E);
Este programa produce la siguiente salida: A = 0 C = 2 20*B + 3*E = 32
Los identificadores A hasta EE tienen los valores indicados. Se pueden declarar variables simples o arreglos del tipo de enumeración. Dichas variables son compatibles con el tipo entero int y la declaración con este tipo de enumeración y con con el tipo int es más una razón de visibilidad de tipos. Ejemplo Con base en el juego del gato3, es posible definir un tipo de enumeración para los valores posibles del tablero de juego y el tablero de este tipo. Así: enum codigo_gato {X = 1, LIBRE = 0, O = -1}; int main(int argc, char *argv[]) { enum codigo_gato tablero[3][3]; int i, j; for (i = 0; i < 3; i++) for (j = 0; j < 3; j++) tablero[i][j] = LIBRE; tablero[0][0] = X; tablero[2][2] = O; for (i = 0; i < 3; i++) { for (j = 0; j < 3; j++) 3
220
Véase el capítulo 2
printf(“ %2d”, tablero [i][j printf(“\n\n”); } exit(0); }
Este programa inicializa el tablero y coloca dos valores en las esquinas. La salida del programa es: 1 0 0 0 0 0 0 0 -1
Este tipo de unión es muy similar sintácticamente al tipo struct, ya que se indica una lista de campos con tipo y nombre, pero semánticamente los dos tipos son muy diferentes. El tipo de unión no indica un conjunto de campos sino una interpretación posible del campo, ya que solo guarda un valor que puede ser interpretado según los tipos indicados. Entonces, la gran diferencia radica en que los campos del tipo de unión son superpuestos en una misma zona de memoria. El tamaño de la zona de memoria ocupada por n valor de este tipo de unión es el máximo de los tamaños de los campos (al contrario de lo que sucede con el tipo estructurado, donde el tamaño es la suma). El tipo de unión se introduce con la palabra clave union; por tanto, la sintaxis es la siguiente: union nombre_tipo_union { tipo1 campo1; tipo2 campo2; ... };
El acceso a los campos se hace con el operador . (punto). En este caso, el nombre del tipo es union nombre_tipo_ union. Este tipo se usa como cualquier otro tipo definido por el usuario.
Ejemplo
En el siguiente programa se introduce el tipo union calificacion, el cual puede contener un flotante de nombre valor20, a fin de guardar los valores numéricos con punto decimal, o un solo carácter, para guardar la calificación de tipo A - F. union calificacion { float valor20; char literal; }; int main(int argc, char *argv[]) { union calificacion mi_calificacion, ayer; mi_calificacion.valor20 = 18.8; printf(“ Mi calificacion de hoy es %f\n”, mi calificacion.valor20); ayer.literal = ‘B’; printf(“ La calificacion de ayer es : %c\n”, ayer.literal); printf(“ El valor de mi_calificacion.literal = %c\n”, mi_calificacion.literal); exit(0); } 221
Introducción a la programación
En este ejemplo, las dos variables de tipo unión se tratan de formas diferentes: una como un valor numérico y la otra como una literal, que son las dos primeras llamadas a la función printf, las cuales son coherentes con las asignaciones que las preceden. La tercera llamada es la más extraña, por lo que el valor contenido en la zona de la variable mi_literal se interpreta sobre el primer byte como un código ASCII. Este programa produce la siguiente salida: Mi calificacion de hoy es 18.799999 La calificacion de ayer es : B El valor de mi_calificacion.literal = f
Es responsabilidad del programador ofrecer una interpretación correcta del campo que se elige para el tratamiento.
Problema resuelto En álgebra, el espacio vectorial n se define como el produc to cartesiano iterado de consigo mismo. Los elementos de n son vectores; un vector v tiene una representación geométrica (norma y ángulo) y una representación algébrica equivalente:
v v= (v 1 , 2v, … ,) n
Entonces, vi
,
para todos i = 1, n.
En este caso, las operaciones algébricas posibles son: suma, diferencia, producto con un escalar, producto con un escalar de dos vectores y producto vectorial. La norma euclidea (o módulo) de un vector en un operario unario. La aplicación deseada es una biblioteca de funciones para manejar vectores de los espacios vectoriales n. Entonces, queremos trabajar con vectores para: Entregar vectores y escribir vectores. Realizar operaciones algébricas si los vectores pertenecen a un mismo espacio vectorial. Calcular el módulo y el vector normalizado asociado. Poder comparar dos vectores; es decir, si son equivalentes (igualdad de todos los componentes) o paralelos.
Por definición, n es el espacio vectorial cartesiano que está dotado con la norma euclidiana. La norma de un vector v (o el módulo) se define como:
v =
n
v2
El vector normalizado se define con respecto a la norma:
v NORM =
1 v v
A excepción del vector 0 = ( 0, 0, … , 0 ) . El vector normalizado tiene siempre su norma equivalente a 1.
Con dos vectores v = ( v1, v2, . . ., vn) y u = (u1, u2, . . ., un) y un escalar k , las operaciones algébricas se definen como: Suma y diferencia:
v + u = (v 1 + u 1 , v 2 + u 2 , . . . , v n + u n) v – u = (v1 – u1, v2 – u2, . . . , vn – un) 222
Producto de un vector con un escalar:
k . v = (kv1, kv2, ..., kvn) Producto escalar:
v ·u =
n
vu
Coseno de dos vectores:
cos(v , u ) =
v ·u v u
Para comparar dos vectores v y u , existe la condición de equivalencia (identidad):
v ≡u
Esta se define como vi = ui, para cualquier i = 1, n. También se definen dos relaciones de posición:
1. Vectores paralelos; v u si y solo si:
cos(v , u ) = ±1
2. Condiciones equivalentes:
k que satisface v = K u .
v
v
NORM
= u NORM.
u si y solamente si cos(v u ) = 0, codición equivalente a: v
u = 0 (producto
escalar equivalente a cero). Los valores de las componentes de un vector se guardan, naturalmente, en un arreglo; por tanto, la dimensión de un vector es un entero. Si trabajáramos con la hipótesis general de estos que elvalores programa con vectores dimensiones, entonces el uso de un tipo estructurado para guardar juntos seríatrabaja una evidencia. Así, de unavarias primera propuesta de tipo puede ser: struct vector_basico { int n; double c[MAX_DIMENSION]; };
En este caso, se toma una constante MAX_DIMENSION como el número máximo autorizado para la componente del vector. Estos datos son suficientes para resolver las preguntas planteadas. Así, se puede decir que la primera parte de esta sección usa este tipo para desarrollar las funciones. Por otro lado, si se realiza una vez el cálculo de la norma, también se puede calcular rápidamente el vector normalizado. La verificación del paralelismo de dos vectores es equivalente a la verificación de la equivalencia (igualdad) de sus vectores normalizados. Lo ideal y más conveniente sería guardar la dimensión y las componentes en la misma estructura, así como el valor de la norma y un apuntador por su vector normalizado. De esta forma, un nuevo tipo estructurado y auto-referenciado sería: struct vector { int n; double c[MAX_DIMENSION]; double norma; struct vector *normalizado; }; 223
Introducción a la programación
La figura 5.3 muestra la estructura de los dos tipos: n
c
norma
normalizado
struct vector
n
c
norma
normalizado
struct vector
n
c
struct vector_basico
Figura 5.3
Solucion básica
Con el tipo estructurado, la función más simple es struct vector_basico ; así, con la ayuda de las definici ones matemáticas, es fácil desarrollar las funciones en lenguaje C de las operaciones algébricas: int suma(struct vector_basico X, struct vector_basico Y, struct vector_basico *Z) { int i; if (X.n != Y.n) return FALSO; Z->n = X.n; for(i = 0; i < Z->n; i++) Z->c[i] = X.c[i] + Y.c[i]; return CORRECTO; } int diferencia(struct vector_basico X, struct vector_basico Y, struct vector_basico *Z) { int i; if (X.n != Y.n) return FALSO; Z->n = X.n; for(i = 0; i < Z->n; i++) Z->c[i] = X.c[i] - Y.c[i]; return CORRECTO; } void producto_con_valor(double k, struct vector_basico X, struct vector_basico *Y) { int i; Y->n = X.n; for(i = 0; i < Y->n; i++) Y->c[i] = k * X.c[i]; return; } int producto_escalar(struct vector_basico X, struct vector_basico Y, double *p) { double s; int i; 224
if (X.n != Y.n) return FALSO; s = 0.0; for(i = 0; i < X.n; i++) s += X.c[i] * Y.c[i]; *p = s; return CORRECTO; }
El resultado de una operación algebraica se traduce en un parámetro transmitido por referencia de la función C que corresponde. Algunas funciones, como suma, regresan un valor de tipo de enumeración: enum verdad {CIERTO = 1, CORRECTO = 1, FALSO = 0}; Un valor FALSO corresponde al caso de las dimensiones de vectores incompatibles.
De igual modo, la lectura y la escritura de un vector también son funciones fáciles de entender: int lectura_vector(struct vector_basico *X) { int n, i; printf(“ Lectura de un vector.\n Introducir la dimension del vector:”); scanf(“%d”, &n); if (n >= MAX_DIMENSION || n <= 0) return FALSO; X->n = n; printf(“ Lectura de los elementos:”); for(i = 0; i < X->n; i++) scanf(“%f”, &X->c[i return CORRECTO; } void imprima_vector(struct vector_basico X) { int i; printf(“ dimension = %d. componentes : “, X.n); for ( i = 0; i < X.n; i++) printf(“ %lf”, X.c[i printf(“\n”); return; }
En la función lectura, la primera lectura de la dimensión no se hace directamente en la variable con, sino en una variable temporal, con el fin de preveer un eventual caso de error (dimensión negativa o fuera de la límite). La función de cálculo del vector normalizado se basa en la función del cálculo de norma y la función producto_con_ valor: float norma(struct vector_basico X) { double s; int i; s = 0.0; for(i = 0; i < X.n; i++) s += X.c[i] * X.c[i]; 225
Introducción a la programación
return sqrt(s); } int vector_normalizado(struct vector_basico X, struct vector_basico *Y) { double nx; nx= norma(X); if (nx == 0.0) return FALSO; // el vector normalizado no esiste producto_con_valor(1/nx, X, Y); return CIERTO; }
Las funciones que verifican el paralelismo o la perpendicularidad de los vectores, también se realizan usando las funciones de cálculo algébrico: int son_paralelos(struct vector_basico X, struct vector_basico Y) { int i; double nx, ny; struct vector_basico NX, NY; nx = norma(X); ny = norma(Y); if (nx ==0.0 || ny ==0.0) return FALSO; // un vector nul no esta paralelo con nadie vector_normalizado(X, &NX); vector_normalizado(Y, &NY); printf(“NX:”);imprima_vector(NX); printf(“NY:”);imprima_vector(NY); for(i = 0; i < X.n; i++) if (NX.c[i] != NY.c[i] ) return FALSO; return CIERTO; } int son_perpendiculares(struct vector_basico X, struct vector_basico Y) { double p; int rep; rep = producto_escalar(X,Y, &p); if (rep == CIERTO && p == 0.0) return CIERTO; else return FALSO; }
Un ejemplo de programa que llama a estas funciones es el que se presenta a continuación: int main(int argc, char* argv[]) { struct vector_basico X, Y, Z, U, V; double k = 3.0; lectura_vector(&X); 226
printf(“ Vector X: “); imprima_vector(X); suma(X, X, &Y); printf(“ Vector Y=X+X: “); imprima_vector(Y); producto_con_valor(k, X, &Z); printf(“ Vector Z = 3 * X: “); imprima_vector(Z); if (son_paralelos(X,X) == CIERTO) printf( “ X y X son paralelos.\n”); else printf( “No, X y X no son paralelos.\n”); lectura_vector(&U); if (vector_normalizado(U, &V) == FALSO) exit(0); printf(“ Vector U: “); imprima_vector(U); printf(“ Vector normalizado de U: “); imprima_vector(V); if (son_perpendiculares(X,V) == CIERTO) printf( “ X y V son perpendiculares.\n”); else printf( “No, X y V no son perpendiculares.\n”); exit(1); }
La salida que produce este programa es la siguiente: Lectura de un vector. Introducir la dimension del vector:4 Lectura de los elementos:1 2 3 4 Vector X: dimension = 4. componentes : 1.000000 2.000000 3.000000 4.000000 Vector Y=X+X: dimension = 4. componentes : 2.000000 4.000000 6.000000 8.000000 Vector Z = 3 * X: dimension = 4. componentes : 3.000000 6.000000 9.000000 12.000000 NX: dimension = 4. componentes : 0.182574 0.365148 0.547723 0.730297 NY: dimension = 4. componentes : 0.182574 0.365148 0.547723 0.730297 X y X son paralelos. Lectura de un vector. Introducir la dimension del vector:4 Lectura de los elementos:4 -3 2 -1 Vector U: dimension = 4. componentes : 4.000000 -3.000000 2.000000 -1.000000 Vector normalizado de U: dimension = 4. componentes : 0.730297 -0.547723 0.36514 8 -0.182574 X y V son perpendiculares.
Sin embargo, una desventaja menor del tipo estructurado elegido, es que el cálculo de la norma se puede realizar varias veces. Por su parte, una desventaja de la construcción de las funciones y del programa general es que se deben declarar todos los vectores de manera estática y no dinámica.
227
Introducción a la programación
Solución elaborada
En esta solución queremos cumplir dos puntos:
1. Si se hace el cálculo por la norma del vector que se calcula, se pretende que el vector normalizado y el resultado de estos cálculos sean guardados de manera permanente. 2. Al manejar vectores de manera dinámica, la asignación no se hace en el bloque main, en el momento en el cual se necesita. La declaración del tipo de estructura y de sinónimos por el tipo mismo y por el tipo apuntador de estructura se ejemplifica en el siguiente programa: // definicion de tipo struct vector { int n; double c[MAX_DIMENSION]; double norma; struct vector *normalizado; }; typedef struct vector Tvector; typedef Tvector *PTvector;
Un vector puede ser creado de una copia de otro vector (en este caso también se hace una copia del vector normalizado), si es que existe o para lectura. En ambos casos, los tipos de creación se realizan de manera dinámica: PTvector construccion_copia(PTvector X) { Tvector *aux; if (X == NULL) return NULL; aux = (Tvector *)malloc(sizeof(Tvector)); // se el contenido de X en aux *aux = *X; if (aux->normalizado != NULL) { if ( aux->norma == 1) aux->normalizado = aux; else aux->normalizado = construccion_copia(X->normalizado); } return aux; } void creation_lectura_vector(PTvector *X) { int i, n; Tvector *aux; // lectura la dimencion del vector; printf(“ La de dimension del espacio:”); scanf(“%d”, &n); if (n > MAX_DIMENSION) { printf(“ Dimension muy grande !”); *X = NULL; 228
return; } //asignacion dinamica para una zona aux = (Tvector *)malloc(sizeof(Tvector)); aux->n = n; aux->normalizado = NULL; aux->norma = -1; // un valor indicando que la nprma no esta calculada //lectura de valores printf(“ Introducir los %d valores: “, n); for ( i = 0; i < n; i++) { scanf(“ } // X toma la direccion de la nueva zona de memoria *X = aux; }
La función de impresión de un vector también toma en cuenta el valor de la norma si está calculado: void imprima_vector(PTvector X) { int i; if (X == NULL) { printf(“ vector vacio.\n”); return; } printf(“ dimension = %d. componentes : “, X->n); for ( i = 0; i < X->n; i++) printf(“%lf”, X->c[i printf(“\n”); if (X->normalizado != NULL) printf(“ norma = %lf”, X->norma); return; }
Las funciones algébricas inician como en la primera solución, es decir, con una de las dimensiones de los vectores, y luego se realiza la asignación de memoria: PTvector suma(PTvector X, PTvector Y) { Tvector *aux; int i; if (X->n != Y->n) return NULL; aux = (Tvector *)malloc(sizeof(Tvector)); aux->n = X->n; aux->norma = -1; aux->normalizado = NULL; for (i = 0; i < aux->n; i++) aux->c[i] = X->c[i] + Y->c[i]; return aux; } 229
Introducción a la programación
Gracias a los cálculos de la norma y del vector normalizado, las dos operaciones se realizan mediante una sola función: void construccion_normalizado(PTvector X) { int i; double s = 0.0; PTvector aux; for (i = 0; i < X->n; i ++) s += X->c[i]*X->c[i]; X->norma = sqrt(s); if (X->norma == 0) { X->normalizado = NULL; return; } aux = (PTvector)malloc(sizeof(Tvector)); aux->n = X->n; aux->norma = 1.0; aux->normalizado = NULL; for (i = 0; i < X->n; i++) aux->c[i] = X->c[i]/X->norma; X->normalizado = aux; }
La verificación del paralelismo de vectores inicia con la comprobación de la compatibilidad de dimensiones; enseguida, se verifica si los vectores normalizados están calculados y, por último, se comprueba explícitamente la equivalencia de los vectores normalizados asociados: int vectores_paralelos(PTvector X, PTvector Y) { int i, rep; if (X->n != Y->n) return FALSO; if (X->normalizado == NULL) construccion_normalizado(X); if (Y->normalizado == NULL) construccion_normalizado(Y); if (X->norma == 0.0 || Y->norma == 0.0) return FALSO; rep = CIERTO; for ( i = 0; i < X->n; i++) if (X->normalizado->c[i] != Y->normalizado->c[i]) { rep = FALSO; break; } return rep; }
Un ejemplo de programa principal4 es el siguiente: int main(int argc, char *argv[]) { Tvector *X; PTvector Y, Z;
Se deja como ejercicio al final de la unidad la realización de las otras funciones de diferencia, productos y vefiricación de la perpendicularidad. 4
230
creation_lectura_vector(&X); printf(“ ---Vector X :”); imprima_vector(X); Y = construccion_copia(X); printf(“ ---Vector Y :”); imprima_vector(Y); Z = suma(X, Y); printf(“ --- Vector Z = X + Y”); imprima_vector(Z); if(vectores_paralelos(X,Z) == CIERTO) printf(“ X y Z son paralelos\n”); else printf(“ X y Z no son paralelos\n”); if(vectores_paralelos(Y,Z) == CIERTO) printf(“ Y y Z son paralelos\n”); else printf(“ Y y Z no son paralelos\n”); exit(0); }
La salida de este programa es: La dimension del espacio:3 Introducir los 3 valores:2 3 4 ---Vector X : dimension = 3. componentes : 2.000000 3.000000 4.000000 ---Vector Y : dimension = 3. componentes : 2.000000 3.000000 4.000000 --- Vector Z = X + Y dimension = 3. componentes : 4.000000 6.000000 8.000000 X y Z son paralelos Y y Z son paralelos
Como se puede observar, esta solución parece más difícil que la lectura; no obstante, en este caso solo se utilizan variables asignadas dinámicamente y los datos de un vector algébrico se almacenan en una misma estructura de un vector algébraico.
5.3 Estructuras de datos Una estructura de datos permite el almacenamiento y el acceso a un conjunto de datos de un cierto tipo. Este tipo de datos puede ser estándar (enteros, flotantes, cadenas de caracteres) o de tipo definido por el usuario, que en la mayoría de los casos es de tipo estructurado. La característica común del tipo elegido es que el tamaño es fijo y suficiente para guardar cualquier elemento del conjunto de datos inicial. No obstante, hay un problema, que es almacenar datos de este tipo permitiendo el acceso para lectura y escritura. En este caso, la escritura tiene el objetivo de insertar nuevos elementos, con el fin de cambiar el contenido o eliminar algún elemento. En ciertos casos, también se imponen algunas restricciones relacionadas con el orden de los elementos en la estructura de los datos; por ejemplo, un orden creciente o decreciente. 231
Introducción a la programación
En esta sección se estudia el almacenamiento y el acceso a las estructuras de datos, sin la imposición de ninguna otra restricción. Para simplificar la escritura de los algoritmos y de las explicaciones sobre el funcionamiento de estas estructuras, debe quedar claro que se supone que únicamente se guardan valores enteros. Las estructuras de datos presentadas se analizan en términos de acceso a un elemento, por lo que debe conocerse su posición o su valor, en función del costo de inserción de un elemento y de la operación inversa de eliminación.
Arreglos Un arreglo unidimensional es una forma de almacenar datos del mismo tipo, que se implementa en la mayoría de los lenguajes de programación. En el capítulo 2 se presenta y se estudia la manera de declarar, asignar en la memoria, leer y escribir el contenido de un arreglo. Un arreglo de enteros se declara de manera estática como: int nombre_arreglo[dimension_maximal];
Un arreglo se puede declarar como un apuntador; luego, se hace la asignación dinámica de la zona de memoria que va a contener sus elementos con la llamada a la función malloc: int nombre_arreglo[]; . . . nom_arreglo = (int*)malloc(dimension_maximal*sizeof(int));
Un arreglo se caracteriza por el nombre que se le asigna, por su dimensión declarada, por el número de sus elementos, el cual está limitado por su dimensión, y por su contenido mismo. Los elementos contenidos en un arreglo se caracterizan por poseer un índice (en lenguaje C, el índice tiene valores entre 0 y dimension_maximal -1), el cual indica la posición al interior del arreglo. El acceso a un elemento del arreglo, cuando se conoce su índice, se hace en tiempo
constante, escribiendo simplemente: nombre_arreglo[indice] Dos funciones de entrada/salida son, por ejemplo: 5 int lectura_arreglo(int X[], int *N, int dimension_maxima) { int i; printf(“ Dimension del arreglo (inferior al %d) :”, DIM_MAX+1); scanf(“%d, N); if (*N >= DIM_MAX) return FALSO; printf(“ Elementos del arreglo :”); for (i = 0; i < *N; i++) scanf(“ %d”, &X[i return CORRECTO; } void escritura_arreglo(int X[], int N) { int i; printf(“ Dimension del arreglo : %d\n”, N); 5
232
Cada vez que se necesite, se llamarían estas funciones o algunas muy parecidas.
printf(“ Elementos :”); for (i = 0; i < N; i++) printf(“ %d”, X[i printf(“\n”); }
La inserción en un arreglo se puede hacer en tiempo constante, escribiendo el elemento al final del arreglo; antes de la llamada, se debe verificar que la nueva dimensión: void insercion_arreglo(int v[], int *dim, int valor) { v[*dim] = valor; ++*dim; return; }
La figura 5.4 muestra el funcionamiento de este tratamiento:
0
1
2
N-1 N
dimension_maximal
Figura 5.4
Cuando se elimina un elemento, es indispensable que la posición del elemento eliminado se ocupe, porque en el arreglo los elementos forman una zona contigua. Para ocupar esta posición vacante, lo más simple es tomar el último elemento del arreglo y ponerlo en esta posición, como se observa a continuación. void eliminacion_arreglo(int v[], int *dim, int posicion) { v[posicion] = v[*dim-1]; --*dim; return; } La figura 5.5 muestra el inicio de una eliminación, acompañada por la mudanza del último elemento del arreglo.
0
1
2
i
N-2 N-1
dimension_maximal
Figura 5.5
Gracias a estas dos funciones es posible transmitir, por referencia, el tamaño del arreglo, debido a que este valor cambia, incrementándose o decrementándose, con el fin de indicar el nuevo tamaño. En un arreglo, la búsqueda de un valor se realiza recorriendo los elementos de este mismo arreglo, ya sea empezando por el inicio o por el final del arreglo, hasta que se encuentra el valor, se logre el éxito de la búsqueda o hasta que se termine el arreglo; en este último caso la búsqueda se considera sin éxito. 233
Introducción a la programación
La complejidad de estas dos operaciones (inserción y eliminación) es 0(1) (tiempo constante). int busqueda_arreglo(int X[], int N, int valor, int *pos) { int i; for (i = 0; i < N; i++) if (X[i] == valor) { *pos = i; return CIERTO; } return FALSO; }
En el mejor de los casos, el elemento que se busca se encuentra en la primera posición, mientras que en el peor de los casos, el elemento no se encuentra y se hacen N+1 operaciones, por un arreglo con N elementos. Si en el arreglo los elementos que se buscan frecuentemente están al inicio del mismo, la búsqueda resultaría más eficaz. Para medir un número promedio de comparaciones, se deben tomar en cuenta las siguientes probabilidades:
pi: el elemento de índice i es el elemento que se busca. q: el elemento no aparece en el arreglo. Ya que pi y q son probabilidades, se tiene que pi, q [0, 1] y q + ∑ n –1 pi = 1 . Para llegar hasta un elemento de índice i, se hacen i + 1 operaciones; en tanto, para ir hasta el fin del arreglo sin éxito se hacen N + 1 operaciones. Entonces, el número medio de comparaciones que se hace es: M = 1 × p0 + 2 × p1 + . . . + N × pn–1 + (N + 1) × q Se puede suponer, sin restricción de generalidad, que p0 = p1 = . . . = pN – 1; de esta forma, el número medio de operaciones deriva en las siguientes fórmulas: M
=
M = p0
x
N
+ N +1
N (N + 1)
+ q (N + 1) 2 De la ecuación ∑ pi = 1 y de la hipótesis p0 = p1 = . . . = PN–1, se obtiene que p0 = las siguientes fórmulas: n –1
1–q N
. Así, el valor de M se obtiene de
1 – q (N + 1) + q (N + 1) N 2 1+ q M= (N + 1) 2 Si se realiza la hipótesis analizada antes, a saber, q = 0, es seguro que el elemento que se busca se halla en el arreglo; mientras que el número medio de operaciones es: N +1 . M=
2
En el caso de que se haya realizado la hipótesis: q = 12 , tenemos la misma probabilidad de que el elemento se encuentre o no en el arreglo; entonces, se obtiene M = 3(N +1) . N
El número medio de operaciones que se hacen para una búsqueda de valor en un arreglo es 0(N). El trabajo con arreglos es muy intuitivo y el desarrollo de las funciones es bastante fácil. No obstante, presenta ventajas y desventajas; la principal ventaja, la más importante de esta estructura de datos, es que el acceso a un elemento (cuando se conoce su posición) se hace en tiempo constante. Por su parte, la principal desventaja del uso de los arreglos es que la asignación de la memoria se hace, esencialmente, de manera estática, al momento de la compilación. 234
Listas ligadas Una lista ligada o lista enlazada se construye a base de celdas de un tipo estructurado auto-referenciado que permite ir de una celda a la siguiente. En la figura 5.6 se observa la representación de una celda de memoria que constituye la base de una lista ligada. clave
next
struct elemento Figura 5.6
Este tipo estructurado se define como: struct elemento {int clave; struct elemento *next; };
Entonces, la lista ligada se traduce por un apuntador a este tipo estructurado: typedef struct elemento* Tlista;
Por defecto, una lista vacia tiene el valor NULL. Por convención, y en la mayoría de los casos, la lista termina con un elemento, por lo cual el último elemento tiene el apuntador next a NULL. El apuntador next de una celda indica cuál es la celda que sigue en la lista. Ejemplo de lista ligada
En la siguiente 5.7 es posible observar la representación de una lista que contiene los valores 1, 3, 5 y 7. L1357
clave next 1
clave next 3
clave next 5
clave next 7 NULL
Figura 5.7
Por su parte, en el programa siguiente se construye esta lista ligada particular: /* programa que construye una lista ligada */ #include
235
Introducción a la programación
int main(int argc, char *argv[]) { struct elemento e1, e3, e5, e7; Tlista L1357; Tlista aux; e1.clave e3.clave e5.clave e7.clave
= = = =
1; 3; 5; 7;
e1.next = &e3; e3.next = &e5; e5.next = &e7; e7.next = NULL; L1357 = &e1; printf(“ Los elementos de la lista son : “); for (aux = L1357; aux != NULL; aux = aux->next) printf(“ %d”, aux->clave); printf(“\n”); exit(0); }
En el programa anterior, las primeras ocho asignaciones permiten escribir valores en las celdas (campo clave) y ligar los cuatro elementos que componen la lista. Por su parte, la asignación L1357 = &e1 indica el inicio de la lista; el apuntador L1357 toma como valor la dirección de e1. En la última parte del programa se efectúa un recorrido de la lista para escribir sus valores. La variable de tipo apuntador aux permite lograr este propósito, ya que se trata de una variable auxiliar que indica, a cada momento, la dirección de una celda de la lista. Para dirigirse a la siguiente celda se toma en consideración el campo next; entonces, la asignación aux = aux->next permite ir de una celda a otra. El recorrido se termina en la última celda que tiene el campo next a NULL. En esta versión de programa, las celdas que componen la lista están asignadas de manera estática: L1357
clave next 1 e1
clave next 3 e2
clave next 5 e3
clave next 7 NULL e4
Figura 5.8
Esta solución no es viable para trabajar con listas de gran tamaño, ya que resulta imposible declarar a priori una variable del tipo struct elemento para cada celda y usar esta cuando se necesita. Si se quiere trabajar con la asignación dinámica de las celdas, resulta indispensable elegir variables de tipo Tlista (equivalente a struct elemento*): int main(int argc, char *argv[]) { struct elemento *pe1, *pe3, *pe5, *pe7; Tlista L1357; Tlista aux; pe1 = (Tlista)malloc(sizeof(struct elemento)); 236
pe3 = (Tlista)malloc(sizeof(struct elemento)); pe5 = (Tlista)malloc(sizeof(struct elemento)); pe7 = (Tlista)malloc(sizeof(struct elemento)); pe1->clave pe3->clave pe5->clave pe7->clave
= = = =
1; 3; 5; 7;
pe1->next = pe3; pe3->next = pe5; pe5->next = pe7; pe7->next = NULL; L1357 = pe1; printf(“ Los elementos de la lista son : “); for (aux = L1357; aux !=NULL; aux = aux->next) printf(“ %d”, aux->clave); printf(“\n”);} exit(0); }
El principio del funcionamiento de este programa se basa en los siguientes puntos: Primero, se asignan dinámicamente celdas de memoria que están referenciadas por las variables de tipo apuntador: pe1, pe3, pe5 y pe7. La figura 5.9 muestra las variables que usa este programa: clave 1 next
clave 3 next
clave 5 next
malloc
malloc
malloc
malloc
pe1
pe3
pe5
pe7
L1357
clave 7 next NULL
Figura 5.9
En otra versión del programa, los valores de la lista L1357 se almacenan en un arreglo y, mediante una estructura iterativa, creamos celdas con contenido y ligas. int main(int argc, char *argv[]) { struct elemento *pe; Tlista L1357; Tlista aux; int valores[] = {1, 3, 5, 7}; int i; aux = NULL; 237
Introducción a la programación
for (i = sizeof(valores)/sizeof(int) - 1; i >= 0; i--) { pe = (Tlista)malloc(sizeof(struct elemento)); pe->clave = valores[i]; pe->next = aux; aux = pe; } L1357 = aux; printf(“ Los elementos de la lista son : “); for (aux = L1357; aux !=NULL; aux = aux->next) printf(“ %d”, aux->clave); printf(“\n”); while (L1357 !=NULL) { aux = L1357; L1357 = L1357->next; free(aux); } exit(0); }
El trabajo que corresponde a cada iteración es asignar una nueva celda, llenar los campos clave y next, y preparar el siguiente paso o el fin de creación de la lista: clave 3
next
clave 5
next
clave 7
next NULL
malloc
pe
aux
Figura 5.10
Aquí, la variable auxiliar aux tiene la función de indicar el fragmento de lista ligada que es correcto. Por tanto, se inicia con NULL y durante una iteración se indica el último elemento de la lista. La creación de la lista inicia con la última celda. El funcionamiento de esta última versión del programa es mucho más flexible que el de las anteriores; únicamente se requieren dos variables para la creación de cualquier tamaño de lista. La única precaución que debe considerarse es liberar el contenido de la lista (sus celdas) al final del programa. Creación, copia, inserción y otras funciones para listas ligadas
Una lista ligada se puede explorar conociendo únicamente el primer elemento del apuntador, el cual recibe el nombre de apuntador de lista. En este caso, solo se realizan dos funciones muy sencillas: la impresión de una lista y el cálculo del largo de una lista: void imprima_lista(Tlista L) { Tlista aux; 238
for (aux = L; aux !=NULL; aux = aux->next) printf(“ %d”, aux->clave); printf(“\n”); } int largo_lista(Tlista L) { int i; i = 0; while (L != NULL) { i++; L = L->next; } return i; }
Estas dos funciones trabajan según un mismo principio; ambas realizan un recorrido a lo largo de la lista, desde el inicio hasta el final (por lo cual el campo next es equivalente a NULL). Existen dos formas de utilizar este principio, las cuales son diferentes entre sí: en un caso se usa una variable auxiliar aux y una estructura iterativa for, mientras que en el otro caso se usa el cambio del valor de apuntador usando la asignación L = L->next, el interior de una estructura iterativa while. En el caso más general, la creación de una lista ligada se realiza por etapas: Creación de una lista vacía. Inse rciones sucesivas (cada uno de los elementos se inserta al inicio de la lista). El código de estos dos tratamientos es el siguiente: void creacion_lista_vacia(Tlista *L) { *L = NULL; } void insercion_lista(Tlista *L, int valor) { Tlista aux; aux = (Tlista)malloc(sizeof(struct elemento)); aux->clave = valor; aux->next = *L; *L = aux; }
En ambas funciones, la lista se transmite por referencia, transmitiendo el apuntador del apuntador de la primera celda, debido a que este cambia. Si la función creacion_lista_vacia es muy evidente, la función de inserción merece una explicación; se trabaja por etapas: Se asigna dinámicamente una celda de lista, la cual se llena con el valor transmitido en parámetro y con el apun-
tador actual de la lista. Se actualiza el nuevo apuntador de lista.
Este principio está representado en la figura 5.11.
239
Introducción a la programación
clave
next
(0) (2)
L
(3)
clave valor
next
(1) malloc aux
Figura 5.11
Con la ayuda de estas dos funciones, creacion_lista_vacia e insercion_lista, se puede desarrollar una función que llena una lista desde elementos introducidos por la lectura: void creacion_lista_lectura(Tlista *L) { int i, n, valor; printf(“ Introducir cuanto elemento se desea en la lista: “); scanf(“%d”, &n); creacion_lista_vacia(L); printf( “ Indicar los elementos de la lista en orden inverso :”); for( i= 0; i < n; i++) {scanf(“%d”, &valor); insercion_lista(L, valor); } return; }
Una función que es bastante sencilla y que es esencial para un adecuado funcionamiento de los programas que manejan las estructuras dinámicas de tipo listas ligadas, es la función que libera la memoria ocupada por los elementos de una lista ligada: void liberacion_lista(Tlista L) { Tlista aux; while (L != NULL) { aux = L; L = L->next; free(aux); } }
Para hacer una copia de una lista ligada, se crea una segunda lista ligada que contiene exactamente el mismo número de elementos que la primera, cada uno de los cuales es una copia del elemento que le corresponde de la lista de srcen; 240
para su realización, se copia elemento por elemento hasta que se encuentra el fin de la lista. Una función recursiva debe comenzar tratando el caso más simple de la lista vacía. En el caso contrario, existe al menos un elemento, por lo que se hace una asignación dinámica de memoria para hacer la copia del primer elemento de la lista; entonces, se copia la clave y el apuntador contenido en la celda. Este sería el resultado de la llamada recursiva para una lista que contiene menos elementos. El código de esta función es el siguiente: void copia_lista(Tlista L, Tlista *LD) { Tlista aux; if (L == NULL) { creacion_lista_vacia(LD); return; } copia_lista(L->next, &aux); *LD = (Tlista)malloc(sizeof(struct elemento)); (*LD)->clave = L->clave; (*LD)->next = aux; return; }
Para calcular la complejidad de esta función se necesita el límite de una función matemática T(n), el cual expresa el número de operaciones necesarias a través de una lista con n elementos. De esta forma, tenemos las siguientes relaciones matemáticas:
T(0) = C0 T(n) = Cp + T(n – 1) Aquí se puede ver que T(n) = nCp + C0, con C0, Cp constantes. Entonces, la complejidad de la función que hace la copia de una lista ligada es O(n). La complejidad de las otras funciones presentadas en esta sección también es O(n) en número de operaciones, por lista ligada, conteniendo n elementos. Acceso, búsqueda y eliminación de elementos en listas ligadas
En un arreglo, el acceso a un elemento, cuando se conoce su índice (o su posición en el arreglo), se realiza en un tiempo constante. Por otra parte, en una lista ligada, el acceso a los elementos, a excepción del primer elemento, necesita recorrer la lista. Una función de acceso al elemento de posición entregada por parámetro es la siguiente: int acceso_elemento(Tlista L, int posicion, Tlista *E) { int i; for (i = 1; i < posicion && L != NULL; i++) L = L->next; if (L == NULL) return FALSO; else { *E = L; return CORRECTO; } } 241
Introducción a la programación
En esta función, el elemento que está al frente de la lista tiene la posición 1. Existen dos posibilidades recorriendo la lista: La lista tiene menos posición de elementos; en este caso, la función regresa FALSO. La lista tiene más posición de elementos; en este caso, la función regresa el valor CORRECTO y el parámetro E
regresa el apuntador por la celda de la posición del elemento. La complejidad de esta función es de 0( k), donde k es el valor de la posición que se busca, en caso de éxito y 0(n); en caso contrario, con n el tamaño de la lista. Por lo común, la complejidad de cualquier posición de la lista es 0( n). La búsqueda de un valor en una lista ligada, también se realiza recorriendo la lista desde el primer elemento hasta que se encuentra el elemento buscado o hasta el final de lista. Entonces, el código de la función puede ser: int busqueda_elemento(Tlista L, int valor, Tlista *E) { Tlista aux; for (aux = L; aux != NULL; aux = aux->next) if (aux->clave == valor) { *E = aux; return CIERTO; } return FALSO; }
Como en el caso de la función precedente, esta función también regresa un apuntador de la primera celda, la cual se encuentra conteniendo el valor. En esta función, la complejidad de la función de búsqueda también es 0(n); asimismo, los cálculos de la complejidad son los mismos que para la búsqueda en arreglo. La eliminación de un elemento de una lista se trata de dos maneras diferentes: Según la posición del elemento que se borra (elimina): Si es el primer elemento, únicamente se libera la zona de memoria correspondiente y el apuntador de la lista
cambia. Si no se trata del primer elemento, se impone la reconstitución de la liga del elemento que precede el elemento
eliminado, antes de la liberación de la zona de memoria. La figura 5.12 representa el tratamiento de los elementos en el primer caso, cuando solo se cambia el apuntador de lista:
clave
next
clave
next
(0) (2) L
(1) aux
L
Figura 5.12
Si se elimina un elemento que no es el primero, es necesario detectar cuál es el elemento que precede a este en la lista, ya que la liga de este elemento debe actualizarse antes de borrar la celda que nos interesa. 242
La figura 5.13 presenta el tratamiento que se realiza cuandose encuentra el elemento quese desea eliminar: clave
clave next (0)
next
(2) (1)
L
prec
aux
Figura 5.13
El código de la función que realiza la eliminación de un elemento indicado por su apuntador es el siguiente: int eliminacion_elemento(Tlista *L, Tlista ap) { Tlista aux, prec; if (*L == ap) { //eliminacion del primer elemento de la lista *L = (*L)->next; free(aux); return CIERTO; } // el elemento no es el primero de la lista aux = (*L)->next; prec = *L; while(aux != ap && aux != NULL) { prec = aux; aux = aux->next; } if (ap == aux) { // Se prepara la liga para poder eliminar la celda apuntada por ap prec->next = prec->next->next; free(aux); return CIERTO; } else { // El elemento no se encuentra en la lista return FALSO; } }
La llamada de esta función debe hacerse después de una búsqueda de valor o después del acceso a un elemento, cuando se conoce su posición. La parte más complicada de esta función es la detección del elemento y de su predecesor en la lista. Este cálculo corresponde a la estructura while y se hace en 0(k) operaciones, con k en la posición del apuntador transmitido por 243
Introducción a la programación
el parámetro. Las operaciones de actualización de liga y la llamada a free siempre se hacen en tiempo constante. La complejidad de la función es en la media de O(n), con n el número de elementos de la lista. Centinelas
Las listas ligadas utilizadas antes respetan la convención de la última celda, que tiene el apuntador next a NULL; sin embargo, en ocasiones es posible poner otro valor constante como indicador de fin de lista, el cual recibe el nombre de centinela. Por ejemplo, para una operación de fusión de dos listas,6 es mejor usar una centinela Z para todas las listas; en este caso, el apuntador next apunta al elemento mismo y tiene como clave el máximo valor entero que se puede almacenar. La figura 5.14 muestra esta centinela: clave
next
Z
Figura 5.14
El siguiente código contiene la declaración de Z como una variable global y la función que la crea: #include
La introducción de una centinela y su uso en lugar del valor NULL no modifican en nada la complejidad de las funciones presentadas, en ocasiones solo se simplifica la escritura de algunos cálculos.
Listas circulares Una lista circular ligada es una lista por medio de la cual el último elemento de esta apunta al primer elemento. En la figura 5.15 se representa una lista circular que contiene los elementos 1, 3, 5, 7 y 9.
5
244
Esta noción se explica en el siguiente capítulo
L13579
clave next 1
clave next 3
clave next 5
clave next 9
clave next 7
Figura 5.15
Los tipos de datos que se pueden manejar con esta estructura de lista circular son estrictamente del mismo tipo los que se manejan en las listas simples. struct elemento {int clave; struct elemento *next; }; typedef struct elemento* Tlista; typedef Tlista Tlista_circular;
El acceso a los elementos de una lista circular se realiza mediante un apuntador; dicho acceso sucede en tiempo constante por el elemento de esta “primera” celda, pero el acceso a otras celdas se hace en tiempo variable. Para trabajar con una lista circular, utilizar un apuntador por una celda es suficiente. Es importante destacar aquí que el trabajo con listas circulares debe respetar la estructura de estas mismas y tomar en cuenta que no hay prácticamente un fin de lista; el fin se debe detectar verificando la equivalencia con el inicio de la lista. Las dos siguientes funciones realizan operaciones básicas con listas circulares: Imprimir la clave (el valor contenido) de cada elemento. acceder por posicion; esto consiste en determinar el elemento de posición transmitido por parámetro. Si el
tamaño de la lista es más pequeño que este valor, se continúa el recorrido de la lista. void imprima_lista(Tlista_circular L) { Tlista_circular aux; if (L == NULL) return; aux = L; do { printf(“ %d”, aux->clave); aux = aux->next; } while (aux != L); printf(“\n”); 245
Introducción a la programación
} void acceso_elemento(Tlista_circular L, int posicion, Tlista_circular *E) { int i; for (i = 1; i < posicion; i++) L = L->next; *E = L; }
La inserción de un elemento en una lista circular se puede hacer en cualquier lugar, pero para realizarlo en tiempo constante, independiente del número de celdas, lo mejor sería hacer la inserción después del primer elemento, como se muestra en la figura 5.16: aux
(1)
clave
next
malloc
(2) L
clave
(3)
next
clave
next
(0)
Figura 5.16
El código de la función de inserción que funciona de esta manera es el siguiente: void insercion_lista(Tlista_circular *L, int valor) { Tlista_circular aux; aux = (Tlista_circular)malloc(sizeof(struct elemento)); aux->clave = valor; if (*L == NULL) { // lista fue vacia aux->next = aux; *L = aux; } else { // lista no fue vacia, insercion en la segunda posicion aux->next = (*L)->next; (*L)->next = aux; } } 246
Una función muy útil es la liberación de la memoria ocupada por las celdas de la lista circular; aplicamos el principio de la detección del fin de lista comparando cada uno de los valores desde el inicio hasta el valor final. El código es el siguiente: void liberacion_lista(Tlista_circular L) { Tlista_circular inicio, aux; inicio = L; do { aux = L; L = L->next; free(aux); } while (L != inicio); }
Para la eliminación de un elemento indicado por su apuntador, primero se debe buscar el elemento que precede al elemento borrado. No obstante, existen excepciones; la primera la constituye el caso de la lista vacía que regresa FALSO, y el segundo caso es la lista con un solo elemento que resulta vacía y regresa CIERTO. La detección del elemento que precede al elemento que se desea eliminar se hace recorriendo los elementos de la lista hasta que se encuentra el apuntador transmitido por referencia. La liga de este elemento se actualiza y se libera físicamente de la memoria ocupada por su vecino.
clave
clave
next
(0)
next
(2) (1)
prec
aux ap
Figura 5.17
Es importante destacar aquí el caso del elemento borrado que se encuentra al inicio de la lista, para lo cual se modifica el apuntador de lista circular. El código de la función de eliminación de un elemento es el siguiente: int eliminacion_elemento(Tlista_circular *L, Tlista ap) { Tlista inicio, aux, prec; if (*L == ap && (*L)->next == *L) { // la lista circular tiene un solo elemento aux = ap; free(aux); *L = NULL; return CIERTO; }
247
Introducción a la programación
inicio = *L; aux = (*L)-> next; prec = *L; do { if (ap == aux) break; prec = aux; aux = aux->next; } while(aux != inicio->next); if (ap != aux) { //El apuntador no se encuentra en la lista circular return FALSO; } // el apuntador encontrado prec->next = aux->next; free(aux); if (*L == ap) // se ha eliminado el primer elemento de la lista *L = prec->next; return CIERTO; }
Un programa que construye la lista circular conteniendo los elementos 1, 3, 5, 7 y 9, y que borra algunos elementos, es el siguiente: Tlista_circular L13579, aux; creacion_lista_vacia(&L13579); insercion_lista(&L13579, 1); insercion_lista(&L13579, 9); insercion_lista(&L13579, 7); insercion_lista(&L13579, 5); insercion_lista(&L13579, 3); printf(“ La lista L13579 es : “); imprima_lista(L13579); acceso_elemento(L13579, 11, &aux); printf(“ El elemento de posicion 11 de la lista es %d \n”,aux->clave); eliminacion_elemento(&L13579, aux); printf(“ Despues borrar este elemento - la lista es :”); imprima_lista(L13579); acceso_elemento(L13579, 3, &aux); 248
printf(“ El elemento de posicion 3 de la lista es %d \n”, aux->clave); eliminacion_elemento(&L13579, aux); printf(“ Despues borrar este elemento - la lista es :”); imprima_lista(L13579); liberacion_lista(L13579); exit(0);
El orden de las inserciones es 1, 9, 7, 5, 3 porque las inserciones se hacen en segunda posición. La llamada de la función acceso_elemento(L13579, 11, &aux)induce un doble recorrido de la lista circular. La salida de este programa es: La lista L13579 es : 1 3 5 7 9 El elemento de posicion 11 de la lista es 1 Despues borrar este elemento - la lista es : 3 5 7 9 El elemento de posicion 3 de la lista es 7 Despues borrar este elemento - la lista es : 3 5 9
Listas doblemente ligadas Una lista doblemente ligada es una estructura lineal por la cual cada elemento de dicha lista está ligado a sus dos vecinos: el antecesor y el sucesor. En la figura 5.18 se representa una lista doblemente ligada que contiene los elementos 1, 3, 5, 7: LD1357
clave prec next 1357
clave prec next
NULL
clave prec next
clave prec next NULL
Figura 5.18
Este tipo de lista se define por el apuntador que se encuentra al inicio de esta. El apuntador al final de la lista se puede obtener recorriéndola toda. Los cálculos son más simples si se almacenan los dos apuntadores. Las definiciones necesarias en lenguaje C para el manejo de listas doblemente ligadas son: struct delemento {int clave; struct delemento *prec; struct delemento *next; };
249
Introducción a la programación
typedef struct delemento* Tlista; typedef struct dlista {Tlista inicio; Tlista fin;} Tlista_doble;
El tipo estructurado por una celda contiene dos apuntadores que lo auto-referencian; en este, se indica un sinónimo para el apuntador de la celda y un tipo estructurado con dos apuntadores para una lista doblemente ligada, los cuales indican el inicio y el final de la lista. Para insertar un elemento en una lista doblemente ligada se debe indicar si este se insertará al inicio o al final de la lista, ya que estas son las posiciones en las cuales la inserción se hace en un tiempo constante. Una lista doble vacía tiene los dos apuntadores (el de inicio y el del final) con NULL. El código de la creación de una lista vacía y de la inserción al inicio de esta lista es el siguiente: void creacion_lista_vacia(Tlista_doble *L) { L->inicio = NULL; L->fin = NULL; } void insercion_lista_doble_inicio(Tlista_doble *L, int valor) { Tlista aux; aux = (Tlista)malloc(sizeof(struct delemento)); aux->clave = valor; if (L->inicio == NULL) { // lista fue vacia aux->next = NULL; aux->prec = NULL; L->inicio = aux; L->fin = aux; } else { // lista no fue vacia aux->next = L->inicio; L->inicio->prec = aux; L->inicio = aux; } }
Para detectar una posición, el código que se utiliza es similar al código que se usa para una lista simplemente ligada; lo mismo sucede para la liberación de la memoria y la impresión de contenido clave de las celdas. int acceso_elemento(Tlista_doble LD, int posicion, Tlista *E) { Tlista L; int i; L = LD.inicio; for (i = 1; i < posicion && L != NULL; i++) L = L->next; if (L == NULL) return FALSO; else { 250
*E = L; return CORRECTO; } } void liberacion_lista(Tlista_doble LD) { Tlista aux, L; L = LD.inicio; while (L != NULL) { aux L; L = = L->next; free(aux); } } void imprima_lista_doble(Tlista_doble LD) { Tlista aux; if (LD.inicio == NULL) { printf( “ Lista vacia\n”); return; } aux = LD.inicio; do { printf(“ %d”, aux->clave); }aux = aux->next; while (aux != NULL); printf(“\n”); }
Para la eliminación de un elemento de una lista doblemente ligada no es necesario recorrer toda la lista para detectar el elemento que lo precede, porque esta información se encuentra en la misma celda. Para guardar la coherencia de la información acerca del inicio y el final de la lista doblemente ligada, hay más casos para analizar. El código es el siguiente: int eliminacion_elemento(Tlista_doble *L, Tlista ap) { if (L->inicio == ap && L->fin == ap) { // lista con un solo elemento que se borra free(ap); L->inicio = NULL; L->fin = NULL; return CIERTO; } if (L->inicio == ap) { // se borra el primer elemento L->inicio->next->prec = NULL; 251
Introducción a la programación
L->inicio = L->inicio->next; free(ap); return CIERTO; } if (L->fin == ap) { // se borra el ultimo elemento L->fin->prec->next = NULL; L->fin = L->fin->prec; free(ap); return CIERTO; } // ap apunta a =unap->prec; elemento en el interior de la lista ap->next->prec ap->prec->next = ap->next; free(ap); return CIERTO; }
La complejidad de la operación de eliminación es 0(1), ya que siempre se realiza un número finito y conocido de operaciones. La complejidad de las otras operaciones son del mismo orden 0(n), como en el caso de los otros tipos de lista (simple o circular), o 0(1) para la inserción en el lugar más conveniente.
5.4 Tipos abstractos de datos Un tipo abstracto de datoso un tipo de datos abstracto es la descripción de una forma de almacenar y acceder a los datos imponiendo una listade delos operaciones deben realizardesobre los datos de este tipo. Un tipo abstracto de de datos aparece en la descripción algoritmosque y nosetiene detalles implementación. Por tanto, la implementación un algoritmo en un lenguaje de programación impone la elección de las estructuras de datos más conveniente para el tipo abstracto de datos. Los tipos de datos más utilizados en la descripción de algoritmos y en la programación, en general, son: Pila, cola, doble cola. Lista. Conjunto matemático. Grafos. Listas ordenadas. Árboles binarios de búsqueda. Cola de prioridades.
En esta sección presentamos la implementación de los primeros tipos, ya que en el siguiente capítulo estudiamos los tipos abstractos de datos que son construidos sobre una relación de orden entre los valores.
Listas El concepto de lista impone poder insertar y borrar elementos, indicar la posición de un elemento en la lista, unir dos listas, hacer copias, entre otras cosas. La lista es un concepto de la programación que aparece en los lenguajes declarativos de programación para la inteligencia artificial LISP y Prolog. 252
Las estructuras de arreglo, lista ligada, lista ligada circular y lista doblemente ligada permiten la implementación de las operaciones listadas antes. El tiempo para realizar operaciones depende de la estructura de datos elegida 0(1), para la inserción, y de 0(n), para la búsqueda de elementos. Otras operaciones tienen varios tiempos de ejecución; por esta razón, según la frecuencia de estas operaciones, se aconseja seleccionar la estructura de datos que ofrece el mejor desempeño para las operaciones más frecuentes.
Pilas, colas, dobles colas La cola (queue, en inglés) es una estructura mediante la cual es posible realizar la extracción de los elementos en el mismo orden de la inserción: orden FIFO (First In First Output). Por su parte, una pila (stack, en inglés) es una estructura a través de la cual se realiza la extracción de los elementos en orden inverso a la inserción: orden LIFO (Last In First Output). La doble cola (deque, en inglés) permite la inserción y la extracción de elementos desde cada extremo. Las siguientes figuras representan un “ferrocarril” de estos tres conceptos: Entrada
Salida
Pila Una pila representada como una red de conmutación de ferrocarril. A veces ayuda a entender el mecanismo de una pila en términos de una analogía con el cambio de vagones de ferrocarril, según lo propone E. W. Dijkstra.
Salida
Doble cola
Entrada
Figura 5.19 Fuente: Knuth, Donald, El arte de programar ordenadores, Vol. 1: Algoritmos fundamentales, Ed. Reverté, 1986.
La implementación de una pila se puede hacer con una lista ligada o con un arreglo. En el caso del arreglo, las entradas/salidas se realizan al final, mientras que para el caso de la lista ligada las entradas/salidas se hacen al inicio. De esta manera, las operaciones se realizan en tiempo constante. 253
Introducción a la programación
Con el objetivo en mente de realizar las operaciones en tiempo constante, lo mejor es utilizar una lista doblemente ligada para implementar la cola y la doble cola, si no el acceso al lado opuesto de la estructura se hace en un tiempo proporcional al número de elementos.
Conjunto matemático En su definición, un conjunto matemático establece la unicidad de los valores contenidos y la ausencia de orden entre los elementos. También se destaca la relación de pertenencia y las operaciones de unión, intersección y diferencia. Un conjunto matemático se puede representar con un arreglo o con una lista ligada, lo más difícil resulta verificar la unicidad de un valor al momento de la inserción. La implementación de la equivalencia también es una operación complicada, ya que es necesario tomar los elementos de un conjunto y verificar la pertenencia en el otro conjunto.
Grafos Un grafo se define como un conjunto de nodos y un conjunto de aristas entre los nodos, ya que la función de una arista es unir dos nodos del grafo. En este caso, se utiliza la notación G = (V, E), con V conjunto de nodos y E conjunto de aristas. Donde V es un conjunto finito, por esta razón a los nodos de un grafo se les asignan valores entre 1 y N; en este caso, N es el número de nodos. Un nodo es vecino de otro si tienen en común (comparten) una arista. En la figura 5.20 se presenta un ejemplo de grafo: 2
1 4
5
3
Figura 5.20
Un grafo se puede representar por: Una matriz de adyacencia, que es una matriz N × N con 0 y 1, donde el valor 1 indica la presencia de la arista entre
los nodos y el 0 la ausencia de esta. Una lista de adyacencia; por cada nodo se guarda una lista con los nodos vecinos.
La matriz de adyacencia del grafo del ejemplo anterior se muestra a continuación: 01100 10110 11010 01101 00010
254
Por su parte, las listas de adyacencia para este grafo son: nodo 1: 2, 3 nodo 2: 1, 3, 4 nodo 3: 1, 2, 4 nodo 4: 2, 3, 5 nodo 5: 4
5.5 Problemas resueltos El objetivo de esta sección es el planteamiento de dos problemas concretos, en los cuales el algoritmo es bastante claro, y poner en evidencia las ventajas o las desventajas de elegir una u otra estructura de datos. Así, presentaremos soluciones completas con dos estructuras de datos e indicaremos, para cada programa, la complejidad en términos de tiempo.
Criba de Eratóstenes Es un procedimiento (algoritmo) que permite generar todos los números primos hasta alcanzar un límite N. Este algoritmo funciona de la siguiente manera: Genera todos los números del 2 hasta N. Toma los números uno a uno hasta el límite de
N.
Cuando se toma un número k se eliminan (jalan) todos los números que siguen y que son divisibles entre k. Cuando se termina el tratamiento de un número k, se toma el siguiente elemento j que no está eliminado. Al final, los números que no se han borrado son los números primos entre 2 y N.
Ejemplo Para los valores hasta 25: 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
Los números primos son: 2 3 5 7 11 13 17 19 23. Borrar un número puedo ser efectivo, pues se elimina el valor de la estructura de datos o solo se coloca una marca de “borrado”, indicando que se trata de un número con divisores. La estructura de datos debe permitir el acceso al elemento siguiente o a un elemento con un índice conocido. Hay varias formas para almacenar los números de 2 hasta N, en términos de estructuras de datos: arreglos o listas ligadas. Las otras operaciones que se deben hacer con la estructura de datos que guardan los números son: Inicializar la estructura con los números de 2 hasta N. Poder recorrer toda la estructura hasta el final para extraer los números primos 255
Introducción a la programación
Para buscar los números que se borran en una etapa y que constituyen números divisibles entre un valor k o que recorren la lista, la cual se prueba con cada número presente si es o no divisible entre k, se detectan “automáticamente”, ya que se trata de los números múltiplos de k. Otra observación importante es que el orden de generación de los números del 2 hasta N se respeta durante todo el algoritmo. Si se toma como estructura de datos una lista ligada, esta estructura permite acceder a los elementos de manera secuencial y borrar un elemento se hace en tiempo constante, sin cambiar el orden de los elementos; en este caso, la única precaución que se debe considerar es manejar juntos el apuntador de un elemento y el apuntador del elemento que lo antecede. Si se toma como estructura de datos un arreglo, el acceso a cualquier elemento se hace en tiempo constante; sin embargo, borrar realmente un elemento y almacenar el orden de sus elementos parece una operación complicada, porque hay que desplazar los elementos que siguen en el arreglo. La solución más simple es poner un valor fuera del rango del número 2 hasta N, para indicar el estado de borrado de un elemento; por ejemplo, un valor negativo. Pero el desarrollo del programa utilizando un arreglo resulta más simple; asimismo, la complejidad en términos de número de operaciones parece más ventajosa con esta estructura. El programa principal que se propone tiene el código siguiente: int main(int argc, char *argv[]) { int N; int sqr, posicion, tamano, val; int *CE; lectura_limite(&N); // asignacion dinamica de memoria CE = (int*)malloc((N-2)*sizeof(int)); // llenar el arreglo con los valores de 2 hasta N tamano = generar_valores(CE,N); // preparacion de las variables sqr = (int)sqrt(N); posicion = 0; while(CE[posicion] <=sqr) { if (CE[posicion] == BORRADO) { posicion++; continue; } val = CE[posicion]; eliminar(CE, val, posicion+1, tamano); posicion++; } extraer_primos(CE, 0, tamano); exit(0); }
Para ser lo más flexible posible en lo que respecta al arreglo, la memoria se asigna dinámicamente. Por la constante BORRADO tomamos el valor negativo -1, definido con una directiva # define. Las funciones de lectura de la dimensión, de inicialización con valores entre 2 y N y de recorrido final del arreglo para extraer los números que no están borrados son muy simples: void lectura_limite(int *N) { 256
printf(“ Indicar la limite para la generacion de numeros primos :”); scanf(“%d”, N); return; } int generar_valores(int C[], int N) { int i,j; for(j = 2, i = 0; j <=N; j++, i++) C[i] = j; return i; } void extraer_primos(int C[], int inicio, int fin) { int i; printf(“ Los numeros primos son :”); for (i = inicio; i < fin; i++) if (C[i] != BORRADO) printf(“ %d”, C[i printf(“\n”); }
La complejidad en un número de operaciones para las últimas dos funciones es 0(N). Para eliminar los múltiplos del valor corriente indicado en una primera versión del main por CE[posicion] se hace de la siguiente manera: void eliminar(int C[], int valor, int inicio, int fin) {
int i; for (i = inicio; i < fin; i++) if (C[i] % valor == 0) C[i] = BORRADO;
}
Esta solución consiste en recorrer los elementos mayores del valor corriente hasta el valor final del arreglo y verificar la divisibilidad con valor para cada elemento. Entre la posición del elemento corriente y el final del arreglo hay N – 2 – valor elementos; entonces, se hacen N – 2 – p operaciones por cada número primo p entre 2 y N . En el programa main, la estructura repetitiva se ejecuta N – 2 veces y cada vez que se ejecuta al máximo un número constante de operaciones más una llamada a la función de eliminación, si el valor corriente del arreglo es primo. La complejidad de esta parte del programa es:
c×
N
N –2–
+C ×
N –2
con C y C2 constantes. Esta fórmula está limitada abajo por 0(NlogN) y arriba por 0(N2). Otra versión de la función de eliminación consiste en detectar los múltiplos sin pasar por otros valores, tomando en cuenta que si CE[posicion] contiene un valor p entonces los elementos de forma CE[posicion + i *p] contienen un múltiplo de p. El código de esta versión es el siguiente: 257
Introducción a la programación
void eliminar(int C[], int valor, int inicio, int fin) { int i; for (i = inicio + valor; i < fin; i = i + valor) C[i] = BORRADO; }
Por tanto, la lla,mada en la estructura main es: eliminar(CE, val, posicion, tamano);
para poder detectar correctamente los múltiplos. f
invalor −inicio ; a saber, a través de un número primo p entre esta función, N −2−el p número de operaciones que se hacen es 2 y Para N se hacen . El número total de las operaciones de la parte iterativa de while es: p
c×
N
∑
pprimo, p= 2
N − 2− p + c2 × ( N − 2) p
Este valor es menor al valor de la primera versión; el orden del valor es O(NloglogN), y se calcula como suma de una serie armónica. Ejecutando las dos versiones sobre una misma máquina y por N bastante grande, se obtiene una diferencia significativa del tiempo de ejecución de las dos versiones de programa. 7 Si queremos implementar la criba de Eratóstenes con lista ligada, la eliminación resulta efectiva con los valores que no son primos, pero sí son divisibles entre p, donde p < N . La estructura del programa sigue los mismos principios: Inicialización de la estructura inicial y de las variables. Una estructura iterativa que permite extraer valores, que son números primos, de la lista ligada hasta llegar al límite < N .
En este caso, el bloque de main es el siguiente: int main(int argc, char *argv[]) { Tlista LE; //la lista con los numeros de 2 hasta N int N; int sqr; //raiz cuadrada de N int p; // el numero primo corriente Tlista aux; lectura_limite(&N); generar_lista(&LE, N); sqr = (int)sqrt(N); aux = LE; while (aux->clave <= sqr) { p = aux->clave; eliminar(aux, p); aux = aux->next; } printf(“ La lista de numeros primos hasta %d es : “, N); En un equipo MacBook, con un procesador de 2.53 GHz, se necesitan 8.53 segundos para la primera versión y 4.47 segundos para la segunda versión, para la generación de los números primos hasta 3 x 10 6. 7
258
imprima_lista(LE); exit(0); }
Para la inicialización y la eliminación de múltiplos, se utilizan dos funciones. No es necesario desarrollar una función que escriba los números primos; una función normal de escritura de elementos de la lista es suficiente. La complejidad de esta escritura es 0(PN), donde PN es un número de números primos menores que N. La función de inicialización es simple; esta genera la lista ligada con los valores de 2 hasta N: void generar_lista(Tlista *L, int N) { Tlista primer, prec, aux; int i; primer = (Tlista)malloc(sizeof(struct elemento)); primer->clave = 2; prec = primer; for (i = 3; i <=N; i++) { aux = (Tlista)malloc(sizeof(struct elemento)); aux->clave = i; prec->next = aux; prec = aux; } aux->next = NULL; *L = primer; }
La complejidad de esta función es 0(N). La función de eliminación recibe únicamente dos parámetros: 1. Un apuntador por una parte de lista.
2. Un valor por el cual se eliminan todos los múltiplos encontrados en la lista. La eliminación de un elemento en una lista ligada impone la reconstrucción del apuntador next de la celda precedente a la celda borrada. Por esta razón, se usan dos apuntadores, uno con la celda corriente y otro con la celda que lo precede. El código de la función es el siguiente: void eliminar(Tlista L, int valor) { Tlista prec, corr; prec = L; corr = L->next; while (corr != NULL) { if (corr->clave % valor == 0) { // se debe eliminar la celda apuntada por corr prec->next = corr->next; free(corr); corr = prec->next; } else { prec = corr; 259
Introducción a la programación
corr = corr->next; } } }
La complejidad de esta función es comparable a la complejidad de la función de eliminación por un arreglo, versión 1. La complejidad de todo el programa resulta del mismo orden que la complejidad de la primera versión usando un arreglo. El tiempo efectivo de ejecución siempre resulta mayor, pero proporcional al tiempo de ejecución de la primera versión. En el caso de este problema de criba de Eratóstenes, la implementación usando un arreglo es mejor que la implementación usando lista ligada.
Problema de Josephus El problema de Josephus8 por k y n parámetros de entrada consiste en detectar el último elemento y eliminarlo. La dinámica del problema (juego) es la siguiente: n personas están ubicadas alrededor de un círculo imaginario. Se mata (elimina) a cada k persona, se inicia con la persona número 1. Se concluye cuando queda una sola persona.
Para n = 8 y k = 3, las personas se enumeran como se muestra en la figura 5.21, el orden de salida es: 3, 6, 1, 5, 2, 8, 4; como se puede observar, el último elemento que queda es el 7. 1 2 8
3
7
6
4 5
Figura 5.21
Si queremos implementar una solución que calcule el resultado del problema, se debe utilizar una estructura de datos que permita la eliminación (real o simbólica de un elemento) y que también permita recorrer los elementos que representan a los jugadores; esta operación debe ser hecha respetando un orden preciso. Aunque es posible utilizar un arreglo o una lista ligada, la mejor opción es usar una lista circular, ya que esta permite Una versión conocida del problema de Josephus es el juego infantil que dice: “De tin marín de don pingüe / Cucara, macara, titere fue / Yo no fui, fue Teté / Pégale, pégale que ella fue”. A quien le toca el señalamiento “ella fue”, sale del juego. 8
260
una traducción más precisa del funcionamiento real del procedimiento. Para cualquier estructura de datos que se elija, la estructura del programa es: inicializar los datos, estructuras y variables simples 0 // es el numero del paso mientras < hacer contar hasta eliminar elemento +1 fin mientras
Si se trabaja con un arreglo, para eliminar un elemento (persona) se coloca un valor de BORRADO y no se elimina físicamente el elemento de la estructura. La versión del programa que trabaja con un arreglo es la siguiente: int main(int argc, char *argv[]) { int k, n; //parametros del problema int *C; //arreglo que guardan las personas int m;//numero de persona que salieron int posicion, indice; lectura_parametros_josephus(&n, &k); C = (int*)malloc(n*sizeof(int)); inicializar_arreglo(C, n); m = 0; posicion = 0; while (m < n-1) { cuenta(C, posicion, n, k, &indice); //printf(“ Paso %d : La persona que sale es =*%d=\n”, m, C[indice C[indice] = BORRADO; m++; posicion = (indice + 1)%n; } buscar_el_ganador(C, n); exit(0); }
La función de lectura tiene una forma muy simple y realiza la lectura de los parámetros del problema de Josephus; por su parte, la función de inicialización coloca los valores de 1 hasta n en el arreglo y, por último, la función de salida tiene como objetivo buscar en el arreglo el único elemento que no está borrado. La función de inicialización tiene una complejidad de 0(n), lo mismo que la función de escritura. void lectura_parametros_josephus(int *n, int *k) { printf(“ Indicar cuantas personas(n) hay alrededor del circulo: “); scanf(“%d”, (“ printf(“ n); Indicar el valor del contador (k): “); scanf(“%d”, k); } void inicializar_arreglo(int C[], int n) 261
Introducción a la programación
{ int i; for (i = 0; i < n; i++) C[i] = i + 1; } void buscar_el_ganador(int C[], int n) { int i; for (i = 0; i < n; i++) if (C[i] != BORRADO) { printf(“ return; El ganador es la persona -%d-\n”, C[i } printf(“ No se encontro ningun ganador\n”); }
Lo más interesante es la función cuenta, la cual toma como parámetros el arreglo, su tamaño n y el número k, regresando el índice con un valor que no se haya borrado y que se sitúe a una distancia de k con respecto a la posición inicial. void cuenta(int C[], int pos, int n, int k, int *indice) { int j; j = 0; while(j < k) { if (C[pos] != BORRADO) { j++; if (j == k) break; } pos = (pos +1) } *indice = pos; }
El número de operaciones que se realiza en una llamada a la función cuenta es variable: desde k, para las primeras veces, hasta n, cuando el arreglo tiene muchos valores eliminados. El número de pasos de iteración de la estructura while del programa principal es n – 1; entonces, la complejidad de todo el programa es: 0(kn) y 0(n2). Si se usa una lista ligada circular como estructura de datos, la eliminación de un elemento resulta ser la operación más delicada, ya que se necesita el apuntador del elemento que precede el elemento borrado, para guardar una lista circular coherente. La función cuenta regresa, entonces, dos parámetros: el apuntador por el elemento que se cuenta a k y el apuntador por el elemento que lo precede. El programa principal es el siguiente: int main(int argc, char *argv[]) { int n, k; //parametros del problema Tlista C; // lista circular que contiene a las personas Tlista prec; //un apuntador util para borrar elementos Tlista aux; //el apuntador por la persona que sale 262
int m; lectura_parametros_josephus(&n, &k); inicializar_lista_circular(&C, n); m = 0; while (m < n - 1) { cuenta(C, k, &aux, &prec); printf(“ Paso %d : La persona que sale es =*%d=\n”, m, aux->clave); prec->next = aux->next; free(aux); C m++; = prec->next; } printf(“ La persona que gana :”); imprima_lista(C); printf(“\n”); exit(0); }
Las funciones de inicialización y de cuenta del elemento k son las siguientes: void inicializar_lista_circular(Tlista *L, int n) { Tlista primer, aux; int i; *L = NULL; primer = (Tlista)malloc(sizeof(struct elemento)); primer->clave = 1; *L = primer; for (i = 2; i <= n; i++) { aux = (Tlista)malloc(sizeof(struct elemento)); aux->clave = i; (*L)->next = aux; *L = aux; } (*L)->next = primer; // se cierre la lista *L = primer; //se regresa el apuntador por el elemento 1 } void cuenta(Tlista C, int k, Tlista *aux, Tlista *prec) { int j; *prec = C; *aux = C->next; j = 2; while (j < k) { *prec = *aux; *aux = (*aux)->next; j++; 263
Introducción a la programación
} }
La complejidad de la función de inicialización es 0(n), en términos de número de operaciones, y la complejidad de la función cuenta es (k) (exactamente ck operaciones con c constante). Entonces, la complejidad global del programa es . Si comparamos las complejidades teóricas de las dos versiones de programa, resulta evidente que la versión que utiliza una lista ligada circular es mejor. Pero si se compara el tiempo de ejecución de los programas, por pequeños valores de n, la diferencia no es significativa; sin embargo, para n = 4000000 = 4 x 106 y k = 17, los tiempos9 son sensiblemente diferente: 10 segundos para la versión en la que se utiliza la lista circular y 19 segundos para la versión en la que se usa un arreglo.
Síntesis del capítulo En este capítulo estudiamos la posibilidad de definir sinónimos a través de tipos conocidos, estándares o propios del usuario; tipos escalares o derivados (apuntador, arreglo, función, etc.). También aprendimos que la palabra clave typedef permite elegir un identificador, para que sea el nombre del nuevo tipo. Si los arreglos agrupan múltiples valores del mismo tipo y con la misma semántica, el tipo estructurado o compuesto definido con struct permite conjuntar valores de tipos diferentes, con semánticas diferentes. Los datos que forman parte del tipo compuesto tratan, generalmente, de un mismo concepto. Los tipos estructurados pueden contener apuntadores para cualquier otro tipo del lenguaje, incluso del mismo tipo. Así, en este caso se habla de tipos estructurados auto-referenciados. Los tipos auto-referenciados permiten la definición de estructura de datos de tipo lista ligada: simple, doble o circular. El tiempo de las operaciones que manejan las estructuras de datos de tipo listas ligadas, en ocasiones es diferente del tiempo de acceso a un arreglo, el cual constituye la estructura de datos más simple. Los dos tipos de estructuras de datos, arreglos y listas ligadas, permiten la implementación de una parte de los tipos abstractos de datos más utilizados: pilas, colas y colas dobles. Durante la resolución de problemas, después de la fase de análisis, la decisión de utilizar arreglos o listas ligadas en el programa depende de la naturaleza de las operaciones necesarias de los datos; la elección influye decisivamente en el desempeño del programa.
8
264
Los programas fueron ejecutados en un MacBook con un procesador de 2.53 Ghz.
Bibliografía Knuth, Donald, El arte de programar ordenadores, vol I: Algoritmos fundamentales, Editorial Reverté, 1986. (The
Art of Computer Programming , Volume 1: Fundamental Algorithms, 3rd edition, 1997, Addison-Wesley Professional.)
, El arte de programar ordenadores, vol III, Editorial Reverté, 1986. (The Art of Computer Programming , Volume 3: Sorting and Searching, 3rd edition, 1998, Addison-Wesley Professional.)
Ritchie, Dennis y Kernighan Brian, El lenguaje de programación C, 2a. edición, Pearson. Educación, México, 1991. Sedgewick, Robert, Algorithms in C, Third edition, Addison-Wesley, 1998.
Ejercicios y problemas 1. Según el modelo de trabajo con la función struct fecha, escribir una función de prototipo: void lectura_persona(struct persona *P)
que lea dos cadenas de caracteres y llene la variable apuntada de tipo struct persona.
2. Escribir la función de asignación dinámica de la memoria con base en una estructura de tipo struct curso, haciendo llamadas a las funciones que llenan estructuras compuestas por fechas y personas. 3. Escribir una función que tome en la entrada dos variables de tipo struct curso y regrese 0 si las fechas se superponen (un curso inicia antes que el otro haya terminado) y 1 si las fechas no se sobreponen. Utilizar llamadas para la función comparar_fechas. 4. Definir un tipo Tmatriz que sea capaz de trabajar con matrices (arreglos bidimensionales) de dimensión variable en términos del número de líneas y de columnas, pero sin que este número exceda al 10 (una constante). Escribir funciones para: Lectura: las dimensiones y los elementos se leen uno a uno. Escritura. Inicialización de una matriz unidad o una matriz 0n. Suma y producto, probando antes si las dimensiones son compatibles con estas operaciones (número de líneas o columnas).
5. Definir primero un tipo estructurado struct info_producto y luego un sinónimo Tproducto, capaz de guardar datos relacionados con los productos que un vendedor de verduras tiene en su tienda: nombre de la fruta/verdura, cantidad existente, precio medio por kilo/pieza. El programa dispone de un archivo “lista.dat” que contiene el detalle de su mercancía en la forma: Producto1 cantidad1 precio1 Producto2 cantidad2 precio2
265
Introducción a la programación
Los datos serían almacenados en un arreglo de tipo Tproducto; el tamaño máximo del archivo es de 400 líneas. Escribir un programa que lea el archivo “lista.dat” y que llame un arreglo de elementos del tipo Tproducto y detecte el producto con la cantidad máxima y el precio mínimo.
6. Retomando los mismos datos del problema anterior, se supone que el tamaño del archivo “lista.dat” es variable y desconocido, por lo que se pide hacer un programa que: Lea una primera vez el archivo, para asignar de manera dinámica el espacio de memoria a través del arreglo
de tipo Tproducto, el cual va a contener los datos sobre los productos. Lea una segunda vez este archivo y guarde los datos en el arreglo. Escriba en otro archivo, llamado “lista_final.dat”, todos los productos que tienen una cantidad
mayor a 10.
7. Con el objetivo de ayudar al profesor en el cálculo de las calificaciones de un curso: Proponer un tipo estructurado conveniente que almacene la calificación final eventuales y notas de cadaalumno. Declarar un arreglo de este tipo (u otra estructura) capaz de guardar todas las calificaciones de un grupo de
alumnos. Escribir un programa capaz de realizar varias lecturas de resultados parciales y que al final de cada ejecución
del programa se almacenen en un archivo todos los datos disponibles.
8. En el programa que se encuentra al final de la primera sección, que trabaja con vectores representados mediante el tipo estructurado struct vector, aún faltan algunas funciones. Escribir las siguientes funciones y las llamadas necesarias en el programa principal main: El producto con un valor escalar. El producto escalar de dos vectores. La diferencia de dos vectores. La verificación de que dos vectores sean perpendiculares.
9. En el mismo programa que trata el tema de los vectores por los cuales se almacena el vector normalizado, aún falta por escribir una función que libera la zona de memoria ocupada para las variables con asignación dinámica de memoria. Escribir una función de prototipo:
void libera_vector(PTvector V);
10. Escribir una función de prototipo: void copia_arreglo(int T[], int nt, int S[], int &ns)
la cual hace una copia del arreglo T en el arreglo S. Se supone que el arreglo S tiene la memoria asignada.
11. En la función busqueda_arreglo de la sección 2 se busca una aparición del valor en arreglo (la primera, ya que el arreglo se recorre de izquierda a derecha). Nos interesa obtener todas las posiciones del arreglo que contienen este valor. Por tanto, el prototipo de la función que realiza esta búsqueda completa sería el siguiente: void copia_arreglo(int T[], int nt, int valor, int posicion[], int *np)
266
Los parámetros transmitidos por valor, nt y valor, son los parámetros de entrada; es decir, el tamaño del arreglo T por el cual se efectúa la búsqueda y el valor. Los parámetros de salida transmitidos por referencia son: *np indica el número de apariciones del valor en el arreglo T. posicion[] es el arreglo que guardan las posiciones que contienen el valor.
Por ejemplo, para el arreglo T:1 2 3 1 1 3 y el valor = 1, la función debe regresar *np: 3 y posicion[]: 0 3 4. ¿Cuál es la complejidad en términos de tiempo de ejecución de la función?
12. Modificar la funciónbusqueda_arreglocon el fin de cambiar la posición del elemento buscado. Cuando se encuentra este elemento y su índicei no es 0 (es decir, no es el primer elemento del arreglo ), se intercambia el elemento encontrado con su vecino deíndice i – 1. Crear un programa que verifique el funcionamiento correcto de esta función. 13. Usando el tipo presentado en este capítulo para trabajar con listas ligadas, escribir una función no recursiva para hacer la copia de una lista en otra lista. El prototipo de la funcion sería: void copia_lista_sin_recursion(Tlista Inicial, Tlista *Final)
¿Cuál es la complejidad de su función?
14. Para una lista ligada, escribir una función que obtenga la copia en espejo de una lista inicial. El prototipo de la función sería: void copia_lista_en_espejo (Tlista Inicial, Tlista *Espejo)
Por ejemplo, lista inicial contiene los valores 1 3 5 7 9 (en ese orden), su copia en espejo tendría que contener 9 7 si5 la3 1.
15. Para una lista simplemente ligada, escribir una función que verifique que la lista contiene un palíndromo. El prototipo de la función sería: int es_palindromo(Tlista L)
Un palíndromo es una sucesión de valores que es idéntica a su imagen en espejo. La sucesión 1 2 3 4 3 2 1 es un palíndromo, mientras que 1 1 2 2 3 no es un palíndromo.
16. Teléfono descompuesto El juego del teléfono descompuesto consiste en transmitir una palabra o una frase bastante larga entre varias personas; el juego inicia cuando la primera persona dice la palabra en la oreja del vecino; es muy probable que el vecino no entienda bien y transmita la palabra o frase a su otro vecino de manera errónea o alterada. El nuevo jugador transmite la palabra oída al siguiente vecino y así sucesivamente hasta que la palabra (alterada) regresa al primer jugador. Si la palabra regresa bien o con muy pocas modificaciones, el jugador pierde. Por ejemplo, las modificaciones posibles de algunas palabras son las siguientes: Se pierden las últimas dos o tres letras. Se duplican letras como c, l, n, r. Al interior de la palabra, una letra puede ser cambiada por otra.
267
Introducción a la programación
El objetivo de este programa sería simular el juego construyendo una lista ligada que contenga la palabra o la frase inicial y que con una determinada probabilidad se modifique la palabra, modificando así la lista que la contiene. Primero, se necesita definir un tipo estructurado capaz de contener letras. Luego, se requiere establecer las siguientes funciones: a) Lectura de una cadena de caracteres y la construcción de la lista ligada que contiene sus palabras. b) Decisión de hacer o no modificaciones a la lista, indicando también el tipo de modificación. c) Para cada tipo de modificación, crear la función que realiza la modificación. d) Una función capaz de extraer la palabra desde la lista ligada y guardarla en un arreglo como una cadena de caracteres. e) Una función capaz de comparar dos cadenas de caracteres, si tienen o no más de la mitad de letras en común. Escribir un programa que utilice el tipo estructurado propuesto y las funciones que simulan las transformaciones de la palabra inicial durante N pasos, donde el valor de N se lee en la entrada del programa.
17. La función presentada, eliminacion_elemento, borra un elemento indicado por un apuntador. Escribir una función de prototipo: int eliminacion_elemento_valor(Tlista &L, int valor)
que borre el primer elemento que contiene el valor transmitido por parámetro.
18. (Difícil) Escribir una función de prototipo: int eliminacion_todos_elementos_valor(Tlista &L, int valor) que elimine de la lista todos los elementos conteniendo el /tt valor. Sugerencia: utilizar una función recursiva.
19. Trabajo con listas circulares. En la sección que aborda el tema de las listas circu lares faltan algunas funciones. Escribir las funciones siguientes y poner en la parte de main las llamadas a las funciones propuestas: El tamaño (número de celdas) de una lista. La inserción al inicio de la lista (y no). La creación de una lista circular de tamaño variable mediante una sola función que lea valores y haga lla-
madas a funciones de inserción. La búsqueda de un elemento que guarde en su clave un valor transmitido como parámetro.
20. Cortar una lista. Escribir una función que corte una lista L en dos listas; el corte se hace en una posición transmitida por parámetro, como en la figura 5.22 para la lista 1, 3, 5, 7 y el corte en la posición 2.
268
Linicial clave next
clave next
1111
Linicial
clave next
clave next NULL
clave next 1
Lsegunda
clave next 1 NULL
clave next 1
clave next 1 NULL
Figura 5.22
La función tiene como prototipo: int corte_lista(Tlista *Linicial, Tlista *Lsegunda, int posicion);
21. Separación de elementos de lista. En una lista ligada hay varios valores enteros; hacer una función que destruya la lista inicial y, con los elementos que la componen, crear dos listas ligadas: una con los números pares y otra con los números impares (veáse la figura 5.23). Linicial clave next
1
Limpares
Lpares
clave next 12578
clave next 2578
clave next
clave next
clave next
clave next
clave next NULL
clave next
clave next NULL
Figura 5.23
El prototipo de la función sería: void separa_lista(Tlista *Linicial, Tlista *Lpares, Tlista *Limpares);
22. Para una lista doblemente ligada, escribir una función que inserte un elemento al final de lista y otra función que inserte un elemento en una posición intermedia k. Los prototipos de estas funciones serían: void insercion_lista_doble_fin(Tlista_doble *LD, int clave) void insercion_lista_doble_intermedia(Tlista_doble *LD, int clave, int posicion)
23. Conjunto matemático implementado con arreglos a) Implementar el tipo abstracto del conjunto matemático como un arreglo con su dimensión.
269
Introducción a la programación
b) Escribir funciones de inserción y de eliminación de valor. Para la inserción, primero se debe verificar si el elemento existe o no en el conjunto. c) Escribir funciones para las tres operaciones de conjuntos conocidas.
24. Listas ordenadas. Los elementos de una lista son ordenados, por lo que se dice que la lista es ordenada si un elemento es seguido de un elemento mayor. Por ejemplo: la lista 1 2 3 6 8 9 es ordenada, pero la lista 9 8 12 14 no lo es. Para una lista ligada simple, escribir una función que verifique si la lista es ordenada o no. El prototipo de la función sería: int es_ordenada(Tlista L);
Una lista ligada circular es ordenada si cuenta con un elemento a partir del cual la lista está ordenada. Ejemplos: la lista 3 4 5 6 1 2 es ordenada, porque desde el elemento 2 el orden se respeta; por el contrario, la lista 3 4 5 6 1 2 0 no está ordenada. Escribir la función que verifica si la lista circular es ordenada y que también regresa el elemento a partir de cual el orden se respeta. Su prototipo sería: int es_ordenada_circular(Tlista_circular L, Tlista_circular *E);
25. Versión del problema de Josephus. En el momento en el cual una persona sale, se considera que esta persona elige a otra que también sale. El cálculo se continúa con el vecino de la segunda persona. Con la estructura de datos que le parezca mejor, escribir el programa que implemente esta versión.
270
6
Contenido 6.1 Introducción 6.2 Fundamentos teóricos y descripción de problemas Relación de orden Marco general de estudio y estructuras de datos Búsqueda Selección Ordenamiento 6.3 Arreglos y listas ligadas Búsqueda Selección Mantenimiento: inserción y eliminación Ordenamiento 6.4 Montículos Definición y propiedades Implementación Inserción y eliminación de elementos Ordenamiento por montículo
Síntesis del capítulo Bibliografía Ejercicios y problemas
Objetivos fundamentales de la computación: búsqueda, selección y ordenamiento. estucturas de datos de tipo arreglo o lista ligada. y su implementación.
271
6.1 Introduccción En este capítulo se abordan y desarrollan tres problemas típicos de programación:
1. La búsqueda de un valor. 2. La selección de los valores extremos (mínimo, máximo y cuartiles). 3. El ordenamiento de valores. Para cada uno de estos tres casos se inicia con un estudio teórico sobre el desempeño de la mejor solución que existe y se concluye con la exposición de las soluciones que existen en términos de algoritmos y de estructuras de datos adaptadas; todo esto implementado en el lenguaje C. De igual manera, para los tres problemas de programación se trabaja con las mismas hipótesis:
1. “Los conjuntos de trabajo tienen una relación de orden y se pueden representar por completo en la memoria interna de la computadora.” 2. “Los problemas se pueden tratar cuando están almacenados en la memoria externa.” Existen soluciones que funcionan para las dos memorias: interna y externa; ese es el caso de las tablas de dispersión y de los árboles-B. Por tanto, los problemas que se tratan en este capítulo no están restringidos a ser trabajados únicamente con la memoria externa.
6.2 Fundamentos teóricos y descripción de problemas En esta sección se describe y explica cuáles son los problemas prin cipales que se manejan en el área de la programación. En este sentido, los fundamentos teóricos resultan de gran importancia, ya que nos permiten tener una idea más clara de los problemas, además de que también podemos analizar cuál es la cualidad de una solución con respecto a la solución ideal.
Relación de orden Una de las principales hipótesis de trabajo para el caso de los problemas que se estudian y la proposición de sus soluciones, es que los elementos de los conjuntos que se tratan pertenecen al universo de posibles valores para los elementos de X. Esto es, los elementos de X tienen una relación de orden completa: a saber, por cualesquiera dos elementos, x, y ∊ X se puede responder en un tiempo de cálculo (de decisión) finito con una y solo una de las siguientes relaciones:
a) x = y: equivalencia. b) x < y o y > x: x menor que y. c) x > y o y < x: x mayor que y. Esto significa que cualesquiera dos valores del universo X son comparables.1 Esta relación también deriva en una relación < o >; esto es, x > y, con la significación x = y o x > y. 1
272
También hay casos de universos en los cuales dos elementos no son siempre comparables; la relación es de orden parcial.
La relación < es una relación de orden estricta; por su parte, la relación < se conoce como relación de orden larga, la cual presenta las siguientes condiciones: Reflexividad: x < x para cualquier x del universo. Antisimetría: si x < y y y < x, entonces x = y. Transitividad: si x < y y y < z, entonces x < z.
También se considera que el punto de vista de la implementación en nuestra relación de orden se traduce en una comparación, a través del uso de un operador estándar, como: <, >, <=, >=, ==, !=, o de una función estándar strcmp o strncmp (por el tipo cadena de caracteres) o, si el tipo de los valores es de un tipo compuesto, con las llamadas a las 2
funciones definidas por el usuario. En el capítulo anterior usamos una función struct fecha para el tipo compuesto: struct fecha { int dia; int mes; int ano; }; int compar_fechas(struct fecha f1, struct fecha f2) { if (f1.ano < return 1; if (f1.ano > return -1; if (f1.mes < return 1; if (f1.mes >
f2.ano) f2.ano) f2.mes) f2.mes)
return -1; if (f1.dia < f2.dia) return 1; if (f1.dia > f2.dia) return -1; else return 0; }
El tiempo para obtener una respuesta a una pregunta: x < y o x = y o x < y (en este caso, la respuesta es SÍ o NO), es considerado como significativo y constante (para cualquier pareja de valores), cualquiera que sea el modo de resolución de la comparación: operador relacional o llamada a una función. En la mayor parte de este capítulo trabajamos con números enteros (tipo int); el conjunto de estos enteros se considera del universo X. En el caso de algunas estructuras de datos vamos a considerar cadenas de caracteres (tipo char[] ), como el caso de la discusión sobre tablas de dispersión. En muchos casos se trata de elementos de cierto tipo compuesto, pero las operaciones de búsqueda, selección u ordenamiento se hacen con respecto a un componente de este tipo, al que se llama clave . El universo X es, entonces, el conjunto de claves posibles de los elementos. En resumen, en la resolución de un problema se trabaja con un subconjunto U de X; naturalmente, U ⊂ X y U es un conjunto finito que se representa en la memoria interna, en una estructura de datos conveniente. Por tanto, podemos
2
El ejemplo completo se encuentra en el capítulo 5, sección 1. 273
considerar, por una parte, que el conjunto U = {x1, x2… xN}, con como cardinal del conjunto U (||U|| = N), y por otra parte también el tamaño del problema a resolver.
Marco general de estudio y estructuras de datos Como se mencionó antes, los tres problemas principales que se tratan en este capítulo son los siguientes:
1. La búsqueda de un valor. 2. La selección del elemento extremo (mínimo o máximo). 3. El ordenamiento de un conjunto finito de valores. Para cada uno de los problemas anteriores se describe la pregunta de una manera formal, con el fin de poder indicar, después, de manera formal, la complejidad teórica de su resolución, a saber, el límite de baja complejidad para cualquier solución concreta, en el caso más general. El desempeño de las soluciones propuestas también depende de la estructura de datos que se selecciona para almacenar los datos que se tratan. A lo largo de este capítulo se trata cada uno de los siguientes puntos para cada problema: Presentar las soluciones que existen, usando las estructuras de datos conocidas: arreglo o lista ligada. Proponer nuevas estructuras de datos y las soluciones que se van a utilizar. Poner en evidencia el desempeño de estas soluciones. Abordar la dinámica de las estructuras de datos (generales o específicos) desde el punto de vista de la creación, la
inserción (adición) de nuevos elementos y la supresión (eliminación) de elementos existentes.
Búsqueda El problema de búsqueda es un problema de verificación de la existencia (pertenencia) de un elemento o una clave en el conjunto de trabajo. La respuesta es de tipo SÍ o NO. En caso de que la respuesta sea positiva, también se espera la ubicación del elemento encontrado, como un índice, un apuntador o cualquier otro mecanismo para acceder al elemento en tiempo constante o razonable. En el caso de que la respuesta sea negativa, en ocasiones, es posible regresar el elemento más cercano o el mayor de todos los más pequeños del número buscado. De manera formal, si hay un elemento x del mismo universo que el conjunto de trabajo U (x ∊ X y U ⊂ X), la respuesta a la incógnita sería:
x∊U Si U = { x1, x2… xN}, se pregunta si regresa un índice i (i entre 1 y N), tal que x = xi. Hace más de 40 años, el gran computólogo Donald Knuth afirmó que una gran parte del tiempo que se dedicaba al cómputo, se utilizaba para resolver problemas de búsqueda; hoy en día, el segundo uso más intenso de Internet es la búsqueda mediante motores de búsqueda. Asimismo, en el uso de bases de datos, el problema de búsqueda también se debe resolver varias veces para cada interrogación compleja de la base. Si el conjunto de trabajo U contiene claves múltiples(esto es, los elementos se guardan con repetición), el problema de búsqueda exige indicar: un solo elemento, el índice más pequeño o elíndice más grande, o todas las apariciones. 274
La estructura de datos que permite dar una respuesta rápida a los problemas de búsqueda se conoce con el nombre de diccionario. Algunos ejemplos de estructuras de tipo diccionario son: arreglos ordenados, listas ligadas ordenadas o no, árboles de búsqueda, árboles equilibrados, árboles rojo-negro, árboles H-equilibrados (o de Adelson-Velskii), árboles-B y tablas de dispersión, entre otros. Para analizar la complejidad del problema, es indispensable tomar en cuenta el número de operaciones de comparación de elementos. Se considera que esta operación es la más complicada de todos los cálculos. Una comparación entre dos elementos, a, b ∊ X es de la forma a : b y el resultado es: = o < o >. Un árbol de comparaciones para un conjunto con dos elementos U = { x1, x2} y un elemento x1 que se busca es el siguiente:
x1 : x <
> =
x <
Sí
: x2 =
NO
x
: x2
> Sí
<
=
NO
NO
> Sí
NO
Figura 6.1
Otro ejemplo de árbol es el siguiente:
x2 : x <
> =
x < NO
Sí
: x1 =
x
> Sí
: x1 < NO
NO
=
> Sí
NO
Figura 6.2
En el ejemplo de la figura 6.2 se inicia con la comparación del segundo elemento. En los dos árboles anteriores, el número de comparaciones, en el peor de los casos es de 2, que corresponde al número máximo de nodos interiores de un camino entre la raíz y una hoja. Un determinado camino contiene siempre nodos interiores y una sola hoja con la respuesta. En el caso de una respuesta positiva, la clave que se busca se encuentra en el último nodo interior del camino.
275
Un cálculo de búsqueda se traduce en un árbol de comparaciones. Así, el grado de un nodo es de: 3 por la raíz. 1 por un nodo que es una hoja (sin hijo). 4 por los otros nodos, los cuales son nodos interiores (ni raíz, ni hojas).
Por cada nodo interior, hay como máximo otros dos nodos descendentes, los cuales son nodos interiores. Si se construye un árbol de comparaciones A con un conjunto con N elementos, entonces A tiene al menos N nodos interiores. Con base en estas observaciones, es posible mostrar el siguiente resultado matemático: Teorema: La búsqueda de un elemento en un conjunto de tamaño N, siempre se realiza haciendo al menos log 2
N comparaciones. Demostración: Si A es un árbol de comparaciones para la búsqueda en el conjunto con N elementos, el número
de comparaciones es la profundidad del árbol excepto 1; esto es, el número de nodos interiores del camino máximo entre la raíz y una hoja. El número mínimo de comparaciones necesario se encuentra en la profundidad mínima de cualquier árbol de comparaciones para los N elementos iniciales. Así, sea k la profundidad mínima del árbol de comparaciones que contiene al menos N nodos interiores. A cada nivel se encuentran 2i nodos. Así, entonces hay: 1 + 2 + … + 2 k–1 < N < 1 + 2 + … + 2k–1 + 2k 2k–1 < N < 2k+1 –1
k < log2 N + 1 < k +1 De aquí, se deduce que k es la parte entera superior de log 2 (N + 1). Entonces:
k = log2 N. Este resultado teórico muestra que cualquier algoritmo en 0(log2N) es considerado como óptimo.
Selección La definición formal del problema de selección es la siguiente. Del conjunto U = { x1, x2… xN} se desea obtener el siguiente valor:
mín (U) = min tal cual min < xi , i = 1, N Y el valor:
máx (U) = max tal cual max > xi , i = 1, N Lo anterior significa que se trata de dos problemas separados; por un lado, el cálculo del mínimo y, por el otro, el cálculo del máximo, los cuales son completamente equivalentes se conoce una solución problema, estafinito, misma solución se puede utilizar para el otro problema). Como la(si relación de orden < es totalpara y elcada conjunto inicial es es posible determinar la solución del problema de selección en tiempo finito. Según la definición misma del problema, se deben hacer al menos N – 1 comparaciones para cada conjunto que no está organizado en ninguna estructura de datos conveniente, para buscar el mínimo o el máximo. Si se pretende buscar los dos extremos, es posible demostrar que el tiempo de selección sería 3 N2– 1 . 276
Proposición: Si los valores del conjunto U se tratan de manera secuencial (es decir, uno después de otro, sin poder regresar), solo el cálculo del mínimo se hace con N – 1 comparaciones. El cálculo del mínimo y del máximo se hace con al menos 32N – 2 comparaciones, en el peor de los casos. Demostración: Para la primera parte se requiere demostrar que se necesitan al menos N – 1 comparaciones. En el caso de que se realicen menos de N – 1 comparaciones, a saber N – 2 o menos, significa que uno de los elementos de U no ha sido tratado; que es absurdo. Para la segunda parte se requiere demostrar que se necesitan al menos 32N – 2 comparaciones. Se supone que trabajamos por inducción, según el número N de los elementos del conjunto. En el caso de N = 2, una sola comparación es suficiente para detectar el mínimo y el máximo: 3×2 2
–2=3–2=1
En el caso de N = 3, se puede proponer el siguiente algoritmo, el cual calcula el mínimo y el máximo de los dos
primeros elementos y luego se comparan sucesivamente con x3: if x1 < x2 then min ← x1; max ← x2 else min ← x2; max ← x1 endif if x3 < min then min ← x3 else if x3 > max then max ← x3 endif endif Si se supone cierta la proposición para todos los M, M < N, hacemos la demostración para N + 1.
Según la hipótesis inductiva por N – 1 se necesita
(3 N – 1) . 2
Así, sean mín’ y máx’ el mínimo y el máximo de los primeros N – 1 elementos. Para calcular el mínimo mín y el máximo máx de todos los N + 1 elementos, podemos proponer el cálculo siguiente: calculo de min’ y max’ if xN-1 < xN then min←xN-1; max←xN else min←x2; max←xN-1 endif if min’ < min then min←min’ endif if max’ > max then max←max’ endif
En la última parte del algoritmo se realizan tres comparaciones; entonces el número total de comparaciones es:
(3 N – 1) 3N +3= 2 2 Si las operaciones de selección del mínimo o del máximo se realizan con frecuencia, los elementos se pueden insertar y borrar, lo más conveniente es organizar el conjunto de datos U en estructuras de datos convenientes. Una estructura 277
de datos es aquella que permite la realización de las siguientes operaciones: inserción, eliminación, cálculo del extremo (mínimo o máximo) y extracción (eliminación) del extremo; esta última recibe el nombre de cola de prioridades.
Ordenamiento En el tercer volumen de su amplia monografía, El arte de programar ordenadores , Donald Knuth estudió a profundidad el problema de búsqueda y el problema de ordenamiento. Al inicio de la parte dedicada al ordenamiento, Knuth indica que 25% del tiempo de cómputo (en aquel tiempo) estaba dedicado a las operaciones de ordenamiento. Hoy en día, las cosas no han cambiado radicalmente, ya que las operaciones de ordenamiento también son muy frecuentes de una manerao los explícita. Por ejemplo, destacan las operaciones bancarias de forma decreciente, acuerdo con la fecha, resultados de un motor de búsqueda de Internet, dondeordenadas los resultados aparecen ordenadosdesegún un criterio de pertinencia que no es explícito.
De manera formal, ordenar un conjunto de elementos U = { x1, x2… xN} es equivalente a determinar una permutación de los índices {1, 2… N}, de tal manera que hay: i
< j : x (i) < x ( j )
Esto significa que:
x (1) < x (2) < … < x (N)
Entonces, en la mayoría de los casos se obtiene por ordenamiento el conjunto U = { x1, x2… xN} = { y1, y2… yN} con los elementos yi ordenados. En el análisis de las soluciones de ordenamiento intervienen varios criterios, entre los que destacan: La representación del conjunto inicial; a saber, la estructura de datos que contiene el conjunto U. El número de operaciones que se hacen. En este caso, las operaciones más significativas son: las comparaciones
y el número de asignaciones entre elementos, para cambiar la zona de memoria ocupada; en algunos algoritmos también se realizan intercambios entre elementos. Si el resultado del ordenamiento se encuentra en la misma parte de la memoria que el conjunto inicial, entonces se habla
de ordenamientoin situ en un caso y de ordenamiento conmemoria suplementariaen el otro caso. Estabilidad. Se dice que si el orden inicial de los elementos equivalentes (de claves equivalentes) se guarda en el
resultado del ordenamiento, entonces el método (algoritmo) de ordenamiento esestable. La propiedad de estabilidad es importante en el caso donde se requiere obtener un resultado ordenado según una clave principal; encambio, en el caso donde las claves principales son equivalentes, el ordenamiento se hace segúnotra clave. Por ejemplo, inicialmente tenemos un conjunto de nombres de alumnos ordenado alfabéticamente donde a cada alumno se le asigna un valor de calificación final; entonces, si el ordenamiento, según esta calificación, es estable, el orden final presenta una lista ordenada con el nombre de los alumnos acompañado de las calificaciones, también ordenadas por el mismo valor de calificaciones, es decir en orden alfabético. Entonces, se puede decir que se hace un solo ordenamiento según un solo criterio, para que el ordenamiento sea estable.
Ejemplo – ˜ ˜ – ˜ ˜ Para el conjunto 3, 2, el 1, orden 3, 1’, el ˜ orden ˜ 1,sí2,es3,estable. 3, no es estable, debido a que no se respeta el orden inicial de los valores; en este caso, 1, 1’, 2, –31’, , 3, Con el objetivo de analizar métodos de ordenamiento, se recomienda construir árboles de ordenamiento, donde en los nodos internos (que no son hojas) hay comparaciones y en las hojas hay permutaciones del conjunto inicial. Por ejemplo, para N = 2 existe el siguiente árbol: 278
x1 : x2 <
>
x2, x1
x1, x2
Figura 6.3
Para N = 3 existe el árbol siguiente: x1 : x2 <
>
x2 : x3
x1 : x3
<
>
<
x3 : x1
x1, x2, x3
<
x1, x3, x2
x2, x1, x3
>
x3 : x2
>
<
x3, x1, x2
x2, x3, x1
>
x3, x2, x1
Figura 6.4
Estos árboles de comparaciones tienen como hojas las permutaciones U, pero para estos sean correctos es necesario (aunque no suficiente) encontrar, al menos unadel vez,conjunto todas lainicial permutaciones. Así,que el número de hojas es por lo menos de N!.3 El número máximo de operaciones que se hacen en un método de ordenamiento descrito por un árbol de comparaciones es indicado por el tamaño del camino entre la raíz y una hoja (el número de nodos de comparación incluso la raíz). A continuación se describen algunas propiedades. Proposición es la profundidad (el camino más largo entre la raíz y una hoja) de un árbol de comparaciones, la cual es de al menos log2 N!. Demostración. Esta propiedad se basa en la observación de que un árbol de comparaciones tiene sus nodos internos de grado 3, con dos descendentes, y que la raíz tiene grado 2, también con dos descendentes. Basándonos en este resultado, es posible enunciar el siguiente teorema: “Cualquier algoritmo de ordenamiento necesita al menos 0(N log2 N) comparaciones, en el caso más desfavorable.” Demostración del teorema: Si se considera el árbol de comparaciones que traduce el algoritmo, en el caso más desfavorable, se encuentra en el camino más largo entre la raíz (el inicio del algoritmo) y una hoja (el resultado final). Es importante destacar que el largo de este camino es lo mismo que la profundidad del árbol. Entonces, el número mínimo de comparaciones de un algoritmo, en el caso más desfavorable, es de log 2 N!. Si aquí aplicamos la fórmula de Stirling: N N N! ≈ 2π N e 3
Se recuerda que N! = 1 x 2 x 3 . . . x N es el número de permutaciones posibles de N objetos. 279
se obtiene log2 N! ≈ N log2 N. Los algoritmos de ordenamiento que se presentan y estudian en este capítulo son: Cuadráticos: 0(N2). Subcuadráticos: 0(N log N) en media, 0(N2) en el peor de los casos. Optimales: 0(N log N). Lineales: 0(N); estos funcionan únicamente para conjuntos particulares.
6.3 Arreglos y listas ligadas En el capítulo 5 se estudian los arreglos y las listas ligadas, estructuras de datos que tienen en común una visión lineal de los elementos que se almacenan. En su caso, el arreglo tiene la ventaja del acceso directo a cualquier elemento, pero necesita una zona de memoria contigua para guardar sus elementos. Por su parte, la lista ligada tiene la ventaja de poder contener un número importante de elementos, sin ninguna restricción sobre la memoria utilizada; su desventaja proviene del acceso a los elementos, ya que falta el acceso directo a los mismos; se puede acceder a un elemento recorriendo la lista desde su inicio hasta que se encuentra el elemento. Asimismo, en el capítulo 5 se explica que estas dos estructuras, de arreglo y de lista ligada, no tienen ninguna restricción entre los elementos. La inserción y la supresión de elementos se realizan fácilmente al fin de arreglo o al inicio de lista. Por lo general, la búsqueda se realiza con 0(N) operaciones. En esta sección seguimos trabajando con arreglos y listas ligadas, pero analizando los siguientes problemas: búsqueda, selección y ordenamiento. Así, para cada una de las estructuras de datos vamos a tratar dos casos:
a) Los elementos están ordenados. b) Los elementos no están ordenados.
Búsqueda En estructuras lineales de tipo arreglo y lista ligada es posible aplicar el método de búsqueda secuencial. En tanto, para un arreglo ordenado, se puede aplicar el método de búsqueda binaria. Un caso particular de búsqueda binaria que siempre se aplica por arreglos ordenados, cuando se conoce la distribución inicial de los elementos, es la búsqueda por interpolación. La búsqueda binaria no se puede aplicar fácilmente a listas ligadas porque se necesita un acceso directo a los elementos. Búsqueda secuencial
El principio de la búsqueda secuencial es muy simple. Primero, se recorre el conjunto U = { x1, x2… xN}, desde el inicio (o desde el fin), en búsqueda de un elemento después de otro, que es su vecino, hasta que se encuentra el valor que se busca. Así, para cada i, de 1 hasta N, comparar x con xi. Si x1 = x, EXITO para por el índice i. Para un arreglo (no ordenado), la función que traduce este algoritmo es la siguiente: int busqueda_secuencial(int X[], int N, int valor, int *pos) { int i; for (i = 0; i < N; i++) 280
if (X[i] == valor) { *pos = i; return CIERTO; } return FALSO; }
La función toma como parámetros de entrada el arreglo, su dimensión y el valor que se busca, y regresa uno de los dos valores, CIERTO o FALSO. Como parámetro de salida (transmitido por referencia), la función toma el primer índice que es equivalente al valor. Una llamada de esta función tiene la forma: busqueda_secuencial(A, na, valor, &pos) Para el caso de una lista ligada elaborada con elementos del tipo: struct elemento {int clave; struct elemento *next; }; typedef struct elemento* Tlista;
la función de búsqueda secuencial es la siguiente: int busqueda_secuencial(Tlista L, int valor, Tlista *E) { Tlista aux; for (aux = L; aux != NULL; aux = aux->next) if (aux->clave == valor) { *E = aux; return CIERTO; } return FALSO; }
La función anterior tiene tres parámetros: el apuntador de la lista, el valor que se busca y un apuntador al elemento, que es equivalente al valor buscado. Una llamada a esta función es de la forma: busqueda_secuencial(L1, val, &aux)
Para analizar la complejidad de la función de búsqueda, 4 se toman las probabilidades pi donde el valor buscado sea equivalente al elemento xi y la probabilidad q del valor no esté en el arreglo; entonces, el número promedio de comparaciones que se hacen es:
M = 1 x p0 + 2 x p1 + … + N x pn–1 + (N + 1) × q Hay que q + Ni = 1 pi = 1 y pi, q función deriva en:
∊
[0,1]. Si se supone que p1 = p2 … = pN, el número medio de comparaciones de la
M = p1 N (N + 1) + q(N + 1) 2 Así, en la hipótesis q = 0 (donde se busca un valor que seguramente pertenece a U), el número promedio de operaciones es N 2+ 1 . 4
Se recuerda que los cálculos se hicieron en el capítulo 5. 281
Por su parte, en la hipótesis q = arreglo), se obtiene M = 3 ( NN+ 1)
1 2
(donde existe la misma probabilidad de que el elemento se encuentre o no en el
Así, el número promedio de operaciones que se hace para una búsqueda secuencial de valor en un arreglo o una lista es 0(N). Para la búsqueda secuencial en estructuras ordenadas, el principio es el mismo, únicamente que cuando se encuentra un elemento mayor que el valor, se puede terminar la búsqueda sin éxito, debido a que todos los elementos que siguen también son mayores que el valor buscado. Para cada i de 1 hasta N comparar x con xi. Si xi = x, salida con ÉXITO para el índice i. Si x < xi, salida SIN ÉXITO.
La función que traduce este algoritmo para un arreglo ordenado es la siguiente: int busqueda_secuencial_ordenada(int X[], int N, int valor, int *pos) { int i; for (i = 0; i < N; i++) { if (X[i] == valor) { *pos = i; return CIERTO; } if (X[i] > valor) { *pos = i; return FALSO; } } return FALSO; }
De esta forma, en el caso FALSO (es decir, SIN ÉXITO), el índice que se regresa indica el primer elemento del arreglo mayor que el valor buscado. La función de búsqueda secuencial en una lista ligada con elementos en orden creciente es la siguiente: int busqueda_secuencial_ordenada(Tlista L, int valor, Tlista *E) { Tlista aux; for (aux = L; aux != NULL; aux = aux->next) { if (aux->clave == valor) { *E = aux; return CIERTO; } if (aux->clave > valor) { *E = aux; return FALSO; 282
} } return FALSO; }
Para calcular el número promedio de comparaciones, debemos considerar las siguientes probabilidades: pi: probabilidad x = xi, i = 1, N. q0: probabilidad x < x1. qN: probabilidad x > xN. qi: probabilidad xi < x < xi + 1, i = 1, N – 1.
Las probabilidades pi y qj verifican:
N
N
i =1
i =1
∑ pi + ∑ qi =1
Si hacemos la hipótesis:
p1 = p2 = … = pN y q0 = q1 = … = qN Entonces, el número promedio de comparaciones se obtiene de:
M = p1 N (N + 1) + q0 (N + 1) (N + 2) 2 2 Si el valor buscado se encuentra, en efecto, en el conjunto U, esto significa: qi = 0, i = 0, N El número de comparaciones proviene de: M N +2 1 Si el valor buscado se encuentra en el arreglo con la probabilidad 12 , entonces: 2N + 3
M= 4 Este resultado indica que el método de búsqueda secuencial con estructuras ordenadas es más rápido que en el caso de una estructura desordenada, mediante un factor de proporcionalidad. La complejidad del algoritmo de búsqueda secuencial en todos los casos es 0(N). Búsqueda binaria
Este método de búsqueda de valor funciona de manera óptima con arreglos ordenados. La idea principal es la reducción del espacio en el cual se busca el valor. Al inicio, el espacio es el arreglo completo. Entonces, se toma un índice al interior del intervalo (la mitad, por ejemplo) y este elemento (el pivote) se compara con el valor buscado. Si la comparación pone en evidencia la equivalencia, la búsqueda termin a con éxito; de lo contrario, si el valor buscado es menor que el elemento pivote, entonces se reduce el intervalo de búsqueda hacia la izquierda. En el caso opuesto, el intervalo se reduce a la derecha. Sean i y d los índices del intervalo en el cual se busca el valor x. Entonces, el inicio es i ← 1 y d ← N. El siguiente es un paso iterativo de la búsqueda con un índice j al interior del intervalo [i, d], i < j < d: if x = xj then ÉXITO endif if x > xj then i←j+1 else d←j-1 endif 283
La búsqueda termina ya sea en un caso de éxito o cuando el intervalo [ i, d] no existe, porque i resulta mayor que d. Para elegir el índice j al interior del intervalo, existen varias opciones posibles: De manera aleatoria. A la mitad del intervalo. Con otro procedimiento.
El código de la función recursiva de búsqueda binaria, tomando la mitad del intervalo, es el siguiente: int busqueda_binaria_rec(int T[], int g, int d, int valor, int *pos) { int m; if (g > d) return FALSO; m = (g + d) / 2; if (T[m] < valor) return busqueda_binaria_rec(T, m+1, d, valor, pos); if (T[m] == valor) { *pos = m; return CIERTO; } else return busqueda_binaria_rec(T, g, m-1, valor, pos); }
La llamada incial de esta función es la siguiente: busqueda_binaria_rec(A, 0, na-1, valor, &pos)
La versión no recursiva de esta función es la siguiente: int busqueda_binaria(int T[], int g, int d, int valor, int *pos) { int m; while (g <= d) { m = (g + d) / 2; if (T[m] == valor) { *pos = m; return CIERTO; } if (T[m] < valor) g = m + 1; else d = m - 1; } return FALSO; }
En comparación con el algoritmo de búsqueda secuencial que encontraba el primer valor del arreglo equivalente al valor buscado, este algoritmo encuentra cualquier elemento equivalente.5 5
284
Para encontrar el primer elemento equivalente se debe modificar la condición de terminación del algoritmo.
Teorema: La búsqueda binaria (dicotómica), tomando la mitad del intervalo, necesita el máximo [log 2N] + 1 comparaciones, tanto en caso de éxito como en caso contrario. Demostración: Si después de una comparación entre el valor y la clave del arreglo, no hay equivalencia, el espacio de búsqueda se reduce de la mitad, es decir, el tamaño se divide entre dos. Entonces, después de k comparaciones, el espacio de búsqueda es de tamaño
N. 2k
En este caso, la búsqueda se termina, lo mismo ocurre cuando hay éxito en el caso o cuando hay
N 2k
< 1.
Entonces, se realizan al máximo k + 1 comparaciones, donde k = [log2N]. La complejidad de la búsqueda binaria es 0(log2N), porque las operaciones que se realizan a cada paso, incluso el cálculo de la mitad del intervalo (también la constitución de la pila de llamadas para la versión recursiva) se hacen en tiempo constante. Asimismo, el algoritmo de búsqueda binaria también está implementado como una función en la biblioteca de funciones estándares stdlib. El prototipo de esta función stdlib.h es el siguiente: void *bsearch(const void *key, const void *base, size_t nel, size_t size, int (*compar)(const void *, const void *));
De esta forma, la función recibe como parámetros: Un arreglo key de un tipo (puede ser cualquiera) de dimensión size. La dimensión del arreglo (su número de elementos): nel. Un apuntador para el valor que se busca: base. Un apuntador para la función que realiza la comparación de los elementos del arreglo.
Esta función regresa un apuntador para un elemento del arreglo equivalente al valor apuntado para base o bien regresa el valor NULL, si el valor no se encontró; en este caso, se supone que el arreglo key está ordenado de forma creciente. Esta función tiene una complejidad en 0(logN). Un ejemplo de uso de la función bsearch es: static int comp(void *e1, void *e2) { int *p1, *p2; p1= e1; p2 = e2; return (p1 <= p2); } int T[300]; int N; int val; int main() { int *p; ..... p =(p== bsearch(&val, T, N, sizeof(int), comp); if NULL) printf(“ No se encontro \n”); else printf(“ El valor %d se encontre.\n “, *p); .... } 285
El programador solo debe implementar la función de comparación. La función regresa –1, 0 o 1, según el resultado de la comparación <, = o >. Búsqueda por interpolación
Este método es una adaptación del algoritmo de búsqueda binara (dicotómica), a través del cual se intenta seleccionar un mejor índice j al interior del intervalo [i, d]. Esta búsqueda tiene similitud con la búsqueda de una palabra que empieza con la letra a en un diccionario; en este caso, el diccionario no se abre a la mitad, sino muy cerca de su inicio. La búsqueda por interpolación es un caso particular de búsqueda binaria, solo que el índice interior m ∊ i, d que se elige entre el límite izquierdo i y el límite derecho d se toma imaginando que los elementos del arreglo son uniformemente distribuidos. Esta hipótesis es muy fuerte, pero si se conoce la distribución de los elementos, mediante una función de distribución, se puede adaptar el método de búsqueda por interpolación a esta función. Si los elementos del arreglo están uniformemente distribuidos, entonces los binomios de valores ( k, xk) se pueden representar sobre una misma línea. Si el valor que se busca, x, jamás se encuentra en el arreglo del índice m, el punto (m, x) pertenece a la misma línea. En la figura 6.5 se representa el caso ideal con los puntos (índice, elemento del arreglo) sobre un mismo segmento de línea:
T[d] x
T[i]
i
m
d
Figura 6.5
Si se supone que los puntos ( i, xi), ( d, xd) y (m, x) son co-lineales, el índice m se puede calcular según la fórmula que traduce la condición de co-linealidad: x – xi x –x = d i d–i m–i Como el índice m es un valor entero, entonces su valor será:
x – xi m = x – x (d – i) + i
Entonces, se impone que xi ≠ xd y xi ≠ x ≠ xd . 286
d
i
La función que traduce esta búsqueda por interpolación es la siguiente: int busqueda_interpolacion(int T[], int g, int d, int valor, int *pos) { int m; while (g <= d) { if (valor < T[g]) return FALSO; if (valor > T[d]) return FALSO;} if{ (T[g] == T[d]) *pos = g; return CIERTO; } m = 1.0 * (valor - T[g]) / (T[d] - T[g]) * (d - g) + g; if (T[m] == valor) { *pos = m; return CIERTO; } if (T[m] < valor) g = m + 1; else d = m - 1; } return FALSO; }
En la parte derecha de la asignación: m = 1.0 * (valor - T[g]) / (T[d] - T[g]) * (d - g) + g;
se fuerza el cálculo exacto del índice m con una primera multiplicación con el valor flotante 1.0; de esta manera, todos los cálculos se hacen en punto flotante. Si no se fuerza el cálculo al punto flotante, la división es la división entera y se pierde el sentido correcto del cálculo. Esta función tiene una complejidad de 0(loglogN),6 siempre con base en la hipótesis de la distribución uniforme de los valores del arreglo. Este resultado es mejor que el de la complejidad teórica óptima de cualquier algoritmo de búsqueda. Esta no es una contradicción, porque el algoritmo de búsqueda por interpolación tiene este desempeño en un caso particular. Esta hipótesis tan fuerte es primordial para el funcionamiento rápido de la función. Por ejemplo, para los valores: 1, 2, 3, 4, 5, 6, 1000, 1001, 1002, 1003, 1004, 1005, la búsqueda del valor 12, aplicando el algoritmo de búsqueda por interpolación, necesita seis iteraciones, mientras que el algoritmo clásico de búsqueda binaria necesita solo tres iteraciones. Se puede proponer una búsqueda híbrida con dos índices: uno, el primero, estaría a la mitad del intervalo, y el otro sería calculado por interpolación, por lo que se elige el mejor.
6
La demostración de este resultado es muy técnica. Véase el libro de G. N. Gonnet (1984), Handbook of Algorithms and Data
Structures, Addison-Wesley, p. 34. 287
int busqueda_interp_dicotomica(int T[], int g, int d, int valor, int *pos) { int m; while (g <= d) { if (valor < T[g]) return FALSO; if (valor > T[d]) return FALSO; if (T[g] == T[d]) { *pos = g; return CIERTO; } m = 1.0 * (valor - T[g]) / (T[d] - T[g]) * (d - g) + g; if (T[m] == valor) { *pos = m; return CIERTO; } if (T[m] < valor) g = m + 1; else d = m - 1; } return FALSO; } Entre las dos posiciones del índice pivote, se toma el valor que reduce al máximo posible el intervalo.
i
m
me
d
Figura 6.6
La complejidad de esta función deja 0(logN).
Selección Gracias a una estructura de tipo lista ligada o a un arreglo ordenado creciente, el valor mínimo se encuentra en la primera posición y el valor máximo en la última. Para obtener estos valores, se necesitan 0(1) operaciones, excepto para determinar el máximo en una lista ligada que impone recorrer por completo la lista para determinar este elemento. En el caso del arreglo y las listas ligadas, que no son ordenadas, el cálculo del mínimo o del máximo se hace, en 0(N) operaciones, más precisamente en N – 1 comparaciones de claves. La función que determina el índice del elemento mínimo de un arreglo de entero es: void determina_min(int T[], int n, int *pos) { 288
int i; int min; min = T[0]; *pos = 0; for (i = 1; i < n; i++) if (T[i] < min) { min = T[i]; *pos = i; } }
return;
Un ejemplo de llamada a esta función es: determina_min(A, na, &pos); printf(“ El minimo es %d y su posicion es %d/n”, A[pos], pos);
Si se trabaja con listas ligadas, es posible aplicar, sin ningún problema, el mismo principio para recorrer los elementos de la lista; también se hacen exactamente N –1 comparaciones para una lista de N elementos. El código de esta función es: void min_lista(Tlista L, Tlista *M) { Tlista aux; if (L == NULL) { *M = NULL; return; } *M = L; aux = L->next; while (aux != NULL) { if (aux->clave < (*M)->clave) *M = aux; aux = aux->next; } return; }
Un ejemplo de llamada a esta función es: Tlista L1, aux, Min; ... min_lista(L1, &Min); printf(“ El elemento minimo de la lista: %d\n”, Min->clave);
En estas dos funciones, la variable min contiene el mínimo relativo del inicio hasta el punto corriente. Para obtener el máximo, el algoritmo es exactamente el mismo: se inicia una variable max con la clave del primer elemento y desde el segundo elemento hasta el fin se ajusta la variable max si se encuentra una clave mayor. 289
Si se desea determinar en un mismo recorrido el mínimo y el máximo, lo óptimo es que en una primera versión de un algoritmo se recorran los elementos, desde el segundo elemento hasta el final del arreglo, comparando sistemáticamente la clave primero con el mínimo y luego con el máximo. Es importante resaltar aquí una observación simple: si la clave proviene de un mínimo, no vale la pena compararla con el máximo relativo. El código de la función que trabaja según este principio es el siguiente: void determina_min_max_simple(int T[], int n, int *posmin, int *posmax) { int i; int min, max; min = T[0]; *posmin = 0; max = T[0]; *posmax = 0; for (i = 1; i < n; i++) { if (T[i] < min) { min = T[i]; *posmin = i; } else if (T[i] > max) { max = T[i]; *posmax = i; } } return; }
A cada iteración se hacen una o dos comparaciones. El número total de comparaciones es entre N – 1 (el arreglo está en orden decreciente) y 2( N – 1) (el arreglo está en orden decreciente). En el estudio teórico del problema de selección conjunta de mínimo y máximo, se demuestra que el número máximo necesario es 3N – 2. En esta demostración del resultado fue puesto en evidencia un trabajo para parejas de valores 2 sucesivos. Inspirándonos en este principio, una función que calcula conjuntamente el mínimo y el máximo es la siguiente: void determina_min_max(int T[], int n, int *posmin, int *posmax) { int i; int j, k; int min, max; // caso del arreglo con un solo elemento if (n == 1) { *posmin = 0; *posmax = 0; return; } // inicializacion para min y max if (T[0] < T[1]) 290
{ min = T[0]; *posmin = 0; max = T[1]; *posmax = 1; } else { min = T[1]; *posmin = 1; max = T[0]; *posmax = 0; //} recorrer 2 por 2 los elementos del arreglo for (i = 2; i < n-1; i +=2) { if (T[i] < T[i+1]) { j = i; k = i+1; } else { j = i+1; k = i; } if (T[j] < min) { min = T[j]; *posmin = j; } (T[k] > max) if { max = T[k]; *posmax = k; } } // si n es impar se trata el ultimo elemento del arreglo if (n % 2 == 1) //n impar { if (T[n-1] < min) { min = T[n - 1]; *posmin = n - 1; } else if (T[n-1] > max) { max = T[n - 1]; *posmax = n - 1; } } return; } 291
Esta función tiene tres pasos:
1. Inicializar las variables min y max con los dos primeros valores del arreglo. 2. Recorrer el arreglo con un paso de dos, comparando un elemento con su vecino sucesor, con el fin de determinar cuál de los dos se compara con el min y cuál con el max. 3. Si el arreglo tiene un número impar de elementos, se debe tratar el último elemento. Al paso 1 se le realiza una sola comparación, durante la estructura iterativa; a diferencia de los pasos 2 y 3, a los cuales se les hacen tres comparaciones. Si es necesario, al paso 3 se le hacen una o dos comparaciones. Por tanto, el número total de comparaciones es 3N – 2. 2 En conclusión, podemos decir que en un arreglo o en una lista ligada ordenada, el mínimo se determina en 0(1) operaciones. En cambio, un arreglo o una listacomplejidad sin ningún orden entre susdeterminar elementos,conjuntamente determinar el mínimo o ely máximo se hace con N –para 1 comparaciones y una de 0(N ). Para el mínimo el máximo se necesitan 0(N) operaciones.
Mantenimiento: inserción y eliminación La inserción y la eliminación de un elemento de una estructura sin orden se hacen en tiempo constante 0(1), excepto en el caso de la eliminación de un elemento de una lista ligada, cuando este tiempo constante interviene después de encontrar la referencia del elemento que lo precede en la lista. En el caso de la falta de orden en la estructura, la inserción se hace en el lugar más conveniente para el programador; ya sea al inicio de la lista o al final del arreglo. Para el caso de las estructuras ordenadas, el orden de los elementos siempre se debe respetar. Cuando se trata de la inserción, primero se busca el lugar de la inserción y luego se realiza dicha inserción. La búsqueda de valor se hace en 0(N), para las listas o para los arreglos, si se aplica la búsqueda secuenci al; también en0(logN), para el caso de los arreglos, si se aplica la búsqueda binaria. En el caso de la lista ligada, la inserción se hace en tiempo constante. Por otro lado, para el caso del arreglo, se deben mudar todos los elementos que siguen a un elemento insertado. Dicha mudanza necesita entre 0 y N operaciones (la inserción se realiza al final del arreglo o al inicio). En promedio, la complejidad de la mudanza es de 0(N). Por su parte, la complejidad global de la operación de inserción es, entonces, en todos los casos, de 0(N). La eliminación de un elemento también se acompaña de una mudanza de los elementos; sin embargo, en el caso de los arreglos solo se hace una reconstitución de las ligas (apuntadores) para las listas ligadas. La complejidad de esta operación es de 0(N), tanto en el caso de la lista ligada, para lo cual la búsqueda es en 0(N) y la eliminación de la celda en0(1), como para el arreglo, ya que la complejidad de la mudanza de elementos es0(N) y una búsqueda es 0(logN) o 0(N). Para el caso de los arreglos, la operación de eliminación de un elemento, cuando se conoce su posición, se traduce en la siguiente función: void eliminacion(int T[], int *N, int pos) { int j; for (j = pos; j < *N ; j++) T[j] = T[j+1]; *N = *N - 1; return; }
Los elementos del arreglo se deben mudar de una posición hacia la izquierda, empezando por el elemento de índice pos hasta el fin del arreglo. La dimensión del arreglo debe cambiar al final de la función de eliminación. La llamada de esta función se hace después de una búsqueda de valor en el arreglo, pero solo en el caso de búsqueda con éxito: if (busqueda_binaria(A, 0, na-1, valor, &pos) == CIERTO) eliminacion(A, &na, pos); 292
Una función de inserción que, al mismo tiempo, hace la búsqueda del lugar y la mudanza de los elementos que siguen al valor insertado es la siguiente: void insercion_busqueda_secuencial(int T[], int *N, int valor) { int j; for (j = *N-1; j >= 0; j--) { if (T[j] <= valor) break; T[j+1] = T[j]; } T[j+1] = valor; *N = *N + 1; return; }
Los elementos que requieren cambiar de posición son los que se encuentran al final del arreglo, hasta que se encuentre la posición de inserción más adecuada. La complejidad de esta función es de 0(N). En el mejor de los casos, se hace una sola comparación, y el elemento insertado es mayor que todos los elementos del arreglo; en el peor de los casos, se hacen N comparaciones y N mudanzas para el caso de la inserción al inicio del arreglo. Si primero se quiere utilizar la búsqueda binaria para determinar la posición del elemento que se inserta, es necesario cambiar la función de búsqueda binaria para regresar también un índice en el caso de búsqueda sin éxito. Esta sería la posición de inserción del nuevo valor. La función de búsqueda binaria cambiada y de la inserción, después de una búsqueda binaria, es: int busqueda_binaria(int T[], int g, int d, int valor, int *pos) { int m; while (g <= d) { m =(T[m] (g + == d) valor) / 2; if { *pos = m; return CIERTO; } if (T[m] < valor) g = m + 1; else d = m - 1; } *pos = g; return FALSO; } void insercion_busqueda_binaria(int T[], int *N, int valor) { int j; int pos; if (busqueda_binaria(T, 0, *N-1, valor, &pos) == CIERTO) pos (j = pos + 1; j >= pos; j--) for = *N-1; T[j+1] = T[j]; T[j+1] = valor; *N = *N + 1; return; } 293
Ordenamiento Existen varios métodos de ordenamiento, algunos óptimos, pero difíciles de implementar; otros, en cambio, cuadráticos o subcuadráticos, que resultan fáciles de entender o desarrollar. En esta sección se presentan varios métodos de ordenamiento; no obstante, algunos de estos solo son aplicables para los arreglos y no para las listas ligadas. Ordenamiento de burbuja
El ordenamiento de burbuja es un método bien conocido por los profesores de deportes, quienes lo aplican en el momento de ordenar a los alumnos por su altura (del más bajo al más alto), indicando, cuando es necesario, si dos alumnos que se siguen deben intercambiar de posición. La idea del algoritmo (en inglés, bubblesortde) es intercambiar, iniciando porligada), un lado, elementos vecinos que no están en central orden adecuado. Al final de un recorrido la estructura (arreglo o lista selos verifica si los intercambios fueron hechos. Si un recorrido se encuentra sin intercambios, entonces ahora se dice que los datos son ordenados, si no se hacen nuevos recorridos.
Ejemplo
Al inicio los datos son: 09873
Un primer recorrido, de izquierda a derecha, implica los intercambios siguientes: 9 con 8, 9 con 7 y 9 con 3. Así, al final de este recorrido los elementos son: 08739
Otro recorrido impone los intercambios: 8 con 7 y 8 con 3. En este paso se puede ver que el último elemento se encuentra bien colocado. Al final de este recorrido los elementos son: 07389 Otro recorrido que inicia con el primer elemento y termina con el tercero impone el intercambio: 7 con 3. Al final se tiene: 03789
Un nuevo recorrido se hace sin intercambios y el algoritmo termina. Una función importante que es necesario implementar aquí, es el intercambio de contenido entre dos elementos. En este caso, para simplificar el discurso, en lugar del término intercambio usamos la palabra cambio entre dos elementos. En el caso de dos elementos de un tipo básico (por ejemplo, un entero), el cambio de contenido se hace con una función con parámetros transmitidos por referencia: void cambio_contenido(int *a, int *b) { int aux; aux = *a; *a = *b; *b = aux; return; }
En las funciones de ordenamiento que se presentan, se va a utilizar una función que cambia dos elementos de un arreglo y que recibe como parámetros el arreglo y los índices:
294
void cambio(int T[], int i, int j) { int tmp; tmp = T[i]; T[i]= T[j]; T[j] = tmp; }
Para el caso de una lista ligada, es posible que se envíen los apuntadores de celdas que se deben cambiar. Una posible función de cambio de contenido es la siguiente: void cambio_contenido(Tlista *E, Tlista *F) {
int aux; aux = (*E)->clave; (*E)->clave = (*F)->clave; (*F)->clave = aux;
}
Para el caso de cambio de dos elementos que se siguen en una lista ligada, otra función de cambio que modifica las ligas (apuntadores) entre celdas es la siguiente: void cambio(Tlista *E, Tlista prec) { Tlista aux, baux; if (*E == NULL || (*E)->next == NULL) return; printf(“ Inicio de la funcion de cambio\n”); aux = *E; baux = (*E)->next; aux->next = baux->next; baux->next = aux; if (prec != NULL) prec->next = baux; *E = baux; }
La función anterior recibe como parámetros el apuntador de la celda, la cual cambia con su vecino sucesor, y el apuntador de la celda precedente. En la figura 6.7 se representa el cambio de ligas que se efectúa.
clave
clave
next
next
prec -> next:
*E
aux
*E
baux
Figura 6.7
295
La función de ordenamiento de burbuja que trabaja con esta versión de función de cambio es la siguiente: void orden_burbuja(Tlista *L) { Tlista aux, prec, inicio; int i, j, N, es_cambio; N = tamano(*L); inicio = *L; for ( i = N; i > 0; i--) { aux = inicio; prec = NULL; es_cambio = FALSO; for ( j = 1; j < i; j++) { if (aux->clave > aux->next->clave) { cambio(&aux, prec); if (prec == NULL) inicio = aux; es_cambio = CIERTO; } prec = aux; aux = aux->next; } if ( es_cambio == FALSO) break; } *L = inicio; return; } Es posible que la primera celda de la lista inicial cambie de lugar; entonces, guardamos en una variable local inicio un apuntador del inicio de lista. La variable es_cambio indica si durante un recorrido se hacen o no intercambios entre elementos.
Otra versión del mismo ordenamiento que usa la otra función de cambio es la siguiente: void orden_burbuja_version2(Tlista *L) { Tlista aux, sig, inicio; int i, j, N, es_cambio; N = tamano(*L); inicio = *L; for ( i = N; i > 0; i--) { aux = inicio; sig for =( aux->next; j = 1; j < es_cambio i; j++) = FALSO; { if (aux->clave > sig->clave) { cambio_contenido(&aux, &sig); es_cambio = CIERTO; 296
} aux = sig; sig = aux->next; } if ( es_cambio == FALSO) break; } *L = inicio; return; }
Si trabajamos con arreglos, el recorrido durante el algoritmo de ordenamiento por burbuja se puede realizar de izquierda a derecha o de derecha a izquierda. En el primer caso (de izquierda a derecha), los elementos que están ordenados se agrupan al finalendelcuenta arreglo; en el segundo derecha a izquierda), elementos se rápido agrupanposible, al inicioeldel arreglo. Si no se toma el hecho de hacercaso o no(de cambios para detener el los algoritmo lo más código del algoritmo de búsqueda de burbuja es el siguiente: void ordenamiento_burbuja(int T[], int N) { int k, j; for(k = 0; k < N-1; k++) { for (j = N-1; j > k ; j--) if (T[j] < T[j-1]) cambio(T,j, j-1); } return; } void ordenamiento_burbuja_v2(int T[], int N) {
int k, j; for(k = 0; k < N-1; k++) { for (j = 0; j < N-1-i ; j--) if (T[j] < T[j-1]) cambio(T,j, j-1); } return; }
En este caso, la primera función recorre el arreglo de derecha a izquierda y la segunda versión en el sentido opuesto. Una llamada a una de las dos funciones se hace simplemente con: ordenamiento_burbuja(A, na);
Este algoritmo es uno de los más simples en términos de comprensión y de tiempo de desarrollo. No obstante, también se le pueden realizar mejoras; la primera mejora que se puede imaginar es introducir una variable que indique, durante un paso (una iteración, según la estructura más externa), si se hacen o no cambios entre elementos vecinos; si no fue hecho ningún cambio, el algoritmo termina porque el arreglo es ordenado. Entonces, el código es el siguiente: void ordenamiento_burbuja_mejora(int T[], int N) { int k, j; 297
int es_cambio; for(k = 0; k < N-1; k++) { es_cambio = FALSO; for (j = 0; j < N-1-k ; j++) if (T[j+1] < T[j]) { cambio(T,j + 1, j); es_cambio = CIERTO; } if (es_cambio == FALSO) } return;
break;
}
Si se analizan las primeras versiones (ordenamiento_burbuja y ordenamiento_burbuja_v2) es posible observar que el algoritmo trabaja sin memoria excedente, solo utiliza un número finito de variables locales. Por otro lado, si se realizan las comparaciones, siempre en un sentido adecuado para hacer el cambio, es decir solo si T[ j ] > T[ j + 1], evitando cambiar valores equivalentes, el ordenamiento por burbuja obtenido es estable. De esta forma, si se analiza el número total de operaciones, se puede ver que hay dos tipos de operaciones: comparaciones de elementos vecinos y cambios. El número total de comparaciones es:
CN =
N –1
N –1
K =1
K =1
∑ ( N +1– k –1) = ∑ ( N – k ) =
N( N – 1) 2
En el peor de los casos, corresponde a un arreglo ordenado decreciente; a cada comparación se hace un cambio, en total:
EN = C N =
N(N – 1) 2
En el mejor de los casos, el arreglo es ordenado, por tanto se hacen 0 cambios:
EN = 0 La complejidad de todo el algoritmo deja en 0(N2); se trata de un algoritmo cuadrático en el peor de los casos. Sin embargo, nos interesa la complejidad media, indicada por el número medio de cambios entre elementos que se hacen. Entonces, si nos interesa el número medio de cambios de posiciones, por una permutación de valores T se puede ver que el número de cambios es equivalente al número de parejas de elementos que no están en orden. 7 Por ejemplo, para la sucesión:
S = (0 9 8 7 3) se deben hacer: 0 + 3 + 2 + 1 = 6 intercambios. En tanto, para la permutación en espejo:
S = (3 7 8 9 0) se deben hacer 1 + 1 + 1 + 1 = 4 intercambios. 7
298
En la teoría algebraica de las permutaciones, una pareja de elementos de una permutación que no está en orden se llama inversión.
En este caso, es fácil mostrar que para cualquier permutación S de N elementos, la suma del número de inversiones en S y S es N (N2+ 1) :
EN (S) + EN (S) = N (N + 1) 2 Como ya se mencionó antes, nos interesa el número medio de intercambios, el cual se calcula según la siguiente fórmula:
1
∑
N! Spermutacion
EN (S)
Cada permutación es la imagen en espejo de otra permutación. Entonces hay que:
∑
E N ( S ) = Tpermutacion ∑ E N (T )
∑
EN (S) =
Spermutacion
Esta es equivalente a: Spermutacion
∑ Tpermutacion
E N (T )
Por tanto, se deduce que:
∑
Spermutacion
EN (S ) =
1 ∑ ( E ( S ) + E N ( S )) 2 Spermutacion N
Si aplicamos la fórmula anterior, obtenemos:
∑
Spermutacion
EN (S) =
1 1 × ∑ ( E ( S ) + E N ( S )) = × N! N(N+1) 2 2 Spermutacion N 2 N(N+1) SpermutacionE N ( S ) =
2
∑
× N!
Así, el número medio de cambios entre elementos es: N (N + 1) 4
.
En este caso, si se introduce una variable para detectar el final del algoritmo lo más pronto posible (como en la versión ordenamiento_burbuja_mejora con la variable es_cambio), el número de comparaciones se puede reducir; sin embargo, no se reduce el número de cambios efectuados. La complejidad general del algoritmo de ordenamiento de burbuja es 0(N2). Ordenamiento por selección
La idea de ordenamiento por selección es muy natural; el mínimo se detecta a cada paso y se coloca en la posición de la estructura. Los pasos varían desde el inicio (o para los arreglos) hasta la penúltima posición. Por ejemplo, en la configuración inicial: 09873 Al final del paso 0: 09873 299
Al final del paso 1: 03879 Al final del paso 2: 03789 Al final del paso 3: 03789 Según este algoritmo, la función que ordena un arreglo es la siguiente: void ordenamiento_seleccion(int T[], int N) { int i, j; int posmin, valmin; for(i = 0; i < N-1; i++) { posmin = i; valmin = T[i]; for (j = i+1; j < N; j++) if (T[j] < valmin) { posmin = j; valmin = T[j]; } if (posmin != i) cambio(T, i, posmin); } return;
}
A cada paso se efectúan N – 1– i comparaciones para detectar el mínimo y el máximo de un cambio entre la posición del mínimo y la posición i. Entonces, para cualquier configuración inicial, el número de comparaciones y el número de cambios son: CN =
N (N – 1) 2
.
0 < EN < N – 1
En este caso no hay diferencias significativas entre el mejor de los casos, donde se hacen 0 cambios, y el peor de los casos, donde se hacen N –1 cambios. A la mitad se puede mostrar que el algoritmo necesita 0(N) cambios entre elementos.8 La complejidad del algoritmo es 0(N2). Se trata de un algoritmo cuadrático. Ordenamiento por inserción
Lahasta idea de este algoritmo que iessecreciente; decir, estructuras ordenadas construyenpor sucesivamente con 1,de 2, ... N elementos. En elespaso consideraesque los las primeros elementos estánseordenados, lo que el elemento rango i + 1 se inserta en la estructura ordenada. La búsqueda de la posición de inserción se puede hacer con cualquier método que se conozca y se puede aplicar a la estructura de datos. 8
300
Véase el libro de D. Knuth referido en la bibliografía y en el capítulo 5.
En un arreglo, se puede iniciar por el final del arreglo T; en este caso, se tratan los elementos T[N – 2], T[N – 3] ... T[1], T[0]. También se puede empezar por el inicio del arreglo, tratando los elementos T[1] hasta T[N – 1]. Entonces, se deduce que se puede utilizar cualquier método de búsqueda del lugar de inserción. La siguiente función implementa un ordenamiento por inserción, por lo cual la búsqueda de la posición para insertar se hace secuencialmente, al mismo tiempo que la transferencia de elementos: void ordenamiento_insercion_sec(int T[], int N) { int i, j; int aux; for(i = 1; i < N; i++) { aux = T[i]; for (j = i-1; j >= 0 ; j--) if (T[j] > aux) T[j+1] = T[j]; else break; j = j + 1; T[j] = aux; } return; }
Para un arreglo, la función de ordenamiento siguiente también utiliza una búsqueda binaria de posición; luego, si la posición es interna, en el segmento de arreglo ordenado se hacen transferencias para insertar el valor. En este caso, la función de búsqueda binaria (dicotómica) fue adaptada para regresar la posición de la posible inserción: int busqueda_dicotomica(int T[], int g, int d, int valor) { int m; if (g >= d) return g; m = (g+d) / 2; if (valor < T[m]) return busqueda_dicotomica(T, g, m, valor); else return busqueda_dicotomica(T, m+1, d, valor); } void ordenamiento_insercion_dicotomica(int T[], int N) { int i, j; int aux, pos; for (i = 1; i < N; i++) { if (T[i] < T[i-1]) {
pos = busqueda_dicotomica(T, 0, i-1, T[i aux = T[i]; for (j = i-1; j >= pos; j--) T[j+1] = T[j]; T[pos] = aux; 301
} } }
Para ordenar una lista ligada, se debe tener una función que inserte una celda (un elemento de tipo struct elemento) en una lista ligada ordenada. Por otra parte, para ordenar cualquier lista, al inicio se toma una lista vacía, que se considera como ordenada, en la cual se insertan las celdas que se toman de la lista inicial. El código de las dos funciones es el siguiente: void insercion_celda_lista_orden(Tlista *L, Tlista *Elemento) { Tlista aux; Tlista temp, prev; int valor; if (*L == NULL) { (*Elemento)->next = NULL; *L = *Elemento; return; } aux = *Elemento; valor = aux->clave; if (valor <= (*L)->clave) { aux->next = *L; *L = aux; return; } else { prev = *L; temp = (*L)->next; while ((temp != NULL) && (valor > temp->clave)) { prev = temp; temp = temp->next; } prev->next = aux; aux->next = temp; } return; } void orden_lista_insercion(Tlista *L) { LI, LF; Tlista aux; LI = *L; LF = NULL; while (LI != NULL) { 302
aux = LI; LI = aux->next; aux->next = NULL; insercion_celda_lista_orden(&LF, &aux); } *L = LF; }
La complejidad del algoritmo de ordenamiento por inserción se evalúa con base en el número de operaciones: comparaciones y transferencias; en este caso, las comparaciones se utilizan para buscar el lugar de inserción, luego se hacen las transferencias de elementos de una celda vecina a otra. Si se usa un ordenamiento por inserción con una búsqueda secuencial, a cada paso i se realizan entre 1 e i + 1 comparaciones. En el mejor de los casos, se trata de un arreglo ordenado, donde solo se verifica que el elemento se halle correctamente ubicado; aquí tampoco se realizan transferencias.
CN = N – 1 TN = 0 En el peor de los casos se realizan i + 1 comparaciones a cada paso; el arreglo es inversamente ordenado. N–2
N –2
i= 0
i =1
C N = ∑ i +1 = ∑ i =
N( N –1) 2
El número de transferencias es TN = CN – (N – 1), porque a cada paso se realiza una transferencia menos que el número de comparaciones. (N – 1)(N – 2) – (N –)1 = 2 2 En un caso medio, es posible imaginar que el arreglo se obtiene de manera aleatoria, es decir a cada paso i hay i + 1 . Entonces, se obtiene: 2 N (N – 1) TN =
N (N – 1)
CN =
TN = C N – N – 1 =
4
(N – 1)(N – 4) 4
Este algoritmo se efectúa, entonces, en 0(N2) comparaciones y 0(N2) transferencias. Se trata de un algoritmo cuadrático. Si se usa un ordenamiento por inserción con una búsqueda binaria a cada paso i, se realiza el mismo número de transferencias que en el caso de la búsqueda por inserción. Así, hay entre 0 y ( N –1)(2N –)2 transferencias, en un caso medio 2 0(N ) transferencias. Como el método de búsqueda cambia, se puede decir que el número de comparaciones depende de esto. A cada paso i se realizan entre 1 y [log 2i + 1] comparaciones. En el mejor de los casos se hacen CN = N – 1 comparaciones. En el peor de los casos se hacen: N –1
CN
N
log 2 i +1) = i ∑ log 2 i = log 2 N =0 =0 i∑
A saber, CN es 0(NlogN). Entonces, este algoritmo es el más adecuado para el número de comparaciones y por ser cuadrático debido al número de transferencias.
303
Ordenamiento de Shell
Es un método de ordenamiento que aplica de una manera srcinal el ordenamiento por inserción, a excepción de que no trabaja con el arreglo entero, sino con subconjuntos del arreglo. En este caso, cada subconjunto contiene elementos a distancia h variable. La variación del valor de h se realiza sucesivamente:
N > ht > ht–1 > … h2 > h1 = 1 En este caso, prácticamente h se toma de la sucesión matemática: 1, 4, 13, 40, ...,
ht = 3ht–1 + 1 La idea es que estos ordenamientos sucesivos induzcan segmentos ordenados uno con respecto del otro y que el ordenamiento del paso más pequeño trabaje únicamente con estos segmentos.9 La complejidad de esta función para sucesiones de pasos de cálculo generales ht sería de y (son conjecturas). Aquí se puede mostrar que el número de comparaciones tiene un límite alto para la sucesión de h: La función que realiza el ordenamiento de Shell tiene dos etapas:
1. Se calcula el primer paso ht. 2. Se itera el paso h reduciéndolo y a cada iteración se hace el ordenamiento por inserción de los elementos a distancia j. El código de la función es el siguiente: void ordenamiento_Shell(int T[], int N) { int h, i, j, aux; // calculo del primer h : lo mayor que 3h+1 <= N for (h = 1; h <= N; h = 3*h +1); h = h / 3; // ordenamientos a paso h for (; h > 0; h = h/3) for (i = h; i < N; i++) { aux = T[i]; j = i; while ((j >= h) && (T[j-h] > aux)) { T[j] = T[j-h]; j -= h; } T[j] = aux; } }
Este algoritmo es sub-cuadrático, además de que resulta bastante eficaz para N y es fácil de programar. Se trata de un ordenamiento inestable, la inestabilidad aparece al inicio cuando los elementos no se tratan juntos. Ordenamiento rápido
Se trata de un método propuesto por C. A. Hoare, en 1962 ( quicksort, en inglés). Su principio se basa en la técnica: dividir e imperar. La idea central de este método es separar el conjunto de valores y ordenarlo en dos conjuntos disjuntos, de tal manera que los valores del primer conjunto sean menores que los elementos del segundo conjunto; en este caso, cada conjunto se ordena de manera recursiva. El pseudocódigo esquemático de este método es: 9
304
Véanse las animaciones del CD-ROM.
fonction ordenamiento_rapido(T[],g,d) if g < d then division_arreglo(T,g,d,j); ordenamiento_rapido(T,g, j-1); ordenamiento_rapido(T, j+1,d); endif endfonction
En este pseudocódigo, la llamada inicial es: ordenamiento_rapido (T, 1, N) u ordenamiento_rapido (T, 0, N – 1). La parte más sensible es la función division. Esta división se realiza con respecto a un valor pivote, con el fin de tener los elementos menores que el pivote, entre las posiciones g, i, j – 1, y los elementos mayores, entre las posiciones j + 1 y d; entonces, el pivote estaría en la posición j. Idealmente, lo adecuado sería tomar como pivote el valor medio del arreglo, entre las posiciones g y d; sin embargo, esto resulta imposible. Entonces, lo que se puede hacer es tomar cualquier elemento como pivote; por ejemplo, el primer elemento T[ g ]. En este caso, se recorre el arreglo de derecha a izquierda, con un índice j, y de izquierda a derecha, con un índice l, hasta que se encuentren dos elementos que no estén en un orden adecuado con respecto al pivote: T [ l ] > pivote > T [ j ]. Es este caso, y si l < j, se hace un intercambio entre las posiciones l y j. La división se termina cuando los dos índices se cruzan: l > j. El código de esta funcion es, por tanto: void division(int T[], int g, int d, int *j) { int k, l; l = g + 1; k = d; while (l <= k) { while (T[k] > T[g]) k = k-1; while ((l <= k) && (T[l] <=T[g])) l = l+1; if (l < k) { cambio(T, l, k); l = l+1; k = k-1; } } cambio(T, g, k); *j = k; }
La función de ordenamiento es la siguiente: void ordenamiento_rapido(int T[], int g, int d) { int j; if (g < d) { division(T, g, d, &j); ordenamiento_rapido(T, g, j-1); ordenamiento_rapido(T, j+1, d); } } 305
La complejidad de la función de división es de N – 1 o N + 1 comparaciones y al máximo N2 cambios. Si se notanCN, el número de operaciones del ordenamiento rápido por N elementos, entonces este valor depend e de la posición final del pivote:
CN = Cp–1 + CN–p + N – 1 En el mejor de los casos, el pivote es el valor medio:
C N = 2C N + N – 1 2
Entonces:
CN = 0(NlogN) En el peor de los casos, el pivote es el mínimo de los valores; en otras palabras, el arreglo está ordenado:
CN = CN–1 + C1 + N – 1 Entonces:
C N = 0(N 2 ) En un término medio, se puede mostrar por inducción que:
Propiedad El número medio de operaciones que se hacen por el ordenamiento de N valores verifica:
CN < 2NlogN Demostración Es claro que C0 = 1 y que C1 = 0. Si los valores son aleatoriamente generados, la probabilidad de que el pivote sea la posición p es de N1 . Según estas probabilidades hay:
C N = N – 1+
1 N ∑ C p – 1 + C N – p ) N p=1
o
C N = N – 1+
2 N –1 ∑ Ci N p=1
cuando se almacenan los valores de Ci significativos. Entonces, se deduce: 2 N –1 ∑C N p=1 i NC N = ( N +1) C N –1 + 2N
C N = N – 1+
CN C -1 2 = N = = N +1 N N +1 CN – 2 2 2 + + = N –1 N N + 1 N C 2 = … 32 + ∑ k = 3 k +1
El valor
CN N +1
puede aproximarse por:
∫
1 2 1N dx = 2 lnN 306
x
Así, se obtiene: CN
2(N + 1)lnN < 2Nlog2N.
Entonces, el algoritmo de ordenamiento rápido es sub-óptimo en caso medio y es cuadrático en el peor de los casos. Con respecto a los otros algoritmos de ordenamiento, para arreglos de tamaño reducido (5 < N < 15), el desempeño del ordenamiento rápido es más lento que los algoritmos cuadráticos de tipo burbuja, mientras que el ordenamiento por inserción es aún mejor, donde una causa serían las llamadas recursivas. El algoritmo no usa memoria suplementaria, solo algunas variables para la función de división. La función de división que se usa induce la inestabilidad del algoritmo con respecto al orden entre valores equivalentes. Algunas otras versiones y adaptaciones existen o pueden ser propuestas por este algoritmo: Elegir el pivote de manera aleatoria en el intervalo [g, d]. Cortar el conjunto en tres partes (y no en dos): a la izquierda los elementos estrictamente menores con respecto
al pivote; primero, los valores equivalentes al pivote y luego los elementos estrictamente mayores al pivote. Eliminar una de las dos recursiones; realizar la recursividad por medio del conjunto que resulta menos largo. Para los valores pequeños de N (N < 15) se requiere utilizar otro algoritmo de ordenamiento.
El algoritmo de ordenamiento rápido está implementado a nivel de la biblioteca estándar de funciones stdlib.h. El prototipo de la función es: void qsort(void *base, size_t nel, size_t width, int (*compar) (const void *, const void *));
Los parámetros de esta función son:
1. 2. 3. 4.
base,
el arreglo que se ordena. número de elementos del arreglo. width, el largo de un elemento (el largo del tipo de los elementos del arreglo). compar, el apuntador de la función de comparación que se aplica a los elementos del arreglo. nel,
Ordenamiento por mezcla
El ordenamiento por mezcla (merge sort, en inglés) consiste (también) en dividir el co njunto inicial en dos conjuntos. En este caso, ambos conjuntos son del mismo tamaño; sin embargo, cada conjunto se ordena y luego se hace una mezcla para obtener el conjunto final ordenado. El pseudocódigo de la función es el siguiente: fonction ordenamiento_mezcla(T) partcionar(T;S1;S2) // T = S1 U S2 ordenamiento_mezcla(S1); ordenamiento_mezcla(S2); mezclar(S1;S2;S); T←S;
endfonction
La parte más interesante no son las llamadas recursivas, sino la función de mezcla. Si dos conjuntos están ordenados, la operación de mezcla solo permite obtener el conjunto final ordenado.
Ejemplo
S1 = {1, 4, 5, 12, 17} S2 = {2, 3, 10, 25, 30} Por tanto, el conjunto obtenido por la operación de mezcla es: S = {1, 2, 3, 4, 5, 10, 12, 17, 25, 30} 307
A continuación se presenta el código de una función de mezcla para dos arreglos: void mezcla(int T[], int S[], int U[], int N, int M) { int i, j, k; T[N] = MAXINT; S[M] = MAXINT; for (i = 0, j = 0, k = 0; k < M+N; k++) if (T[i] < S[j]) U[k] = T[i++]; else U[k] = S[j++]; } La función recibe como parámetros los arreglos: dos de entrada, el arreglo resultado y los tamaños de los arreglos.
Las asignaciones con el valor MAXINT sirven para poner una centinela al final de los arreglos, lo que permite colocar un código unitario para esta operación de mezcla. La complejidad de esta función de 0(M + N), con N y M, es el tamaño de cada arreglo. La función de ordenamiento por mezcla utiliza este procedimiento en la última etapa. Es importante destacar que las etapas de esta función respetan las etapas generales del pseudocódigo, aunque los arreglos están organizados de otra manera. Así: Primero se constituyen las dos mitades, las cuales aplica recursivamente el algoritmo. La segunda etapa consiste en copiar los arreglos ordenados que están ubicados en la zona de memoria del arreglo
inicial en otro arreglo, poniendo los arreglos al final; uno junto al otro. Al final se aplica la operación de mezcla.
Por ejemplo, si se entrega el siguiente arreglo con 15 elementos resulta:
T: 15 14 13 1 20 9 8 7 6 5 4 3 1 33 0 Las llamadas recursivas de ordenamiento por mezcla producen: T: 0 7 8 9 12 13 14 15|0 1 3 4 5 6 33 Cuando se copian los elementos en el arreglo, se obtiene:
S: 0 7 8 9 12 13 14 15|33 6 5 4 3 1 0 Al final del ordenamiento por mezcla se tiene el arreglo inicial T:
T: 0 0 1 3 4 5 6 7 8 9 12 13 14 15 33 El código de esta función de ordenamiento por mezcla que trabaja con arreglos es el siguiente: void ordenamiento_mezcla(int T[], int g, int d) { int S[DIM_MAX]; int i, j, m, k; if (g == d) return; m = (g + d) / 2; ordenamiento_mezcla(T, g, m); ordenamiento_mezcla(T, m+1, d); for (i = m; i >= g; i--) S[i] = T[i]; for (j = m+1; j <= d; j++) 308
S[(d + m + 1) - j] = T[j]; for (k = g, i = g, j = d; k <= d; k++) if (S[i] <= S[j]) { T[k] = S[i]; i++; } else { T[k] = S[j]; j--; } } El algoritmo de ordenamiento de esta forma necesita un espacio de memoria suplementario del mismo largo que el arreglo inicial. Se trata de un ordenamiento estable, ya que la operación de mezcla respeta la estabilidad. Para calcular la complejidad de la función, sea CN el número de operaciones necesarias para un arreglo de N elementos. Entonces, hay:
CN = CN/2 + CN/2 + N/2 + N/2 Relación equivalente a:
CN = 2CN/2 + N Entonces, se obtiene:
CN = 0(NlogN). Por tanto, el algoritmo de ordenamiento por mezcla es óptimo. La operación de mezcla se realiza, por ejemplo, con el siguiente código: Tlista mezcla(Tlista l1, Tlista l2) { Tlista struct res; elemento u; u.next = NULL; res = &u; while((l1 != NULL) && (l2 != NULL)) { if (l1->clave <= l2->clave) { res->next = l1; l1 = l1->next; res = res->next; } else { res->next = l2; l2 = l2->next; res = res->next; } } if (l1 != NULL) res->next = l1; if (l2 != NULL) res->next = l2; return u.next; } 309
Al inicio de esta función se declara un elemento de tipo lista, que es el primer elemento de la lista que se forma durante la mezcla. Al principio de la mezcla se cortan las ligas entre elementos, para constituir la lista resultado. La operación de mezcla se realiza hasta que se encuentra el final de la lista para cada una de las dos listas. Después, la lista que siempre contiene elementos se liga a la última celda de la lista resultado. Esta función destruye las listas de parámetro, debido a que la lista resultado se construye con las celdas de las dos listas iniciales. La complejidad de esta función también es 0(M + N), donde M y N son los tamaños de cada lista de parámetro. La función de ordenamiento por mezcla llama explícitamente a esta función de mezcla. Al inicio se calcula el tamaño de la lista; si la lista contiene un solo elemento, la recursividad termina. En caso contrario, se construyen dos listas cortando la liga que se encuentra a la mitad de la lista inicial y las llamadas recursivas de la función de ordenamiento se hacen a través de estas listas. Así, el código de la función es el siguiente: void ordenamiento_mezcla(Tlista *L) { Tlista L1, L2, tmp; int i, N; N = tamano(*L); if (N <= 1) return; L1 = *L; L2 = *L; tmp = NULL; for (i = 0; i < N/2; i++) { tmp = L2; L2 = L2->next; } tmp->next = NULL; ordenamiento_mezcla(&L1); ordenamiento_mezcla(&L2); *L = mezcla(L1, L2); }
La función de ordenamiento por mezcla, aplicada a las listas, produce una complejidad óptima 0(NlogN) y tiene la ventaja de que en esta no se utiliza la memoria suplementaria. Ordenamiento por enumeración
Es un algoritmo que se aplica a un arreglo particular, que contiene un número finito de valores entre 1 y k (k es un entero positivo). El principio de este algoritmo es enumerar primero cuántos elementos del arreglo son equivalentes a un valor i con 1 < i < k. Luego se calculan las posiciones de los valores equivalentes a un valor i que van a ocupar un lugar en el arreglo resultado. Luego se recorre el arreglo de entrada y se construye el arreglo resultado. El código de esta función es el siguiente: void ordenamiento_enumeracion(int T[], int V[], int N) { int C[K_MAX + 1]; int i, j; // etapa 1 : se enumera cuantos valores hay for (j = 1; j <= K_MAX; j++) C[j] = 0; for (i = 1; i <= N; i++) C[T[i]]++; // se calculan las posiciones sumando los numeros de apariciones for (j = 1; j < K_MAX; j++) C[j] += C[j-1]; // se llena el arreglo resultado for (i = N ; i > 0; i--) { 310
V[C[T[i]]] = T[i]; C[T[i]]--; } return;
arreglo desde 1 hasta N. La constante K_MAX constituye el límite de los valores que el arreglo puede contener.
}
Para analizar el número de operaciones de la función, los cálculos son bastante simples; esto es, para cada estructura iterativa for al interior se ejecuta siempre un número finito de operaciones. Entones, el número total de operaciones es:
aK_MAX + bN + CN Sabemos que K_MAX es una constante, entonces la complejidad de este método de ordenamiento es de 0(N), el cual es mejor que la complejidad teórica. Pero no hay ninguna contradicción, este resultado de complejidad es mejor que el límite teórico que tiene como justificación la distribución de los valores iniciales que se ordenan. Este algoritmo de ordenamiento también tiene la propiedad de que es estable (la estabilidad se almacena durante la última etapa, cuando se recorre el arreglo desde el final hasta el inicio). Sin embargo, la única desventaja de este arreglo es que se necesitan dos arreglos más:
1. Un arreglo del mismo tamaño, para el arreglo resultado. 2. Un arreglo de tamaño K_MAX, para enumerar los valores encontrados y calcular, después, las posiciones en el arreglo. Ordenamiento por casilleros
El ordenamiento por casilleros ( bucket sort, en inglés) es un método muy intuitivo que consiste en ordenar los N elementos en N listas disjuntas; luego, cada lista se or dena y al final se concatena; esto es, cada lista es un casillero. Por ejemplo, si se supone que los elementos del conjunto inicial son [0, 1] al interior del intervalo, cada lista Li , con i de 0 hasta N – 1, contiene los elementos entre Ni e i N+ 1 . El pseudocódigo del algoritmo es el siguiente: for i = 1 to N anadir T[i] a la lista L[└ nT[i]┘] endfor for j = 0 to N - 1 ordenar la lista L[j] endfor concatenacion L[j], j = 0;N -1.
La complejidad de la primera etapa de construcción de las listas y de la última concatenación de las listas ordenadas, para obtener la lista final, se produce con n comparaciones y N otras operaciones, para cualquier implementación concreta de estas listas L [ i ] (con arreglos o con listas). Para ordenar las listas que se forman, se utiliza cualquier algoritmo de ordenamiento; debido a que, intuitivamente, las listas tienen pocos elementos, lo mejor es utilizar un algoritmo cuadrático. Entonces, la complejidad del algoritmo es la suma de estas complejidades. El algoritmo de ordenamiento por casilleros necesita memoria suplementaria y es estable si los algoritmos que se aplican para cada lista son estables. Este algoritmo resulta lineal en cuanto al número de operaciones, incluso cuando se aplica un método de ordenamiento cuadrático a las listas obtenidas, como en el caso de los valores iniciales que están uniformemente distribuidos. Proposición: Si los valores iniciales están uniformemente distribuidos en el intervalo [0,1], la complejidad del algoritmo de ordenamiento por casilleros es 0(N). Demostración: Sea nj el número de elementos de la lista L [ j ], el tiempo que se espera para odenar estos elementos es:
E[ 0( n2j )] = 0( E[ n2j ]) 311
Según las observaciones hechas antes, el número medio de operaciones realizadas por el algorimo de ordenamiento por casilleros es: N –1
N –1
j =0
j =0
∑ 0( E[ n2j ]) = 0( ∑ E[ n2j ])
Entonces, si T [ i ], i = 0, N – 1 son uniformemente distribuidos. Así, k = nj tiene una distribución discreta binomial B(k; N, p), con p = N1 . Entonces, para este caso se utilizan las siguientes fórmulas:
E[ nj ] = Np = 1 1 Vnar[ j ] = Np(1 – p) = 1 – N 1 E[ n2j ] = Var[ nj ][+ E nj ]2 = 2 – N
Así pues, E[]n2j = 0(1) y la complejidad del algoritmo es 0(N).
6.4 Montículos Un montículo es una estructura de datos que se puede usar como una pila de prioridades, para obtener o extraer el elemento extremo (mínimo o máximo). El montículo se puede implementar en un arreglo o en un árbol con ligas. En esta sección lo implementaremos en arreglos con el objetivo de utilizar la representación por montículo para ordenar elementos.
Definición y propiedades Un montículo (heap, en inglés) es una estructura de datos de forma arborescente, la cual se distingue por las siguientes características: Es binaria (esto significa que cada nodo que no está en una hoja tiene dos descendientes). Es completa, excepto el último nivel, que puede contener cuando mucho un solo nodo con una sola hoja. Contiene en cada nodo, incluso en las hojas, una clave (un valor). Cada clave contenida en un nodo es mayor que todas las claves ubicadas en su descendencia.
Ejemplos
La arborescencia que se representa en la figura 6.8 es un montículo: 1 5
3
7 11
9 10
15
6 13
Figura 6.8 312
9
8
La siguiente arborescencia no se considera un montículo, porque no está completa: 1 5
3
7 11
6 10
8
9
Figura 6.9
Esta definición de montículo corresponde a un montículo por mínimo, también se puede introducir una definición de montículo por máximo, en el cual una clave es mayor que todas las claves que se encuentran y se consideran como descendientes. Una primera observación que se puede hacer es que el mínimo se encuentra en el nodo raíz. Conocer el mínimo del conjunto clave es equivalente a poder acceder al nodo raíz. Cualquier sub-arborescencia de un montículo también es un montículo. Para un nivel i (en donde la raíz tiene el nivel 0), que está completo (lo que significa que no es el último nivel), se encuentran 2i nodos. Un montículo que contiene N nodos y una altura h (es decir, que tiene h + 1 niveles, incluso hasta la raíz) verifica la siguiente relación: log2(N + 1) – 1 h < log2(N + 1) Esta relación se demuestra adicionando los nodos en cada nivel y porque en el último nivel hay entre 1 y 2h nodos.
Implementación Un montículo se puede implementar como un árbol con ligas, donde cada celda tendrá el tipo siguiente: struct elemento_monticulo {int clave; struct elemento *izq; struct elemento *dr; };
El acceso a la raíz se realiza en tiempo constante; en cambio, resulta difícil acceder directamente a una hoja y, lo más importante, resulta aún más difícil acceder a la última hoja para poder insertar un nuevo elemento o eliminar un elemento. Entonces, lo más recomendable es representar el montículo mediante un arreglo. La siguiente propiedad nos indica cómo se puede realizar esta representación de la manera más conveniente: Propiedad de representación
Un montículo de N elementos se puede representar con un arreglo del mismo tamaño, donde cada nodo de rango i representa el índice i en el arreglo; el rango de un nodo se obtiene recorriendo el montículo por niveles, empezando en la raíz y siguiendo de derecha a izquierda. 313
En el caso del montículo del ejemplo anterior, su representación con arreglo sería como se observa en la figura 6.10: 1
8
3
5
2
4
7
4
11
1
91 10
9
0 15
11 13
6
6
12
9
3
7
8
1
5
3
7
9
6
8
11
10
15
13
9
1
2
3
4
5
6
7
8
9
10
11
12
Figura 6.10 desde 1 hasta N.
Como se puede observar en la figura 6.10: Las hojas tienen los índices de
N 2
+ 1 hasta N.
Si T [ i ] es un elemento del montículo, sus descendientes directos (sus hijos) son T [ 2i ] y T [ 2i + 1]. Si T [ i ] no es una hoja (i > 1), entonces el nodo padre es T [ i/2].
La siguiente función verifica con el máximo N – 1 comparaciones si los elementos de un arreglo entre los índices 1 y N forman un montículo: int es_monticulo(int T[], int N) { int i; for ( i = 2; i <= N; i ++) if (T[i] <= T[i / 2]) return FALSO; return CIERTO; }
Inserción y eliminación de elementos Por lo general, el montículo es una estructura dinámica; entonces, lo que más nos debe interesar es hacer las operaciones de inserción y de eliminación para el mínimo y para cualquier elemento. Eliminación del mínimo
La eliminación del mínimo de un montículo se realiza en tres pasos:
1. Se elimina la raíz. 2. Se sustituye la raíz con el último elemento del montículo. 314
3. Se reconstituye el montículo recorriendo desde la raíz hasta las hojas (de arriba hacia abajo), cambiando valores presentes en el camino. En este caso, el primer paso es natural, el segundo tiene como objetivo almacenar la estructura del montículo, con su propiedad binaria y completa, y, por último, el tercer paso reconstituye la condición que debe cumplirse entre las claves. La siguiente imagen muestra de manera esquemática este procedimiento:
Figura 6.11
Lo más difícil es recorrer el montículo desde la raíz. Esto significa que en una posición se debe verificar que la clave de un nodo j es menor que las claves de sus hijos.
j
hijo
hijo + 1
Figura 6.12
En esta figura (véase figura 6.12), primero se detecta al hijo con la clave más baja (su índice es hijo). Aquí aparecen dos casos: T [ j ] < = T [ hijo ], entonces la relación se respeta para el otro hijo y la arborescencia con la raíz j es un montículo. T [ j ] > T [ hijo ], entonces se intercambian las claves de los nodos j e hijo y se continúa el recorrido con el nodo hijo.
La función que se traduce de este procedimiento es la siguiente: void eliminacion_min_monticulo(int T[], int *N) { int j, hijo; T[1] = T[*N]; (*N)--; // recorrido de la raiz hasta (el maximo) una hoja j = 1; while(j <= *N / 2) 315
{ hijo = j*2; if ((hijo+1 <= (*N)) && (T[hijo+1] < T[hijo])) hijo = hijo+1; if (T[j] < T[hijo]) break; else { cambio(T, j, hijo); j = hijo; } } }
return;
La función cambio intercambia el contenido de dos elementos que pertenecen al montículo: void cambio(int T[], int i, int j) { int tmp; tmp = T[i]; T[i]= T[j]; T[j] = tmp; } Inserción de un elemento
Esquemáticamente, la inserción de un valor en un montículo se puede representar como se muestra en la figura 6.13:
Figura 6.13
En la figura 6.13 se ponen en evidencia los dos pasos que se ejecutan en este caso:
1. Añadir el nuevo elemento a la última posición. 2. Reconstruir el montículo de abajo hacia arriba, es decir, de la nueva hoja hasta la raíz, haciendo los cambios necesarios si la relación entre las claves no se respeta. El recorrido se hace subiendo por cada nodo j; este recorrido se compara con el nodo padre (padre = j/2).
316
padre
j
j+1
Figura 6.14
Aquí se presentan dos casos:
1. La condición del montículo se respeta: T [ padre ] < = T [ j ]. En este caso, el recorrido termina porque en la parte de arriba las condiciones entre claves se respetan y abajo las condiciones son correctas. 2. Si T [ padre ] > T [ j ], las claves entre estos dos índices se cambian; la condición de montículo se respeta con este y con el otro hijo, pero se debe verificar que corresponda con el padre del padre. La función que realiza este tratamiento es la siguiente: void insercion_monticulo(int T[], int *N, int valor) { int j, padre; *N = *N + 1; T[*N] = valor; j= *N; = j/2; padre while ((padre >= 1) && (T[j] < T[padre])) { cambio(T,j, padre); j = padre; padre = j/2; } }
Un ejemplo de programa principal que llama a las dos funciones presentadas es el siguiente: imprima_simple_monticulo(MT, N); eliminacion_min_monticulo(MT, &N); printf(“ Despues la eliminacion del = min = :”); imprima_simple_monticulo(MT, N); printf(“ Introducir un elemento por insertar : “); scanf(“%d”, &val); insercion_monticulo(MT, &N, val); imprima_simple_monticulo(MT, N);
Un ejemplo de salida es: Dimension del monticulo : 9 Elementos : 1 2 3 4 5 6 7 8 9 317
Despues la eliminiacion del = min = : Dimension del monticulo : 8 Elementos : 2 4 3 8 5 6 7 9 Introducir un elemento por insertar : -1 Dimension del monticulo : 9 Elementos : -1 2 3 4 5 6 7 9 8 Eliminación de un elemento
Para eliminar un elemento, es necesario seguir los pasos que se muestran a continuación:
1. Sustituir el elemento eliminado por el último elemento y actualizar la dimensión del montículo. 2. Reconstituir el montículo recorriendo por arriba o por abajo, a partir de este punto y cambiando, si necesario, contenidos. Si en una etapa no se necesita un cambio, el algoritmo termina. Después de la sustitución del nodo eliminado por el último nodo, puede aparecer una contradicción con la propiedad del montículo o con los hijos; en este caso, el recorrido se realiza hasta las hojas o con el nodo padre, haciéndolo sobre el camino hasta la raíz. Una vez que se detecta una contradicción nodo-hijo o nodo-padre, las otras contradicciones son de la misma naturaleza.10 La complejidad de las operaciones de inserción y de eliminación se genera por el costo del recorrido. El largo máximo de este recorrido es la altura del montículo h = log2N. En cada iteración de este recorrido, se realiza un número constante de operaciones: comparaciones o cambios. Entonces, la complejidad de las tres funciones es 0(log2N) para un montículo de tamaño N.
Ordenamiento por montículo Un montículo es una estructura interesante que permite la extracción del valor mínimo en un tiempo razonable. La idea básica del algoritmo de ordenamiento por montículo ( heap sort, en inglés) es la construcción inicial de un montículo que contenga todos los elementos del arreglo y luego, de manera iterativa, extraer el mínimo y colocarlo en la última posición. Al final, los elementos estarían en orden decreciente. La figura 6.15 indica el esquema general de la segunda etapa de trabajo del algoritmo:
elementos ordenados
montículo 1
j
j + 1
N
Figura 6.15
Se recurre a este caso si se necesita el orden creciente o se hace una imagen en espejo del resultado, o bien se trabaja con un montículo por máximo. La primera etapa (construcción inicial del montículo) puede realizarse por inserciones sucesivas. La segunda eny considerar, maneranuevamente iterativa, a cada paso i, el montículo de largo N – i; intercambiar el mínimo con laetapa últimaconsiste posición, trabajar paradeobtener un montículo, ya que el primer elemento no respeta las condiciones del montículo. Entonces, se equilibra la estructura, desde la raíz hasta las hojas, haciendo comparaciones del nodo corriente con sus hijos, hasta que se respeta la condición. 10
318
Dejamos como ejercicio la escritura de esta función.
El código de la función de ordenamiento y de la función que equilibra el montículo es el siguiente: void equilibrar_monticulo_abajo(int T[], int N, int j) { int hijo; while(j <= N/2) { hijo = j*2; if ((hijo+1 <= N) && (T[hijo+1] < T[hijo])) hijo = hijo+1; if (T[j] < T[hijo]) break; else { cambio(T, j, hijo); j = hijo; } } return; } void ordenamiento_por_monticulo(int T[], int N) { int i, j; j = 1; for (i = 2; i <= N; i++) insercion_monticulo(T, &j, T[i for (i = 1; i < N; i++) { printf(“ Paso i = %d \n”, i); imprima_simple_monticulo(T, j); cambio(T, 1, j--); equilibrar_monticulo_abajo(T, j, 1); } }
La primera etapa tiene N pasos; a cada paso, la inserción necesita entre 1 y log 2h comparaciones, donde h es la profundidad variable del montículo. La complejidad de esta etapa del algoritmo es (NlogN). En la segunda etapa se hace al menos un cambio y una comparación en cada paso. Como máximo se realizan h + 1 cambios y 0(h) comparaciones, donde h es la profundidad variable del montículo. La complejidad de esta etapa también es (NlogN). Se puede decir que este algoritmo es óptimo en términos del número de operaciones, por lo que no necesita memoria suplementaria. El hecho de que no sea estable es solo un defecto, ya que al momento de la construcción del montículo, el orden entre elementos equivalentes puede cambiar. Unados posible mejorade sería construir más rápidoy el el primero montículo inicial con losenNsuelementos. la siguiente: si existen montículos, la misma profundidad, está completo último nivelLay idea si se es toma un valor de la raíz, esto es suficiente para equilibrar este nodo por abajo, con cambios sucesivos con sus hijos, hasta que la condición entre claves se cumpla o se trate una hoja. La figura 6.16 muestra este principio.
319
Figura 6.16
Otra hoja observación importanteEntonces, que debepara considerarse en montículo la construcción de un montículo de tamaño N, esnoque solo tenga una en el montículo. construir un es necesario recorrer los nodos que tengan hojas desde el penúltimo nivel hasta la raíz, con el fin de equilibrarlo de arriba hacia abajo. Entonces, esto se hace para cada nodo i de rango N/2 hasta 1; el equilibrio tiene como máximo un número de operación de h', donde h' es la profundidad del nodo i. Esta profundidad es, por tanto, log 2N – log2i. Por ende, la complejidad de esta etapa es 0(N), debido a que existen las siguientes equivalencias: N /1
∑ (log 2 N – log 2 i ) =
i =1
=
1 N log N – ∑ log 2 i 2 2 i = N1/2
N N N N N N log N – log 2 ( N / 2)! + log 2 − log 2 2 2 2 2 2 2 2
El código de la nueva función de ordenamiento por montículo, donde usamos esta estrategia para la construcción de la primera etapa, es el siguiente: void ordenamiento_por_monticulo2(int T[], int N) { int i, j; for (i = N/2; i >= 1 ;i--) equilibrar_monticulo_abajo(T, N, i); j = N; for (i = 1; i < N; i++) { cambio(T, 1, j--); equilibrar_monticulo_abajo(T, j, 1); } }
La complejidad y las otras propiedades de esta versión de ordenamiento por montículo no cambian.
Síntesis del capítulo En este capítulo se introdujeron y desarrollaron tres problemas básicos e importantes de la computación:
1. Búsqueda de un valor. 2. Selección del elemento mínimo o el elemento máximo. 3. Ordenamiento. 320
El marco de trabajo se realizó con base en un conjunto de valores, por lo cual existe una relación de orden total, con el cual es posible comparar cualesquiera dos elementos. Así, en cada problema fue indicada su complejidad en términos de números edoperaciones que deben efectuarse en el mejor de los casos. Esta complejidad teórica obtenida fue utilizada como un criterio de calidad para los algoritmos propuestos para cada problema. Aunque, a veces, estos algoritmos propuestos funcionan sobre dos estructuras de datos en uso: Arreglos. Listas ligadas.
También pueden funcionar sobre una sola estructura. Para el caso del problema de búsqueda, se determinó que la complejidad teórica esde 0(logN), donde N es el número de valores que se trata. La siguiente tabla muestra la complejidad de las operaciones de búsqueda, inserción, eliminación y selección en arreglos y listas, ordenados o no, y en el tipo montícu lo, que es un tipo abstracto implementado con un arreglo.
Tabla 6.1 Estructura Vect. n-ord. Lista n-ord.
Búsqueda
Inserción
Eliminación
Mín/máx
O(N)
O(1)
O(1)
O(N) O(N)
O(N)
O(1)
O(1)
Vect. ord.
O(logN)
O(N)
O(N)
O(1)
Lista ord.
O(N)
O(1)
O(1)
O(1) / O(N)
Montículo
O(N)
O(logN)
O(logN)
O(1) / O(N)
Para el problema de ordenamiento, se demostró que la complejidad es 0(NlogN). Los algoritmos presentados fueron analizados con base en el número de operaciones que se efectúan en promedio, en el mejor de los casos o en el peor de los casos. Otro criterio de análisis fue que la estabilidad y el ordenamiento se realizan o no in situ (sin memoria importante). La tabla 6.2 sintetiza los algoritmos detallados en este capítulo: Tabla 6.2 Método
Complejidad media 2
Complejidad peor caso 2
Estable
Estructura
Burbuja
O (N )
O(N )
Sí
arreglo/ista
Selección
O(N2)
O(N2)
Sí
arreglo/ista
Insert. séq.
O(N2)
O(N2)
Sí
arreglo/ista
1.25
1.5
Shell
O (N )
O(N )
No
arreglo
Rapidez
O(NlogN)
O(N2)
No
arreglo
Insert. dich.
O(NlogN)
O(NlogN)
Sí
arreglo
Fusión
O(NlogN)
O(NlogN)
Sí
lista/rreglo
Tas
O(NlogN)
O(NlogN)
No
arreglo
También fueron presentados dos algoritmos de ordenamiento lineales:
1. Por enumeración. 2. Por casilleros que tienen un buen desempeño en casos particulares de valores para ordenar.
321
Bibliografía Cormen, Thomas H., Charles E. Leiserson, Ronald L. Rivest y Clifford Stein (2009). Introduction to Algorithms, 3rd.
edition. MIT Press. Knuth, Donald (1986). El arte de programar ordenadores . Vol III. Editorial Reverté o The Art of Computer Progra-
mming (1998). Volume 3: Sorting and Searching (3rd. edition). Addison-Wesley Professional. Sedgewick, Robert (1998). Algorithms in C, 3rd. edition, Addison-Wesley.
Ejercicios y problemas 1. Escribir un programa marco que contenga un menú interactivo que permita al usuario leer, buscar, insertar y borrar elementos de una estructura de datos. El diálogo con el usuario sería de la forma: Opciones: 1 - lectura de la estructura 2 - insercion de un elemento 3 - busqueda de un elemento 4 - eliminacion de un elemento 5 - imprimir los elementos de la estructura 0 - FIN Su elección:
2. Adaptar su programa marco al trabajo con arreglos ordenados. 3. Estructuras lineales adaptativas. a) Si la probabilidad de buscar el elemento xi es pi, demostrar que la búsqueda secuencial es más rápida si p1 > p2 > … pN . b) Implementar los dos métodos de búsqueda y adaptación siguientes; si se busca un elemento su lugar se cambia: El elemento encontrado se pone en la primera posición del arreglo. El elemento encontrado avanza de posición, cambiando su lugar con el vecino que lo precede. Complejidad de función (en la hipótesis de que la probabilidad de encontrar un elemento es la misma
por cualquier valor de la estructura).
c) Si después de haber realizado un número importante de operaciones de búsqueda y adaptación, se considera que el arreglo va a cambiar muy poco, solo se hacen operaciones de búsqueda. Entonces, están enfrente de la estructura (arreglo o lista no ordenada) las claves que se buscan con mayor frecuencia. (¡Las probabilidades no son las mismas!) Con base en esta hipótesis, se pide calcular la complejidad de la función deTomar búsqueda secuencial. decrecientes: Sugerencia: las probabilidades pi =
1 2i
p1 Nc, p2 (n – 1)c, …, pN c, con c =
322
2
N ( N + 1)
4. Reducción de estructuras. En la presentación del método de búsqueda se trabaja con estructuras que pueden tener valores con repetición. Así, para cada una de las cuatro estructuras utilizadas, arreglo o lista, ordenada o no, escribir una función donde se eliminen las apariciones múltiples de una misma clave. Ejemplos
Si la estructura no ordenada contiene 2 0 9 2 9 0 9 7, la función de transformación debe regresar 2 0 9 7. Si la estructura ordenada contiene 0 2 2 7 9 9 9, la función de transformación debe regresar 0 2 7 9. 5. Búsqueda multi-clave. Regresar un arreglo de índices o de apuntadores de todos los elementos equivalentes al valor buscado. 6. Listas ligadas con centinelas. En este capítulo trabajamos con listas ligadas que terminan con un apuntador equivalente a NULL. Si se trabaja con una centinela, adaptar las funciones de búsqueda secuencial, de cálculo del mínimo y del máximo en listas que terminan con centinela.11 7. En la presentación del algoritmo de búsqueda de estructuras ordenas se realizó una hipótesis, en la que se establecía que los elementos están ordenados en orden creciente; adaptar las dos funciones para el caso de las estructuras ordenadas en orden decreciente. 8. Modificar una de las funciones de búsqueda binaria para determinar el primer elemento equivalente al valor buscado. 9. La elección de la mitad del intervalo parece la opción más natural, aunque también es posible descomponer el largo del intervalo como una suma de números de Fibonacci sucesivos y tomar el índice interior j, de manera que los conjuntos formados tengan como tamaños estos números de Fibonacci. Hacer una función capaz de descomponer un número entero positivo en una suma de dos números de
Fibonacci, si el número es un número de Fibonacci, o la suma de un número de Fibonacci (lo más grande posible) u otro número, en caso contrario.
Con esta descomposición del largo del intervalo, calcular j como una suma entre i y un número de Fibo-
nacci. ¿Este método es más rápido que la búsqueda binaria por la mitad del intervalo?
10. Escribir dos funciones que determinen el mínimo y el máximo de una lista ligada, recorriendo una sola vez todos los elementos. En una primera versión se compara solo un elemento a la vez; en la segunda versión se comparan dos elementos en cada iteración. 11. Escribir una función que determine dos valores que disminuyan todos los elementos de un conjunto U. Si el conjunto U tiene claves múltiples, es posible que se regresen dos valores equivalentes. 12. Escribir una función que determine los dos valores distintos min 1 y min 2 de un arreglo, los cuales son menores que todos los elementos de un conjunto:
min1 min2 xi min1, xi U xi min2, xi U y xi min1 11
Veáse el capítulo 5, en especial el tema de la introducción de la centinela Z.
323
13. Escribir una función que extraiga los k primeros valores de un arreglo. El prototipo de la función debe ser: int k_minimos(int T[], int N, int K, int R[])
La función regresa falso si K > N.
14. Ordenamiento por sacudida (por burbuja bidireccional). a) Implementar este método como una función clásica de ordenamiento. Analizar el número de comparaciones b) el ordenamiento clásico por burbuja. y de cambios que se hacen. Deducir si esta versión es mejor que
15. Ordenamiento por selección por una lista ligada. Escribir dos versiones de función que realicen un ordenamiento por selección en una lista ligada, usando cada una de las funciones de cambio entre elementos de lista: cambio de contenido solo o cambio de ligas entre elementos. 16. Implementar el ordenamiento por selección en un arreglo, trabajando con el máximo de valores. El máximo se pasa al final del arreglo. 17. El ordenamiento de una lista por inserción fue propuesto tomando una lista de trabajo vacía. Tomar esta lista inicial de trabajo como el primer elemento de la lista para ordenar y cambiar las funciones y , con el fin de no tratar el caso de lista vacía. ¿El número total de operaciones cambia de manera significativa o no?
18. Proponer otro método de manejo de listas para el ordenamiento por inserción, evitando cor tar las ligas entre los elementos. Sugerencia: Guardar un apuntador de fin de lista ordenada, que constituye el precedente del fragmento de lista no ordenada, haciendo el corte de liga next solo en el caso de una inserción enfrente de la lista ordenada. 19. Si un arreglo de tamaño N es la representación de un montículo (por el mínimo), escribir una función capaz de calcular el máximo. El número máximo de comparaciones debe ser N2 – 1 . 20. Escribir una función de prototipo: void imprima_monticulo(int T[], int N);
para la impresión del montículo, que pone en evidencia los niveles del montículo. Por ejemplo, para el montículo del ejemplo de la sección 3, obtener una escritura de este estilo: 1 5 7
9 11
10
3
6 15
8 13
9
21. Con base en el ejemplo de ejecución del programa con eliminación de min y su inserción en el montículo, dibujar el montículo inicial y hacer las operaciones necesarias. ¿Se obtiene el mismo montículo que el programa?
324
22. Desarrollar la función que elimina un elemento de un rango al interior de un montículo. El prototipo de la función debe ser: void eliminacion_elem_monticulo(int T[], int &dimension, int indice);
23. En la sección dedicada al estudio de los montículos, la representación en un arreglo empieza con el índice 1. Si también se desea utilizar el índice 0 (los índices de 0 hasta N – 1), escribir las fórmulas para la detección de los hijos y del nodo padre. ¿Es o no una buena idea? 24. En el capítulo dedicado al estudio del algoritmo de ordenamiento por mezcla se utilizan listas que terminan con el apuntador tanto centinela, el algoritmo de mezcla como el en algoritmo NULL. Implementar elementos que terminan con un elemento como fue introducido capítulode 5. ordenamiento ¿El código quepor se obtiene es más simple o no? ¿Cuál es la complejidad de las funciones?
25. El algoritmo de ordenamiento por cifras es el siguiente: los números que se ordenan están representados en una base de numeración (10, por ejemplo) con k cifras; donde se consideran, incluso, las cifras 0 que se encuentran enfrente de los números. En este caso, se ordena varias veces con un algoritmo de ordenamiento estable (es imperativo que el algoritmo sea estable), según la cifra de las unidades y luego las cifras de las decimales; asimismo, se consideran hasta las cifras más significativas. a) Realizar un ejemplo con una docena de números en base de 10 con tres cifras. Justificar por qué se obtienen al final los números ordenados. b) Implementar el algoritmo como una función. 26. Trabajo con cadenas de caracteres. Implementar al menos tres algoritmos de ordenamientos para valores de tipo cadenas de caracteres. Sugerencia: Se pueden tomar como funciones de co mparación strcmp o strncmp de la biblioteca estándar string.h. 27. Realizar el algoritmo de ordenamiento por casilleros para una lista ligada. Efectuar el mismo algoritmo por valores que se encuentran en un arreglo.
28. Ordenamiento “loco”. El principio de este algoritmo es bastante simple: se recorre el conjunto de los elementos en una dirección, hasta que se encuentran dos elementos vecinos que no respetan el orden esperado; luego, se toma el elemento que apareció y se coloca de manera aleatoria en un lugar que está ordenado. El recorrido inicia nuevamente desde el principio. El algoritmo termina cuando todos los vecinos respetan el orden esperado. Justificar si este algoritmo es o no correcto; más precisamente, si termina o no termina jamás. Implementar el algoritmo como una función de ordenamiento. ¿Cuál es la complejidad del número de operaciones: comparaciones e inserciones?
29. Mezcla “inversa”. Si hay dos listas conocidas: L y M, y esta última fue obtenida por una operación de mezcla de L con una lista desconocida X, proponer un algoritmo capaz de extraer de M los elementos de X. ¿Cuál es la mejor elección para la implementación de estas listas: arreglo o lista ligada? Evaluar la complejidad en número de operaciones.
325