Uploaded by Daniel Muñoz

V Anton Spraul-Think Like a Programmer-EN (1)

advertisement
Machine Translated by Google
PIENSA COMO
UN PROGRAMADOR
UNA INTRODUCCIÓN A
SOLUCIÓN CREATIVA DE PROBLEMAS
t
l
S
YYPAG
oh
C
A
DY
Y
V. ANTON SPRAUL
Machine Translated by Google
Machine Translated by Google
PIENSA COMO UN PROGRAMADOR
Machine Translated by Google
Machine Translated by Google
PIENSA COMO UN
PROGRAMADOR
Una introducción a
Solución creativa de problemas
por V. Anton Spraul
San Francisco
Machine Translated by Google
PIENSA COMO UN PROGRAMADOR. Copyright © 2012 por V. Anton Spraul.
Reservados todos los derechos. Ninguna parte de este trabajo puede reproducirse o transmitirse de ninguna forma ni por ningún medio, electrónico o
mecánico, incluidas fotocopias, grabaciones o cualquier sistema de almacenamiento o recuperación de información, sin el permiso previo por escrito
del propietario de los derechos de autor y del editor.
Primera impresión
15 14 13 12
123456789
ISBN­10: 1­59327­424­6
ISBN­13: 978­1­59327­424­5
Editorial: William Pollock
Editor de producción: Alison Law
Diseño de portada: Charlie Wylie
Diseño de interiores: Estudios Octopod
Editor de desarrollo: Keith Fancher
Revisor técnico: Dan Randall
Editora: Julianne Jigour
Compositor: Susan Glinert Stevens
Corrector: Ward Webber
Para obtener información sobre distribuidores de libros o traducciones, comuníquese directamente con No Starch Press, Inc.:
Sin almidón Press, Inc.
38 Ringold Street, San Francisco, CA 94103
teléfono: 415.863.9900; fax: 415.863.9950; info@nostarch.com; www.nostarch.com
Datos de catalogación en publicación de la Biblioteca del Congreso
Un registro de catálogo de este libro está disponible en la Biblioteca del Congreso.
No Starch Press y el logotipo de No Starch Press son marcas comerciales registradas de No Starch Press, Inc. Otros nombres de productos y
empresas mencionados aquí pueden ser marcas comerciales de sus respectivos propietarios. En lugar de utilizar un símbolo de marca registrada
cada vez que aparece un nombre de marca registrada, utilizamos los nombres solo de manera editorial y para beneficio del propietario de la marca,
sin intención de infringir la marca.
La información contenida en este libro se distribuye "tal cual", sin garantía. Si bien se han tomado todas las precauciones en la preparación de
este trabajo, ni el autor ni No Starch Press, Inc. tendrán responsabilidad alguna ante ninguna persona o entidad con respecto a cualquier
pérdida o daño causado o presuntamente causado directa o indirectamente por el información contenida en el mismo.
Machine Translated by Google
CONTENIDOS BREVES
Agradecimientos ................................................ ................................................. ....... xi
Introducción ................................................. ................................................. ................ xiii
Capítulo 1: Estrategias para la resolución de problemas ................................. ................................1
Capítulo 2: Puros rompecabezas................................................ ................................................. ..25
Capítulo 3: Resolución de problemas con matrices ................................. ................................55
Capítulo 4: Resolución de problemas con punteros y memoria dinámica ................................81
Capítulo 5: Resolver problemas con las clases ........................................... ..........................111
Capítulo 6: Resolución de problemas con recursividad ................................. ........................143
Capítulo 7: Resolución de problemas con la reutilización de código................................ ........................171
Capítulo 8: Pensar como un programador ................................................ ................................195
Índice................................................. ................................................. ........................227
Machine Translated by Google
Machine Translated by Google
CONTENIDOS EN DETALLE
EXPRESIONES DE GRATITUD
INTRODUCCIÓN
xi
xiii
Sobre este libro ............................................... ................................................. ..... xv
Requisitos previos ................................................. ................................................ xvi
Temas elegidos ................................................ .......................................... xv
Estilo de programación................................................ ................................. xvi
Ejercicios ................................................. ................................................ xvi
¿Por qué C++? ................................................. ........................................xvii
1
ESTRATEGIAS PARA LA RESOLUCIÓN DE PROBLEMAS
1
Rompecabezas clásicos ................................................. ................................................. ...... 2
La zorra, la oca y el maíz ................................................ ........................ 3
Problema: ¿Cómo cruzar el río? ................................................. ........................ 3
Rompecabezas de fichas deslizantes ................................. ........................................ 7
Problema: El ocho deslizante ................................................ ........................................ 7
Problema: Los cinco deslizantes ......................................... ........................................ 8
Sudoku................................................. ................................................. .
Problema: completar un cuadrado de Sudoku ................................. .................
La esclusa de Quarrasi ................................................ ........................................ 13
11
11
Problema: Abrir la cerradura alienígena ................................. ........................ 13
Técnicas generales de resolución de problemas ................................. ........................... 15
Tenga siempre un plan................................................ .................................... dieciséis
Replantear el problema................................................. ................................ 17
Dividir el problema................................................. ........................................ 17
Comience con lo que sabe ................................................ ................................ 18
Reducir el problema................................................. ................................. 19
Busque analogías ................................................. ........................................ 20
Experimento ................................................. ................................................ 20
No te frustres ......................................... ........................................ 21
Ejercicios ................................................. ................................................. ............ 22
2
PUZLES PUROS
25
Revisión de C++ utilizado en este capítulo ................................. ................................ 26
Patrones de salida ................................................. ................................................. .... 26
Problema: la mitad de un cuadrado ................................................ ................................ 26
Problema: Un cuadrado (reducción de la mitad de un cuadrado) ................................. ........ 27
Problema: Una línea (reducción adicional de la mitad de un cuadrado) ........................ ... 27
Problema: Cuenta atrás contando hacia arriba ................................. ................... 28
Problema: un triángulo lateral ................................................ ................................ 29
Procesamiento de entrada ................................................ ................................................. ... 31
Problema: Validación de la suma de comprobación de Luhn ................................. ................... 31
Analizando el problema................................................ ........................ 33
Machine Translated by Google
Problema: convertir un dígito de carácter en un número entero ........................... ........... 35 Problema:
Validación de suma de comprobación de Luhn, longitud fija ................. ................ 36 Problema:
Validación de suma de comprobación simple, longitud fija ................. ................ 36 Problema: Positivo
o Negativo ................................ ................................................. 39 Uniendo las
piezas . ................................................. ........................ 39 Estado de
seguimiento ......................... ................................................. ................................. 41 Problema: Decodificar un
mensaje ................. ................................................. ........... 41 Problema: Leer un número de tres o
cuatro dígitos ................. .......... 45 Problema: Leer un número con tres o cuatro dígitos, aún más
simplificado ........ 46 Conclusión ................ ................................................. ................................................
53 Ejercicios .... ................................................. ................................................. ...... 53
3
SOLUCIONAR PROBLEMAS CON ARRAYS
55
Repaso de los fundamentos de los arreglos ................................................ ................................ 56
Tienda ................. ................................................. ........................................ 56
Copiar ........ ................................................. ................................................. 57 Recuperación y
búsqueda . ................................................. ................................. 57
Ordenar ................. ................................................. ................................... 59 Computar
estadísticas ........... ................................................. ........................ 61 Resolver problemas con
matrices ................. ................................................. ............ 62 Problema: Encontrar el
modo ................................ ................................................ 62
Refactorización .. ................................................. .......................................... 65 Matrices de datos
fijos .. ................................................. .......................................... 67 Matrices no
escalares ... ................................................. ................................................. 69 Matrices
multidimensionales .. ................................................. ........................................ 71 Decidir cuándo utilizar
matrices ......... ................................................. ......................... 74
Ejercicios ......................... ................................................. ........................................ 78
4
RESOLVIENDO PROBLEMAS CON PUNTEROS Y
MEMORIA DINÁMICA
81
Repaso de los fundamentos del puntero ................................................ ................................. 82 Beneficios de los
punteros .................. ................................................. ................................. 83 Estructuras de datos del tamaño de
tiempo de ejecución ........... ................................................. ......... 83 Estructuras de datos
redimensionables .................................. ........................................ 83 Compartir
memoria ......... ................................................. ................................. 84 Cuándo utilizar
punteros ................. ................................................. .......................... 84 La memoria
importa .................... ................................................. ................................. 85 La pila y el
montón ................. ................................................. ................. 86 Tamaño de la
memoria ................................. ................................................. ........ 88 Vida
útil ........................................ ................................................. .......... 90 Resolver problemas de
punteros ................................. ................................................. ... 91 Cadenas de longitud
variable ................................. ................................... 91 Problema: Manipulación de cadenas de
longitud variable ..... ................................................. 91 Listas
enlazadas .. ................................................. ........................................ 101 Problema: Seguimiento de
una cantidad desconocida de estudiantes Registros ................................ 101 Conclusión y próximos
pasos ................. ................................................. ................ 108
Ejercicios ................................ ................................................. .......................... 109
viii Contenido en detalle
Machine Translated by Google
5
RESOLVER PROBLEMAS CON CLASES
111
Repaso de los fundamentos de la clase ......................................... ................................ 112
Objetivos del uso de la clase .................................. ................................................ 113
Encapsulación................................................. ........................................ 114
Reutilización de código ................................................ ........................................ 114
Dividiendo el problema................................................. ................................ 115
Ocultación de información ................................................ ................................ 115
Legibilidad ................................................. ........................................ 117
Expresividad ................................................. ........................................ 117
Construyendo una clase sencilla ................................................ .......................................... 118
Problema: Lista de clases ................................................ ........................................ 118
El marco básico de la clase ......................................... ........................ 119
Métodos de soporte ................................................. ........................................ 122
Clases con datos dinámicos ................................................ ........................................ 125
Problema: Seguimiento de una cantidad desconocida de registros de estudiantes ......... 126
Agregar un nodo ................................................. ........................................ 128
Reorganizar la lista................................................. ................................ 130
Destructor................................................ ................................................ 133
Copia profunda ................................................. ........................................ 134
El panorama general de las clases con memoria dinámica ................................. 139
Errores a evitar................................................ ................................................ 140
La clase falsa................................................ ........................................ 140
Personas con una sola tarea .................................. .......................................... 141
Ejercicios ................................................. ................................................. ..........
141
6
RESOLVER PROBLEMAS CON RECURSIÓN
143
Revisión de los fundamentos de la recursividad ................................................ .......................... 144
Recursión de cabeza y cola ................................................ ........................................ 144
Problema: ¿Cuántos loros? ................................................. ........................ 144
Enfoque 1 ................................................. ........................................ 145
Enfoque 2 ................................................ ........................................ 146
Problema: ¿Quién es nuestro mejor cliente? ................................................. ............ 148
Enfoque 1 ................................................. ........................................ 149
Enfoque 2 ................................................ ........................................ 151
La gran idea recursiva ................................................ ........................................ 152
Problema: calcular la suma de una matriz de números enteros ................................. 153
Errores comunes ................................................ ................................................ 155
Demasiados parámetros ............................................... ................................ 155
Variables globales ................................................ ........................................ 156
Aplicar la recursividad a estructuras de datos dinámicas ................................. .......... 158
Recursividad y listas enlazadas ................................................ ........................ 158
Problema: contar números negativos en una lista enlazada individualmente ................. 159
Recursividad y árboles binarios ................................................ ........................ 160
Problema: encontrar el valor más grande en un árbol binario ........................................ ..... 162
Funciones de contenedor ................................................ ................................................ 163
Problema: encontrar el número de hojas en un árbol binario ................................. 163
Cuándo elegir la recursividad................................................ ........................................ 165
Argumentos en contra de la recursividad ................................................ ................. 166
Contenido en detalle ix
Machine Translated by Google
Problema: Mostrar una lista enlazada en orden ................................. ................ 168
Problema: Mostrar una lista enlazada en orden inverso ........................ ........................
168 Ejercicios ........................ ................................................. ................................ 170
7
SOLUCIONAR PROBLEMAS CON LA REUTILIZACIÓN DE CÓDIGO
171
Buena reutilización y mala reutilización ................................. ................................... 172 Revisión de
los fundamentos de los componentes ........ ................................................. ............ 173 Bloque de
código ................................. ................................................. ....... 173
Algoritmos ......................................... ................................................. ... 173
Patrones ................................................ ................................................. ... 174 Tipos de
datos abstractos ................................. ................................... 175
Bibliotecas ........... ................................................. .................................... 175 Conocimiento
de los componentes del edificio ........... ................................................. ................ 176 Aprendizaje
exploratorio ................................. ................................................ 176 Problema: El primer
estudiante ................................................ ................................. 177 Aprendizaje según
sea necesario ............ ................................................. .................. 180 Problema: Recorrido
eficiente ........................ ................................................ 180 Elección de un tipo de
componente ................................................ ................................. 188 Elección de componentes en
acción ........... ................................................. ....... 189 Problema: Clasificar algunos y
dejar otros solos ................................. ................ 189 Comparación de los
resultados ................................ ................................................. 193
Ejercicios ... ................................................. ................................................. ...... 193
8
PENSAR COMO UN PROGRAMADOR
195
Creando tu propio plan maestro ................................................ ................................. 196
Aprovechando tus fortalezas y debilidades ............. ................................. 196 Armando el
Plan Maestro ......... ................................................. ..... 202 Abordar cualquier
problema ......................................... ................................................. 203 Problema: hacer trampa en
el ahorcado ................................. ......................... 204 Encontrar una manera de hacer
trampa ................... ................................................. ..... 205 Operaciones requeridas para
hacer trampa en el ahorcado ................................. .... 206 Diseño
inicial ................................................ ................................................ 208 Codificación
inicial ................................................. ......................................... 210 Análisis de resultados
iniciales..... ................................................. ................... 217 El arte de resolver
problemas ......................... ................................................ 218 Aprender nueva
programación Habilidades ................................................. ......................... 219 Nuevos
idiomas ......................... ................................................. ................ 219 Nuevas habilidades
222
para un idioma que ya conoce .................. .................
Nuevas bibliotecas ................................................. ........................................ 223 Toma
una clase ..... ................................................. ................................. 223
Conclusión ............ ................................................. ................................................ 224
Ejercicios .... ................................................. ................................................. ..... 225
ÍNDICE
x Contenido en detalle
227
Machine Translated by Google
EXPRESIONES DE GRATITUD
Ningún libro es realmente obra de un solo autor y he recibido mucha ayuda sobre
Think Like a Programmer.
Estoy agradecido a todos en No Starch Press, especialmente a Keith Fancher y
Alison Law, quien editó, dio forma y guió el libro durante toda su producción. También
debo agradecer a Bill Pollock por su decisión de inscribirme en primer lugar; espero
que esté tan satisfecho con el resultado como yo. La gente de No Starch ha sido
siempre amable y servicial en su correspondencia conmigo. Espero algún día
conocerlos en persona y ver hasta qué punto se parecen a sus avatares de dibujos
animados en el sitio web de la empresa.
Dan Randall hizo un trabajo maravilloso como editor técnico. Sus numerosas
sugerencias más allá de la revisión técnica me ayudaron a fortalecer el manuscrito en
muchas áreas.
En el frente interno, las personas más importantes de mi vida, Mary Beth y
Madeline, me brindaron amor, apoyo y entusiasmo y, lo que es más importante, tiempo
para escribir.
Finalmente, a todos los estudiantes de programación que he tenido a lo
largo de los años: Gracias por dejarme ser su maestro. Las técnicas y estrategias
descritas en este libro se desarrollaron gracias a nuestros esfuerzos conjuntos.
Espero que hayamos facilitado el viaje a la próxima generación de programadores.
Machine Translated by Google
Machine Translated by Google
t
l
¿Tiene dificultades para escribir programas,
aunque cree que comprende los lenguajes de
programación? ¿Eres capaz de leer
¿Lee un capítulo en un libro de programación,
asiente con la cabeza todo el tiempo, pero no puede
aplicar lo que ha leído a sus propios programas? Eres capaz de
S
YYPAG
oh
C
A
DY
Y
INTRODUCCIÓN
comprender un ejemplo de programa que ha leído en línea, incluso hasta el punto de
poder explicarle a otra persona lo que hace cada línea del código, pero siente que su
cerebro se paraliza cuando se enfrenta a una tarea de programación y una pantalla en
blanco en su texto. ¿editor?
No estás solo. He enseñado programación durante más de 15 años y la mayoría de
mis alumnos habrían encajado en esta descripción en algún momento de su instrucción.
Llamaremos a la habilidad faltante resolución de problemas, la capacidad de tomar una
descripción dada del problema y escribir un programa original para resolverlo. No toda
la programación requiere una resolución exhaustiva de problemas. Si sólo está realizando
modificaciones menores a un programa existente, depurando o agregando código de prueba, el
Machine Translated by Google
La programación puede ser de naturaleza tan mecánica que su creatividad nunca se pone a prueba.
Pero todos los programas requieren la resolución de problemas en algún momento, y todos los buenos
programadores pueden resolver problemas.
Resolver problemas es difícil. Es cierto que algunas personas hacen que parezca fácil.
los “naturales”, el equivalente en el mundo de la programación de un atleta talentoso, como Michael
Jordan. Para estos pocos elegidos, las ideas de alto nivel se traducen sin esfuerzo al código fuente.
Para hacer una metáfora de Java, es como si sus cerebros ejecutaran Java de forma nativa, mientras
que el resto de nosotros tuviéramos que ejecutar una máquina virtual, interpretando sobre la marcha.
No tener talento natural no es fatal para convertirse en programador; si lo fuera, el mundo tendría
pocos programadores. Sin embargo, he visto a muchos estudiantes valiosos luchar durante demasiado
tiempo ante la frustración. En el peor de los casos, abandonan por completo la programación,
convencidos de que nunca podrán ser programadores, de que los únicos buenos programadores son
los que nacen con un don innato.
¿Por qué es tan difícil aprender a resolver problemas de programación?
En parte, se debe a que la resolución de problemas es una actividad diferente de aprender la
sintaxis de programación y, por lo tanto, utiliza un conjunto diferente de "músculos" mentales.
Aprender la sintaxis de programación, leer programas, memorizar elementos de una interfaz de
programación de aplicaciones: estas son en su mayoría actividades analíticas del “cerebro izquierdo”.
Escribir un programa original utilizando herramientas y habilidades previamente aprendidas es una
actividad creativa del "lado derecho del cerebro".
Supongamos que necesita quitar una rama que cayó en una de las canaletas de lluvia de su
casa, pero su escalera no es lo suficientemente larga para alcanzar la rama. Te diriges a tu garaje y
buscas algo, o una combinación de cosas, que te permita quitar la rama de la canaleta. ¿Hay alguna
forma de extender la escalera? ¿Hay algo que puedas sostener en la parte superior de la escalera
para agarrar o desalojar la rama? Tal vez podrías simplemente subir al techo desde otro lugar y tomar
la rama desde arriba. Eso es resolución de problemas y es una actividad creativa. Lo creas o no,
cuando diseñas un programa original, tu proceso mental es bastante similar al de la persona que
descubre cómo quitar la rama del canal y bastante diferente al de una persona que depura un bucle
for existente .
Sin embargo, la mayoría de los libros de programación centran su atención en la sintaxis y la
semántica. Aprender la sintaxis y la semántica de un lenguaje de programación es esencial, pero es
sólo el primer paso para aprender a programar en ese lenguaje. En esencia, la mayoría de los libros
de programación para principiantes enseñan cómo leer un programa, no cómo escribirlo. Los libros
que sí se centran en la escritura a menudo son efectivamente “libros de cocina” en el sentido de
que enseñan “recetas” específicas para usar en situaciones particulares. Estos libros pueden ser muy
valiosos para ahorrar tiempo, pero no como un camino para aprender a escribir código original. Piense
en los libros de cocina en el sentido original. Aunque los grandes cocineros poseen libros de cocina,
nadie que dependa de ellos puede ser un gran cocinero. Un gran cocinero comprende los ingredientes,
los métodos de preparación y de cocción, y sabe cómo combinarlos para preparar excelentes comidas.
Todo lo que un gran cocinero necesita para preparar una comida sabrosa es una cocina completamente
equipada. De la misma manera, un gran programador comprende la sintaxis del lenguaje, los marcos
de aplicación, los algoritmos y los principios de la ingeniería de software y sabe cómo combinarlos
para crear excelentes programas. Dale a un gran programador una lista de especificaciones, déjalo
libre con un entorno de programación completamente equipado y sucederán grandes cosas.
xiv Introducción
Machine Translated by Google
En general, la educación actual en programación no ofrece mucha orientación en el
área de resolución de problemas. En cambio, se supone que si a los programadores se les
da acceso a todas las herramientas de programación y se les pide que escriban suficientes
programas, eventualmente aprenderán a escribir dichos programas y los escribirán bien.
Hay algo de verdad en esto, pero “eventualmente” puede llevar mucho tiempo. El viaje
desde la iniciación hasta la iluminación puede estar lleno de frustración, y muchos de los
que inician el viaje nunca llegan al destino.
En lugar de aprender por prueba y error, puede aprender a resolver problemas de
forma sistemática. De eso se trata este libro. Puede aprender técnicas para organizar sus
pensamientos, procedimientos para descubrir soluciones y estrategias para aplicar a
ciertas clases de problemas. Al estudiar estos enfoques, puedes desbloquear tu creatividad.
No se equivoque: la programación, y especialmente la resolución de problemas, es una
actividad creativa. La creatividad es misteriosa y nadie puede decir exactamente cómo
funciona la mente creativa. Sin embargo, si podemos aprender a componer música, recibir
consejos sobre escritura creativa o que nos enseñen a pintar, entonces también podremos
aprender a resolver problemas de programación de forma creativa. Este libro no le dirá
exactamente qué hacer; le ayudará a desarrollar sus habilidades latentes de resolución
de problemas para que sepa qué debe hacer. Este libro trata de ayudarte a convertirte en el
programador que debes ser.
Mi objetivo es que usted y todos los demás lectores de este libro aprendan a abordar
sistemáticamente cada tarea de programación y tengan la confianza de que, en última
instancia, resolverán un problema determinado. Cuando completes este libro, quiero que
pienses como un programador y creas que eres un programador.
Sobre este libro
Habiendo explicado la necesidad de este libro, necesito hacer algunos comentarios sobre
qué es y qué no es este libro.
Requisitos previos
Este libro asume que usted ya está familiarizado con la sintaxis y semántica básica del
lenguaje C++ y que ha comenzado a escribir programas. La mayoría de los capítulos
esperarán que usted conozca los fundamentos específicos de C++; Estos capítulos
comenzarán con una revisión de esos fundamentos. Si todavía estás absorbiendo los
conceptos básicos del idioma, no te preocupes. Hay muchos libros excelentes sobre la
sintaxis de C++ y puedes aprender a resolver problemas en paralelo al aprendizaje de la
sintaxis. Sólo asegúrese de haber estudiado la sintaxis relevante antes de intentar abordar
los problemas de un capítulo.
Temas elegidos
Los temas tratados en este libro representan áreas en las que con mayor frecuencia he
visto luchar a los nuevos programadores. También presentan una amplia muestra
representativa de diferentes áreas en la programación temprana e intermedia.
Sin embargo, debo enfatizar que este no es un “libro de cocina” de algoritmos.
o patrones para resolver problemas específicos. Aunque en capítulos posteriores se
analiza cómo emplear algoritmos o patrones conocidos, no debería utilizar este
Introducción xvi
Machine Translated by Google
Reserve como una “hoja de cuna” para superar problemas particulares o concéntrese solo
en los capítulos que se relacionan directamente con sus luchas actuales. En lugar de ello,
trabajaría durante todo el libro, omitiendo material sólo si carece de los requisitos previos
necesarios para seguir la discusión.
Estilo de programación
Una nota rápida sobre el estilo de programación empleado en este libro: este libro no trata
sobre programación de alto rendimiento ni sobre la ejecución del código más compacto y
eficiente. El estilo que he elegido para los ejemplos de código fuente pretende ser legible
por encima de cualquier otra consideración. En algunos casos, tomo varios pasos para
lograr algo que podría hacerse en un solo paso, solo para que quede claro el principio que
estoy tratando de demostrar.
En este libro se cubrirán algunos aspectos del estilo de programación, pero
cuestiones más importantes, como qué se debe o no incluir en una clase, no cuestiones
pequeñas, como cómo se debe sangrar el código. Como programador en desarrollo, por
supuesto querrás emplear un estilo coherente y legible en todo el trabajo que realices.
Ejercicios
El libro incluye una serie de ejercicios de programación. Este no es un libro de texto y no
encontrará respuestas a ninguno de los ejercicios al final. Los ejercicios le brindan
oportunidades para aplicar los conceptos descritos en los capítulos. Por supuesto,
depende de usted decidir probar cualquiera de los ejercicios, pero es esencial que ponga
estos conceptos en práctica. Simplemente leer el libro no logrará nada. Recuerde que este
libro no le dirá exactamente qué hacer en cada situación. Al aplicar las técnicas que se
muestran en este libro, desarrollará su propia capacidad para descubrir qué hacer.
Además, aumentar su confianza, otro objetivo principal de este libro, requiere éxito. De
hecho, esa es una buena manera de saber cuándo ha realizado suficientes ejercicios en un
área problemática determinada: cuando está seguro de que puede abordar otros problemas
en el área. Por último, los ejercicios de programación deberían ser divertidos. Si bien puede
haber momentos en los que preferirías hacer otra cosa, resolver un problema de
programación debería ser un desafío gratificante.
Deberías pensar en este libro como una carrera de obstáculos para tu cerebro. Las
carreras de obstáculos desarrollan fuerza, resistencia y agilidad y dan confianza al entrenador.
Al leer los capítulos y aplicar los conceptos a tantos ejercicios como pueda, generará
confianza y desarrollará habilidades de resolución de problemas que pueden usarse en
cualquier situación de programación. En el futuro, cuando se enfrente a un problema difícil,
sabrá si debe intentar superarlo, superarlo o superarlo.
xvi Introducción
Machine Translated by Google
¿Por qué C++?
Los ejemplos de programación de este texto están codificados en C++. Dicho esto, este
libro trata sobre la resolución de problemas con programas, no específicamente sobre
C++. Aquí no encontrará muchos consejos y trucos específicos de C++, y los conceptos
generales que se enseñan a lo largo de este libro se pueden emplear en cualquier
lenguaje de programación. Sin embargo, no se puede hablar de programación sin hablar
de programas, y hubo que elegir un lenguaje específico.
Se seleccionó C++ por varias razones. Primero, es popular en una variedad de áreas
problemáticas. En segundo lugar, debido a sus orígenes en el lenguaje C estrictamente
procedimental, el código C++ puede escribirse utilizando tanto el paradigma procedimental
como el orientado a objetos. La programación orientada a objetos es tan común ahora que
no se puede omitir en una discusión sobre resolución de problemas, pero muchos conceptos
fundamentales de resolución de problemas se pueden discutir en términos de
programación estrictamente procedimental, y hacerlo simplifica tanto el código como la
discusión. sión. En tercer lugar, como lenguaje de bajo nivel con bibliotecas de alto nivel,
C++ nos permite analizar ambos niveles de programación. Los mejores programadores
pueden “conectar manualmente” soluciones cuando sea necesario y utilizar bibliotecas de
alto nivel e interfaces de programación de aplicaciones para reducir el tiempo de desarrollo.
Por último, y en parte como función de las otras razones enumeradas, C++ es una
excelente opción porque una vez que aprendes a resolver problemas en C++, aprendes a
resolver problemas en cualquier lenguaje de programación. Muchos programadores han
descubierto cómo las habilidades aprendidas en un lenguaje se aplican fácilmente a otros
lenguajes, pero esto es especialmente cierto para C++ debido a su enfoque de paradigmas
cruzados y, francamente, a su dificultad. C++ es el verdadero negocio: es programación sin ruedas de apo
Esto es desalentador al principio, pero una vez que empieces a tener éxito en C++,
sabrás que no serás alguien que pueda codificar un poco: serás un programador.
Introducción
xvii
Machine Translated by Google
Machine Translated by Google
t
l
S
YYPAG
oh
C
A
DY
Y
ESTRATEGIAS PARA
RESOLUCIÓN DE PROBLEMAS
Este libro trata sobre la resolución de problemas,
pero ¿qué es exactamente la resolución de
problemas? Cuando la gente usa el término en una
conversación ordinaria, a menudo significa algo muy
diferente de lo que queremos decir aquí. Si su Honda Civic 1997
tiene humo azul saliendo del tubo de escape, está funcionando bruscamente en
ha perdido eficiencia de combustible, este es un problema que se puede resolver
con conocimiento automotriz, diagnóstico, equipos de reemplazo y herramientas
comunes de taller. Sin embargo, si les cuentas tu problema a tus amigos, uno de
ellos podría decir: “Oye, deberías cambiar ese viejo Honda por algo nuevo. Problema resuelto."
Pero la sugerencia de su amigo no sería realmente una solución al problema: sería
una forma de evitarlo .
Los problemas incluyen restricciones, reglas inquebrantables sobre el problema
o la forma en que se debe resolver el problema. Con el Civic averiado, una de las
limitaciones es que deseas reparar el auto actual, no comprar uno nuevo. Las
limitaciones también pueden incluir el costo total de las reparaciones, cuánto tiempo
llevará la reparación o el requisito de que no se puedan comprar herramientas nuevas
solo para esta reparación.
Machine Translated by Google
Al resolver un problema con un programa, también existen restricciones. Las limitaciones comunes
incluyen el lenguaje de programación, la plataforma (¿se ejecuta en una PC, un iPhone o qué?), el
rendimiento (un programa de juego puede requerir que los gráficos se actualicen al menos 30 veces por
segundo, una aplicación empresarial puede requerir que los gráficos se actualicen al menos 30 veces
por segundo). un tiempo máximo de respuesta a la entrada del usuario) o huella de memoria.
A veces, la restricción implica qué otro código puede hacer referencia: tal vez el programa no pueda incluir
cierto código de fuente abierta, o tal vez lo contrario.
tal vez solo pueda usar código abierto.
Entonces, para los programadores podemos definir la resolución de problemas como escribir un
programa original que realice un conjunto particular de tareas y cumpla con todas las restricciones establecidas.
Los programadores principiantes a menudo están tan ansiosos por cumplir la primera parte de esa
definición (escribir un programa para realizar una determinada tarea) que fallan en la segunda parte de la
definición, cumpliendo las restricciones establecidas. A un programa como ese, uno que parece producir
resultados correctos pero que infringe una o más de las reglas establecidas, lo llamo Kobayashi Maru. Si
ese nombre no le resulta familiar, significa que no está suficientemente familiarizado con una de las piedras
angulares de la cultura geek, la película Star Trek II: La ira de Khan. La película contiene una trama
secundaria sobre un ejercicio para aspirantes a oficiales en la Academia de la Flota Estelar. Los cadetes
son colocados a bordo de un puente de nave estelar simulado y obligados a actuar como capitanes en una
misión que implica una elección imposible. Personas inocentes morirán en un barco herido, el Kobayashi
Maru, pero para llegar hasta ellos es necesario iniciar una batalla con los klingon, una batalla que sólo
puede terminar con la destrucción del barco del capitán. El ejercicio tiene como objetivo poner a prueba
el coraje de un cadete bajo fuego.
No hay forma de ganar y todas las decisiones conducen a malos resultados. Hacia el final de la película,
descubrimos que el Capitán Kirk modificó la simulación para que realmente se pudiera ganar. Kirk fue
inteligente, pero no resolvió el dilema del Kobayashi Maru; lo evitó.
Afortunadamente, los problemas que enfrentará como programador tienen solución, pero muchos
programadores aún recurren al enfoque de Kirk. En algunos casos, lo hacen de forma accidental. (“¡Oh,
vaya! Mi solución sólo funciona si hay cien elementos de datos o menos. Se supone que funciona para un
conjunto de datos ilimitado. Tendré que reconsiderar esto”). En otros casos, la eliminación de restricciones
es deliberada. , una estratagema para cumplir un plazo impuesto por un jefe o un instructor. En otros
casos, el programador simplemente no sabe cómo cumplir con todas las restricciones.
En los peores casos que he visto, el estudiante de programación le ha pagado a otra persona para que
escriba el programa. Independientemente de las motivaciones, siempre debemos ser diligentes para evitar
al Kobayashi Maru.
Rompecabezas clásicos
A medida que avance en este libro, notará que aunque los detalles del código fuente cambian de un área
problemática a otra, surgirán ciertos patrones en los enfoques que adoptemos. Esta es una gran noticia
porque es lo que finalmente nos permite abordar cualquier problema con confianza, tengamos o no una
amplia experiencia en esa área problemática. problema experto
2 Capítulo 1
Machine Translated by Google
Los solucionadores reconocen rápidamente una analogía, una similitud explotable entre
un problema resuelto y un problema no resuelto. Si reconocemos que una característica
del problema A es análoga a una característica del problema B y ya hemos resuelto el
problema B, tenemos una idea valiosa para resolver el problema A.
En esta sección, discutiremos problemas clásicos fuera del mundo de
programación que tienen lecciones que podemos aplicar a problemas de programación.
El zorro, la oca y el maíz
El primer problema clásico que discutiremos es un acertijo sobre un granjero que necesita
cruzar un río. Probablemente lo hayas encontrado anteriormente de una forma u otra.
PROBLEMA: ¿CÓMO CRUZAR EL RÍO?
Un granjero con un zorro, un ganso y un saco de maíz necesita cruzar un río. El granjero tiene un bote de
remos, pero sólo hay espacio para él y uno de sus tres objetos. Desafortunadamente, tanto el zorro como el
ganso tienen hambre. No se puede dejar al zorro solo con el ganso, o el zorro se comerá al ganso. Asimismo,
no se puede dejar sola a la oca con el saco de maíz, o la oca se comerá el maíz. ¿Cómo consigue el granjero
que todo cruce el río?
La configuración para este problema se muestra en la Figura 1­1. Si nunca antes te
has encontrado con este problema, detente aquí y dedica unos minutos a intentar resolverlo.
Si has escuchado este acertijo antes, intenta recordar la solución y si pudiste resolverlo por
tu cuenta.
Costa lejana
BOLSA
Oh'
MAÍZ
Cerca de la costa
Figura 1­1: El zorro, el ganso y el saco de maíz. El barco puede
transportar un artículo a la vez. No se puede dejar al zorro en la
misma orilla que al ganso, ni al ganso en la misma orilla que al saco
de maíz.
Estrategias para la resolución de problemas 3
Machine Translated by Google
Pocas personas son capaces de resolver este enigma, al menos sin una
pista. Sé que no lo era. Así es como suele ser el razonamiento. Como el granjero
sólo puede llevar una cosa a la vez, necesitará varios viajes para llevar todo a la
otra orilla. En el primer viaje, si el granjero lleva al zorro, el ganso se quedaría
con el saco de maíz y el ganso se comería el maíz. Asimismo, si el granjero se
llevara el saco de maíz en el primer viaje, el zorro se quedaría con el ganso y el
zorro se comería el ganso. Por lo tanto, el granjero debe llevar el ganso en el
primer viaje, lo que da como resultado la configuración que se muestra en la Figura 1­2.
Costa lejana
BOLSA
Oh'
MAÍZ
Cerca de la costa
Figura 1­2: El primer paso necesario para resolver el problema del zorro, la oca y
el saco de maíz. Sin embargo, a partir de este paso, todos los pasos siguientes
parecen terminar en un fracaso.
Hasta ahora, todo bien. Pero en el segundo viaje, el granjero debe llevarse el
zorro o el maíz. Sin embargo, todo lo que el granjero tome debe dejarse en la
orilla opuesta con el ganso mientras el granjero regresa a la orilla más cercana
por el artículo restante. Esto significa que o el zorro y el ganso quedarán juntos
o el ganso y el maíz quedarán juntos. Como ninguna de estas situaciones es
aceptable, el problema parece irresoluble.
Nuevamente, si ha visto este problema antes, probablemente recuerde el
elemento clave de la solución. El granjero tiene que llevar el ganso en el primer
viaje, como se explicó anteriormente. En el segundo viaje, supongamos que el
granjero lleva al zorro. Sin embargo, en lugar de dejar al zorro con el ganso, el
granjero lleva el ganso de regreso a la orilla cercana. Luego, el granjero cruza
el saco de maíz, dejando al zorro y el maíz en la otra orilla, mientras regresa
para un cuarto viaje con el ganso. La solución completa se muestra en la Figura 1­3.
Este acertijo es difícil porque la mayoría de las personas nunca consideran
llevar uno de los objetos de la orilla lejana a la orilla cercana. Algunas personas
incluso sugerirán que el problema es injusto y dirán algo como: "¡No dijiste que
podía retirar algo!". Esto es cierto, pero también es cierto que nada en la
descripción del problema sugiere que esté prohibido recuperar algo.
4 Capítulo 1
Machine Translated by Google
2
1
3
El paso "truco"
4
5
6
7
8
Figura 1­3: Solución paso a paso del rompecabezas del zorro, el ganso y el maíz
Piense en lo fácil que sería resolver el rompecabezas si se hiciera explícita la
posibilidad de llevar uno de los objetos a la orilla cercana: el granjero tiene un bote
de remos que puede usarse para transferir objetos en cualquier dirección, pero hay
Sólo hay espacio para el granjero y uno de sus tres objetos. Con esa sugerencia a la
vista, más personas resolverían el problema. Esto ilustra un principio importante de
la resolución de problemas: si desconoce todas las acciones posibles que podría
realizar, es posible que no pueda resolver el problema. Podemos referirnos a estas
acciones como operaciones. Al enumerar todas las operaciones posibles, podemos
resolver muchos problemas probando cada combinación de operaciones hasta que
encontremos una que funcione. En términos más generales, al reformular un
problema en términos más formales, a menudo podemos descubrir soluciones que de
otro modo se nos habrían escapado.
Estrategias para la resolución de problemas 5
Machine Translated by Google
Olvidemos que ya conocemos la solución e intentemos plantear este enigma en particular
de manera más formal. Primero, enumeraremos nuestras restricciones. Las limitaciones clave
aquí son:
1. El agricultor sólo podrá llevar un artículo a la vez en la embarcación.
2. No se puede dejar al zorro y al ganso solos en la misma orilla.
3. La oca y el maíz no pueden quedar solos en la misma orilla.
Este problema es un buen ejemplo de la importancia de las restricciones. Si eliminamos
cualquiera de estas limitaciones, el rompecabezas es fácil. Si eliminamos la primera restricción,
podemos simplemente cruzar los tres elementos en un solo viaje. Incluso si solo podemos
llevar dos artículos en el bote, podemos cruzar con el zorro y el maíz y luego regresar por el
ganso. Si eliminamos la segunda restricción (pero dejamos las otras restricciones en su lugar),
solo tenemos que tener cuidado, cruzando primero el ganso, luego el zorro y finalmente el
maíz. Por lo tanto, si olvidamos o ignoramos alguna de las limitaciones, terminaremos con un
Kobayashi Maru.
A continuación, enumeremos las operaciones. Hay varias formas de expresar las operaciones
de este rompecabezas. Podríamos hacer una lista específica de las acciones que creemos que
podemos realizar:
1. Operación: Lleva al zorro al otro lado del río.
2. Operación: Llevar el ganso al otro lado del río.
3. Operación: Llevar el maíz al otro lado del río.
Recuerde, sin embargo, que el objetivo de reformular formalmente el problema es
obtener información para una solución. A menos que ya hayamos resuelto el problema y
descubierto la posible operación “oculta”, llevando el ganso de regreso al lado cercano del
río, no la descubriremos al hacer nuestra lista de acciones. En lugar de ello, deberíamos
intentar que las operaciones sean genéricas o parametrizadas.
1. Operación: Remar el bote de una orilla a la otra.
2. Operación: Si el barco está vacío, cargar un artículo desde la orilla.
3. Operación: Si el barco no está vacío, descargue el artículo a la orilla.
Pensando en el problema en los términos más generales, esta segunda lista de
operaciones nos permitirá resolver el problema sin necesidad de un “¡ajá!” momento relativo
al viaje de regreso a la orilla cercana con el ganso. Si generamos todas las secuencias
posibles de movimientos, terminando cada secuencia una vez que viola una de nuestras
restricciones o alcanza una configuración que hemos visto antes, eventualmente
encontraremos la secuencia de la Figura 1­3 y resolveremos el rompecabezas. La dificultad
inherente del rompecabezas se habrá evitado mediante la reformulación formal de
restricciones y operaciones.
6 Capítulo 1
Machine Translated by Google
Lecciones aprendidas
¿Qué podemos aprender del zorro, el ganso y el maíz?
Replantear el problema de una manera más formal es una excelente técnica para comprender
mejor un problema. Muchos programadores buscan a otros programadores para discutir un problema,
no sólo porque otros programadores pueden tener la respuesta sino también porque articular el
problema en voz alta a menudo desencadena pensamientos nuevos y útiles. Replantear un problema
es como tener esa discusión con otro programador, excepto que estás desempeñando ambas partes.
La lección más amplia es que pensar en el problema puede ser tan productivo, o en algunos
casos más productivo, que pensar en la solución. En muchos casos, el enfoque correcto para la
solución es la solución.
Rompecabezas de azulejos deslizantes
El rompecabezas de fichas deslizantes viene en diferentes tamaños, lo que, como veremos más
adelante, ofrece un mecanismo de resolución particular. La siguiente descripción es para una versión
3×3 del rompecabezas.
PROBLEMA: EL OCHO DESLIZANTE
Una cuadrícula de 3×3 se llena con ocho fichas, numeradas del 1 al 8, y un espacio vacío. Inicialmente, la red está en una configuración
confusa. Una ficha se puede deslizar hacia un espacio vacío adyacente, dejando vacía la ubicación anterior de la ficha. El objetivo es
deslizar los mosaicos para colocar la cuadrícula en una configuración ordenada, desde el mosaico 1 en la esquina superior izquierda.
El objetivo de este problema se muestra en la Figura 1­4. Si nunca antes has intentado un
rompecabezas como este, tómate el tiempo para hacerlo ahora. Se pueden encontrar muchos
simuladores de rompecabezas deslizantes en la Web, pero para nuestros propósitos es mejor si usa
naipes o fichas para crear su propio juego en una mesa. En la Figura 1­5 se muestra una
configuración inicial sugerida.
123
472
456
861
78
35
Figura 1­4: La configuración de
Figura 1­5: Una configuración
la meta en la versión de ocho
inicial particular para el
fichas del rompecabezas de
rompecabezas de fichas deslizantes
fichas deslizantes. El cuadrado
vacío representa el espacio vacío
en el que puede deslizarse una
ficha adyacente.
Estrategias para la resolución de problemas 7
Machine Translated by Google
Este rompecabezas es bastante diferente al del granjero con su zorro, su ganso y su maíz.
La dificultad en ese problema vino por pasar por alto una de las posibles operaciones. En este
problema, eso no sucede. Desde cualquier configuración dada, hasta cuatro fichas pueden estar
adyacentes al espacio vacío, y cualquiera de esas fichas se puede deslizar hacia el espacio vacío.
Eso enumera completamente todas las operaciones posibles.
La dificultad de este problema surge más bien de la larga cadena de operaciones que requiere
la solución. Una serie de operaciones de deslizamiento puede mover algunas fichas a sus posiciones
finales correctas mientras mueve otras fichas fuera de su posición, o puede acercar algunas fichas
a sus posiciones correctas mientras aleja otras. Debido a esto, es difícil decir si alguna operación
en particular lograría avances hacia el objetivo final. Sin poder medir el progreso, es difícil
formular una estrategia. Muchas personas que intentan resolver un rompecabezas de fichas
deslizantes simplemente mueven las fichas al azar, con la esperanza de encontrar una
configuración desde la cual se pueda ver un camino hacia la configuración objetivo.
Sin embargo, existen estrategias para los rompecabezas de fichas deslizantes. Para ilustrar
un enfoque, consideremos el rompecabezas de una cuadrícula más pequeña que es rectangular
pero no cuadrada.
PROBLEMA: LOS CINCO DESLIZANTES
Una cuadrícula de 2×3 se llena con cinco fichas, numeradas del 4 al 8, y un espacio vacío. Inicialmente,
la red está en una configuración confusa. Una ficha se puede deslizar hacia un espacio vacío adyacente,
dejando vacía la ubicación anterior de la ficha. El objetivo es deslizar los mosaicos para colocar la
cuadrícula en una configuración ordenada, desde el mosaico 4 en la parte superior izquierda.
Es posible que hayas notado que nuestras cinco fichas están numeradas del 4 al 8.
del 1 al 5. La razón de esto quedará clara en breve.
Aunque este es el mismo problema básico que el ocho deslizante, es mucho más
más fácil con sólo cinco fichas. Pruebe la configuración que se muestra en la Figura 1­6.
Si juegas con estos mosaicos durante unos minutos,
probablemente encontrarás una solución. Al jugar con rompecabezas
de fichas pequeñas, he desarrollado una habilidad particular. Es
68
esta habilidad, junto con una observación que discutiremos en
breve, la que utilizo para resolver todos los acertijos de fichas
deslizantes.
A mi técnica la llamo tren. Se basa en la observación
de que un circuito de posiciones de mosaicos que incluye el
547
Figura 1­6: Una configuración
inicial particular para un
espacio vacío forma un tren de mosaicos que se puede rotar en
rompecabezas de
cualquier parte del circuito preservando al mismo tiempo el orden
mosaicos deslizantes reducido de 2×3
relativo de los mosaicos. La Figura 1­7 ilustra el tren más pequeño
posible de cuatro posiciones. Desde la primera configuración, el 1 puede deslizarse hacia el
cuadrado vacío, el 2 puede deslizarse hacia el espacio que dejó el 1 y finalmente el 3 puede
deslizarse hacia el espacio que dejó el 2.
Esto deja el espacio vacío adyacente al 1, lo que permite que el tren continúe y, por lo tanto,
que las fichas giren efectivamente en cualquier lugar a lo largo del recorrido del tren.
8 Capítulo 1
Machine Translated by Google
12
3
23
3
1
21
1
32
Figura 1­7: Un “tren”, un camino de fichas que comienza junto al cuadrado vacío y puede deslizarse
como un tren de vagones a través del rompecabezas.
Utilizando un tren podremos mover una serie de fichas manteniendo su relación.
relación positiva. Ahora volvamos a la configuración anterior de cuadrícula de 2×3.
Aunque ninguno de los mosaicos en esta cuadrícula está en su posición final correcta, algunos
mosaicos están adyacentes a los mosaicos que deben bordear en la configuración final.
Por ejemplo, en la configuración final, el 4 estará encima del 7 y actualmente esos mosaicos son
adyacentes. Como se muestra en la Figura 1­8, podemos usar un tren de seis posiciones para llevar el 4
y el 7 a sus posiciones finales correctas. Cuando hacemos eso, los mosaicos restantes son casi
correctos; sólo tenemos que deslizar el 8.
68
45
645
547
7
8
1
2
6
78
3
Figura 1­8: Desde la configuración 1, dos rotaciones a lo largo del “tren” delineado nos llevan a la
configuración 2. A partir de ahí, un solo deslizamiento de mosaico da como resultado el objetivo, la configuración 3.
Entonces, ¿cómo nos permite esta técnica resolver cualquier rompecabezas de fichas deslizantes?
Considere nuestra configuración original de 3×3. Podemos usar un tren de seis posiciones para mover
las fichas 1 y 2 adyacentes de modo que 2 y 3 sean adyacentes, como se muestra en la Figura 1­9.
472
4
861
81
35
327
1
5
6
2
Figura 1­9: Desde la configuración 1, las fichas se giran a lo
largo del "tren" delineado para llegar a la configuración 2.
Esto coloca 1, 2 y 3 en cuadrados adyacentes. Con un tren de ocho posiciones, podemos mover
las fichas 1, 2 y 3 a sus posiciones finales correctas, como se muestra en la Figura 1­10.
Estrategias para la resolución de problemas 9
Machine Translated by Google
4
5
6
123
81
68
327
547
1
2
Figura 1­10: Desde la configuración 1, los mosaicos se giran para
llegar a la configuración 2, en la que los mosaicos 1, 2 y 3 están en
sus posiciones finales correctas.
Observe las posiciones de los mosaicos 4 a 8. Los mosaicos están en la configuración que di para la
cuadrícula de 2×3. Esta es la observación clave. Habiendo colocado las fichas 1 a 3 en sus posiciones
correctas, podemos resolver el resto de la cuadrícula como un rompecabezas separado, más pequeño y más
fácil. Tenga en cuenta que tenemos que resolver una fila o columna completa para que este método
funcione; Si colocamos los mosaicos 1 y 2 en las posiciones correctas pero el mosaico 3 todavía está fuera
de lugar, no hay forma de mover algo a la esquina superior derecha sin mover uno o ambos mosaicos de la
fila superior fuera de su lugar. .
Esta misma técnica se puede utilizar para resolver acertijos de fichas deslizantes aún más grandes.
El tamaño común más grande es un rompecabezas de 15 fichas, una cuadrícula de 4×4. Esto se puede
resolver poco a poco moviendo primero los mosaicos 1 a 4 a su posición correcta, dejando una cuadrícula
de 3×4, y luego moviendo los mosaicos de la columna más a la izquierda, dejando una cuadrícula de 3×3. En
ese punto, el problema se ha reducido a un rompecabezas de 8 fichas.
Lecciones aprendidas
¿Qué lecciones podemos aprender de los rompecabezas de fichas deslizantes?
El número de movimientos de los mosaicos es tan grande que es difícil o imposible planificar una
solución completa para un rompecabezas de mosaicos deslizantes desde la configuración inicial. Sin
embargo, nuestra incapacidad para planificar una solución completa no nos impide elaborar estrategias o
emplear técnicas para resolver sistemáticamente el rompecabezas. Al resolver problemas de programación,
a veces nos enfrentamos a situaciones en las que no podemos ver un camino claro para codificar la solución,
pero nunca debemos permitir que esto sea una excusa para renunciar a la planificación y los enfoques
sistemáticos. Es mejor desarrollar una estrategia que atacar el problema mediante prueba y error.
Desarrollé mi técnica de “tren” jugueteando con un pequeño rompecabezas. A menudo utilizo una
técnica similar en programación. Cuando me enfrento a un problema oneroso, experimento con una versión
reducida del problema.
Estos experimentos frecuentemente producen ideas valiosas.
La otra lección es que a veces los problemas son divisibles de maneras que no son inmediatamente
obvias. Debido a que mover una ficha afecta no solo a esa ficha sino también a los posibles movimientos que
se pueden realizar a continuación, uno podría pensar que un rompecabezas de fichas deslizantes debe
resolverse en un solo paso, no en etapas. Buscar una forma de dividir un problema suele ser un tiempo bien
empleado. Incluso si no puedes encontrar un
10 Capítulo 1
Machine Translated by Google
división limpia, es posible que aprenda algo sobre el problema que le ayude a resolverlo. Al
resolver problemas, trabajar con un objetivo específico en mente siempre es mejor que un
esfuerzo aleatorio, ya sea que logres ese objetivo específico o no.
Sudokus
El juego de sudoku se ha vuelto enormemente popular gracias a sus apariciones en
periódicos y revistas y también como juego basado en la web y por teléfono.
Existen variaciones, pero discutiremos brevemente la versión tradicional.
PROBLEMA: COMPLETAR UN CUADRADO SUDOKU
Una cuadrícula de 9 × 9 está parcialmente llena con un solo dígito (del 1 al 9), y el jugador debe
completar los cuadrados vacíos cumpliendo ciertas restricciones: en cada fila y columna, cada
dígito debe aparecer exactamente una vez, y además, en cada área marcada de 3 × 3, cada
dígito debe aparecer exactamente una vez.
Si has jugado a este juego antes, probablemente ya tengas un conjunto de estrategias
para completar un cuadrado en el tiempo mínimo. Centrémonos en la estrategia inicial
clave observando el cuadrado de muestra que se muestra en la Figura 1­11.
Figura 1­11: Un sencillo sudoku cuadrado
Los sudokus varían en dificultad y su dificultad está determinada por el número
de cuadrados que quedan por llenar. Según esta medida, este es un rompecabezas muy fácil.
Como ya hay 36 cuadrados numerados, solo quedan 45 que deben llenarse para completar
el rompecabezas. La pregunta es: ¿qué cuadrados deberíamos intentar rellenar primero?
Estrategias para la resolución de problemas
11
Machine Translated by Google
Recuerde las limitaciones del rompecabezas. Cada uno de los nueve dígitos debe
aparecer una vez en cada fila, en cada columna y en cada área de 3 × 3 marcada por
bordes gruesos. Estas reglas dictan dónde debemos comenzar nuestros esfuerzos. El
área de 3×3 en el medio del rompecabezas ya tiene números en ocho de sus nueve
cuadrados. Por lo tanto, el cuadrado en el centro sólo puede tener un valor posible, el valor
que aún no está representado en otro cuadrado en esa área de 3×3.
Ahí es donde deberíamos empezar a resolver este rompecabezas. El número que falta en
esa área es 7, así que lo colocaríamos en el cuadrado del medio.
Con ese valor en su lugar, observe que la columna más central ahora tiene valores
en siete de sus nueve cuadrados, lo que deja sólo dos cuadrados restantes, cada uno de
los cuales debe tener un valor que no esté ya en la columna: Los dos números que faltan
son 3 y 9. La restricción en esta columna nos permitiría colocar cualquier número en
cualquier lugar, pero observe que 3 ya está presente en la tercera fila y 9 ya está presente
en la séptima fila. Por lo tanto, las restricciones de fila dictan que 9 vayan en la tercera
fila de la columna del medio y 3 vayan en la séptima fila de la columna del medio. Estos
pasos se resumen en la Figura 1­12.
6
91
7
82
5
913
14
5
6
14
825
5
9
67
5
69
1
6
15
5
69
2
62
4
6
8
825
9
7
67
4
2
913
14
5
39
9
3
2
8
825
67
4
5
62
7
9
7
15
4
7
82
2
913
2
8
6
91
39
3
62
4
7
82
2
3
2
6
91
39
3
5
4
69
7
15
5
3
Figura 1­12: Los primeros pasos para resolver el sudoku de muestra
No resolveremos todo el rompecabezas aquí, pero estos primeros pasos resaltan
la importancia de buscar cuadrados que tengan el menor número de valores posibles;
idealmente, solo uno.
Lecciones aprendidas
La principal lección del sudoku es que debemos buscar la parte más limitada del problema.
Si bien las restricciones son a menudo las que hacen que un problema sea difícil para
empezar (recordemos el zorro, el ganso y el maíz), también pueden simplificar nuestro
pensamiento sobre la solución porque eliminan opciones.
Aunque no analizaremos la inteligencia artificial específicamente en este libro, existe
una regla para resolver ciertos tipos de problemas en inteligencia artificial llamada la
"variable más restringida". Significa que en un problema en el que intentas asignar
diferentes valores a diferentes variables para cumplir con las restricciones, debes comenzar
con la variable que tiene la mayor cantidad de restricciones, o dicho de otra manera, la
variable que tiene el menor número de valores posibles.
12 Capítulo 1
Machine Translated by Google
He aquí un ejemplo de este tipo de pensamiento. Supongamos que un grupo de
compañeros de trabajo quiere ir a almorzar juntos y le han pedido que busque un
restaurante que guste a todos. El problema es que cada uno de los compañeros de
trabajo impone algún tipo de restricción a la decisión del grupo: Pam es vegetariana,
a Todd no le gusta la comida china, etc. Si su objetivo es minimizar la cantidad de
tiempo que lleva encontrar un restaurante, debe comenzar hablando con el compañero
de trabajo que tiene las restricciones más onerosas. Si Bob tiene una serie de alergias
alimentarias generales, por ejemplo, tendría sentido comenzar buscando una lista de
restaurantes donde sepa que puede comer, en lugar de comenzar con Todd, cuyo
disgusto por la comida china puede mitigarse fácilmente.
A menudo se puede aplicar la misma técnica a problemas de programación. Si
una parte del problema está muy limitada, ese es un excelente lugar para comenzar
porque puede progresar sin preocuparse de perder tiempo en un trabajo que luego se
deshará. Un corolario relacionado es que debes comenzar con la parte que es obvia. Si
puedes resolver parte del problema, sigue adelante y haz lo que puedas. Puedes
aprender algo al ver tu propio código que estimulará tu imaginación para resolver el
resto.
La cerradura Quarrasi
Es posible que hayas visto cada uno de los acertijos anteriores antes, pero no deberías
haber visto el último de este capítulo a menos que hayas leído este libro anteriormente,
porque este lo inventé yo mismo. Lea atentamente porque la redacción de este problema
es un poco complicada.
PROBLEMA: ABRIR LA CERRADURA EXTRANJERA
Una raza alienígena hostil, los Quarrasi, ha aterrizado en la Tierra y tú has sido capturado.
Has logrado dominar a tus guardias, a pesar de que son enormes y tienen diez tácticas, pero para
escapar de la nave espacial (aún en tierra), debes abrir la enorme puerta. Las instrucciones para
abrir la puerta, curiosamente, están impresas en inglés, pero aún así no es pan comido. Para abrir la
puerta, debes deslizar el Kratzz con forma de tres barras a lo largo de las vías que conducen desde
el receptor derecho al receptor izquierdo, que se encuentra al final de la puerta, a 10 pies de distancia.
Esto es bastante fácil, pero hay que evitar que se activen las alarmas, que funcionan de la
siguiente manera. En cada Kratzz hay una o más gemas de poder de cristal en forma de estrella
conocidas como Quinicrys. Cada receptor tiene cuatro sensores que se iluminan si el número de
Quinicrys en la columna de arriba es par. Una alarma suena si el número de sensores encendidos es
exactamente uno. Tenga en cuenta que la alarma de cada receptor es independiente: nunca puede
tener exactamente un sensor encendido para el receptor izquierdo o para el derecho. La buena
noticia es que cada alarma está equipada con un supresor, que evita que la alarma suene mientras se
presiona el botón. Si pudieras presionar ambos supresores a la vez, el problema sería fácil, pero no
puedes porque tienes brazos humanos cortos en lugar de largos tentáculos Quarassi.
Teniendo en cuenta todo esto, ¿cómo se desliza el Kratzz para abrir la puerta sin activar ninguna
de las alarmas?
Estrategias para la resolución de problemas 13
Machine Translated by Google
La configuración inicial se muestra en la Figura 1­13, con los tres Kratzz en el
receptor derecho. Para mayor claridad, la Figura 1­14 muestra una mala idea: deslizar el
Kratzz superior hacia el receptor izquierdo provoca un estado de alarma en el receptor derecho.
Podrías pensar que podríamos evitar la alarma con el supresor, pero recuerda que
acabamos de mover el Kratzz superior al receptor izquierdo, por lo que estamos a 10
pies de distancia del supresor del receptor derecho.
Receptor derecho
Receptor izquierdo
10 pies
Cada Kratzz puede
deslizarse de un
receptor al otro.
otro.
Alarma
Supresor
Alarma
Alarma
Alarma
Supresor
Figura 1­13: Configuración inicial para el problema de la cerradura Quarrasi. Debes deslizar las tres barras Kratzz,
actualmente en el receptor derecho, hacia el receptor izquierdo sin activar ninguna de las alarmas. Un sensor
se enciende cuando aparece un número par de Quinicrys en forma de estrella en la columna de arriba, y suena
una alarma si se enciende exactamente un sensor conectado. Los supresores pueden evitar que suene una
alarma, pero sólo para el receptor en el que se encuentra.
Receptor derecho
Receptor izquierdo
10 pies
Alarma
Alarma
Supresor
Alarma
Alarma
Supresor
Figura 1­14: La cerradura Quarrasi en estado de alarma. Simplemente deslizó el Kratzz superior hacia el receptor
izquierdo, por lo que el receptor derecho queda fuera de su alcance. El segundo sensor de la alarma derecha
está encendido porque aparece un número par de Quinicrys en la columna de arriba y suena una alarma cuando
exactamente uno de sus sensores está encendido.
Antes de continuar, tómate un tiempo para estudiar este problema e intenta
desarrollar una solución. Dependiendo de su punto de vista, este problema no es tan
difícil como parece. En serio, ¡piénsalo antes de continuar!
14 Capítulo 1
Machine Translated by Google
Has pensado sobre eso? ¿Pudiste encontrar una solución?
Hay dos caminos posibles hacia una respuesta aquí. El primer camino es prueba y error: intentar
varios movimientos de Kratzz de forma metódica y retroceder a los pasos anteriores cuando llegues a un
estado de alarma hasta encontrar una serie de movimientos que tengan éxito.
El segundo camino es darse cuenta de que el rompecabezas es un truco. Si no has visto
El truco todavía está aquí: esto es sólo el problema del zorro, el ganso y el maíz disfrazado de forma
elaborada. Aunque las reglas para la alarma están escritas de forma general, hay un número limitado
de combinaciones para esta cerradura específica. Con sólo tres Kratzz, sólo nos queda saber qué
combinaciones de Kratzz en un receptor son aceptables. Si etiquetamos los tres Kratzz como superior,
medio e inferior, entonces las combinaciones que crean alarmas son “arriba y medio” y “medio e abajo”.
Si cambiamos el nombre de arriba a zorro, del medio a ganso y de abajo a maíz, entonces las
combinaciones problemáticas son las mismas que en el otro problema, “zorro y ganso” y “ganso y maíz”.
Por tanto, este problema se resuelve de la misma manera que el problema del zorro, el ganso y el
maíz. Deslizamos el Kratzz (ganso) del medio hacia el receptáculo izquierdo.
Luego, deslizamos la parte superior (zorro) hacia la izquierda, sujetando el supresor de la alarma izquierda
mientras colocamos la parte superior (zorro) en su lugar. A continuación, comenzamos a deslizar el medio
(ganso) de regreso al receptáculo derecho. Luego, deslizamos la parte inferior (maíz) hacia la
izquierda, y finalmente, deslizamos la del medio (ganso) hacia la izquierda una vez más, abriendo la cerradura.
Lecciones aprendidas
La principal lección aquí es la importancia de reconocer analogías. Aquí podemos ver que el problema
de la cerradura de Quarrasi es análogo al problema del zorro, el ganso y el maíz. Si descubrimos esa
analogía lo suficientemente temprano, podemos evitar la mayor parte del trabajo del problema traduciendo
nuestra solución del primer problema en lugar de crear una nueva solución. La mayoría de las analogías
en la resolución de problemas no serán tan directas, pero ocurrirán con una frecuencia cada vez mayor.
Si tuvo problemas para ver la conexión entre este problema y el problema del zorro, el ganso y el
maíz, es porque deliberadamente incluí tantos detalles superfluos como fue posible. La historia que
plantea el problema de los Quarrasi es irrelevante, al igual que los nombres de toda la tecnología
alienígena, que sirven para aumentar la sensación de desconocimiento. Además, el mecanismo par/
impar de la alarma hace que el problema parezca más complicado de lo que es. Si observas la posición
real de los Quinicrys, puedes ver que el Kratzz superior y el Kratzz inferior son opuestos, por lo que no
interactúan en el sistema de alarma.
El Kratzz del medio, sin embargo, interactúa con los otros dos.
Nuevamente, si no vio la analogía, no se preocupe. Comenzarás a reconocerlos más después de
que te pongas en alerta para detectarlos.
Técnicas generales de resolución de problemas
Los ejemplos que hemos analizado demuestran muchas de las técnicas clave que se emplean en la
resolución de problemas. En el resto de este libro, veremos problemas de programación específicos y
descubriremos formas de resolverlos, pero primero necesitamos un conjunto general de técnicas y
principios. Algunas áreas problemáticas
Estrategias para la resolución de problemas 15
Machine Translated by Google
Tenemos técnicas específicas, como veremos, pero las reglas siguientes se aplican a casi
cualquier situación. Si los convierte en una parte habitual de su enfoque de resolución de
problemas, siempre tendrá un método para atacar un problema.
Siempre tenga un plan
Esta es quizás la regla más importante. Siempre debes tener un plan, en lugar de involucrarte
en actividades sin dirección.
A estas alturas, debes comprender que tener un plan siempre es posible.
Es cierto que si aún no has resuelto el problema en tu cabeza, entonces no puedes tener
un plan para implementar una solución en código. Eso vendrá más tarde. Sin embargo,
incluso al principio, debes tener un plan sobre cómo vas a encontrar la solución.
Para ser justos, es posible que el plan requiera modificaciones en algún momento del
viaje, o que tengas que abandonar tu plan original y elaborar otro. Entonces, ¿por qué es
tan importante esta regla? El general Dwight D. Eisenhower era famoso por decir:
“Siempre he pensado que los planes son inútiles, pero la planificación es indispensable”.
Quería decir que las batallas son tan caóticas que es imposible predecir todo lo que podría
suceder y tener una respuesta predeterminada para cada resultado. Entonces, en ese
sentido, los planes son inútiles en el campo de batalla (otro líder militar, el prusiano
Helmuth von Moltke, dijo la famosa frase que “ningún plan sobrevive al primer contacto
con el enemigo”). Pero ningún ejército puede tener éxito sin planificación y organización.
A través de la planificación, un general aprende cuáles son las capacidades de su ejército,
cómo trabajan juntas las diferentes partes del ejército, etc.
De la misma manera, siempre debes tener un plan para resolver un problema. Puede
que no sobreviva al primer contacto con el enemigo (puede que lo descarten tan pronto
como empieces a escribir código en tu editor de código fuente), pero debes tener un plan.
Sin un plan, simplemente estás esperando un golpe de suerte, el equivalente al mono
que escribe al azar produciendo una de las obras de Shakespeare.
Los golpes de suerte son poco comunes y los que ocurren aún pueden requerir un plan.
Mucha gente ha oído la historia del descubrimiento de la penicilina: un investigador llamado
Alexander Fleming olvidó cerrar una placa de Petri una noche y por la mañana descubrió
que el moho había inhibido el crecimiento de las bacterias en la placa. Pero Fleming no
estaba sentado esperando un golpe de suerte; había estado experimentando de forma
exhaustiva y controlada y así reconoció la importancia de lo que vio en la placa de Petri. (Si
encontrara moho creciendo en algo que dejé afuera la noche anterior, esto no resultaría en
una contribución importante a la ciencia).
La planificación también permite fijar objetivos intermedios y alcanzarlos.
Sin un plan, sólo tienes un objetivo: resolver todo el problema. Hasta que no hayas resuelto
el problema, no sentirás que has logrado nada. Como probablemente habrá experimentado,
muchos programas no hacen nada útil hasta que están a punto de finalizar. Por lo tanto,
trabajar sólo hacia el objetivo principal conduce inevitablemente a la frustración, ya que no
hay ningún refuerzo positivo de tus esfuerzos hasta el final. Si, en cambio, crea un plan con
una serie de objetivos menores, incluso si algunos parecen tangenciales al problema
principal, logrará un progreso mensurable hacia una solución y sentirá que su tiempo se
ha acabado.
16 Capítulo 1
Machine Translated by Google
sido gastado útilmente. Al final de cada sesión de trabajo, podrá marcar elementos de su plan
y ganar confianza en que encontrará una solución en lugar de sentirse cada vez más
frustrado.
Replantear el problema
Como lo demostró especialmente el problema del zorro, el ganso y el maíz, reformular un
problema puede producir resultados valiosos. En algunos casos, un problema que parece
muy difícil puede parecer fácil cuando se plantea de otra manera o se utilizan términos
diferentes. Replantear un problema es como rodear la base de una colina que debes escalar;
Antes de comenzar a subir, ¿por qué no observas la colina desde todos los ángulos para ver
si hay un camino más fácil para subir?
La reformulación a veces nos muestra que el objetivo no era lo que pensábamos que
era. Una vez leí sobre una abuela que cuidaba a su nieta bebé mientras tejía. Para poder
tejer, la abuela puso al bebé a su lado en un corralito portátil, pero al bebé no le gustaba
estar en el corral y seguía llorando. La abuela probó todo tipo de juguetes para que el corral
fuera más divertido para el bebé, hasta que se dio cuenta de que mantener al bebé en el corral
era sólo un medio para lograr un fin. El objetivo era que la abuela pudiera tejer en paz. La
solución: dejar que el bebé juegue felizmente en la alfombra, mientras la abuela teje
dentro del corral. La reformulación puede ser una técnica poderosa, pero muchos
programadores la omitirán porque no implica directamente escribir código o incluso diseñar
una solución. Ésta es otra razón por la que tener un plan es esencial. Sin un plan, su único
objetivo es tener un código que funcione, y la reformulación le quita tiempo a la escritura
del código.
Con un plan, puede plantear “replantear formalmente el problema” como primer paso; por
lo tanto, completar la reexpresión oficialmente cuenta como avance.
Incluso si una reformulación no conduce a ninguna información inmediata, puede ayudar
De otras maneras. Por ejemplo, si un supervisor o un instructor le ha asignado un problema,
puede llevar su reformulación a la persona que le asignó el problema y confirmar su
comprensión. Además, reformular el problema puede ser un paso previo necesario para
utilizar otras técnicas comunes, como reducir o dividir el problema.
En términos más generales, la reformulación puede transformar áreas problemáticas
enteras. La técnica que empleo para soluciones recursivas, que compartiré en un capítulo
posterior, es un método para reformular problemas recursivos de modo que pueda tratarlos
igual que problemas iterativos.
Divida el problema
Encontrar una manera de dividir un problema en pasos o fases puede facilitarlo mucho. Si
puedes dividir un problema en dos partes, podrías pensar que cada parte sería la mitad de
difícil de resolver que el conjunto original, pero normalmente es incluso más fácil que eso.
He aquí una analogía que le resultará familiar si ya ha visto algoritmos de clasificación
comunes. Supongamos que tiene 100 archivos que necesita colocar en un cuadro en orden
alfabético, y su método básico de alfabetización es efectivamente lo que llamamos ordenación
por inserción: toma uno de los archivos al azar, lo coloca en el cuadro,
Estrategias para la resolución de problemas 17
Machine Translated by Google
luego coloque el siguiente archivo en el cuadro en la relación correcta con el primer archivo,
y luego continúe, colocando siempre el nuevo archivo en su posición correcta con respecto a
los otros archivos, de modo que en un momento dado, los archivos en el cuadro estén
alfabéticos. ­izado. Supongamos que alguien inicialmente separa los archivos en 4 grupos
de aproximadamente el mismo tamaño, A–F, G–M, N–S y T–Z, y le dice que ordene
alfabéticamente los 4 grupos individualmente y luego los suelte uno tras otro en el caja.
Si cada uno de los grupos contuviera alrededor de 25 archivos, entonces uno podría
pensar que alfabetizar 4 grupos de 25 es aproximadamente la misma cantidad de trabajo que
alfabetizar un solo grupo de 100. Pero en realidad es mucho menos trabajo porque el trabajo
que implica insertar un un solo archivo crece a medida que crece el número de archivos ya
archivados; debe mirar cada archivo en el cuadro para saber dónde debe colocarse el
nuevo archivo. (Si dudas de esto, piensa en una versión más extrema—
compare la idea de ordenar 50 grupos de 2 archivos, lo que probablemente podría hacer en
menos de un minuto, con ordenar un solo grupo de 100 archivos).
De la misma manera, dividir un problema a menudo puede reducir la dificultad en un
orden de magnitud. Combinar técnicas de programación es mucho más complicado que
utilizar técnicas únicamente. Por ejemplo, una sección de código que emplea una serie de
sentencias if dentro de un bucle while que a su vez está dentro de un bucle for será más difícil
de escribir (y leer) que una sección de código que emplea todas esas mismas sentencias de
control secuencialmente.
Discutiremos formas específicas de dividir los problemas en los capítulos siguientes,
pero siempre debes estar alerta a la posibilidad. Recuerde que algunos problemas, como
nuestro rompecabezas de fichas deslizantes, a menudo ocultan su posible subdivisión. A
veces, la forma de encontrar las divisiones de un problema es reducir el problema, como
veremos en breve.
Comience con lo que sabe
A los novelistas primerizos a menudo se les aconseja "escribe lo que sabes". Esto no
significa que los novelistas deban intentar elaborar obras únicamente en torno a incidentes
y personas que hayan observado directamente en sus propias vidas; si este fuera el caso,
nunca podríamos tener novelas fantásticas, ficción histórica o muchos otros géneros
populares. Pero significa que cuanto más se aleja un escritor de su propia experiencia, más
difícil puede resultarle escribir.
De la misma manera, al programar, debes intentar comenzar con lo que ya sabes
hacer y trabajar desde ahí. Una vez que haya dividido el problema en partes, por ejemplo,
continúe y complete las partes que ya sepa codificar. Tener una solución parcial que funcione
puede generar ideas sobre el resto del problema. Además, como habrás notado, un tema
común en la resolución de problemas es lograr avances útiles para generar confianza en
que finalmente completarás la tarea. Al comenzar con lo que sabe, genera confianza e
impulso hacia la meta.
La máxima de “empieza con lo que sabes” también se aplica en los casos en los que
no has dividido el problema. Imagine que alguien hiciera una lista completa de todas las
habilidades en programación: escribir una clase de C++, ordenar una lista de números,
encontrar el valor más grande en una lista vinculada, etc. En cada punto de tu desarrollo
como programador, habrá muchas habilidades en esta lista que puedes desarrollar bien,
otras habilidades que puedes usar con esfuerzo y luego otras habilidades que puedes usar con esfuerzo.
18 Capítulo 1
Machine Translated by Google
Aún no lo sé. Un problema en particular puede resolverse por completo con las habilidades
que ya tienes o no, pero debes investigar a fondo el problema utilizando las habilidades que
ya tienes en tu cabeza antes de buscar en otra parte. Si pensamos en las habilidades de
programación como herramientas y en un problema de programación como un proyecto
de reparación del hogar, debería intentar realizar la reparación utilizando las herramientas
que ya tiene en su garaje antes de dirigirse a la ferretería.
Esta técnica sigue los principios que ya hemos comentado. Sigue un plan y da orden a
nuestros esfuerzos. Cuando comenzamos nuestra investigación de un problema aplicando
las habilidades que ya tenemos, podemos aprender más sobre el problema y su solución
final.
Reducir el problema
Con esta técnica, cuando te enfrentas a un problema que no puedes resolver, reduces el
alcance del problema, ya sea agregando o eliminando restricciones, para producir un
problema que sí sabes cómo resolver. Veremos esta técnica en acción en capítulos
posteriores, pero aquí hay un ejemplo básico. Suponga que le dan una serie de coordenadas
en un espacio tridimensional y debe encontrar las coordenadas más cercanas entre sí.
Si no sabes inmediatamente cómo solucionar esto, existen diferentes formas de reducir
el problema para buscar una solución. Por ejemplo, ¿qué pasa si las coordenadas están
en un espacio bidimensional, en lugar de un espacio tridimensional? Si eso no ayuda, ¿qué
pasa si los puntos se encuentran a lo largo de una sola línea, de modo que las
coordenadas son solo números individuales (C++ se duplica, digamos)? Ahora la pregunta
esencialmente es, en una lista de números, encontrar los dos números con la mínima
diferencia absoluta.
O podría reducir el problema manteniendo las coordenadas en un espacio
tridimensional pero con solo tres valores, en lugar de una serie de tamaño arbitrario.
Entonces, en lugar de un algoritmo para encontrar la distancia más pequeña entre dos
coordenadas cualesquiera, es solo una cuestión de comparar la coordenada A con la
coordenada B, luego B con C y luego A con C.
Estas reducciones simplifican el problema de diferentes maneras. La primera reducción
elimina la necesidad de calcular la distancia entre puntos tridimensionales. Tal vez no
sepamos cómo hacerlo todavía, pero hasta que lo descubramos, todavía podemos avanzar
hacia una solución. La segunda reducción, por el contrario, se centra casi por completo en
calcular la distancia entre puntos tridimensionales, pero elimina el problema de encontrar
un valor mínimo en una serie de valores de tamaño arbitrario.
Por supuesto, para resolver el problema original, eventualmente necesitaremos
las habilidades involucradas en ambas reducciones. Aun así, la reducción nos permite
trabajar en un problema más simple incluso cuando no podemos encontrar una manera
de dividir el problema en pasos. En efecto, es como un Kobayashi Maru deliberado, pero
temporal. Sabemos que no estamos trabajando en el problema completo, pero el problema
reducido tiene suficiente en común con el problema completo como para avanzar hacia la
solución definitiva. Muchas veces, los programadores descubren que tienen todas las
habilidades individuales necesarias para resolver el problema y, al escribir código para
resolver cada aspecto individual del problema, ven cómo combinar las distintas piezas de
código en un todo unificado.
Estrategias para la resolución de problemas 19
Machine Translated by Google
Reducir el problema también nos permite identificar exactamente dónde radica la dificultad
restante. Los programadores principiantes a menudo necesitan buscar ayuda de programadores
experimentados, pero esto puede ser una experiencia frustrante para todos los involucrados si
el programador con dificultades no puede describir con precisión la ayuda que necesita. Uno
nunca quiere verse reducido a decir: “Aquí está mi programa y no funciona. ¿Por qué no?"
Usando la técnica de reducción de problemas, uno puede identificar la ayuda necesaria, diciendo
algo como: “Aquí tienes un código que escribí. Como puedes ver, sé cómo encontrar la
distancia entre dos coordenadas tridimensionales y sé cómo comprobar si una distancia es
menor que otra. Pero parece que no puedo encontrar una solución general para encontrar el
par de coordenadas con la distancia mínima”.
Busque analogías
Una analogía, para nuestros propósitos, es una similitud entre un problema actual y un problema
ya resuelto que puede aprovecharse para ayudar a resolver el problema actual.
La similitud puede adoptar muchas formas. A veces significa que los dos problemas son en
realidad el mismo problema. Esta es la situación que tuvimos con el problema del zorro, la oca y
el maíz y el problema de la esclusa de Quarrasi.
La mayoría de las analogías no son tan directas. A veces la similitud afecta sólo a una
parte de los problemas. Por ejemplo, dos problemas de procesamiento de números pueden ser
diferentes en todos los aspectos, excepto que ambos trabajan con números que requieren más
precisión que la proporcionada por los tipos de datos de punto flotante integrados; No podrás
usar esta analogía para resolver todo el problema, pero si ya has descubierto una manera de
manejar el problema de la precisión adicional, puedes manejar el mismo problema de la misma
manera nuevamente.
Aunque reconocer analogías es la forma más importante de mejorar
su velocidad y habilidad para resolver problemas, también es la habilidad más difícil de
desarrollar. La razón por la que es tan difícil al principio es que no puedes buscar analogías hasta
que tengas un almacén de soluciones anteriores a las que puedas hacer referencia.
Aquí es donde los programadores en desarrollo a menudo intentan tomar un atajo,
encontrar código que sea similar al código necesario y modificarlo desde allí. Sin embargo, por
varias razones esto es un error. En primer lugar, si no completa una solución usted mismo, no
la habrá comprendido ni internalizado por completo. En pocas palabras, es muy difícil modificar
correctamente un programa que no comprendes completamente. No es necesario haber escrito
el código para comprenderlo completamente, pero si no hubiera podido escribir el código, su
comprensión será necesariamente limitada. En segundo lugar, cada programa exitoso que
usted escriba es más que una solución a un problema actual; es una fuente potencial de
analogías para resolver problemas futuros. Cuanto más confíe ahora en el código de otros
programadores, más tendrá que depender de él en el futuro. Hablaremos en profundidad sobre
la “buena reutilización” y la “mala reutilización” en el Capítulo 7.
Experimento
A veces la mejor manera de progresar es probar cosas y observar los resultados. Tenga en
cuenta que experimentar no es lo mismo que adivinar. Cuando adivinas, escribes un código
y esperas que funcione, sin tener una fe firme.
20 Capítulo 1
Machine Translated by Google
que así será. Un experimento es un proceso controlado. Plantea una hipótesis sobre lo
que sucederá cuando se ejecute cierto código, lo prueba y comprueba si su hipótesis es
correcta. A partir de estas observaciones, obtienes información que te ayudará a resolver
el problema original.
La experimentación puede resultar especialmente útil cuando se trata de interfaces
de programación de aplicaciones o bibliotecas de clases. Supongamos que está
escribiendo un programa que usa una clase de biblioteca que representa un vector (en
este contexto, una matriz unidimensional que crece automáticamente a medida que
se agregan más elementos), pero nunca antes ha usado esta clase de vector y no está
Estoy seguro de qué sucede cuando se elimina un elemento del vector. En lugar de
seguir adelante resolviendo el problema original mientras las incertidumbres se
arremolinan en su cabeza, podría crear un programa corto e independiente sólo para
jugar con la clase de vector y probar específicamente las situaciones que le preocupan.
Si dedica un poco de tiempo al programa “demostrante vectorial”, podría convertirse
en una referencia para el trabajo futuro con la clase.
Otras formas de experimentación son similares a la depuración. Supongamos
que cierto programa produce un producto que está por detrás de las expectativas:
por ejemplo, si la salida es numérica, los números son los esperados, pero en orden
inverso. Si no ve por qué ocurre esto después de revisar su código, como experimento,
puede intentar modificar el código para hacer que la salida sea al revés deliberadamente
(ejecutar un bucle en la dirección inversa, tal vez).
El cambio resultante, o la falta de cambio, en la salida puede revelar el problema en su
código fuente original o puede revelar una brecha en su comprensión.
De cualquier manera, estás más cerca de una solución.
No te frustres
La técnica final no es tanto una técnica, sino una máxima: no te frustres. Cuando esté
frustrado, no pensará con tanta claridad, no trabajará con tanta eficiencia y todo le llevará
más tiempo y le parecerá más difícil. Peor aún, la frustración tiende a alimentarse de
sí misma, de modo que lo que comienza como una leve irritación termina como una ira
absoluta.
Cuando doy este consejo a nuevos programadores, a menudo me responden que,
si bien en principio están de acuerdo con mi punto, no tienen control sobre sus
frustraciones. ¿No es pedirle a un programador que no se frustre por la falta de éxito
como pedirle a un niño que no grite si pisa una tachuela? La respuesta es no. Cuando
alguien pisa una tachuela, se envía inmediatamente una señal fuerte a través del sistema
nervioso central, donde responden las profundidades inferiores del cerebro. A menos que
sepas que estás a punto de dar el paso, es imposible reaccionar a tiempo para
contrarrestar la respuesta automática del cerebro. Así que dejaremos que el niño se salga
con la suya por gritar.
El programador no está en el mismo barco. A riesgo de parecer un gurú de la
autoayuda, un programador frustrado no responde a un estímulo externo. El programador
frustrado no está enojado con el código fuente en el monitor, aunque puede expresar
su frustración en esos términos. En cambio, el programador frustrado está enojado
consigo mismo. La fuente de la frustración es también el destino, la mente del programador.
Estrategias para la resolución de problemas
21
Machine Translated by Google
Cuando te permites frustrarte (y uso la palabra "permitir" deliberadamente), en
realidad te estás dando una excusa para seguir fallando.
Suponga que está trabajando en un problema difícil y siente que aumenta su frustración.
Horas más tarde, recuerdas una tarde de dientes apretados y lápices rotos con ira y te
dices a ti mismo que habrías logrado un progreso real si hubieras podido calmarte. En
verdad, es posible que haya decidido que ceder a su enojo era más fácil que enfrentar el
problema difícil.
Entonces, en última instancia, evitar la frustración es una decisión que debes tomar.
Sin embargo, hay algunas ideas que puedes emplear y que te ayudarán. En primer lugar,
nunca olvide la primera regla: siempre debe tener un plan y que, si bien escribir código que
resuelva el problema original es el objetivo de ese plan, no es el único paso de ese plan.
Por lo tanto, si tienes un plan y lo estás siguiendo, entonces estás progresando y debes
creerlo. Si ha seguido todos los pasos de su plan original y aún no está listo para comenzar
a codificar, entonces es hora de hacer otro plan.
Además, cuando se trata de frustrarse o tomar un descanso, debes tomarte un
descanso. Un truco consiste en tener más de un problema en el que trabajar, de modo que
si este problema te bloquea, puedas dirigir tus esfuerzos a otra parte.
Tenga en cuenta que si divide el problema con éxito, puede utilizar esta técnica en un solo
problema; simplemente bloquea la parte del problema que te tiene estancado y trabaja
en otra cosa. Si no tienes otro problema que puedas abordar, levántate de la silla y haz
otra cosa, algo que mantenga tu sangre fluyendo pero que no te haga daño al cerebro: sal
a caminar, lava la ropa, sigue tu rutina de estiramiento. (Si te registras para ser
programador y estás sentado frente a una computadora todo el día, ¡te recomiendo que
desarrolles una rutina de estiramiento!). No pienses en el problema hasta que termine
tu descanso.
Ejercicios
Recuerda, para aprender algo de verdad hay que ponerlo en práctica, así que trabaja tantos
ejercicios como puedas. En este primer capítulo, por supuesto, todavía no estamos
hablando de programación, pero aun así, te animo a que pruebes algunos ejercicios.
Piensa en estas preguntas como calentamiento para tus dedos antes de empezar a tocar
música real.
1­1. Pruebe un sudoku de dificultad media (puede encontrarlos en todo el
Web y probablemente en tu periódico local), experimentando con diferentes estrategias y
tomando nota de los resultados. ¿Puedes escribir un plan general para resolver un
sudoku?
1­2. Considere una variante de rompecabezas de fichas deslizantes en la que las fichas están
cubiertas con una imagen en lugar de números. ¿Cuánto aumenta esto la dificultad y por qué?
1­3. Encuentra una estrategia para rompecabezas de fichas deslizantes diferente a la mía.
22 Capítulo 1
Machine Translated by Google
1­4. Busca acertijos antiguos del tipo del zorro, el ganso y el maíz e intenta resolverlos. Muchos de
los grandes acertijos fueron originados o popularizados por Sam Loyd, por lo que puedes
buscar su nombre. Además, una vez que descubras (o te rindas y leas) la solución, piensa
en cómo podrías hacer una versión más sencilla del rompecabezas. ¿Qué tendrías que
cambiar? ¿Las limitaciones o simplemente la redacción?
1­5. Intente escribir algunas estrategias explícitas para otros juegos tradicionales de lápiz y papel,
como los crucigramas. ¿Por dónde deberías empezar? ¿Qué debes hacer cuando estás
estancado? Incluso los juegos de periódicos sencillos, como “Jumble”, son útiles para
reflexionar sobre la estrategia.
Estrategias para la resolución de problemas 23
Machine Translated by Google
Machine Translated by Google
t
l
S
YYPAG
oh
C
A
DY
Y
PUZLES PUROS
En este capítulo, comenzaremos a tratar
con el código real. Si bien se necesitarán
conocimientos intermedios de programación
para capítulos posteriores, las habilidades de
programación requeridas en este capítulo son tan
simples como pueden ser. Eso no significa que todos
estos acertijos serán fáciles, solo que debes poder concentrarte en res
y no la sintaxis de programación. Esto es resolución de problemas en su forma más
pura. Una vez que sepas lo que quieres hacer, traducir tus pensamientos al código
C++ será sencillo. Recuerde que la lectura de este libro, en sí misma, proporciona
un beneficio limitado. Debe resolver cualquier problema que le parezca no trivial
mientras lo analizamos, intentando resolverlo usted mismo antes de leer sobre mi
enfoque. Al final del capítulo, pruebe algunos de los ejercicios, muchos de los
cuales serán extensiones de los problemas que analizamos.
Machine Translated by Google
Revisión de C++ utilizado en este capítulo
Este capítulo utiliza el C++ básico con el que ya debería estar familiarizado, incluidas las
declaraciones de control if, for, while , do­ while y switch. Es posible que todavía no se sienta cómodo
escribiendo código para resolver problemas originales con estas declaraciones; después de
todo, de eso se trata este libro. Sin embargo, debe comprender la sintaxis de cómo se escriben
estas declaraciones o tener a mano una buena referencia de C++.
También debes saber cómo escribir y llamar funciones. Para simplificar las cosas,
usaremos los flujos estándar cin y cout para entrada y salida. Para usar estas secuencias,
incluya el archivo de encabezado necesario, iostream, en su código y agregue instrucciones
de uso para los dos objetos de secuencia estándar:
#incluir <iostream>
usando std::cin;
usando std::cout;
Por motivos de brevedad, estas declaraciones no se mostrarán en los listados de
códigos. Se supone su inclusión en cualquier programa que los utilice.
Patrones de salida
En este capítulo, resolveremos tres problemas principales. Debido a que haremos un uso
extensivo de las técnicas de división y reducción de problemas, cada uno de estos
problemas principales generará varios subproblemas. En esta primera sección, probaremos
una serie de programas que producen resultados con patrones en una forma regular.
Programas como estos desarrollan habilidades para escribir bucles.
PROBLEMA: MEDIO CUADRADO
Escriba un programa que utilice sólo dos declaraciones de salida, cout << "#" y cout << "\n", para
producir un patrón de símbolos hash con la forma de la mitad de un cuadrado perfecto de 5 x 5 (o un
triángulo rectángulo):
#####
####
###
##
#
Aquí hay otro gran ejemplo de la importancia de las restricciones. Si ignoramos el
requisito de que solo podemos usar dos sentencias de salida, una que produzca un único
símbolo hash y otra que produzca un final de línea, podemos escribir un Kobayashi Maru y
resolver este problema de manera trivial. Sin embargo, con esa restricción implementada,
tendremos que usar bucles para resolver este problema.
Puede que ya veas la solución en tu cabeza, pero supongamos que no es así. Una
buena primera arma es la reducción. ¿Cómo podemos reducir este problema a un punto en
el que sea fácil de resolver? ¿Qué pasaría si el patrón fuera un cuadrado entero en lugar de
la mitad de un cuadrado?
26 Capítulo 2
Machine Translated by Google
PROBLEMA: UN CUADRADO
(MITAD DE REDUCCIÓN CUADRADA)
Escriba un programa que utilice sólo dos declaraciones de salida, cout << "#" y cout << "\n", para producir
un patrón de símbolos hash con forma de cuadrado perfecto de 5x5:
#####
#####
#####
#####
#####
Esto puede ser suficiente para ponernos en marcha, pero supongamos que tampoco
supiéramos cómo abordarlo. Podríamos reducir aún más el problema, creando una sola línea de
símbolos hash en lugar del cuadrado.
PROBLEMA: UNA LÍNEA
(MEDIO CUADRADO DE REDUCCIÓN ADICIONAL)
Escriba un programa que utilice sólo dos declaraciones de salida, cout << "#" y cout << "\n", para producir
una línea de cinco símbolos hash:
#####
Ahora tenemos un problema trivial que se puede resolver con un bucle for :
para (int número hash = 1; número hash <= 5; número hash ++) {
cout << "#";
}
cout << "\n";
A partir de aquí, volvemos a la reducción anterior, la forma cuadrada completa. El cuadrado
completo son simplemente cinco repeticiones de la línea de cinco símbolos hash. Sabemos cómo
hacer código repetido; simplemente escribimos un bucle. Entonces podemos convertir nuestro
bucle simple en un bucle doble:
para (int fila = 1; fila <= 5; fila++) {
para (int número hash = 1; número hash <= 5; número hash ++) {
cout << "#";
}
cout << "\n";
}
Hemos colocado todo el código del listado anterior en un nuevo bucle para que se repita
cinco veces, produciendo cinco filas, cada fila una línea de cinco símbolos hash. Nos estamos
acercando a la solución definitiva. ¿Cómo modificamos el código para que produzca el patrón de
medio cuadrado? Si miramos el último listado y lo comparamos con nuestra salida de medio
cuadrado deseada, podemos ver que el problema está en la expresión condicional hashNum
<= 5. Este condicional produce
Rompecabezas puros 27
Machine Translated by Google
la misma línea de cinco símbolos hash en cada fila. Lo que necesitamos es un mecanismo
para ajustar el número de símbolos producidos en cada fila de modo que la primera fila tenga
cinco símbolos, la segunda fila tenga cuatro, y así sucesivamente.
Para ver cómo hacer esto, hagamos otro experimento de programa reducido.
Una vez más, siempre es más fácil trabajar en la parte problemática de un problema de forma
aislada. Por un momento, olvidémonos de los símbolos hash y hablemos sólo de números.
PROBLEMA: CUENTA ATRÁS CONTANDO ARRIBA
Escriba una línea de código que vaya en la posición designada en el bucle en el listado
a continuación. El programa muestra los números del 5 al 1, en ese orden, con cada número
en una línea separada.
para (int fila = 1; fila <= 5; fila++) {
cout <<
expresión << "\n";
}
Debemos encontrar una expresión
que sea 5 cuando la fila es 1, 4 cuando la fila
es 2, y así sucesivamente. Si queremos una expresión que disminuya a medida que aumenta
la fila , nuestro primer pensamiento podría ser colocar un signo menos delante de los
valores de la fila multiplicando la fila por –1. Esto produce números que bajan, pero no los
números deseados. Sin embargo, es posible que estemos más cerca de lo que pensamos.
¿Cuál es la diferencia entre el valor deseado y el valor obtenido al multiplicar la fila por –1?
La Tabla 2­1 resume este análisis.
Tabla 2­1: Cálculo del valor deseado a partir de la variable de fila
Fila
Valor
Fila * –1
diferencia de
Valor deseado
1
5
–1
6
2
4
–2
6
3
3
–3
6
4
2
–4
6
5
1
–5
6
Deseado
La diferencia es un valor fijo, 6. Esto significa que la expresión que necesitamos es * ­1
fila
+ 6. Usando un poco de álgebra, podemos simplificar esto a 6 filas. Probémoslo:
para (int fila = 1; fila <= 5; fila++) {
cout << 6 ­ fila << "\n";
}
Genial, ¡funciona! Si esto no hubiera funcionado, nuestro error probablemente habría
sido menor, debido a los cuidadosos pasos que hemos tomado. De nuevo es muy fácil
28 Capítulo 2
Machine Translated by Google
experimentar con un bloque de código así de pequeño y simple. Ahora tomemos esta
expresión y usémosla para limitar el bucle interno:
para (int fila = 1; fila <= 5; fila++) {
for (int hashNum = 1; hashNum <= 6 ­ fila; hashNum++) {
cout << "#";
}
cout << "\n";
}
Usar la técnica de reducción requiere más pasos para pasar de la descripción al
programa completo, pero cada paso es más fácil. Piense en usar una serie de poleas
para levantar un objeto pesado: debe tirar de la cuerda más lejos para lograr la misma
cantidad de elevación, pero cada tirón es mucho más suave para sus músculos.
Abordemos otro problema de forma antes de continuar.
PROBLEMA: UN TRIÁNGULO LATERAL
Escriba un programa que utilice sólo dos declaraciones de salida, cout << "#" y cout << "\n", para producir un
patrón de símbolos hash con forma de triángulo lateral:
#
##
###
####
###
##
#
No vamos a seguir todos los pasos que utilizamos en el problema anterior
porque no es necesario. Este problema del “triángulo lateral” es análogo al problema
de la “mitad de un cuadrado”, por lo que podemos usar lo que hemos aprendido del
último en el primero. ¿Recuerdas la máxima de “empieza con lo que sabes”?
Comencemos enumerando habilidades y técnicas del problema “La mitad de un
cuadrado” que se pueden aplicar a este problema. Sabemos cómo:
Mostrar una fila de símbolos de una longitud particular usando un bucle
Mostrar una serie de filas usando bucles anidados
Cree un número variable de símbolos en cada fila usando un método algebraico.
expresión en lugar de un valor fijo
Descubra la expresión algebraica correcta a través de la experimentación y
análisis
La Figura 2­1 resume nuestra posición actual. La primera fila muestra el problema
anterior "La mitad de un cuadrado". Vemos el patrón deseado de símbolos hash (a), el
patrón de líneas (b), el patrón de cuadrados (c) y la secuencia numérica (d) que
transformará el patrón cuadrado en el patrón de medio cuadrado. La segunda fila
muestra el problema actual del "Triángulo lateral". Nuevamente vemos el patrón
deseado (e), la línea (f), un patrón de rectángulo (g) y una secuencia numérica (h).
Puros rompecabezas 29
Machine Translated by Google
En este punto, no tendremos
problemas para producir (f) porque es
casi lo mismo que (b). Y deberíamos poder
producir (g) porque es simplemente (c)
con más filas y un símbolo menos por fila.
Finalmente, si alguien nos diera la
expresión algebraica que produciría la
secuencia numérica (h), no tendríamos
dificultades para crear el patrón deseado
(e).
Por tanto, la mayor parte del trabajo
mental necesario para crear una solución
al problema del “triángulo lateral” ya se
ha realizado. Además, sabemos
exactamente qué trabajo mental queda:
descubrir una expresión para producir la
secuencia numérica (h).
Entonces es ahí donde debemos dirigir
nuestra atención. Podríamos tomar el
#####
#####
5
####
#####
4
###
#####
3
##
#####
2
#
#####
1
(C)
(d)
#
####
1
##
####
2
###
####
3
(a)
####
#####
(b)
####
4
###
####
3
##
####
2
#
####
1
(gramo)
(h)
(Es)
####
(F)
Figura 2­1: Varios componentes necesarios para
resolver los problemas de forma.
código terminado para el problema “La mitad
de un cuadrado” y experimentar hasta que podamos producir la secuencia numérica
deseada o adivinar y hacer una tabla como la Tabla 2­1 para ver si eso estimula nuestra
creatividad.
Intentemos experimentar esta vez. En el problema “La mitad de un cuadrado”, restar
la fila de un número mayor funcionó bien, así que veamos qué números obtenemos al
ejecutar la fila en un bucle del 1 al 7 y restar la fila de 8. El resultado se muestra en
Figura 2­2 (b). Eso no es lo que queremos. ¿A dónde vamos desde aquí? En el
problema anterior, necesitábamos un número que bajara en lugar de subir, así que
restamos nuestra variable de bucle de un número mayor. En este problema, primero
debemos subir y luego bajar. ¿Tendría sentido restarle a un número del medio? Si
reemplazamos la fila 8 en el código anterior con la fila 4, obtenemos el resultado en la
Figura 2­2 (c). Eso tampoco está bien, pero parece que podría ser un patrón útil si no
miramos los signos menos de los últimos tres números. ¿Qué pasaría si usáramos la
función de valor absoluto para eliminar esos signos menos? La expresión abs(4 ­ fila)
produce los resultados de la Figura 2­2 (d). Estamos tan cerca ahora que ¡casi puedo
saborearlo! Es sólo que primero bajamos y luego subimos cuando necesitamos subir
primero y luego bajar. Pero, ¿cómo pasamos de la secuencia numérica que tenemos a
la secuencia numérica que necesitamos?
Intentemos observar los números de la Figura 2­2 (d) de una manera diferente.
¿Qué pasa si contamos los espacios vacíos en lugar de las marcas, como se muestra
en la Figura 2­2 (e)? La columna (d) es el patrón correcto de valores si contamos los
espacios vacíos. Para obtener el número correcto de marcas de almohadilla, piense
que cada fila tiene cuatro cuadros y luego reste el número de espacios vacíos. Si cada
fila tiene cuatro cuadros de los cuales abs(4 ­ fila) son espacios vacíos, entonces el
número de cuadros con marcas de almohadilla vendrá dado por 4 ­ abs(4 ­ fila). Eso
funciona. Conéctelo y pruébelo.
30 Capítulo 2
Machine Translated by Google
1
7
3
3
#
2
6
2
2
##
3
5
1
1
###
4
4
0
0
####
3
3
­1
1
###
2
2
­2
2
##
1
1
­3
3
#
(a)
(b)
(C)
(d)
(Es)
Figura 2­2: Varios componentes necesarios para resolver el problema
Problema del “triángulo lateral”
Hemos evitado la mayor parte del trabajo para este problema mediante analogía y
hemos resuelto el resto mediante experimentación. Este doble golpe es un gran enfoque
cuando un nuevo problema es muy similar a otro que ya puedes resolver.
Procesamiento de entrada
Los programas anteriores sólo produjeron resultados. Cambiemos las cosas y probemos
programas que se centren exclusivamente en procesar la entrada. Cada uno de estos
programas comparte una restricción: la entrada se leerá carácter por carácter y el programa
debe procesar cada carácter antes de leer el siguiente. En otras palabras, los programas
no almacenarán los caracteres en una estructura de datos para su posterior procesamiento,
sino que los procesarán sobre la marcha.
En este primer problema, realizaremos la validación del número de identificación. En
el mundo moderno, casi todo tiene un número de identificación, como un ISBN o un
número de cliente. A veces, estos números deben ingresarse a mano, lo que introduce la
posibilidad de error. Si un número ingresado por error no coincide con ningún número
de identificación válido, el sistema puede rechazarlo fácilmente. Pero ¿qué pasa si el
número es incorrecto pero aún es válido? Por ejemplo, ¿qué pasa si un cajero, al intentar
acreditar su cuenta por la devolución de un producto, ingresa el número de cuenta de otro
cliente? El otro cliente recibiría su crédito.
Para evitar esta situación se han desarrollado sistemas para detectar errores en los
números de identificación. Funcionan ejecutando el número de identificación a través
de una fórmula que genera uno o más dígitos adicionales, que pasan a formar parte de
un número de identificación ampliado. Si se cambia alguno de los dígitos, la parte original
del número y los dígitos adicionales ya no coincidirán y el número puede rechazarse.
PROBLEMA: VALIDACIÓN DE LA SUMA DE VERIFICACIÓN DE LUHN
La fórmula de Luhn es un sistema ampliamente utilizado para validar números de identificación.
Usando el número original, duplica el valor de cada dos dígitos. Luego sume los valores de los dígitos
individuales (si un valor duplicado ahora tiene dos dígitos, sume los dígitos individualmente). El
número de identificación es válido si la suma es divisible por 10.
Escriba un programa que tome un número de identificación de longitud arbitraria y
determine si el número es válido según la fórmula de Luhn. El programa debe procesar cada
carácter antes de leer el siguiente.
Rompecabezas puros 31
Machine Translated by Google
El proceso suena un poco complicado, pero un ejemplo aclarará todo. Nuestro
programa solo validará un número de identificación, no creará el dígito de control.
Repasemos ambos extremos del proceso: calcular un dígito de control y validar el
resultado. Este proceso se demuestra en la Figura 2­3. En el inciso (a), calculamos el
dígito de control. El número de identificación original, 176248, se muestra en el cuadro
de línea discontinua. Cada dos dígitos, comenzando desde el dígito más a la
derecha del número original (que, después de la suma del dígito de control, será el
segundo más a la derecha), se duplica. Luego se suma cada dígito. Tenga en cuenta
que cuando al duplicar un dígito se obtiene un número de dos dígitos, cada uno de esos
dígitos se considera por separado. Por ejemplo, cuando se duplica 7 para producir
14, no se suma 14 a la suma de comprobación, sino 1 y 4 individualmente. En este
caso, la suma de verificación es 27, por lo que el dígito de verificación es 3 porque ese
es el valor del dígito que haría que la suma total sea 30.
Recuerde, la suma de verificación del número final debe ser divisible por 10; en otras
palabras, debería terminar en 0.
1
7
6
2
×2
3
×2
4
+4
8
×2
14
1+1+
4
dieciséis
4 6 ++++ 4
6 = 27 +
1
= 30 3
(a)
1
7
6
2
×2
×2
14
1+1+
4
4 6 ++++ 4
3
×2
4
+4
8
dieciséis
1
6 + 3 = 30
(b)
Figura 2­3: Fórmula de suma de comprobación de Luhn
En la parte (b), validamos el número 1762483, que ahora incluye el dígito de
control. Este es el proceso que usaremos para este problema. Como antes, duplicamos
cada segundo dígito, comenzando con el dígito a la derecha del dígito de control, y
sumamos los valores de todos los dígitos, incluido el dígito de control, para determinar la
suma de control. Debido a que la suma de verificación es divisible por 10, este número se valida.
32 Capítulo 2
Machine Translated by Google
Rompiendo el problema
El programa que resolverá este problema tiene varios problemas distintos que tendremos
que abordar. Un problema es la duplicación de dígitos, lo cual es complicado porque los
dígitos duplicados se determinan desde el extremo derecho del número de identificación.
Recuerde, no vamos a leer y almacenar todos los dígitos y luego
proceso. Vamos a procesar sobre la marcha. El problema es que obtendremos los dígitos de
izquierda a derecha, pero realmente los necesitamos de derecha a izquierda para saber qué
dígitos duplicar. Sabríamos qué dígitos duplicar si supiéramos cuántos dígitos hay en el
número de identificación, pero no lo sabemos porque el problema establece que el número
de identificación tiene una longitud arbitraria. Otro problema es que los números duplicados
del 10 en adelante deben tratarse según sus dígitos individuales. Además, debemos
determinar cuándo hemos leído el número de identificación completo. Finalmente, tenemos
que descubrir cómo leer el número dígito por dígito. En otras palabras, el usuario va a ingresar
un número largo, pero queremos leerlo como si los dígitos se ingresaran como números
separados.
Como siempre queremos tener un plan, debemos hacer una lista de estos
problemas y abordarlos uno por uno:
Saber qué dígitos duplicar
Tratar los números duplicados 10 y mayores según sus características individuales.
dígitos
Saber que hemos llegado al final del número.
Leer cada dígito por separado
Para resolver problemas, trabajaremos en piezas individuales antes de escribir una
solución final. Por lo tanto, no es necesario trabajar en estos temas en ningún orden en
particular. Comience con el tema que parezca más fácil o, si quiere un desafío, el que
parezca más difícil. O simplemente comience con el que sea más interesante.
Comencemos abordando los dígitos duplicados que son 10 o más. Ésta es una situación
en la que las limitaciones del problema hacen que las cosas sean más fáciles en lugar de
dificultarlas. Calcular la suma de los dígitos de un número entero arbitrario podría suponer
una buena cantidad de trabajo por sí solo. Pero ¿cuál es el rango de valores posibles aquí?
Si comenzamos con dígitos individuales del 0 al 9 y los duplicamos, el valor máximo es 18.
Por lo tanto, sólo hay dos posibilidades. Si el valor duplicado es de un solo dígito, entonces
no hay nada más que hacer. Si el valor duplicado es 10 o mayor, entonces debe estar en el
rango de 10 a 18 y, por lo tanto, el primer dígito es siempre 1.
Hagamos un experimento de código rápido para confirmar este enfoque:
dígito entero;
cout << "Ingrese un número de un solo dígito, 0­9: ";
cin >> dígito;
int dígito duplicado = dígito * 2;
suma interna;
if (Dígito duplicado >= 10) suma = 1 + Dígito duplicado % 10;
else suma = dígito duplicado;
"
<< soy << "\n";
cout << "Suma de dígitos en un número duplicado:
Rompecabezas puros 33
Machine Translated by Google
NOTA El operador % se denomina operador de módulo. Para números enteros positivos, devuelve el
Resto de la división de números enteros. Por ejemplo, 12 % 10 sería 2 porque después de dividir 10
entre 12 sobra el 2.
Este es un código sencillo: el programa lee el dígito, lo duplica
número duplicado
y genera la suma
un número duplicado mayor que 10
, suma los dígitos del
. El corazón del experimento es el cálculo de la suma de
. Al igual que con el cálculo del número de marcas
necesarias para una fila particular en nuestros problemas de formas, aislar este cálculo en un
programa corto propio facilita la experimentación. Incluso si al principio no encontramos la fórmula
correcta, seguramente la encontraremos rápidamente.
Antes de eliminar este problema de nuestra lista, conviertamos este código en una función
breve que podamos usar para simplificar futuros listados de códigos:
int doubleDigitValue(int dígito) {
int dígito duplicado = dígito * 2;
suma interna;
si (dobleDígito > 10) suma = 1 + dobledoDígito % 10;
else suma = dígito duplicado;
suma de devolución;
}
Ahora trabajemos en la lectura de los dígitos individuales del número de identificación.
ber. Nuevamente, podríamos abordar un problema diferente a continuación si quisiéramos, pero
creo que este problema es una buena opción porque nos permitirá escribir el número de
identificación de forma natural al probar las otras partes del problema.
Si leemos el número de identificación como un tipo numérico (int, por ejemplo), obtendremos
un número largo y tendremos mucho trabajo por delante. Además, hay un límite en cuanto al tamaño
de un número entero que podemos leer, y la pregunta dice que el número de identificación tiene
una longitud arbitraria. Por tanto, tendremos que leer carácter por carácter. Esto significa que
debemos asegurarnos de saber cómo leer un carácter que representa un dígito y convertirlo en un
tipo de número entero con el que podamos trabajar matemáticamente. Para ver qué pasaría si
tomáramos el valor del carácter y lo usáramos directamente en una expresión entera, eche un
vistazo a la siguiente lista, que incluye resultados de muestra.
dígito de carácter;
cout << "Ingrese un número de un dígito: ";
dígito = cin.get();
int suma = dígito;
"
<< soy << "? \n";
cout << "Es la suma de dígitos
Ingrese un número de un dígito: 7
¿La suma de los dígitos es 55?
Tenga en cuenta que utilizamos el método get
porque el operador de extracción básico
(como en cin >> dígito) omite espacios en blanco. Eso no es un problema aquí, pero como verá,
causaría problemas más adelante. En el ejemplo de entrada y salida
, verá el problema. Todos
los datos informáticos son esencialmente numéricos, por lo que los caracteres individuales se
representan mediante códigos de caracteres enteros. Diferentes sistemas operativos
34 Capítulo 2
Machine Translated by Google
Puede utilizar diferentes sistemas de códigos de caracteres, pero en este texto nos centraremos
en el sistema ASCII común. En este sistema, el carácter 7 se almacena como el valor del código
de carácter 55, por lo que cuando tratamos el valor como un número entero, lo que obtenemos es 55.
Necesitamos un mecanismo para convertir el carácter 7 en el número entero 7.
PROBLEMA: CONVERTIR DÍGITOS DE CARÁCTER A ENTERO
Escriba un programa que lea un carácter del usuario que represente un dígito, del 0 al 9. Convierta el carácter
al entero equivalente en el rango del 0 al 9 y luego genere el número entero para demostrar el resultado.
En los problemas de forma de la sección anterior, teníamos una variable con un rango de
valores que queríamos convertir a otro rango de valores. Hicimos una tabla con columnas para los
valores originales y los valores deseados y luego verificamos la diferencia entre los dos. Este
es un problema análogo y podemos usar nuevamente la idea de la tabla, como en la Tabla 2­2.
Tabla 2­2: Códigos de caracteres y valores enteros deseados
Carácter Código de carácter Entero deseado
Diferencia
0
48
0
48
1
49
1
48
2
50
2
48
3
51
3
48
4
52
4
48
5
53
5
48
6
54
6
48
7
55
7
48
8
56
8
48
9
57
9
48
La diferencia entre el código de carácter y el número entero deseado es siempre 48, por
lo que lo único que debemos hacer es restar ese valor. Es posible que hayas notado que este es
el valor del código de caracteres para el carácter cero, 0. Esto siempre será cierto porque los
sistemas de códigos de caracteres siempre almacenan los caracteres de los dígitos en orden,
comenzando desde 0. Por lo tanto, podemos crear un código más general y más legible. ,
solución restando el carácter 0 en lugar de usar un valor predeterminado, como 48:
dígito de carácter;
cout << "Ingrese un número de un dígito: ";
cin >> dígito;
int suma = dígito ­ '0';
"
<< soy << "? \n";
cout << "Es la suma de dígitos
Rompecabezas puros 35
Machine Translated by Google
Ahora podemos pasar a determinar qué dígitos duplicar. Es posible que se
requieran varios pasos para resolver esta parte del problema, así que intentemos
reducirlo. ¿Qué pasaría si inicialmente nos limitáramos a un número de longitud fija?
Esto confirmaría nuestra comprensión de la fórmula general y al mismo tiempo avanzaría
hacia el objetivo final. Intentemos limitar la longitud a seis; esto es lo suficientemente
largo como para ser una buena representación del desafío general.
PROBLEMA: VALIDACIÓN DE LA SUMA DE VERIFICACIÓN DE LUHN,
LONGITUD FIJA
Escriba un programa que tome un número de identificación (incluido su dígito de
control) de longitud seis y determine si el número es válido según la fórmula de
Luhn. El programa debe procesar cada carácter antes de leer el siguiente.
Como antes, podemos reducir aún más para que comenzar sea lo más fácil posible.
¿Qué pasaría si cambiáramos la fórmula para que ninguno de los dígitos se duplique?
Entonces el programa sólo tiene que leer los dígitos y sumarlos.
PROBLEMA: VALIDACIÓN SIMPLE DE SUMA DE VERIFICACIÓN,
LONGITUD FIJA
Escriba un programa que tome un número de identificación (incluido su dígito de
control) de longitud seis y determine si el número es válido mediante una fórmula
simple donde se suman los valores de cada dígito y se verifica el resultado para ver si
es divisible por 10. El programa debe procesar cada carácter antes de leer el siguiente.
Como sabemos cómo leer un dígito individual como un carácter, podemos resolver
este problema de suma de comprobación simple y de longitud fija con bastante facilidad.
Sólo necesitamos leer seis dígitos, sumarlos y determinar si la suma es divisible
por 10.
dígito de carácter;
suma de comprobación int = 0;
cout << "Ingrese un número de seis dígitos: ";
for (int posición = 1; posición <= 6; posición ++) {
cin >> dígito;
suma de comprobación += dígito ­ '0';
}
cout << "La suma de comprobación es
"
<< suma de comprobación << ". \n";
si (suma de comprobación % 10 == 0) {
cout << "La suma de comprobación es divisible por 10. Válido. \n";
} demás {
cout << "La suma de comprobación no es divisible por 10. No válida. \n";
}
36 Capítulo 2
Machine Translated by Google
A partir de aquí, necesitamos agregar la lógica para la fórmula de validación de
Luhn real, lo que significa duplicar cada dos dígitos comenzando desde el segundo
dígito de la derecha. Como actualmente nos limitamos a números de seis dígitos,
debemos duplicar los dígitos en las posiciones uno, tres y cinco, contando desde la
izquierda. En otras palabras, duplicamos el dígito si la posición es impar. Podemos
identificar posiciones pares e impares usando el operador de módulo (%) porque la
definición de un número par es que es divisible por dos. Entonces, si el resultado de
la expresión posición % 2 es 1, la posición es impar y debemos duplicarla. Es
importante recordar que aquí duplicar significa duplicar el dígito individual y también
sumar los dígitos del número duplicado si la duplicación da como resultado un número
10 o mayor. Aquí es donde nuestra función anterior realmente ayuda. Cuando
necesitamos duplicar un dígito según la fórmula de Luhn, simplemente lo enviamos
a nuestra función y usamos el resultado. Juntando esto, simplemente cambie el
código dentro del bucle for del listado anterior:
for (int posición = 1; posición <= 6; posición++) {
cin >> dígito;
if (posición % 2 == 0) suma de comprobación += dígito ­ '0';
else suma de comprobación += doubleDigitValue(dígito ­ '0');
}
Hemos logrado mucho en este problema hasta ahora, pero aún quedan un
par de pasos por recorrer antes de que podamos escribir el código para números de
identificación de longitud arbitraria. Para resolver en última instancia este problema,
necesitamos dividir y conquistar. Supongamos que te pido que modifiques el código
anterior para números de 10 o 16 dígitos. Eso sería trivial: sólo tendrías que cambiar
el 6 usado como límite superior del bucle por otro valor. Pero supongamos que le pido
que valide números de siete dígitos. Eso requeriría una pequeña modificación
adicional porque si el número de dígitos es impar y duplicamos cada dígito a partir del
segundo de la derecha, el primer dígito de la izquierda ya no se duplica. En este
caso, necesitas duplicar las posiciones pares: 2, 4, 6, etc. Dejando de lado esa
cuestión por el momento, descubramos cómo manejar cualquier número de longitud
par.
El primer problema al que nos enfrentamos es determinar cuándo hemos llegado
al final del número. Si el usuario ingresa un número de varios dígitos y presiona
ENTER y leemos la entrada carácter por carácter, ¿qué carácter se lee después del
último dígito? En realidad, esto varía según el sistema operativo, pero simplemente
escribiremos un experimento:
cout << "Ingrese un número: ";
dígito de carácter;
mientras (verdadero) {
dígito = cin.get();
cout << int(dígito) << " ";
}
Puros rompecabezas 37
Machine Translated by Google
Este bucle se ejecuta para siempre, pero cumple su función. Escribí el número 1234
y presioné ENTER. El resultado fue 49 50 51 52 10 (basado en ASCII; esto variará según
el sistema operativo). Por lo tanto, 10 es lo que estoy buscando. Con esa información en
mano, podemos reemplazar el bucle for en nuestro código anterior con un bucle while :
dígito de carácter;
suma de comprobación int = 0;
posición interna = 1;
cout << "Ingrese un número con un número par de dígitos: ";
dígito = cin.get();
mientras (dígito!= 10) {
if (posición % 2 == 0) suma de comprobación += dígito ­ '0';
else suma de comprobación += 2 * (dígito ­ '0');
dígito = cin.get();
posición++;
}
"
cout << "La suma de comprobación
<< suma de comprobación << ". \n";
es si (suma de comprobación % 10 == 0) {
cout << "La suma de comprobación es divisible por 10. Válido. \n";
} demás {
cout << "La suma de comprobación no es divisible por 10. No válida. \n";
}
En este código, la posición ya no es la variable de control en un bucle for , por lo que
debemos inicializar
e incrementarla por separado . El bucle ahora está controlado por
la expresión condicional , que busca el valor del código de carácter que señala el final
de la línea. Debido a que necesitamos un valor para verificar la primera vez que
recorremos el ciclo, leemos el primer valor antes de que comience el ciclo
y luego leemos
cada valor subsiguiente dentro del ciclo , después del código de procesamiento.
Nuevamente, este código manejará un número de cualquier longitud par. Para
manejar un número de cualquier longitud impar, solo necesitaríamos modificar el código
de procesamiento, invirtiendo la lógica de la condición de declaración if
para duplicar
los números en las posiciones pares, en lugar de las posiciones impares.
Esto, al menos, agota todas las posibilidades. La longitud del número de
identificación debe ser par o impar. Si supiéramos la longitud de antemano, sabríamos si
duplicar las posiciones pares o impares del número. Sin embargo, no tenemos esa
información hasta que hayamos llegado al final del número. ¿Es imposible una solución
dadas estas limitaciones? Si sabemos cómo resolver el problema para un número impar de
dígitos y para un número par de dígitos pero no sabemos cuántos dígitos hay en el número
hasta que lo hayamos leído por completo, ¿cómo podemos resolver este problema?
Es posible que ya vea la respuesta a este problema. Si no lo hace, no es porque la
respuesta sea difícil sino porque está oculta en los detalles. Lo que podríamos usar aquí es
una analogía, pero hasta ahora no hemos visto una situación análoga. En lugar de eso,
haremos nuestra propia analogía. Hagamos un problema que se refiera explícitamente a
esta misma situación y veamos si mirarlo cara a cara nos ayuda a encontrar una solución.
Despeja tu mente de ideas preconcebidas basadas en el trabajo hasta el momento y lee
el siguiente problema.
38 Capítulo 2
Machine Translated by Google
PROBLEMA: POSITIVO O NEGATIVO
Escriba un programa que lea 10 números enteros del usuario. Una vez ingresados todos los números, el usuario puede solicitar
mostrar el recuento de números positivos o el recuento de números negativos.
Este es un problema simple, que no parece tener ninguna complicación. Sólo necesitamos
una variable que cuente los números positivos y otra variable que cuente los números negativos.
Cuando el usuario especifica la solicitud al final del programa, solo necesitamos consultar la variable
adecuada para la respuesta:
número entero;
int recuentopositivo = 0;
int número negativo = 0;
para (int i = 1; i <= 10; i++) {
cin >> número;
if (número > 0) positivCount++;
if (número < 0) negativoCount++;
}
respuesta de carácter;
cout << "¿Quieres el recuento (p)positivo o (n)negativo?";
cin >> respuesta;
if (respuesta == 'p') cout
"
<< "El recuento positivo es if
<< positivoCount << "\n";
(respuesta == 'n')
"
out << "El recuento negativo es
<< númeronegativo << "\n";
Esto muestra el método que necesitamos utilizar para el problema de la suma de comprobación
de Luhn: realice un seguimiento de la suma de comprobación en ejecución en ambos sentidos,
como si el número de identificación tuviera una longitud impar y nuevamente como si fuera una
longitud par. Cuando lleguemos al final del número y descubramos la longitud real, tendremos la
suma de verificación correcta en una variable u otra.
Juntando las piezas
Ahora hemos marcado todo lo que estaba en nuestra lista original de “tareas pendientes”. Es hora
de juntar todo y resolver este problema. Debido a que hemos resuelto todos los subproblemas por
separado, sabemos exactamente lo que debemos hacer y podemos usar nuestros programas
anteriores como referencia para producir el resultado final rápidamente:
dígito de carácter;
int longitudimparSuma de verificación = 0;
int inclusoLongitudChecksum = 0;
posición interna = 1;
cout << "Ingrese un número: ";
dígito = cin.get();
mientras (dígito! = 10) {
Puros rompecabezas 39
Machine Translated by Google
si (posición % 2 == 0) {
oddLengthChecksum += doubleDigitValue(dígito ­ '0');
EvenLengthChecksum += dígito ­ '0';
} demás {
oddLengthChecksum += dígito ­ '0';
evenLengthChecksum += doubleDigitValue(dígito ­ '0');
}
dígito = cin.get();
posición++;
}
suma de comprobación interna;
if ((posición ­ 1) % 2 == 0) suma de comprobación = suma de comprobación de longitud uniforme;
else suma de comprobación = oddLengthChecksum;
"
cout << "La suma de comprobación es << suma de comprobación <<". \norte";
si (suma de comprobación % 10 == 0) {
cout << "La suma de comprobación es divisible por 10. Válido. \n";
} demás {
cout << "La suma de comprobación no es divisible por 10. No válida. \n";
}
Tenga en cuenta que cuando comprobamos si la longitud del número de entrada es
par o impar , restamos 1 de la posición. Hacemos esto porque el último carácter que
leemos en el bucle será el final de la línea final, no el último dígito del número. También
podríamos haber escrito la expresión de prueba como (posición % 2 == 1), pero eso es
más confuso de leer. En otras palabras, es mejor decir "si la posición ­ 1 es par, use la
suma de verificación par" que "si la posición es impar, use la suma de verificación par" y
debe recordar por qué eso tiene sentido.
Esta es la lista de códigos más larga que hemos visto hasta ahora, pero no necesito
anotar todo el código y describir cómo funciona cada parte porque ya has visto cada
parte de forma aislada. Este es el poder de tener un plan.
Sin embargo, es importante señalar que mi plan no es necesariamente el suyo . Es
probable que los problemas que vi en la descripción original del problema y los pasos que
tomé para solucionarlos difieran de lo que usted habría visto y hecho. Su experiencia
como programador y los problemas que ha completado con éxito determinan qué partes
del problema son triviales o difíciles y, por lo tanto, qué pasos debe seguir para resolver
el problema. Es posible que haya habido un punto en la sección anterior en el que tomé
lo que parecía un desvío innecesario para descubrir algo que ya era obvio para usted.
Por el contrario, es posible que haya habido un punto en el que me salté ágilmente algo
que era complicado para ti. Además, si hubiera trabajado en esto usted mismo, podría
haber creado un programa igualmente exitoso que se veía bastante diferente al mío. No
existe una única solución "correcta" para un problema, ya que cualquier programa que
cumpla todas las restricciones cuenta como una solución, y para cualquier solución, no
existe una única manera "correcta" de alcanzarla.
Al ver todos los pasos que tomamos para llegar a la solución, junto con la relativa
brevedad del código final, es posible que se sienta tentado a intentar recortar pasos en su
propio proceso de resolución de problemas. Yo advertiría contra este impulso. Siempre
es mejor dar más pasos que intentar hacer demasiado a la vez, incluso si algunos pasos
parecen triviales. Recuerde cuáles son los objetivos en la resolución de problemas. El
objetivo principal es, por supuesto, encontrar un programa que resuelva el problema planteado.
40 Capítulo 2
Machine Translated by Google
y cumple con todas las limitaciones. El objetivo secundario es encontrar ese
programa en el mínimo de tiempo. Minimizar la cantidad de pasos no es un
objetivo y nadie tiene que saber cuántos pasos diste. Considere intentar llegar a la
cima de una colina empinada que tiene un camino poco profundo pero largo y
sinuoso. Ignorar el camino y subir la colina directamente desde la base hasta la
cima ciertamente requerirá menos pasos que seguir el camino, pero ¿es más
rápido? El resultado más probable de un ascenso directo es que te rindas y colapses.
Recuerde también la última de mis reglas generales para la resolución de
problemas: evite la frustración. Cuanto más trabajo intentes hacer en cada paso,
más generarás frustración potencial. Incluso si retrocedes en un paso difícil y lo
divides en subpasos, el daño ya estará hecho porque psicológicamente sentirás que
estás retrocediendo en lugar de progresar. Cuando entreno a programadores
principiantes en un enfoque paso a paso, a veces un estudiante se queja: "Oye,
ese paso fue demasiado fácil". A lo que respondo: “¿De qué te quejas?” Si ha
tomado un problema que inicialmente parecía difícil y lo ha dividido en pedazos
tan pequeños que cada uno de ellos es trivial de resolver, le digo: ¡Felicitaciones!
Eso es justo lo que deberías esperar.
Estado de seguimiento
El último problema que resolveremos en este capítulo es también el más difícil.
Este problema tiene muchas piezas diferentes y una descripción complicada,
que ilustrará la importancia de analizar un problema complejo.
PROBLEMA: DECODIFICAR UN MENSAJE
Un mensaje se ha codificado como una secuencia de texto que debe leerse carácter por carácter.
La secuencia contiene una serie de números enteros delimitados por comas, cada uno de los cuales
es un número positivo que puede representarse mediante un int de C++. Sin embargo, el carácter
representado por un número entero particular depende del modo de decodificación actual. Hay
tres modos: mayúsculas, minúsculas y puntuación.
En modo mayúscula, cada número entero representa una letra mayúscula: el número
entero módulo 27 indica la letra del alfabeto (donde 1 = A y así sucesivamente). Entonces, un valor
de entrada de 143 en modo mayúscula produciría la letra H porque 143 módulo 27 es 8 y H es la
octava letra del alfabeto.
El modo minúsculas funciona igual pero con letras minúsculas; el resto de
dividir el número entero por 27 representa la letra minúscula (1 = a y así sucesivamente).
Entonces, un valor de entrada de 56 en modo minúscula produciría la letra b porque 57 módulo 27
es 2 y b es la segunda letra del alfabeto.
En el modo de puntuación, el número entero se considera módulo 9, con la interpretación
dada en la Tabla 2­3 a continuación. Entonces 19 produciría un signo de exclamación porque 19
módulo 9 es 1.
Al comienzo de cada mensaje, el modo de decodificación es letras mayúsculas. Cada vez que
la operación del módulo (por 27 o 9, dependiendo del modo) resulta en 0, el modo de decodificación
cambia. Si el modo actual es mayúscula, el modo cambia a letras minúsculas. Si el modo actual es
minúscula, el modo cambia a puntuación, y si es puntuación, vuelve a estar en mayúsculas.
Rompecabezas puros 41
Machine Translated by Google
Tabla 2­3: Modo de decodificación de puntuación
Número
Símbolo
1
!
2
?
3
,
4
.
5
6
7
8
(espacio)
;
"
'
Al igual que con la fórmula de validación de Luhn, analizaremos un ejemplo concreto para
asegurarnos de que tenemos todos los pasos en orden. La Figura 2­4 muestra un ejemplo de
decodificación. El flujo de entrada original se muestra en la parte superior. Los pasos del
procesamiento proceden de arriba hacia abajo. La columna (a) muestra el número actual en la
entrada. La columna (b) es el modo actual, pasando de mayúsculas (U) a minúsculas (L) y
puntuación (P). La columna (c) muestra el divisor del modo actual. La columna (d) es el resto de
dividir el divisor actual en la columna (c) entre la entrada actual de la columna (a). El resultado
se muestra en la columna (e), ya sea un carácter o, si el resultado en (d) es 0, un cambio al
siguiente modo en el ciclo.
Al igual que con el problema anterior, podemos comenzar considerando explícitamente
las habilidades que necesitaremos para diseñar una solución. Necesitamos leer una cadena de
caracteres hasta llegar al final de la línea. Los caracteres representan una serie de números
enteros, por lo que necesitamos leer los caracteres numéricos y convertirlos en números enteros
para su posterior procesamiento. Una vez que tenemos los números enteros, necesitamos
convertir el número entero en un solo carácter para la salida. Finalmente, necesitamos alguna
forma de rastrear el modo de decodificación para saber si el número entero actual debe
decodificarse en una letra minúscula, mayúscula o puntuación. Convirtamos esto en una lista formal:
Leer carácter por carácter hasta llegar al final de la línea.
Convierte una serie de caracteres que representan un número en un número entero.
Convierta un número entero del 1 al 26 en una letra mayúscula.
Convierta un número entero del 1 al 26 en una letra minúscula.
Convierta un número entero del 1 al 8 en un símbolo de puntuación según la Tabla 2­3.
Seguimiento de un modo de decodificación.
El primer elemento es algo que ya sabemos hacer del problema anterior. Además, aunque
sólo tratamos dígitos individuales en la fórmula de validación de Luhn, sospecho que algo de
lo que hicimos allí también será útil en el segundo elemento de nuestra lista. El código terminado
para el algoritmo de Luhn probablemente todavía esté fresco en su mente, pero si deja el libro
entre ese problema y este, querrá volver atrás y revisar ese código. En general, cuando la
descripción de un problema actual "le suena", querrá extraer cualquier código similar de sus
archivos para estudiarlo.
42 Capítulo 2
Machine Translated by Google
Entrada original:
18,12312,171,763,98423,1208,216,11,500,18,241,0,32,20620,27,10
(a)
(b)
18
En 27 18
12312
En 27 0
171
L 27 6
763
L 27 7
(C)
(d)
(Es)
R
l
Ciclo de modos:
EN
i
l
gramo
PAG
98423
L 27 8
h
1208
L 27 20
t
216
L 27 0
Mensaje decodificado:
¿Bien? ¡Sí!
11
500
18
241
PAG
PAG
PAG
PAG
9
2
9
5
9
0
?
EN
U 27 25 A
l
0
En 27 0
32
L 27 5
Es
20620
L 27 19
s
27
L 27 0
10
PAG
PAG
9
1
!
Figura 2­4: Procesamiento de muestra para el problema "Decodificar un mensaje"
Vayamos al grano con los puntos que quedan. Es posible que hayas notado que he
convertido cada una de las conversiones en un elemento separado. Sospecho que convertir
un número en una letra minúscula será muy similar a convertir un número en una letra
mayúscula, pero tal vez convertirlo a un símbolo de puntuación requiera algo diferente. En
cualquier caso, no hay ninguna desventaja real en cortar la lista demasiado finamente;
simplemente significa que podrás tachar cosas de la lista con más frecuencia.
Comencemos con esas conversiones de entero a carácter. A partir del programa
de fórmulas de Luhn, conocemos el código necesario para leer un dígito de carácter del
0 al 9 y convertirlo en un número entero en el rango del 0 al 9. ¿Cómo podemos ampliar
este método para tratar con números de varios dígitos? Consideremos la posibilidad más
simple: números de dos dígitos. Esto parece sencillo. En un número de dos cifras, la primera
Rompecabezas puros 43
Machine Translated by Google
ALMACENAMIENTO DEL CÓDIGO PARA SU REUTILIZACIÓN POSTERIOR
Las similitudes entre los elementos del problema actual y el problema anterior muestran la
importancia de guardar el código fuente de una manera que facilite su revisión posterior. Los
desarrolladores de software hablan mucho sobre la reutilización de código, que ocurre cada vez que
se utilizan piezas de software antiguo para crear software nuevo. A menudo, esto implica utilizar un
componente encapsulado o reutilizar el código fuente palabra por palabra. Sin embargo, es igualmente
importante tener fácil acceso a las soluciones anteriores que haya escrito. Incluso si no estás
copiando código antiguo directamente, esto te permite reutilizar habilidades y técnicas aprendidas
previamente sin tener que volver a aprenderlas. Para maximizar este beneficio, esfuércese por
conservar todo el código fuente que escriba (teniendo en cuenta cualquier acuerdo de propiedad
intelectual que pueda tener con clientes o empleadores, por supuesto).
Sin embargo, el hecho de que usted reciba el máximo beneficio de los programas escritos
previamente depende en gran medida del cuidado que tenga al guardarlos; El código que no
puedes encontrar es un código que no puedes usar. Si emplea un enfoque paso a paso y escribe
programas individuales para probar ideas por separado antes de integrarlas en el todo, asegúrese de
guardar también esos programas intermedios. Puede que le resulte muy conveniente tenerlos
disponibles más adelante, cuando la similitud entre su programa actual y el antiguo se encuentre
en una de las áreas para las cuales escribió un programa de prueba.
El dígito es el dígito de las decenas, por lo que debemos multiplicar este dígito individual por
10 y luego sumar el valor del segundo dígito. Por ejemplo, si el número fuera 35, después de
leer los dígitos individuales como caracteres 3 y 5, y convertirlos a números enteros 3 y 5,
obtendríamos el número entero general que necesitamos mediante la expresión 3 * 10 + 5.
Confirmemos esto. con código:
cout << "Ingrese un número de dos dígitos: ";
char digitChar1 = cin.get();
char digitChar2 = cin.get();
int dígito1 = dígitoChar1 ­ '0';
int dígito2 = dígitoChar2 ­ '0';
int número total = dígito1 * 10 + dígito2;
"
cout << "Ese número como un número entero:
<< número total << "\n";
Eso funciona: el programa genera el mismo número de dos dígitos que ingresamos.
Sin embargo, encontramos un problema cuando intentamos ampliar este método. Este
programa utiliza dos variables diferentes para contener las dos entradas de caracteres y, si
bien eso no causa problemas aquí, ciertamente no queremos extenderlo como una solución
general. Si lo hiciéramos, necesitaríamos tantas variables como dígitos tengamos. Eso sería
complicado y sería difícil de modificar si variara el rango de números posibles en el flujo de
entrada. Necesitamos una solución más general para este subproblema de convertir
caracteres a números enteros. El primer paso para encontrar esa solución general es reducir
el código anterior a sólo dos variables: una char y una int:
cout << "Ingrese un número de dos dígitos: ";
char dígitoChar = cin.get();
int número global = (digitChar ­ '0') * 10;
44 Capítulo 2
Machine Translated by Google
dígitoChar = cin.get();
número global += (digitChar ­ '0');
cout << "Ese número como un número entero:
"
<< número total << "\n";
Logramos esto haciendo todos los cálculos en el primer dígito antes de leer el segundo
dígito. Después de leer el primer dígito del carácter
en un paso, lo convertimos a un
número entero, lo multiplicamos por 10 y almacenamos el resultado . Después de leer el
segundo dígito , sumamos su valor entero al total acumulado . Esto es equivalente al
código anterior usando solo dos variables, una para el último carácter leído y otra para el
valor general del número entero. El siguiente paso es considerar ampliar este método a
números de tres dígitos. Una vez que hagamos eso, es probable que veamos un patrón
que nos permitirá crear una solución general para cualquier número de dígitos.
Sin embargo, cuando intentamos esto, encontramos un problema. Con el número de
dos dígitos, multiplicamos el dígito izquierdo por 10 porque el dígito izquierdo estaba en
la posición de las decenas. El dígito más a la izquierda en un número de tres dígitos estaría
en la posición de las centenas, por lo que necesitaríamos multiplicar ese dígito por 100.
Luego podríamos leer el dígito del medio, multiplicarlo por 10, sumarlo al total acumulado,
y luego lea el último dígito y agréguelo también. Eso debería funcionar, pero no nos lleva
hacia una solución general. ¿Ves el problema?
Considere la afirmación anterior: el dígito más a la izquierda en un número de tres dígitos
estaría en la posición de las centenas. Para una solución general, no sabremos cuántos
dígitos hay en cada número hasta que lleguemos a la siguiente coma. El dígito más a la
izquierda de un número con una cantidad desconocida de dígitos no se puede etiquetar en
la posición de las centenas ni en ninguna otra posición. Entonces, ¿cómo sabemos qué
multiplicador usar para cada dígito antes de sumarlo al total acumulado? ¿O necesitamos
otro enfoque completamente diferente?
Como siempre, cuando se atasca, es una buena idea crear un problema simplificado
en el que trabajar. El problema aquí es no saber cuántos dígitos tendrá el número. El
problema más simple que se ocupa de este tema sería uno que tenga sólo dos posibles
recuentos de dígitos.
PROBLEMA: LEER UN NÚMERO CON TRES O
CUATRO DÍGITOS
Escriba un programa para leer un número carácter por carácter y convertirlo a un número entero, usando
solo una variable char y una variable int . El número tendrá tres o cuatro dígitos.
Esta cuestión de no saber el número de caracteres hasta el final pero necesitar contarlos
desde el principio es análoga a la cuestión de la fórmula de Luhn. En la fórmula de Luhn, no
sabíamos si el número de identificación tenía una longitud par o impar. En ese caso,
nuestra solución fue calcular los resultados de dos formas diferentes y elegir la adecuada al
final.
¿Podríamos hacer algo así aquí? Si el número tiene tres o cuatro dígitos, sólo hay dos
posibilidades. Si el número tiene tres dígitos, el dígito más a la izquierda es el dígito de las
centenas. Si el número tiene cuatro dígitos, el más a la izquierda
Rompecabezas puros 45
Machine Translated by Google
El dígito es el dígito de los millares. Podríamos calcular como si tuviéramos un número de
tres dígitos y como si tuviéramos un número de cuatro dígitos y luego elegir el número
correcto al final, pero la descripción del problema nos permite tener solo una variable
numérica. Relajemos esa restricción para lograr algún progreso.
PROBLEMA: LEER UN NÚMERO CON TRES O
CUATRO DÍGITOS, MÁS SIMPLIFICADO
Escriba un programa para leer un número carácter por carácter y convertirlo a un
número entero, usando solo una variable char y dos variables int . El número tendrá
tres o cuatro dígitos.
Ahora podemos poner en práctica el método de “calcularlo en ambos sentidos”.
Procesaremos los primeros tres dígitos de dos maneras diferentes y luego veremos si hay
un cuarto dígito:
cout << "Ingrese un número de tres o cuatro dígitos: ";
char digitChar = cin.get();
int número de tres dígitos = (carácter de dígitos ­ '0') * 100;
int cuatroNúmeroDeDígitos = (digitChar ­ '0') * 1000;
digitChar = cin.get();
tresNúmeroDeDígitos += (digitChar ­ '0') * 10;
cuatroNúmeroDeDígitos += (digitChar ­ '0') * 100;
digitChar = cin.get();
tresNúmeroDigit += (digitChar ­ '0');
cuatroNúmeroDeDígitos += (digitChar ­ '0') * 10;
digitChar = cin.get();
si (digitChar == 10) {
"
cout << "Numerado ingresado: }
<< número de tres dígitos << "\n";
else {
cuatroNúmeroDigit += (digitChar ­ '0');
"
cout << "Numerado ingresado:
<< número de cuatro dígitos << "\n";
}
Después de leer el dígito más a la izquierda, multiplicamos su valor entero por 100 y
guárdelo en nuestra variable de tres dígitos . También multiplicamos el valor entero por
1000 y lo almacenamos en nuestra variable de cuatro dígitos . Este patrón continúa
durante los siguientes dos dígitos. El segundo dígito se trata como un dígito de decenas en
un número de tres dígitos y como un dígito de centenas en un número de cuatro dígitos.
El tercer dígito se trata como un dígito de unidades y decenas. Después de leer el cuarto
carácter, verificamos si es un final de línea comparándolo con el número 10
(al igual que en el problema anterior, este valor puede variar según el sistema operativo). Si
es un final de línea, la entrada fue un número de tres dígitos. Si no, aún necesitamos sumar
el dígito de las unidades al total .
Ahora necesitamos descubrir cómo deshacernos de una de las variables enteras.
Supongamos que eliminamos por completo la variable fourDigitNumber . El valor de
threeDigitNumber aún se asignaría correctamente, pero cuando llegáramos a un punto en
el que necesitáramos fourDigitNumber, no lo tendríamos. Usando el valor en
threeDigitNumber, ¿hay alguna manera de determinar el valor que tendría?
46 Capítulo 2
Machine Translated by Google
estado en fourDigitNumber? Supongamos que la entrada original fue 1234.
Después de leer los primeros tres dígitos, el valor en threeDigitNumber sería 123;
el valor que habría estado en fourDigitNumber es 1230. En general, dado que los
multiplicadores de fourDigitNumber son 10 veces los de threeDigitNumber, el
primero siempre sería 10 veces el segundo. Por lo tanto, sólo se necesita una
variable entera porque la otra variable puede simplemente multiplicarse por 10 si es necesario:
cout << "Ingrese un número de tres o cuatro dígitos: ";
char digitChar = cin.get();
número int = (digitChar ­ '0') * 100;
digitChar = cin.get();
número += (digitChar ­ '0') * 10;
digitChar = cin.get();
número += (digitChar ­ '0');
digitChar = cin.get();
si (digitChar == 10) {
"
cout << "Numerado ingresado:
<< número << "\n";
} demás {
número = número * 10 + (digitChar ­ '0');
"
cout << "Numerado ingresado:
<< número << "\n";
}
Ahora tenemos un patrón explotable. Considere expandir este código para
manejar números de cinco dígitos. Después de calcular el valor correcto para los
primeros cuatro dígitos, repetiríamos el mismo proceso que seguimos para leer el
cuarto carácter en lugar de mostrar el resultado inmediatamente: leer un quinto
carácter, verificar si es un final de línea, mostrar el número calculado previamente
si lo es; de lo contrario, multiplique por 10 y agregue el valor del dígito del carácter
actual:
cout << "Ingrese un número de tres, cuatro o cinco dígitos: ";
char digitChar = cin.get();
número int = (digitChar ­ '0') * 100;
digitChar = cin.get();
número += (digitChar ­ '0') * 10;
digitChar = cin.get();
número += (digitChar ­ '0');
digitChar = cin.get();
si (digitChar == 10) {
"
cout << "Numerado ingresado: }
<< número << "\n";
else {
número = número * 10 + (digitChar ­ '0');
digitChar = cin.get();
si (digitChar == 10) {
"
cout << "Numerado ingresado:
<< número << "\n";
} demás {
número = número * 10 + (digitChar ­ '0');
"
cout << "Numerado ingresado:
<< número << "\n";
}
}
Rompecabezas puros 47
Machine Translated by Google
En este punto, podríamos expandir fácilmente el código para manejar números de seis
dígitos o números con menos dígitos. El patrón es claro: si el siguiente carácter es otro dígito,
multiplique el total acumulado por 10 antes de sumar el valor del dígito entero del carácter. Con
este entendimiento, podemos escribir un bucle para manejar un número de cualquier longitud:
cout << "Ingrese un número con tantos dígitos como desee: ";
char dígitoChar = cin.get();
int número = (digitChar ­ '0');
dígitoChar = cin.get();
mientras (digitChar != 10) {
número = número * 10 + (digitChar ­ '0');
digitChar = cin.get();
}
"
cout << "Numerado ingresado:
<< número << "\n";
Aquí, leemos el primer carácter
Luego leemos el segundo carácter
y determinamos su valor de dígito
.
y llegamos al bucle, donde comprobamos si el carácter leído
más recientemente es un final de línea
. De lo contrario, multiplicamos el total acumulado en el
bucle por 10 y sumamos el valor del dígito del carácter actual
antes de leer el siguiente carácter
. Una vez que llegamos al final de la línea, el número de la variable total acumulado contiene el
valor entero para que generemos
.
Eso maneja la conversión de una serie de caracteres a su equivalente entero. En el
programa final, leeremos una serie de números, separados por comas. Cada número deberá
leerse y procesarse por separado. Como siempre, es mejor empezar pensando en una
situación sencilla que demuestre el problema. Consideremos la entrada 101,22[EOL], donde
[EOL] marca explícitamente el final de la línea para mayor claridad. Sería suficiente modificar la
condición de prueba del bucle para comprobar si hay un carácter de fin de línea o una coma.
Luego necesitaríamos colocar todo el código que procesa un número dentro de un bucle más grande
que continúa hasta que se hayan leído todos los números.
Entonces, el bucle interno debe detenerse durante [EOL] o una coma, pero el bucle externo
debe detenerse solo durante [EOL]:
carácter dígitoChar;
hacer {
digitChar = cin.get();
número int = (digitChar ­ '0');
digitChar = cin.get();
mientras ((digitChar != 10) && (digitChar != ',')) {
número = número * 10 + (digitChar ­ '0');
digitChar = cin.get();
}
"
cout << "Numerado ingresado:
<< número << "\n";
} mientras (digitChar!= 10);
Este es otro gran ejemplo de la importancia de los pequeños pasos. Aunque se trata de un
programa corto, la naturaleza de ruedas dentro de ruedas del doble bucle habría generado un
código complicado si hubiéramos intentado escribirlo desde cero. Es
48 Capítulo 2
Machine Translated by Google
Sin embargo, es sencillo cuando llegamos a este código siguiendo un paso del programa
anterior. La declaración de digitChar
se mueve a una línea separada para que la
declaración esté dentro del alcance de todo el código. El resto del código es el mismo
que el listado anterior, excepto que se coloca dentro de un bucle do­ while que
continúa hasta llegar al final de la línea .
Una vez implementada esa parte de la solución, podemos centrarnos en procesar
los números individuales. El siguiente elemento de nuestra lista es convertir un número
del 1 al 26 en una letra de la A a la Z. Si lo piensas bien, esto es en realidad una inversión
del proceso que usamos para convertir los caracteres de dígitos individuales a sus
equivalentes enteros. Si restamos el código de caracteres para que 0 se traduzca del
rango de códigos de caracteres de 0 a 9 al rango de enteros de 0 a 9, deberíamos poder
agregar un código de caracteres para traducir de 1 a 26 a A­Z. ¿Y si añadimos 'A'? Aquí
hay un intento junto con una entrada y salida de muestra:
cout << "Ingrese un número del 1 al 26: ";
número entero;
cin >> número;
carácter de salida de caracteres;
carácter de salida =
número + 'A';
cout << "Símbolo equivalente:
"
<< carácter de salida << "\n";
Ingrese un número del 1 al 26: 5
Letra equivalente: F
Eso no es del todo bien. La quinta letra del alfabeto es E, no F. El problema ocurre
porque estamos sumando un número en el rango que comienza en 1. Cuando
estábamos convirtiendo en la otra dirección, de un dígito de carácter a su equivalente
entero, estábamos tratando con un rango que comenzaba desde 0. Podemos
solucionar este problema cambiando el cálculo
de número + 'A' a número + 'A' ­ 1.
Tenga en cuenta que podemos buscar el valor del código de carácter para la letra A (es
65 en ASCII) y simplemente use uno menos que ese valor (por ejemplo, número + 64 en
ASCII). Sin embargo, prefiero la primera versión porque es más legible. En otras
palabras, si vuelves a mirar este código más tarde, podrás recordar más rápidamente
qué número + 'A' ­ 1
hace que lo que hace el número + 64 porque la aparición de 'A' en el primero le recordará
la conversión a letras mayúsculas.
Una vez resuelto esto, podemos adaptar fácilmente esta idea para convertirla a
letras minúsculas cambiando el cálculo
a número + 'a' ­ 1. La conversión de la tabla de
puntuación no es tan concisa porque los símbolos de puntuación en la tabla sí lo hacen.
no aparecen en ese orden en ASCII ni en ningún otro sistema de código de caracteres.
Como tal, vamos a tener que manejar esto mediante la fuerza bruta:
cout << "Ingrese un número del 1 al 8: ";
número entero;
cin >> número;
carácter de salida de caracteres;
cambiar (número) {
caso 1: carácter de salida = '!'; romper;
caso 2: carácter de salida = '?'; romper;
caso 3: carácter de salida = ','; romper;
Rompecabezas puros 49
Machine Translated by Google
caso 4: carácter de salida = '.'; romper;
caso 5: carácter de salida = ' '; romper;
caso 6: carácter de salida = ';'; romper;
caso 7: carácter de salida = '"'; descanso;
caso 8: carácter de salida =
'\''; romper;
}
"
cout << "Símbolo equivalente:
<< carácter de salida << "\n";
Aquí, hemos utilizado una instrucción de cambio
para generar el carácter de
puntuación correcto. Tenga en cuenta que se ha empleado una barra invertida como
"escape" para mostrar la comilla simple .
Tenemos un último subproblema que abordar antes de armar todo:
cambiar de modo a modo cada vez que el valor más reciente se decodifica a 0.
Recuerde que la descripción del problema requiere que modulemos cada valor entero
por 27 (si actualmente estamos en el modo mayúsculas o minúsculas) o 9 (si estamos
en modo de puntuación). Cuando el resultado es 0, cambiamos al siguiente modo. Lo
que necesitamos es una variable para almacenar el modo actual y la lógica dentro de
nuestro bucle "leer y procesar el siguiente valor" para cambiar de modo si es necesario.
La variable que rastrea el modo actual podría ser un número entero simple, pero es más
legible usar una enumeración. Una buena regla general: si una variable solo rastrea
un estado y ningún valor en particular tiene un significado inherente, una enumeración
es una buena idea. En este caso, podríamos tener una variable en modo int , diciendo
arbitrariamente que el valor de 1 significa mayúsculas, 2 significa minúsculas y 3 significa
puntuación. Sin embargo, no existe una razón inherente por la que se elijan esos valores.
Cuando volvamos a ver el código más adelante, tendremos que volver a familiarizarnos
con el sistema para entender una declaración como if (mode == 2). Si usamos una
enumeración, como en la declaración (modo == MINÚSCULAS), no hay nada que
recordar porque está todo explicado. Aquí está el código que resulta de esta idea,
junto con una interacción de muestra:
enum modeType {MAYÚSCULAS, MINÚSCULAS, PUNTUACIÓN};
número entero;
modo tipo modo = MAYÚSCULAS;
cout << "Ingrese algunos números que terminan en ­1: ";
hacer {
cin >> número;
cout << "Número leído:
"
<< número;
cambiar (modo) {
caso MAYÚSCULAS:
número = número % 27;
cout << ". Módulo 27: if
"
<< número << ". ";
(número == 0) {
cout << "Cambiar a MINÚSCULAS";
modo = MINÚSCULAS;
}
romper;
caso MINÚSCULAS:
número = número % 27;
cout << ". Módulo 27: if
(número == 0) {
50 Capítulo 2
"
<< número << ". ";
Machine Translated by Google
cout << "Cambiar a PUNTUACIÓN";
modo = PUNTUACIÓN;
}
romper;
caso PUNTUACIÓN:
número = número % 9;
cout << ". Módulo 9: if
"
<< número << ". ";
(número == 0) {
cout << "Cambiar a MAYÚSCULAS";
modo = MAYÚSCULAS;
}
romper;
}
cout << "\n";
} mientras (número! = ­1);
Ingrese algunos números que terminan en ­1: 2 1 0 52 53 54 55 6 7 8 9 10 ­1
Número leído: 2. Módulo 27: 2.
Número leído: 1. Módulo 27: 1.
Número leído: 0. Módulo 27: 0. Cambiar a MINÚSCULAS
Número leído: 52. Módulo 27: 25.
Número leído: 53. Módulo 27: 26.
Número leído: 54. Módulo 27: 0. Cambiar a PUNTUACIÓN
Número leído: 55. Módulo 9: 1.
Número leído: 6. Módulo 9: 6.
Número leído: 7. Módulo 9: 7.
Número leído: 8. Módulo 9: 8.
Número leído: 9. Módulo 9: 0. Cambiar a MAYÚSCULAS
Número leído: 10. Módulo 27: 10.
Número leído: ­1. Módulo 27: ­1.
Hemos tachado todo lo que está en nuestra lista, por lo que ahora es el
momento de integrar estos listados de códigos individuales para crear una solución
para el programa general. Podríamos abordar esta integración de diferentes maneras.
Podríamos juntar sólo dos piezas y construir a partir de ahí. Por ejemplo, podríamos
combinar el código para leer y convertir los números separados por comas con el
modo cambiando desde la lista más reciente. Luego podríamos probar esa integración
y agregar el código para convertir cada número a la letra o símbolo de puntuación apropiado.
O podríamos construir en la otra dirección, tomando la lista de números a caracteres
y convirtiéndola en una serie de funciones que se llamarán desde el programa
principal. En este punto, hemos ido más allá de la resolución de problemas hacia la
ingeniería de software, que es un tema diferente. Hicimos una serie de bloques...
Esa fue la parte difícil y ahora sólo tenemos que ensamblarlos, como se muestra en
la Figura 2­5.
Casi todas las líneas de este programa se extrajeron del código anterior de
esta sección. La mayor parte del código
proviene del programa de cambio de
modo. El bucle de procesamiento central
proviene de nuestro código para leer una
serie de números delimitados por comas carácter por carácter. Finalmente,
reconocerás el código que convierte los números enteros en letras mayúsculas,
minúsculas y puntuación . La pequeña cantidad de código nuevo está marcada con . El
Rompecabezas puros 51
Machine Translated by Google
Las declaraciones de continuación nos llevan a la siguiente iteración del bucle cuando la última
entrada fue un comando de cambio de modo, omitiendo el cout <<outputCharacter al final del
bucle.
carácter de salida de caracteres;
enum modeType {MAYÚSCULAS, MINÚSCULAS, PUNTUACIÓN};
modo tipo modo = MAYÚSCULAS;
dígito de carácter Char;
hacer {
digitChar = cin.get();
número int = (digitChar ­ '0');
digitChar = cin.get();
while ((digitChar!= 10) && (digitChar!= ',')) { número = número * 10 +
(digitChar ­ '0'); digitChar = cin.get();
} modo interruptor) {
caso MAYÚSCULAS:
número = número % 27;
carácter de salida = número + 'A' ­ 1; si (número
== 0) {
modo = MINÚSCULAS;
continuar;
} romper;
caso MINÚSCULAS:
número = número % 27;
carácter de salida = número + 'a' ­ 1; si (número
== 0) {
modo = PUNTUACIÓN;
continuar;
}
romper; case
PUNTUACIÓN: número =
número % 9; cambiar
(número) { caso 1: carácter de salida = '!'; romper;
caso 2: carácter de salida = '?'; romper;
caso 3: carácter de salida = ','; romper;
caso 4: carácter de salida = '.'; romper;
caso 5: carácter de salida = caso 6: ' '; romper;
carácter de salida = ';'; romper;
caso 7: carácter de salida = '"'; descanso;
caso 8: carácter de salida = '\''; romper;
}
si (número == 0) {
modo = MAYÚSCULAS;
continuar;
}
romper;
} cout << carácter de salida; }
mientras (digitChar! = 10); cout <<
"\n";
Figura 2­5: La solución ensamblada para el problema "Decodificar un
mensaje"
52 Capítulo 2
Machine Translated by Google
Si bien este es un trabajo de cortar y pegar, este es el buen tipo de trabajo de cortar y pegar,
donde reutilizas el código que acabas de escribir y, por lo tanto, lo comprendes por completo.
Como antes, piense en lo fácil que fue cada paso en este proceso, en lugar de intentar escribir el
listado final desde cero. Sin duda, un buen programador podría producir el listado final sin pasar
por los pasos intermedios, pero habría pasos en falso, ocasiones en las que el código se vería
feo y líneas de código comentadas y luego reintroducidas. Al tomar pasos más pequeños, todo el
trabajo sucio se hace temprano y el código nunca se vuelve demasiado feo porque el código con
el que estamos trabajando actualmente nunca se vuelve largo ni complicado.
Conclusión
En este capítulo, analizamos tres problemas diferentes. En cierto sentido, tuvimos que tomar tres
caminos diferentes para resolverlos. En otro sentido, tomamos el mismo camino cada vez
porque utilizamos la misma técnica básica de dividir el problema en componentes; escribir código
para resolver esos componentes individualmente; y luego usar el conocimiento adquirido al
escribir los programas, o incluso usar directamente líneas de código de los programas, para
resolver el problema original. En los capítulos que siguen, no usaremos este método
explícitamente para cada problema, pero la idea fundamental siempre está ahí: dividir el
problema en partes manejables.
Dependiendo de sus antecedentes, estos problemas pueden haber parecido
inicialmente encontrarse en cualquier parte del espectro de dificultad, desde diabólicos hasta triviales.
Independientemente de lo difícil que parezca inicialmente un problema, recomendaría utilizar
esta técnica en cada nuevo problema que enfrente. No querrás esperar hasta llegar a un
problema frustrantemente difícil antes de probar una nueva técnica. Recuerda que uno de los
objetivos de este texto es que desarrolles confianza en tu capacidad para resolver problemas.
Practica el uso de las técnicas en problemas “fáciles” y tendrás mucho impulso para cuando
resuelvas los difíciles.
Ejercicios
Como antes, te insto a que pruebes tantos ejercicios como puedas soportar. Ahora que estamos
de lleno en la programación real, realizar ejercicios es esencial para que pueda desarrollar sus
habilidades de resolución de problemas.
2­1. Utilizando la misma regla que los programas de formas vistos anteriormente en este capítulo (solo dos
declaraciones de salida, una que genera la marca hash y otra que genera un final de línea),
escriba un programa que produzca la siguiente forma:
########
######
####
##
Rompecabezas puros 53
Machine Translated by Google
2­2. O qué tal:
##
####
######
########
########
######
####
##
2­3.
Aquí hay uno especialmente complicado:
##
## ##
### ###
########
########
### ###
## ##
##
2­4. Diseñe el suyo propio: piense en su propio patrón simétrico de marcas de almohadilla y vea
si puede escribir un programa para producirlo que siga la regla de las formas.
2­5.
Si le gusta el problema de la fórmula de Luhn, intente escribir un programa para un sistema
de dígitos de control diferente, como el sistema ISBN de 13 dígitos. El programa podría
tomar un número de identificación y verificarlo o tomar un número sin su dígito de control y
generar el dígito de control.
2­6.
Si ha aprendido sobre números binarios y cómo convertir de decimal a binario y viceversa,
intente escribir programas para realizar esas conversiones con números de longitud ilimitada
(pero puede asumir que los números son lo suficientemente pequeños como para
almacenarlos en un int estándar de C++) . .
2­7. ¿Has aprendido sobre hexadecimal? Intente escribir un programa que permita
El usuario especifica una entrada en binario, decimal o hexadecimal y una salida en
cualquiera de los tres.
2­8. ¿Quieres un desafío adicional? Generalice el código del ejercicio anterior para
cree un programa que convierta de cualquier número de base 16 o menos a cualquier otra
base numérica. Entonces, por ejemplo, el programa podría convertir de base 9 a base 4.
2­9. Escribe un programa que lea una línea de texto, cuente el número de palabras, identifique la
longitud de la palabra más larga, el mayor número de vocales en una palabra y/o
cualquier otra estadística que se te ocurra.
54 Capítulo 2
Machine Translated by Google
t
l
S
YYPAG
oh
C
En el capítulo anterior, nos limitamos a variables
escalares, es decir, variables que pueden
contener sólo un valor a la vez. En este capítulo,
veremos problemas que utilizan la estructura de
datos agregados más común, la matriz. Aunque los arrays
son estructuras simples con limitaciones fundamentales,
su uso magnifica enormemente el poder de nuestros programas.
A
DY
Y
RESOLVIENDO PROBLEMAS
CON ARRAYS
En este capítulo, nos ocuparemos principalmente de matrices reales, es decir, aquellas
declaradas con la sintaxis incorporada de C++, como por ejemplo:
int tenIntegerArray[10];
Sin embargo, las técnicas que analizamos se aplican igualmente a estructuras de datos con
atributos similares. La más común de estas estructuras es un vector. El término vector se usa a
menudo como sinónimo de cualquier matriz de una sola dimensión, pero lo usaremos aquí en el
sentido más específico de una estructura que tiene los atributos de una matriz sin un número máximo
especificado de elementos. Entonces, para nuestras discusiones, una matriz tiene un tamaño fijo,
mientras que un vector puede crecer o reducirse.
Machine Translated by Google
automáticamente según sea necesario. Cada uno de los problemas que analizamos en este
capítulo incluye alguna restricción que nos permite utilizar una estructura con un número fijo de
elementos. Sin embargo, los problemas sin tales restricciones podrían adaptarse
utilizar un vector.
Además, las técnicas utilizadas con matrices a menudo se pueden utilizar con estructuras
de datos que no tienen todos los atributos enumerados anteriormente. Algunas técnicas, por
ejemplo, no requieren acceso aleatorio, por lo que pueden usarse con estructuras como listas
enlazadas. Debido a que los arreglos son tan comunes en la programación y debido a que las
técnicas de arreglos se usan con frecuencia en contextos sin arreglos, los arreglos son un excelente
campo de entrenamiento para el estudio de la resolución de problemas con estructuras de datos.
Revisión de los fundamentos de las matrices
Ya deberías saber qué es una matriz, pero repasemos algunos de sus atributos para mayor
claridad. Una matriz es una colección de variables del mismo tipo organizadas bajo un nombre,
donde las variables individuales se indican con un número. A las variables individuales las llamamos
elementos de la matriz. En C++ y la mayoría de los demás lenguajes, el primer elemento tiene el
número 0, pero en algunos lenguajes esto variará.
Los atributos principales de la matriz se derivan directamente de la definición.
Cada valor almacenado en una matriz es del mismo tipo, mientras que otras estructuras de datos
agregados pueden almacenar valores de tipos mixtos. Un elemento individual está referenciado
por un número llamado subíndice ; en otras estructuras de datos, se puede hacer referencia a
elementos individuales por nombre o por un valor clave.
De estos atributos primarios podemos derivar varios atributos secundarios.
Como cada uno de los elementos está designado por un número en una secuencia que comienza
en 0, podemos examinar fácilmente cada valor de una matriz. En otras estructuras de datos,
esto puede resultar difícil, ineficiente o incluso imposible. Además, mientras que a algunas
estructuras de datos, como las listas enlazadas, solo se puede acceder de forma secuencial, una
matriz ofrece acceso aleatorio, lo que significa que podemos acceder a cualquier elemento de la
matriz en cualquier momento.
Estos atributos primarios y secundarios determinan cómo podemos usar las matrices.
Cuando se trata de cualquier estructura de datos agregados, es bueno tener en mente un conjunto
de operaciones básicas al considerar los problemas. Piense en estas operaciones básicas como
herramientas comunes: los martillos, destornilladores y llaves inglesas de la estructura de datos.
No todos los problemas mecánicos se pueden resolver con herramientas comunes, pero siempre
debe considerar si un problema se puede resolver con herramientas comunes antes de ir a la
ferretería. Aquí está mi lista de operaciones básicas para matrices.
Almacenar
Esta es la más básica de las operaciones. Una matriz es una colección de variables y podemos
asignar un valor a cada una de esas variables. Para asignar el número entero 5 al primer elemento
(elemento 0) del array previamente declarado, simplemente decimos:
tenIntegerArray[0] = 5;
56 Capítulo 3
Machine Translated by Google
Como ocurre con cualquier variable, los valores de los elementos dentro de nuestra matriz serán
“basura” aleatoria hasta que se asignen valores particulares, por lo que las matrices deben
inicializarse antes de usarse. En algunos casos, especialmente para pruebas, querremos asignar un
valor particular a cada elemento de la matriz. Podemos hacer eso con un inicializador cuando se declara
la matriz.
int tenIntegerArray[10] = {4, 5, 9, 12, ­4, 0, ­57, 30987, ­287, 1};
En breve veremos un buen uso de un inicializador de matriz. A veces, en lugar de asignar un valor
diferente a cada elemento, solo queremos que cada elemento de la matriz se inicialice con el mismo
valor. Hay algunos atajos para asignar un cero a cada elemento de la matriz, dependiendo de la
situación o del compilador utilizado (el compilador de C++ en Microsoft Visual Studio, por ejemplo,
inicializa cada valor de cualquier matriz a cero a menos que se especifique lo contrario). En esta
etapa, sin embargo, siempre inicializaría explícitamente una matriz siempre que sea necesaria la
inicialización para mejorar la legibilidad, como en este código, que establece cada elemento en una
matriz de 10 elementos en –1:
int tenIntegerArray[10];
for (int i = 0; i < 10; i++) tenIntegerArray[i] = ­1;
Copiar
Podemos hacer una copia de la matriz. Hay dos situaciones comunes en las que esto podría resultar
útil. En primer lugar, es posible que queramos manipular mucho la matriz pero aún así necesitemos la
matriz en su forma original para su posterior procesamiento. Devolver la matriz a su forma original
después de la manipulación puede ser difícil, o incluso imposible, si hemos cambiado alguno de los
valores. Al copiar toda la matriz, podemos manipular la copia sin alterar el original. Todo lo que
necesitamos para copiar una matriz completa es un bucle y una declaración de asignación, al igual
que el código de inicialización:
int tenIntegerArray[10] = {4, 5, 9, 12, ­4, 0, ­57, 30987, ­287, 1};
int segundoArray[10];
for (int i = 0; i < 10; i++) secondArray[i] = tenIntegerArray[i];
Esa operación está disponible para la mayoría de las estructuras de datos agregados. El segundo
La situación es más específica de las matrices. A veces queremos copiar parte de los datos de una
matriz a una segunda matriz, o queremos copiar los elementos de una matriz a una segunda matriz
como método para reorganizar el orden de los elementos. Si ha estudiado el algoritmo de ordenación
por combinación, habrá visto esta idea en acción. Veremos ejemplos de copia más adelante en este
capítulo.
Recuperación y búsqueda
Con la capacidad de poner valores en la matriz, también necesitamos la capacidad de sacarlos de la
matriz. Recuperar el valor de una ubicación particular es sencillo:
int num = tenIntegerArray[0];
Resolver problemas con matrices 57
Machine Translated by Google
Buscando un valor específico
Normalmente la situación no es tan sencilla. A menudo no sabemos la ubicación que necesitamos y, en
cambio, tenemos que buscar en la matriz para encontrar la ubicación de un valor específico. Si los
elementos de la matriz no están en ningún orden en particular, lo mejor que podemos hacer es una
búsqueda secuencial, donde miramos cada elemento de la matriz de un extremo al otro hasta
encontrar el valor deseado. Aquí tienes una versión básica.
constante int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {4, 5, 9, 12, ­4, 0, ­57, 30987, ­287, 1};
int valordestino = 12;
int TargetPos = 0;
while ((intArray[targetPos] != targetValue) && (targetPos < ARRAY_SIZE))
Pos.objetivo++;
En este código, tenemos una constante que almacena el tamaño de la matriz
, la matriz en sí
, una variable para almacenar el valor que buscamos en la matriz
y una variable para almacenar la
ubicación donde se encuentra el valor
. En este ejemplo, usamos nuestra constante ARRAY_SIZE
para limitar el número de iteraciones sobre nuestra matriz
, de modo que no pasemos del final de la
matriz cuando targetValue no se encuentre entre los elementos de la matriz. Podrías “cablear” el número
10 en lugar de la constante, pero usar la constante hace que el código sea más general, lo que facilita
su modificación y reutilización. Usaremos una constante ARRAY_SIZE en la mayor parte del código de
este capítulo. Tenga en cuenta que si targetValue no se encuentra en intArray, targetPos será igual a
ARRAY_SIZE después del ciclo. Esto es suficiente para indicar el evento porque ARRAY_SIZE no es un
número de elemento válido. Sin embargo, dependerá del código que sigue comprobarlo. Tenga en
cuenta también que el código no hace ningún esfuerzo por gestionar la posibilidad de que el valor
objetivo aparezca más de una vez. La primera vez que aparece el valor objetivo, el ciclo termina.
Búsqueda basada en criterios
A veces el valor que buscamos no es un valor fijo sino un valor basado en la relación con otros valores
de la matriz. Por ejemplo, es posible que deseemos encontrar el valor más alto de la matriz. El
mecanismo para hacerlo es lo que yo llamo “Rey de la colina”, en referencia al juego del patio de recreo.
Tener una variable que represente el valor más alto visto hasta ahora en la matriz. Recorre todos los
elementos de la matriz con un bucle, y cada vez que encuentres un valor mayor que el valor más alto
anterior, el nuevo valor derriba al rey anterior de la colina y ocupa su lugar:
constante int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {4, 5, 9, 12, ­4, 0, ­57, 30987, ­287, 1};
int valormás alto = intArray[0];
para (int i = 1; i < ARRAY_SIZE; i++) {
if (intArray[i] > valor más alto) Valor más alto = intArray[i];
}
La variable valormás alto almacena el valor más grande encontrado en la matriz hasta el momento.
En su declaración, se le asigna el valor del primer elemento del array
bucle en el segundo elemento del array (permite
58 Capítulo 3
, lo que nos permite iniciar el
Machine Translated by Google
comencemos con i en 1 en lugar de 0)
. Dentro del ciclo, comparamos el valor en la posición
actual con el valor más alto, reemplazando el valor más alto si es apropiado
. Tenga en
cuenta que encontrar el valor más bajo, en lugar del más alto, es sólo una cuestión de
cambiar la comparación “mayor que”
a una comparación “menor que” (y cambiar el nombre
de la variable para no confundir nosotros mismos).
Esta estructura básica se puede aplicar a todo tipo de situaciones en las que queremos
observar cada elemento de la matriz para encontrar el valor que mejor ejemplifique una
cualidad particular.
Clasificar
Ordenar significa poner los datos en un orden específico. Probablemente ya haya encontrado
algoritmos de clasificación para matrices. Esta es un área clásica para el análisis de
desempeño porque hay muchos algoritmos de clasificación en competencia, cada uno con
características de desempeño que varían dependiendo de las características de los datos
subyacentes. El estudio de diferentes algoritmos de clasificación podría ser el tema de un libro
completo por sí solo, por lo que no vamos a explorar esta área en toda su profundidad. En
cambio, nos centraremos en lo que es práctico. Para la mayoría de las situaciones, puede
conformarse con dos clasificaciones en su caja de herramientas: una clasificación rápida y
fácil de usar y una clasificación decente y fácil de entender que puede modificar con
confianza cuando surja la situación. Para hacerlo más rápido y fácil, usaremos la función de
biblioteca estándar qsort, y cuando necesitemos modificar algo, usaremos una ordenación por inserción.
Clasificación rápida y sencilla con qsort
La clasificación rápida predeterminada para los programadores de C/C++ es la función
qsort en la biblioteca estándar (el nombre sugiere que la clasificación subyacente emplea una
clasificación rápida, pero el implementador de la biblioteca no está obligado a usar ese algoritmo).
Para usar qsort, tenemos que escribir una función de comparación. qsort llamará a esta
función lo que sea necesario para comparar dos elementos en la matriz y ver cuál debería
aparecer antes en orden. La función se pasa dos vacíos.
punteros. No hemos discutido los punteros todavía en este libro, pero todo lo que necesitas
saber aquí es que debes convertir esos punteros vacíos en punteros al tipo de elemento en tu
matriz. Entonces la función debería devolver un int, ya sea positivo, negativo o cero, según
si el primer elemento es mayor, menor o igual que el segundo elemento. El valor exacto
devuelto no importa, sólo si es positivo, negativo o cero. Aclaremos esta discusión con un
ejemplo rápido de cómo ordenar una matriz de 10 números enteros usando qsort. Nuestra
función de comparación:
int compareFunc( const void * voidA, const void * voidB) {
int * intA = (int *)(voidA);
int * intB = (int *)(voidB);
return *intA ­ *intB;
}
La lista de parámetros consta de dos punteros nulos constantes
. Nuevamente, este
es siempre el caso del comparador. El código dentro de la función comienza declarando dos
punteros int
y lanzando los dos punteros void al int
Resolver problemas con matrices 59
Machine Translated by Google
tipo de puntero. Podríamos escribir la función sin las dos variables temporales; Los incluyo aquí
para mayor claridad. El punto es que, una vez que hayamos terminado con esas declaraciones,
intA e intB apuntarán a dos elementos de nuestra matriz, y *intA e *intB serán dos números enteros
que deben compararse. Finalmente, devolvemos el resultado de restar el segundo número entero
del primer
. Esto produce el resultado que queremos. Si intA > intB, por ejemplo, queremos
devolver un número positivo, e intA – intB será positivo si intA > intB. Asimismo, intA – intB
será negativo si intB > intA y será cero cuando los dos números enteros sean iguales.
Con la función de comparación implementada, un ejemplo de uso de qsort se ve así:
constante int ARRAY_SIZE = 10;
int intArray[ARRAY_SIZE] = {87, 28, 100, 78, 84, 98, 75, 70, 81, 68};
qsort( intArray, ARRAY_SIZE, sizeof(int), compareFunc);
Como puede ver, la llamada a qsort toma cuatro parámetros: la matriz a ordenar
número de elementos en esa matriz
; el
; el tamaño de un elemento en la matriz, generalmente
determinado, como aquí, por el operador sizeof
; y finalmente, la función comparadora
. Si
no ha tenido mucha experiencia pasando funciones como parámetros a otras funciones,
observe la sintaxis utilizada para el último parámetro. Estamos pasando la función en sí, no
llamando a la función y pasando el resultado de la llamada. Por lo tanto, simplemente indicamos
el nombre de la función, sin lista de parámetros ni paréntesis.
Clasificación fácil de modificar con clasificación por inserción
En algunos casos, necesitarás escribir tu propio código de clasificación. A veces, la clasificación
incorporada simplemente no funciona para su situación. Por ejemplo, supongamos que tiene una
matriz de datos que desea ordenar en función de los datos de otra matriz.
Cuando tenga que escribir su propia clasificación, querrá una rutina de clasificación sencilla en la
que crea y que pueda implementar cuando lo solicite. Una sugerencia razonable para una
clasificación de acceso es una clasificación por inserción. La clasificación por inserción funciona
de la misma manera que muchas personas clasificarían las cartas cuando juegan al bridge:
recogen las cartas una a la vez y las insertan en el lugar apropiado de sus manos para
mantener el orden general, moviendo las otras cartas hacia abajo para hacer espacio.
Aquí hay una implementación básica para nuestra matriz de enteros:
int inicio = 0;
int fin = ARRAY_SIZE ­ 1;
for (int i = inicio + 1; i <= fin; i++) {
for ( int j = i; j > inicio && intArray[j­1] > intArray[j]; j­­) {
int temp = intArray[j­1];
intArray[j­1] = intArray[j];
intArray[j] = temporal;
}
}
Comenzamos declarando dos variables, inicio
y fin
, indicando el subíndice del primer
y último elemento de la matriz. Esto mejora la capacidad de lectura del código y también permite
que el código se modifique fácilmente para ordenar solo una parte de la matriz, si se desea. El
bucle exterior selecciona la siguiente “tarjeta” que se
60 Capítulo 3
Machine Translated by Google
insertado en nuestra mano cada vez más ordenada
. Observe que el bucle inicializa i para
comenzar + 1. Recuerde que en el código "encontrar el valor más grande", inicializamos
nuestra variable de valor más alto en el primer elemento de la matriz y comenzamos
nuestro bucle con el segundo elemento de la matriz. formación. Esta es la misma idea. Si
solo tenemos un valor (o “tarjeta”), entonces por definición está “en orden” y podemos
comenzar considerando si el segundo valor debe ir antes o después del primero. El bucle
interno coloca el valor actual en su posición correcta intercambiando repetidamente el
valor actual con su predecesor hasta que alcanza la ubicación correcta. El contador de
bucle j comienza en i
, y el bucle disminuye j siempre que no hayamos alcanzado el
extremo inferior de la matriz
correcto para este nuevo valor
y aún no hayamos encontrado el punto de parada
. Hasta entonces, usamos tres declaraciones de asignación
para intercambiar el valor actual una posición hacia abajo en la matriz
. En otras palabras,
si tuvieras una mano de 13 cartas y ya hubieras ordenado las 4 cartas más a la izquierda,
podrías colocar la quinta carta de la izquierda en la posición correcta moviéndola
repetidamente hacia abajo una carta hasta que ya no fuera de una carta más baja. valor que
la carta a su izquierda. Eso es lo que hace el bucle interior. El bucle exterior hace esto para
cada tarjeta comenzando desde el extremo izquierdo. Entonces, cuando terminemos, toda
la matriz estará ordenada.
Una ordenación por inserción no es la forma más eficiente en la mayoría de las
circunstancias y, a decir verdad, el código anterior ni siquiera es la forma más eficiente de
realizar una ordenación por inserción. Sin embargo, es razonablemente eficiente para
arreglos de tamaño pequeño a moderado y es lo suficientemente simple como para memorizarlo.
Piense en ello como una macro mental. Ya sea que elija este tipo u otro, debe tener una
rutina de clasificación decente o mejor que pueda codificar usted mismo con confianza. No
basta con tener acceso al código de clasificación de otra persona que no comprendes del
todo. No querrás jugar con la maquinaria si no estás seguro de cómo funciona todo.
Calcular estadísticas
La operación final es similar a la operación de recuperación, en el sentido de que debemos
observar cada elemento de la matriz antes de devolver un valor. Se diferencia de la
operación de recuperación en que el valor no es simplemente uno de los elementos de la
matriz, sino una estadística calculada a partir de todos los valores de la matriz. Por ejemplo,
podríamos calcular el promedio, la mediana o la moda, y realizaremos todos estos cálculos
más adelante en este capítulo. Una estadística básica que podríamos calcular podría ser el
promedio de un conjunto de calificaciones de estudiantes:
constante int ARRAY_SIZE = 10;
int gradeArray[ARRAY_SIZE] = {87, 76, 100, 97, 64, 83, 88, 92, 74, 95};
doble suma = 0;
para (int i = 0; i < ARRAY_SIZE; i++) {
suma += arreglodegrado[i];
}
media doble = suma/ARRAY_SIZE;
Como otro ejemplo sencillo, considere la validación de datos. Supongamos que una
matriz de valores dobles llamada sellerPayments representa pagos a proveedores. Sólo los
valores positivos son válidos y, por lo tanto, los valores negativos indican integridad de los datos.
Resolver problemas con matrices
61
Machine Translated by Google
problemas. Como parte de un informe de validación, podríamos escribir un bucle para
contar el número de valores negativos en la matriz:
constante int ARRAY_SIZE = 10;
int recuentoNegativo = 0;
para (int i = 0; i < ARRAY_SIZE; i++) {
if (vendorPayments[i] < 0) countNegative++;
}
Resolver problemas con matrices
Una vez que haya comprendido las operaciones comunes, resolver un problema con
matrices no es muy diferente a resolver problemas con datos simples, como lo hicimos en
el capítulo anterior. Tomemos un ejemplo y repasémoslo utilizando las técnicas del capítulo
anterior y cualquiera de las operaciones comunes para matrices que podamos necesitar.
PROBLEMA: ENCONTRAR EL MODO
En estadística, la moda de un conjunto de valores es el valor que aparece con más frecuencia. Escriba
código que procese una serie de datos de encuestas, donde los encuestados hayan respondido a una
pregunta con un número en el rango del 1 al 10, para determinar la moda del conjunto de datos.
Para nuestro propósito, si existen múltiples modos, se puede elegir cualquiera.
En este problema, se nos pide que recuperemos uno de los valores de una matriz.
Usando las técnicas de búsqueda de analogías y comenzando con lo que sabemos,
podemos esperar poder aplicar alguna variación de la técnica de recuperación que ya
hemos visto: encontrar el valor más grande en una matriz. Ese código funciona
almacenando el valor más grande visto hasta ahora en una variable. Luego, el código
compara cada valor posterior con esta variable y lo reemplaza si es necesario.
El método análogo aquí sería decir que almacenaríamos el valor visto con más frecuencia
hasta el momento en una variable y luego reemplazaríamos el valor en la variable cada
vez que descubriéramos un valor más común en la matriz. Cuando lo decimos así, en
inglés, casi suena como si pudiera funcionar, pero cuando pensamos en el código real,
descubrimos el problema. Echemos un vistazo a una matriz de muestra y una constante de
tamaño para este problema:
constante int ARRAY_SIZE = 12;
int encuestaData[ARRAY_SIZE] = {4, 7, 3, 8, 9, 7, 3, 9, 9, 3, 3, 10};
La moda de estos datos es 3 porque 3 aparece cuatro veces, que es más frecuente
que cualquier otro valor. Pero si procesamos esta matriz secuencialmente, como lo
hacemos para el problema del “valor más alto”, ¿en qué punto decidimos que 3 es nuestra
moda? ¿Cómo sabemos, cuando nos hemos encontrado con la cuarta y última aparición de
3 en la matriz, que de hecho es la cuarta y última aparición?
No parece haber ninguna manera de descubrir esta información con un procesamiento
único y secuencial de los datos de la matriz.
62 Capítulo 3
Machine Translated by Google
Así que pasemos a una de nuestras otras técnicas: simplificar el problema.
¿Qué pasaría si nos facilitáramos las cosas juntando todas las apariciones del mismo
número? Entonces, por ejemplo, ¿qué pasaría si los datos de nuestra encuesta de matriz de
muestra se vieran así?
int encuestaData[ARRAY_SIZE] = {4, 7, 7, 9, 9, 9, 8, 3, 3, 3, 3, 10};
Ahora los dos 7 están juntos, los 9 están juntos y los 3 están juntos. Con los datos
agrupados de esta manera, parece que deberíamos poder procesar secuencialmente la
matriz para encontrar el modo. Al procesar la matriz a mano, es fácil contar las apariciones
de cada valor, porque simplemente sigues contando la matriz hasta encontrar el primer
número que es diferente.
Sin embargo, convertir lo que podemos hacer en nuestra cabeza en declaraciones de
programación puede ser complicado. Entonces, antes de intentar escribir el código para este
problema simplificado, escribamos un pseudocódigo, que son declaraciones similares a la
programación que no son completamente en inglés o C++, sino algo intermedio. Esto nos
recordará lo que intentamos hacer con cada declaración que necesitamos escribir.
int más frecuente = ?;
int frecuencia más alta = ?;
int frecuenciaactual = 0;
para (int i = 0; i < ARRAY_SIZE; i++) {
FrecuenciaActual++;
if (surveyData[i] ES LA ÚLTIMA OCURRENCIA DE UN VALOR) {
if (frecuencia actual > frecuencia más alta) {
frecuencia más alta = frecuencia actual;
más frecuente = datos de encuesta[i];
}
FrecuenciaActual = 0;
}
}
No existe una forma correcta o incorrecta de escribir pseudocódigo y, si utiliza esta
técnica, debe adoptar su propio estilo. Cuando escribo pseudocódigo, tiendo a escribir C++
legal para cualquier declaración en la que ya estoy seguro y luego detallo en inglés los
lugares en los que todavía tengo que pensar. Aquí sabemos que necesitaremos una variable
(mostFrequent) para contener el valor encontrado con más frecuencia hasta el momento, que
al final del ciclo será el modo una vez que hayamos escrito todo correctamente. También
necesitamos una variable para almacenar la frecuencia con la que ocurre ese valor
(frecuencia más alta) para tener algo con qué comparar. Finalmente, necesitamos una
variable que podamos usar para contar el número de apariciones del valor que estamos
rastreando actualmente mientras procesamos secuencialmente la matriz (currentFrequency).
Sabemos que necesitamos inicializar nuestras variables. Para currentFrequency, lógicamente
tiene que comenzar en 0, pero aún no está claro cómo debemos inicializar las otras variables,
sin el otro código implementado. Así que simplemente coloquemos signos de interrogación
para recordarnos que debemos mirar eso nuevamente más tarde.
El bucle en sí es el mismo bucle de procesamiento de matrices que ya hemos visto,
por lo que ya está en su forma final . Dentro del ciclo, incrementamos la variable que
cuenta las apariciones del valor actual
y luego llegamos a la declaración fundamental.
Sabemos que debemos comprobar si hemos llegado al último
Resolver problemas con matrices 63
Machine Translated by Google
aparición de un valor particular
. El pseudocódigo nos permite saltarnos la lógica por
ahora y esbozar el resto del código. Sin embargo, si esta es la última aparición del valor,
sabemos qué hacer porque es como el código de "valor más alto": Necesitamos ver si el
recuento de este valor es mayor que el más alto visto hasta ahora. Si es así, este valor se
convierte en el nuevo valor más frecuente . Luego, debido a que el siguiente valor leído
será la primera aparición de un nuevo valor, reiniciamos nuestro contador .
Volvamos a la lógica de la declaración if que nos saltamos. ¿Cómo sabemos si
¿Esta es la última aparición de un valor en la matriz? Debido a que los valores de la matriz
están agrupados, sabemos si un valor es la última aparición cuando el siguiente valor de la
matriz es algo diferente: en términos de C++, cuando SurveyData[i]
y SurveyData[i + 1] no son iguales. Además, el último valor de la matriz también es la última
aparición de algún valor, aunque no haya un valor siguiente.
Podemos comprobar esto comprobando si i == ARRAY_SIZE ­ 1, en cuyo caso este es el
último valor de la matriz.
Con todo eso resuelto, pensemos en esos valores iniciales para nuestras variables.
Recuerde que con el código de procesamiento de matrices de “valor más alto”, inicializamos
nuestra variable “más alta hasta ahora” con el primer valor de la matriz. Aquí, el valor "visto
con más frecuencia" está representado por dos variables, mostFrequent para el valor en sí y
mosthighFrequency para el número de apariciones. Sería fantástico si pudiéramos inicializar
mostFrequent con el primer valor que aparece en la matriz y mosthighFrequency con su
recuento de frecuencia, pero no hay forma de determinar la frecuencia del primer valor hasta
que entremos en el bucle y comencemos a contar.
Llegados a este punto, se nos podría ocurrir que la frecuencia del primer valor, sea cual sea,
sería mayor que cero. Por lo tanto, si establecemos la frecuencia más alta en cero, una vez
que lleguemos a la última aparición del primer valor, nuestro código reemplazará la
frecuencia más frecuente y la frecuencia más alta con los números del primer valor de todos modos.
El código completo se ve así:
int más frecuente;
int frecuencia más alta = 0;
int frecuenciaactual = 0;
para (int i = 0; i < ARRAY_SIZE; i++) {
frecuenciaactual++;
// if (surveyData[i] ES LA ÚLTIMA OCURRENCIA DE UN VALOR)
if (i == ARRAY_SIZE ­ 1 || datosencuesta[i] != Datosencuesta[i + 1]) {
if (frecuencia actual > frecuencia más alta) {
frecuencia más alta = frecuencia actual;
más frecuente = datos de encuesta[i];
}
frecuencia actual = 0;
}
}
En este libro, no hablaremos mucho sobre temas de estilo puro, como el estilo de
documentación (comentarios), pero como estamos usando pseudocódigo en este problema,
quiero mencionar un consejo. He notado que las líneas que dejo en “inglés simple” en el
pseudocódigo son las que más se benefician de un comentario.
64 Capítulo 3
Machine Translated by Google
en el código final, y el inglés simple es un gran comentario. Lo he demostrado en el código aquí. Es
posible que olvides el significado exacto detrás de la expresión condicional en la declaración if
pero el comentario en la línea anterior
,
aclara las cosas muy bien.
En cuanto al código en sí, hace el trabajo, pero recuerda que requiere que los datos de nuestra
encuesta estén agrupados. Agrupar los datos puede ser un trabajo en sí mismo, excepto...
¿Qué pasa si ordenamos la matriz? En realidad, no necesitamos que los datos estén ordenados, pero
la clasificación logrará la agrupación que necesitamos. Como no pretendemos realizar ningún tipo
especial de clasificación, simplemente agreguemos esta llamada a qsort al principio de nuestro código:
qsort(surveyData, ARRAY_SIZE, sizeof(int), compareFunc);
Tenga en cuenta que estamos usando la misma compareFunc que escribimos anteriormente para
usar con qsort. Con el paso de clasificación implementado, tenemos una solución completa al problema
original. Entonces nuestro trabajo está hecho. ¿O es eso?
Refactorización
Algunos programadores hablan de código que desprende "malos olores". Están hablando de un código
funcional que esté libre de errores pero que aún sea problemático de alguna manera.
A veces esto significa que el código es demasiado complicado o tiene demasiados casos especiales,
lo que hace que al programador le resulte difícil modificar y mantener el programa. En otros casos, el
código no es tan eficiente como podría ser y, si bien funciona para casos de prueba, al programador le
preocupa que el rendimiento se vea afectado en casos más grandes. Esa es mi preocupación aquí.
El paso de clasificación es casi instantáneo para nuestro pequeño caso de prueba, pero ¿qué pasa si la
matriz es enorme? Además, sé que el algoritmo de clasificación rápida, que puede estar usando
qsort , tiene su rendimiento más bajo cuando hay muchos valores duplicados en la matriz, y el objetivo
de este problema es que todos nuestros valores están en el rango de 1 a 10. . Por tanto, propongo
refactorizar el código. Refactorizar significa mejorar el código de trabajo, no cambiar lo que hace sino
cómo lo hace. Quiero una solución que sea muy eficiente incluso para matrices grandes, suponiendo
que los valores estén en el rango de 1 a 10.
Pensemos nuevamente en las operaciones que sabemos hacer con matrices.
Ya hemos explorado varias versiones del código "encontrar el más alto". Sabemos que aplicar el
código "encontrar el más alto" directamente a nuestros datos de encuesta
La matriz no producirá resultados útiles. ¿Existe una matriz a la que podamos aplicar la versión "stock"
de "encontrar el más alto" y obtener la moda de los datos de la encuesta?
La respuesta es sí. La matriz que necesitamos es el histograma de la matriz SurveyData .
Un histograma es un gráfico que muestra con qué frecuencia aparecen valores diferentes en un conjunto
de datos subyacente; nuestra matriz serán los datos para dicho histograma. En otras palabras,
almacenaremos, en una matriz de 10 elementos, la frecuencia con la que aparece cada uno de los
valores del 1 al 10 en SurveyData. Aquí está el código para crear nuestro histograma:
constanteint MAX_RESPONSE = 10;
int histograma[MAX_RESPONSE];
Resolver problemas con matrices 65
Machine Translated by Google
para (int i = 0; i < MAX_RESPONSE; i++) {
histograma[i] = 0;
}
para (int i = 0; i < ARRAY_SIZE; i++) {
histograma[datosencuesta[i] ­ 1]++;
}
En la primera línea, declaramos la matriz que contendrá los datos de nuestro histograma
.
Notará que declaramos la matriz con 10 elementos, pero el rango de las respuestas de nuestra
encuesta es de 1 a 10 y el rango de subíndices para esta matriz es de 0 a 9. Por lo tanto, tendremos
que hacer ajustes, poniendo la cuenta de 1 en el histograma [0] y así sucesivamente. (Algunos
programadores elegirían declarar la matriz con 11 elementos, dejando la ubicación [0] sin usar, para
permitir que cada conteo entre en su posición natural).
Inicializamos explícitamente los valores de la matriz a cero con un bucle
, y luego estamos listos
para contar las apariciones de cada valor en SurveyData con otro bucle
.
La declaración dentro del bucle
debe leerse con atención; Estamos usando el valor
en la ubicación actual de SurveyData para decirnos qué posición en el histograma.
para incrementar. Para que esto quede claro, tomemos un ejemplo. Supongamos que i es 42.
Inspeccionamos SurveyData[42] y encontramos (digamos) el valor 7. Por lo tanto, necesitamos
incrementar nuestro contador de 7. Restamos 1 de 7 para obtener 6 porque el contador de 7 está en
la posición [6] en el histograma y el histograma [6] se incrementa.
Con los datos del histograma en su lugar, podemos escribir el resto del código. Tenga en
cuenta que el código del histograma se escribió por separado para poder probarlo por separado. No
se ahorra tiempo escribiendo todo el código a la vez en una situación en la que el problema se
separa fácilmente en partes que se pueden escribir y probar individualmente. Habiendo probado el
código anterior, ahora buscamos el valor más grande en la matriz del histograma :
int más frecuente = 0;
para (int i = 1; i < MAX_RESPONSE; i++) {
if (histograma[i] > histograma[másfrecuente])
}
más frecuente++;
másfrecuente = i;
Aunque se trata de una adaptación del código "encontrar el más alto", hay una diferencia.
Aunque estamos buscando el valor más alto en la matriz del histograma, en última instancia, no
queremos el valor en sí, sino la posición. En otras palabras, con nuestra matriz de muestra,
queremos saber que 3 ocurre con más frecuencia que cualquier otro valor en los datos de la
encuesta, pero el número real de veces que ocurre 3 no es importante. Por lo tanto, mostFrequent
será la posición del valor más alto en el histograma, no el valor más alto en sí. Por lo tanto, lo
inicializamos a 0
y no al valor en la ubicación [0]. Esto también significa que en la declaración if ,
comparamos con el histograma [más frecuente]
y no con el más frecuente , y asignamos i, no con
el histograma [i], a más frecuente
cuando se encuentra un valor mayor.
Finalmente, incrementamos mostFrequent
. Esto es lo contrario de lo que hicimos en el ciclo
anterior, restando 1 para obtener la posición correcta de la matriz. Si mostFrequent nos dice que la
posición más alta de la matriz es 5, por ejemplo, significa que la entrada más frecuente en los datos
de la encuesta fue 6.
66 Capítulo 3
Machine Translated by Google
La solución de histograma escala linealmente con la cantidad de elementos en nuestra
matriz SurveyData , que es lo mejor que podríamos esperar. Por lo tanto, es una mejor solución
que nuestro enfoque original. Esto no significa que el primer acercamiento haya sido un error o
una pérdida de tiempo. Por supuesto, es posible haber escrito este código sin pasar por la versión
anterior, y se nos puede perdonar que deseemos haber conducido directamente a nuestro destino
en lugar de tomar la ruta más larga. Sin embargo, le advierto que no se dé una palmada en la frente
en aquellas ocasiones en las que la primera solución resulta no ser la solución final. Escribir un
programa original (y recuerde que esto significa original para el programador que lo escribe) es
un proceso de aprendizaje y no se puede esperar que progrese siempre en línea recta. Además,
suele ocurrir que tomar un camino más largo en un problema nos ayuda a tomar un camino más
corto en un problema posterior.
En este caso particular, tenga en cuenta que nuestra solución original (si bien no se adapta bien a
nuestro problema particular) podría ser la solución correcta si las respuestas de la encuesta
no se limitaran estrictamente al pequeño rango de 1 a 10. O supongamos que luego se le pide que
escriba un código que encuentre la mediana de un conjunto de valores enteros (la mediana es el
valor en el medio, de modo que la mitad de los otros valores en el conjunto son mayores y la mitad
de los otros valores son más bajos). El enfoque del histograma no te lleva a ninguna parte con la
mediana, pero nuestro primer enfoque para la moda sí.
La lección aquí es que un viaje largo no es una pérdida de tiempo si aprendes algo que no
habrías aprendido si hubieras seguido el camino corto.
Esta es otra razón por la que resulta útil almacenar metódicamente todo el código que escribe
para poder encontrarlo y reutilizarlo fácilmente más adelante. Incluso el código que resulta ser un
“callejón sin salida” puede convertirse en un recurso valioso.
Matrices de datos fijos
En la mayoría de los problemas de arreglo, el arreglo es un depósito de datos externos al programa,
como datos ingresados por el usuario, datos en un disco local o datos de un servidor.
Sin embargo, para aprovechar al máximo la herramienta de matriz, es necesario reconocer otras
situaciones en las que se puede utilizar una matriz. A menudo resulta útil crear una matriz donde
los valores nunca cambian después de la inicialización. Una matriz de este tipo puede permitir
un bucle simple o incluso una búsqueda directa en la matriz para reemplazar un bloque completo
de declaraciones de control.
En el código final para el problema "Decodificar un mensaje" en la página 52, utilizamos
una instrucción de cambio para traducir el número de entrada decodificado (en el rango 1­8)
al carácter apropiado cuando estamos en "modo de puntuación" porque la conexión entre los
El número y el carácter eran arbitrarios. Aunque esto funcionó bien, hizo que esa sección de
código fuera más larga que el código equivalente para los modos mayúsculas y minúsculas, y el
código no escalaría bien si aumentara la cantidad de símbolos de puntuación. Podemos usar
una matriz para resolver este problema en lugar de la declaración de cambio . Primero,
necesitamos asignar permanentemente los símbolos de puntuación a una matriz en el mismo
orden en que aparecen en el esquema de codificación:
const char puntuación[8] = {'!', '?', ',', '.', ' ', ';', '"', '\''};
Resolver problemas con matrices 67
Machine Translated by Google
Observe que esta matriz se ha declarado constante porque los valores internos nunca
cambiarán. Con esa declaración implementada, podemos reemplazar toda la declaración de cambio
con una única declaración de asignación que haga referencia a la matriz:
carácterdesalida = puntuación[número ­ 1];
Debido a que el número de entrada está en el rango de 1 a 8, pero los elementos de la matriz
están numerados a partir de 0, tenemos que restar 1 del número de entrada antes de hacer
referencia a la matriz; Este es el mismo ajuste que hicimos en la versión de histograma del
programa "Encontrar el modo". Puedes usar la misma matriz para ir en la otra dirección. Supongamos
que en lugar de decodificar el mensaje, tuviéramos que codificar un mensaje; es decir, nos dieran
una serie de caracteres para convertirlos en números que pudieran decodificarse usando las reglas
del problema original. Para convertir un símbolo de puntuación en su número, tenemos que ubicar
el símbolo en la matriz. Se trata de una recuperación realizada mediante la técnica de búsqueda
secuencial. Suponiendo que el carácter se va a convertir y almacenar en la variable char
targetValue, podríamos adaptar el código de búsqueda secuencial de la siguiente manera:
constante int ARRAY_SIZE = 8;
int TargetPos = 0;
while (puntuación[targetPos] != targetValue && targetPos < ARRAY_SIZE)
Pos.objetivo++;
int código de puntuación = targetPos + 1;
Tenga en cuenta que así como tuvimos que restar 1 del número en el ejemplo anterior para
obtener la posición correcta de la matriz, en este ejemplo tenemos que sumar 1 a la posición de la
matriz para obtener nuestro código de puntuación, convirtiendo del rango de la matriz de 0 a 7 a
nuestro rango de códigos de puntuación del 1 al 8. Aunque este código no es tan simple como
una sola línea, es mucho más simple que una serie de declaraciones de cambio y se escala bien. Si
duplicáramos la cantidad de símbolos de puntuación en nuestro esquema de codificación, se
duplicaría la cantidad de elementos en la matriz, pero la longitud del código permanecería igual.
Entonces, en general, las matrices constantes se pueden utilizar como tablas de búsqueda,
reemplazando una serie engorrosa de declaraciones de control. Suponga que está escribiendo un
programa para calcular el costo de una licencia comercial en un estado donde el costo de la licencia
varía a medida que varían las cifras de ventas brutas de la empresa.
Tabla 3­1: Costos de licencia comercial
68 Capítulo 3
Categoría de negocios
Umbral de ventas
Costo de la licencia
I
$0
$25
II
$50.000
$200
III
$150.000
$1,000
IV
$500.000
$5,000
Machine Translated by Google
Con este problema, podríamos usar matrices tanto para determinar la categoría comercial en
función de las ventas brutas de la empresa como para asignar el costo de la licencia en función de la
categoría comercial. Supongamos que una variable doble , Ventasbrutas, almacena las ventas brutas de
una empresa y, según la cifra de ventas, queremos asignar los valores adecuados a categoría int y costo
doble:
const int NUM_CATEGORIAS = 4;
const doble categoríaUmbrales[NUM_CATEGORIES] =
{0,0, 50000,0, 150000,0, 500000,0};
const doble costo de licencia [NUM_CATEGORIES] = {50.0,
200.0, 1000.0, 5000.0};
categoría = 0;
while (categoría < NUM_CATEGORIES &&
categoríaUmbrales[categoría] <= ventasbrutas) {
categoría++;
}
costo = costo de licencia[categoría ­ 1];
Este código utiliza dos matrices de valores fijos. La primera matriz almacena el umbral de ventas
brutas para cada categoría comercial
. Por ejemplo, una empresa con $65 000 en ventas brutas
anuales está en la categoría II porque esta cantidad excede el umbral de $50 000 de la categoría II
pero es menor que el umbral de $150 000 de la categoría III. La segunda matriz almacena el costo de
una licencia comercial para cada categoría
en 0
. Con las matrices en su lugar, inicializamos la categoría
y buscamos en la matriz categoríaUmbrales , deteniéndonos cuando el umbral excede las ventas
brutas o cuando nos quedamos sin categorías
. En cualquier caso, cuando finalice el ciclo, a la
categoría se le asignará correctamente del 1 al 4 según las ventas brutas. El último paso es utilizar la
categoría para hacer referencia al costo de la licencia de la matriz LicenseCost
.
Como antes, tenemos que hacer un pequeño ajuste del rango 1 a 4 de las categorías comerciales
al rango 0 a 3 de nuestra matriz.
Matrices no escalares
Hasta ahora, sólo hemos trabajado con matrices de tipos de datos simples, como int y double. Sin
embargo, a menudo los programadores deben trabajar con matrices de datos compuestos, ya sean
estructuras u objetos (estructura o clase). Aunque el uso de tipos de datos compuestos necesariamente
complica un poco el código, no tiene por qué complicar nuestro pensamiento sobre el procesamiento de
matrices. Por lo general, el procesamiento de la matriz solo involucra un miembro de datos de la
estructura o clase, y podemos ignorar las otras partes de la estructura de datos. A veces, sin embargo,
el uso de tipos de datos compuestos requiere que hagamos algunos cambios en nuestro enfoque.
Por ejemplo, consideremos el problema de encontrar la más alta de un conjunto de calificaciones
de estudiantes. Supongamos que en lugar de una matriz de int, tenemos una matriz de estructuras de
datos, cada una de las cuales representa el registro de un estudiante:
estudiante de estructura {
grado internacional;
int ID de estudiante;
nombre de cadena;
};
Resolver problemas con matrices 69
Machine Translated by Google
Lo bueno de trabajar con matrices es que es fácil inicializar una matriz completa con
valores literales para realizar pruebas fácilmente, incluso con una matriz de estructura:
constante int ARRAY_SIZE = 10;
estudiante StudentArray[ARRAY_SIZE] = {
{87, 10001, "Fred"},
{28, 10002, "Tomás"},
{100, 10003, "Alistair"},
{78, 10004, "Sasha"},
{84, 10005, "Erin"},
{98, 10006, "Belinda"},
{75, 10007, "Leslie"},
{70, 10008, "Dulces"},
{81, 10009, "Aretha"},
{68, 10010, "Verónica"}
};
Esta declaración significa que StudentArray[0] tiene 87 para su calificación, 10001 para
su ID de estudiante y "Fred" como nombre, y así sucesivamente para los otros nueve
elementos de la matriz. En cuanto al resto del código, podría ser tan simple como copiar el
código del principio de este capítulo y luego reemplazar cada referencia del formato
intArray[subíndice] con estudianteArray[subíndice].calificación. Eso daría como resultado lo
siguiente:
int más alto = StudentArray[0].grado;
para (int i = 1; i < ARRAY_SIZE; i++) {
if (studentArray[i].calificación > más alto) más alto = StudentArray[i].calificación;
}
Supongamos, en cambio, que como ahora tenemos información adicional para cada
estudiante, queremos encontrar el nombre del estudiante con la mejor calificación, no la
calificación en sí. Esto requeriría modificaciones adicionales. Cuando nuestro ciclo termina,
la única estadística que tenemos es la mejor nota, y eso no nos permite determinar
directamente a qué alumno pertenece. Tendríamos que recorrer la matriz nuevamente,
buscando la estructura con la calificación coincidente , lo que parece un trabajo adicional que
no deberíamos tener que hacer. Para evitar este problema, debemos realizar un seguimiento
adicional del nombre del estudiante que coincide con el valor actual más alto o, en lugar
de realizar un seguimiento de la calificación más alta, realizar un seguimiento de la ubicación
en la matriz donde se encuentra la calificación más alta, de forma muy similar a lo que
hicimos con histograma anteriormente. El último enfoque es el más general porque el
seguimiento de la posición de la matriz nos permite recuperar cualquiera de los datos de ese estudiante más
int posiciónalta = 0;
para (int i = 1; i < ARRAY_SIZE; i++) {
if (matrizestudiante[i].grado > Arrayestudiante[posiciónalta].calificación) {
Posiciónalta = i;
}
}
70 Capítulo 3
Machine Translated by Google
Aquí la variable highPosition
ocupa el lugar de la más alta. Debido a que no estamos rastreando
directamente la calificación más cercana al promedio, cuando llega el momento de comparar la calificación
más cercana con la calificación actual, usamos highPosition como referencia en StudentArray
. Si la
calificación en la posición actual de la matriz es mayor, la posición actual en nuestro bucle de procesamiento
se asigna a highPosition
.
Una vez finalizado el ciclo, podemos acceder al nombre del estudiante con la calificación más cercana al
promedio usando StudentArray[highPosition].name, y también podemos acceder a cualquier otro dato
relacionado con ese registro de estudiante.
Matrices multidimensionales
Hasta ahora, sólo hemos analizado los arreglos unidimensionales porque son los más comunes. Las matrices
bidimensionales son poco comunes y las matrices con tres o más dimensiones son raras. Esto se debe a que
la mayoría de los datos son unidimensionales por naturaleza. Además, los datos que son inherentemente
multidimensionales pueden representarse como múltiples matrices unidimensionales, por lo que utilizar una
matriz multidimensional siempre es elección del programador. Considere los datos de licencias comerciales de
la Tabla 3­1. Se trata claramente de datos multidimensionales. Quiero decir, mírenlo: ¡es una cuadrícula!
Sin embargo, representé estos datos multidimensionales como dos matrices unidimensionales,
CategoryThresholds y LicenseCost. Podría haber representado la tabla de datos como una matriz bidimensional,
así:
const doble licenciaData[2][númeroCategorías] = {
{0,0, 50000,0, 150000,0, 500000,0}, {50,0,
200,0, 1000,0, 5000,0}
};
Es difícil discernir alguna ventaja al combinar las dos matrices en una. Ninguno de nuestros códigos está
simplificado porque no hay razón para procesar todos los datos de la tabla a la vez. Sin embargo, lo que está
claro es que hemos reducido la legibilidad y la facilidad de uso de los datos de nuestra tabla. En la versión
original, los nombres de las dos matrices separadas dejan claro qué datos se almacenan en cada una.
Con la matriz combinada, los programadores tendremos que recordar que las referencias del formulario
LicenseData[0][] se refieren a los umbrales de ventas brutas de las diferentes categorías comerciales,
mientras que las referencias del formulario LicenseData[1][]
consulte los costos de la licencia comercial.
Sin embargo, a veces tiene sentido utilizar una matriz multidimensional.
Supongamos que estamos procesando los datos de ventas mensuales de tres agentes de ventas y una de las
tareas es encontrar las ventas mensuales más altas de cualquier agente. Tener todos los datos en una matriz
de 3 x 12 significa que podemos procesar toda la matriz a la vez, usando bucles anidados:
const int NUM_AGENTES = 3;
constante int NUM_MESES = 12;
int ventas[NUM_AGENTS][NUM_MONTHS] = {
{1856, 498, 30924, 87478, 328, 2653, 387, 3754, 387587, 2873, 276, 32},
{5865, 5456, 3983, 6464, 9957, 4785, 3875, 3838, 4959, 1122, 7766, 2534},
{23, 55, 67, 99, 265, 376, 232, 223, 4546, 564, 4544, 3434}
};
Resolver problemas con matrices
71
Machine Translated by Google
int ventas más altas = ventas[0][0];
for ( int agente = 0; agente < NUM_AGENTES; agente++) {
for ( int mes = 0; mes < NUM_MESES; mes++) {
if (ventas[agente][mes] > mayoresVentas)
ventasmayores = ventas[agente][mes];
}
}
Aunque se trata de una adaptación sencilla del código básico de “encontrar el número más
grande”, existen algunas arrugas. Cuando declaramos nuestra matriz bidimensional, observe
que el inicializador está organizado por agente, es decir, como 3 grupos de 12, no 12 grupos de
3
. Como verá en el siguiente problema, esta decisión puede tener consecuencias. Inicializamos
ventasmásaltas en el primer elemento de la matriz, como de costumbre
. Se le puede ocurrir
que la primera vez que pase por los bucles anidados, ambos contadores de bucles serán 0, por
lo que compararemos este valor inicial de ventas más altas consigo mismo. Esto no afecta el
resultado, pero a veces los programadores novatos intentarán evitar esta pequeña ineficiencia
colocando una segunda declaración if en el cuerpo del bucle interno:
if (agente != 0 || mes != 0)
if (ventas[agente][mes] > mayoresVentas)
ventasmayores = ventas[agente][mes];
Esto, sin embargo, es considerablemente menos eficiente que la versión anterior
porque estaríamos realizando 50 comparaciones adicionales evitando solo una.
Observe también que he usado nombres significativos para las variables del bucle:
agente para el bucle externo
y mes para el bucle interno
. En un bucle único que procesa
una matriz unidimensional, un identificador descriptivo gana poco. Sin embargo, en un bucle
doble que procesa una matriz bidimensional, los identificadores significativos me ayudan a
mantener mis dimensiones y subíndices correctos porque puedo mirar hacia arriba y ver que
estoy usando agente en la misma dimensión donde usé numAgents en la declaración de
matriz .
Incluso cuando tenemos una matriz multidimensional, a veces el mejor enfoque es
tratar sólo con una dimensión a la vez. Supongamos que usando las mismas ventas
matriz como el código anterior, queríamos mostrar el promedio de ventas mensual más alto del
agente. Podríamos hacer esto usando un bucle doble, como lo hicimos anteriormente, pero el
código sería más claro de leer y más fácil de escribir si tratáramos toda la matriz como tres
matrices individuales y las procesáramos por separado.
Recuerde el código que hemos estado usando repetidamente para calcular el promedio.
¿Edad de una serie de números enteros? Convirtamos eso en una función:
doble matrizPromedio(int intArray[], int ARRAY_SIZE) {
doble suma = 0;
para (int i = 0; i < ARRAY_SIZE; i++) {
suma += intArray[i];
}
media doble = (suma + 0,5) / ARRAY_SIZE;
promedio de retorno;
}
72 Capítulo 3
Machine Translated by Google
Con la función implementada, podemos modificar nuevamente el básico “buscar el
número más grande” para encontrar el agente con el promedio de ventas mensual más alto:
int promediomás alto =
arrayPromedio(ventas[0], 12);
for (int agente = 1; agente < NUM_AGENTS; agente++) {
int agentePromedio =
arrayPromedio(ventas[agente], 12);
if (agentePromedio > mayorPromedio)
promedio más alto = promedio del agente;
}
cout << "Promedio mensual más alto:
"
<< promedio más alto << "\n";
La gran idea nueva aquí se muestra en las dos llamadas a arrayAverage. El primer
parámetro aceptado por esta función es una matriz unidimensional de int.
En la primera llamada, pasamos ventas[0] para el primer argumento
llamada, pasamos ventas[agente]
, y en la segunda
. Entonces, en ambos casos, especificamos un subíndice
para la primera dimensión de nuestras ventas de matriz bidimensional , pero no para la
segunda dimensión. Debido a la relación directa entre matrices y direcciones en C++, esta
referencia indica la dirección del primer elemento de la fila especificada, que luego puede ser
utilizada por nuestra función como dirección base de una matriz unidimensional que consta
solo de eso. fila.
Si esto suena confuso, mire nuevamente la declaración de la matriz de ventas ,
y en particular, el inicializador. Los valores se presentan en el inicializador en el mismo orden
en que se distribuirán en la memoria cuando se esté ejecutando el programa. Entonces, las
ventas[0][0], que son 1856, serán las primeras, seguidas de las ventas[0][1], 498, y así
sucesivamente hasta el último mes para el primer agente, las ventas[0][11], 32. Luego
comenzarán los valores para el segundo agente, comenzando con ventas[1][0], 5865.
Por lo tanto, aunque conceptualmente la matriz consta de 3 filas de 12 valores, se presenta en
la memoria como una gran secuencia de 36 valores.
Es importante tener en cuenta que esta técnica funciona debido al orden en que
colocamos los datos en la matriz. Si la matriz estuviera organizada según el otro eje, es
decir, por mes en lugar de por agente, no podríamos hacer lo que estamos haciendo aquí. La
buena noticia es que existe una manera sencilla de asegurarse de haber configurado la matriz
correctamente: simplemente verifique el inicializador. Si los datos que desea procesar
individualmente no son contiguos en el inicializador de matriz, ha organizado los datos de
manera incorrecta.
Lo último que hay que tener en cuenta sobre este código es el uso de la variable
temporal agentAverage. Debido a que potencialmente se hace referencia a las ventas
mensuales promedio del agente actual dos veces, una vez en la expresión condicional de if
declaración y luego nuevamente en la declaración de asignación en el cuerpo, la variable
temporal elimina la posibilidad de llamar a arrayAverage dos veces para los datos del mismo
agente.
Esta técnica de considerar una matriz multidimensional como una matriz de matrices
se deriva directamente de nuestro principio central de dividir los problemas en componentes
más simples y, en general, hace que los problemas de matrices multidimensionales sean
mucho más fáciles de conceptualizar. Aun así, puede que estés pensando que la técnica
parece un poco complicada de emplear y, si eres como la mayoría de los nuevos programadores
de C++, probablemente desconfíes un poco de las direcciones y de las direcciones detrás de escena.
Resolver problemas con matrices 73
Machine Translated by Google
aritmética. Creo que la mejor manera de evitar esos sentimientos es hacer que la separación
entre las dimensiones sea aún más fuerte, colocando un nivel de matriz dentro de una estructura
o clase. Supongamos que creamos una estructura de agente:
estructura agenteEstructura {
int ventas mensuales[12];
};
Después de tomarnos la molestia de crear una estructura, podríamos pensar en agregar
otros datos, como un número de identificación del agente, pero esto hará el trabajo en términos
de simplificar nuestros procesos de pensamiento. Con la estructura implementada, en lugar de
crear una matriz bidimensional de ventas, creamos una matriz unidimensional de agentes:
AgentStruct agentes[3];
Ahora, cuando hacemos nuestra llamada a la función de promedio de matrices, no
estamos empleando un truco específico de C++; solo estamos pasando una matriz unidimensional.
Por ejemplo:
int promediomás alto = promedioarray(agentes[1].ventasmensuales, 12);
Decidir cuándo utilizar matrices
Una matriz es solo una herramienta. Como ocurre con cualquier herramienta, una parte
importante de aprender a utilizar una matriz es saber cuándo utilizarla y cuándo no. Los
problemas de muestra discutidos hasta ahora asumieron el uso de matrices en sus descripciones.
Sin embargo, en la mayoría de las situaciones, no tendremos este detalle detallado y, en
cambio, debemos tomar nuestra propia determinación sobre el uso de la matriz. Las situaciones
más comunes en las que debemos tomar esta decisión son aquellas en las que se nos dan
datos agregados pero no se nos dice cómo deben almacenarse internamente. Por ejemplo, en
el problema donde encontramos el modo, supongamos que la línea que comienza escribe
. ., había leído Escribir código que procesa
código que procesa una matriz de datos de
encuesta. recopilación de
un archivo . . Ahora la elección de usar una matriz o no sería
datos de encuestas. . nuestro. ¿Cómo tomaríamos esta decisión?
Recuerde que no podemos cambiar el tamaño de una matriz una vez creada. Si nos
quedáramos sin espacio, nuestro programa fracasaría. Entonces, la primera consideración es si
sabremos, en el lugar de nuestro programa donde necesitamos una estructura de datos
agregados, cuántos valores almacenaremos o al menos una estimación confiable del tamaño
máximo. Esto no significa que tengamos que saber el tamaño de la matriz cuando escribimos
el programa. C++, así como la mayoría de los otros lenguajes, nos permite crear una matriz cuyo
tamaño se dimensiona en tiempo de ejecución. Supongamos que el problema del modo se
modificó de modo que no supiéramos de antemano cuántas respuestas a la encuesta
tendríamos, pero ese número llegó al programa como entrada del usuario. Luego podríamos
declarar dinámicamente una matriz para almacenar los datos de la encuesta.
74 Capítulo 3
Machine Translated by Google
intARRAY_SIZE;
cout << "Número de respuestas a la encuesta: ";
cin >> ARRAY_SIZE;
int *surveyData = nuevo int[ARRAY_SIZE];
para(int i = 0; i < ARRAY_SIZE; i++) {
"
<< i + 1 << ": ";
cout << "Respuesta de la encuesta
cin >> datosencuesta[i];
}
Declaramos la matriz usando notación de puntero, inicializándola mediante una
invocación del nuevo operador
. Debido a la fluidez entre los tipos de puntero y matriz en C+
+, se puede acceder a los elementos usando la notación de matriz
, aunque SurveyData se
declare como un puntero. Tenga en cuenta que debido a que esta matriz se asigna
dinámicamente, al final del programa, cuando ya no necesitamos la matriz, debemos asegurarnos
de desasignarla:
eliminar[] datos de encuesta;
El operador eliminar[] , en lugar del operador de eliminación habitual , se utiliza para
matrices. Aunque no hará ninguna diferencia con una matriz de números enteros, si crea una
matriz de objetos, el operador eliminar[] garantiza que los objetos individuales de la matriz se
eliminen antes de que se elimine la matriz misma. Por lo tanto, debería adoptar el hábito de
utilizar siempre eliminar [] con matrices asignadas dinámicamente.
Tener la responsabilidad de limpiar la memoria dinámica es la pesadilla del programador
de C++, pero si programa en el lenguaje, es algo que simplemente debe hacer. Los
programadores principiantes a menudo eluden esta responsabilidad porque sus programas
son tan pequeños y se ejecutan durante períodos de tiempo tan cortos que nunca ven los
efectos dañinos de las pérdidas de memoria (memoria que ya no es utilizada por el programa
pero que nunca se desasigna y, por lo tanto, no está disponible para el programa). resto del
sistema). No desarrolles este mal hábito.
Tenga en cuenta que podemos usar la matriz dinámica solo porque el usuario nos dice de
antemano el número de respuestas de la encuesta. Considere otra variante donde el usuario
comienza ingresando respuestas de la encuesta sin decirnos el número de respuestas,
indicando que no hay más respuestas ingresando un –1 (un método de ingreso de datos
conocido como centinela ). ¿Podemos seguir usando una matriz para resolver este problema?
Esta es un área gris. Aún podríamos usar una matriz si tuviéramos un número máximo
garantizado de respuestas. En tal caso, podríamos declarar una matriz de ese tamaño y asumir
que estamos a salvo. Sin embargo, es posible que todavía tengamos preocupaciones a largo
plazo. ¿Qué pasa si el tamaño del grupo de encuestas aumenta en el futuro?
¿Qué pasa si queremos utilizar el mismo programa con un encuestador diferente? En términos
más generales, ¿por qué crear un programa con una limitación conocida si podemos evitarla?
Mejor entonces utilizar una recopilación de datos sin un tamaño fijo. Como se analizó
anteriormente, la clase vectorial de la biblioteca de plantillas estándar de C++ actúa como una
matriz pero crece según sea necesario. Una vez declarado e inicializado, el vector se puede
procesar exactamente de la misma manera que una matriz. Podemos asignar un valor a un
elemento vectorial o recuperar un valor usando la notación de matriz estándar. si el vector
Resolver problemas con matrices 75
Machine Translated by Google
ha llenado su tamaño inicial y necesitamos agregar otro elemento, podemos hacerlo usando el
método push_back . Resolver el problema modificado con un vector se ve así:
vector<int> datos de encuesta;
encuestaData.reserve(30);
int encuestaRespuesta;
cout << "Ingrese la siguiente respuesta a la encuesta o ­1 para finalizar: ";
cin >> encuestaRespuesta;
while (respuestaencuesta! = ­1) {
surveyData.push_back(respuestaencuesta);
cout << "Ingrese la siguiente respuesta a la encuesta o ­1 para finalizar: ";
cin >> encuestaRespuesta;
}
int vectorSize = encuestaData.size();
constanteint MAX_RESPONSE = 10;
int histograma[MAX_RESPONSE];
para (int i = 0; i < MAX_RESPONSE; i++) {
histograma[i] = 0;
}
for (int i = 0; i < tamañovector; i++) {
histograma[datosencuesta[i] ­ 1]++;
}
int más frecuente = 0;
para (int i = 1; i < MAX_RESPONSE; i++) {
if (histograma[i] > histograma[másfrecuente]) másfrecuente = i;
}
más frecuente++;
En este código, primero declaramos el vector
Muchas respuestas
y luego reservamos espacio para 30 sur­
. El segundo paso no es estrictamente necesario, pero reservar una
pequeña cantidad de espacio que exceda el número probable de elementos evita que el vector
cambie de tamaño con frecuencia a medida que le agregamos valores. Leemos el primer grado
antes del ciclo de entrada de datos
, una técnica que utilizamos por primera vez en el capítulo
anterior y que nos permite verificar cada valor ingresado antes de procesarlo. En este caso,
queremos evitar agregar el valor centinela, –1, a nuestro vector.
Los resultados de la encuesta se agregan al vector usando el método push_back
. Una vez
completado el ciclo de entrada de datos, recuperamos el tamaño del vector utilizando el método
de tamaño . También podríamos haber contado el número de elementos nosotros mismos
en el bucle de entrada de datos, pero como el vector ya sigue su tamaño, esto evita esfuerzos
duplicados. El resto del código es igual que la versión anterior con el array y el número fijo de
respuestas, excepto que hemos cambiado los nombres de las variables.
Sin embargo, toda esta discusión sobre los vectores pasa por alto un punto importante. Si
leemos los datos directamente del usuario, en lugar de que nos digan que estamos comenzando
con una matriz u otra recopilación de datos, es posible que no necesitemos una matriz para los
datos de la encuesta, solo una para el histograma. En cambio, podemos procesar los valores de
la encuesta a medida que los leemos. Necesitamos una estructura de datos sólo cuando necesitamos
76 Capítulo 3
Machine Translated by Google
leer todos los valores antes de procesarlos o necesita procesar los valores más de una vez. En
este caso, tampoco necesitamos hacer:
constanteint MAX_RESPONSE = 10;
int histograma[MAX_RESPONSE];
para (int i = 0; i < MAX_RESPONSE; i++) {
histograma[i] = 0;
}
int encuestaRespuesta;
cout << "Ingrese la siguiente respuesta a la encuesta o ­1 para finalizar: ";
cin >> encuestaRespuesta;
while (respuestaencuesta! = ­1) {
histograma[respuestaencuesta ­ 1]++;
cout << "Ingrese la siguiente respuesta a la encuesta o ­1 para finalizar: ";
cin >> encuestaRespuesta;
} int más frecuente = 0;
para (int i = 1; i < MAX_RESPONSE; i++) {
if (histograma[i] > histograma[másfrecuente]) másfrecuente = i;
}
más frecuente++;
Aunque este código fue fácil de escribir, dadas las versiones anteriores como guía, habría
sido aún más fácil simplemente leer los datos del usuario en una matriz y usar el ciclo de
procesamiento anterior palabra por palabra. El beneficio de este enfoque de proceso sobre la
marcha es la eficiencia. Evitamos almacenar innecesariamente cada una de las respuestas
de la encuesta, cuando necesitamos almacenar solo una respuesta a la vez. Nuestra
solución basada en vectores era ineficiente en el espacio: ocupaba más espacio del necesario
sin proporcionar el beneficio correspondiente. Además, leer todas las calificaciones en el vector
requería un bucle propio, separado de los bucles, para procesar todas las respuestas de la
encuesta y encontrar el valor más alto en el histograma. Eso significa que la versión vectorial
hace más trabajo que la versión anterior. Por lo tanto, la versión vectorial también es ineficiente
en el tiempo: hace más trabajo del necesario sin proporcionar el beneficio correspondiente. En
algunos casos, diferentes soluciones ofrecen compensaciones y los programadores deben
decidir entre eficiencia de espacio y eficiencia de tiempo. En este caso, sin embargo, el uso del
vector hace que el programa sea ineficaz en todos sus aspectos.
En este libro no dedicaremos mucho tiempo a rastrear cada ineficiencia.
En ocasiones, los programadores deben participar en el ajuste del rendimiento, que es el
análisis sistemático y la mejora de la eficiencia de un programa en el tiempo y el espacio.
La puesta a punto del rendimiento de un programa es muy parecida a la puesta a punto del
rendimiento de un coche de carreras: un trabajo exigente, donde pequeños ajustes pueden
tener grandes efectos y se requiere un conocimiento experto de cómo funcionan los
mecanismos "debajo del capó". Sin embargo, incluso si no tenemos el tiempo, el deseo o el
conocimiento para ajustar completamente el desempeño de un programa, debemos evitar
decisiones que conduzcan a graves ineficiencias. Usar un vector o una matriz innecesariamente
no es como un motor con una mezcla de combustible y aire demasiado pobre; es como conducir
un autobús a la playa de vacaciones cuando podrías haber cabido todo lo que llevabas en un Honda Civic.
Resolver problemas con matrices 77
Machine Translated by Google
Si estamos seguros de que necesitamos procesar los datos varias veces y conocemos bien el
tamaño máximo del conjunto de datos, el último criterio para decidir si usar una matriz es el acceso
aleatorio. Más adelante, analizaremos estructuras de datos alternativas, como listas, que, al igual que
los vectores, pueden crecer según sea necesario pero, a diferencia de los vectores y las matrices, solo
se puede acceder a los elementos de forma secuencial. Es decir, si queremos acceder al décimo
elemento de una lista, tenemos que recorrer los primeros 9 elementos para llegar a él. Por el contrario,
el acceso aleatorio significa que podemos acceder a cualquier elemento de una matriz o vector en
cualquier momento. Entonces, la última regla es que debemos usar una matriz cuando necesitemos
acceso aleatorio. Si sólo necesitamos acceso secuencial, podríamos considerar una estructura
diferente.
Quizás observe que muchos de los programas de este capítulo fallan en este último criterio;
accedemos a los datos de forma secuencial, no aleatoria, y aun así estamos usando una matriz.
Esto lleva a la gran excepción de sentido común a todas estas reglas. Si una matriz es pequeña,
entonces ninguna de las objeciones anteriores tiene mucho peso. Lo que constituye "pequeño"
puede variar según la plataforma o aplicación. El punto es que, si su programa necesita una
colección de tan solo 1 o hasta 10 elementos, cada uno de los cuales requiere 10 bytes, debe
considerar si el posible desperdicio de 90 bytes que podría resultar de la asignación de una matriz
del máximo Vale la pena buscar el tamaño requerido para encontrar una mejor solución.
Utilice las matrices con prudencia, pero no permita que lo perfecto sea enemigo de lo bueno.
Ejercicios
Como siempre, te recomiendo que pruebes tantos ejercicios como puedas soportar.
3­1. ¿Estás decepcionado de que no hicimos más con la clasificación? Estoy aqui para ayudar. Para asegurarse
de que se siente cómodo con qsort, escriba código que use la función para ordenar una matriz de
nuestra estructura de estudiantes. Primero ordene por grado y luego inténtelo nuevamente usando la
identificación del estudiante.
3­2.
Vuelva a escribir el código que encuentra al agente con el mejor promedio de ventas mensual para
que encuentre al agente con la mediana de ventas más alta. Como se dijo anteriormente, la mediana
de un conjunto de valores es “la que está en el medio”, de modo que la mitad de los otros valores son
más altos y la mitad de los otros valores son más bajos. Si hay un número par de valores, la mediana
es el promedio simple de los dos valores del medio.
Por ejemplo, en el conjunto 10, 6, 2, 14, 7, 9, los valores del medio son 7 y 9.
El promedio de 7 y 9 es 8, por lo que 8 es la mediana.
3­3. Escriba una función bool a la que se le pase una matriz y el número de elementos de esa matriz y determine
si los datos de la matriz están ordenados. ¡Esto debería requerir solo una pasada!
3­4.
Aquí hay una variación de la matriz de valores constantes . Escriba un programa para crear un
problema de cifrado de sustitución. En un problema de cifrado por sustitución, todos los mensajes
están formados por letras mayúsculas y puntuación. El mensaje original se llama texto sin formato y el
texto cifrado se crea sustituyendo cada letra por otra (por ejemplo, cada C podría convertirse en una
X). Para este problema, codifique una matriz constante de 26 elementos char para el cifrado y haga
que su programa lea un mensaje de texto plano y genere el texto cifrado equivalente.
78 Capítulo 3
Machine Translated by Google
3­5. Haga que el programa anterior convierta el texto cifrado nuevamente a texto sin formato para
verificar la codificación y decodificación.
3­6. Para hacer que el problema del texto cifrado sea aún más desafiante, haga que su programa
gram genera aleatoriamente la matriz de cifrado en lugar de una matriz constante codificada .
Efectivamente, esto significa colocar un carácter aleatorio en cada elemento de la matriz, pero
recuerda que no puedes sustituir una letra por sí misma. Entonces, el primer elemento no
puede ser A y no puedes usar la misma letra para dos sustituciones.
es decir, si el primer elemento es S, ningún otro elemento puede ser S.
3­7. Escriba un programa al que se le dé una matriz de números enteros y determine la moda,
que es el número que aparece con más frecuencia en la matriz.
3­8. Escriba un programa que procese una serie de objetos de estudiantes y determine los cuartiles de
calificaciones, es decir, la calificación que uno necesitaría para obtener una puntuación igual o
mejor que el 25 % de los estudiantes, el 50 % de los estudiantes y el 75 % de los estudiantes. .
3­9. Considere esta modificación de la matriz de ventas : debido a que los vendedores van y vienen a lo
largo del año, ahora marcamos los meses antes de la contratación de un agente de ventas, o
después del último mes de un agente de ventas, con un –1. Vuelva a escribir su código de
promedio de ventas más alto o mediana de ventas más alta para compensar.
Resolver problemas con matrices 79
Machine Translated by Google
Machine Translated by Google
RESOLVIENDO PROBLEMAS
t
l
S
YYPAG
oh
C
A
DY
Y
CON PUNTEROS Y
MEMORIA DINÁMICA
En este capítulo, aprenderemos a resolver
problemas utilizando punteros y memoria dinámica,
lo que nos permitirá escribir programas flexibles
que puedan acomodar tamaños de datos desconocidos.
hasta que se ejecute el programa. Punteros y memoria dinámica.
La asignación es una programación “dura”. Cuando puedes escribir programas que
toman bloques de memoria sobre la marcha, vincularlos a estructuras útiles y limpiar
todo al final para que no queden residuos, no eres simplemente alguien que puede
codificar un poco: eres un programador.
Como los punteros son complicados y muchos lenguajes populares, como Java,
parecen prescindir del uso de punteros, algunos programadores novatos se
convencerán de que pueden saltarse este tema por completo. Esto es un error.
Los punteros y el acceso indirecto a la memoria siempre se utilizarán en la
programación avanzada, aunque puedan estar ocultos por los mecanismos de un
lenguaje de alto nivel. Por lo tanto, para pensar verdaderamente como un
programador, debe ser capaz de pensar a través de punteros y problemas basados en punteros.
Machine Translated by Google
Sin embargo, antes de comenzar a resolver problemas de punteros, examinaremos
cuidadosamente todos los aspectos de cómo funcionan los punteros, tanto en la superficie como
detrás de escena. Este estudio proporciona dos beneficios. En primer lugar, este conocimiento nos
permitirá hacer el uso más eficaz de los punteros. En segundo lugar, al disipar los misterios de los
indicadores, podemos emplearlos con confianza.
Revisión de los fundamentos del puntero
Al igual que con los temas tratados en capítulos anteriores, debería haber tenido cierta exposición al
uso básico del puntero, pero para asegurarse de que estamos en la misma página, aquí hay una
revisión rápida.
Los punteros en C++ se indican con un asterisco (*). Dependiendo del contexto, el asterisco
indica que se está declarando un puntero o que nos referimos a la memoria apuntada, no al puntero
en sí. Para declarar un puntero, colocamos el asterisco entre el nombre del tipo y el identificador:
En t * intPuntero;
Esto declara la variable intPointer como un puntero a un int. Tenga en cuenta que el asterisco
se vincula con el identificador, no con el tipo. A continuación, la variable1 es un puntero a un int, pero
la variable2 es solo un int:
int*variable1, variable2;
Un signo comercial delante de una variable actúa como la dirección del operador. Entonces
podríamos asignar la dirección de la variable2 a la variable1 con:
variable1 = &variable2;
También podemos asignar el valor de una variable puntero a otra directamente:
intPuntero = variable1;
Quizás lo más importante es que podemos asignar memoria durante el tiempo de ejecución que
Sólo se puede acceder a través de un puntero. Esto se logra con el nuevo
operador:
doble * doblePuntero = nuevo doble;
Acceder a la memoria en el otro extremo del puntero se conoce como desreferenciación
y se logra con un asterisco a la izquierda de un identificador de puntero. Nuevamente, esta es la
misma ubicación que usaríamos para una declaración de puntero. El contexto hace que el significado
sea diferente. He aquí un ejemplo:
*puntero doble = 35,4;
doble localDoble = *doblePuntero;
82 Capítulo 4
Machine Translated by Google
Asignamos un valor al doble asignado por el código anterior
antes
copiando el valor de esta ubicación de memoria a la variable localDouble
.
Para desasignar la memoria asignada con nueva, una vez que ya no la necesitemos, usamos la palabra
clave eliminar:
eliminar doble puntero;
La mecánica de este proceso se describe en detalle en “La memoria importa” en la página 85.
Beneficios de los punteros
Los punteros nos brindan capacidades que no están disponibles con la asignación de memoria estática y
también brindan nuevas oportunidades para el uso eficiente de la memoria. Los tres beneficios principales del
uso de punteros son:
Estructuras de datos del tamaño de tiempo de ejecución
Estructuras de datos redimensionables
Compartir memoria
Echemos un vistazo a cada uno de estos con un poco más de detalle.
Estructuras de datos del tamaño de tiempo de ejecución
Al usar punteros, podemos crear una matriz con un tamaño determinado en tiempo de ejecución, en lugar de
tener que elegir el tamaño antes de crear nuestra aplicación. Esto nos ahorra tener que elegir entre quedarnos
sin espacio en la matriz o hacer que la matriz sea tan grande como sea posible, desperdiciando así gran
parte del espacio de la matriz en el caso promedio. Vimos por primera vez el tamaño de los datos en tiempo de
ejecución en “Decidir cuándo usar matrices” en la página 74. Usaremos este concepto más adelante en
este capítulo, en “Cadenas de longitud variable” en la página 91.
Estructuras de datos redimensionables
También podemos crear estructuras de datos basadas en punteros que crecen o se reducen durante el
tiempo de ejecución según sea necesario. La estructura de datos de tamaño variable más básica es la lista
vinculada, que quizás ya haya visto. Aunque solo se puede acceder a los datos de la estructura en orden
secuencial, la lista vinculada siempre tiene tantos lugares para los datos como los datos mismos, sin
desperdiciar espacio. Otras estructuras de datos basadas en punteros más elaboradas, como verá más
adelante, tienen ordenamientos y “formas” que pueden reflejar la relación de los datos subyacentes mejor
que una matriz. Debido a esto, aunque una matriz ofrece un acceso aleatorio completo que ninguna estructura
basada en punteros ofrece, la operación de recuperación (donde encontramos el elemento en la estructura que
mejor cumple con un determinado criterio) puede ser mucho más rápida con una estructura basada en
punteros. . Usaremos este beneficio más adelante en este capítulo para crear una estructura de datos para los
registros de los estudiantes que crezca según sea necesario.
Resolver problemas con punteros y memoria dinámica 83
Machine Translated by Google
Compartir memoria
Los punteros pueden mejorar la eficiencia del programa al permitir que se compartan bloques
de memoria. Por ejemplo, cuando llamamos a una función, podemos pasar un puntero a un
bloque de memoria en lugar de pasar una copia del bloque usando parámetros de referencia.
Probablemente los hayas visto antes; son parámetros en los que aparece un símbolo (&) entre
el tipo y el nombre en la lista de parámetros formal:
void refParamFunction (int
x = 10;
}
& x) {
número entero = 5;
refParamFunction( número);
cout << número << "\n";
NOTA Los espacios que se muestran antes y después del símbolo comercial no son necesarios; solo
Incluirlos aquí por razones estéticas. En el código de otros desarrolladores, es posible que
vea int& x, int &x o incluso int&x.
En este código, el parámetro formal x
no es una copia del argumento número
;
más bien, es una referencia a la memoria donde se almacena el número .
Por lo tanto, cuando se cambia x
, el espacio de memoria para el número cambia y la
salida al final del fragmento de código es 10 . Los parámetros de referencia se pueden utilizar
como mecanismo para enviar valores fuera de una función, como se muestra en este ejemplo.
En términos más generales, los parámetros de referencia permiten que la función llamada y la
función que llama compartan la misma memoria, lo que reduce la sobrecarga. Si una variable
que se pasa como parámetro ocupa un kilobyte de memoria, pasar la variable como referencia
significa copiar un puntero de 32 o 64 bits en lugar del kilobyte. Podemos indicar que
estamos usando un parámetro de referencia para el rendimiento, no su salida, usando la
palabra clave const :
int otra función (const int & x);
Al anteponer la palabra const en la declaración del parámetro de referencia x, otra
función recibirá una referencia al argumento pasado en la llamada pero no podrá modificar el
valor de ese argumento, como cualquier otro parámetro constante .
En general, podemos usar punteros de esta manera para permitir que diferentes partes
de un programa, o diferentes estructuras de datos dentro del programa, tengan acceso a los
mismos datos sin la sobrecarga de copiar.
Cuándo utilizar punteros
Como comentamos con las matrices, los punteros tienen posibles inconvenientes y deben
usarse solo cuando sea apropiado. ¿Cómo sabemos cuándo es apropiado el uso del puntero?
Habiendo enumerado los beneficios de los punteros, podemos decir que los punteros deben
usarse solo cuando requerimos uno o más de sus beneficios. Si su programa necesita una
estructura para contener un conjunto de datos, pero no puede estimar con precisión
84 Capítulo 4
Machine Translated by Google
cuántos datos hay antes del tiempo de ejecución; si necesita una estructura que pueda crecer y
encogerse durante la ejecución; o si tiene objetos grandes u otros bloques de datos que se
pasan por su programa, los punteros pueden ser el camino a seguir. Sin embargo, en
ausencia de cualquiera de estas situaciones, debe tener cuidado con los punteros y la
asignación de memoria dinámica.
Dada la notoria reputación de los punteros como una de las funciones más difíciles de C++,
tures, se podría pensar que ningún programador intentaría utilizar un puntero cuando no sea
necesario. Sin embargo, muchas veces me ha sorprendido descubrir lo contrario. A veces los
programadores simplemente se engañan a sí mismos pensando que se necesita un puntero.
Supongamos que está realizando una llamada a una función escrita por otra persona, desde
una biblioteca o interfaz de programación de aplicaciones, tal vez, con el siguiente prototipo:
cálculo nulo (entrada int, salida int*);
Podríamos imaginar que esta función está escrita en C, no en C++, y es por eso que usa
un puntero en lugar de una referencia (&) para crear un parámetro "saliente". Al llamar a esta
función, un programador podría hacer descuidadamente algo como esto:
int número1 = 10;
int* número2 = nuevo int;
calcular(núm1, núm2);
Este código es ineficiente en el espacio porque crea un puntero donde no se necesita
ninguno. En lugar del espacio para dos números enteros, utiliza el espacio para dos números
enteros y un puntero. El código también es ineficiente en el tiempo porque la asignación de
memoria innecesaria lleva tiempo (como se explica en la siguiente sección). Por último, el
programador ahora debe recordar eliminar la memoria asignada. Todo esto podría haberse
evitado usando el otro aspecto del operador & , que le permite obtener la dirección de una
variable asignada estáticamente, como esta:
int número1 = 10;
int número2;
calcular(núm1, &núm2);
Estrictamente hablando, todavía usamos un puntero en la segunda versión, pero lo
usamos implícitamente, sin una variable de puntero ni una asignación de memoria dinámica.
La memoria importa
Para comprender cómo la asignación de memoria dinámica nos permite dimensionar el tiempo
de ejecución y compartir la memoria, debemos comprender un poco cómo funciona la asignación
de memoria en general. Esta es una de las áreas en las que creo que beneficia a los nuevos
programadores aprender C++. Todos los programadores deben eventualmente comprender
cómo funcionan los sistemas de memoria en una computadora moderna, y C++ te obliga a
enfrentar este problema de frente. Otros idiomas ocultan suficientes detalles sucios de la memoria.
Resolver problemas con punteros y memoria dinámica 85
Machine Translated by Google
sistemas que los nuevos programadores se convencen a sí mismos de que estos detalles no
son motivo de preocupación, lo que simplemente no es el caso. Más bien, los detalles no
importan mientras todo funcione. Sin embargo, tan pronto como surge un problema, el
desconocimiento de los modelos de memoria subyacentes crea un obstáculo insuperable
entre el programador y la solución.
La pila y el montón
C++ asigna memoria en dos lugares: la pila y el montón. Como lo implican los nombres, la
pila está organizada y ordenada, y el montón está desunido y desordenado. La pila de
nombres es especialmente descriptiva porque le ayuda a visualizar la naturaleza contigua de
la asignación de memoria. Piense en una pila de cajas, como en la Figura 4­1 (a). Cuando
tengas una caja para almacenar, la colocas en la parte superior de la pila. Para eliminar
una caja en particular de la pila, primero debes eliminar todas las cajas que están encima de
ella. En términos prácticos de programación, esto significa que una vez que haya asignado
un bloque de memoria (una caja) en la pila, no hay forma de cambiar su tamaño porque en
cualquier momento puede tener otros bloques de memoria inmediatamente después (otras
cajas encima). de ello).
En C++, puedes crear explícitamente tu propia pila para usarla en un determinado
algoritmo, pero independientemente, hay una pila que su programa siempre usará,
conocida como pila de tiempo de ejecución del programa. Cada vez que se llama a una
función (y esto incluye la función principal ), se asigna un bloque de memoria en la parte
superior de la pila de tiempo de ejecución. Este bloque de memoria se llama registro de activación.
Una discusión completa de su contenido está más allá del alcance de este texto, pero para
su comprensión como solucionador de problemas, el contenido principal del registro de
activación es el espacio de almacenamiento de variables. La memoria para todas las variables
locales, incluidos los parámetros de la función, se asigna dentro del registro de activación.
Echemos un vistazo a un ejemplo:
int funciónB(int valorentrada) {
retornar valor de entrada ­ 10;
}
int funciónA(int núm) {
int variable local = funciónB(núm * 10);
devolver variable local;
}
int principal()
{
entero x = 12;
int y = funciónA(x);
devolver 0;
}
En este código, la función principal llama a la funciónA, que a su vez llama a la funciónB.
La Figura 4­1 (b) muestra una versión simplificada de cómo se organizaría la pila de tiempo
de ejecución en el punto justo antes de ejecutar la declaración de retorno de la función B
. Los registros de activación para las tres funciones se organizarían en una pila de memoria
contigua, con la función principal en la parte inferior de la pila. (Solo para hacer las cosas aún
más confusas, es posible que la pila comience en el punto más alto posible de la memoria y
se construya hacia abajo para reducir la memoria).
86 Capítulo 4
Machine Translated by Google
direcciones en lugar de subir a direcciones de memoria más altas. Sin embargo, no se hace ningún
daño al ignorar la posibilidad.) Lógicamente, el registro de activación de la función principal está
en la parte inferior de la pila, con el registro de activación de la funciónA encima y el registro de
activación de la funciónB encima de la funciónA.
Ninguno de los dos registros de activación inferiores se puede eliminar antes de que se elimine el
registro de activación de la función B.
más alto
valor de entrada
función B
en uno
variable local
función
X
y
principal
más bajo
(a)
(b)
Figura 4­1: Una pila de cajas y una pila de llamadas a funciones
Mientras que una pila está muy organizada, un montón, por el contrario, tiene poca
organización. Supongamos que estás guardando cosas en cajas nuevamente, pero estas cajas son
frágiles y no puedes apilarlas una encima de otra. Tienes un espacio grande, inicialmente vacío, para
guardar las cajas, y puedes colocarlas en cualquier lugar del suelo que quieras. Sin embargo, las
cajas son pesadas, por lo que una vez que coloques una, será mejor dejarla donde está hasta que
estés listo para sacarla de la habitación. Este sistema tiene ventajas y desventajas en comparación
con el stack. Por un lado, este sistema de almacenamiento es flexible y permite acceder al
contenido de cualquier caja en cualquier momento. Por otro lado, la habitación rápidamente se
convertirá en un desastre. Si las cajas son todas de diferentes tamaños, será especialmente difícil
aprovechar todo el espacio disponible en el suelo. Terminarás con muchos espacios entre las cajas
que son demasiado pequeños para llenarlos con otra caja. Debido a que las cajas no se pueden
mover fácilmente, quitar varias cajas solo crea varios espacios difíciles de llenar en lugar de
proporcionar el almacenamiento abierto de nuestro piso vacío original. En términos prácticos de
programación, nuestro montón es como el suelo de esa habitación. Un bloque de memoria es una
serie contigua de direcciones; por lo tanto, durante la vida útil de un programa con muchas
asignaciones y desasignaciones de memoria, terminaremos con muchos espacios entre los bloques
de memoria asignados restantes.
Este problema se conoce como fragmentación de la memoria.
Resolver problemas con punteros y memoria dinámica 87
Machine Translated by Google
Cada programa tiene su propio montón, desde el cual se asigna memoria dinámicamente. En
C++, esto generalmente significa una invocación de la nueva palabra clave, pero también verá
llamadas a las funciones antiguas de C para la asignación de memoria, como malloc.
Cada llamada a nuevo (o malloc) reserva un trozo de memoria en el montón y devuelve un
puntero al trozo, mientras que cada llamada a eliminar (o liberar si la memoria se asignó con
malloc) devuelve el trozo al grupo de memoria de montón disponible. Debido a la fragmentación,
no toda la memoria del grupo es igualmente útil. Si nuestro programa comienza asignando las
variables A, B y C en la memoria del montón, podríamos esperar que esos bloques sean
contiguos. Si desasignamos B, el vacío que deja solo puede llenarse con otra solicitud que sea del
tamaño de B o menor, hasta que A o C también sean desasignados.
La figura 4­2 aclara la situación. En el inciso (a), vemos el piso de nuestra habitación lleno de
cajas. En algún momento la sala probablemente estuvo bien organizada, pero con el tiempo, la
disposición se volvió desordenada. Ahora hay una pequeña caja (b) que no cabe en ningún espacio
abierto del piso, aunque el área total del piso no utilizada excede en gran medida el espacio que
ocupa la caja. En la parte (c), representamos un pequeño montón. Los cuadrados de líneas
discontinuas son los fragmentos de memoria más pequeños (indivisibles), que pueden ser un solo
byte, una palabra de memoria o algo más grande, según el administrador del montón. Las áreas
sombreadas representan asignaciones de memoria contigua; Para mayor claridad, una asignación
tiene algunas de sus partes numeradas. Al igual que con el piso fragmentado, el montón
fragmentado tiene separados los fragmentos de memoria no asignados, lo que reduce su usabilidad.
Hay un total de 85 fragmentos de memoria no utilizados, pero el rango contiguo más grande de
memoria no utilizada, como lo indica la flecha, tiene sólo 17 fragmentos de longitud. En otras
palabras, si cada fragmento fuera un byte, este montón no podría satisfacer ninguna solicitud de
una invocación de new de más de 17 bytes, aunque el montón tenga 85 bytes libres.
16
20
15
1
(a)
(b)
(C)
Figura 4­2: Un piso fragmentado, una caja que no se puede colocar y una memoria fragmentada
Tamaño de la memoria
El primer problema práctico con la memoria es limitar su uso a lo necesario.
Los sistemas informáticos modernos tienen tanta memoria que es fácil pensar en ella como un
recurso infinito, pero en realidad cada programa tiene una cantidad limitada de memoria. Además,
los programas necesitan usar la memoria de manera eficiente para evitar fallas generales del sistema.
88 Capítulo 4
Machine Translated by Google
desacelerar. En un sistema operativo multitarea (lo que significa casi todos los sistemas
operativos modernos), cada byte de memoria desperdiciado por un programa empuja al
sistema como un todo hacia el punto en que el conjunto de programas actualmente en
ejecución no tiene suficiente memoria para ejecutarse. . En ese momento, el sistema
operativo cambia constantemente fragmentos de un programa por otro y, por lo tanto,
avanza lentamente. Esta condición se conoce como paliza.
Tenga en cuenta que, más allá del deseo de mantener la huella de memoria general
del programa lo más pequeña posible, la pila y el montón tienen tamaños máximos. Para
probar esto, asignemos memoria del montón un kilobyte a la vez, hasta que algo explote:
const int enterosPorKilobyte = 1024 / tamañode(int);
mientras (verdadero) {
int *oneKilobyteArray = nuevo int[intsPerKilobyte];
}
Permítanme enfatizar que este es un código horrible escrito únicamente para demostrar
un punto. Si prueba este código en su sistema, primero debe guardar todo su trabajo, solo
para estar seguro. Lo que debería suceder es que el programa se detenga y su sistema
operativo se queje de que el código generó pero no manejó una excepción bad_alloc . Esta
excepción la genera new cuando ningún bloque de memoria no asignada en el montón es lo
suficientemente grande para cumplir con la solicitud. Quedarse sin memoria del montón se
denomina desbordamiento del montón. En algunos sistemas, un desbordamiento del
montón puede ser común, mientras que en otros sistemas, un programa provocará una
paliza mucho antes de producir un bad_alloc (en mi sistema, la nueva llamada no falló hasta
que asigné dos gigabytes en llamadas anteriores ).
Existe una situación similar con la pila de tiempo de ejecución. Cada llamada a función
asigna espacio en la pila y hay una sobrecarga fija para cada registro de activación, incluso
para una función sin parámetros ni variables locales. La forma más sencilla de demostrar
esto es con una función recursiva fuera de control:
int recuento = 0;
pila vacíaOverflow() {
cuenta++;
stackOverflow();
}
int principal()
{
stackOverflow();
devolver 0;
}
Este código tiene una variable global , que en la mayoría de los casos tiene un mal
estilo, pero aquí necesito un valor que persista en todas las llamadas recursivas. Como esta
variable se declara fuera de la función, no se le asigna memoria en el registro de activación
de la función, ni existen otras variables o parámetros locales. Todo lo que hace la función
es incrementar el recuento y realizar una llamada recursiva . La recursividad se analiza
ampliamente en el Capítulo 6, pero aquí se utiliza simplemente para hacer que la cadena de
llamadas a funciones sea lo más larga posible. El registro de activación.
Resolver problemas con punteros y memoria dinámica 89
Machine Translated by Google
de una función permanece en la pila hasta que finaliza esa función. Entonces, cuando se
realiza la primera llamada a stackOverflow desde main, se coloca un registro de activación
en la pila de tiempo de ejecución que no se puede eliminar hasta que finalice la primera
llamada a la función. Esto nunca sucederá porque la función realiza una segunda llamada a
stackOverflow, colocando otro registro de activación en la pila, que luego realiza una tercera
llamada, y así sucesivamente. Estos registros de activación se acumulan hasta que la pila se queda sin espac
En mi sistema, el recuento es de alrededor de 4.900 cuando el programa fracasa. Mi entorno
de desarrollo, Visual Studio, tiene por defecto una asignación de pila de 1 MB, lo que significa
que cada una de estas llamadas a funciones, incluso sin variables o parámetros locales,
crea un registro de activación de más de 200 bytes.
Toda la vida
La vida útil de una variable es el lapso de tiempo entre la asignación y la desasignación.
Con una variable basada en pila, es decir, una variable local o un parámetro, la vida útil se
maneja implícitamente. La variable se asigna cuando se llama a la función y se desasigna
cuando finaliza la función. Con una variable basada en montón, es decir, una variable asignada
dinámicamente usando new, la vida útil está en nuestras manos.
Gestionar la vida útil de las variables asignadas dinámicamente es la pesadilla de todo
programador de C++. El problema más obvio es la temida pérdida de memoria, una situación
en la que la memoria se asigna desde el montón pero nunca se desasigna y ningún puntero
hace referencia a ella. He aquí un ejemplo sencillo:
int *intPtr = nuevo int;
intPtr = NULO;
En este código, declaramos un puntero a un número entero
, inicializándolo asignando
un número entero del montón. Luego, en la segunda línea, configuramos nuestro puntero
entero en NULL
(que es simplemente un alias para el número cero). Sin embargo, el número
entero que asignamos con new todavía existe. Está sentado, solitario y desamparado, en su
lugar en el montón, esperando una desasignación que nunca podrá llegar. No podemos
desasignar el número entero porque para desasignar un bloque de memoria usamos eliminar
seguido de un puntero al bloque y ya no tenemos un puntero al bloque. Si intentáramos seguir
el código anterior con eliminar intPtr, obtendríamos un error porque
intPtr es cero.
A veces, en lugar de una memoria que nunca se desasigna, tenemos el problema
opuesto: intentar desasignar la misma memoria dos veces, lo que produce un error de tiempo
de ejecución. Esto puede parecer un problema fácil de evitar: simplemente no llame a eliminar
dos veces en la misma variable. Lo que complica esta situación es que podemos tener
múltiples variables apuntando a la misma memoria. Si varias variables apuntan a la misma
memoria y llamamos a eliminar en cualquiera de esas variables, efectivamente hemos
desasignado la memoria para todas las variables. Si no borramos explícitamente las variables
a NULL, se las conocerá como referencias pendientes y llamar a eliminar en cualquiera de
ellas producirá un error de tiempo de ejecución.
90 Capítulo 4
Machine Translated by Google
Resolver problemas de puntero
En este punto, probablemente esté listo para algunos problemas, así que veamos un par y veamos
cómo podemos usar punteros y asignación de memoria dinámica para resolverlos. Primero
trabajaremos con algunas matrices asignadas dinámicamente, lo que demostrará cómo realizar
un seguimiento de la memoria del montón a través de todas nuestras manipulaciones. Entonces nos
mojaremos los pies con una estructura verdaderamente dinámica.
Cadenas de longitud variable
En este primer problema, crearemos funciones para manipular cadenas.
Aquí usamos el término en su sentido más general: una secuencia de caracteres, independientemente
de cómo se almacenen esos caracteres. Supongamos que necesitamos admitir tres funciones en
nuestro tipo de cadena.
PROBLEMA: MANIPULACIÓN DE CUERDAS DE LONGITUD VARIABLE
Escriba implementaciones basadas en montón para tres funciones de cadena requeridas:
append Esta función toma una cadena y un carácter y agrega el carácter al final de la cadena.
concatenar Esta función toma dos cadenas y agrega los caracteres de la segunda cadena a la primera.
caracterAt Esta función toma una cadena y un número y devuelve el carácter en esa posición en la cadena
(con el primer carácter de la cadena numerado cero).
Escriba el código asumiendo que se llamará a caracterAt con frecuencia, mientras que las otras dos
funciones se llamarán relativamente raramente. La eficiencia relativa de las operaciones debería reflejar la
frecuencia de las llamadas.
En este caso, queremos elegir una representación para nuestra cadena que permita una función
carácter rápido , lo que significa que necesitamos una forma rápida de localizar un carácter en
particular. Como probablemente recordará del capítulo anterior, esto es lo que mejor hace una matriz:
acceso aleatorio. Entonces, resolvamos este problema usando matrices de caracteres. Las funciones
de agregar y concatenar cambian el tamaño de la cadena, lo que significa que nos encontramos con
todos los problemas de matrices que discutimos anteriormente. Debido a que no existe una limitación
incorporada para el tamaño de la cadena en este problema, no podemos elegir un tamaño inicial
grande para nuestras matrices y esperar lo mejor. En su lugar, necesitaremos cambiar el tamaño
de nuestras matrices durante el tiempo de ejecución.
Para comenzar, creemos un typedef para nuestro tipo de cadena. Sabemos que vamos a
crear nuestras matrices dinámicamente, por lo que debemos hacer que nuestro tipo de cadena sea
un puntero a char.
typedef char * arrayString;
Resolución de problemas con punteros y memoria dinámica 91
Machine Translated by Google
Una vez establecido esto, comencemos con las funciones. Usando el principio de
comenzar con lo que ya sabemos hacer, podemos escribir rápidamente el carácter At
función.
char caracterAt(arrayString s, int posición) {
return s[posición];
}
Recuerde del Capítulo 3 que si a un puntero se le asigna la dirección de una matriz,
podemos acceder a los elementos de la matriz usando la notación de matriz normal . Sin
embargo, tenga en cuenta que pueden suceder cosas malas si la posición no es en
realidad un número de elemento válido para la matriz , y este código asigna la
responsabilidad de validar el segundo parámetro a la persona que llama. Consideraremos
alternativas a esta situación en los ejercicios. Por ahora, pasemos a la función de
agregar . Podemos imaginar lo que hará esta función en general, pero para entender los
detalles correctos, deberíamos considerar un ejemplo. Esta es una técnica que llamo resolución por caso d
Comience con una entrada de muestra no trivial para la función o programa. Escriba
todos los detalles de esa entrada junto con todos los detalles de la salida. Luego, cuando
escriba su código, escribirá para el caso general y, al mismo tiempo, verificará cómo cada
paso transforma su muestra para asegurarse de alcanzar el estado de salida deseado.
Esta técnica es especialmente útil cuando se trata de punteros y memoria asignada
dinámicamente, porque gran parte de lo que sucede en el programa está fuera de la vista
directa. Seguir un caso en papel le obliga a realizar un seguimiento de todos los valores
cambiantes en la memoria, no sólo los representados directamente por variables sino
también los del montón.
Supongamos que comenzamos con la prueba de cadena, es decir, tenemos una
matriz de caracteres en el montón con t, e, s y t, en ese orden, y queremos agregar, usando
nuestra función, un signo de exclamación. La Figura 4­3 muestra el estado de la memoria
antes (a) y después (b) de esta operación. En estos diagramas, todo lo que esté a la
izquierda de la línea vertical discontinua es memoria de pila (variables o parámetros
locales) y todo lo que esté a la derecha es memoria de pila, asignada dinámicamente usando new.
t
Es
t
t! e s
s
t
(a)
(b)
Figura 4­3: Estados propuestos “antes” (a) y
“después” (b) para la función agregar
Al observar esta figura, inmediatamente veo un problema potencial para nuestra
función. Según nuestro enfoque de implementación para las cadenas, la función creará
una nueva matriz que es un elemento más grande que la matriz original y copiará todos los
caracteres de la primera matriz a la segunda. Pero, ¿cómo vamos a saber qué tan grande
es la primera matriz? Del capítulo anterior, sabemos que tenemos que realizar un
seguimiento del tamaño de nuestras matrices nosotros mismos. Entonces falta algo.
92 Capítulo 4
Machine Translated by Google
Si hemos tenido experiencia trabajando con cadenas en la biblioteca estándar C/C++,
ya sabremos el ingrediente que falta, pero si no lo sabemos, podemos razonarlo rápidamente.
Recuerda que una de nuestras técnicas de resolución de problemas es buscar analogías.
Quizás deberíamos pensar en otros problemas en los que se desconociera la longitud de
algo. En el Capítulo 2, procesamos códigos de identificación con un número arbitrario de
dígitos para el problema de “Validación de suma de comprobación de Luhn”. En ese problema,
no sabíamos cuántos dígitos ingresaría el usuario. Al final, escribimos un bucle while que continuó
hasta que el último carácter leído fue el final de la línea.
Desafortunadamente, no hay ningún carácter de fin de línea esperándonos al final de
nuestras matrices. Pero ¿qué pasa si ponemos un carácter de fin de línea en el último elemento
de todos nuestros conjuntos de cadenas? Luego podríamos descubrir la longitud de nuestras
matrices de la misma manera que descubrimos cuántos dígitos había en los códigos de identificación.
El único inconveniente de este enfoque es que ya no podemos usar el carácter de fin de línea
en nuestras cadenas, excepto como terminador de cadena. Esto no es necesariamente una
gran restricción, dependiendo de cómo se usarán las cadenas, pero para una máxima flexibilidad,
sería mejor elegir un valor que no pueda confundirse con ningún carácter que alguien quiera
usar. Por lo tanto, usaremos un cero para terminar nuestras matrices porque un cero representa
un carácter nulo en ASCII y otros sistemas de código de caracteres. Este es exactamente el
método utilizado por la biblioteca estándar C/C++.
Una vez aclarado ese problema, seamos más específicos sobre qué hará append con
nuestros datos de muestra. Sabemos que nuestra función tendrá dos parámetros, el primero será
una cadena de matriz, un puntero a una matriz de caracteres en el montón, y el segundo será el
carácter que se agregará. Para mantener las cosas claras, sigamos adelante y escribamos el
esquema de la función de agregar y el código para probarla.
anexar vacío ( arrayString& s, char c) {
}
anular appendTester() {
arrayString a = nuevo carácter[5];
a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
append(a, '!');
cout << a << "\n";
}
La función appendTester asigna nuestra cadena en el montón
. Tenga en cuenta que el
tamaño de la matriz es cinco, lo cual es necesario para que podamos asignar las cuatro letras de
la palabra prueba junto con nuestro carácter nulo final
. Luego llamamos append
, que en
este punto es solo un shell vacío. Cuando escribí el shell, me di cuenta de que el parámetro
arrayString tenía que ser una referencia (&)
porque la función creará una nueva matriz en el montón. Después de todo, ese es el objetivo
de usar memoria dinámica aquí: crear una nueva matriz cada vez que se cambia el tamaño de
la cadena. Por lo tanto, el valor que tiene la variable a cuando se pasa para agregar no es el
mismo valor que debería tener cuando finaliza la función, porque necesita apuntar a una nueva
matriz. Tenga en cuenta que debido a que nuestras matrices utilizan la terminación de carácter
nulo esperada por las bibliotecas estándar, podemos enviar la matriz a la que hace referencia el
puntero a directamente al flujo de salida para verificar el valor
.
Resolver problemas con punteros y memoria dinámica 93
Machine Translated by Google
La Figura 4­4 muestra nuestra nueva comprensión de lo que hará la función con nuestro caso
de prueba. Los terminadores de matriz están en su lugar y se muestran como NULL para mayor claridad.
En el estado posterior a (b), está claro que s apunta a una nueva asignación de memoria. La matriz
anterior ahora está en un cuadro sombreado; En estos diagramas, uso cuadros sombreados para
indicar la memoria que se ha desasignado. Incluir la memoria asignada en nuestros diagramas nos
ayuda a recordarnos que debemos realizar la desasignación.
s
t
Es
s
t
NULO
t
Es
s
t
NULO
t
t! mi s
!
C
(a)
s
!
C
NULO
(b)
Figura 4­4: Estados de memoria actualizados y elaborados antes (a) y
después (b) de la función de agregar
Con todo correctamente visualizado, podemos escribir esta función:
anexar vacío (arrayString& s, char c) {
int longitud antigua = 0;
mientras (s[longitudantigua] != 0) {
antiguoLongitud++;
}
arrayString newS = nuevo carácter[oldLength + 2];
for (int i = 0; i < longitudantigua; i++) {
nuevoS[i] = s[i];
}
newS[oldLength] = c;
nuevaS[antiguaLongitud + 1] = 0;
eliminar[] s;
s = nuevoS;
}
Están sucediendo muchas cosas en este código, así que veámoslo pieza por pieza.
Al comienzo de la función, tenemos un bucle para localizar el carácter nulo que termina nuestro array
. Cuando se complete el ciclo, oldLength será el número de caracteres legítimos en la matriz (es
decir, sin incluir el carácter nulo final). Asignamos la nueva matriz del montón con un tamaño de
oldLength + 2
. Este es uno de esos detalles que es difícil de mantener claro si lo estás descifrando
todo mentalmente, pero que es fácil de entender si tienes un diagrama.
Siguiendo el código de nuestro ejemplo en la Figura 4­5, vemos que oldLength
94 Capítulo 4
Machine Translated by Google
Serían cuatro en este caso. Sabemos que oldLength sería cuatro porque la prueba tiene cuatro
caracteres y que la nueva matriz en la parte (b) requiere seis caracteres porque necesitamos
espacio para el carácter agregado y el terminador nulo.
Con la nueva matriz asignada, copiamos todos los caracteres legítimos de
la matriz anterior a la nueva
, y luego asignamos el carácter agregado
y el terminador de carácter nulo
a sus ubicaciones apropiadas en la nueva matriz.
Nuevamente, nuestro diagrama nos ayuda a mantener las cosas en orden. Para aclarar aún
más las cosas, la Figura 4­5 muestra cómo se calculó el valor de oldLength y qué posición
indicaría ese valor en la nueva matriz. Con ese recordatorio visual, es fácil corregir los
subíndices en esas dos tareas.
declaraciones.
antiguoLongitud = 4
s
C
t
Es
s
t
NULO
t
Es
s
t
NULO
t
t! mi s
!
(a)
s
C
!
NULO
012 3 4 5
(b)
antiguoLongitud
Figura 4­5: Muestra la relación de una variable local, parámetros y
memoria asignada antes y después de la función de agregar
Las últimas tres líneas de la función de agregar tratan sobre el cuadro sombreado en la
parte (b) de la figura. Para evitar una pérdida de memoria, tenemos que desasignar la matriz
en el montón al que nuestro parámetro s originalmente apuntaba a
nuestra función con s apuntando a la nueva matriz más larga
. Finalmente, dejamos
. Desafortunadamente, una de
las razones por las que las pérdidas de memoria son tan comunes en la programación en C++
es que hasta que la cantidad total de pérdidas de memoria sea grande, el programa y el
sistema en general no mostrarán efectos nocivos. Por lo tanto, las fugas pueden pasar
totalmente desapercibidas para los programadores durante las pruebas. Por lo tanto, como
programadores debemos ser diligentes y considerar siempre la vida útil de nuestras asignaciones
de memoria dinámica. Cada vez que utilice la palabra clave nuevo, piense dónde y cuándo eliminar correspond
ocurrira.
Observe cómo todo en esta función se deriva directamente de nuestros diagramas.
La programación complicada se vuelve mucho menos complicada con buenos diagramas, y
desearía que más programadores nuevos se tomaran el tiempo para dibujar antes de codificar.
Esto se remonta a nuestro principio más fundamental de resolución de problemas: tener
siempre un plan. Un diagrama bien dibujado para un ejemplo de problema es como tener un
Resolución de problemas con punteros y memoria dinámica 95
Machine Translated by Google
ruta trazada hacia su destino antes de comenzar un largo viaje de vacaciones.
Es un poco de esfuerzo extra al principio para evitar potencialmente mucho más esfuerzo
y frustración al final.
CREAR DIAGRAMAS
Todo lo que necesitas para dibujar un diagrama es lápiz y papel. Sin embargo, si tienes tiempo,
te recomendaría utilizar un programa de dibujo. Existen herramientas de dibujo con plantillas
específicas para problemas de programación, pero cualquier programa de dibujo general
basado en vectores le ayudará a empezar (el término vector aquí significa que el programa
funciona con líneas y curvas y no es un programa de caja de pintura como Photoshop). Hice
las ilustraciones originales de este libro usando un programa llamado Inkscape, que está
disponible gratuitamente. Crear los diagramas en su computadora le permite mantenerlos
organizados en el mismo lugar donde almacena el código que ilustran los diagramas. También
es probable que los diagramas sean más claros y, por lo tanto, más fáciles de entender si
regresa a ellos después de una ausencia. Finalmente, es fácil copiar y modificar un diagrama
creado por computadora, como lo hice cuando creé la Figura 4­5 a partir de la Figura 4­4, y
si desea hacer algunas anotaciones temporales rápidas, siempre puede imprimir una copia para garabatear. en.
Volviendo a nuestra función de agregar , el código parece sólido, pero recuerde que
basamos este código en un caso de muestra particular. Por lo tanto, no debemos ser
arrogantes y asumir que el código funcionará en todos los casos válidos. En particular,
debemos comprobar si hay casos especiales. En programación, un caso especial es
una situación en la que datos válidos harán que el flujo normal de código produzca
resultados erróneos.
Tenga en cuenta que este problema es distinto del de los datos incorrectos, como
los datos fuera de rango. En el código de este libro, hemos asumido buenos datos de
entrada para programas y funciones individuales. Por ejemplo, si el programa espera una
serie de números enteros separados por comas, asumimos que eso es lo que obtiene
el programa, no caracteres extraños, no números, etc. Esta suposición es necesaria
para mantener una longitud de código razonable y evitar repetir el mismo código de
verificación de datos una y otra vez. En el mundo real, sin embargo, debemos tomar
precauciones razonables contra malas entradas.
Esto se conoce como robustez. Un programa robusto funciona bien incluso con malas
entradas. Por ejemplo, un programa de este tipo podría mostrar un mensaje de error al
usuario en lugar de fallar.
Comprobación de casos especiales
Miremos addend nuevamente, comprobando casos especiales; en otras palabras,
asegurándonos de que no tengamos situaciones extrañas entre los posibles buenos
valores de entrada. Los culpables más comunes de casos especiales se encuentran en
los extremos, como la entrada más pequeña o más grande posible. Con append, no hay
un tamaño máximo para nuestra matriz de cadenas, pero sí un tamaño mínimo. Si la
cadena no tiene caracteres legítimos, en realidad correspondería a una matriz de un
carácter (siendo ese carácter el carácter de terminación nula). Como antes, hagamos
un diagrama para mantener las cosas claras. Supongamos que agregamos el signo de
exclamación a una cadena nula, como se muestra en la Figura 4­6.
96 Capítulo 4
Machine Translated by Google
longitud antigua = 0
NULO
s
C
!
(a)
NULO
s
C
!
!
NULO
01
(b)
antiguoLongitud
Figura 4­6: Prueba del caso más pequeño para la función de agregar
Cuando miramos el diagrama, este no parece ser un caso especial,
pero deberíamos ejecutar el caso a través de nuestra función para comprobarlo. Agreguemos
lo siguiente a nuestro código appendTester :
arrayString b = nuevo carácter[1];
b[0] = 0;
añadir(b, '!');
cout << b << "\n";
Eso también funciona. Ahora que estamos razonablemente seguros de que la función de
agregar es correcta, ¿nos gusta? El código parecía sencillo y no siento ningún “malo olor”, pero
parece un poco largo para una operación simple. Mientras pienso en la función de concatenar , se
me ocurre que, al igual que anexar, la función de concatenar necesitará determinar la longitud de
una matriz de cadenas, o tal vez las longitudes de dos matrices de cadenas. Debido a que ambas
operaciones necesitarán un bucle que encuentre el carácter nulo que termina la cadena,
podríamos poner ese código en su propia función, que luego se llama desde agregar y concatenar
según sea necesario. Sigamos adelante y hagamos eso y modifiquemos el anexo en consecuencia.
longitud int (cadena de matriz s) {
int recuento = 0;
mientras (s[cuenta] != 0) {
contar++;
}
recuento de devoluciones;
}
anexar vacío (arrayString& s, char c) {
int oldLength = longitud(es);
arrayString newS = nuevo carácter[oldLength + 2];
for (int i = 0; i < longitudantigua; i++) {
nuevoS[i] = s[i];
}
Resolución de problemas con punteros y memoria dinámica 97
Machine Translated by Google
nuevoS[antiguoLongitud] = c;
nuevoS[antiguoLongitud + 1] = 0;
eliminar[] s;
s = nuevoS;
}
El código en la función de longitud
es esencialmente el mismo código que
anteriormente comenzó la función de agregar . En la función de agregar , reemplazamos ese
código con una llamada a la longitud . La función de longitud es lo que se conoce como
función auxiliar, una función que encapsula una operación común a varias otras funciones.
Además de reducir la longitud de nuestro código, la eliminación del código redundante significa
que nuestro código es más confiable y más fácil de modificar. También nos ayuda a resolver
problemas porque las funciones auxiliares dividen nuestro código en fragmentos más
pequeños, lo que nos facilita reconocer oportunidades para la reutilización del código.
Copiar cadenas asignadas dinámicamente
Ahora es el momento de abordar esa función de concatenación . Tomaremos el mismo
enfoque que hicimos con append. Primero, escribiremos una versión de shell vacía de la
función para tener los parámetros y sus tipos directamente en nuestra cabeza. Luego,
haremos un diagrama de un caso de prueba y, finalmente, escribiremos código que coincida
con nuestro diagrama. Aquí está el shell de la función, junto con el código de prueba adicional:
concatenar vacío ( arrayString& s1, arrayString s2) {
}
vacío concatenateTester() {
arrayString a = nuevo carácter[5];
a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
arrayString b = nuevo carácter[4];
b[0] = 'b'; b[1] = 'e'; b[2] = 'd'; b[3] = 0;
concatenar(a, b);
}
Recuerde que la descripción de esta función dice que los caracteres de la segunda
cadena (el segundo parámetro) se agregan al final de la primera cadena. Por lo tanto, el
primer parámetro a concatenar será un parámetro de referencia
, por el mismo motivo
que el primer parámetro de append. Sin embargo, la función no debe cambiar el segundo
parámetro
, por lo que será un parámetro de valor. Ahora para nuestro caso de muestra:
estamos concatenando las cadenas test y bed. El diagrama de antes y después se
muestra en la Figura 4­7.
Los detalles del diagrama deberían resultarle familiares gracias a la función de agregar .
Aquí, para concatenar, comenzamos con dos matrices asignadas dinámicamente en el
montón, a las que apuntan nuestros dos parámetros, s1 y s2. Cuando la función esté
completa, s1 apuntará a una nueva matriz en el montón que tiene nueve caracteres de largo.
La matriz a la que s1 apuntaba anteriormente ha sido desasignada; s2 y su matriz no
cambian. Si bien puede parecer inútil incluir s2 y el conjunto de camas en nuestro diagrama,
cuando intentamos evitar errores de codificación, realizar un seguimiento de lo que no cambia
es tan importante como realizar un seguimiento de lo que sí cambia. También numeré los
elementos de las matrices antiguas y nuevas, ya que eso fue útil con el anexo
función. Todo está en su lugar ahora, así que escribamos esta función.
98 Capítulo 4
Machine Translated by Google
s1
s2
t
Es
s
t
b
Es
d
NULO
t
Es
s
t
NULO
t
Es
s
t
b
NULO
0123
(a)
s1
s2
Es
d NULO
0123456 7
b
Es
d
NULO
(b)
Figura 4­7: Muestra los estados “antes” (a) y “después” (b) del método de concatenación
concatenar vacío (arrayString& s1, arrayString s2) {
int s1_OldLength = longitud(s1);
int s2_Longitud = longitud(s2);
int s1_NewLength = s1_OldLength + s2_Length;
arrayString newS = nuevo carácter[s1_NewLength + 1];
for(int i = 0; i < s1_OldLength; i++) {
nuevoS[i] = s1[i];
}
para(int i = 0; i < s2_Length; i++) {
nuevoS[ s1_OldLength + i] = s2[i];
}
newS[s1_NewLength] = 0;
eliminar[] s1;
s1 = nuevoS;
}
Primero, determinamos las longitudes de ambas cadenas que estamos
concatenando , y luego sumamos esos valores para obtener la longitud que
tendrá la cadena concatenada cuando hayamos terminado. Recuerde que todas
estas longitudes son para la cantidad de caracteres legítimos, sin incluir el
terminador nulo. Por lo tanto, cuando creamos la matriz en el montón para
almacenar la nueva cadena , asignamos una longitud más que la longitud
combinada para tener un espacio para el terminador. Luego copiamos los
caracteres de las dos cadenas originales a la nueva cadena . El primer ciclo es
sencillo, pero observe el cálculo del subíndice en el segundo ciclo . Estamos
copiando desde el principio del s2 a la mitad de newS; Este es otro ejemplo más
de traducción de un rango de valores a otro rango de valores, lo que hemos estado haciendo en
Al observar los números de elementos en mi diagrama, puedo ver qué variables
necesito juntar para calcular el subíndice de destino correcto. El resto de la función
coloca el terminador nulo al final de la nueva cadena . Al igual que con append,
desasignamos la memoria del montón original señalada por nuestro primer
parámetro
y redireccionamos el primer parámetro a la cadena recién asignada
.
Resolución de problemas con punteros y memoria dinámica 99
Machine Translated by Google
Este código parece funcionar, pero como antes, queremos asegurarnos de no
haber creado inadvertidamente una función que tenga éxito en nuestro caso de prueba,
pero no en todos los casos. Los casos de problemas más probables serían cuando
uno o ambos parámetros son cadenas de longitud cero (solo el terminador nulo).
Deberíamos comprobar estos casos explícitamente antes de continuar. Tenga en
cuenta que cuando verifica la corrección del código que utiliza punteros, debe tener
cuidado de observar los punteros en sí y no solo los valores en el montón al que hacen referencia.
Aquí hay un caso de prueba:
arrayString a = nuevo carácter[5];
a[0] = 't'; a[1] = 'e'; a[2] = 's'; a[3] = 't'; a[4] = 0;
arrayString c = nuevo carácter[1];
c[0] = 0;
concatenar(c, a);
cout << a << "\n" << c << "\n";
cout << (void *) a << "\n" << (void *) c << "\n";
Quería estar seguro de que la llamada para concatenar dé como resultado que
a y c apunten a la prueba de cadena, es decir, que apunten a matrices con valores
idénticos. Sin embargo, es igualmente importante que apunten a cadenas diferentes ,
como se muestra en la Figura 4­8 (a). Compruebo esto en la segunda declaración de
salida cambiando los tipos de variables a void *, lo que obliga al flujo de salida a
mostrar el valor bruto de los punteros . Si los propios punteros tuvieran el mismo
valor, entonces diríamos que se han entrecruzado, como se muestra en la Figura 4­8
(b). Cuando los punteros, sin saberlo, se entrecruzan, ocurren problemas sutiles
porque cambiar el contenido de una variable en el montón cambia misteriosamente
otra variable; en realidad la misma variable, pero en un programa grande, eso puede
ser difícil de ver. Además, recuerde que si dos punteros tienen enlaces cruzados,
cuando uno de ellos se desasigna mediante eliminación, el puntero restante se
convierte en una referencia colgante. Por lo tanto, debemos ser diligentes cuando
revisamos nuestro código y comprobar siempre posibles enlaces cruzados.
a
t
Es
s
t
NULO
C
t
Es
s
t
NULO
t
Es
s
t
NULO
(a)
a
C
(b)
Figura 4­8: la concatenación debería dar como resultado dos cadenas distintas
(a), no dos punteros entrecruzados (b).
Con las tres funciones implementadas:characterAt, append y
concatenar: hemos completado el problema.
100 Capítulo 4
Machine Translated by Google
Listas enlazadas
Ahora vamos a intentar algo más complicado. Las manipulaciones del puntero serán más
complicadas, pero mantendremos todo en orden ahora que sabemos cómo generar los diagramas.
PROBLEMA: SEGUIMIENTO DE UN DESCONOCIDO
CANTIDAD DE EXPEDIENTES ESTUDIANTILES
En este problema, escribirá funciones para almacenar y manipular una colección de registros de estudiantes. Un registro
de estudiante contiene un número de estudiante y una calificación, ambos números enteros. Se deben implementar las
siguientes funciones:
addRecord Esta función toma un puntero a una colección de registros de estudiantes (un número de estudiante y
una calificación) y agrega un nuevo registro con estos datos a la colección.
AverageRecord Esta función toma un puntero a una colección de registros de estudiantes y devuelve
el promedio simple de las calificaciones de los estudiantes en la colección como un doble.
La colección puede ser de cualquier tamaño. Se espera que la operación addRecord sea
Se llama con frecuencia, por lo que debe implementarse de manera eficiente.
Varios enfoques cumplirían con las especificaciones, pero elegiremos un método que
nos ayude a practicar nuestras técnicas de resolución de problemas basadas en punteros: listas
enlazadas. Es posible que ya haya visto una lista vinculada antes, pero si no, sepa que la
introducción de listas vinculadas representa una especie de cambio radical con respecto a lo
que hemos discutido hasta ahora en este texto. Un buen solucionador de problemas podría
haber desarrollado cualquiera de las soluciones anteriores si hubiera tenido suficiente tiempo y
una reflexión cuidadosa. Sin embargo, a la mayoría de los programadores no se les ocurriría el
concepto de lista enlazada sin ayuda. Sin embargo, una vez que lo veas y domines los conceptos
básicos, te vendrán a la mente otras estructuras vinculadas y entonces estarás listo y funcionando.
Una lista enlazada es verdaderamente una estructura dinámica. Nuestras matrices de cadenas se
almacenaban en una memoria asignada dinámicamente, pero una vez creadas, eran
estructuras estáticas que nunca se hacían más grandes ni más pequeñas, simplemente eran
reemplazadas. Una lista enlazada, por el contrario, crece pieza a pieza con el tiempo como una cadena.
Construyendo una lista de nodos
Construyamos una lista enlazada de muestra de registros de estudiantes. Para crear una lista
vinculada, necesita una estructura que contenga un puntero a la misma estructura, además de
cualquier dato que desee almacenar en la colección representada por la lista vinculada. Para
nuestro problema, la estructura contendrá un número de estudiante y una calificación.
estructura
listaNodo {
int númeroestudiante;
grado internacional;
listaNodo *
próximo;
};
typedef listNode * StudentCollection;
El nombre de nuestra estructura es listNode
. Una estructura utilizada para crear una
lista vinculada siempre se denomina nodo. Presumiblemente el nombre es una analogía con el
Resolución de problemas con punteros y memoria dinámica 101
Machine Translated by Google
Término botánico, que significa un punto en un tallo desde el cual crece una nueva rama.
El nodo contiene el número de estudiante
y la calificación que conforman la "carga útil" real del
nodo. El nodo también contiene un puntero al mismo tipo de estructura que estamos definiendo
.
La primera vez que la mayoría de los programadores ven esto, les parece confuso y tal vez incluso
una imposibilidad sintáctica: ¿Cómo podemos definir una estructura en términos de sí misma? Pero
esto es legal y el significado quedará claro en breve. Tenga en cuenta que el puntero de
autorreferencia en un nodo normalmente recibe un nombre como next, nextPtr o similar. Por último,
este código declara una definición de tipo para un puntero a nuestro tipo de nodo
. Esto ayudará a
la legibilidad de nuestras funciones.
Ahora construyamos nuestra lista enlazada de muestra usando estos tipos:
colección de estudiantes sc;
listaNodo * nodo1 = nuevo listaNodo;
nodo1­>numeroestudiante = 1001; nodo1­>grado = 78;
listNode * node2 = nuevo listNode;
nodo2­>numestudiante = 1012; nodo2­>grado = 93;
listNode * node3 = nuevo listNode;
nodo3­>numestudiante = 1076; nodo3­>grado = 85;
sc = nodo1;
nodo1­>siguiente = nodo2;
nodo2­>siguiente = nodo3;
nodo3­>siguiente = NULL;
nodo1 = nodo2 = nodo3 = NULL;
Comenzamos declarando una colección de estudiantes, sc
nombre de nuestra lista vinculada. Luego declaramos nodo1
, que eventualmente se convertirá en el
, un puntero a un nodo de lista. Nuevamente,
StudentCollection es sinónimo de nodo *, pero para facilitar la lectura, estoy usando el tipo StudentCollection
solo para variables que se referirán a toda la estructura de la lista. Después de declarar el nodo1 y apuntarlo
a un nodo de lista recién asignado en el montón
campos en ese nodo
, asignamos valores a StudentNum y Grade.
. En este punto, el siguiente campo no está asignado. Este no es un libro sobre
sintaxis, pero si no ha visto la notación ­> antes, se usa para indicar el campo de una estructura (o
clase) apuntada . Entonces node1­>studentNum significa “el campo StudentNum en la estructura
apuntada por node1” y es equivalente a (*node1).studentNum. Luego repetimos el mismo proceso
para el nodo2 y el nodo3.
Después de asignar los valores de campo al último nodo, el estado de la memoria se muestra en la
Figura 4­9. En estos diagramas, usaremos la notación de caja dividida que usamos anteriormente para
las matrices para mostrar la estructura del nodo.
Carolina del Sur
número de estudiante
?
calificación
1001 78
?
1012 93
?
1076 85
?
próximo
nodo1
nodo2
nodo3
estructura listNode
Figura 4­9: A mitad de la construcción de una lista enlazada de muestra
102 Capítulo 4
Machine Translated by Google
Ahora que tenemos todos nuestros nodos, podemos unirlos para formar una lista
vinculada. Eso es lo que hace el resto del listado de códigos anterior. Primero, apuntamos
nuestra variable StudentCollection al primer nodo
del primer nodo al segundo nodo
al tercer nodo
, luego apuntamos el siguiente campo
, y luego apuntamos el siguiente campo del segundo nodo
. En el siguiente paso, asignamos NULL
(nuevamente, esto es solo un sinónimo de cero) al siguiente campo del tercer nodo
.
Hacemos esto por la misma razón que pusimos un carácter nulo al final de nuestras matrices
en el problema anterior: para terminar la estructura. Así como necesitábamos un carácter
especial para mostrarnos el final de la matriz, necesitamos un cero en el siguiente campo del
último nodo de nuestra lista vinculada para que sepamos que es el último nodo. Finalmente,
para limpiar las cosas y evitar posibles problemas de entrecruzamiento, asignamos NULL a
cada uno de los punteros de nodo individuales
. El estado resultante de la memoria se
muestra en la Figura 4­10.
1001 78
Carolina del Sur
nodo1
NULO
nodo2
NULO
1012 93
nodo3 NULO
1076 85
NULO
Figura 4­10: Lista enlazada de muestra completa
Con esta imagen frente a nosotros, queda claro por qué la estructura se llama vinculada.
lista: cada nodo de la lista está vinculado al siguiente. A menudo verás listas enlazadas
dibujado linealmente, pero en realidad prefiero el aspecto de memoria dispersa de este
diagrama porque enfatiza que estos nodos no tienen ninguna relación entre sí además de
los enlaces; cada uno de ellos podría estar en cualquier lugar dentro del montón.
Asegúrese de rastrear el código hasta que esté seguro de que está de acuerdo con el
diagrama.
Observe que, en el estado final, solo queda un puntero basado en pila.
en uso, nuestra variable sc de StudentCollection , que apunta al primer nodo. Un puntero
externo a la lista (es decir, no al siguiente campo de un nodo en la lista) que apunta al
primer nodo en una lista vinculada se conoce como puntero principal. A nivel simbólico, esta
variable representa la lista en su conjunto, pero por supuesto hace referencia directa sólo
al primer nodo. Para llegar al segundo nodo, tenemos que pasar por el primero, y para llegar
al tercer nodo, tenemos que pasar por los dos primeros, y así sucesivamente. Esto significa
que las listas enlazadas ofrecen sólo acceso secuencial, a diferencia del acceso aleatorio
que proporcionan las matrices. El acceso secuencial es la debilidad de las estructuras de
listas enlazadas. La fortaleza de las estructuras de listas enlazadas, como se mencionó
anteriormente, es nuestra capacidad de aumentar o reducir el tamaño de la estructura
agregando o eliminando nodos, sin tener que crear una estructura completamente nueva y
copiar los datos, como lo hacemos. He terminado con las matrices.
Resolver problemas con punteros y memoria dinámica 103
Machine Translated by Google
Agregar nodos a una lista
Ahora implementemos la función addRecord . Esta función creará un nuevo nodo y lo
conectará a una lista vinculada existente. Usaremos las mismas técnicas que usamos
en el problema anterior. Primero: un shell de función y una llamada de muestra. Para
realizar pruebas, agregaremos código al listado anterior, de modo que sc ya exista
como puntero principal a la lista de tres nodos.
void addRecord(colecciónestudiante& sc, int stuNum, int gr) {
}
addRecord(sc, 1274, 91);
Nuevamente, la llamada
vendría al final del listado anterior. Con el shell de
función describiendo los parámetros, podemos diagramar el estado "antes" de esta
llamada, como se muestra en la Figura 4­11.
1001 78
Carolina del Sur
estuNum 1274
1012 93
91
gramo
1076 85
NULO
Figura 4­11: El estado "antes" de la función addRecord
Sin embargo, en cuanto al estado “después”, tenemos una opción. Podemos
adivinar que vamos a crear un nuevo nodo en el montón y copiar los valores de los
parámetros stuNum y gr en los campos StudentNum y Grade del nuevo nodo.
La pregunta es dónde va a ir, lógicamente, este nodo en nuestra lista enlazada. La
elección más obvia estaría al final; hay un valor NULL en el siguiente campo que
simplemente solicita que se le apunte a un nuevo nodo. Eso correspondería a la Figura 4­12.
1001 78
Carolina del Sur
estuNum
1274
gramo
91
1274 91 NULO
1012 93
1076 85
Figura 4­12: Estado "después" propuesto para la función addRecord
Pero si podemos suponer que el orden de los registros no importa (que no
necesitamos mantener los registros en el mismo orden en que se agregaron a la
colección), entonces esta es la elección incorrecta. Para ver por qué, consideremos
una colección, no de tres expedientes de estudiantes, sino de 3.000. Para llegar al
último registro de nuestra lista vinculada y modificar su siguiente campo sería necesario
viajar a través de los 3.000 nodos. Esto es inaceptablemente ineficiente porque podemos
incluir el nuevo nodo en la lista sin pasar por ninguno de los nodos existentes.
104 Capítulo 4
Machine Translated by Google
La Figura 4­13 muestra cómo. Una vez creado el nuevo nodo, se vincula a la lista al
principio, no al final. En el estado "después", nuestro puntero principal sc
apunta al nuevo nodo, mientras que el siguiente campo del nuevo nodo apunta al que
anteriormente era el primer nodo de la lista, el que tiene el número de estudiante 1001.
Tenga en cuenta que si bien asignamos un valor al siguiente campo del nuevo nodo, el único
puntero existente que cambia es sc, y ninguno de los valores de los nodos existentes se
modifica ni siquiera se inspecciona. Trabajando desde nuestro diagrama, aquí está el código:
void addRecord(colecciónestudiante& sc, int stuNum, int gr) {
listNode * newNode = nuevo listNode;
nuevoNodo­>NumEstudiante = NumEstudiante;
nuevoNodo­>grado = gr;
nuevoNodo­>siguiente = sc;
sc = nuevoNodo;
}
1274 91
1001 78
Carolina del Sur
estuNum
gramo
1274
1012 93
91
1076 85
NULO
Figura 4­13: Estado "después" aceptable para la función addRecord .
La flecha discontinua indica el valor anterior del puntero almacenado en sc.
Nuevamente, permítame enfatizar que traducir un diagrama y ese código es mucho más
fácil que tratar de mantener las cosas claras en su cabeza. El código proviene directamente de
la ilustración. Creamos un nuevo nodo
a partir de los parámetros
y asignamos el número de estudiante y la calificación
. Luego vinculamos el nuevo nodo a la lista, primero apuntando el
siguiente campo del nuevo nodo al primer nodo anterior (asignándole el valor de sc)
luego apuntando sc al nuevo nodo
y
.
Tenga en cuenta que los dos últimos pasos deben realizarse en ese orden; Necesitamos usar
el valor original de sc antes de cambiarlo. También tenga en cuenta que debido a que
cambiamos sc, debe ser un parámetro de referencia.
Como siempre, cuando creamos código a partir de un caso de muestra, tenemos que
comprobar posibles casos especiales. Aquí, eso significa verificar que la función funcione
con una lista vacía. Con nuestras matrices de cadenas, una cadena vacía seguía siendo un
puntero válido porque todavía teníamos una matriz a la que señalar, una matriz con solo el
carácter de terminación nula. Aquí, sin embargo, el número de nodos es el mismo que el
número de registros, y una lista vacía sería un puntero de cabecera NULL . ¿Nuestro código
seguirá vigente si intentamos insertar nuestros datos de muestra cuando el puntero principal
entrante es NULL? La Figura 4­14 muestra el estado "antes" y el estado "después" deseado.
Resolver problemas con punteros y memoria dinámica 105
Machine Translated by Google
Recorriendo este ejemplo a través de nuestro
código, vemos que maneja bien este caso. El
nuevo nodo se crea igual que antes. Debido a
que sc es NULL en el estado "antes", cuando
NULO
Carolina del Sur
este
estuNum
valor se copia en el siguiente campo de nuestro
1274
91
gramo
nuevo nodo, eso es exactamente lo que queremos
(a)
y nuestra lista de un nodo termina correctamente.
Tenga en cuenta que si hubiéramos continuado
con la otra idea de implementación (agregar
el nuevo nodo al final de la lista vinculada en
lugar de al principio), una lista inicialmente vacía
1274 91 NULO
Carolina del Sur
estuNum
sería un caso especial porque entonces sería el
gramo
único caso en el que sc es modificado.
1274
91
(b)
Figura 4­14: Los estados “antes” y
“después” para el caso addRecord más pequeño
Recorrido de lista
Ahora es el momento de descubrir la función AverageRecord . Como antes, comenzaremos con un
caparazón y un diagrama. Aquí está el shell de funciones y la llamada de muestra. Supongamos que la
llamada de muestra
ocurre después de la creación de nuestra lista de muestra original, como se
muestra en la Figura 4­10.
doble registro promedio (colección de estudiantes sc) {
}
int promedio = registropromedio(sc);
Como puedes ver, elegí calcular el promedio como un int, como hicimos con las matrices en el
capítulo anterior. Sin embargo, dependiendo del problema, podría ser mejor calcularlo como un valor
de coma flotante. Ahora necesitamos un diagrama, pero prácticamente ya tenemos un estado "antes"
con la Figura 4­9. No necesitamos un diagrama para el estado "después" porque esta función no
cambiará nuestra estructura dinámica, solo informará sobre ello. Sólo necesitamos saber el resultado
esperado, que en este caso es aproximadamente 85,3333.
Entonces, ¿cómo calculamos realmente el promedio? A partir de nuestra experiencia en el
cálculo del promedio de todos los valores de una matriz, conocemos el concepto general. Necesitamos
sumar todos los valores de la colección y luego dividir esa suma por la cantidad de valores. Con nuestro
código de promedio de matriz, inspeccionamos cada valor usando un bucle for de 0 a uno menor que
el tamaño de la matriz, usando el contador de bucle como subíndice de la matriz. No podemos usar un
bucle for aquí porque no sabemos de antemano cuántos números hay en la lista vinculada; Tenemos
que continuar hasta alcanzar el valor NULL en el siguiente campo de un nodo que indica la
terminación de la lista. Esto sugiere un bucle while , algo parecido a lo que usamos anteriormente en
este capítulo para procesar nuestros arreglos de longitud desconocida. corriendo a través de un
106 Capítulo 4
Machine Translated by Google
Una lista vinculada como esta, desde el principio hasta el final, se conoce como recorrido de lista.
Esta es una de las operaciones básicas en una lista vinculada. Pongamos en práctica la idea
transversal para resolver este problema:
doble registro promedio (colección de estudiantes sc) {
int recuento = 0;
doble suma = 0;
listNode * loopPtr = sc;
mientras (loopPtr! = NULL) {
suma += loopPtr­>grado;
cuenta++;
loopPtr = loopPtr­>siguiente;
}
doble promedio = suma / recuento;
promedio de retorno;
}
Comenzamos declarando una variable count para almacenar el número de nodos que
encontramos en la lista ; este también será el número de valores en la colección, que usaremos
para calcular el promedio. A continuación declaramos una suma variable.
para almacenar el total acumulado de valores de calificación en la lista
listNode * llamado loopPtr, que usaremos para recorrer la lista
. Luego declaramos un
. Este es el equivalente de
nuestra variable de bucle entero en un bucle for de procesamiento de matrices; realiza un
seguimiento de dónde estamos en la lista vinculada, no con el número de posición sino almacenando
un puntero al nodo que estamos procesando actualmente.
En este punto comienza el recorrido propiamente dicho. El bucle transversal continúa
hasta que nuestro puntero de seguimiento del bucle alcanza nuestro NULL
terminal. Dentro
del bucle, agregamos el valor del campo de calificación en el nodo al que se hace referencia
actualmente para sumar
. Incrementamos el recuento
del nodo actual a nuestro puntero de seguimiento de bucle
y luego asignamos el siguiente campo
. Esto tiene el efecto de adelantar
nuestro recorrido un nodo. Esta es la parte complicada del código, así que asegurémonos de
tenerlo claro. En la Figura 4­15, muestro cómo la variable del nodo cambia con el tiempo. Las letras
(a) a (d) marcan diferentes puntos durante la ejecución del código en nuestros datos de muestra,
mostrando diferentes puntos durante la vida útil de loopPtr y las ubicaciones desde las cuales se
obtuvo el valor de loopPtr . El punto (a) es justo cuando comienza el ciclo; loopPtr acaba de
inicializarse con el valor de sc. Por lo tanto, loopPtr apunta al primer nodo de la lista, tal como lo
hace sc . Entonces, durante la primera iteración del ciclo, el valor de calificación del primer nodo de
78 se suma a la suma. El siguiente valor del primer nodo se copia en loopPtr
de modo que ahora loopPtr apunte al segundo nodo de la lista; este es el punto (b). Durante la
segunda iteración, sumamos 93 a la suma y copiamos el siguiente campo del segundo nodo a
loopPtr; Este es el punto (c). Finalmente, durante la tercera y última iteración del bucle, sumamos
85 a la suma y asignamos el NULL del siguiente campo en el tercer nodo a loopPtr; este es el punto
(d). Cuando volvemos a llegar a la parte superior del bucle while , el bucle termina porque loopPtr
es NULL. Debido a que incrementamos el conteo cada vez que iteramos, el conteo es tres.
Resolver problemas con punteros y memoria dinámica 107
Machine Translated by Google
(a)
Carolina del Sur
1001 78
(b)
(a)
buclePtr
1012 93
(C)
(b)
buclePtr
1076 85
(C)
buclePtr
(d)
NULO
(d)
buclePtr
Figura 4­15: Cómo cambia la variable local loopPtr
durante las iteraciones del bucle en la función AverageRecord
Una vez que el ciclo está completo, simplemente dividimos la suma por el recuento y devolvemos el
resultado .
El código funciona en nuestro caso de muestra, pero como siempre, debemos verificar posibles casos
especiales. Nuevamente, con las listas, el caso especial más obvio es una lista vacía. ¿Qué sucede con
nuestro código si sc es NULL cuando comienza la función?
¿Adivina qué? El código explota. (Tuve que hacer que uno de estos casos especiales saliera mal;
de lo contrario, no me tomarías en serio). No hay nada malo con el bucle para el procesamiento de la lista
enlazada en sí. Si sc es NULL, entonces loopPtr se inicializa en NULL, el ciclo termina tan pronto como
comienza y la suma se deja en cero, lo que parece bastante razonable. El problema es que cuando realizamos
la división para calcular el promedio
, el recuento también es cero, lo que significa que estamos dividiendo
por cero y esto provocará un bloqueo del programa o un resultado basura. Para manejar este caso especial,
podríamos comparar el conteo con cero al final de la función, pero ¿por qué no manejar la situación desde
el principio y verificar sc? Agreguemos lo siguiente como nueva primera línea en nuestra función
AverageRecord :
si (sc == NULL) devuelve 0;
Como muestra este ejemplo, manejar casos especiales suele ser bastante sencillo. Nosotros
Sólo tenemos que asegurarnos de que nos tomemos el tiempo para identificarlos.
Conclusión y próximos pasos
Este capítulo apenas ha arañado la superficie de la resolución de problemas utilizando punteros y memoria
dinámica. Verá punteros y asignaciones de montón a lo largo del resto de este texto. Por ejemplo, las
técnicas de programación orientada a objetos, que analizaremos en el Capítulo 5, son especialmente útiles
cuando se trata de punteros. Nos permiten encapsular punteros de tal manera que no tengamos que
preocuparnos por pérdidas de memoria, punteros colgantes o cualquiera de los otros errores comunes de
los punteros.
108 Capítulo 4
Machine Translated by Google
Aunque hay mucho más que aprender sobre la resolución de problemas en esta área,
podrá desarrollar sus habilidades con estructuras basadas en punteros de complejidad
creciente si sigue las ideas básicas de este capítulo: Primero, aplique las reglas generales
del problema. resolviendo. Luego, aplique reglas específicas para los punteros y use un
diagrama o herramienta similar para visualizar cada solución antes de comenzar a codificar.
Ejercicios
No bromeo acerca de hacer los ejercicios. No estás simplemente leyendo los capítulos y
siguiendo adelante, ¿verdad?
4­1. Diseñe el suyo propio: tome un problema que ya sepa resolver usando una matriz pero que
esté limitado por el tamaño de la matriz. Vuelva a escribir el código para eliminar esa
limitación utilizando una matriz asignada dinámicamente.
4­2. Para nuestras cadenas asignadas dinámicamente, cree una subcadena de función que tome
tres parámetros: una cadena de matriz, un entero de posición inicial y una longitud entera
de caracteres. La función devuelve un puntero a una nueva matriz de cadenas asignada
dinámicamente. Esta matriz de cadenas contiene los caracteres de la cadena original,
comenzando en la posición especificada para la longitud especificada. La cadena original
no se ve afectada por la operación. Entonces, si la cadena original era abcdefg, la posición
era 3 y la longitud era 4, entonces la nueva cadena contendría cdef.
4­3. Para nuestras cadenas asignadas dinámicamente, cree una función reemplazarCadena que
tome tres parámetros, cada uno de tipo cadenamatriz: origen, destino y reemplazarTexto.
La función reemplaza cada aparición de destino en el origen con reemplazarTexto.
Por ejemplo, si la fuente apunta a una matriz que contiene abcdabee, el destino apunta a
ab y replaceText apunta a xyz, cuando finalice la función, la fuente debe apuntar a una
matriz que contenga xyzcdxyzee.
4­4. Cambie la implementación de nuestras cadenas de modo que la ubicación [0] en la matriz
almacene el tamaño de la matriz (y por lo tanto la ubicación [1] almacene el primer
carácter real de la cadena), en lugar de usar un terminador de carácter nulo.
Implemente cada una de las tres funciones, agregar, concatenar y caractertAt,
aprovechando la información de tamaño almacenada siempre que sea posible. Debido a
que ya no usaremos la convención de terminación nula esperada por el flujo de salida
estándar, necesitará escribir su propia función de salida que recorra su parámetro de
cadena y muestre los caracteres.
4­5. Escriba una función removeRecord que tome un puntero a una colección de estudiantes y un
número de estudiante y que elimine el registro con ese número de estudiante de la colección.
4­6.
Creemos una implementación para cadenas que utilice una lista vinculada de caracteres en
lugar de matrices asignadas dinámicamente. Entonces tendremos una lista vinculada
donde la carga de datos es un solo carácter; esto permitirá que las cadenas crezcan sin
tener que volver a crear la cadena completa. Comenzaremos implementando append y caracterAt
funciones.
Resolver problemas con punteros y memoria dinámica 109
Machine Translated by Google
4­7.
Siguiendo con el ejercicio anterior, implemente la función concatenar .
Tenga en cuenta que si hacemos una llamada concatenate(s1, s2), donde ambos
parámetros son punteros a los primeros nodos de sus respectivas listas enlazadas, la
función debería crear una copia de cada uno de los nodos en s2 y agregarlos al final de s1 . .
Es decir, la función no debería simplemente apuntar el siguiente campo del último nodo de la
lista de s1 al primer nodo de la lista de s2.
4­8. Agregue una función a la implementación de cadenas de lista vinculada llamada removeChars
para eliminar una sección de caracteres de una cadena según la posición y la longitud.
Por ejemplo, removeChars(s1, 5, 3) eliminaría los tres caracteres comenzando por el quinto
carácter de la cadena. Asegúrese de que los nodos eliminados se desasignen correctamente.
4­9. Imagine una lista vinculada donde, en lugar de que el nodo almacene un carácter, el nodo
almacena un dígito: un int en el rango de 0 a 9. Podríamos representar números positivos
de cualquier tamaño utilizando dicha lista enlazada; el número 149, por ejemplo, sería una
lista enlazada en la que el primer nodo almacena un 1, el segundo un 4 y el tercero y último
un 9. Escribe una función intToList que tome un valor entero y produzca una lista enlazada
de este clasificar. Sugerencia: Puede que le resulte más fácil crear la lista vinculada al
revés, por lo que si el valor fuera 149, crearía el nodo 9 primero.
4­10. Para la lista de dígitos del ejercicio anterior, escriba una función que tome dos de esas listas y
produzca una nueva lista que represente su suma.
110 Capítulo 4
Machine Translated by Google
t
l
S
YYPAG
oh
C
A
DY
Y
RESOLVIENDO PROBLEMAS
CON CLASES
En este capítulo, discutiremos las clases y la
programación orientada a objetos. Como antes, se
supone que has visto la clase .
declaración en C++ y comprender los conceptos básicos
sintaxis para crear una clase, invocar los métodos de una
clase, etc. Haremos una revisión rápida en la siguiente sección,
pero discutiremos principalmente los aspectos de resolución
de problemas de las clases.
Ésta es otra situación en la que creo que C++ tiene una ventaja sobre otros
lenguajes. Debido a que C++ es un lenguaje híbrido, el programador de C++ puede
crear clases cuando sea apropiado, pero nunca tiene que hacerlo. Por el contrario, en
un lenguaje como Java o C#, todo el código debe aparecer dentro de los límites de una
declaración de clase. En manos de programadores expertos, esto no causa ningún
daño indebido, pero en manos de novatos puede generar malos hábitos. Para un
programador de Java o C#, todo es un objeto. Si bien todo el código escrito en estos
lenguajes debe encapsularse en objetos, el resultado no siempre refleja un diseño
sensato orientado a objetos. Un objeto debe ser una colección significativa y muy unida
de datos y código que opere con esos datos. No debería ser una bolsa arbitraria de sobras.
Machine Translated by Google
Debido a que estamos programando en C++ y por lo tanto podemos elegir entre
programación procedimental y orientada a objetos, hablaremos sobre el buen diseño de clases, así
como también sobre cuándo se deben y no se deben usar las clases. Reconocer una situación en la
que una clase sería útil es esencial para alcanzar niveles superiores de estilo de programación,
pero es igualmente importante reconocer situaciones en las que una clase empeorará las cosas.
Revisión de los fundamentos de la clase
Como siempre, este libro supone que usted tiene contacto previo con los fundamentos y
referencias de la sintaxis de C++, pero revisemos los fundamentos de la sintaxis de clases para que
estemos en la misma página con la terminología. Una clase es un modelo para construir un paquete
particular de código y datos; cada variable creada según el modelo de una clase se conoce como
un objeto de esa clase. El código fuera de una clase que crea y usa un objeto de esa clase se
conoce como cliente.
de la clase. Una declaración de clase nombra la clase y enumera todos los miembros o elementos
dentro de esa clase. Cada elemento es un miembro de datos (una variable declarada dentro de la
clase) o un método (también conocido como función miembro), que es una función declarada dentro
de la clase. Las funciones miembro pueden incluir un tipo especial llamado constructor, que tiene
el mismo nombre que la clase y se invoca implícitamente cuando se declara un objeto de la clase.
Además de los atributos normales de una variable o declaración de función (como el tipo y, para las
funciones, la lista de parámetros), cada miembro también tiene un especificador de acceso, que
indica qué funciones pueden acceder al miembro. Se puede acceder a un miembro público
mediante cualquier código utilizando el objeto: código dentro de la clase, un cliente de la clase o
código en una subclase, que es una clase que "hereda" todo el código y los datos de una clase
existente. Solo se puede acceder a un miembro privado mediante el código dentro de la clase.
Los miembros protegidos, que veremos brevemente en este capítulo, son similares a los
miembros privados, excepto que los métodos en las subclases también pueden hacer referencia a
ellos. Sin embargo, tanto los miembros privados como los protegidos son inaccesibles desde el
código del cliente.
A diferencia de atributos como el tipo de retorno, el especificador de acceso dentro de la
declaración de clase se mantiene hasta que se reemplaza por un especificador diferente. Por lo
tanto, cada especificador suele aparecer sólo una vez, con los miembros agrupados por acceso.
Esto lleva a los programadores a referirse a "la sección pública" o "la sección privada" de una clase,
como en "Deberíamos poner este método en la sección privada".
Veamos un pequeño ejemplo de declaración de clase:
clase muestra {
público:
muestra();
muestra(int num);
int hace algo (doble parámetro);
privado:
int intDatos;
} ;
Esta declaración comienza nombrando la clase
, por lo que luego sample se convierte en un
nombre de tipo. La declaración comienza con un especificador de acceso público
112 Capítulo 5
, por lo que hasta
Machine Translated by Google
llegamos al especificador privado , todo lo que sigue es público. Muchos programadores
incluyen primero las declaraciones públicas, esperando que la interfaz pública sea de mayor
interés para otros lectores. Las declaraciones públicas aquí son dos constructores ( y )
llamados muestra y otro método, hace algo .
Los constructores se invocan implícitamente cuando se declaran objetos de esta clase.
objeto de muestra1;
objeto de muestra2(15);
Aquí, el objeto1 invocaría al primer constructor , conocido como constructor
predeterminado, que no tiene parámetros, mientras que el objeto2 invocaría al segundo
constructor
porque especifica un valor entero único y, por lo tanto, coincide con la
firma del parámetro del segundo constructor.
La declaración concluye con un miembro de datos privados, intData . Recuerde que
una declaración de clase termina con una llave de cierre y un punto y coma . Este punto
y coma puede parecer un poco misterioso porque no concluimos funciones, bloques de
instrucciones if ni ninguna otra llave de cierre con punto y coma. La presencia del punto y
coma en realidad indica que las declaraciones de clase también son, opcionalmente,
declaraciones de objeto; Podríamos poner identificadores entre la llave de cierre y el punto
y coma y crear objetos a medida que creamos nuestras clases. Sin embargo, esto no es
muy común en C++, especialmente considerando que muchos programadores colocan
sus definiciones de clases en archivos separados de los programas que las usan. El
misterioso punto y coma también aparece después de la llave de cierre de una estructura .
Hablando de estructura, debes saber que en C++, estructura y clase denotan
casi lo mismo. La única diferencia entre los dos implica los miembros (datos o métodos)
declarados antes del primer especificador de acceso. En una estructura, estos miembros
serían públicos, mientras que en una clase serían privados. Sin embargo, los buenos
programadores utilizan las dos estructuras de diferentes maneras. Esto es análogo a cómo
cualquier bucle for podría escribirse como un bucle while , pero un buen programador puede
hacer que el código sea más legible utilizando bucles for en bucles de conteo más sencillos.
La mayoría de los programadores reservan struct para estructuras más simples, ya sea
aquellas que no tienen miembros de datos más allá de los constructores o aquellas
destinadas a usarse como parámetros para métodos de una clase más grande.
Objetivos del uso de la clase
Para reconocer las situaciones correctas e incorrectas para el uso de clases y la forma
correcta e incorrecta de construir una clase, tenemos que decidir cuáles son nuestros
objetivos al usar las clases en primer lugar. Al considerar esto, debemos recordar que las
clases son siempre opcionales. Es decir, las clases no nos brindan nuevas capacidades
como lo hacen una matriz o una estructura basada en punteros. Si toma un programa que
utiliza una matriz para ordenar 10.000 registros, no será posible escribir ese mismo
programa sin la matriz. Si tiene un programa que depende de la capacidad de una lista
vinculada para crecer y reducirse con el tiempo, no podrá crear los mismos efectos con la
misma eficiencia sin utilizar una lista vinculada o una estructura similar basada en punteros.
Sin embargo, si elimina las clases de un programa orientado a objetos y las reescribe, el
programa se verá diferente, pero las capacidades y eficiencia del programa no disminuirán.
En efecto,
Resolver problemas con las clases 113
Machine Translated by Google
Los primeros compiladores de C++ funcionaban como preprocesadores. El compilador de C++ leería el
código fuente de C++ y generaría una nueva fuente sobre la marcha que fuera sintaxis legal de C.
Este código fuente modificado luego se enviaría a un compilador de C. Lo que esto nos dice es que las
principales adiciones que C++ hizo al lenguaje C no se referían a las capacidades funcionales del
lenguaje sino a cómo lee el código fuente al programador.
Por lo tanto, al elegir nuestros objetivos generales de diseño de clases, elegimos objetivos que
nos ayuden, como programadores, a realizar nuestras tareas. En particular, dado que este libro trata
sobre la resolución de problemas, deberíamos pensar en cómo las clases nos ayudan a resolver
problemas.
Encapsulación
La palabra encapsulación es una forma elegante de decir que las clases reúnen múltiples datos y códigos
en un solo paquete. Si alguna vez ha visto una cápsula de gelatina llena de pequeñas esferas, esa es
una buena analogía: el paciente toma una cápsula y traga todas las esferas de ingredientes individuales
que contiene.
La encapsulación es el mecanismo que permite que muchos de los otros objetivos que enumeramos
a continuación tengan éxito, pero también es un beneficio en sí mismo porque organiza nuestro código.
En una larga lista de programas de código puramente procedimental (en C++, esto significaría código
con funciones pero sin clases), puede ser difícil encontrar un buen orden para nuestras funciones y
directivas del compilador que nos permita recordar fácilmente sus ubicaciones. En cambio, nos vemos
obligados a confiar en nuestro entorno de desarrollo para encontrar nuestras funciones por nosotros. La
encapsulación mantiene unidas las cosas que van juntas.
Si estás trabajando en un método de clase y te das cuenta de que necesitas mirar o modificar otro
código, es probable que aparezca otro código en otro método de la misma clase y, por lo tanto, esté
cerca.
Reutilización de código
Desde el punto de vista de la resolución de problemas, la encapsulación nos permite reutilizar más
fácilmente el código de problemas anteriores para resolver los problemas actuales. A menudo,
aunque hayamos trabajado en un problema similar a nuestro proyecto actual, reutilizar lo que
aprendimos antes todavía requiere mucho trabajo. Una clase completamente encapsulada puede
funcionar como una unidad USB externa; simplemente lo conectas y funciona. Sin embargo, para que
esto suceda, debemos diseñar la clase correctamente para asegurarnos de que el código y los datos
estén realmente encapsulados y sean lo más independientes posible de cualquier cosa fuera de la clase.
Por ejemplo, una clase que hace referencia a una variable global no se puede copiar en un nuevo
proyecto sin copiar también la variable global.
Más allá de reutilizar clases de un programa a otro, las clases ofrecen el potencial de una forma
más inmediata de reutilización de código: la herencia. Recuerde que en el Capítulo 4 hablamos sobre el
uso de funciones auxiliares para “factorizar” el código común a dos o más funciones. La herencia lleva
esta idea a una escala mayor. Usando la herencia, creamos clases principales con métodos comunes a
dos o más clases secundarias, "factorizando" no sólo unas pocas líneas de código sino métodos
completos. La herencia es un tema amplio en sí mismo y exploraremos esta forma de reutilización de
código más adelante en este capítulo.
114 Capítulo 5
Machine Translated by Google
Dividiendo el problema
Una técnica a la que hemos regresado una y otra vez es dividir un problema complejo
en partes más pequeñas y manejables. Las clases son excelentes para dividir programas
en unidades funcionales. La encapsulación no sólo mantiene los datos y el código
juntos en un paquete reutilizable; también aísla esos datos y código del resto del
programa, permitiéndonos trabajar en esa clase y en todo lo demás por separado.
Cuantas más clases hagamos en un programa, mayor será el efecto de división del
problema.
Entonces, cuando sea posible, debemos dejar que la clase sea nuestro método
para dividir problemas complejos. Si las clases están bien diseñadas, se impondrá la
separación funcional y el problema será más fácil de resolver. Como efecto secundario,
podemos encontrar que las clases que creamos para un problema son reutilizables
en otros problemas, incluso si no consideramos completamente esa posibilidad cuando
las creamos.
Ocultación de información
Algunas personas usan los términos ocultación de información y encapsulación
indistintamente, pero aquí separaremos las ideas. Como se describió anteriormente en
este capítulo, la encapsulación consiste en empaquetar datos y código juntos. Ocultar
información significa separar la interfaz de una estructura de datos (la definición de las
operaciones y sus parámetros) de la implementación de una estructura de datos o el
código dentro de las funciones. Si una clase se ha escrito con información oculta como
objetivo, entonces es posible cambiar la implementación de los métodos sin requerir
ningún cambio en el código del cliente (el código que usa la clase). Nuevamente
debemos tener claro el término interfaz; esto significa no sólo el nombre de los
métodos y su lista de parámetros sino también la explicación (quizás expresada en la
documentación del código) de lo que hacen los diferentes métodos. Cuando hablamos
de cambiar la implementación sin cambiar la interfaz, queremos decir que cambiamos
cómo funcionan los métodos de clase pero no qué hacen. Algunos autores de
programación se han referido a esto como una especie de contrato implícito entre la
clase y el cliente: la clase se compromete a no cambiar nunca los efectos de las
operaciones existentes, y el cliente se compromete a utilizar la clase estrictamente
sobre la base de su interfaz e ignorarla. cualquier detalle de implementación. Piense
en tener un control remoto universal que pueda controlar cualquier televisor, ya sea
un modelo de tubo antiguo o uno que use una pantalla LCD o plasma.
Presiona 2, luego 5, luego Enter, y cualquiera de las pantallas mostrará el canal 25,
aunque el mecanismo para que esto suceda es muy diferente dependiendo de la
tecnología subyacente.
No hay forma de ocultar información sin encapsular, pero como hemos definido los
términos, es posible encapsular sin ocultar información. La forma más obvia en que
esto puede suceder es si los miembros de datos de una clase se declaran públicos.
En tal caso, la clase sigue siendo una encapsulación, en el sentido de que es un
paquete de código y datos que van juntos. Sin embargo, el código del cliente ahora
tiene acceso a un detalle importante de implementación de la clase: las variables y
tipos que la clase usa para almacenar sus datos. Incluso si el código del cliente no
modifica los datos de la clase directamente y solo los inspecciona, el código del cliente
Resolver problemas con las clases 115
Machine Translated by Google
entonces requiere la implementación de esa clase en particular. Cualquier cambio en la clase
que cambie el nombre o tipo de cualquiera de las variables a las que accede el código del
cliente también requiere cambios en el código del cliente.
Su primer pensamiento podría ser que la ocultación de información está asegurada siempre
que todos los datos sean privados y dediquemos suficiente tiempo a diseñar la lista de funciones
miembro y sus listas de parámetros para que nunca tengan que cambiar.
Si bien todo esto es necesario para ocultar información, no es suficiente porque los problemas
de ocultación de información pueden ser más sutiles. Recuerde que la clase acepta no cambiar
lo que hace cualquiera de los métodos, independientemente de la situación. En capítulos
anteriores, tuvimos que decidir el caso más pequeño que manejará una función o qué hacer
con un caso anómalo, como encontrar el promedio de una matriz cuando el parámetro que
almacena el tamaño de la matriz es cero. Cambiar el resultado de un método incluso en un
caso extraño representa un cambio de la interfaz y debe evitarse. Ésta es otra razón por la
que considerar explícitamente casos especiales es tan importante en la programación.
Muchos programas han explotado cuando se ha actualizado su tecnología subyacente o su
interfaz de programación de aplicaciones (API), y alguna llamada al sistema que solía devolver
de forma fiable un –1 cuando uno de los parámetros era erróneo ahora devuelve un resultado
aparentemente aleatorio, pero aún así. numero negativo. Una de las mejores formas de evitar
este problema es indicar los resultados de casos especiales en la documentación de la clase
o método.
Si su propia documentación dice que devuelve un código de error –1 cuando ocurre una
situación determinada, lo pensará dos veces antes de que su método devuelva algo más.
Entonces, ¿cómo afecta el ocultamiento de información a la resolución de problemas? El
principio de ocultación de información le dice al programador que deje de lado los detalles de
implementación de la clase cuando trabaja en el código del cliente, o más ampliamente, que
se preocupe por la implementación de una clase en particular sólo cuando trabaja dentro de esa clase.
Cuando pueda dejar de pensar en los detalles de la implementación, podrá eliminar los
pensamientos que le distraigan y concentrarse en resolver el problema en cuestión.
Sin embargo, debemos ser conscientes de las limitaciones del ocultamiento de información
en lo que respecta a la resolución de problemas. A veces, los detalles de implementación sí son
importantes para el cliente. En capítulos anteriores, hemos visto las fortalezas y debilidades de
algunas estructuras de datos basadas en matrices y punteros. Las estructuras basadas en
matrices permiten el acceso aleatorio, pero no pueden crecer ni reducirse fácilmente, mientras
que las estructuras basadas en punteros solo ofrecen acceso secuencial, pero se les pueden
agregar o quitar piezas sin tener que volver a crear toda la estructura. Por lo tanto, una clase
construida con una estructura basada en matrices como base tendrá cualidades diferentes a
una basada en una estructura basada en punteros.
En informática, a menudo hablamos del concepto de tipo de datos abstracto, que es
información oculta en su forma más pura: un tipo de datos definido únicamente por sus
operaciones. En el Capítulo 4, analizamos el concepto de pila y describimos cómo la pila de
un programa es un bloque contiguo de memoria. Pero como tipo de datos abstracto, una pila
es cualquier tipo de datos en el que se pueden agregar y eliminar elementos individuales, y
los elementos se eliminan en el orden opuesto al que se agregaron. Esto se conoce como
orden de último en entrar, primero en salir o LIFO. Nada requiere que una pila sea un bloque
de memoria contiguo, y podríamos crear una pila usando una lista vinculada. Porque un bloque
de memoria contiguo y un bloque vinculado
116 Capítulo 5
Machine Translated by Google
list tiene propiedades diferentes, una pila que usa una implementación u otra también tendrá
propiedades diferentes, y estas pueden marcar una gran diferencia para el cliente que usa la pila.
El punto de todo esto es que ocultar información será un objetivo útil para nosotros como
solucionadores de problemas, en la medida en que nos permita dividir problemas y trabajar en
diferentes partes de un programa por separado. Sin embargo, no podemos permitirnos ignorar por
completo los detalles de la implementación.
Legibilidad
Una buena clase mejora la legibilidad del programa en el que aparece.
Los objetos pueden corresponder a cómo vemos el mundo real y, por lo tanto, las llamadas a
métodos a menudo tienen una legibilidad similar al inglés. Además, la relación entre objetos suele
ser más clara que la relación entre variables simples.
Mejorar la legibilidad mejora nuestra capacidad para resolver problemas, porque podemos
comprender nuestro propio código más fácilmente mientras está en desarrollo y porque la
reutilización mejora cuando el código antiguo es fácil de seguir.
Para maximizar el beneficio de legibilidad de las clases, debemos pensar en cómo
Los métodos de nuestra clase se utilizarán en la práctica. Los nombres de los métodos deben
elegirse con cuidado para reflejar el significado más específico de los efectos del método.
Por ejemplo, considere una clase que representa una inversión financiera que contiene un método
para calcular el valor futuro. El nombre calcular no transmite tanta información como
calcularFutureValue. Incluso elegir la parte correcta del discurso para el nombre puede resultar útil.
El nombre ComputeFutureValue es un verbo, mientras que FutureValue es un sustantivo. Observe
cómo se utilizan los nombres en los ejemplos de código que aparecen a continuación:
doble FV;
inversión.computeFutureValue(FV, 2050);
si (inversión.futureValue(2050) > 10000) { ...
Si lo piensas bien, lo primero tiene más sentido para una llamada que sería independiente, es
decir, una función nula en la que el valor futuro se envía de vuelta a la persona que llama a través
de un parámetro de referencia
. Esto último tiene más sentido para una llamada que se usaría en
una expresión, es decir, el valor futuro regresa como el valor de la función
.
Veremos ejemplos específicos más adelante en el capítulo, pero el principio rector para
maximizar la legibilidad es pensar siempre en el código del cliente cuando se escribe cualquier parte
de la interfaz de clase.
expresividad
Un objetivo final de una clase bien diseñada es la expresividad, o lo que en términos generales podría
llamarse capacidad de escritura: la facilidad con la que se puede escribir código. Una buena clase,
una vez escrita, hace que el resto del código sea más sencillo de escribir de la misma manera que
una buena función hace que el código sea más sencillo de escribir. Las clases amplían efectivamente
el idioma, convirtiéndose en contrapartes de alto nivel de características básicas de bajo nivel, como
Resolver problemas con las clases 117
Machine Translated by Google
como bucles, declaraciones if , etc. En C++, incluso la funcionalidad central como la entrada
y la salida no es una parte inherente de la sintaxis del lenguaje, sino que se proporciona
como un conjunto de clases que deben incluirse explícitamente en el programa que la utiliza.
Con las clases programar acciones que antes requerían muchos pasos se pueden realizar
en pocos pasos o en uno solo. Como solucionadores de problemas, debemos hacer de
este objetivo una prioridad especial. Siempre deberíamos estar pensando: "¿Cómo va a
hacer esta clase que el resto de este programa y los programas futuros que puedan usar
esta clase sean más fáciles de escribir?"
Construyendo una clase simple
Ahora que sabemos a qué objetivos deben apuntar nuestras clases, es hora de poner la
teoría en práctica y construir algunas clases. Primero, desarrollaremos nuestra clase en
etapas para usarla en el siguiente problema.
PROBLEMA: LISTA DE CLASE
Diseñar una clase o conjunto de clases para usar en un programa que mantiene una lista de clases. Para
cada estudiante, almacene el nombre del estudiante, su identificación y su calificación final en el rango de 0 a 100.
El programa permitirá agregar o eliminar registros de estudiantes; mostrar el expediente de un estudiante en
particular, identificado por identificación, con la calificación mostrada como un número y una letra; y mostrar
el puntaje promedio de la clase. La calificación con letras apropiada para una puntuación particular se muestra
en la Tabla 5­1.
Tabla 5­1: Calificaciones con letras
Rango de puntuación
Grado de la letra
93­100
A
90–92
A­
87–89
B+
83–86
B
80–82
B­
77–79
C+
73–76
C
70–72
C­
67–69
D+
60–66
D
0–59
F
Comenzaremos viendo un marco de clases básico que forma la base.
de la mayoría de las clases. Luego veremos formas en las que se amplía el marco básico.
118 Capítulo 5
Machine Translated by Google
El marco de clase básico
La mejor manera de explorar el marco de clases básico es a través de una clase de muestra.
Para este ejemplo, comenzaremos con la estructura de estudiantes del Capítulo 3 y la construiremos
en una clase completa. Para facilitar la referencia, aquí está la estructura original:
estudiante de estructura {
grado internacional;
int ID de estudiante;
nombre de cadena;
};
Incluso con una estructura simple de esta forma, al menos obtenemos encapsulación. recuerda­
Recuerde que en el Capítulo 3 construimos una matriz de datos de estudiantes con esta estructura,
y sin usar la estructura, habríamos tenido que construir tres matrices paralelas, una para las
calificaciones, una para los ID y otra para los nombres. ¡Qué feo! Sin embargo, lo que definitivamente
no conseguimos con esta estructura es ocultar información. El marco de clases básico nos brinda
información oculta al declarar todos los datos como privados y luego agregar métodos públicos para
permitir que el código del cliente acceda o cambie indirectamente estos datos.
registro de estudiante de clase {
público:
registrodeestudiante();
StudentRecord(int nuevaCalificación, int nuevaID, cadena nuevoNombre);
int grado();
void setGrade(int newGrade);
int ID de estudiante();
void setStudentID(int nuevoID);
nombre de cadena();
void setName(cadena nuevoNombre);
privado:
int _grado;
int _IDEstudiante;
cadena _nombre;
};
Como se prometió, esta declaración de clase está separada en una sección pública con
funciones miembro
original
y una sección privada
, que contiene los mismos datos que la estructura
. Hay ocho funciones miembro: dos constructores
y luego un par de funciones miembro para cada miembro de datos. Por ejemplo, el miembro de
datos _grade tiene dos funciones miembro asociadas, calificación
y setGrade
. El primero de
estos métodos será utilizado por el código del cliente para recuperar la calificación de un
StudentRecord en particular, mientras que el segundo de estos métodos se utilizará para almacenar
una nueva calificación para este StudentRecord en particular.
Los métodos de recuperación y almacenamiento asociados con un miembro de datos son tan
comunes que normalmente se los denomina con los términos abreviados get y set.
Como puede ver, incorporé la palabra conjunto en los métodos que almacenan nuevos valores en los
miembros de datos. Muchos programadores también habrían incorporado get en otros nombres, por
ejemplo, getGrade en lugar de grade. Por qué
Resolver problemas con las clases 119
Machine Translated by Google
¿No hice esto? Porque entonces habría estado usando un nombre de verbo para una función que
se usa como sustantivo. Algunos argumentarían, sin embargo, que el término get se entiende tan
universalmente y, por lo tanto, su significado es tan claro, que su uso anula la otra preocupación. En
última instancia, es una cuestión de estilo personal.
Aunque en este libro me he apresurado a señalar las ventajas que tiene C++ sobre otros
lenguajes, debo admitir que los lenguajes más recientes, como C#, superan a C++ cuando se trata
de métodos de obtención y configuración . C# tiene un mecanismo incorporado llamado propiedad
que actúa como método get y set . Una vez definido, el código del cliente puede acceder a la
propiedad como si fuera un miembro de datos en lugar de una llamada de función. Esta es una
gran mejora en la legibilidad y expresividad. En C++, sin un mecanismo incorporado, es importante
que decidamos alguna convención de nomenclatura para nuestros métodos y la usemos de manera
consistente.
Tenga en cuenta que mi convención de nomenclatura se extiende a los miembros de datos
que, a diferencia de la estructura original, todos comienzan con guiones bajos. Esto me permite
nombrar las funciones de obtención con (casi) el mismo nombre que los miembros de datos que recuperan.
Esto también permite un fácil reconocimiento de las referencias de miembros de datos en el código,
lo que mejora la legibilidad. Algunos programadores usan la palabra clave this para todas las
referencias a miembros de datos en lugar de usar un prefijo de guión bajo. Entonces, en lugar de
una declaración como:
devolver _grado;
ellos tendrían:
devolver este grado;
Si no has visto la palabra clave this antes, es una referencia al objeto en el que aparece.
Entonces, si la declaración anterior apareciera en un método de clase y ese método también declarara
una variable local con el nombre grado, la expresión this.grade se referiría al grado del miembro de
datos, no a la variable local con el mismo nombre. Emplear la palabra clave de esta manera tiene
una ventaja en un entorno de desarrollo con finalización automática de sintaxis: el programador
puede simplemente escribir esto, presionar la tecla punto y seleccionar el miembro de datos de una
lista, evitando tipeo adicional y posibles errores ortográficos. Sin embargo, cualquiera de las técnicas
resalta las referencias de los miembros de datos, que es lo importante.
Ahora que hemos visto la declaración de clase, veamos la implementación de los métodos.
Comenzaremos con el primer par get/set .
int registroestudiante::calificación() {
return _grade;
}
void StudentRecord::setGrade(int nuevaCalificación) {
_grade = nuevaCalificación;
}
Esta es la forma más básica del par get/set . El primer método, calificación, devuelve el valor
actual del miembro de datos asociado, _grado
parámetro newGrade a los datos.
120 Capítulo 5
. El segundo método, setGrade, asigna el valor del
Machine Translated by Google
miembro _grado
. Sin embargo, si esto fuera todo lo que hiciéramos con nuestra clase, no
habríamos logrado nada. Aunque este código proporciona información oculta porque pasa
datos en ambas direcciones sin ninguna consideración o modificación, es mejor que
declarar _grade público porque nos reserva el derecho de cambiar el nombre o tipo de
datos del miembro. El conjunto de calificaciones
El método debería al menos realizar alguna validación rudimentaria; debería evitar que se
asignen valores de newGrade que no tienen sentido como calificación al miembro de datos
_grade . Sin embargo, debemos tener cuidado de seguir las especificaciones del problema y
no hacer suposiciones sobre los datos basadas en nuestras propias experiencias, sin
considerar al usuario. Podría ser razonable limitar las calificaciones al rango de 0 a 100,
pero podría no serlo, por ejemplo, si una escuela permite créditos adicionales para elevar
una puntuación por encima de 100 o utiliza una calificación de ­1 como código para el retiro
de una clase. En este caso, debido a que la descripción del problema nos brinda alguna
orientación, podemos incorporar ese conocimiento en la validación.
void StudentRecord::setGrade(int nuevaCalificación) {
if ((nuevaCalificación >= 0) && (nuevaCalificación <= 100))
_calificación = nuevaCalificación;
}
Aquí, la validación es sólo un guardián. Sin embargo, dependiendo de la definición del
problema, podría tener sentido que el método produzca un mensaje de error, escriba en un
registro de errores o maneje el error de otra manera.
Los otros pares get/set funcionarían exactamente de la misma manera. Sin duda, existen
reglas sobre la construcción de números de identificación de estudiantes en una escuela en
particular que podrían usarse para la validación. Sin embargo, con el nombre de un estudiante,
lo mejor que podemos hacer es rechazar cadenas con caracteres extraños, como % o @, y
hoy en día quizás ni siquiera eso sería posible.
El último paso para completar nuestra clase es escribir los constructores. En el marco
básico, incluimos dos constructores: un constructor predeterminado, que no tiene parámetros
y establece los miembros de datos en valores predeterminados razonables, y un constructor
con parámetros para cada miembro de datos. La segunda forma de constructor es importante
para nuestro objetivo de expresividad , ya que nos permite crear un objeto de nuestra clase
e inicializar los valores internos en un solo paso. Una vez que haya escrito el código para
los otros métodos, este segundo constructor casi se escribe solo.
RegistroEstudiante::RegistroEstudiante(int nuevaCalificación, int nuevoID, cadena nuevoNombre) {
setGrade(newGrade);
setIDEstudiante(nuevoID);
setName(nuevoNombre);
}
Como puede ver, el constructor simplemente llama a los métodos establecidos apropiados
para cada uno de los parámetros. En la mayoría de los casos, este es el enfoque correcto
porque evita la duplicación de código y garantiza que el constructor aprovechará cualquier
código de validación en los métodos establecidos .
Resolver problemas con las clases
121
Machine Translated by Google
El constructor predeterminado a veces es un poco complicado, no porque el código sea
complicado sino porque no siempre hay un valor predeterminado obvio. Al elegir valores
predeterminados para los miembros de datos, tenga en cuenta las situaciones en las que se
utilizaría un objeto creado con el constructor predeterminado y, en particular, si existe un objeto
predeterminado legítimo para esa clase. Esto le indicará si debe completar los miembros de datos
con valores predeterminados útiles o con valores que indiquen que el objeto no está inicializado
correctamente. Por ejemplo, considere una clase que representa una colección de valores que
encapsula una lista vinculada. Hay una lista enlazada predeterminada significativa, y esa es una
lista enlazada vacía, por lo que configuraríamos nuestros miembros de datos para crear una lista
legítima, pero conceptualmente vacía . Pero con nuestra clase básica de muestra, no existe una
definición significativa de estudiante predeterminado; No querríamos dar un número de identificación
válido a un objeto StudentRecord predeterminado porque eso podría causar confusión con un
StudentRecord legítimo. Por lo tanto, deberíamos elegir un valor predeterminado para el campo
_studentID que obviamente sea ilegítimo, como por ejemplo –1:
RegistroEstudiante::RegistroEstudiante() {
setGrade(0);
establecerIDEstudiante(­1);
escoger un nombre("");
}
Asignamos la calificación con setGrade, que valida su parámetro. Esto significa que
tenemos que asignar una calificación válida, en este caso, 0. Debido a que el ID se establece en
un valor no válido, el registro en su conjunto puede identificarse fácilmente como ilegítimo.
Por lo tanto, la calificación válida no debería ser un problema. Si eso fuera una preocupación,
podríamos asignar un valor no válido directamente al miembro de datos _grade .
Esto completa el marco de clase básico. Tenemos un grupo de miembros de datos privados
que hacen referencia a atributos del mismo objeto lógico, en este caso, el registro de clase de
un estudiante; tenemos funciones miembro para recuperar o alterar los datos del objeto, con
validación según corresponda; y tenemos un conjunto útil de constructores. Tenemos una buena
base de clase. La pregunta es: ¿necesitamos hacer más?
Métodos de soporte
Un método de soporte es un método de una clase que no se limita a recuperar o almacenar
datos. Algunos programadores pueden referirse a estos como métodos auxiliares, métodos
auxiliares o cualquier otra cosa, pero como sea que se llamen, son los que llevan una clase
más allá del marco de clase básico. Un conjunto bien diseñado de métodos de soporte es a
menudo lo que hace que una clase sea realmente útil.
Para determinar posibles métodos de soporte, considere cómo se utilizará la clase. ¿Existen
actividades comunes que esperaríamos que realizara el código del cliente en los datos de
nuestra clase? En este caso, se nos dice que el programa para el cual estamos diseñando
inicialmente nuestra clase mostrará las calificaciones de los estudiantes no sólo como puntuaciones
numéricas sino también como letras. Entonces, creemos un método de soporte que devuelva la
calificación de un estudiante como una letra. Primero, agregaremos la declaración del método a
la sección pública de nuestra declaración de clase.
122 Capítulo 5
Machine Translated by Google
cadena letraCalificación();
Ahora necesitamos implementar este método. La función convertirá el valor numérico
almacenado en _grade a la cadena apropiada según la tabla de calificaciones que se muestra en el
problema. Podríamos lograr esto con una serie de si
declaraciones, pero ¿existe una forma más limpia y elegante? Si simplemente pensó: "Oye, esto
se parece mucho a cómo convertimos los ingresos en categorías de licencias comerciales en el
Capítulo 3", felicitaciones: ha descubierto una analogía de programación adecuada. Podemos
adaptar ese código, con matrices constantes paralelas para almacenar las calificaciones con letras
y las puntuaciones numéricas más bajas asociadas con esas calificaciones, para convertir la
puntuación numérica con un bucle.
string registroestudiante::letraCalificación() {
const int NUMBER_CATEGORIES = 11;
cadena constante GRADE_LETTER[] = {"F", "D", "D+", "C­", "C", "C+", "B­", "B", "B+", "A­", "A"};
constante int LOWEST_GRADE_SCORE[] = {0, 60, 67, 70, 73, 77, 80, 83, 87, 90, 93};
categoría int = 0;
mientras (categoría < NUMBER_CATEGORIES && LOWEST_GRADE_SCORE[categoría] <= _grado)
categoría++;
devolver GRADE_LETTER[categoría ­ 1];
}
Este método es una adaptación directa de la función del Capítulo 3, por lo que no hay nada
nuevo que explicar sobre cómo funciona el código. Sin embargo, su adaptación para un método de
clase introduce algunas decisiones de diseño. Lo primero que debemos tener en cuenta es que
no hemos creado un nuevo miembro de datos para almacenar la calificación con letras, sino para
calcular la calificación con letras adecuada sobre la marcha para cada solicitud.
El enfoque alternativo sería tener un miembro de datos _letterGrade y reescribir el método
setGrade para actualizar _letterGrade junto con _grade. Entonces este método letterGrade se
convertiría en un método get simple , que devolvería el valor del miembro de datos ya calculado.
El problema con este enfoque es la redundancia de datos, un término que describe una
situación en la que se almacenan datos que son un duplicado literal de otros datos o que pueden
determinarse directamente a partir de otros datos. Este problema se ve más comúnmente con
las bases de datos y los diseñadores de bases de datos siguen procesos elaborados para
evitar la creación de datos redundantes en sus tablas. Sin embargo, la redundancia de datos puede
ocurrir en cualquier programa, si no somos precavidos. Para ver el peligro, considere un programa
de registros médicos que almacene la edad y la fecha de nacimiento de cada uno de un conjunto de pacientes.
La fecha de nacimiento nos da información que la edad no. Por lo tanto, los dos elementos de
datos no son iguales, pero la edad no nos dice nada que no podamos saber a partir de la fecha de
nacimiento. ¿Y qué pasa si los dos valores no coinciden (lo que eventualmente sucederá, a
menos que la edad se actualice automáticamente)?
¿En qué valor confiamos? Me acuerdo de la famosa (aunque posiblemente apócrifa) proclamación
del califa Omar cuando ordenó quemar la Biblioteca de Alejandría. Proclamó que si los libros de
la biblioteca coincidían con el Corán, eran redundantes y no necesitaban ser preservados, pero
si no estaban de acuerdo con el Corán, eran perniciosos y debían ser destruidos.
Los datos redundantes son problemas a la espera de ocurrir. La única justificación sería
Resolver problemas con las clases 123
Machine Translated by Google
sería el rendimiento, si pensáramos que las actualizaciones de _grade serían raras y las
llamadas a letterGrade serían frecuentes, pero es difícil imaginar un aumento significativo en el
rendimiento general del programa.
Sin embargo, este método podría mejorarse. Al probar este método, noté un problema.
Aunque el método produce resultados correctos para valores válidos de _grade, el método falla
cuando _grade es un valor negativo. cuando el tiempo
se alcanza el bucle, el valor negativo de _grade hace que la prueba del bucle falle
inmediatamente; por lo tanto, la categoría permanece en cero y la declaración de devolución
intenta hacer referencia a GRADE_LETTER[­1]. Podríamos evitar este problema inicializando
la categoría en uno en lugar de cero, pero eso significaría que se asignaría una calificación
negativa "F" cuando en realidad no se le debería asignar ninguna cadena porque, como valor
de calificación no válido, no No encaja en ninguna categoría.
En su lugar, podríamos validar _grade antes de convertirlo en una calificación con letras.
Ya estamos validando valores de calificación en el método setGrade , por lo que en lugar de
agregar un nuevo código de validación al método letterGrade , deberíamos "factorizar" cuál sería
el código común en estos métodos para crear un tercer método.
(Quizá se pregunte cómo, si validamos las calificaciones a medida que se asignan, podríamos
tener una calificación no válida, pero recuerde que nuestro constructor predeterminado asigna –
1 para indicar que aún no se ha asignado ninguna calificación legítima). Este es otro tipo de
calificación. del método de soporte, que es la clase equivalente del concepto de función auxiliar
general presentado en capítulos anteriores. Implementemos este método y modifiquemos
nuestros otros métodos para usarlo:
bool StudentRecord::
isValidGrade(
int calificación) {
if ((calificación >= 0) && (calificación <= 100))
devuelve verdadero;
de lo
contrario devolverá falso;
}
void StudentRecord::setGrade(int nuevaCalificación) {
si (
isValidGrade(newGrade))
_grade = nuevaCalificación;
}
string registroestudiante::letraCalificación() {
si (
!isValidGrade(_grade)) devuelve "ERROR";
const int NUMBER_CATEGORIES = 11;
cadena constante GRADE_LETTER[] = {"F", "D", "D+", "C­", "C", "C+", "B­", "B", "B+", "A­", "A"};
constante int LOWEST_GRADE_SCORE[] = {0, 60, 67, 70, 73, 77, 80, 83, 87, 90, 93};
categoría int = 0;
mientras (categoría < NUMBER_CATEGORIES && LOWEST_GRADE_SCORE[categoría] <= _grado)
categoría++;
devolver GRADE_LETTER[categoría ­ 1];
}
El nuevo método de validación de calificaciones es de tipo bool
problema de sí o no, elegí el nombre isValidGrade
, y como se trata de un
. Esto brinda la lectura más parecida al
inglés para las llamadas a este método, como las del setGrade
y métodos letterGrade
validar como parámetro
124 Capítulo 5
. Además, tenga en cuenta que el método toma la calificación a
. Aunque letterGrade ya está validando el valor
Machine Translated by Google
en el miembro de datos _grade , setGrade valida el valor que podemos o no asignar al
miembro de datos. Entonces isValidGrade necesita tomar la calificación como parámetro
para que sea útil para los otros dos métodos.
Aunque el método isValidGrade está implementado, queda una decisión al respecto:
¿Qué nivel de acceso debemos asignarle? Es decir, ¿deberíamos colocarlo en la sección
pública de la clase o en la sección privada? A diferencia del get
y establecer métodos del framework de clases básico, que siempre van en la sección
pública, los métodos de soporte pueden ser públicos o privados dependiendo de su uso.
¿ Cuáles son los efectos de hacer público isValidGrade ? Lo más obvio es que el código
del cliente puede acceder al método. Debido a que tener más métodos públicos parece
hacer que una clase sea más útil, muchos programadores novatos hacen públicos todos
los métodos que el cliente podría utilizar. Sin embargo, esto ignora el otro efecto de la
designación de acceso público. Recuerde que la sección pública define la interfaz de
nuestra clase, y deberíamos ser reacios a cambiar el método una vez que nuestra clase
esté integrada en uno o más programas porque es probable que dicho cambio se produzca
en cascada y requiera cambios en todo el código del cliente.
Por lo tanto, colocar un método en la sección pública bloquea la interfaz del método y sus
efectos. En este caso, supongamos que algún código de cliente, basado en la formulación
original de isValidGrade, se basa en él como un verificador de rango de 0 a 100, pero luego,
las reglas para las calificaciones aceptables se vuelven más complicadas. El código del
cliente podría fallar. Para evitar eso, es posible que tengamos que crear un método de
validación de segundo grado dentro de la clase y dejar el primero solo.
Supongamos que esperamos que isValidGrade sea de utilidad limitada para el cliente
y hemos decidido no hacerlo público. Podríamos hacer que el método sea privado, pero esa
no es la única opción. Debido a que la función no hace referencia directamente a ningún
miembro de datos ni a ningún otro método de la clase, podríamos declarar la función
fuera de la clase por completo. Sin embargo, esto no sólo crea el mismo problema que
tiene el acceso público con respecto a la modificabilidad, sino que también reduce la
encapsulación porque ahora esta función, que es requerida por la clase, ya no es parte de
ella. También podríamos dejar el método en la clase pero hacerlo protegido en lugar de privado.
La diferencia se vería en cualquier subclase. Si isValidGrade está protegido, el método
puede ser llamado por métodos en subclases; Si isValidGrade es privado, solo lo pueden
utilizar otros métodos de la clase StudentRecord . Este es el mismo dilema entre lo público
y lo privado a menor escala. ¿Esperamos que los métodos de las subclases obtengan
mucho uso de nuestro método y esperamos que el efecto del método o su interfaz puedan
cambiar en el futuro? En muchos casos, lo más seguro es hacer que todos los métodos
auxiliares sean privados y hacer públicos sólo aquellos métodos de soporte que se
escribieron para beneficiar al cliente.
Clases con datos dinámicos
Una de las mejores razones para crear una clase es encapsular estructuras de datos
dinámicas. Como comentamos en el Capítulo 4, los programadores se enfrentan a una
verdadera tarea de realizar un seguimiento de las asignaciones dinámicas, asignaciones de
punteros y desasignaciones para evitar pérdidas de memoria, referencias colgantes y
referencias de memoria ilegales. Poner todas las referencias de puntero en una clase no
elimina el trabajo difícil, pero sí significa que una vez que lo hayamos hecho bien, podremos con seguridad
Resolver problemas con las clases 125
Machine Translated by Google
suelte ese código en otros proyectos. También significa que cualquier problema con nuestra
estructura de datos dinámica está aislado del código dentro de la clase misma, lo que
simplifica la depuración.
Creemos una clase con datos dinámicos para ver cómo funciona. Para nuestro
problema de muestra, usaremos una versión modificada del problema principal del Capítulo
4.
PROBLEMA: SEGUIMIENTO DE UN DESCONOCIDO
CANTIDAD DE EXPEDIENTES ESTUDIANTILES
En este problema, escribirás una clase con métodos para almacenar y manipular una colección de registros de
estudiantes. Un registro de estudiante contiene un número de estudiante y una calificación, ambos números enteros, y
una cadena para el nombre del estudiante. Se deben implementar las siguientes funciones:
addRecord Este método toma el número, el nombre y la calificación de un estudiante y agrega un nuevo registro
con estos datos a la colección.
recordWithNumber Esta función toma un número de estudiante y recupera el registro con ese número de
estudiante de la colección.
removeRecord Esta función toma un número de estudiante y elimina el registro con ese número de estudiante de
la colección.
La colección puede ser de cualquier tamaño. Se espera que la operación addRecord sea
Se llama con frecuencia, por lo que debe implementarse de manera eficiente.
Las principales diferencias entre esta descripción y la versión original son que hemos
agregado una nueva operación, registrarConNúmero, y también que ninguna de las
operaciones hace referencia a un parámetro de puntero. Este es el beneficio clave de
utilizar una clase para encapsular una lista vinculada. El cliente puede ser consciente de
que la clase implementa la recopilación de registros de los estudiantes como una lista
vinculada e incluso puede estar contando con eso (recuerde nuestra discusión anterior
sobre las limitaciones de ocultar información). El código del cliente, sin embargo, no tendrá
interacción directa con la lista enlazada ni con ningún puntero de la clase.
Debido a que este problema almacena la misma información por estudiante que el
problema anterior, aquí tenemos la oportunidad de reutilizarla en clase. En nuestro tipo de
nodo de lista vinculada, en lugar de campos separados para cada uno de los tres datos de
los estudiantes, tendremos un objeto StudentRecord . Usar un objeto de una clase como
tipo de datos en una segunda clase se conoce como composición.
Ahora tenemos suficiente información para hacer una declaración de clase preliminar:
clase colección de estudiantes {
privado:
estructura estudianteNodo {
studentRecord datos del estudiante;
nodoestudiante * próximo;
};
126 Capítulo 5
Machine Translated by Google
público:
colecciónestudiante();
void addRecord(studentRecord nuevoEstudiante);
estudianteRecord recordWithNumber(int idNum);
void removeRecord(int idNum);
privado:
typedef estudianteNodo * lista de estudiantes;
studentList _listHead;
};
Anteriormente dije que los programadores tienden a comenzar las clases con declaraciones públicas,
pero aquí tenemos que hacer una excepción. Comenzamos con una declaración privada de la estructura
del nodo, StudentNode
, que usaremos para crear nuestra lista enlazada. Esta declaración debe
aparecer antes de la sección pública porque varias de nuestras funciones miembro públicas hacen
referencia a este tipo. A diferencia de nuestro tipo de nodo en el Capítulo 4, este nodo no tiene campos
individuales para los datos de carga útil, sino que incluye un miembro del tipo de estructura StudentRecord
. Las funciones de miembro público
siga directamente de la descripción del problema; además, como siempre, tenemos un constructor.
En la segunda sección privada, declaramos un typedef
para un puntero a nuestro tipo de nodo
para mayor claridad, tal como lo hicimos en el Capítulo 4. Luego declaramos nuestro puntero de
encabezado de lista, hábilmente llamado _listHead
.
Esta clase declara dos tipos privados. Las clases pueden declarar tipos, así como funciones
miembro y miembros de datos. Al igual que con otros miembros, los tipos que aparecen en la clase
se pueden declarar con cualquier especificador de acceso. Sin embargo, al igual que con los
miembros de datos, debe considerar que las definiciones de tipo son privadas de forma
predeterminada y solo hacerlas menos restrictivas si tiene una razón clara para hacerlo. Las
declaraciones de tipos suelen ser el núcleo de cómo opera una clase detrás de escena y, como
tales, son fundamentales para ocultar información. Además, en la mayoría de los casos, el código
del cliente no sirve para los tipos que declararás en tu clase. Se produce una excepción cuando
un tipo definido en la clase se utiliza como tipo de retorno de un método público o como tipo de
parámetro de un método público. En este caso, el tipo debe ser público o el código del cliente no
podrá utilizar el método público.
La clase StudentCollection supone que el tipo de estructura StudentRecord se declarará por
separado, pero también podríamos convertirlo en parte de la clase. Si lo hiciéramos, tendríamos
que declararlo en la sección pública .
Ahora estamos listos para implementar nuestros métodos de clase, comenzando con el
constructor. A diferencia de nuestro ejemplo anterior, aquí solo tenemos el constructor
predeterminado, no un constructor que toma un parámetro para inicializar nuestro miembro de datos.
El objetivo de nuestra clase es ocultar los detalles de nuestra lista enlazada, por lo que no queremos
que el cliente ni siquiera piense en nuestro _listHead, y mucho menos lo manipule.
Todo lo que necesitamos hacer en nuestro constructor predeterminado es establecer el puntero principal en NULL:
Colección de estudiantes::Colección de estudiantes() {
_listHead = NULL;
}
Resolver problemas con las clases 127
Machine Translated by Google
Agregar un nodo
Pasamos a addRecord. Debido a que nada en la descripción del problema requiere
que mantengamos los registros de los estudiantes en un orden particular, podemos
adaptar directamente la función addRecord del Capítulo 4 para usarla aquí.
void StudentCollection::addRecord( studentRecord nuevoEstudiante) {
studentNode * newNode = nuevo StudentNode;
nuevoNodo­>studentData = nuevoEstudiante;
newNode­>siguiente = _listHead;
_listHead = nuevoNodo;
}
Sólo hay dos diferencias entre este código y nuestra función de modelo. Aquí, solo
necesitamos un parámetro en nuestra lista de parámetros , que es el objeto StudentRecord
que vamos a agregar a nuestra colección. Esto encapsula todos los datos de un estudiante,
lo que reduce la cantidad de parámetros necesarios. Tampoco necesitamos pasar un
puntero de encabezado de lista porque ya está almacenado en nuestra clase como _listHead
y se hace referencia directamente a él cuando es necesario. Al igual que con la función
addRecord del Capítulo 4, creamos un nuevo nodo , copiamos los datos del nuevo
estudiante en el nuevo nodo , apuntamos el siguiente campo del nuevo nodo al primer
nodo anterior de la lista
y finalmente apuntamos _listHead a el nuevo nodo .
Normalmente recomiendo dibujar un diagrama para todas las manipulaciones del puntero,
pero como esta es la misma manipulación que ya estábamos haciendo, podemos hacer
referencia a nuestro diagrama dibujado anteriormente.
Ahora podemos centrar nuestra atención en la última de las tres funciones miembro,
registroConNúmero. Ese nombre es un poco complicado y algunos programadores
podrían haber elegido retrieveRecord o algo similar. Sin embargo, siguiendo las reglas de
nomenclatura establecidas anteriormente, decidí usar un sustantivo porque este método
devuelve un valor. Este método será similar a AverageRecord en que necesita recorrer la
lista; la diferencia en este caso es que podemos detenernos una vez que encontremos el
registro del estudiante coincidente.
StudentRecord StudentCollection::recordWithNumber(int idNum) {
studentNode * loopPtr = _listHead;
while (loopPtr­>studentData.studentID() != idNum) {
loopPtr = loopPtr­>siguiente;
}
bucle de retornoPtr­>studentData;
}
En esta función, inicializamos nuestro puntero de bucle al encabezado de la lista
y recorrer la lista siempre y cuando no hayamos visto el número de ID deseado .
Finalmente, al llegar al nodo deseado, devolvemos el registro coincidente completo como el
valor de la función . Este código se ve bien, pero como siempre, debemos considerar
posibles casos especiales. El caso que siempre consideramos cuando tratamos con listas
enlazadas es un puntero de cabecera inicialmente NULL . Aquí, eso definitivamente causa
un problema, ya que no estamos comprobando eso y el código explotará cuando
128 Capítulo 5
Machine Translated by Google
intente eliminar la referencia a loopPtr al ingresar por primera vez al ciclo. Sin embargo, en términos
más generales, debemos considerar la posibilidad de que el número de identificación proporcionado
por el código del cliente en realidad no coincida con ninguno de los registros de nuestra colección.
En ese caso, incluso si _listHead no es NULL, loopPtr eventualmente se convertirá en NULL cuando
lleguemos al final de la lista.
Entonces, el problema general es que debemos detener el ciclo si loopPtr se vuelve NULL.
Eso no es difícil, pero entonces, ¿qué retornamos en esta situación? Ciertamente no podemos
devolver loopPtr­>studentData porque loopPtr será NULL. En su lugar, podemos crear y devolver un
StudentRecord ficticio con valores no válidos obvios en su interior.
StudentRecord StudentCollection::recordWithNumber(int idNum) {
StudentNode * loopPtr = _listHead;
while ( loopPtr != NULL && loopPtr­>studentData.studentID() != idNum) {
loopPtr = loopPtr­>siguiente;
}
si ( buclePtr == NULL) {
studentRecord dummyRecord(­1, ­1, "");
devolver registro ficticio;
} demás {
retorno loopPtr­>studentData;
}
}
En esta versión del método, si nuestro puntero de bucle es NULL cuando el bucle termina
,
creamos un registro ficticio con una cadena nula para un nombre y valores –1 para la calificación y el
ID del estudiante
y lo devolvemos. De vuelta en el bucle, estamos comprobando esa condición
NULL loopPtr , lo que nuevamente puede ocurrir porque no hay una lista que recorrer o porque la
hemos recorrido sin éxito. Un punto clave aquí es que la expresión condicional del bucle
es una
expresión compuesta con loopPtr != NULL primero. Esto es absolutamente necesario. C++ utiliza
un mecanismo para evaluar expresiones booleanas compuestas conocido como evaluación de
cortocircuito; En pocas palabras, no evalúa la mitad derecha de una expresión booleana
compuesta cuando ya se conoce el valor general de la expresión.
Debido a que && representa un booleano lógico y, si el lado izquierdo de una expresión && se
evalúa como falso, la expresión general también debe ser falsa, independientemente de la evaluación
del lado derecho. Para mayor eficiencia, C++ aprovecha este hecho, omitiendo la evaluación del lado
derecho de una expresión && cuando el lado izquierdo es falso (para un ||, lógico o, el lado derecho
no se evalúa cuando el lado izquierdo es verdadero). , por la misma razón). Por lo tanto, cuando
loopPtr es NULL, la expresión loopPtr != NULL se evalúa como falsa y el lado derecho de &&
nunca se evalúa. Sin la evaluación de cortocircuito, se evaluaría el lado derecho y estaríamos
desreferenciando un puntero NULL , colapsando el programa.
La implementación evita el posible fallo de la primera versión, pero
debemos ser conscientes de que deposita mucha confianza en el código del cliente.
Es decir, la función que llama a este método es responsable de verificar el StudentRecord que
regresa y asegurarse de que no sea el registro ficticio antes de continuar con el procesamiento. Si
eres como yo, esto te incomoda un poco.
Resolver problemas con las clases 129
Machine Translated by Google
EXCEPCIONES
Hay otra opción. C++, así como muchos otros lenguajes de programación, ofrece un mecanismo
conocido como excepción, que permite que una función, ya sea un método o una función
general, indique inequívocamente un estado de error a la persona que llama. Está diseñado
para el tipo de situación que tenemos en este método, donde no hay una buena respuesta
sobre qué devolver cuando la entrada es mala. La sintaxis de excepciones es más de lo que
podemos abordar aquí y, desafortunadamente, la forma en que se implementan las
excepciones en C++ no resuelve el problema de confianza explicado en el párrafo anterior.
Reorganizar la lista
El método removeRecord es similar a recordWithNumber en que debemos recorrer la lista para
encontrar el nodo que vamos a eliminar de la lista, pero hay mucho más. Eliminar un nodo de
una lista requiere cuidado para mantener vinculados los nodos restantes de la lista. La forma
más sencilla de coser el agujero que habremos creado es unir el nodo que venía antes del nodo
eliminado con el nodo que vino después. No necesitamos un esquema de función porque ya
tenemos un prototipo de función en la declaración de clase, por lo que solo necesitamos un
caso de prueba:
Colección de estudiantes;
StudentRecord stu3(84, 1152, "Sue");
StudentRecord stu2(75, 4875, "Ed");
StudentRecord stu1(98, 2938, "Todd");
s.addRecord(stu3);
s.addRecord(stu2);
s.addRecord(stu1);
s.removeRecord(4875);
Aquí hemos creado un objeto StudentCollection , así como tres StudentRecord.
objetos, cada uno de los cuales se agrega a nuestra colección. Tenga en cuenta que podríamos
reutilizar el mismo registro, cambiando los valores entre las llamadas a addRecord, pero
hacerlo de esta manera simplifica nuestro código de prueba. La última línea de la prueba es la
llamada a removeRecord
, que en este caso eliminará el segundo registro, el del estudiante
llamado "Ed". Utilizando el mismo estilo de diagramas de puntero utilizados en el Capítulo 4,
la Figura 5­1 muestra el estado de la memoria antes y después.
después de esta llamada.
En la Figura 5­1 (a), vemos la lista vinculada que fue creada por nuestro código de prueba.
Tenga en cuenta que debido a que usamos una clase, las convenciones de nuestros
diagramas están un poco sesgadas. En el lado izquierdo de nuestra división de pila/montón,
tenemos _listHead, que es el miembro de datos privados dentro de nuestros objetos
StudentCollection , y idNum, que es el parámetro para eliminarRecord. En el lado derecho está
la lista en sí, en el montón. Recuerde que addRecord coloca el nuevo registro al principio de la
lista, por lo que los registros están en el orden opuesto al que se agregaron en el código de
prueba. El nodo del medio, "Ed", tiene el número de identificación que coincide con el parámetro,
4875, por lo que se eliminará de la lista. La figura 5­1 (b) muestra la
130 Capítulo 5
Machine Translated by Google
resultado de la convocatoria. El primer nodo de la lista, el de "Todd", apunta ahora al
que era el tercer nodo de la lista, el de "Sue". El nodo "Ed" ya no está vinculado a la
lista más grande y se ha eliminado.
listaCabeza
número de identificación
"Todd"
98 2938
4875
75
4875 "Ed"
84
1152 "Demandar"
NULO
(a)
listaCabeza
número de identificación
98
2938
"Todd"
4875
75 4875 "Ed"
84 1152 "Demandar" NULO
(b)
Figura 5­1: Estados "antes" y "después" del caso de prueba removeRecord
Ahora que sabemos qué efecto debería tener el código, podemos empezar a
escribirlo. Como sabemos que necesitamos encontrar el nodo con el número de
ID coincidente, podríamos comenzar con el bucle while de recordWithNumber.
Cuando se complete ese ciclo, tendremos un puntero al nodo que necesitábamos
eliminar. Desafortunadamente, necesitamos más que eso para completar la eliminación.
Mire la Figura 5­1; Para cerrar el agujero y mantener la lista enlazada, necesitamos
cambiar el siguiente campo del nodo "Todd" . Si todo lo que tenemos es un puntero al
nodo "Ed" , no hay forma de hacer referencia al nodo "Todd" porque cada nodo en la
lista vinculada hace referencia a su sucesor, no a su predecesor. (Debido a situaciones
como esta, algunas listas enlazadas se enlazan en ambas direcciones; se conocen
como listas doblemente enlazadas, pero rara vez son necesarias). Entonces, además
de un puntero al nodo que se eliminará (que se llamará loopPtr si adaptar el código
de la función anterior), necesitamos un puntero al nodo inmediatamente anterior:
llamemos a este puntero arrastrado. La Figura 5­2 muestra este concepto aplicado a
nuestro caso de muestra.
Resolver problemas con las clases
131
Machine Translated by Google
listaCabeza
número de identificación
98 2938
"Todd"
4875
75 4875 "Ed"
siguiendo
buclePtr
84
1152 "Demandar" NULO
Figura 5­2: Los punteros necesarios para eliminar el nodo especificado por idNum
Con loopPtr haciendo referencia al nodo que estamos eliminando y siguiendo la referencia
Al seleccionar el nodo anterior, podemos eliminar el nodo deseado y mantener la lista junta.
void StudentCollection::removeRecord(int idNum) {
StudentNode * loopPtr = _listHead;
studentNode * final = NULL;
while (loopPtr != NULL && loopPtr­>studentData.studentID() != idNum) {
arrastre = loopPtr;
loopPtr = loopPtr­>siguiente;
}
if (loopPtr == NULL) retorno;
trailing­>siguiente = loopPtr­>siguiente;
eliminar loopPtr;
}
La primera parte de esta función es como la de recordWithNumber, excepto que
Declaramos nuestro puntero final
loopPtr al puntero
y, dentro del bucle, asignamos el antiguo valor de
antes de avanzar loopPtr al siguiente nodo. De esta manera, el
seguimiento siempre es un nodo detrás de loopPtr. Debido a nuestro trabajo con la función
anterior, ya estamos en guardia contra un caso especial. Por lo tanto, cuando finaliza el ciclo,
verificamos si loopPtr es NULL. Si es así, significa que nunca encontramos un nodo con el
número de identificación deseado e inmediatamente devolvemos
. Llamo a una declaración
de devolución que aparece en medio de una función "salir de Dodge". Algunos programadores
se oponen a esto porque las funciones con múltiples puntos de salida pueden ser más difíciles
de leer. La alternativa en este caso, sin embargo, es otro nivel de anidamiento para las
declaraciones if que siguen, y prefiero simplemente salir de Dodge.
Habiendo determinado que hay un nodo que eliminar, es hora de eliminarlo.
En nuestro diagrama, vemos que necesitamos configurar el siguiente campo del nodo final para
que apunte al nodo actualmente apuntado por el siguiente campo del nodo loopPtr
Luego podemos eliminar de forma segura el nodo al que apunta loopPtr
.
.
Eso funciona para nuestro caso de prueba, pero como siempre, debemos verificar posibles
casos especiales. Ya hemos manejado la posibilidad de que idNum no aparezca en ninguno de
los registros de nuestra colección, pero ¿existe otro posible problema?
Mirando nuestro caso de prueba, ¿cambiaría algo si intentáramos eliminar el primer o tercer
nodo en lugar del nodo del medio? Espectáculos de prueba y control manual.
132 Capítulo 5
Machine Translated by Google
no hay problemas con el tercer (último) nodo. El primer nodo, sin embargo, causa problemas
porque en esta situación no hay ningún nodo anterior al que señalar .
En cambio, debemos manipular el propio _listHead . La Figura 5­3 muestra la situación
después de que finaliza el ciclo while .
listaCabeza
número de identificación
siguiendo
98
2938
"Todd"
2938
NULO
75
4875 "Ed"
buclePtr
84 1152 "Demandar" NULO
Figura 5­3: La situación antes de eliminar el primer nodo de la lista
En esta situación, necesitamos redireccionar _listHead al segundo nodo anterior de la lista,
el de "Ed". Reescribamos nuestro método para manejar el caso especial.
void StudentCollection::removeRecord(int idNum) {
StudentNode * loopPtr = _listHead;
StudentNode * final = NULL;
while (loopPtr != NULL && loopPtr­>studentData.studentID() != idNum) {
final = loopPtr;
loopPtr = loopPtr­>siguiente;
}
si (loopPtr == NULL) regresa;
if (final == NULL) {
_listHead = _listHead­>siguiente;
} demás {
final­>siguiente = loopPtr­>siguiente;
}
eliminar loopPtr;
}
Como puede ver, tanto la prueba condicional
especial
como el código para manejar el caso
son sencillos porque hemos analizado cuidadosamente la situación antes de
escribir el código.
Incinerador de basuras
Con los tres métodos especificados por el problema implementados, podríamos pensar que
nuestra clase StudentCollection está completa. Sin embargo, tal como está, tiene serios
problemas. La primera es que la clase carece de destructor. Este es un método especial que
se llama cuando el objeto sale del alcance (cuando se completa la función que declaró el
objeto). Cuando una clase no tiene datos dinámicos, normalmente no necesita un destructor,
pero si tiene el primero, definitivamente necesitará el segundo. Recuerda que tenemos que
borrar todo lo que tenemos asignado.
Resolver problemas con las clases 133
Machine Translated by Google
con nuevo para evitar pérdidas de memoria. Si un objeto de nuestra clase StudentCollection tiene tres
nodos, es necesario desasignar cada uno de esos nodos. Afortunadamente, esto no es demasiado
difícil. Solo necesitamos recorrer nuestra lista enlazada, eliminando a medida que avanzamos.
Sin embargo, en lugar de hacer esto directamente, escribamos un método auxiliar que elimine todos
los nodos en una lista de estudiantes. En la sección privada de nuestra clase, agregamos la
declaración:
void eliminarLista(listadeestudiantes &listPtr);
El código del método en sí sería:
void StudentCollection::deleteList(studentList &listPtr) {
mientras (listaPtr! = NULL) {
nodoestudiante * temperatura = listaPtr;
listPtr = listPtr­>siguiente;
eliminar temperatura;
}
}
El recorrido copia el puntero al nodo actual en una variable temporal
nodo actual
y luego elimina el nodo al que apunta la variable temporal
, avanza el puntero del
. Con este código
implementado, podemos codificar el destructor de manera muy simple. Primero, agregamos el
destructor a la sección pública de nuestra declaración de clase:
~ColecciónEstudiante();
Tenga en cuenta que, al igual que un constructor, el destructor se especifica usando el nombre
de la clase y no hay ningún tipo de devolución. La tilde antes del nombre distingue el destructor
de los constructores. La implementación es la siguiente:
ColecciónEstudiantes::~ColecciónEstudiantes() {
eliminarLista(_listHead);
}
El código de estos métodos es sencillo, pero es importante probar el destructor. Aunque un
destructor mal escrito podría bloquear su programa, muchos problemas con los destructores no
provocan bloqueos, sólo pérdidas de memoria o, peor aún, un comportamiento inexplicable del
programa. Por lo tanto, es importante probar el destructor utilizando el depurador de su entorno de
desarrollo para que pueda ver que el destructor realmente está llamando a eliminar en cada nodo.
Copia profunda
Otro problema grave persiste. En el Capítulo 4, analizamos brevemente el concepto de
entrecruzamiento, donde dos variables indicadoras tenían el mismo valor.
Aunque las variables en sí eran distintas, apuntaban a la misma estructura de datos; por lo tanto,
modificar la estructura de una variable modificó
134 Capítulo 5
Machine Translated by Google
los dos. Este problema puede ocurrir fácilmente con clases que incorporan
memoria asignada dinámicamente. Para ver por qué esto puede ser un problema,
considere la siguiente secuencia de código elemental de C++:
entero x = 10;
int y = 15;
x = y;
x = 5;
Supongamos que le pregunto qué efecto tuvo la última afirmación
sobre el
valor de la variable y. Probablemente te preguntarás si me había equivocado.
La última afirmación no tendría ningún efecto en y , sólo en x. Pero ahora considere
esto:
Colección de estudiantes s1;
Colección de estudiantes s2;
StudentRecord r1(85, 99837, "John");
s2.addRecord(r1);
StudentRecord r2(77, 4765, "Elsie");
s2.addRecord(r2);
s1 = s2;
s2.removeRecord(99837);
Supongamos que le pregunto qué efecto tuvo la última afirmación
en s1.
Desafortunadamente, tiene un efecto. Aunque s1 y s2 son dos objetos diferentes,
ya no son objetos completamente separados. Por defecto, cuando un objeto se
asigna a otro, como aquí asignamos s2 a s1 , C++ realiza lo que se conoce
como una copia superficial. En una copia superficial, cada miembro de datos de un
objeto se asigna directamente al otro. Entonces, si _listHead, nuestro único miembro
de datos, fuera público, s1 = s2 sería lo mismo que s1._listHead = s2._listHead.
Esto deja al miembro de datos _listHead de ambos objetos apuntando al mismo
lugar en la memoria: el nodo de "Elsie", que apunta al otro nodo, el de "John". Por
lo tanto, cuando se elimina el nodo de "John" , aparentemente se elimina de dos
listas porque en realidad solo hay una lista. La Figura 5­4 muestra la situación al
final del código.
listaCabeza
77
(t1)
listaCabeza
4765 "Elsie"
NULO
85 99837 "Juan" NULO
(t2)
Figura 5­4: La copia superficial produce entrecruzamiento; Al eliminar el nodo "John" de una lista, se elimina
de ambas.
Resolver problemas con las clases 135
Machine Translated by Google
Sin embargo, por muy peculiar que sea, en realidad podría haber sido mucho peor.
¿Qué pasaría si la última línea del código hubiera eliminado el primer registro, el "Elsie"?
¿nodo? En ese caso, el _listHead dentro de s2 se habría actualizado para que apunte a
"John" y el nodo "Elsie" se habría eliminado. Sin embargo, el _listHead dentro de s1 aún
apuntaría al nodo "Elsie" eliminado , una referencia peligrosa y colgante, como se
muestra en la Figura 5­5.
listaCabeza
77
4765 "Elsie"
(t1)
listaCabeza
85
99837 "Juan" NULO
(t2)
Figura 5­5: Eliminación de s2 que provoca una referencia colgante en s1
La solución a este problema es una copia profunda, lo que significa que no sólo
copiamos el puntero a la estructura sino que hacemos copias de todo lo que hay en la
estructura. En este caso, significa copiar todos los nodos de la lista para hacer una copia
de lista verdadera. Como antes, comencemos creando un método auxiliar privado, en este
caso, uno que copie una lista de estudiantes. La declaración en la sección privada de la
clase tiene este aspecto:
lista de estudiantes lista copiada (const lista de estudiantes original);
Como antes, elegí un sustantivo para un método que devuelve un valor. La
implementación del método es la siguiente:
ColecciónEstudiantes::ListaEstudiantesColecciónEstudiantes::Listacopiada(constListaEstudiantes original) {
si (original == NULL) {
devolver NULO;
}
ListaEstudiantes nuevaLista = nuevo NodoEstudiante;
newList­>studentData = original­>studentData;
studentNode * oldLoopPtr = original­>siguiente;
studentNode * newLoopPtr = nuevaLista;
mientras (oldLoopPtr! = NULL) {
newLoopPtr­>next = nuevo estudianteNodo;
nuevoLoopPtr = nuevoLoopPtr­>siguiente;
newLoopPtr­>studentData = oldLoopPtr­>studentData;
oldLoopPtr = oldLoopPtr­>siguiente;
}
newLoopPtr­>siguiente = NULL;
devuelve nuevaLista;
}
136 Capítulo 5
Machine Translated by Google
Están sucediendo muchas cosas en este método, así que vayamos paso a paso. En una
nota de sintaxis, al especificar el tipo de retorno en la implementación, debemos anteponer el
nombre de la clase
. De lo contrario, el compilador no sabrá de qué tipo estamos hablando.
(Dentro del método, eso no es necesario porque el compilador ya sabe de qué clase forma parte
el método, ¡un poco confuso!) Comprobamos si la lista entrante está vacía. Si es así, salimos de
Dodge
. Una vez que sabemos que hay una lista para copiar, copiamos los datos del primer
nodo antes del ciclo
porque para ese nodo tenemos que modificar el puntero principal de
nuestra nueva lista.
Luego configuramos dos punteros para rastrear las dos listas. oldLoopPtr
atraviesa
la lista entrante ; siempre apuntará al nodo que estamos a punto de copiar. newLoopPtr
atraviesa la lista nueva copiada y siempre apunta al último nodo que creamos, que es el nodo
anterior a donde agregaremos el siguiente nodo. Al igual que en el método removeRecord ,
aquí necesitamos una especie de puntero final. Dentro del bucle
, creamos un nuevo nodo,
avanzamos newLoopPtr para apuntar a él, copiamos los datos del nodo antiguo al nuevo y
avanzamos oldLoopPtr. Después del ciclo, terminamos la nueva lista asignando NULL al siguiente
campo del último nodo
y devolvemos el puntero a la nueva lista
.
Entonces, ¿cómo resuelve este método auxiliar el problema que vimos anteriormente?
Por sí solo, no es así. Pero con este código implementado, ahora podemos sobrecargar el
operador de asignación. La sobrecarga de operadores es una característica de C++ que nos
permite cambiar lo que hacen los operadores integrados con ciertos tipos. En este caso, queremos
sobrecargar el operador de asignación (=), de modo que en lugar de la copia superficial
predeterminada, llame a nuestro método copyList para realizar una copia profunda. En la sección
pública de nuestra clase, agregamos:
StudentCollection&
operator=(
const StudentCollection &
rhs);
El operador que estamos sobrecargando se especifica nombrando el método usando la
palabra clave operador seguido del operador que queremos sobrecargar
El nombre que he elegido para el parámetro (rhs
.
) es una opción común para las sobrecargas
de operadores porque representa el lado derecho. Esto ayuda al programador a mantener las
cosas en orden. Entonces, en la declaración de asignación que inició esta discusión, s2 = s1,
el objeto s1 sería el lado derecho de la operación de asignación y s2 sería el lado izquierdo.
Hacemos referencia al lado derecho a través del parámetro y hacemos referencia al lado
izquierdo accediendo directamente a los miembros de la clase, como lo haríamos con cualquier
otro método de la clase. Entonces nuestra tarea en este caso es crear una lista apuntada por
_listHead
esa es una copia de la lista señalada por _listHead de rhs. Esto tendrá el efecto en la llamada
s2 = s1 de hacer de s2 una copia fiel de s1.
El tipo de parámetro es siempre una referencia constante a la clase en cuestión
de retorno es siempre una referencia a la clase
; el tipo
. Verás por qué el parámetro es una referencia
en breve. Quizás se pregunte por qué el método devuelve algo, ya que estamos manipulando el
miembro de datos directamente en el método. Esto se debe a que C++ permite asignaciones
encadenadas, como s3 = s2 = s1, en las que el valor de retorno de una asignación se convierte
en el parámetro de la siguiente.
Resolver problemas con las clases 137
Machine Translated by Google
Una vez que se entiende toda la sintaxis, el código para el operador de asignación es
bastante directo:
Colección de estudiantes& Colección de estudiantes::operador=(const Colección de estudiantes &rhs) {
si (esto!= &rhs) {
deleteList(_listHead);
_listHead = listacopiada(rhs._listHead);
}
devolver *esto;
}
Para evitar una pérdida de memoria, primero debemos eliminar todos los nodos de la lista
del lado izquierdo
. (Es para este propósito que escribimos eliminarList como método auxiliar
en lugar de incluir su código directamente en el destructor). Con la lista anterior de la izquierda
eliminada, copiamos la lista de la derecha usando nuestro otro método auxiliar
. Sin
embargo, antes de realizar cualquiera de estos pasos, comprobamos que el objeto del lado
derecho es diferente del objeto del lado izquierdo (es decir, no es algo así como s1 = s1)
comprobando si los punteros son diferentes .
. Si los punteros son idénticos, no es necesario
hacer nada, pero no es sólo una cuestión de eficiencia. Si realizamos la copia profunda en
punteros idénticos, cuando eliminemos los nodos actualmente en la lista del lado izquierdo,
también estaríamos eliminando los nodos en la lista del lado derecho.
Finalmente, devolvemos un puntero al objeto
del lado izquierdo; Esto sucede ya sea que
hayamos copiado algo o no, porque aunque una declaración como s2 = s1 = s1 es una locura,
aún así nos gustaría que funcionara si alguien la intenta.
Mientras tengamos nuestro método auxiliar de copia de listas, también deberíamos crear
un constructor de copias. Este es un constructor que toma otro objeto de la misma clase como
objeto. El constructor de copia se puede invocar explícitamente siempre que necesitemos crear
un duplicado de una colección de estudiantes existente, pero los constructores de copia
también se invocan implícitamente cada vez que un objeto de esa clase se pasa como parámetro
de valor a una función. Debido a esto, debería considerar pasar parámetros de objeto como
referencias constantes en lugar de parámetros de valor, a menos que la función que recibe el
objeto necesite modificar la copia. De lo contrario, su código podría estar realizando mucho
trabajo innecesariamente. Consideremos, por ejemplo, una colección estudiantil de 10.000
registros. La colección podría pasarse como referencia, un único puntero. Alternativamente,
podría invocar al constructor de copias para un recorrido largo y 10 000 asignaciones de
memoria, y esta copia local luego invocaría al destructor al final de la función con otro recorrido
largo y 10 000 desasignaciones. Esta es la razón por la que el parámetro del lado derecho
de la sobrecarga del operador de asignación utiliza un parámetro de referencia constante .
Para agregar el constructor de copia a nuestra clase, primero agregamos su declaración
a nuestra declaración de clase en la sección pública.
StudentCollection(const StudentCollection &original);
Como ocurre con todos los constructores, no hay tipo de retorno y, al igual que con el
operador de asignación sobrecargado, el parámetro es una referencia constante a nuestra clase.
La implementación es fácil porque ya tenemos el método auxiliar.
138 Capítulo 5
Machine Translated by Google
Colección de estudiantes::Colección de estudiantes(const Colección de estudiantes y original) {
_listHead = listacopiada(original._listHead);
}
Ahora podemos hacer una declaración como esta:
Colección de estudiantes s2(s1);
Esta declaración tiene el efecto de declarar s2 y copiar los nodos de s1 en él.
El panorama general de las clases con memoria dinámica
Realmente hemos hecho mucho con esta clase desde que completamos los métodos
especificados en la descripción del problema, así que tomemos un momento para revisar.
Así es como se ve nuestra declaración de clase ahora.
clase colección de estudiantes {
privado:
estructura nodoestudiante {
StudentRecord StudentData;
StudentNode * siguiente;
};
público:
colecciónestudiante();
~ColecciónEstudiante();
StudentCollection(const StudentCollection &original);
Colección de estudiantes& operador=(const Colección de estudiantes &rhs);
void addRecord(studentRecord nuevoEstudiante);
estudianteRecord recordWithNumber(int idNum);
void removeRecord(int idNum);
privado:
typedef nodoestudiante * lista de estudiantes;
lista de estudiantes _listHead;
void eliminarLista(listadeestudiantes &listPtr);
lista de estudiantes lista copiada (const lista de estudiantes original);
};
La lección aquí es que se requieren nuevas piezas al crear una clase con memoria
dinámica. Además de las características de nuestro marco de clases básico (datos privados,
un constructor predeterminado y métodos para enviar datos dentro y fuera del objeto), tenemos
que agregar métodos adicionales para manejar la asignación y limpieza de la memoria
dinámica. Como mínimo, deberíamos agregar un constructor de copia y un destructor y también
sobrecargar el operador de asignación si existe alguna posibilidad de que alguien lo use. La
creación de estos métodos adicionales a menudo se puede facilitar creando métodos
auxiliares para copiar o eliminar la estructura de datos dinámica subyacente.
Esto puede parecer mucho trabajo, y puede serlo, pero es importante tener en cuenta que
todo lo que agregas a la clase es algo con lo que debes ocuparte de todos modos. En otras
palabras, si no tuviéramos una clase para nuestra colección de listas enlazadas
Resolver problemas con las clases 139
Machine Translated by Google
de los registros de los estudiantes, todavía somos responsables de eliminar los nodos de
la lista cuando terminemos con ellos. Aún tendríamos que tener cuidado con los enlaces
cruzados, aún tendríamos que recorrer una lista y copiar nodo por nodo si quisiéramos una
copia fiel de la lista original, y así sucesivamente. Poner todo en la estructura de clases
es sólo un poco más de trabajo por adelantado, y una vez que todo funciona, el código del
cliente puede ignorar todos los detalles de asignación de memoria. Al final, la encapsulación
y la ocultación de información hacen que sea mucho más fácil trabajar con estructuras de
datos dinámicas.
Errores a evitar
Hemos hablado sobre cómo crear una buena clase en C++, así que terminemos la
discusión hablando de un par de errores comunes que debes evitar.
La clase falsa
Como mencioné al principio de este capítulo, creo que C++, como lenguaje híbrido que
incluye tanto el paradigma procedimental como el orientado a objetos, es un gran lenguaje
para aprender programación orientada a objetos porque la creación de una clase es
siempre un elección positiva por parte del programador.
En un lenguaje como Java, la pregunta nunca es: "¿Debería crear una clase?". sino más
bien: "¿Cómo voy a poner esto en una clase?" El requisito de poner todo en una
estructura de clases da como resultado lo que yo llamo una clase falsa, una clase sin un
diseño coherente que sea correcto sintácticamente pero que no tenga un significado real.
La palabra clase , tal como se usa en programación, se deriva del sentido de la palabra
inglesa que significa un grupo de cosas con atributos comunes, y una buena clase de C++
cumple con esta definición.
Las clases falsas pueden ocurrir por varias razones. Un tipo ocurre porque el
programador realmente quiere usar variables globales, no por ninguna razón defendible
(tales razones son raras, aunque existen), sino por pereza, simplemente para evitar pasar
parámetros de una función a otra. Si bien el programador sabe que el uso generalizado de
variables globales se considera un estilo terrible, cree que ha encontrado la laguna jurídica.
Todas o la mayoría de las funciones del programa se agrupan en una clase, y las variables
que habrían sido globales ahora son miembros de datos de la clase. La función principal del
programa simplemente crea un objeto de la clase falsa e invoca algún método "maestro" en
la clase. Técnicamente, el programa no utiliza variables globales, pero la clase falsa significa
que el programa tiene los mismos defectos que uno que sí lo hace.
Otro tipo de clase falsa ocurre porque el programador simplemente asume que la
programación orientada a objetos siempre es “mejor” y la obliga a situaciones en las que
no se aplica. En estos casos, el programador suele crear una clase que encapsula una
funcionalidad muy específica que sólo tiene sentido en el contexto del programa original
para el que está escrita. Hay dos formas de comprobar si estás escribiendo este tipo de
clase falsa. La primera es preguntar: “¿Puedo darle a la clase un nombre específico y
razonablemente corto?” Si se encuentra con un nombre como
PayrollReportManagerAndPrintSpooler, es posible que tenga un problema.
140 Capítulo 5
Machine Translated by Google
La otra prueba pregunta: “Si tuviera que escribir otro programa con funcionalidad similar,
¿puedo imaginar cómo se podría reutilizar la clase, con sólo pequeñas modificaciones?
¿O habría que reescribirlo dramáticamente?
Incluso en C++, un cierto número de clases falsas es inevitable, por ejemplo, porque
tenemos que encapsular datos para usarlos en clases de colección. Sin embargo, estas
clases suelen ser pequeñas y básicas. Si podemos evitar clases falsas elaboradas, nuestro
código mejorará.
Personas con una sola tarea
Si alguna vez ha visto el programa de televisión Good Eats, sabrá que el presentador Alton
Brown pasa mucho tiempo discutiendo cómo debe equipar su cocina para lograr la máxima
eficiencia. A menudo critica los aparatos de cocina que llama de una sola tarea, es decir,
herramientas que hacen bien una tarea pero no hacen nada más. Al escribir nuestras clases,
debemos esforzarnos por hacerlas lo más generales posible, consistentes con la inclusión de
todas las funciones específicas requeridas para nuestro programa.
Una forma de hacerlo es con clases de plantilla. Este es un tema avanzado con una
sintaxis algo arcana, pero nos permite crear clases donde uno o más de los miembros de datos
tienen un tipo que se especifica cuando se crea un objeto de la clase. Las clases de plantilla
nos permiten “excluir” la funcionalidad general. Por ejemplo, nuestra clase StudentCollection
contiene una gran cantidad de código que es común a cualquier clase que encapsule una lista
vinculada. En su lugar, podríamos crear una clase de plantilla para una lista enlazada general,
de modo que el tipo de datos dentro de los nodos de la lista se especifique cuando se crea el
objeto de la clase de plantilla, en lugar de estar programado como un registro de estudiante .
Entonces nuestra clase StudentCollection tendría un objeto de la clase de lista vinculada de
plantilla como miembro de datos, en lugar de un puntero de encabezado de lista, y ya no
manipularía la lista vinculada directamente.
Las clases de plantilla están más allá del alcance de este libro, pero a medida que
desarrolle sus habilidades como diseñador de clases, siempre debe esforzarse por crear
clases que sean multitarea. Es una gran sensación cuando descubres que un problema actual
se puede resolver usando una clase que escribiste anteriormente, mucho antes de que
supieras que existía el problema actual.
Ejercicios
Sabes lo que voy a decir, ¿no? ¡Anímate y prueba algunos!
5­1.
Intentemos implementar una clase usando el marco básico. Considere una clase para
almacenar los datos de un automóvil. Tendremos tres datos: el nombre del fabricante y el
nombre del modelo, ambas cadenas, y el año del modelo, un número entero. Cree una clase
con métodos get/set para cada miembro de datos. Asegúrese de tomar buenas decisiones
con respecto a detalles como los nombres de los miembros. No es importante que sigas mi
convención de nomenclatura particular. Lo importante es que piense en las decisiones que
toma y sea coherente en sus decisiones.
Resolver problemas con las clases
141
Machine Translated by Google
5­2. Para nuestra clase de automóvil del ejercicio anterior, agregue un método de soporte que devuelva
una descripción completa del objeto automóvil como una cadena formateada, como "Chevrolet
Impala 1957". Agregue un segundo método de soporte que devuelva la antigüedad del
automóvil en años.
5­3. Tome las funciones de cadena de longitud variable del Capítulo 4 (añadir, concatenar y carácterAt) y
úselas para crear una clase para cadenas de longitud variable, asegurándose de implementar
todos los constructores necesarios, un destructor y un operador de asignación sobrecargado.
5­4. Para la clase de cadena de longitud variable del ejercicio anterior, reemplace el
Método caracterAt con un operador [] sobrecargado . Por ejemplo, si miCadena
es un objeto de nuestra clase, entonces myString[1] debería devolver el mismo resultado
que myString.characterAt(1).
5­5. Para la clase de cadena de longitud variable de los ejercicios anteriores, agregue una eliminación
método que toma una posición inicial y una cantidad de caracteres y elimina esa cantidad de
caracteres del medio de la cadena. Entonces myString.remove(5,3)
eliminaría tres caracteres comenzando en la quinta posición. Asegúrese de que su método se
comporte cuando el valor de cualquiera de los parámetros no sea válido.
5­6. Revise su clase de cadena de longitud variable para una posible refactorización. Por ejemplo, ¿existe
alguna funcionalidad común que pueda separarse en un método de soporte privado?
5­7. Tome las funciones de registro de estudiantes del Capítulo 4 (addRecord y AverageRecord) y utilícelas
para crear una clase que represente una colección de registros de estudiantes, como antes,
asegurándose de implementar todos los constructores necesarios, un destructor y un operador
de asignación sobrecargado.
5­8. Para la clase de colección de registros de estudiantes del ejercicio anterior, agregue un método
RecordsWithinRange que tome una calificación baja y una calificación alta como parámetros y
devuelva una nueva colección que consta de los registros en ese rango (la colección original no
se ve afectada). Por ejemplo, miCollection.RecordsWithinRange(75, 80)
devolvería una colección de todos los registros con calificaciones en el rango 75–80 inclusive.
142 Capítulo 5
Machine Translated by Google
t
l
S
YYPAG
oh
C
A
DY
Y
RESOLVIENDO PROBLEMAS
CON RECURSIÓN
Este capítulo trata sobre la recursividad, que es cuando
una función se llama a sí misma directa o indirectamente.
La programación recursiva parece que debería
ser simple. De hecho, una buena solución recursiva suele
tener una apariencia simple, casi elegante. Sin embargo, muy a
menudo el camino hacia esa solución es cualquier cosa.
pero sencillo. Esto se debe a que la recursividad requiere que pensemos de manera
diferente a como lo hacemos con otros tipos de programación. Cuando procesamos
datos usando bucles, pensamos en procesarlos de manera secuencial, pero cuando
procesamos datos usando recursividad, nuestro proceso normal de pensamiento
secuencial no ayudará. Muchos buenos programadores novatos luchan con la
recursividad porque no ven una manera de aplicar las habilidades de resolución de
problemas que han aprendido a problemas recursivos. En este capítulo, analizaremos
cómo atacar sistemáticamente los problemas recursivos. La respuesta es utilizar lo que
llamaremos la Gran Idea Recursiva, en adelante denominada BRI. Es una idea tan
sencilla que parecerá un truco, pero funciona.
Machine Translated by Google
Revisión de los fundamentos de la recursividad
No hay mucho que saber sobre la sintaxis de la recursividad; La dificultad surge cuando intentas
utilizar la recursividad para resolver problemas. La recursividad ocurre cada vez que una función
se llama a sí misma, por lo que la sintaxis de la recursividad es solo la sintaxis de una llamada
a una función. La forma más común es la recursividad directa, cuando se produce una llamada
a una función en el cuerpo de esa misma función. Por ejemplo:
int factorial(int n) {
if (n == 1) devuelve 1;
* factorial(n ­ 1);
}
de lo contrario regresar sustantivo, masculino—
Esta función, que es una demostración de recursividad común pero muy ineficiente,
calcula el factorial de n. Por ejemplo, si n es 5, entonces el factorial es el producto de todos los
números de 5 a 1, o 120. Tenga en cuenta que en algunos casos no se produce recursividad.
En esta función, si el parámetro es 1, simplemente devolvemos un valor directamente sin
ninguna recursividad , lo que se conoce como caso base.
De lo contrario, hacemos la llamada recursiva .
La otra forma de recursividad es la recursividad indirecta, por ejemplo, si la función
A llama a la función B, que luego llama a la función A. La recursividad indirecta rara vez se
utiliza como técnica de resolución de problemas, por lo que no la cubriremos aquí.
Recursión de cabeza y cola
Antes de analizar el BRI, debemos comprender la diferencia entre recursividad principal y
recursiva final. En la recursividad principal, la llamada recursiva, cuando ocurre, viene antes que
otro procesamiento en la función (piense en que sucede en la parte superior o cabecera de la
función). En la recursividad de cola, es lo contrario:
el procesamiento ocurre antes de la llamada recursiva. Elegir entre los dos estilos recursivos
puede parecer arbitrario, pero la elección puede marcar la diferencia. Para ilustrar esta
diferencia, veamos dos problemas.
PROBLEMA: ¿CUÁNTOS LOROS?
Los pasajeros del Tropical Paradise Railway (TPR) esperan ver docenas de loros de
colores desde las ventanillas del tren. Debido a esto, el ferrocarril se interesa mucho por
la salud de la población local de loros y decide contar el número de loros en cada andén
a lo largo de la línea principal. Cada plataforma cuenta con un empleado de TPR (ver
Figura 6­1), que ciertamente es capaz de contar loros. Desafortunadamente, el trabajo
se complica por el primitivo sistema telefónico. Cada plataforma puede llamar sólo a sus
vecinos inmediatos. ¿Cómo obtenemos el total del loro en la terminal de la línea principal?
144 Capítulo 6
Machine Translated by Google
belinda
Arte
debbie
Cory
Evan
Figura 6­1: Los empleados de las cinco estaciones sólo pueden comunicarse con sus vecinos
inmediatos.
Supongamos que hay 7 loros de Art en la terminal principal, 5 loros de
Belinda, 3 loros de Cory, 10 loros de Debbie y 2 loros de Evan en la última
estación. El número total de loros es, por tanto, 27. La pregunta es, ¿cómo van
a trabajar juntos los empleados para comunicar este total a Art?
Cualquier solución a este problema requerirá una cadena de comunicaciones
desde la terminal principal hasta el final de la línea y viceversa. Se pedirá al
miembro del personal de cada plataforma que cuente los loros y luego
informará sus observaciones. Aun así, existen dos enfoques distintos para esta
cadena de comunicaciones, y esos enfoques corresponden a las técnicas de
recursividad principal y recursiva final en programación.
Enfoque 1
En este enfoque, mantenemos un total acumulado de loros a medida que
avanzamos en las comunicaciones salientes. Cada empleado, al realizar la
solicitud al siguiente empleado de la línea, pasa el número de loros vistos hasta
el momento. Cuando lleguemos al final de la línea, Evan será el primero en
descubrir el total del loro, que le pasará a Debbie, quien se lo pasará a Cory, y
así sucesivamente (como se muestra en la Figura 6­2).
2
4
6
8
1
3
5
7
9
Arte
belinda
Cory
debbie
Evan
13
12
11
10
Figura 6­2: Numeración de los pasos seguidos en el Método 1 para el problema de contar loros
1. ART comienza contando los loros alrededor de su plataforma. El cuenta
7 loros.
2. ARTE a BELINDA: “Hay 7 loros aquí en la terminal principal”.
3. BELINDA cuenta 5 loros alrededor de su plataforma para un total de 12.
4. BELINDA a CORY: “Hay 12 loros alrededor de las dos primeras estaciones”.
5. CORY cuenta 3 loros.
Resolver problemas con recursividad 145
Machine Translated by Google
6. CORY a DEBBIE: "Hay 15 loros alrededor de las primeras tres estaciones".
7. DEBBIE cuenta 10 loros.
8. DEBBIE a EVAN: "Hay 25 loros alrededor de las primeras cuatro estaciones".
9. EVAN cuenta 2 loros y descubre que el número total de loros es 27.
10. EVAN a DEBBIE: “El número total de loros es 27”.
11. DEBBIE a CORY: "El número total de loros es 27".
12. CORY a BELINDA: “El número total de loros es 27”.
13. BELINDA a ART: “El número total de loros es 27”.
Este enfoque es análogo a la recursividad de cola. En la recursividad de cola, la
llamada recursiva ocurre después del procesamiento; la llamada recursiva es el último
paso de la función. En la cadena de comunicaciones anterior, observe que el “trabajo”
de los empleados (el conteo y la suma de loros) ocurre antes de que le indiquen al
siguiente empleado. Todo el trabajo ocurre en la cadena de comunicaciones salientes,
no en la cadena entrante. Estos son los pasos que sigue cada empleado:
1. Cuente los loros visibles desde el andén de la estación.
2. Sume este conteo al total dado por la estación anterior.
3. Llame a la siguiente estación para pasar la suma acumulada de conteos de loros.
4. Espere a que la siguiente estación llame con el conteo total de loros y luego pase
este total a la estación anterior.
Enfoque 2
En este enfoque, sumamos los recuentos de loros desde el otro extremo. Cada
empleado, al comunicarse con la siguiente estación, solicita el número total de loros
desde esa estación en adelante. Luego, el empleado suma el número de loros en su
propia estación y pasa este nuevo total a la línea (como se muestra en la Figura 6­3).
2
1
4
3
Arte
belinda
Cory
debbie
13
11
9
7
12
10
8
Evan
5
6
Figura 6­3: Numeración de los pasos seguidos en el Método 2 para el problema de contar loros
1. ARTE a BELINDA: “¿Cuál es el número total de loros de tu estación?
¿Hasta el final de la línea?
2. BELINDA a CORY: “¿Cuál es el número total de loros de tu estación?
¿Hasta el final de la línea?
146 Capítulo 6
Machine Translated by Google
3. CORY a DEBBIE: “¿Cuál es el número total de loros de tu estación?
¿Hasta el final de la línea?
4. DEBBIE a EVAN: “¿Cuál es el número total de loros de tu estación?
¿Hasta el final de la línea?
5. EVAN es el final del camino. Cuenta 2 loros.
6. EVAN a DEBBIE: “El número total de loros aquí al final es 2”.
7. DEBBIE cuenta 10 loros en su estación, por lo que el total desde su estación hasta
el final es 12.
8. DEBBIE a CORY: “El número total de loros desde aquí hasta el final
son 12”.
9. CORY cuenta 3 loros.
10. CORY a BELINDA: “El número total de loros desde aquí hasta el final
es 15.”
11. BELINDA cuenta 5 loros.
12. BELINDA al ARTE: “El total de loros de aquí hasta el final
es 20.”
13. ART cuenta 7 loros en la terminal principal, haciendo un total de 27.
Este enfoque es análogo a la recursión de cabezas. En la recursividad principal, la
llamada recursiva ocurre antes que el otro procesamiento. Aquí primero se llama a la
siguiente estación, antes de contar los loros o realizar la suma. El “trabajo” se pospone
hasta que las estaciones de la línea hayan informado sus totales. Estos son los pasos que
sigue cada empleado:
1. Llame a la siguiente estación.
2. Cuente los loros visibles desde el andén de la estación.
3. Sume este conteo al total dado por la siguiente estación.
4. Pasar la suma resultante a la estación anterior.
Es posible que haya notado dos efectos prácticos de los diferentes enfoques.
En el primer enfoque, eventualmente todos los empleados de la estación conocerán el
total general de loros. En el segundo enfoque, sólo Art, en la terminal principal, aprende
el total completo, pero tenga en cuenta que Art es el único empleado que necesita el
total completo.
El otro efecto práctico será más importante para nuestro análisis cuando pasemos
la discusión al código de programación real. En el primer enfoque, cada empleado pasa
el “total acumulado” a la siguiente estación al realizar la solicitud. En el segundo enfoque,
el empleado simplemente realiza la solicitud de información desde la siguiente estación,
sin transmitir ningún dato. Este efecto es típico del enfoque de recursividad principal.
Debido a que la llamada recursiva ocurre primero, antes de cualquier otro procesamiento,
no hay información nueva para proporcionar la llamada recursiva. En general, el enfoque
de recursividad principal permite pasar el conjunto mínimo de datos a la llamada recursiva.
Ahora veamos otro problema.
Resolver problemas con recursividad 147
Machine Translated by Google
PROBLEMA: ¿QUIÉN ES NUESTRO MEJOR CLIENTE?
El gerente de DelegateCorp necesita determinar cuál de ocho clientes genera mayores
ingresos para su empresa. Dos factores complican esta tarea que de otro modo sería sencilla.
En primer lugar, determinar los ingresos totales de un cliente requiere revisar todo el
expediente de ese cliente y contar las cifras de docenas de pedidos y recibos. En segundo
lugar, a los empleados de DelegateCorp, como su nombre indica, les encanta delegar y
cada empleado pasa el trabajo a alguien de un nivel inferior siempre que es posible. Para
evitar que la situación se salga de control, el gerente impone una regla: cuando delega,
debe hacer una parte del trabajo usted mismo y debe darle al empleado delegado menos
trabajo del que le asignaron a usted.
Las tablas 6­1 y 6­2 identifican a los empleados y clientes de DelegateCorp.
Tabla 6­1: Títulos y rango de los empleados de DelegateCorp
Título
Rango
Gerente
1
Subgerente
2
Gerente asociado
3
Subdirector
4
gerente junior
5
Interno
6
Tabla 6­2: Clientes de DelegateCorp
Número de cliente
Ganancia
#0001
$172,000
#0002
$68,000
#0003
$193,000
#0004
$13,000
#0005
$256,000
#0006
$99,000
Siguiendo la norma de la empresa sobre la delegación del trabajo, esto es lo
que sucederá con los seis expedientes de clientes. El gerente tomará un archivo y
determinará cuántos ingresos ha generado ese cliente para la empresa. El gerente
delegará los otros cinco expedientes en el subgerente. El subdirector procesará un
expediente y pasará los otros cuatro al director asociado. Este proceso continúa
hasta llegar al sexto empleado, el becario, a quien se le entrega un expediente y
debe simplemente procesarlo, sin posibilidad de delegar más.
La figura 6­4 describe las líneas de comunicación y la división del trabajo.
Sin embargo, al igual que en el ejemplo anterior, existen dos enfoques distintos de
la cadena de comunicaciones.
148 Capítulo 6
Machine Translated by Google
Gerente
Gerente
1
dieciséis
2
dieciséis
15
1
Subgerente
Subgerente
3
14
4
15
2
Gerente asociado
13
Gerente asociado
5
12
14
6
11
3
Subdirector
Subdirector
7
10
8
13
4
Gerente Junior
9
Gerente Junior
9
8
12
10
Interno
5
7
Interno
11
6
(a)
(b)
Figura 6­4: Numeración de los pasos en el Método 1 (a) y el Método 2 (b) para
encontrar el cliente con mayores ingresos
Método 1
En este enfoque, al delegar los archivos restantes, el empleado también
transfiere la mayor cantidad de ingresos vista hasta el momento. Esto significa
que el empleado debe contar los ingresos en un archivo y compararlos con la
cantidad más alta vista anteriormente antes de delegar los archivos restantes
a otro empleado. He aquí un ejemplo de cómo procedería esto en la práctica.
1. El ADMINISTRADOR cuenta los ingresos del cliente n.° 0001, que son $172 000.
2. DEL GERENTE al VICEPRESIDENTE: “Los ingresos más altos que hemos visto hasta
ahora son $172 000, cliente n.° 0001. Tome estos cinco archivos y determine los
ingresos más altos en general”.
3. VICE MANAGER cuenta los ingresos del cliente n.° 0002, que son $68 000.
Los ingresos más altos observados hasta ahora siguen siendo de 172 000 dólares, cliente n.º 0001.
Resolver problemas con recursividad 149
Machine Translated by Google
4. VICEPRESIDENTE a ASOCIADO ADMINISTRADOR: “Los ingresos más altos que hemos
visto hasta ahora son $172 000, cliente n.° 0001. Tome estos cuatro archivos y determine
los ingresos más altos en general”.
5. El ADMINISTRADOR ASOCIADO cuenta los ingresos del cliente n.° 0003, que son $193 000.
El ingreso más alto visto hasta ahora es de $193,000, cliente #0003.
6. GERENTE ASOCIADO a GERENTE ASISTENTE: “Los ingresos más altos que hemos visto
hasta ahora son $193 000, cliente n.° 0003. Tome estos tres archivos y determine los
ingresos más altos en general”.
7. El SUBGERENTE cuenta los ingresos del cliente n.° 0004, que son $13 000. Los ingresos
más altos observados hasta ahora siguen siendo de 193 000 dólares, cliente n.º 0003.
8. SUBGERENTE a JUNIOR MANAGER: “Los ingresos más altos que hemos visto hasta ahora
son $193 000, cliente n.º 0003. Tome estos dos archivos y determine los ingresos más altos
en general”.
9. JUNIOR MANAGER cuenta los ingresos del cliente #0005, que es
256.000 dólares. El ingreso más alto visto hasta ahora es de $256,000, cliente #0005.
10. GERENTE JUNIOR a PASANTE: “Los ingresos más altos que hemos visto hasta ahora son
$256 000, cliente n.° 0005. Tome este archivo restante y determine los ingresos más altos
en general”.
11. INTERN cuenta los ingresos del cliente n.° 0006, que son $99 000. Los ingresos más altos
observados hasta ahora siguen siendo de 256 000 dólares, cliente n.º 0005.
12. PASANTE DE JUNIOR MANAGER: “El ingreso más alto de todos los clientes es de $256
000, cliente n.° 0005”.
13. GERENTE JUNIOR a GERENTE ASISTENTE: “El ingreso más alto de todos los clientes
es $256 000, cliente n.° 0005”.
14. SUBGERENTE a SUBGERENTE ASOCIADO: “El ingreso más alto de todos los clientes es
de $256 000, cliente n.° 0005”.
15. GERENTE ASOCIADO a VICEPRESIDENTE: “El ingreso más alto de todos los clientes es
de $256 000, cliente n.° 0005”.
16. VICE DIRECTOR a DIRECTOR: “El ingreso más alto de todos los clientes es de $256 000,
cliente n.° 0005”.
Este enfoque, que se muestra en la Figura 6­4 (a), utiliza la técnica de recursividad de cola.
Cada empleado procesa un archivo de cliente y compara los ingresos calculados para ese
cliente con los ingresos más altos observados hasta el momento. Luego, el empleado pasa el
resultado de esa comparación al empleado subordinado.
La recursividad (la desaparición del trabajo) ocurre después del otro procesamiento.
El proceso de cada empleado se ejecuta así:
1. Cuente los ingresos en un archivo de cliente.
2. Compare este total con los ingresos más altos vistos por superiores en otros archivos de
clientes.
3. Pasar los archivos de clientes restantes a un empleado subordinado, junto con
la mayor cantidad de ingresos vista hasta ahora.
4. Cuando el empleado subordinado devuelva los ingresos más altos de todos los archivos de
clientes, páselo al superior.
150 Capítulo 6
Machine Translated by Google
Enfoque 2
En este enfoque, cada empleado comienza apartando un archivo y luego pasando los demás al
subordinado. En este caso, no se le pide al subordinado que determine los ingresos más altos de
todos los archivos, sólo de los archivos que se le han entregado. Al igual que con el primer
problema de muestra, esto simplifica las solicitudes. Utilizando los mismos datos que en el primer
enfoque, la conversación sería la siguiente:
1. DEL GERENTE al VICEPRESIDENTE: “Tome estos cinco archivos de clientes y dígaselo.
Yo tengo los ingresos más altos”.
2. VICEPRESIDENTE al DIRECTOR ASOCIADO: “Tome estos cuatro clientes
archivos y dime los ingresos más altos”.
3. GERENTE ASOCIADO al GERENTE ADJUNTO: “Tome estos tres
archivos de clientes y dime cuáles son los ingresos más altos”.
4. SUBGERENTE al JUNIOR DIRECTOR: “Tome estos dos clientes
archivos y dime los ingresos más altos”.
5. GERENTE JUNIOR a PASANTE: “Tome este archivo de cliente y dígaselo.
Yo tengo los ingresos más altos”.
6. INTERN cuenta los ingresos del cliente n.° 0006, que son $99 000. Este es el único archivo que
el INTERN ha visto, por lo que son los ingresos más altos.
7. PASANTE a JUNIOR MANAGER: “El ingreso más alto en mis archivos es $99,000, cliente
#0006”.
8. JUNIOR MANAGER cuenta los ingresos del cliente #0005, que es
256.000 dólares. El ingreso más alto que conoce este empleado es de $256 000, cliente n.º
0005.
9. JUNIOR DIRECTOR a SUBDIRECTOR: “Los mayores ingresos
en mis archivos hay $256,000, cliente #0005”.
10. El SUBGERENTE cuenta los ingresos del cliente n.° 0004, que son $13 000. El ingreso más
alto que conoce este empleado es de $256 000, cliente n.º 0005.
11. SUBGERENTE a SUBGERENTE ASOCIADO: “El ingreso más alto en mis archivos es $256
000, cliente n.° 0005”.
12. El ADMINISTRADOR ASOCIADO cuenta los ingresos del cliente n.° 0003, que son $193 000.
El ingreso más alto que conoce este empleado es de $256 000, cliente n.º 0005.
13. GERENTE ASOCIADO a VICEPRESIDENTE: “Los ingresos más altos en
mis archivos cuestan $256,000, cliente #0005”.
14. VICE MANAGER cuenta los ingresos del cliente #0002, que es
$68,000. El ingreso más alto que conoce este empleado es de $256 000, cliente n.º 0005.
15. VICE MANAGER a MANAGER: “El ingreso más alto en mis archivos es $256 000, cliente
#0005”.
16. El ADMINISTRADOR cuenta los ingresos del cliente n.° 0001, que son $172 000. El ingreso
más alto que conoce este empleado es de $256 000, cliente n.º 0005.
Resolver problemas con recursividad
151
Machine Translated by Google
Este enfoque, que se muestra en la figura 6­4 (b), utiliza la técnica de recursividad
de la cabeza. Cada empleado todavía tiene que contabilizar los ingresos en un archivo de
cliente, pero esa acción se pospone hasta que el empleado subordinado determine los
ingresos más altos entre los archivos restantes. El proceso que realiza cada empleado es el
siguiente:
1. Pasar todos los archivos de clientes excepto uno a un empleado subordinado.
2. Obtener los mayores ingresos de esos archivos del empleado subordinado.
3. Cuente los ingresos en el archivo de un cliente.
4. Pasar el mayor de esos dos ingresos al superior.
Como en el problema de “contar loros”, la técnica de recursividad principal permite
que cada empleado pase la cantidad mínima de información al subordinado.
La gran idea recursiva
Llegamos ahora a la Gran Idea Recursiva. De hecho, si ha leído los pasos de los problemas
de muestra, ya habrá visto el BRI en acción.
¿Cómo es eso? Ambos problemas de muestra siguen la forma de una solución recursiva.
Cada persona en la cadena de comunicaciones realiza los mismos pasos en un subconjunto
cada vez más pequeño de los datos originales. Es importante señalar, sin embargo, que
los problemas no implican recurrencia alguna.
En el primer problema, cada empleado ferroviario hace una solicitud al siguiente
estación en la línea, y al cumplir con esa solicitud, el siguiente empleado sigue los mismos
pasos que el empleado anterior. Pero nada en el texto de la solicitud exige que un empleado
siga esos pasos en particular. Cuando Art llamó a Belinda usando el Método 2, por ejemplo,
le pidió que contara el número total de loros desde su estación hasta el final de la línea. No
dictó un método para descubrir este total. Si lo hubiera pensado, se habría dado cuenta de
que Belinda tendría que seguir los mismos pasos que él mismo estaba siguiendo, pero no
tiene por qué considerar esto. Para completar su tarea, todo lo que Art necesitaba era que
Belinda proporcionara la respuesta correcta a la pregunta que hizo.
De manera similar, en el segundo problema, cada empleado de la cadena gerencial
entrega tanto trabajo como sea posible a un subordinado. El subgerente, por ejemplo,
puede conocer bien al gerente subalterno y esperar que éste le entregue todos los archivos
menos uno al pasante. Sin embargo, el subdirector no tiene por qué preocuparse si el
subdirector procesa todos los archivos restantes o pasa algunos de ellos a un subordinado.
Al subdirector sólo le importa que el subdirector dé la respuesta correcta. Debido a que el
subgerente no va a repetir el trabajo del subgerente, el subgerente simplemente asume que
el resultado arrojado por el subgerente es correcto y utiliza esos datos para resolver la tarea
general que el subgerente recibió del subgerente.
152 Capítulo 6
Machine Translated by Google
En ambos problemas, cuando los empleados hacen peticiones a otros empleados, les
preocupa qué pero no cómo. Se entrega una pregunta; se recibe una respuesta. Ésta,
entonces, es la gran idea recursiva: si sigues ciertas convenciones en tu codificación,
puedes fingir que no se está produciendo ninguna recursividad. Incluso puedes usar un truco
económico (que se muestra a continuación) para pasar de una implementación iterativa a
una implementación recursiva, sin considerar explícitamente cómo la recursión realmente
resuelve el problema. Con el tiempo, desarrollará una comprensión intuitiva de cómo
funcionan las soluciones recursivas, pero antes de que se desarrolle esa intuición, podrá
diseñar implementaciones recursivas y tener confianza en su código.
Pongamos el concepto en práctica mediante un ejemplo de código.
PROBLEMA: CÁLCULO DE LA SUMA
DE UNA ARRAY DE ENTEROS
Escriba una función recursiva a la que se le proporcione una matriz de números enteros y el tamaño de la
matriz como parámetros. La función devuelve la suma de los números enteros de la matriz.
Es posible que su primer pensamiento haya sido que este problema sería trivial de
resolver de forma iterativa. De hecho, comencemos con una solución iterativa a este problema:
int iterativeArraySum(int enteros[], int tamaño) {
suma int = 0;
para (int i = 0; i < tamaño; i++) {
suma += enteros[i];
}
suma de devolución;
}
Viste un código muy similar a este en el Capítulo 3, por lo que la función debería ser
fácil de entender. El siguiente paso es escribir código que esté a medio camino entre la
solución iterativa y la solución recursiva final deseada. Mantendremos la función iterativa y
agregaremos una segunda función a la que nos referiremos como despachador. El
despachador entregará la mayor parte del trabajo a la función iterativa previamente escrita
y utilizará esta información para resolver el problema general. Para escribir un
despachador, debemos seguir dos reglas:
1. El despachador debe manejar completamente el caso más trivial, sin llamar a la
función iterativa.
2. El despachador, al llamar a la función iterativa, debe pasar un valor más pequeño
versión del problema.
Al aplicar la primera regla a este problema, debemos decidir cuál es el caso más trivial.
Si el tamaño es 0, entonces a la función se le ha pasado conceptualmente una matriz
“nula”, con una suma de 0. También se podría argumentar que el caso más trivial debería
ser cuando el tamaño es 1. En ese caso, solo habría un número en la matriz lógica, y
podríamos devolver ese número como
Resolver problemas con recursividad 153
Machine Translated by Google
suma. Cualquiera de estas interpretaciones funcionará, pero hacer la primera
elección permite que la función maneje un caso especial. Tenga en cuenta que la
función iterativa original no fallará cuando el tamaño sea cero, por lo que sería preferible
mantener esa flexibilidad.
Para aplicar la segunda regla a este problema, debemos encontrar una manera
de pasar una versión más pequeña del problema desde el despachador a la función
iterativa. No existe una manera fácil de pasar una matriz más pequeña, pero podemos
pasar fácilmente un valor de tamaño más pequeño. Si al despachador se le da el valor
de 10 para el tamaño, se le pide a la función que calcule la suma de 10 valores en la
matriz. Si el despachador pasa 9 como valor de tamaño a la función iterativa, está
solicitando la suma de los primeros 9 valores de la matriz. Luego, el despachador puede
sumar el valor del valor restante en la matriz (el décimo) para calcular la suma de los 10
valores. Tenga en cuenta que reducir el tamaño en 1 al llamar a la función iterativa
maximiza el trabajo de la función iterativa y, por lo tanto, minimiza el trabajo del
despachador. Este es siempre el enfoque deseado: al igual que los gerentes de
DelegateCorp, la función de despachador evita tanto trabajo como sea posible.
Juntando estas ideas, aquí hay una función de despachador para este problema:
int arraySumDelegate(int enteros[], int tamaño) {
if (tamaño == 0) devuelve 0;
int últimoNúmero = enteros[tamaño ­ 1];
int allButLastSum = iterativeArraySum(enteros, tamaño ­ 1);
return lastNumber + allButLastSum;
}
La primera declaración aplica la primera regla de los despachadores: busca un
caso trivial y lo maneja por completo, en este caso, devolviendo 0 . De lo contrario, el
control pasa al código restante, que aplica la segunda regla. El último número de la
matriz se almacena en una variable local llamada últimoNúmero , y luego la suma de
todos los demás valores de la matriz se calcula mediante una llamada a la función
iterativa . Este resultado se almacena en otra variable local, allButLastSum, y
finalmente la función devuelve la suma de las dos variables locales .
Si hemos creado correctamente una función de despachador, ya hemos creado
efectivamente una solución recursiva. Esta es la Gran Idea Recursiva en acción. Para
convertir esta solución iterativa en una solución recursiva solo se requiere un paso más
y simple: hacer que la función delegada se llame a sí misma donde antes llamaba a la
función iterativa. Luego podemos eliminar la función iterativa por completo.
int
arraySumRecursive(int enteros[], int tamaño) {
si (tamaño == 0) devuelve 0;
int últimoNúmero = enteros[tamaño ­ 1];
int allButLastSum = arraySumRecursive(enteros, tamaño ­ 1);
devolver últimoNúmero + todoPeroÚltimaSuma;
}
154 Capítulo 6
Machine Translated by Google
Sólo se han realizado dos cambios al código anterior. El nombre de la función se ha cambiado
para describir mejor su nueva forma
a la función iterativa
, y la función ahora se llama a sí misma donde antes llamaba
. La lógica de las dos funciones, arraySumDelegate y arraySumRecursive,
es idéntica.
Cada función busca un caso trivial en el que la suma ya sea conocida; en este caso, una matriz de
tamaño 0 que tiene una suma de 0. De lo contrario, cada función calcula la suma de los valores en
la matriz realizando una llamada a función. para calcular la suma de todos los valores, guarde el
último. Finalmente, cada función suma ese último valor a la suma devuelta para obtener un total
general. La única diferencia es que la primera versión de la función llama a otra función, mientras
que la versión recursiva se llama a sí misma. El BRI nos dice que si seguimos las reglas descritas
anteriormente para escribir al despachador, podemos ignorar esa distinción.
No es necesario seguir literalmente todos los pasos que se muestran arriba para seguir el BRI.
En particular, normalmente no implementaría una solución iterativa al problema antes de implementar
una solución recursiva. Escribir una función iterativa como trampolín es un trabajo extra que
eventualmente se desperdiciará.
Además, la recursividad se aplica mejor a situaciones en las que una solución iterativa es difícil,
como se explica más adelante. Sin embargo, puede seguir el esquema de la BRI sin tener que
escribir la solución iterativa. La clave es pensar en una llamada recursiva como una llamada a otra
función, sin tener en cuenta los aspectos internos de esa función. De esta manera, eliminas las
complejidades de la lógica recursiva de la solución recursiva.
Errores comunes
Como se muestra arriba, con el enfoque correcto, las soluciones recursivas a menudo pueden ser
muy fáciles de escribir. Pero puede ser igual de fácil encontrar una implementación recursiva
incorrecta o una solución recursiva que "funcione" pero que sea desgarbada.
La mayoría de los problemas con las implementaciones recursivas surgen de dos errores básicos:
pensar demasiado en el problema o comenzar la implementación sin un plan claro.
Pensar demasiado en los problemas recursivos es común para los nuevos programadores
porque la experiencia limitada y la falta de confianza con la recursividad los llevan a pensar que el
problema es más difícil de lo que realmente es. El código producido por pensar demasiado puede
reconocerse por su apariencia demasiado cuidadosa. Por ejemplo, una función recursiva puede
tener varios casos especiales en los que solo necesita uno.
Comenzar la implementación demasiado pronto puede llevar a un código “Rube Goldberg”
demasiado complicado, donde interacciones imprevistas conducen a correcciones que se incorporan
al código original.
Veamos algunos errores específicos y cómo evitarlos.
Demasiados parámetros
Como se describió anteriormente, la técnica de recursividad principal puede reducir los datos
pasados a la llamada recursiva, mientras que la técnica de recursividad final puede resultar en
pasar datos adicionales a las llamadas recursivas. Los programadores a menudo se quedan
atrapados en el modo recursivo de cola porque piensan demasiado y comienzan la implementación demasiado pronto
Resolver problemas con recursividad 155
Machine Translated by Google
Considere nuestro problema de calcular recursivamente la suma de un conjunto de
números enteros. Al escribir una solución iterativa a este problema, el programador sabe que
se necesitará una variable de "total acumulado" (en la solución iterativa proporcionada, llamé
a esta suma) y la matriz se sumará comenzando desde el primer elemento.
Considerando la solución recursiva, el programador naturalmente imagina una
implementación que refleje más directamente la solución iterativa, con una variable de total
acumulado y la primera llamada recursiva manejando el primer elemento de la matriz. Sin
embargo, este enfoque requiere que la función recursiva pase el total acumulado y la
ubicación donde debe comenzar a procesarse la siguiente llamada recursiva. Una solución
de este tipo se vería así:
int arraySumRecursiveExtraParams(int enteros[], int tamaño, int suma, int índiceactual) {
if (currentIndex == tamaño) devuelve la suma;
suma += enteros[currentIndex];
devolver arraySumRecursiveExtraParameters (enteros, tamaño, suma, índice actual + 1);
}
Este código es tan corto como la otra versión recursiva pero considerablemente más
complejo semánticamente debido a los parámetros adicionales, suma
y índice actual
. Desde el punto de vista del código del cliente, los parámetros adicionales
no tienen sentido y siempre tendrán que ser ceros en la llamada, como se muestra en este
ejemplo:
int a[10] = {20, 3, 5, 22, 7, 9, 14, 17, 4, 9};
int total = arraySumRecursiveExtraParameters(a, 10, 0, 0);
Este problema se puede evitar con el uso de una función contenedora, como se describe
en la siguiente sección, pero como no podemos eliminar esos parámetros por completo,
esa no es la mejor solución. La función iterativa para este problema y la función recursiva
original responden a la pregunta: ¿cuál es la suma de esta matriz con tantos elementos? En
contraste, a esta segunda función recursiva se le pregunta: ¿cuál es la suma de esta
matriz si tiene tantos elementos, estamos comenzando con este elemento en particular y esta
es la suma de todos los elementos anteriores?
El problema de “demasiados parámetros” se evita eligiendo su función.
parámetros de ción antes de pensar en la recursividad. En otras palabras, oblíguese a
utilizar la misma lista de parámetros que usaría si la solución fuera iterativa. Si utiliza el
proceso BRI completo y escribe primero la función iterativa, evitará este problema
automáticamente. Sin embargo, si omite el uso formal de todo el proceso, aún puede usar
la idea conceptualmente si escribe la lista de parámetros según lo que esperaría de una
función iterativa.
Variables globales
Evitar demasiados parámetros a veces lleva a los programadores a cometer un error diferente:
usar variables globales para pasar datos de una llamada recursiva a otra. El uso de variables
globales es generalmente una mala práctica de programación, aunque a veces está
permitido por motivos de rendimiento. Global
156 Capítulo 6
Machine Translated by Google
Las variables siempre deben evitarse en funciones recursivas cuando sea posible. Veamos un
problema específico para ver cómo los programadores se convencen a sí mismos de cometer
este error. Supongamos que nos pidieran que escribiéramos una función recursiva que contara
el número de ceros que aparecen en una matriz de números enteros. Este es un problema
simple de resolver usando iteración:
int zeroCountIterative(int números[], int tamaño) {
suma int = 0;
int recuento = 0;
para (int i = 0; i < tamaño; i++) {
if (números[i] == 0) cuenta ++;
}
recuento de devoluciones;
}
La lógica de este código es sencilla. Simplemente recorremos la matriz desde la primera
ubicación hasta la última, contando los ceros a medida que avanzamos y usando una variable
local, contamos
, como rastreador. Sin embargo, si tenemos una función como esta en mente
cuando escribimos nuestra función recursiva, podemos asumir que también necesitamos una
variable de seguimiento en esa versión. No podemos simplemente declarar el recuento como
una variable local en la versión recursiva porque entonces sería una nueva variable en cada
llamada recursiva. Entonces podríamos sentirnos tentados a declararla como una variable global:
recuento int;
int zeroCountRecursive(int números[], int tamaño) {
if (tamaño == 0) recuento de retorno;
if (números[tamaño ­ 1] == 0) contar++;
zeroCountRecursive(números, tamaño ­ 1);
}
Este código funciona, pero la variable global es completamente innecesaria y causa todos
los problemas que suelen causar las variables globales, como una mala legibilidad y un
mantenimiento del código más difícil. Algunos programadores podrían intentar mitigar el
problema haciendo que la variable sea local, pero estática:
int zeroCountStatic(int números[], int tamaño) {
recuento int estático = 0;
if (tamaño == 0) recuento de retorno;
if (números[tamaño ­ 1] == 0) contar++;
zeroCountStatic(números, tamaño ­ 1);
}
En C++, una variable local declarada como estática conserva su valor de una llamada de
función a la siguiente; por lo tanto, el recuento de la variable estática local
actuaría igual que
la variable global en la versión anterior. ¿Entonces, cuál es el problema? La inicialización de la
variable a cero
ocurre sólo la primera vez que se llama a la función. Esto es necesario para
que la declaración estática sea de alguna utilidad, pero significa que la función devolverá una
respuesta correcta solo la primera vez que se llama. Si esta función fuera llamada dos veces,
primero con una matriz que tenía tres
Resolver problemas con recursividad 157
Machine Translated by Google
ceros, luego con una matriz que tuviera cinco ceros; la función devolvería una respuesta de
ocho para la segunda matriz porque el conteo comenzaría donde lo dejó.
La solución para evitar la variable global en este caso es utilizar el BRI.
Podemos suponer que una llamada recursiva con un valor de tamaño menor devolverá el
resultado correcto y calculará el valor correcto para la matriz general a partir de ahí. Esto
conducirá a una solución recursiva principal:
int zeroCountRecursive(int números[], int tamaño) {
si (tamaño == 0) devuelve 0;
int count = zeroCountRecursive(números, tamaño ­ 1);
if (números[tamaño ­ 1] == 0) contar++;
recuento de devoluciones;
}
En esta función, todavía tenemos una variable local, contar , pero aquí no se intenta
mantener su valor de una llamada a la siguiente. En cambio, almacena el valor de retorno de
nuestra llamada recursiva; opcionalmente incrementamos la variable
antes de devolverlo
.
Aplicar recursividad a estructuras de datos dinámicas
La recursividad se aplica a menudo a estructuras dinámicas como listas vinculadas, árboles y
gráficos. Cuanto más complicada sea la estructura, más se beneficiará la codificación de una
solución recursiva. Procesar estructuras complicadas es a menudo muy parecido a encontrar el
camino a través de un laberinto, y la recursividad nos permite retroceder a pasos anteriores de
nuestro procesamiento.
Recursividad y listas enlazadas
Sin embargo, comencemos con la estructura dinámica más básica: una lista enlazada. Para
las discusiones en esta sección, supongamos que tenemos la estructura de nodos más simple
para nuestra lista vinculada, solo un int para datos. Aquí están nuestras declaraciones de tipo:
estructura listaNnodo {
datos enteros;
listaNodo *
próximo;
};
typedef listNode * listPtr;
La aplicación del BRI a una lista enlazada individualmente sigue el mismo esquema general
independientemente de la tarea específica. La recursividad requiere que dividamos el problema
para poder pasar una versión reducida del problema original a la llamada recursiva. Sólo hay
una forma práctica de dividir una lista enlazada individualmente: el primer nodo de la lista y el
resto de la lista.
En la Figura 6­5, vemos una lista de muestra dividida en partes desiguales: el primer nodo
y todos los demás nodos. Conceptualmente, podemos ver el "resto de" la lista original como su
propia lista, comenzando con el segundo nodo de la lista original. Es esta vista la que permite
que la recursividad funcione sin problemas.
158 Capítulo 6
Machine Translated by Google
listaCabeza
7
12
14 NULO
9
Primer nodo Resto de la lista
Figura 6­5: Una lista dividida en un primer nodo y "el resto de la lista"
Nuevamente, sin embargo, no
estamos obligados a imaginar todos los
pasos de la recursividad para que funcione.
escribe una función recursiva para
conceptualizar como el primer nodo, con el
que tenemos que tratar, y el resto de la lista,
?
Primer nodo Resto de la lista
Desde el punto de vista de alguien que
procesar una lista enlazada, se puede
7
listaCabeza
Figura 6­6: La lista como programador que usa
recursividad debería representarla: un primer nodo y
el resto de la lista como una forma nebulosa que se
pasará a la llamada recursiva.
que no lo hacemos y, por lo tanto, no lo
hacemos. preocupado por. Esta actitud
se muestra en la Figura 6­6.
Una vez fijada la división del trabajo, podemos decir que el procesamiento recursivo de listas
enlazadas individualmente se realizará de acuerdo con el siguiente plan general. Dada una lista
enlazada L y una pregunta Q:
1. Si L es mínimo, asignamos directamente un valor predeterminado. De lo contrario . . .
2. Utilice una llamada recursiva para producir una respuesta a Q para el “resto de” la lista L (la
lista que comienza con el segundo nodo de L).
3. Inspeccione el valor en el primer nodo de L.
4. Utilice los resultados de los dos pasos anteriores para responder Q para el total de L.
Como puede ver, esta es solo una aplicación sencilla de la BRI dadas las restricciones prácticas
para dividir una lista vinculada. Ahora apliquemos este modelo a un problema específico.
PROBLEMA: CONTAR NEGATIVO
NÚMEROS EN UNA LISTA ENLAZADA ÚNICAMENTE
Escriba una función recursiva a la que se le proporcione una lista enlazada individualmente donde el tipo de datos sea un número entero.
La función devuelve el recuento de números negativos en la lista.
La pregunta Q que queremos responder es ¿cuántos números negativos hay?
¿en la lista? Por tanto, nuestro plan se puede enunciar como:
1. Si la lista no tiene nodos, el recuento es 0 de forma predeterminada. De lo contrario . . .
2. Utilice una llamada recursiva para contar cuántos números negativos hay en el "resto".
de” la lista.
3. Vea si el valor en el primer nodo de la lista es negativo.
Resolver problemas con recursividad 159
Machine Translated by Google
4. Utilice los resultados de los dos pasos anteriores para determinar cuántos números
negativos hay en la lista completa.
Aquí hay una implementación de función que se deriva directamente de este plan:
int countNegative(listPtr encabezado) {
si (head == NULL) devuelve 0;
int listCount = countNegative(head­>siguiente);
if (cabeza­>datos < 0) listCount++;
devolver listaContar;
}
Observe cómo este código sigue los mismos principios que los ejemplos anteriores.
Contará los números negativos "hacia atrás", desde el final de la lista hacia el frente.
También tenga en cuenta que el código emplea la técnica de recursividad principal;
Procesamos el “resto de” la lista antes de procesar el primer nodo. Como antes, esto nos
permite evitar pasar datos adicionales en la llamada recursiva o usar variables globales.
Observe también cómo la regla 1 de la lista enlazada, "si la lista L es mínima", se
interpreta en la implementación específica de este problema como "si la lista no tiene nodos".
Esto se debe a que tiene sentido decir que una lista sin nodos tiene cero valores negativos.
Sin embargo, en algunos casos no hay una respuesta significativa para nuestra pregunta Q
para una lista sin nodos, y el caso mínimo es una lista con un nodo. Supongamos que
nuestra pregunta fuera: ¿cuál es el número más grande en esta lista? Esa pregunta no se
puede responder para una lista sin valores. Si no entiendes por qué, imagina que eres un
maestro de escuela primaria y que en tu clase sólo hay niñas. Si el director de tu escuela te
preguntara cuántos niños de tu salón de clases eran miembros del coro de niños, podrías
simplemente responder cero porque no tienes niños. Si el director te pidiera que nombraras
al niño más alto de tu clase, no podrías dar una respuesta significativa a esa pregunta;
tendrías que tener al menos un niño para tener un niño más alto. De la misma manera, si la
pregunta sobre un conjunto de datos requiere al menos un valor para ser respondida de
manera significativa, el conjunto de datos mínimo es un elemento. Sin embargo, es posible
que aún quieras devolver algo para el caso de “tamaño cero”, aunque sólo sea por flexibilidad
en el uso de la función y para protegerte contra un bloqueo.
Recursión y árboles binarios
Todos los ejemplos que hemos explorado hasta ahora no realizan más de una llamada
recursiva. Sin embargo, las estructuras más complicadas pueden requerir múltiples llamadas
recursivas. Para tener una idea de cómo funciona, consideremos la estructura conocida
como árbol binario, en la que cada nodo contiene enlaces "izquierdos" y "derechos" a otros nodos.
Estos son los tipos que usaremos:
estructura nodoárbol {
datos enteros;
treeNode * izquierda;
treeNode * derecha;
};
typedef treeNode * treePtr;
160 Capítulo 6
Machine Translated by Google
Debido a que cada nodo del árbol apunta a otros dos nodos, las funciones recursivas de
procesamiento de árboles requieren dos llamadas recursivas. Conceptualizamos las listas enlazadas con
dos partes: un primer nodo y el resto de la lista. Para aplicar la recursividad, conceptualizaremos los
árboles con tres partes: el nodo en la parte superior, conocido como nodo raíz; todos los nodos alcanzados
desde el enlace izquierdo de la raíz, conocido como subárbol izquierdo; y todos los nodos llegan desde el
enlace derecho de la raíz, conocido como subárbol derecho. Esta conceptualización se muestra en la
Figura 6­7. Al igual que con la lista enlazada y como desarrolladores de una solución recursiva, simplemente
nos centramos en la existencia de los subárboles izquierdo y derecho, sin considerar su contenido. Esto se
muestra en la Figura 6­8.
datos izquierdo derecho
nodo raíz
17
estructura treeNode
4
NULO
3
NULO NULO
144
dieciséis
NULO NULO
subárbol izquierdo
99
NULO NULO
217
NULO
subárbol derecho
Figura 6­7: Un árbol binario dividido en un nodo raíz y un subárbol izquierdo y derecho
Como siempre, al resolver recursivamente
nodo raíz
problemas que involucran árboles binarios, queremos
17
emplear el BRI. Haremos llamadas a funciones recursivas
y asumiremos que arrojan resultados correctos sin
preocuparnos de cómo el proceso recursivo resuelve el
problema general. Al igual que con las listas enlazadas,
trabajaremos con las divisiones naturales de un árbol
binario. Esto produce el siguiente plan general. Para
responder una pregunta Q para el árbol T:
1. Si el árbol T tiene un tamaño mínimo, asigne
?
subárbol izquierdo
?
subárbol derecho
Figura 6­8: Un programador que usa
recursividad debería imaginar un
árbol binario: un nodo raíz con
subárboles izquierdo y derecho de
estructura desconocida y no considerada
..
directamente un valor predeterminado. De lo contrario
.
2. Realice una llamada recursiva para responder Q para el subárbol izquierdo de T.
3. Realice una llamada recursiva para responder Q para el subárbol derecho de T.
4. Inspeccione el valor en el nodo raíz de T.
5. Utilice los resultados de los tres pasos anteriores para responder Q para todo T.
Ahora apliquemos el plan general a un problema específico.
Resolver problemas con recursividad
161
Machine Translated by Google
PROBLEMA: ENCUENTRA EL VALOR MÁS GRANDE EN UN ÁRBOL BINARIO
Escriba una función que, cuando se le proporcione un árbol binario donde cada nodo contiene un número entero,
devuelva el número entero más grande del árbol.
Aplicar el plan general a este problema específico da como resultado los siguientes pasos:
1. Si la raíz del árbol no tiene hijos, devuelve el valor en la raíz.
De lo contrario . . .
2. Realice una llamada recursiva para encontrar el valor más grande en el subárbol izquierdo.
3. Realice una llamada recursiva para encontrar el valor más grande en el subárbol derecho.
4. Inspeccione el valor en el nodo raíz.
5. Devuelva el mayor de los valores de los tres pasos anteriores.
Con esos pasos en mente, podemos escribir directamente el código de la solución:
int maxValue(árbolPtr raíz) {
if (raíz == NULL) devuelve 0;
if (raíz­>derecha == NULL && raíz­>izquierda == NULL)
return raíz­>datos;
int leftMax = maxValue(raíz­>izquierda);
int rightMax = maxValue(raíz­>derecha);
int maxNum = raíz­>datos;
if (leftMax > maxNum) maxNum = leftMax;
if (rightMax > maxNum) maxNum = rightMax;
devolver númmáx;
}
Observe cómo el árbol mínimo para este problema es un solo nodo
vacío está cubierto por seguridad
(aunque el caso del árbol
). Esto se debe a que la pregunta que formulamos sólo puede
responderse de manera significativa con al menos un valor de datos.
Considere el problema práctico si intentáramos hacer del árbol vacío el caso base. ¿Qué valor
podríamos devolver? Si devolvemos cero, implícitamente requerimos algunos valores positivos en
el árbol; Si todos los valores del árbol son negativos, se devolverá cero por error como el valor más
grande del árbol. Podríamos resolver este problema devolviendo el número entero más bajo (más
negativo) posible, pero entonces tendríamos que tener cuidado al adaptar el código a otros tipos
numéricos. Al hacer de un solo nodo el caso base, evitamos esta decisión por completo.
El resto del código es sencillo. Usamos recursividad para encontrar los valores máximos en los
subárboles izquierdo
y derecho
. Luego encontramos el mayor de los tres valores (valor en la raíz,
mayor en el subárbol izquierdo, mayor en el subárbol derecho) usando una variante del algoritmo "Rey
de la colina" que hemos estado usando a lo largo de este libro
162 Capítulo 6
.
Machine Translated by Google
Funciones de contenedor
En los ejemplos anteriores de este capítulo, hemos analizado sólo la función recursiva en sí. En
algunos casos, sin embargo, la función recursiva necesita ser "configurada" por una segunda
función. Más comúnmente, esto ocurre cuando escribimos funciones recursivas dentro de
estructuras de clases. Esto puede provocar una discrepancia entre los parámetros necesarios para
la función recursiva y los parámetros necesarios para un método público de la clase. Debido a que
las clases normalmente obligan a ocultar información, es posible que el código del cliente de la
clase no tenga acceso a los datos o tipos que requiere la función recursiva. Este problema y su
solución se muestran en el siguiente ejemplo.
PROBLEMA: ENCUENTRA EL NÚMERO DE HOJAS DE UN ÁRBOL BINARIO
Para una clase que implementa un árbol binario, agregue un método de acceso público que devuelva el número
de hojas (nodos sin hijos) en el árbol. El recuento de hojas debe realizarse mediante recursividad.
Esbocemos un esquema de cómo podría verse esta clase antes de intentar implementar una
solución a este problema. Para simplificar, incluiremos solo las partes relevantes de la clase,
ignorando los constructores, el destructor e incluso los métodos que nos permitirían construir el
árbol para centrarnos en nuestro método recursivo.
clase árbol binario {
público:
int contarHojas();
privado:
estructura binarioTreeNode {
datos enteros;
binarioTreeNode * izquierda;
binarioTreeNode * derecha;
};
typedef treeNode * treePtr;
árbolPtr _root;
};
Tenga en cuenta que nuestra función de recuento de hojas no toma parámetros
. Desde
el punto de vista de la interfaz, esto es exactamente correcto. Considere una llamada de
ejemplo para un objeto binarioTree previamente construido :
int numLeaves = bt.countLeaves();
Después de todo, si le preguntamos al árbol cuántas hojas tiene, ¿qué información
podríamos proporcionarle al objeto que no conozca ya sobre sí mismo? Por muy correcto que
sea esto para la interfaz, todo es incorrecto para la implementación recursiva. Si no hay ningún
parámetro, ¿qué cambia de un recursivo?
Resolver problemas con recursividad 163
Machine Translated by Google
llamar a la siguiente? Nada puede cambiar en ese caso, excepto a través de variables globales,
que, como se dijo anteriormente, deben evitarse. Si nada cambia, no hay forma de que la recursión
progrese o finalice.
La forma de solucionar este problema es escribir primero la función recursiva,
conceptualizándola como una función fuera de una clase. En otras palabras, escribiremos esta
función para contar las hojas en un árbol binario con el mismo estilo que escribimos la función
para encontrar el valor más grande en un árbol binario. El único parámetro que debemos pasar es
un puntero a nuestra estructura de nodos.
Esto nos brinda otra oportunidad de emplear la BRI. ¿Cuál es la pregunta Q en este caso?
¿Cuántas hojas tiene el árbol? La aplicación del plan general para el procesamiento recursivo de
árboles binarios a este problema específico da como resultado lo siguiente:
1. Si la raíz del árbol no tiene hijos, entonces el árbol tiene un nodo en total.
Ese nodo es una hoja por definición, así que devuelve 1. En caso contrario. . .
2. Realice una llamada recursiva para contar las hojas en el subárbol izquierdo.
3. Realice una llamada recursiva para contar las hojas en el subárbol derecho.
4. En este caso, no es necesario inspeccionar el nodo raíz porque si llegamos a
En este paso, no hay forma de que la raíz sea una hoja. Entonces . . .
5. Devuelve la suma de los pasos 2 y 3.
Traducir este plan a código da como resultado esto:
estructura binarioTreeNode {
datos enteros;
treeNode * izquierda;
treeNode * derecha;
};
typedef binarioTreeNode * treePtr;
int countLeaves(treePtr rootPtr) {
si (rootPtr == NULL) devuelve 0; si
(rootPtr­>right == NULL && rootPtr­>left == NULL) devuelve 1;
int leftCount = countLeaves(rootPtr­>left);
int rightCount = countLeaves(rootPtr­>right);
devolver Cuentaizquierda + Cuentaderecha;
}
Como puede ver, el código es una traducción directa del plan. La pregunta es, ¿cómo pasamos
de esta función independiente a algo que podamos usar en la clase? Aquí es donde el programador
desprevenido podría fácilmente meterse en problemas, pensando que necesitamos usar una variable
global o hacer público el puntero raíz. Pero no necesitamos hacer eso; Podemos mantener todo
dentro de la clase.
El truco consiste en utilizar una función contenedora. Primero, colocamos la función independiente,
con el parámetro treePtr , en la sección privada de nuestra clase. Luego, escribimos una función
pública, la función contenedora, que "envolverá" la función privada.
164 Capítulo 6
Machine Translated by Google
Debido a que la función pública tiene acceso a la raíz del miembro de datos privados, puede pasar esto
a la función recursiva y luego devolver los resultados al cliente de esta manera:
clase árbol binario {
público:
int publicCountLeaves();
privado:
estructura binarioTreeNode {
datos enteros;
binarioTreeNode * izquierda;
binarioTreeNode * derecha;
};
typedef binarioTreeNode * treePtr;
árbolPtr _root;
int privateCountLeaves(árbolPtr raízPtr);
};
int árbol binario::cuentaprivadaHojas(árbolPtr raízPtr) {
si (rootPtr == NULL) devuelve 0;
si (rootPtr­>right == NULL && rootPtr­>left == NULL) devuelve 1;
int leftCount = privateCountLeaves(rootPtr­>izquierda);
int rightCount = privateCountLeaves(rootPtr­>right);
devolver Cuentaizquierda + Cuentaderecha;
}
int árbol binario::publicCountLeaves() {
return privateCountLeaves(_root);
}
Aunque C++ permitiría que ambas funciones tuvieran el mismo nombre, para mayor claridad he
usado nombres diferentes para distinguir entre las funciones públicas y privadas de “contar hojas”. El
código en privateCountLeaves
es exactamente el mismo que nuestra función independiente anterior
countLeaves. La función contenedora publicCountLeaves
es simple. Llama a privateCountLeaves,
pasa la raíz del miembro de datos privados y devuelve el resultado
. En esencia, "ceba la bomba"
del proceso recursivo. Las funciones contenedoras son muy útiles al escribir funciones recursivas dentro
de clases, pero se pueden usar siempre que exista una discrepancia entre la lista de parámetros
requerida por una función y la lista de parámetros deseada de una persona que llama.
Cuándo elegir la recursividad
Los nuevos programadores a menudo se preguntan por qué alguien tiene que lidiar con la recursividad.
Es posible que ya hayan aprendido que cualquier programa se puede construir utilizando estructuras de
control básicas, como la selección (declaraciones if ) y la iteración (por ejemplo).
y bucles while ). Si la recursividad es más difícil de emplear que las estructuras de control básicas y es
innecesaria, tal vez debería ignorarse.
Hay varias refutaciones a esto. En primer lugar, la programación recursiva ayuda a los programadores
a pensar de forma recursiva, y el pensamiento recursivo se emplea en todo el mundo de la informática en
áreas como el diseño de compiladores. Segundo,
Resolver problemas con recursividad 165
Machine Translated by Google
algunos lenguajes simplemente requieren recursividad porque carecen de algunas estructuras de control
básicas. Las versiones puras del lenguaje Lisp, por ejemplo, requieren recursividad en casi todas las
funciones no triviales.
Sin embargo, la pregunta sigue siendo: si un programador ha estudiado la recursividad lo
suficiente como para “entenderlo” y está utilizando un lenguaje con todas las funciones como C++,
Java o Python, ¿debería emplearse alguna vez la recursividad? ¿Tiene la recursividad un uso práctico
en estos lenguajes o es sólo un ejercicio mental?
Argumentos en contra de la recursividad
Para explorar esta pregunta, enumeremos las características negativas de la recursividad.
Complejidad conceptual
Para la mayoría de los problemas, es más difícil para el programador promedio resolver un
problema usando la recursividad. Incluso una vez que comprenda la gran idea recursiva, en
la mayoría de los casos seguirá siendo más fácil escribir código utilizando bucles.
Actuación
Las llamadas a funciones generan una sobrecarga significativa. La recursividad implica muchas
llamadas a funciones y, por lo tanto, puede ser lenta.
Requisitos de espacio
La recursividad no emplea simplemente muchas llamadas a funciones; también los anida.
Es decir, puede terminar con una larga cadena de llamadas a funciones esperando a que se
completen otras llamadas. Cada llamada a función que ha comenzado pero que aún no ha
finalizado ocupa espacio adicional en la pila del sistema.
A primera vista, esta lista de características constituye una fuerte acusación contra la
recursividad por considerarla difícil, lenta y derrochadora de espacio. Sin embargo, estos argumentos
no son válidos universalmente. Entonces, la regla más básica para decidir entre recursividad e
iteración es elegir la recursividad cuando estos argumentos no se aplican.
Considere nuestra función que cuenta el número de hojas en un árbol binario.
¿Cómo resolverías este problema sin recursividad? Es posible, pero necesitaría un mecanismo
explícito para mantener el "sendero de ruta de navegación" de los nodos para los cuales los hijos de la
izquierda ya han sido visitados pero no los hijos de la derecha. Estos nodos tendrían que ser revisados
en algún momento para que pudiéramos viajar por el lado derecho. Podrías almacenar estos nodos en
una estructura dinámica, como una pila. A modo de comparación, aquí hay una implementación de la
función que utiliza la clase de pila de la biblioteca de plantillas estándar de C++:
int árbol binario::stackBasedCountLeaves() {
si (_root == NULL) devuelve 0;
int número de hojas = 0;
stack< binaryTreeNode *> nodos;
nodos.push(_root);
mientras ( !nodos.empty()) {
treePtr currentNode = nodes.top();
nodos.pop();
if (currentNode­>left == NULL && currentNode­>right == NULL) leafCount++;
166 Capítulo 6
Machine Translated by Google
demás {
if (currentNode­>right != NULL) nodes.push(currentNode­>right);
if (currentNode­>left != NULL) nodes.push(currentNode­>left);
}
}
devolver recuento de hojas;
}
Este código sigue el mismo patrón que el original, pero si nunca antes ha usado la clase
de pila, es necesario hacer algunos comentarios. La clase pila funciona como la pila del
sistema que analizamos en el Capítulo 3; puedes agregar y eliminar elementos solo en la parte
superior. Tenga en cuenta que podríamos realizar nuestra operación de recuento de hojas
utilizando cualquier estructura de datos que no tenga un tamaño fijo. Podríamos haber
usado un vector, por ejemplo, pero el uso de la pila refleja más directamente el código original.
Cuando declaramos la pila
, especificamos el tipo de elementos que almacenaremos allí. En
este caso, almacenaríamos punteros a nuestra estructura binariaTreeNode
métodos de clase de pila en este código. El método push
. Usamos cuatro
coloca un elemento (un puntero
de nodo, en este caso) en la parte superior de la pila. El método vacío
nos dice si quedan elementos en la pila. El método top
parte superior de la pila, y el método pop
nos da una copia del elemento en la
elimina el elemento superior de la pila.
El código resuelve el problema colocando un puntero al primer nodo de la pila y luego
eliminando repetidamente un puntero a un nodo de la pila, verificando si es una hoja,
incrementando nuestro contador si lo es y colocando punteros a los nodos secundarios. , si
existen, en la pila. Entonces, la pila realiza un seguimiento de los nodos que hemos
descubierto, pero que aún tenemos que procesar, de la misma manera que la cadena de
llamadas recursivas en la versión recursiva realiza un seguimiento de los nodos que debemos
volver a visitar. Al comparar esta versión iterativa con la versión recursiva, vemos que ninguna
de las objeciones estándar a la recursividad se aplica con mucho vigor en este caso. Primero,
este código es más largo y complicado que la versión recursiva, por lo que no hay ningún
argumento en contra de la versión recursiva sobre la base de la complejidad conceptual. En
segundo lugar, observe cuántas llamadas a funciones realiza stackBasedCountLeaves :
para cada visita a un nodo interior (es decir, no una hoja), esta función realiza cuatro
llamadas a funciones: una para vaciar y otra para tapar, y dos para empujar. La versión
recursiva realiza sólo dos llamadas recursivas para cada nodo interior. (Tenga en cuenta que
es posible evitar las llamadas de función al objeto de pila incorporando la lógica de la pila
dentro de la función. Sin embargo, esto aumentaría aún más la complejidad de la función). En
tercer lugar, si bien esta versión iterativa no No utiliza espacio adicional en la pila del sistema,
sino que hace uso explícito de una pila privada. Para ser justos, esto es menos espacio que
la sobrecarga de la pila del sistema de las llamadas recursivas, pero sigue siendo un gasto
de memoria del sistema en proporción a la profundidad máxima del árbol binario que estamos
atravesando.
Debido a que en este caso las objeciones contra la recursividad se mitigan o minimizan,
la recursividad es una buena opción para el problema. En términos más generales, si un
problema es fácil de resolver de forma iterativa, entonces la iteración debería ser su primera opción.
La recursividad debe usarse cuando la iteración sea complicada. A menudo, esto implica la
necesidad del mecanismo de “ruta de ruta de navegación” que se muestra aquí.
Resolver problemas con recursividad 167
Machine Translated by Google
Los recorridos de estructuras ramificadas, como árboles y gráficos, son inherentemente recursivos.
El procesamiento de estructuras lineales, como matrices y listas enlazadas, normalmente no requiere
recursividad, pero existen excepciones. Nunca te equivocarás al intentar por primera vez un problema
mediante la iteración y viendo hasta dónde llegas.
Como último conjunto de ejemplos, considere los siguientes problemas de listas enlazadas.
PROBLEMA: MOSTRAR UNA LISTA VINCULADA EN ORDEN
Escriba una función a la que se le pase el puntero principal de una lista enlazada individualmente donde el
tipo de datos de cada nodo sea un número entero y que muestre esos números enteros, uno por línea, en
el orden en que aparecen en la lista.
PROBLEMA: MUESTRA UNA LISTA VINCULADA EN ORDEN INVERSO
Escriba una función a la que se le pase el puntero principal de una lista enlazada individualmente donde el
tipo de datos de cada nodo sea un número entero y que muestre esos números enteros, uno por línea, en
el orden inverso en que aparecen en la lista.
Debido a que estos problemas son imágenes especulares entre sí, es natural suponer que sus
implementaciones también serían imágenes especulares. De hecho, ese es el caso de las
implementaciones recursivas. Usando listNode y listPtr
tipo dado anteriormente, aquí hay funciones recursivas para resolver ambos problemas:
void displayListForwardsRecursion (listPtr cabeza) {
si (cabeza! = NULL) {
cout << cabeza­>datos << "\n";
displayListForwardsRecursion(cabeza­>siguiente);
}
}
void displayListBackwardsRecursion (listPtr cabeza) {
si (cabeza! = NULL) {
displayListBackwardsRecursion(cabeza­>siguiente);
cout << cabeza­>datos << "\n";
}
}
Como puede ver, el código en estas funciones es idéntico excepto por el orden de las dos
declaraciones dentro de la declaración if . Eso hace toda la diferencia. En el primer caso, mostramos
el valor en el primer nodo
antes de realizar la llamada recursiva para mostrar el resto de la lista
. En el segundo caso, hacemos la llamada para mostrar el resto de la lista
valor en el primer nodo
168 Capítulo 6
antes de mostrar el
. Esto da como resultado una visualización general hacia atrás.
Machine Translated by Google
Dado que ambas funciones son igualmente concisas, se podría suponer que la
recursividad se utiliza correctamente para resolver ambos problemas, pero ese no es el
caso. Para ver eso, veamos implementaciones iterativas de ambas funciones.
void displayListForwardsIterative(listPtr encabezado) {
for (listPtr actual = encabezado; actual != NULL; actual = actual­>siguiente) cout
<< actual­>datos << "\n";
}
void displayListBackwardsIterative(listPtr encabezado) {
apilar nodos<listPtr>;
for (listPtr actual = encabezado; actual! = NULL; actual = actual­>siguiente)
nodos.push(actual);
mientras (!nodos.empty()) {
nodePtr actual = nodos.top();
nodos.pop();
cout << datos actuales­><< "\n";
}
}
La función para mostrar la lista en orden no es más que un bucle transversal
directo , como los que vimos en el Capítulo 4. Sin embargo, la función para mostrar
la lista en orden inverso es más complicada. Adolece del mismo requisito de “sendero
de ruta de navegación” que nuestros problemas de árbol binario. Mostrar los nodos en
una lista vinculada en orden inverso requiere regresar a los nodos anteriores por
definición. En una lista enlazada individualmente, no hay manera de hacerlo usando la
lista misma, por lo que se requiere una segunda estructura. En este caso, necesitamos
otra pila. Después de declarar la pila , empujamos todos los nodos de nuestra lista
enlazada a la pila usando un bucle for . Debido a que se trata de una pila, donde
cada elemento se agrega encima de los elementos anteriores, el primer elemento de la
lista vinculada estará en la parte inferior de la pila y el último elemento de la lista
vinculada estará en la parte superior. Entramos en un bucle while que continúa hasta
que la pila está vacía , tomando repetidamente un puntero al nodo superior de la pila
, eliminando ese puntero de nodo de la pila
y luego mostrando los datos en el
nodo al que se hace referencia . Debido a que los datos en la parte superior son los
últimos datos en la lista vinculada, esto tiene el efecto de mostrar los datos en la lista vinculada en ord
Al igual que con la función de árbol binario iterativo mostrada anteriormente, sería
posible escribir esta función sin usar una pila (construyendo una segunda lista dentro
de la función que sea inversa a la original). Sin embargo, no hay manera de hacer que
la segunda función sea tan simple como la primera o de evitar atravesar efectivamente
dos estructuras en lugar de una. Al comparar las implementaciones recursiva e iterativa,
es fácil ver que la función iterativa “hacia adelante” es tan simple que no existe ninguna
ventaja práctica en emplear la recursividad, y existen varias desventajas prácticas. Por
el contrario, la función recursiva "hacia atrás" es más simple que la versión iterativa
y se debe esperar que funcione aproximadamente tan bien como la versión iterativa.
Por lo tanto, la función “hacia atrás” es un uso razonable de la recursividad, mientras
que la función “hacia adelante”, aunque es un buen ejercicio de programación
recursiva, no es un buen uso práctico de la recursividad.
Resolver problemas con recursividad 169
Machine Translated by Google
Ejercicios
Como siempre, ¡es imperativo probar las ideas presentadas en el capítulo!
6­1. Escribe una función para calcular la suma solo de los números positivos en una matriz de números enteros. Primero,
resuelva el problema mediante iteración. Luego, utilizando la técnica que se muestra en este capítulo,
convierta su función iterativa en una función recursiva.
6­2. Considere una matriz que representa una cadena binaria, donde el valor de datos de cada elemento es 0 o 1.
Escriba una función bool para determinar si la cadena binaria tiene paridad impar (un número impar de 1
bits). Sugerencia: recuerde que la función recursiva devolverá verdadero (impar) o falso (par), no el recuento
de 1 bit.
Resuelva el problema primero usando la iteración y luego la recursividad.
6­3. Escriba una función a la que se le pase una matriz de números enteros y un número "objetivo"
y eso devuelve el número de apariciones del objetivo en la matriz. Resuelva el problema primero usando la
iteración y luego la recursividad.
6­4. Diseñe el suyo propio: encuentre un problema que procese una matriz unidimensional que ya haya resuelto o que
sea trivial para usted en su nivel de habilidad actual, y resuelva el problema (o resuélvalo nuevamente)
usando recursividad.
6­5.
Resuelva el ejercicio 6­1 nuevamente, usando una lista enlazada en lugar de una matriz.
6­6.
Resuelva el ejercicio 6­2 nuevamente, usando una lista enlazada en lugar de una matriz.
6­7.
Resuelva el ejercicio 6­3 nuevamente, usando una lista enlazada en lugar de una matriz.
6­8. Diseñe el suyo propio: intente descubrir un problema de procesamiento de listas enlazadas que sea
Es difícil de resolver mediante iteración, pero se puede resolver directamente mediante recursividad.
6­9. Algunas palabras en programación tienen más de un significado común. En
En el capítulo 4, aprendimos sobre el montón, del cual obtenemos memoria asignada con nueva. El término
montón también describe un árbol binario en el que cada valor de nodo es mayor que cualquiera del subárbol
izquierdo o derecho. Escriba una función recursiva para determinar si un árbol binario es un montón.
6­10. Un árbol de búsqueda binario es un árbol binario en el que el valor de cada nodo es mayor que cualquier valor
en el subárbol izquierdo de ese nodo pero menor que cualquier valor en el subárbol derecho del nodo.
Escriba una función recursiva para determinar si un árbol binario es un árbol de búsqueda binario.
6­11. Escriba una función recursiva a la que se le pase el puntero raíz de un árbol de búsqueda binario y un nuevo valor
para insertar y que cree un nuevo nodo con el nuevo valor, colocándolo en la ubicación correcta para
mantener la estructura del árbol de búsqueda binario.
Sugerencia: considere convertir el parámetro del puntero raíz en un parámetro de referencia.
6­12. Diseñe el suyo propio: considere preguntas estadísticas básicas que pueda formular sobre un conjunto de
valores numéricos, como promedio, mediana, moda, etc. Intente escribir funciones recursivas para calcular
esas estadísticas para un árbol binario de números enteros.
Algunos son más fáciles de escribir que otros. ¿Por qué?
170 Capítulo 6
Machine Translated by Google
RESOLVIENDO PROBLEMAS
CON REUTILIZACIÓN DE CÓDIGO
Y
t
l
S
YYPAG
oh
C
Este capítulo es muy diferente a los
anteriores. En capítulos anteriores, enfaticé
la importancia de encontrar su propia solución
a los problemas. Después de todo, de eso se
trata el libro: de escribir soluciones originales a
problemas de programación. Incluso en capítulos anteriores, sin em
A
DY
Hablé de cómo siempre aprendes de lo que has escrito antes y es por eso que
debes conservar todo el código que escribes para referencia futura. En este
capítulo, iremos un paso más allá y discutiremos cómo usar código e ideas de
otros programadores para resolver nuestros problemas.
Si recuerda cómo empezó este libro, este tema puede parecer una inclusión
extraña. Al principio hablé del error que era intentar resolver problemas complejos
modificando el código de otra persona. Esto no sólo tiene pocas posibilidades de
éxito, sino que incluso cuando tiene éxito, no le proporciona ninguna experiencia
de aprendizaje. Y si esto es todo lo que hace, en realidad nunca se convertirá en
programador y su utilidad en el desarrollo de software será limitada. Dicho esto, una
vez que cualquier problema de programación alcanza un tamaño respetable, no es razonable
Machine Translated by Google
Espere que un programador desarrolle una solución completamente desde cero. Ése es un uso
ineficiente del tiempo del programador y depende demasiado de que el programador sea un experto en
todas las cosas. Además, es más probable que el programa tenga errores o sea difícil de mantener.
Buena reutilización y mala reutilización
Por lo tanto, debemos distinguir entre una buena reutilización, que nos permite escribir mejores programas
y escribirlos más rápidamente, y una mala reutilización, que puede permitirnos pasar por un programador
por un tiempo pero que finalmente conduce a un desarrollo deficiente, tanto del código como del
programador. . La tabla 7­1 resume las diferencias. La columna de la izquierda muestra las propiedades
de una buena reutilización y la columna de la derecha muestra las propiedades de una mala reutilización.
Al considerar si intentar o no reutilizar el código, pregúntese si es más probable que produzca las
propiedades en la columna de la izquierda o en la columna de la derecha.
Tabla 7­1: Reutilización de códigos buenos y malos
Buena reutilización
Mala reutilización
Siguiendo un modelo
Copiar el trabajo de otra persona
Magnifica y amplía tus capacidades
Falsifica tus capacidades
Te ayuda a aprender
Te ayuda a evitar el aprendizaje.
Ahorra tiempo a corto y largo plazo Puede ahorrar tiempo a corto plazo pero puede
alargar el tiempo a largo plazo
Resultados en un programa de trabajo.
Puede resultar en un programa que de todos modos no
funciona
Es importante tener en cuenta que la diferencia entre una buena y una mala reutilización no
reside en qué código reutiliza o cómo lo reutiliza, sino en su relación con el código y los conceptos que
está tomando prestado. Una vez, mientras escribía un trabajo final en una clase de literatura, descubrí
que algo que había aprendido en un curso anterior era relevante para el tema de mi trabajo, así que lo
incluí.
Cuando le envié un borrador de mi artículo a la profesora, ella me dijo que necesitaba una cita para esa
información. Frustrado, le pregunté a mi profesor en qué momento podía simplemente exponer mis
conocimientos en un artículo sin proporcionar una referencia.
Su respuesta fue que podía dejar de hacer referencia a otros por lo que tenía en la cabeza cuando me
volviera tan experto que otros hicieran referencia a mí.
En términos de programación, una buena reutilización ocurre cuando usted mismo escribe el código.
basado en la lectura de la descripción de alguien de un concepto general o cuando hace uso de código
que podría haber escrito usted mismo. A lo largo de este capítulo, hablaremos sobre cómo puede
apropiarse de los conceptos de codificación para estar seguro de que su reutilización lo ayudará a
convertirse en un mejor programador, no en uno más vago.
Permítanme llamar también la atención sobre la última fila del Cuadro 7­1. Los intentos de mala
reutilización a menudo fracasan por completo. Esto no es sorprendente, porque se trata de un
programador que utiliza un código que en realidad no comprende. En algunas situaciones, el código
prestado funcionará inicialmente, pero cuando el programador
172 Capítulo 7
Machine Translated by Google
Cuando se intenta modificar o ampliar el código base prestado, la falta de comprensión profunda
elimina la posibilidad de un enfoque organizado. Luego, el programador recurre a la agitación y al
ensayo y error, violando así la primera y más importante de nuestras reglas generales de
resolución de problemas: tener siempre un plan.
Revisión de los fundamentos de los componentes
Ahora que sabemos el tipo de reutilización que buscamos, categoricemos las diferentes formas
en que se puede reutilizar el código. En este libro, usaré el término componente para referirme a
cualquier cosa creada por un programador que pueda ser reutilizada por otro para ayudar a
resolver un problema de programación. Los componentes pueden existir en cualquier parte del
continuo, desde lo abstracto a lo concreto, desde una idea hasta un código completamente
implementado. Si pensamos que resolver un problema de programación es algo análogo a
abordar un proyecto de personal de mantenimiento, las técnicas que hemos aprendido para
resolver problemas son como herramientas y los componentes son como piezas especiales.
Cada uno de los siguientes componentes es una forma diferente de reutilizar el trabajo anterior de
los programadores.
Bloque de código
Un bloque de código es simplemente eso: un bloque de código que ha sido copiado de una lista
de programas a otra. De manera más coloquial, lo llamaríamos un trabajo de copiar y pegar. Esta
es la forma más baja de uso de componentes y, a menudo, es una mala reutilización, con todos los
problemas que ello implica. Por supuesto, si el código que está copiando es suyo, no hay ningún
daño real, excepto que podría considerar empaquetar el código existente como una biblioteca de
clases u otra estructura para permitir su reutilización de una manera más limpia y más fácil de
mantener.
Algoritmos
Un algoritmo es una receta de programación; es un método particular para lograr una meta y se
expresa en lenguaje sencillo o gráficamente como en un diagrama de flujo. Por ejemplo, en el
Capítulo 3, analizamos la operación de clasificación para matrices y las diferentes formas en que
se podría lograr esta clasificación. Un método para ordenar una matriz es el algoritmo de ordenación
por inserción, y mostré una implementación de muestra del algoritmo. Es importante tener en
cuenta que el código proporcionado fue una implementación de la ordenación por inserción,
pero la ordenación por inserción es el algoritmo en sí (esa forma de ordenar una matriz) y no el
código en particular. La ordenación por inserción funciona tomando repetidamente el siguiente
valor no ordenado en la matriz y desplazando los valores ordenados “hacia arriba” una posición
hasta que hayamos hecho un agujero en la posición correcta para el valor que estamos insertando
actualmente. Cualquier código que utilice este método para ordenar una matriz es un ordenamiento
por inserción.
Los algoritmos son una forma de reutilización de alto nivel y generalmente conducen a buenas
propiedades de reutilización. Los algoritmos son esencialmente sólo ideas y usted, el programador,
debe implementar las ideas, recurriendo a sus habilidades de programación y su profundo
conocimiento del algoritmo en sí. Los algoritmos que comúnmente
Resolver problemas con la reutilización de código 173
Machine Translated by Google
su uso está bien estudiado y tiene un rendimiento predecible en diversas situaciones.
Con un algoritmo como modelo, puede tener confianza en la exactitud de su código y en su rendimiento.
Sin embargo, existen algunas desventajas potenciales al basar el código en un algoritmo.
Cuando utilizas un algoritmo, estás comenzando en el nivel conceptual.
Por lo tanto, le queda un largo camino por recorrer hasta llegar al código terminado para esa sección
del programa. El algoritmo ciertamente ahorra tiempo, porque el aspecto de resolución de problemas
es esencialmente completo, pero dependiendo del algoritmo y su aplicación particular en su
programación, la implementación del algoritmo puede no ser trivial.
Patrones
En programación, un patrón (o patrón de diseño) es una plantilla para una técnica de programación
particular. El concepto está relacionado con un algoritmo pero es distinguible. Los algoritmos son
como recetas para resolver problemas particulares, mientras que los patrones son técnicas generales
utilizadas en situaciones de programación particulares.
Los problemas que resuelven los patrones suelen estar dentro de la estructura del propio código. Por
ejemplo, en el Capítulo 6 analizamos el problema que presenta una función recursiva en una clase
de lista enlazada: la función recursiva necesitaba el puntero "encabezado" al primer nodo de la lista
como parámetro, pero esos datos debían permanecer privados. La solución fue crear un
contenedor, una función que adaptaría una lista de parámetros a otra. La técnica del envoltorio es
un patrón de diseño. Podemos usar este patrón para resolver el problema de una función recursiva en
una clase, pero también se puede usar de otras maneras. Por ejemplo, supongamos que tenemos
una clase LinkedList que permite insertar o eliminar elementos en cualquier punto de la lista, pero lo
que necesitábamos es una clase de pila, es decir, una lista que permite la inserción y eliminación solo
en un extremo. Podríamos crear una nueva pila de clases que tuviera métodos públicos para las
operaciones típicas de la pila, como push
y pop. Estos métodos simplemente llamarían a funciones miembro en la lista vinculada
objeto que era un miembro de datos privados de nuestra clase de pila . De esta manera, reutilizaríamos
la funcionalidad de una clase de lista vinculada y al mismo tiempo proporcionaríamos la interfaz de una
clase de pila.
Al igual que los algoritmos, los patrones son una forma de uso de componentes de alto nivel, y
aprender patrones es una excelente manera de desarrollar su caja de herramientas de programación.
Sin embargo, los patrones comparten algunos de los problemas potenciales de los algoritmos. Saber
que existe un patrón no es lo mismo que saber cómo implementar un patrón en el lenguaje particular
que ha elegido para una solución de programación, y los patrones suelen ser difíciles de
implementar correctamente o con el máximo rendimiento. Por ejemplo, existe un patrón conocido
como singleton, que es una clase que permite crear solo un objeto de la clase. Crear una clase
singleton es sencillo, pero crear una clase singleton que no crea el único objeto de instancia permitido
hasta que realmente se necesita puede ser sorprendentemente difícil, y la mejor técnica puede
variar de un idioma a otro.
174 Capítulo 7
Machine Translated by Google
Tipos de datos abstractos
Un tipo de datos abstracto, como analizamos en el Capítulo 5, es un tipo definido por sus
operaciones, no por cómo se implementan esas operaciones. El tipo pila, que hemos utilizado varias
veces en este libro, es un buen ejemplo. Los tipos de datos abstractos son como patrones en el sentido
de que definen los efectos de las operaciones, pero no definen específicamente cómo se implementan
esas operaciones. Sin embargo, al igual que ocurre con los algoritmos, existen técnicas de
implementación bien conocidas para estas operaciones. Por ejemplo, una pila se puede implementar
utilizando cualquier cantidad de estructuras de datos subyacentes, como una lista vinculada o una
matriz. Sin embargo, una vez que tomamos la decisión de utilizar una estructura de datos particular, a
veces las decisiones de implementación ya están tomadas. Supongamos que implementamos una
pila usando una lista vinculada y no podemos ajustar una lista vinculada existente, pero debemos
escribir nuestro propio código de lista. Debido a que la pila es una estructura de último en entrar,
primero en salir, solo tiene sentido para nosotros insertar y eliminar elementos en un extremo de la lista
vinculada. Además, sólo tiene sentido insertar y eliminar al principio de la lista. En teoría, podría insertar
y eliminar al final, pero esto daría como resultado un recorrido ineficiente de toda la lista para cada
inserción o eliminación. Para evitar esos recorridos se necesitaría una lista doblemente enlazada con
un puntero separado al último nodo de la lista. Insertar y eliminar al principio de la lista permite la
implementación más simple y eficiente, por lo que las implementaciones de pilas de listas vinculadas
se implementan casi todas de la misma manera.
Por lo tanto, aunque el tipo de datos abstracto en abstracto significa que el tipo es conceptual y
sin detalles de implementación, en la práctica, cuando elige implementar un tipo de datos abstracto en
su código, no estará descubriendo la implementación desde cero. Más bien, tendrá implementaciones
existentes del tipo como guías.
Bibliotecas
En programación, una biblioteca es una colección de fragmentos de código relacionados. Una
biblioteca normalmente incluye el código en forma compilada, junto con las declaraciones del código
fuente necesarias. Las bibliotecas pueden incluir funciones independientes, clases, declaraciones de
tipos o cualquier otra cosa que pueda aparecer en el código. En C++, los ejemplos más obvios son
las bibliotecas estándar. La función strcmp que usamos en capítulos anteriores proviene de la antigua
biblioteca C cstring, las clases contenedoras como vector provienen de la biblioteca de plantillas
estándar de C++, e incluso el NULL que hemos usado en todo nuestro código basado en punteros no
es parte de la Lenguaje C++ en sí, pero definido en un archivo de encabezado de biblioteca, stdlib.h.
Debido a que las bibliotecas contienen tantas funciones básicas, su uso es inevitable en la
programación moderna.
Generalmente, el uso de la biblioteca es una buena reutilización de código. El código se incluye
en una biblioteca porque proporciona una funcionalidad que comúnmente se necesita en una
variedad de programas; el código de la biblioteca ayuda a los programadores a evitar "reinventar la rueda".
Sin embargo, como programadores en desarrollo, cuando utilizamos código de biblioteca, debemos
esforzarnos por aprender de la experiencia y no simplemente tomar un atajo. Veremos un ejemplo de
esto más adelante en el capítulo.
Resolver problemas con la reutilización de código 175
Machine Translated by Google
Tenga en cuenta que, si bien muchas bibliotecas son de uso general, otras están
diseñadas como interfaces de programación de aplicaciones (API) que brindan al
programador de lenguajes de alto nivel una vista simplificada o más coherente de una
plataforma subyacente. Por ejemplo, el lenguaje Java incluye una API llamada JDBC, que
proporciona clases que permiten a los programas interactuar con bases de datos relacionales
de forma estándar. Otro ejemplo es DirectX, que proporciona a los programadores de juegos
de Microsoft Windows una amplia funcionalidad con sonido y gráficos. En ambos casos, la
biblioteca proporciona una conexión entre el programa de alto nivel y el hardware y software
de nivel básico: el motor de base de datos en el caso de JDBC y el hardware de gráficos y
sonido en el caso de DirectX. Además, en ambos casos, la reutilización del código no sólo
es buena: es, a todos los efectos prácticos, necesaria. Un programador de bases de datos
en Java o un programador de gráficos que escriba código C++ para Windows hará uso de
una API; si no estas API, entonces algo más, pero el programador no va a crear una
nueva conexión a la plataforma. desde cero.
Conocimiento de los componentes de construcción
Los componentes son tan útiles que los programadores los utilizan siempre que sea posible.
Sin embargo, para utilizar un componente para ayudar a resolver un problema, un programador
debe conocer su existencia. Dependiendo de cuán finamente los defina, los componentes
disponibles pueden ser cientos o incluso miles, y un programador principiante estará
expuesto a solo unos pocos de ellos. Por lo tanto, un buen programador siempre debe
agregar conocimientos sobre componentes a su conjunto de herramientas. Esta recopilación
de conocimientos se produce de dos maneras diferentes: un programador puede asignar
tiempo explícitamente para aprender nuevos componentes como una tarea general, o el
programador puede buscar un componente para resolver un problema específico. Llamaremos
al primer enfoque aprendizaje exploratorio y al segundo aprendizaje según sea necesario.
Para desarrollarse como programador, deberá emplear ambos enfoques. Una vez que haya
dominado la sintaxis del lenguaje de programación elegido, descubrir nuevos componentes
es una de las principales formas de mejorar como programador.
Aprendizaje exploratorio
Comencemos con un ejemplo de aprendizaje exploratorio. Supongamos que quisiéramos
aprender más sobre patrones de diseño. Afortunadamente, existe un acuerdo general sobre
qué patrones de diseño son los más útiles o los más utilizados, por lo que podríamos
comenzar con cualquier cantidad de recursos sobre este tema y estar bastante seguros de
que no nos estamos perdiendo nada importante. Nos beneficiaríamos simplemente
encontrando una lista de patrones de diseño y estudiándola, pero obtendríamos más
información si implementáramos algunos de los patrones.
Un patrón que encontraremos en una lista típica se llama estrategia o política. Este es el
idea de permitir que un algoritmo, o parte de un algoritmo, sea elegido en tiempo de
ejecución. En su forma más pura, la forma de estrategia, este patrón permite cambiar cómo
opera una función o método pero no altera el resultado. Por ejemplo, un método de una
clase que ordena sus datos, o implica ordenar datos, podría permitir que
176 Capítulo 7
Machine Translated by Google
metodología de clasificación (clasificación rápida o clasificación por inserción, por ejemplo)
que se elija. El resultado es el mismo en cualquier caso (datos ordenados), pero permitir
que el cliente elija la metodología de clasificación podría ofrecer beneficios de rendimiento.
Por ejemplo, el cliente podría evitar el uso de clasificación rápida para datos con una alta
tasa de duplicados. En la forma de póliza, la elección del cliente afecta el resultado. Por
ejemplo, supongamos que una clase representa una mano de naipes. La política de
clasificación podría determinar si los ases se consideran altos (por encima de un rey) o bajos (menos de un 2
Poner el aprendizaje en práctica
Al leer ese párrafo, ahora sabe cuál es el patrón de estrategia/política, pero no lo ha hecho
suyo. Es la diferencia entre buscar herramientas en la ferretería y comprar una y usarla. Así
que saquemos este patrón de diseño del estante y pongámoslo en práctica. La forma más
rápida de probar una nueva técnica es incorporarla al código que ya ha escrito.
Creemos un problema que pueda resolverse usando este patrón y que se base en el código
que ya hemos escrito.
PROBLEMA: EL PRIMER ALUMNO
En una escuela en particular, cada clase tiene un "primer estudiante" designado que es responsable de mantener el orden en el aula si
el maestro tiene que salir del salón. Originalmente, este título se otorgaba al estudiante con la calificación más alta, pero ahora algunos
maestros piensan que el primer estudiante debería ser el estudiante con mayor antigüedad, lo que significa el número de identificación
de estudiante más bajo, ya que se asignan secuencialmente. Otra facción de profesores piensa que la tradición del primer estudiante es
tonta y pretende protestar simplemente eligiendo al estudiante cuyo nombre aparece primero en la lista alfabética de la clase. Nuestra
tarea es modificar la clase de colección de estudiantes, agregando un método para recuperar al primer estudiante de la colección, al
mismo tiempo que adaptamos los criterios de selección de los distintos grupos de maestros.
Como puede ver, este problema empleará la forma política del patrón. Queremos que
nuestro método que devuelve al primer estudiante devuelva un estudiante diferente según un
criterio elegido. Para que esto suceda en C++, usaremos punteros de función. Hemos
visto brevemente este concepto en acción en el Capítulo 3 con la función qsort , que toma un
puntero a una función que compara dos elementos de la matriz que se va a ordenar.
Haremos algo similar aquí; Tendremos un conjunto de funciones de comparación que toma
dos de nuestros objetos StudentRecord y determina si el primer estudiante es "mejor" que
el segundo observando las calificaciones, los números de identificación o los nombres de los
estudiantes.
Para comenzar, necesitamos definir un tipo para nuestras funciones de comparación:
typedef bool
(* firstStudentPolicy)(studentRecord r1, StudentRecord r2);
Esta declaración crea un tipo llamado firstStudentPolicy como puntero a un
función que devuelve un bool y toma dos parámetros de tipo StudentRecord.
Los paréntesis alrededor de * firstStudentPolicy
son necesarios para evitar
Resolver problemas con la reutilización de código 177
Machine Translated by Google
declaración sea interpretada como una función que devuelve un puntero a un bool. Con
esta declaración implementada, podemos crear nuestras tres funciones de política:
bool calificaciónsuperior(registroestudiante r1, registroestudiante r2)
{ return r1.grado() > r2.grado();
} bool lowerNumeroEstudiante(RegistroEstudiante r1, RegistroEstudiante r2) {
return r1.studentID() < r2.studentID();
} bool nombreComesFirst(studentRecord r1, StudentRecord r2) {
return strcmp(r1.name().c_str() , r2.name().c_str() ) < 0;
}
Las dos primeras funciones son muy simples: mayorCalificación devuelve verdadero
cuando el primer registro tiene la calificación más alta, y menorNúmero de Estudiante
devuelve verdadero cuando el primer registro tiene el número de estudiante menor. La
tercera función, nameComesFirst, es esencialmente la misma, pero requiere la función
de biblioteca strcmp , que espera dos cadenas “estilo C”, es decir, matrices de
caracteres terminadas en nulo en lugar de objetos de cadena . Entonces tenemos que
invocar el método c_str() en las cadenas de nombres en ambos registros de
estudiantes. La función strcmp devuelve un número negativo cuando la primera cadena
va antes de la segunda alfabéticamente, por lo que verificamos el valor de retorno
para ver si es menor que cero . Ahora estamos listos para modificar la propia clase StudentCollection :
clase StudentCollection
{ privado:
estructura StudentNode
{ StudentRecord StudentData;
StudentNode * siguiente;
};
público:
colección de estudiantes();
~ColecciónEstudiante();
Colección de estudiantes(const Colección de estudiantes
&copiar); Colección de estudiantes& operador=(const Colección de
estudiantes &rhs); void addRecord(studentRecord
nuevoEstudiante); estudianteRecord
recordWithNumber(int IDnum); void removeRecord(int IDnum);
void setFirstStudentPolicy(primeraPolicíaEstudiante f);
studentRecord primerEstudiante();
privado:
firstStudentPolicy _currentPolicy; typedef
nodoestudiante * lista de estudiantes; lista
de estudiantes _listHead;
void eliminarLista(listadeestudiantes &listPtr);
lista de estudiantes lista copiada (copia constante de lista
de estudiantes); };
178 Capítulo 7
Machine Translated by Google
Esta es la declaración de clase que vimos en el Capítulo 5 con tres nuevos miembros: un
miembro de datos privados, _currentPolicy
de política; un método setFirstStudentPolicy
, para almacenar un puntero a una de nuestras funciones
para cambiar esta política; y el método firstStudent en sí
, que devolverá el primer estudiante de acuerdo con la política actual. El código para
setFirstStudentPolicy es simple:
void StudentCollection::setFirstStudentPolicy(primeraPolicíaEstudiante f) {
_PolíticaActual = f;
}
También necesitamos modificar el constructor predeterminado para inicializar la política actual:
Colección de estudiantes::Colección de estudiantes() {
_listHead = NULL;
_CurrentPolicy = NULL;
}
Ahora estamos listos para escribir primeroEstudiante:
StudentRecord StudentCollection::primerEstudiante() {
if (_listHead == NULL || _currentPolicy == NULL) {
StudentRecord dummyRecord(­1, ­1, "");
devolver registro ficticio;
}
StudentNode * loopPtr = _listHead;
studentRecord primero = loopPtr­>studentData;
buclePtr = buclePtr­>siguiente;
mientras (buclePtr! = NULL) {
if ( _currentPolicy(loopPtr­>studentData, primero)) {
primero = loopPtr­>studentData;
}
loopPtr = loopPtr­>siguiente;
}
regresar primero;
}
El método comienza comprobando casos especiales. Si no hay una lista para revisar o no existe
una política vigente
, devolvemos un registro ficticio. De lo contrario, recorremos la lista para
encontrar al estudiante que mejor cumpla con la política actual, utilizando las técnicas de búsqueda
básicas que hemos estado utilizando a lo largo de este libro. Asignamos el registro al principio de la
lista al primer
, comenzamos nuestra variable de bucle en el segundo registro de la lista
y
comenzamos el recorrido. Dentro del bucle transversal, una llamada a la función de política actual
nos dice si el estudiante que estamos observando actualmente es "mejor" que el mejor estudiante que
hemos encontrado hasta ahora, según el criterio actual. Cuando finaliza el ciclo, devolvemos el “primer
estudiante”
.
Resolver problemas con la reutilización de código 179
Machine Translated by Google
Análisis de la primera solución estudiantil.
Habiendo resuelto un problema utilizando el patrón estrategia/política, es mucho más
probable que reconozcamos situaciones en las que se puede emplear la técnica que si
hubiéramos leído sobre la técnica una vez y nunca la hubiéramos usado. También
podemos analizar nuestro problema de muestra para empezar a formarnos nuestra propia
opinión sobre el valor de la técnica, cuándo se puede emplear correctamente y cuándo
podría ser un error, o al menos generar más problemas de los que vale la pena. Una idea
que se le puede haber ocurrido acerca de este patrón en particular es que debilita la
encapsulación y el ocultamiento de información. Por ejemplo, si el código del cliente
proporciona las funciones de política, requiere acceso a tipos que normalmente
permanecerían internos a la clase, en este caso, el tipo StudentRecord .
(Consideraremos una solución a este problema en los ejercicios). Esto significa que el
código del cliente podría fallar si alguna vez modificamos ese tipo, y debemos sopesar
esta preocupación con los beneficios del patrón antes de aplicarlo en otros proyectos.
En capítulos anteriores, analizamos cómo saber cuándo usar una técnica (o cuándo no
usarla) es tan importante como saber cómo usarla. Al examinar su propio código,
obtendrá información sobre esta cuestión crítica.
Para practicar más, puede revisar su biblioteca de proyectos completados en busca
de código que pueda refactorizarse utilizando esta técnica. Recuerde que gran parte de
la programación del “mundo real” implica complementar o modificar una base de código
existente, por lo que esta es una práctica excelente para dichas modificaciones, además
de desarrollar su habilidad con el componente en particular. Además, uno de los
beneficios de una buena reutilización de código es que aprendemos de él, y esta
práctica maximiza el aprendizaje.
Aprendizaje según sea necesario
La sección anterior describió lo que podríamos llamar “aprender a través de la
deambulación”. Si bien estos viajes son valiosos para los programadores, hay otros
momentos en los que debemos avanzar hacia un objetivo particular. Si está trabajando
en un problema particular, especialmente si está trabajando contra algún tipo de fecha
límite, y sospecha que un componente podría ser de gran ayuda para usted, no querrá
vagar al azar por el mundo de programación y espero que encuentres lo que necesitas.
En lugar de ello, desea encontrar el componente o componentes que se apliquen
directamente a su situación lo más rápido posible. Sin embargo, eso suena muy
complicado: ¿cómo encuentras lo que necesitas cuando no sabes exactamente lo que
estás buscando? Considere el siguiente problema de muestra:
PROBLEMA: RECORRIDO EFICIENTE
Un proyecto de programación utilizará su clase StudentCollection . El código del cliente necesita
la capacidad de atravesar a todos los estudiantes de la colección. Obviamente, para mantener la
información oculta, no se puede dar acceso directo al código del cliente a la lista, pero es un
requisito que los recorridos sean eficientes.
180 Capítulo 7
Machine Translated by Google
Debido a que la palabra clave en esta descripción es eficiente, seamos precisos acerca de
lo que eso significa en este caso. Supongamos que un objeto particular de nuestra clase
StudentCollection tiene 100 estudiantes. Si tuviéramos acceso directo a la lista vinculada,
podríamos escribir un bucle para recorrer la lista que se repetiría 100 veces. Eso es lo más
eficiente que puede ser cualquier recorrido de lista. Cualquier solución que requiera que
realicemos un bucle más de 100 veces para determinar el resultado sería ineficiente.
Sin el requisito de eficiencia, podríamos intentar resolver el problema agregando un
método recordAt simple a nuestra clase que devolvería el registro del estudiante en una
posición particular de la colección, numerando el primer registro como 1:
StudentRecord StudentCollection::recordAt(int posición) {
StudentNode * loopPtr = _listHead;
int yo = 1;
while (loopPtr! = NULL && i < posición) {
yo ++;
loopPtr = loopPtr­>siguiente;
}
si (buclePtr == NULL) {
studentRecord dummyRecord(­1, ­1, "");
devolver registro ficticio;
} demás {
bucle de retornoPtr­>studentData;
}
}
En este método, usamos un bucle
para recorrer la lista hasta llegar a la posición
deseada o llegar al final de la lista. Al final del ciclo, si se ha llegado al final de la lista, creamos
y devolvemos un registro ficticio
, o devolvemos el registro en la posición especificada
. El
problema es que estamos realizando un recorrido simplemente para encontrar un registro de
estudiante. Esto no es necesariamente un recorrido completo, porque nos detendremos
cuando alcancemos la posición deseada, pero es un recorrido de todos modos. Supongamos
que el código del cliente intenta promediar las calificaciones de los estudiantes:
int calificaciónTotal = 0;
for (int recNum = 1; recNum <= numRecords; recNum++) {
studentRecord temp = sc.recordAt(recNum);
calificaciónTotal += temp.calificación();
}
doble promedio = (doble) calificaciónTotal / numRecords;
Para este segmento de código, supongamos que sc es una colección de estudiantes
previamente declarada y poblada y recNum es un int que almacena el número de registros.
Supongamos que recNum es 100. Si echa un vistazo a este código, podría parecer que
calcular el promedio requiere solo 100 viajes a través del bucle, pero dado que cada llamada
a recordAt es en sí misma un recorrido parcial de la lista, este código implica 100 recorridos.
Resolver problemas con la reutilización de código
181
Machine Translated by Google
cada uno de los cuales implicará realizar bucles unas 50 veces en el caso promedio. Entonces, en
lugar de 100 pasos, lo que sería eficiente, esto podría requerir alrededor de 5000 pasos, lo cual es
muy ineficiente.
Cuándo buscar un componente
Hemos llegado ahora al verdadero problema. Proporcionar acceso de cliente a los miembros de
la colección para realizar recorridos es fácil; proporcionar dicho acceso de manera eficiente no lo es.
Por supuesto, podríamos intentar resolver este problema utilizando sólo nuestra propia capacidad
de resolución de problemas, pero alcanzaríamos la solución mucho más rápido si pudiéramos utilizar
un componente. El primer paso para encontrar un componente previamente desconocido que pueda
ayudar a nuestra solución es asumir que dicho componente realmente existe. Dicho de otra manera,
no encontrará un componente a menos que comience a buscarlo.
Por lo tanto, para maximizar el beneficio de los componentes, es necesario estar atento a
situaciones en las que puedan ayudar. Cuando se encuentre atascado en algún aspecto del problema,
intente lo siguiente:
1. Replantee el problema de manera genérica.
2. Pregúntese: ¿Es probable que este sea un problema común?
El primer paso es importante porque si planteamos nuestro problema como “Permitir que el
código del cliente calcule eficientemente la calificación promedio de un estudiante en una lista
vinculada de registros encapsulados en una clase”, parece que es específico de nuestra situación.
Sin embargo, si planteamos el problema como "Permitir que el código del cliente atraviese
eficientemente una lista vinculada sin proporcionar acceso directo a los punteros de la lista",
entonces comenzamos a comprender que este podría ser un problema común. Seguramente,
podríamos preguntarnos, tan a menudo como los programas almacenan listas enlazadas y otras
estructuras a las que se accede secuencialmente dentro de las clases, ¿otros programadores deben
haber descubierto formas de permitir el acceso eficiente a cada elemento de la estructura?
Encontrar un componente
Ahora que hemos acordado buscar, es hora de encontrar nuestro componente. Para aclarar las
cosas, reformulemos el problema de programación original como un problema de investigación:
"Encuentre un componente que podamos usar para modificar nuestra clase StudentCollection para
permitir que el código del cliente atraviese eficientemente la lista interna". ¿ Cómo solucionamos
este problema? Podríamos comenzar analizando cualquiera de nuestros tipos de componentes:
patrones, algoritmos, tipos de datos abstractos o bibliotecas.
Supongamos que empezamos mirando las bibliotecas estándar de C++. No necesariamente
estaríamos buscando una clase para "conectarnos" a nuestra solución, sino que podríamos
extraer una clase de biblioteca que fuera similar a nuestra colección de estudiantes .
clase para ideas. Esto emplea la estrategia de analogía que utilizamos para resolver problemas de
programación. Si encontramos una clase que tiene un problema análogo, podemos tomar prestada
su solución análoga. Nuestra exposición previa a la biblioteca C++ nos ha puesto en contacto con
sus clases contenedoras, como vector, y debemos buscar la clase contenedora que más se parezca
a nuestra clase de colección de estudiantes. Si vamos a una referencia de C++ favorita, ya sea un
libro o un sitio en la Web, y revisamos las clases contenedoras de C++, vemos que hay un "contenedor
de secuencias" llamado lista .
182 Capítulo 7
Machine Translated by Google
eso encaja a la perfección. ¿La clase de lista permite un recorrido eficiente por el código del cliente? Lo hace,
utilizando un objeto conocido como iterador. Vemos que la clase de lista proporciona métodos de inicio y fin
que producen iteradores, que son objetos que pueden hacer referencia a un elemento particular en la lista y
ser incrementados para hacer que el iterador haga referencia al siguiente objeto en la lista. Si integerList es
una lista<int>, llena de números enteros, e iter es una lista<int>::iterator, entonces podríamos mostrar
todos los números enteros en la lista con lo siguiente:
iter = intList.begin();
mientras (iter != intList.end()) {
cout << *iter << "\n";
iter++;
}
Mediante el uso del iterador, la clase de lista ha resuelto el problema de proporcionar un mecanismo al
cliente para recorrer la lista de manera eficiente. En este punto, podríamos pensar en colocar la clase de lista
en nuestra colección de estudiantes.
clase, reemplazando nuestra lista vinculada hecha en casa. Entonces podríamos crear un comienzo y un final.
métodos para nuestra clase que incluirían los mismos métodos del objeto de lista incrustado y el problema se
resolvería. Esto, sin embargo, lleva directamente a la cuestión de la reutilización buena o mala. Una vez
que comprendamos completamente el concepto de iterador y podamos reproducirlo por nuestra cuenta en
nuestro propio código, conectar una clase existente de la Biblioteca de plantillas estándar a nuestro código
será una buena opción, quizás la mejor opción. Si no podemos hacer eso, usar la clase lista se convierte en
un atajo que no nos ayuda a crecer como programadores.
A veces, por supuesto, debemos aprovechar componentes que no podríamos reproducir, pero si caemos en
el hábito de depender de otros programadores para resolver nuestros problemas, corremos el riesgo de no
convertirnos nunca en solucionadores de problemas.
Así que implementemos el iterador nosotros mismos. Pero antes de hacer eso, veamos brevemente
otras formas en que podríamos haber llegado al mismo lugar. Comenzamos la búsqueda en las bibliotecas de
plantillas estándar, pero podríamos haber comenzado en otro lugar. Por ejemplo, podríamos haber buscado
en una lista de patrones de diseño comunes. Bajo el título de “patrones de comportamiento”,
encontraríamos el patrón iterador , en el que al cliente se le permite acceso secuencial a una colección de
elementos sin exponer la estructura subyacente de la colección.
Esto es exactamente lo que necesitamos, pero sólo podríamos haberlo encontrado buscando en una lista
de patrones o recordándolo de investigaciones previas de patrones. Podríamos haber comenzado nuestra
búsqueda con tipos de datos abstractos porque las listas en general, y las listas enlazadas en particular, son
tipos de datos abstractos comunes.
Sin embargo, muchas discusiones e implementaciones del tipo de datos abstractos de lista no consideran que
el recorrido de la lista de clientes sea una operación básica, por lo que el concepto de iterador nunca
surge. Finalmente, si comenzamos nuestra búsqueda en el área de algoritmos, es poco probable que
encontremos algo útil. Los algoritmos tienden a describir código complicado y el código para crear un
iterador es bastante simple, como veremos pronto. En este caso, entonces, la biblioteca de clases era la ruta
más rápida hacia nuestro destino, seguida de los patrones. Sin embargo, como regla general, debe considerar
todos los tipos de componentes cuando busque un componente útil.
Resolver problemas con la reutilización de código 183
Machine Translated by Google
Aplicar el componente
Ahora sabemos que vamos a crear un iterador para nuestra clase StudentCollection , pero lo único
que nos ha mostrado la lista de clases de biblioteca estándar es cómo funcionan externamente
los métodos de iterador. Si nos quedamos atascados en la implementación, podríamos considerar
revisar la lista de código fuente y sus clases antecesoras, pero dada la dificultad de leer grandes
extensiones de código desconocido, esa es una medida de último recurso.
En cambio, pensemos en cómo superar esto. Usando el ejemplo de código anterior como guía,
podemos decir que un iterador está definido por cuatro operaciones centrales:
1. Un método en la clase de colección que proporciona un iterador que hace referencia al primer
elemento de la colección. En la clase de lista , esto fue el comienzo.
2. Un mecanismo para probar si el iterador ha avanzado más allá del último elemento de la
colección. En el ejemplo anterior, este era un método llamado fin en la clase de lista que
producía un objeto iterador especial para realizar la prueba.
3. Un método en la clase de iterador que mueve el iterador para que haga referencia al siguiente
elemento de la colección. En el ejemplo anterior, este era el operador ++ sobrecargado .
4. Un método en la clase iterador que devuelve el objeto al que se hace referencia actualmente.
elemento de la colección. En el ejemplo anterior, este era el operador * (prefijo) sobrecargado.
En términos de escribir el código, nada aquí parece difícil. Es solo una pregunta­
ción de poner todo en el lugar correcto. Entonces empecemos. Según las descripciones
anteriores, nuestro iterador, al que llamaremos scIterator, necesita almacenar una referencia a un
elemento en StudentCollection y debe poder avanzar al siguiente elemento. Por lo tanto, nuestro
iterador debería almacenar un puntero a un nodo de estudiante.
Eso le permitirá devolver el StudentRecord contenido en él, así como avanzar al siguiente
StudentNode. Por lo tanto, la sección privada de la clase iteradora tendrá este miembro de datos:
colecciónestudiante::nodoestudiante * actual;
De inmediato, tenemos un problema. El tipo StudentNode se declara dentro
una sección privada de StudentCollection y, por lo tanto, la línea anterior no funcionará. Nuestro
primer pensamiento es que quizás StudentNode no debería haberse declarado de forma
privada, pero esa no es la respuesta correcta. El tipo de nodo es inherentemente privado porque
no queremos que el código de cliente aleatorio dependa de una implementación particular del
tipo de nodo, creando así código que podría romperse si modificamos nuestra clase. Sin embargo,
debemos permitir el acceso de scIterator a nuestro tipo privado. Lo hacemos con una declaración
de amigo . En la sección pública de StudentCollection, agregamos:
scIterador de clase amiga;
184 Capítulo 7
Machine Translated by Google
Ahora scIterator puede acceder a las declaraciones privadas dentro de StudentCollection,
incluida la declaración de StudentNode. También podemos declarar algunos
constructores:
scIterador::scIterador() {
actual = NULO;
}
scIterator::scIterator(colecciónestudiante::nodoestudiante * inicial) {
actual = inicial;
}
Saltemos a la colección de estudiantes por un segundo y escribamos nuestro inicio.
método: un método que devuelve un iterador que hace referencia al primer elemento de nuestra
colección. Siguiendo el esquema de nombres que he usado en este libro, este método debe
tener un sustantivo como nombre, como firstItemIterator:
scIterator StudentCollection::firstItemIterator() {
devolver scIterator(_listHead);
}
Como puede ver, todo lo que tenemos que hacer aquí es introducir el puntero principal de
la lista vinculada en un objeto scIterator y devolverlo. Si eres como yo, ver los punteros volando
por aquí puede ponerte un poco nervioso, pero ten en cuenta que scIterator simplemente
mantendrá una referencia a un elemento en la lista StudentCollection . No asignará memoria
propia y, por lo tanto, no necesitamos preocuparnos por la copia profunda ni por los operadores
de asignación sobrecargados.
Volvamos a scIterator y escribamos nuestros otros métodos. Necesitamos un método
para avanzar el iterador al siguiente elemento, así como un método para determinar si hemos
pasado el final de la colección. Deberíamos pensar en ambas cosas al mismo tiempo. Al hacer
avanzar el iterador, necesitamos saber qué valor debe tener el iterador cuando pasa más allá del
último nodo de la lista.
Si no hacemos nada especial, el iterador naturalmente obtendrá el valor NULL, por lo que sería
el valor más fácil de usar. Tenga en cuenta que hemos inicializado nuestro iterador en NULL en
el constructor predeterminado, por lo que cuando usamos NULL para indicar pasado el final
perdemos cualquier distinción entre estos dos estados, pero para este problema actual eso no es
un problema. El código de los métodos es:
void scIterator::avanzado() {
if (actual! = NULL)
actual = actual­>siguiente;
}
bool scIterator::pastadoEnd() {
devolver actual == NULL;
}
Recuerde que solo estamos usando el concepto de iterador para resolver el problema
original. No intentamos duplicar la especificación exacta de un C++.
Resolver problemas con la reutilización de código 185
Machine Translated by Google
Iterador de biblioteca de plantillas estándar, por lo que no tenemos que usar la misma interfaz. En
este caso, en lugar de sobrecargar el operador ++ , tengo un método llamado avance
, que verifica
que el puntero actual no sea NULL
antes de avanzar al siguiente nodo
. De manera similar, me parece engorroso tener que crear un
iterador de "final" especial para compararlo, así que solo tengo un bool
método llamado pastEnd
que determina si nos hemos quedado sin nodos.
Por último, necesitamos una forma de obtener el objeto StudentRecord al que se hace referencia actualmente :
registro de estudiante scIterator::estudiante() {
if (actual == NULL) {
StudentRecord dummyRecord(­1, ­1, "");
devolver registro ficticio;
} más {
devolver actual­>studentData;
}
}
Como hemos hecho anteriormente, por seguridad, si nuestro puntero es NULL, creamos y
devolver un registro ficticio
actualmente
. De lo contrario, devolvemos el registro al que se hace referencia
. Esto completa la implementación del concepto de iterador con nuestra clase
StudentCollection . Para mayor claridad, aquí está la declaración completa de la clase scIterator :
clase scIterador {
público:
scIterador();
scIterator(colecciónestudiante::nodoestudiante * inicial);
avance nulo();
bool pasadoEnd();
estudianteRegistro de estudiante();
privado:
colecciónestudiante::nodoestudiante *
actual;
};
Con el código en su lugar, podemos probarlo con un recorrido de muestra.
Implementemos ese cálculo de calificación promedio para comparar:
iterador de ruta;
int calificaciónTotal = 0;
int númRegistros = 0;
iter = sc.firstItemIterator();
mientras (!iter.pastEnd()) {
númRegistros++;
gradoTotal += iter.estudiante().grado();
iter.advance();
}
doble promedio = (doble) calificaciónTotal / numRecords;
Este listado utiliza todos nuestros métodos relacionados con iteradores, por lo que es una
buena prueba de nuestro código. Llamamos a firstItemIterator para inicializar nuestro scIterator .
objeto
186 Capítulo 7
. Llamamos pastEnd como nuestra prueba de terminación de bucle
. llamamos al estudiante
Machine Translated by Google
método del objeto iterador para obtener el StudentRecord actual para que podamos extraer
la calificación
avanzado
. Finalmente, para mover el iterador al siguiente registro, llamamos al método
. Cuando este código funciona, podemos estar razonablemente seguros de
que hemos implementado los diversos métodos correctamente y, más que eso, de que
tenemos una comprensión firme del concepto de iterador.
Análisis de una solución transversal eficiente
Como antes, el hecho de que el código funcione no significa que se haya acabado la
posibilidad de aprender de este evento. Deberíamos considerar cuidadosamente lo que
hemos hecho, sus efectos positivos y negativos, y contemplar ampliaciones de la idea
básica que acabamos de implementar. En este caso, podemos decir que el concepto de
iterador definitivamente resuelve el problema original del recorrido ineficiente del cliente en
nuestra colección y, una vez implementado, el uso del iterador es elegante y altamente legible.
En el lado negativo, no se puede negar que el enfoque ineficiente basado en el método
recordAt fue mucho más fácil de escribir. Para decidir si la implementación de un iterador
es valiosa o no para una situación particular, debemos preguntarnos con qué frecuencia se
producirían los recorridos, cuántos elementos normalmente habría en nuestra lista, etc. Si
los recorridos son poco frecuentes y la lista es pequeña, la ineficiencia probablemente no
sea importante, pero si esperamos que la lista crezca o no podemos garantizar que no lo
hará, es posible que se requiera el iterador.
Por supuesto, si hubiéramos decidido utilizar un objeto de lista de la biblioteca de
plantillas estándar, ya no nos preocuparíamos por la dificultad de implementar el iterador
porque no lo estaríamos implementando nosotros mismos. La próxima vez que surja una
situación como esta, podemos hacer uso de la clase lista sin sentir que nos estamos
defraudando o preparándonos para dificultades posteriores, porque hemos investigado tanto
las listas como los iteradores hasta el punto en que entendemos lo que debe ser.
sucediendo detrás de escena, incluso si nunca revisamos el código fuente real.
Yendo más allá, podemos pensar en aplicaciones más amplias de los iteradores y sus
posibles limitaciones. Supongamos, por ejemplo, que necesitáramos un iterador que pudiera
pasar de manera eficiente no solo al siguiente elemento de nuestra colección de estudiantes
sino también al elemento anterior. Ahora que sabemos cómo funciona el iterador, podemos
ver que realmente no hay forma de hacer esto con nuestra colección de estudiantes actual.
implementación. Si el iterador mantiene un enlace a un nodo particular en la lista, avanzar
al siguiente nodo requiere simplemente seguir el enlace en el nodo. Sin embargo, volver
al nodo anterior requiere recorrer la lista nuevamente hasta ese punto. En su lugar,
necesitaríamos una lista doblemente enlazada, donde los nodos tengan punteros en
ambas direcciones, tanto al siguiente nodo como al anterior. Podemos generalizar este
pensamiento y comenzar a considerar diferentes estructuras de datos y qué tipos de
recorridos o acceso a datos se pueden ofrecer de manera eficiente a los clientes. Por
ejemplo, en el capítulo anterior sobre recursividad, encontramos brevemente la estructura
de árbol binario. ¿Existe alguna forma de permitir que el cliente recorra eficientemente esta
estructura en su forma estándar? Si no, ¿cómo tendríamos que modificarlo para permitir
reversiones eficientes? ¿Cuál es incluso el orden correcto para recorrer los nodos de un árbol
binario? Pensar en preguntas como estas nos ayuda a convertirnos en mejores programadores.
No sólo aprenderemos nuevas habilidades, sino que también aprenderemos más sobre
las fortalezas y
Resolver problemas con la reutilización de código 187
Machine Translated by Google
debilidades de diferentes componentes. Conocer los pros y los contras de un componente
nos permitirá utilizarlo sabiamente. No considerar las limitaciones de un enfoque particular
puede llevarnos a callejones sin salida, y cuanto más sepamos sobre los componentes que
utilizamos, es menos probable que esto nos suceda a nosotros.
Elegir un tipo de componente
Como hemos visto en estos ejemplos, el mismo problema se puede resolver utilizando
diferentes tipos de componentes. Un patrón puede expresar la idea de una solución, un
algoritmo puede esbozar una implementación de esa idea u otra idea que resolverá el mismo
problema, un tipo de datos abstracto puede encapsular el concepto y una clase en una
biblioteca puede contener una solución completa. Implementación probada del tipo de datos
abstractos. Si cada uno de estos es una expresión del mismo concepto que necesitamos para
resolver nuestro problema, ¿cómo sabemos qué tipo de componente sacar de nuestra caja
de herramientas?
Una consideración principal es cuánto trabajo puede ser necesario para integrar el
componente en nuestra solución. Vincular una biblioteca de clases a nuestro código suele ser
una forma rápida de resolver un problema, mientras que implementar un algoritmo a partir
de una descripción de pseudocódigo puede llevar mucho tiempo. Otra consideración
importante es cuánta flexibilidad ofrece el componente propuesto. A menudo, un componente
viene en una forma bonita y preempaquetada, pero cuando se integra en el proyecto, el
programador descubre que, si bien el componente hace la mayor parte de lo que necesita, no
lo hace todo. Quizás el valor de retorno de un método esté en el formato incorrecto y requiera
procesamiento adicional, por ejemplo. Si el componente se utiliza de todos modos, es posible
que se descubran más problemas en el futuro antes de que el componente finalmente se
descarte por completo y se desarrolle un nuevo código para esa parte del problema desde cero.
Si el programador hubiera elegido un componente en un nivel conceptual superior, como un
patrón, la implementación del código resultante se ajustaría perfectamente al problema porque
fue creado específicamente para ese problema.
La figura 7­1 resume la interacción de estos dos factores. Generalmente, el código de una
biblioteca viene listo para usar, pero no se puede modificar directamente. Sólo se puede
modificar indirectamente mediante el uso de plantillas de C++ o si el código en cuestión
implementa algo parecido al patrón de estrategia que vimos anteriormente en este capítulo.
En el otro extremo de la escala, un patrón puede presentarse como nada más que una idea
(“una clase que sólo puede tener una instancia”), ofreciendo máxima flexibilidad de
implementación pero requiriendo mucho trabajo por parte del programador.
Por supuesto, esto es sólo una pauta general y los casos individuales serán diferentes.
Quizás la clase que estamos usando de la biblioteca esté en un nivel tan bajo en nuestro
programa que la flexibilidad no se verá afectada. Por ejemplo, podríamos envolver una clase
de colección de nuestro propio diseño alrededor de una clase de contenedor básica como
lista, que tiene capacidades lo suficientemente amplias como para que incluso si tenemos que
expandir la funcionalidad de nuestra clase de contenedor, podemos esperar que la clase de
lista la maneje. . Antes de usar un patrón, quizás ya hayamos implementado un patrón particular
antes, por lo que no estamos tanto creando código nuevo sino adaptando código escrito previamente.
188 Capítulo 7
Machine Translated by Google
Alto
Patrón
Algoritmo
Flexibilidad
Datos abstractos
Tipo
Biblioteca
Bajo
Alto
Requiere trabajo
Figura 7­1: Flexibilidad versus trabajo requerido para los tipos de componentes
Cuanta más experiencia tenga en el uso de componentes, más confianza tendrá
puedes ser que estás empezando en el lugar correcto. Hasta que desarrolle esa
experiencia, puede utilizar el equilibrio entre flexibilidad y trabajo requerido como guía
aproximada. Para cada situación específica, hágase preguntas como las siguientes:
¿ Puedo usar el componente tal como está o requiere código adicional para atornillarlo?
¿En mi proyecto?
¿ Estoy seguro de que comprendo el alcance total del problema o la parte relacionada
con este componente y que no cambiará en el futuro?
¿ Aumentaré mis conocimientos de programación eligiendo este componente?
Sus respuestas a estas preguntas le ayudarán a estimar cuánto trabajo le costará
participará y cuánto beneficio recibirá de cada enfoque posible.
Elección de componentes en acción
Ahora que entendemos la idea general, veamos un ejemplo rápido para demostrar los
detalles.
PROBLEMA: CLASIFICAR ALGUNOS, DEJAR OTROS SOLOS
Un proyecto requiere que ordenes una serie de objetos StudentRecord por grado, pero
hay un problema. Otra parte del programa utiliza un valor de calificación especial de –1 para
indicar un estudiante cuyo registro no se puede mover. Entonces, si bien todos los demás
registros deben moverse, aquellos con calificaciones de ­1 deben dejarse exactamente donde
están, lo que da como resultado una matriz ordenada excepto las calificaciones de ­1 intercaladas.
Resolver problemas con la reutilización de código 189
Machine Translated by Google
Este es un problema complicado y hay muchas maneras en que podríamos intentar resolverlo.
Para simplificar las cosas, reduzcamos nuestras opciones a dos: o elegimos un algoritmo (es
decir, una rutina de clasificación como la clasificación por inserción) y lo modificamos para ignorar
los objetos StudentRecord con calificaciones de ­1, o encontramos una manera de Utilice la rutina de
la biblioteca qsort para resolver este problema. Ambas opciones son posibles. Debido a que nos
sentimos cómodos con el código de ordenación por inserción, no debería ser demasiado difícil
incluir algunas declaraciones if para verificar y omitir explícitamente registros con calificaciones de
­1. Hacer que qsort haga el trabajo por nosotros requerirá una pequeña solución. Podríamos
copiar los registros de los estudiantes con las calificaciones reales en una matriz separada,
ordenarlos usando qsort y luego copiarlos nuevamente, asegurándonos de no copiar ninguno de los
registros de calificaciones ­1.
Sigamos con ambas opciones para ver cómo la elección del tipo de componente afecta el
código resultante. Comenzaremos con el componente del algoritmo y escribiremos nuestro propio
tipo de inserción modificado para resolver el problema. Como es habitual, abordaremos este
problema por etapas. Primero, reduzcamos el problema eliminando todo el problema de la calificación
­1 y simplemente ordenando una serie de objetos StudentRecord sin ninguna regla especial. Si
sra es una matriz que contiene objetos de tamaño de matriz de tipo StudentRecord, el código
resultante se ve así:
int inicio = 0;
int end = tamaño de matriz ­ 1;
for (int i = inicio + 1; i <= fin; i++) {
for (int j = i; j > inicio && sra[j­1].grade() > sra[j].grade(); j­­) {
temperatura de registro de estudiante = sra[j­1];
sra[j­1] = sra[j];
sra[j] = temperatura;
}
}
Este código es muy similar al ordenamiento por inserción para números enteros. Las únicas
diferencias son que la comparación requiere llamadas al método de calificación
temporal usado para el espacio de intercambio ha cambiado de tipo
, y nuestro objeto
. Este código funciona bien,
pero hay una advertencia para probar este y otros bloques de código que siguen en esta sección:
nuestra clase StudentRecord valida los datos y, como se escribió anteriormente, no aceptará una
calificación de –1, así que asegúrese de realizar lo necesario. cambios.
Ahora estamos listos para completar esta versión de la solución. Necesitamos el tipo de inserción
para ignorar registros con calificaciones de ­1. Esto no es tan simple como parece.
En el algoritmo básico de ordenación por inserción, siempre intercambiamos ubicaciones
adyacentes en la matriz, j y j ­ 1 en el código anterior. Sin embargo, si dejamos registros con
calificaciones de –1, las ubicaciones de los siguientes registros que se intercambiarán podrían
estar a una distancia arbitraria.
La Figura 7­2 ilustra este problema con un ejemplo. Si esto muestra la matriz en su configuración
original, entonces las flechas indican las ubicaciones de los primeros registros que se intercambiarán
y no son adyacentes. Además, eventualmente el último registro (de Arte) tendrá que cambiarse de
la ubicación [5] a la [3].
y luego de [3] a [0], por lo que todos los intercambios necesarios para ordenar esta matriz (por mucho
que la estemos ordenando) involucran registros no adyacentes.
190 Capítulo 7
Machine Translated by Google
[0]
[1]
[2]
[3]
[4]
[5]
Él mismo
jane
John
Arte
87
−1
−1
84
−1
72
11523
83764
65342
11523
11764
77663
gladys
Tomás
Figura 7­2: Distancia arbitraria entre registros que se intercambiarán en el orden de inserción modificado
Al considerar cómo resolver este problema, busqué una analogía y encontré una
en el procesamiento de listas enlazadas. En muchos algoritmos de listas enlazadas,
tenemos que mantener un puntero no sólo al nodo actual en nuestro recorrido de lista
sino también al nodo anterior. Entonces, al final de los cuerpos del bucle, a menudo
asignamos el puntero actual al puntero anterior antes de avanzar el puntero actual.
Algo similar tiene que suceder aquí. Necesitamos realizar un seguimiento del último
registro "real" del estudiante a medida que avanzamos linealmente a través de la matriz
para encontrar el siguiente registro "real". Poner esta idea en práctica da como resultado el siguiente cód
for (int i = inicio + 1; i <= fin; i++) {
if (sra[i].grado() != ­1) {
int intercambio de derechos = i;
for (int leftswap = i ­ 1; leftswap >=
start &&
(sra[leftswap].grade() > sra[rightswap].grade()
== ­1); leftswap ­­)
|| sra[leftswap].grade()
{
if(sra[intercambio izquierdo].grado() != ­1) {
StudentRecord temp = sra[leftswap];
sra[intercambio izquierdo] = sra[intercambio derecho];
sra[intercambio de derechos] = temporal;
intercambio de derechas = intercambio de izquierdas;
}
}
}
}
En el algoritmo básico de ordenación por inserción, insertamos repetidamente elementos sin
clasificar en un área ordenada en constante crecimiento dentro de la matriz. El bucle exterior selecciona
el siguiente elemento sin clasificar que se colocará en orden. En esta versión del código, comenzamos
verificando que la calificación en la ubicación i no sea –1
dentro del cuerpo del bucle exterior. Si es así,
simplemente pasaremos al siguiente registro y dejaremos este registro en su lugar. Una vez que
hayamos establecido que el registro del estudiante en la ubicación i se puede mover, inicializamos el
intercambio de derechos en esta ubicación
. Luego comenzamos el bucle interior. En el algoritmo
básico de ordenación por inserción, cada iteración del bucle interno intercambia un elemento con su
vecino. Sin embargo, en nuestra versión, debido a que dejamos registros con calificaciones ­1 en su lugar,
realizamos un intercambio solo cuando la ubicación j
no contiene una calificación de –1
. Luego intercambiamos entre ubicaciones leftswap
Resolver problemas con la reutilización de código
191
Machine Translated by Google
y rightwap y asigna leftswap a rightwap , configurando el siguiente intercambio en el
bucle interno si lo hay. Finalmente, tenemos que modificar nuestra condición de bucle
interno. Normalmente, el bucle interno en una ordenación por inserción se detiene cuando
llegamos al extremo frontal de la matriz o cuando encontramos un valor menor que el
valor que estamos insertando. Aquí, tenemos que hacer una condición compuesta usando lógica o
de modo que el bucle continúe más allá de –1 grados
(porque –1 será menor que
cualquier grado legítimo, deteniendo así el bucle prematuramente).
Este código resuelve nuestro problema, pero es posible que esté emitiendo algunos
"malos olores". El código estándar de ordenación por inserción es fácil de leer,
especialmente si comprende la esencia de lo que está haciendo, pero esta versión
modificada es difícil de ver y probablemente necesite algunas líneas de comentarios si
queremos poder entenderlo más adelante. Quizás sea necesaria una refactorización,
pero probemos el otro enfoque para resolver este problema y veamos cómo se interpreta.
Lo primero que necesitaremos es una función de comparación para usar con qsort.
En este caso, compararemos dos objetos StudentRecord y nuestra función restará una
calificación de la otra:
int compareStudentRecord(const void * voidA, const void * voidB) {
RegistroEstudiante * registroA = (RegistroEstudiante *) voidA;
RegistroEstudiante * registroB = (RegistroEstudiante *) voidB;
return registroA­>calificación() ­ registroB­>calificación();
}
Ahora estamos listos para ordenar los registros. Haremos esto en tres fases. Primero,
copiaremos todos los registros que no tengan una calificación de ­1 a una matriz
secundaria, sin dejar espacios. Luego, llamaremos a qsort para ordenar la matriz
secundaria. Finalmente, copiaremos los registros de la matriz secundaria nuevamente a
la matriz original, omitiendo los registros con calificaciones ­1. El código resultante se ve así:
StudentRecord sortArray[arraySize];
int ordenarArrayCount = 0;
para (int i = 0; i < tamañomatriz; i++) {
if (sra[i].grado() != ­1) {
sortArray[sortArrayCount] = sra[i];
ordenarArrayCount++;
}
}
qsort(sortArray, sortArrayCount, sizeof(studentRecord), compareStudentRecord);
ordenarArrayCount = 0;
for (int i = 0; i < tamañomatriz; i++) {
if (sra[i].grado() != ­1) {
sra[i] = ordenarArray[sortArrayCount];
ordenarArrayCount++;
}
}
Aunque este código tiene aproximadamente la misma longitud que la otra solución,
es más sencillo y fácil de leer. Comenzamos declarando nuestra matriz secundaria,
sortArray , del mismo tamaño que la matriz original. La variable sortArrayCount se
inicializa a cero ; en el primer bucle, usaremos esto para rastrear
192 Capítulo 7
Machine Translated by Google
cuántos registros hemos copiado en la matriz secundaria. Dentro de ese bucle, cada vez
que encontramos un registro sin una calificación –1 , lo asignamos al siguiente espacio
disponible en sortArray e incrementamos sortArrayCount. Cuando termina el ciclo,
ordenamos la matriz secundaria . La variable sortArrayCount se restablece a 0 ; Lo
usaremos en el segundo ciclo para rastrear cuántos registros hemos copiado de la
matriz secundaria a la matriz original. Tenga en cuenta que el segundo bucle atraviesa
la matriz original , buscando espacios que deban llenarse .
Si abordamos esto de otra manera, intentando recorrer la matriz secundaria y empujando
los registros a la matriz original, necesitaríamos un bucle doble, con el bucle interno
buscando la siguiente ranura de grado real en la matriz original. . Este es otro ejemplo
de cómo el problema puede hacerse fácil o difícil según nuestra conceptualización del
mismo.
Comparando los resultados
Ambas soluciones funcionan y son enfoques razonables. Para la mayoría de los
programadores, la primera solución, en la que modificamos el orden de inserción para
dejar algunos registros en su lugar mientras los ordenábamos, es más difícil de escribir y
de leer. Sin embargo, la segunda solución parece introducir cierta ineficiencia porque
requiere copiar los datos a la matriz secundaria y viceversa. Aquí es donde resulta útil
un poco de conocimiento sobre el análisis de algoritmos. Supongamos que estamos
clasificando 10.000 registros; si clasificáramos muchos menos, realmente no nos
importaría la eficiencia. No podemos saber con certeza qué algoritmo subyace a la
llamada qsort , pero el peor de los casos para una clasificación de propósito general
requeriría 100 millones de intercambios de registros, y el mejor de los casos sería
alrededor de 130.000. Independientemente de dónde acabemos en el rango, copiar
10.000 registros de un lado a otro no supondrá una pérdida importante de rendimiento en comparación c
Además, debemos considerar que cualquier algoritmo utilizado por qsort puede ser
más eficiente que nuestra simple ordenación por inserción, eliminando cualquier beneficio
que podamos haber obtenido al evitar copiar los datos hacia y desde la matriz secundaria.
Entonces, en este escenario, el segundo enfoque, usar qsort, parece ser el
ganador. Es más sencillo de implementar, más sencillo de leer y, por lo tanto, más fácil
de mantener, y podemos esperar que su rendimiento sea tan bueno o posiblemente
mejor que la primera solución. Lo mejor que podemos decir sobre el primer enfoque es
que podemos haber aprendido habilidades que podemos aplicar a otros problemas,
mientras que el segundo enfoque, en virtud de su simplicidad, no ofrece tales
conocimientos. Como regla general, cuando se encuentra en la etapa de programación
en la que intenta maximizar su aprendizaje, debe favorecer los componentes de nivel
superior, como algoritmos y patrones. Cuando esté en la etapa de intentar maximizar su
eficiencia como programador (o tenga una fecha límite estricta), debe favorecer los
componentes de nivel inferior y elegir código prediseñado cuando sea posible. Por
supuesto, si el tiempo lo permite, probar varios enfoques diferentes, como hemos hecho
aquí, proporciona lo mejor de todos los mundos.
Ejercicios
Pruebe tantos componentes como pueda. Una vez que aprendas cómo aprender
nuevos componentes, tus habilidades como programador comenzarán a crecer rápidamente.
Resolver problemas con la reutilización de código 193
Machine Translated by Google
7­1. Una queja ofrecida contra el patrón de política/estrategia es que requiere exponer algunos
aspectos internos de la clase, como los tipos. Modifique el programa "primer estudiante" de
este capítulo para que todas las funciones de política se almacenen dentro de la clase y se
elijan pasando un valor de código (de un nuevo tipo enumerado, por ejemplo), en lugar de
pasar la función de política en sí. .
7­2.
Vuelva a escribir nuestras funciones StudentCollection del Capítulo 4 (addRecord y
AverageRecord) para que, en lugar de implementar directamente una lista vinculada,
utilice una clase de la biblioteca C++.
7­3. Considere una colección de objetos StudentRecord . Queremos poder encontrar rápidamente un
registro particular según el número de estudiante. Almacene los registros de los estudiantes
en una matriz, ordene la matriz por número de estudiante e investigue e implemente el
algoritmo de búsqueda por interpolación .
7­4. Para el problema del 7­3, implemente una solución implementando un tipo de datos abstracto
que permita almacenar una cantidad arbitraria de elementos y recuperar registros individuales
en función de un valor clave. Un término genérico para una estructura que puede almacenar
y recuperar elementos de manera eficiente en función de un valor clave es una tabla de
símbolos, y las implementaciones comunes de la idea de la tabla de símbolos son tablas hash.
y árboles de búsqueda binarios.
7­5. Para el problema del 7­3, implemente una solución utilizando una clase de la biblioteca C++.
7­6. Suponga que está trabajando en un proyecto en el que un estudiante en particularRegistro
Es posible que sea necesario aumentarlo con uno de los siguientes datos: título
del trabajo final, año de inscripción o un bool que indique si el estudiante está
auditando la clase. No desea incluir todos estos campos de datos en la clase
base StudentRecord , sabiendo que en la mayoría de los casos no se utilizarán.
Su primera idea es crear tres subclases, cada una con uno de los campos de
datos, con nombres como StudentRecordTitle, StudentRecordYear y StudentRecordAudit.
Luego se le informa que algunos registros de estudiantes contendrán dos de estos campos
de datos adicionales o quizás los tres. Crear subclases para cada variación posible no
es práctico. Encuentre un patrón de diseño que resuelva este enigma e implemente una
solución.
7­7. Desarrolle una solución al problema descrito en 7­6 que no utilice el patrón que descubrió sino
que resuelva el problema utilizando clases de la biblioteca C++. En lugar de centrarse en
los tres campos de datos particulares descritos en la pregunta anterior, intente crear
una solución general: una versión de la clase StudentRecord que permita agregar campos
de datos adicionales arbitrarios a objetos particulares. Entonces, por ejemplo, si sr1 es un
registro de estudiante, es posible que desee que el código del cliente realice la llamada
sr1.addExtraField("Título", "Problemas de ramificación incondicional") y luego
sr1.retrieveField("Título") devolvería “ Problemas de ramificación incondicional ".
7­8. Diseña el tuyo propio: toma un problema que ya hayas resuelto y resuélvelo nuevamente usando
un componente diferente. Recuerde analizar los resultados en comparación con su solución
original.
194 Capítulo 7
Machine Translated by Google
t
l
S
YYPAG
oh
C
A
DY
Y
PENSAR COMO UN
PROGRAMADOR
Es hora de que reunamos todo lo que hemos experimentado
en los capítulos anteriores para completar el viaje desde la
novata
codificador a programador de resolución de problemas.
En capítulos anteriores, hemos resuelto problemas en una variedad de áreas. Yo creo
estas áreas son las más beneficiosas para que el programador en desarrollo las domine,
pero, por supuesto, siempre hay más cosas que aprender y muchos problemas requerirán
habilidades que no se tratan en este libro. Así que en este capítulo cerraremos el círculo
de los conceptos generales de resolución de problemas, tomando el conocimiento que
hemos adquirido en nuestro viaje para desarrollar un plan maestro para atacar cualquier
problema de programación. Aunque podríamos llamar a esto un plan general, en cierto
modo es en realidad un plan muy específico: será su plan y el de nadie más. También
veremos las muchas formas en que puede ampliar sus conocimientos y habilidades
como programador.
Machine Translated by Google
Creando tu propio plan maestro
En el primer capítulo aprendimos que la primera regla para la resolución de problemas era que
siempre se debe tener un plan. Una formulación más precisa sería decir que siempre debes seguir
tu plan. Debes construir un plan maestro que maximice tus fortalezas y minimice tus debilidades
y luego aplicar este plan maestro a cada problema que debas resolver.
Durante muchos años de enseñanza, he visto estudiantes de todas las habilidades diferentes.
Con esto no me refiero simplemente a que algunos programadores tengan más habilidades que
otros, aunque, por supuesto, esto es cierto. Incluso entre programadores con el mismo nivel de
habilidad, existe una gran diversidad. He perdido la cuenta de la frecuencia con la que me ha
sorprendido un estudiante con dificultades que domina rápidamente una habilidad particular o un
estudiante talentoso que muestra una debilidad en un área nueva. Así como no hay dos huellas
dactilares iguales, no hay dos cerebros iguales y las lecciones que son fáciles para una persona
son difíciles para otra.
Suponga que es un entrenador de fútbol y planifica su ofensiva para el próximo partido.
Debido a una lesión, no estás seguro de cuál de los dos mariscales de campo podrá ser titular.
Ambos mariscales de campo son profesionales altamente capaces, pero como cualquier individuo
en cualquier esfuerzo, tienen sus fortalezas y debilidades. El plan de juego que crea la mejor
oportunidad de victoria para un mariscal de campo puede ser terrible para el otro.
Al crear su plan maestro, usted es el entrenador y su conjunto de habilidades es su mariscal
de campo. Para maximizar sus posibilidades de éxito, necesita un plan que reconozca tanto sus
fortalezas como sus debilidades.
Aprovechando tus fortalezas y debilidades
Entonces, el paso clave para elaborar su propio plan maestro es identificar sus fortalezas y
debilidades. Esto no es difícil, pero requiere esfuerzo y un buen grado de honesta autoevaluación.
Para poder beneficiarte de tus errores no sólo debes corregirlos en los programas en los que
aparecen, sino que también debes anotarlos, al menos mentalmente, o mejor aún, en un documento.
De esta manera, puedes identificar patrones de comportamiento que de otro modo habrías
pasado por alto.
Voy a describir las debilidades en dos categorías diferentes: codificación y diseño. Las
debilidades de la codificación son áreas en las que tiendes a repetir errores cuando estás
escribiendo el código. Por ejemplo, muchos programadores escriben con frecuencia bucles que se
repiten una vez de más o de menos. Esto se conoce como error de poste de cerca, debido a un viejo
acertijo sobre cuántos postes se necesitan para construir una cerca de 50 pies con rieles de 10 pies
de largo entre los postes. La respuesta inmediata de la mayoría de las personas es cinco, pero si lo
piensas detenidamente, la respuesta es seis, como se muestra en la Figura 8­1.
La mayoría de las debilidades de la codificación son situaciones en las que el programador
crea errores semánticos al codificar demasiado rápido o sin suficiente preparación. Las debilidades
de diseño, por el contrario, son problemas que comúnmente surgen en la etapa de resolución de
problemas o de diseño. Por ejemplo, es posible que descubra que tiene problemas para comenzar
o para integrar subprogramas escritos previamente en una solución completa.
196 Capítulo 8
Machine Translated by Google
10 pies
50 pies
Figura 8­1: El rompecabezas del poste de la cerca
Aunque existe cierta superposición entre estas dos categorías, los dos tipos de
debilidades tienden a crear diferentes tipos de problemas y deben defenderse de
diferentes maneras.
Planificación contra las debilidades de la codificación
Quizás la actividad más frustrante en programación es pasar horas rastreando un
error semántico que resulta sencillo de corregir una vez identificado. Como nadie es
perfecto, no hay forma de eliminar por completo estas situaciones, pero un buen
programador hará todo lo posible para evitar cometer los mismos errores una y otra
vez.
Conocí a un programador que se había cansado de cometer el que quizás sea
el error semántico más común en la programación en C++: la sustitución del operador
de asignación (=) por el operador de igualdad (==). Debido a que las expresiones
condicionales en C++ son enteras, no estrictamente booleanas, una declaración como
la siguiente es sintácticamente legal:
si (número = 1) bandera = verdadero;
En este caso, el valor entero 1 se asigna al número y luego el valor 1 se utiliza como resultado de
la declaración condicional, que C++ evalúa como verdadera.
Lo que el programador pretendía hacer, por supuesto, era:
if (número == 1) bandera = verdadero;
Frustrado por cometer este tipo de error una y otra vez, el programador aprendió
por sí mismo a escribir siempre pruebas de igualdad al revés, con el literal numérico en
el lado izquierdo, como por ejemplo:
if (1 == número) bandera = verdadero;
Al hacer esto, si el programador comete un error y sustituye el operador de
igualdad, la expresión 1 = número ya no sería una sintaxis legal de C++ y produciría un
error de sintaxis que se detectaría en el momento de la compilación. El error original
es de sintaxis legal, por lo que es solo un error semántico, que se detectaría en el
momento de la compilación o no se detectaría en absoluto. Como yo mismo había
cometido este error muchas veces (y me había vuelto loco tratando de localizar el
error), empleé este método, colocando el literal numérico en el lado izquierdo del
operador de igualdad. Al hacerlo, descubrí algo curioso. Porque esto
Pensando como un programador 197
Machine Translated by Google
iba en contra de mi estilo habitual, poner el literal a la izquierda me obligó a hacer
una pausa momentánea al escribir declaraciones condicionales. Pensaba: "Necesito
acordarme de poner el literal a la izquierda para poder controlarme si uso el operador de
asignación". Como era de esperar, al tener ese pensamiento en mi cabeza, en realidad
nunca usé el operador de asignación, pero siempre usé correctamente el operador de
igualdad. Ahora, ya no pongo el literal en el lado izquierdo del operador de igualdad, pero
todavía hago una pausa y dejo que esos pensamientos pasen por mi cabeza, lo que me
impide usar el operador equivocado.
La lección aquí es que ser consciente de las debilidades a nivel de codificación
suele ser todo lo que se necesita para evitarlas. Ésa es la buena noticia. La mala noticia
es que, en primer lugar, todavía tienes que esforzarte para ser consciente de tus
debilidades en la codificación. La técnica clave es preguntarse por qué cometió un error
en particular, en lugar de limitarse a corregir el error y seguir adelante. Esto le permitirá
identificar el principio general que no siguió. Por ejemplo, suponga que ha escrito la
siguiente función para calcular el promedio de los números positivos en una matriz de
números enteros:
doble promedioPositivo(int matriz[ARRAYSIZE]) {
entero total = 0;
int recuentopositivo = 0;
para (int i = 0; i < ARRAYSIZE; i++) {
si (matriz[i] > 0) {
total += matriz[i];
positivoCount++;
}
}
retorno total / (doble) recuento positivo;
}
A primera vista, esta función parece estar bien, pero tras una inspección más
cercana, tiene un problema. Si no hay números positivos en la matriz, entonces el valor
de positivCount será cero cuando finalice el ciclo, y esto dará como resultado una división
por cero al final de la función . Debido a que se trata de una división de punto flotante,
es posible que el programa no se bloquee sino que produzca un comportamiento extraño,
dependiendo de cómo se utilice el valor de esta función en el programa general.
Si estaba intentando ejecutar rápidamente su código y descubrió este problema,
puede agregar algo de código para manejar el caso en el que positivCount es cero y
continuar. Pero si quieres crecer como programador, debes preguntarte qué error
cometiste. El problema específico, por supuesto, es que no tomaste en cuenta la
posibilidad de dividir por cero. Sin embargo, si el análisis es tan profundo, no le ayudará
mucho en el futuro. Claro, es posible que te encuentres con otra situación en la que un
divisor podría resultar ser cero, pero esa no es una situación muy común. Más bien,
deberíamos preguntarnos qué principio general se ha violado. La respuesta: que
siempre debemos buscar casos especiales que puedan hacer estallar nuestro código.
Al considerar este principio general, será más probable que veamos patrones en
nuestros errores y, por lo tanto, será más probable que los detectemos en el futuro.
Preguntarnos: "¿Hay alguna posibilidad de dividir por cero aquí?" No es tan útil como
preguntarnos: "¿Cuáles son los casos especiales de estos datos?" Al preguntarle al
198 Capítulo 8
Machine Translated by Google
En una pregunta más amplia, se nos recordará que debemos verificar no solo la división por cero sino
también los conjuntos de datos vacíos, los datos fuera del rango esperado, etc.
Planificación contra las debilidades del diseño
Las debilidades del diseño requieren un enfoque diferente para evitarlas. Sin embargo, el primer paso es
el mismo: identificar las debilidades. Mucha gente tiene problemas con este paso porque no les gusta
ser tan críticos consigo mismos.
Estamos condicionados a ocultar nuestros errores personales. Es como cuando un entrevistador de
trabajo te pregunta cuál es tu mayor debilidad y se espera que respondas con tonterías acerca de que
te preocupas demasiado por la calidad de tu trabajo en lugar de mencionar una debilidad real . Pero así
como Superman tiene su Krypto­nite, incluso los mejores programadores tienen debilidades reales.
Aquí hay una lista de muestra (y ciertamente no exhaustiva) de los puntos débiles del programador.
nesses. Comprueba si te reconoces en alguna de estas descripciones.
Diseños complicados
El programador con esta debilidad crea programas que tienen demasiadas partes o demasiados
pasos. Si bien los programas funcionan, no inspiran confianza (como la ropa gastada que
parece desmoronarse al primer tirón de un hilo) y son claramente ineficientes.
No puedo empezar
Este programador tiene un alto grado de inercia. Ya sea por falta de confianza en la resolución de
problemas o por simple procrastinación, este programador tarda demasiado en realizar algún
progreso inicial en un problema.
No se puede probar
A este programador no le gusta probar formalmente el código. A menudo, el código funcionará
para casos generales, pero no para casos especiales. En otras situaciones, el código funcionará
bien pero no se “ampliará” para conjuntos de problemas más grandes que el programador no
haya probado.
Demasiado seguro
La confianza es algo grandioso (este libro pretende aumentar la confianza de sus lectores), pero a
veces demasiada confianza puede ser tanto un problema como muy poca. El exceso de confianza
se manifiesta de varias maneras.
El programador demasiado confiado podría intentar una solución más complicada de lo necesario o
dejar muy poco tiempo para terminar un proyecto, lo que daría como resultado un programa
apresurado y plagado de errores.
zona débil
Esta categoría es un poco generalizada. Algunos programadores trabajan con bastante fluidez
hasta que alcanzan ciertos conceptos. Considere los temas discutidos en capítulos anteriores de
este libro. La mayoría de los programadores, incluso después de completar los ejercicios, tendrán
más confianza en algunas de las áreas que hemos cubierto que en otras. Por ejemplo, tal vez el
programador se pierda con los programas de puntero, o la recursividad le da vuelta la cabeza al
programador.
Quizás el programador tenga problemas para diseñar clases elaboradas. No es que el programador
no pueda salir del paso y resolver el problema, pero es un trabajo duro, como conducir sobre barro.
Pensando como un programador 199
Machine Translated by Google
Hay diferentes maneras de afrontar tus debilidades a gran escala, pero una vez que
las reconoces, es fácil planificar en torno a ellas. Si usted es el tipo de programador que a
menudo se salta las pruebas, por ejemplo, haga de las pruebas una parte explícita de su
plan para escribir cada módulo y no pase al siguiente módulo hasta que marque esa casilla.
O considere un paradigma de diseño llamado desarrollo basado en pruebas, en el que
primero se escribe el código de prueba y luego se escribe el código para completar esas
pruebas. Si tiene problemas para comenzar, utilice los principios de dividir o reducir
problemas y comience a escribir código tan pronto como pueda, entendiendo que es posible
que tenga que reescribir ese código más adelante. Si sus diseños suelen ser demasiado
complicados, agregue un paso de refactorización explícito a su plan maestro. El punto es
que, no importa qué debilidades tengas como programador, si las reconoces, puedes
planificar en torno a ellas.
Entonces sus debilidades ya no serán debilidades, sino simplemente obstáculos en el
camino que deberá sortear en el camino hacia la finalización exitosa del proyecto.
Planificación de sus fortalezas
La planificación de sus debilidades se trata en gran medida de evitar errores. Sin embargo,
una buena planificación no se trata sólo de evitar errores. Se trata de trabajar para lograr
el mejor resultado posible dadas sus capacidades actuales y cualquier restricción bajo la
cual pueda estar operando. Esto significa que también debes incorporar tus fortalezas
en tu plan maestro.
Podrías pensar que esta sección no es para ti, o al menos no todavía. Después de
todo, si estás leyendo este libro, todavía te estás convirtiendo en programador.
Quizás se pregunte si tiene alguna fortaleza en esta etapa de su desarrollo. Estoy aquí para
decirte que sí, incluso si aún no los has reconocido. Aquí hay una lista de fortalezas comunes
de los programadores, de ninguna manera exhaustiva, con descripciones de cada una y
sugerencias para ayudarlo a reconocer si el término se aplica a usted:
Ojo al detalle
Este tipo de programador puede anticipar casos especiales, ver posibles problemas
de rendimiento antes de que surjan y nunca dejar que el panorama general opaque
los detalles importantes que deben manejarse para que el programa sea una solución
completa y correcta. Los programadores con esta fortaleza tienden a probar sus
planes en papel antes de codificar, codifican lentamente y realizan pruebas con frecuencia.
Aprendiz rapido
Quien aprende rápido adquiere nuevas habilidades rápidamente, ya sea aprendiendo
una nueva técnica en un lenguaje ya conocido o trabajando con un nuevo marco de
aplicación. Este tipo de programador disfruta del desafío de aprender cosas nuevas y
puede elegir proyectos según esta preferencia.
codificador rápido
El codificador rápido no necesita pasar mucho tiempo con un libro de referencia para
desarrollar una función. Una vez que llega el momento de comenzar a escribir, el
código fluye de las puntas de los dedos del codificador rápido sin mucho esfuerzo y
con pocos errores sintácticos.
200 Capítulo 8
Machine Translated by Google
Nunca se rinde
Para algunos programadores, un error molesto es una afrenta personal que no se
puede ignorar. Es como si el programa hubiera abofeteado al programador en la
boca con un guante de cuero y le tocara al programador responder.
Este tipo de programador siempre parece mantenerse sensato, decidido pero nunca
muy frustrado y confiado en que, con suficiente esfuerzo, la victoria está asegurada.
Súper solucionador de problemas
Es de suponer que usted no era un gran solucionador de problemas cuando compró
este libro, pero ahora que ha recibido alguna orientación, tal vez todo esté empezando
a resultarle más fácil. El programador con este rasgo comienza a imaginar posibles
soluciones a un problema incluso mientras lo lee.
manitas
Para este tipo de programador, un programa en funcionamiento es como una
maravillosa caja de juguetes. El manitas nunca ha perdido la emoción de hacer que
la computadora cumpla sus órdenes y le encanta seguir encontrando algo más que
pueda hacer la computadora. Tal vez los retoques signifiquen agregar más y más
funcionalidad a un programa de trabajo, un síntoma conocido como caracteristicismo progresivo.
Quizás el programa pueda refactorizarse para mejorar el rendimiento. Quizás el
programa pueda hacerse más bonito para el programador o el usuario.
Pocos programadores exhibirán más de un par de estas fortalezas; de hecho, algunas
de ellas tienden a anularse entre sí. Pero cada programador tiene puntos fuertes. Si no te
reconoces en ninguno de estos, simplemente significa que aún tienes que aprender lo
suficiente sobre ti mismo o que tu fortaleza es algo que no encaja en una de mis categorías.
Una vez que haya identificado sus puntos fuertes, deberá tenerlos en cuenta en su
plan Maestro. Supongamos que eres un codificador rápido. Obviamente, esto ayudará a
que cualquier proyecto llegue a la meta, pero ¿cómo se puede aprovechar esta fortaleza
de manera sistemática? En ingeniería de software formal, existe un enfoque llamado
creación rápida de prototipos, en el que un programa se escribe inicialmente sin una
planificación extensa y luego se mejora mediante iteraciones sucesivas hasta que los
resultados cumplan con los requisitos del problema. Si es un codificador rápido, puede
intentar adoptar este método, codificar tan pronto como tenga una idea básica y dejar que
su prototipo aproximado guíe el diseño y desarrollo del código final del programa.
Si aprende rápido, tal vez debería comenzar cada proyecto buscando nuevos recursos
o técnicas para resolver el problema actual. Si no aprendes rápido, pero eres el tipo de
programador que no se frustra fácilmente, tal vez deberías comenzar el proyecto con las
áreas que crees que serán más difíciles y que te darás más tiempo para abordar. a ellos.
Entonces, cualesquiera que sean sus fortalezas, asegúrese de aprovecharlas en su
programación. Diseña tu plan maestro para que dediques el mayor tiempo posible a
hacer lo que mejor sabes hacer. De esta manera no sólo obtendrás los mejores resultados,
sino que también te divertirás al máximo.
Pensando como un programador 201
Machine Translated by Google
Elaborar el plan maestro
Veamos cómo construir un plan maestro de muestra. Los ingredientes incluyen todas las técnicas
de resolución de problemas que hemos desarrollado, además de nuestro análisis de nuestras
fortalezas y debilidades. Para este ejemplo, usaré mis propias fortalezas y debilidades.
En términos de técnicas de resolución de problemas, utilizo todas las técnicas que comparto en
este libro, pero me gusta especialmente la técnica de “reducir el problema” porque usar esa técnica
me permite sentir que siempre estoy tomando decisiones. Cretar progreso hacia mi meta. Si
actualmente no puedo encontrar una manera de escribir código que cumpla con la especificación
completa, simplemente descarto parte de la especificación hasta que gane impulso.
Mi mayor debilidad en la codificación es el entusiasmo excesivo. Me encanta programar
porque me encanta ver las computadoras siguiendo mis instrucciones. A veces esto me lleva a
pensar: "Vamos a darle una oportunidad a esto y ver qué pasa", cuando todavía debería estar
analizando la exactitud de lo que acabo de escribir. El peligro aquí no es que el programa falle, sino
que parezca tener éxito pero no cubra todos los casos especiales, o tenga éxito pero no sea la
mejor solución posible que pueda escribir.
Me encantan los diseños de programas elegantes que son fáciles de ampliar y reutilizar. A
menudo, cuando codifico proyectos más grandes, dedico mucho tiempo a desarrollar diseños alternativos.
En general, esta es una buena característica, pero a veces esto hace que dedique demasiado tiempo
a la fase de diseño, sin dejar suficiente tiempo para implementar el diseño seleccionado. Además,
esto a veces puede dar como resultado una solución demasiado diseñada. Es decir, a veces la
solución es más elegante, ampliable y robusta de lo que realmente necesita. Debido a que cada
proyecto está limitado en tiempo y dinero, la mejor solución debe equilibrar el deseo de una alta
calidad del software con la necesidad de conservar recursos.
Creo que mi mejor punto fuerte en la programación es que capto bien nuevos conceptos y
me encanta aprender. Si bien a algunos programadores les gusta usar las mismas habilidades una
y otra vez, a mí me encantan los proyectos en los que puedo aprender algo nuevo y ese desafío
siempre me entusiasma.
Con todo eso en mente, aquí está mi plan maestro para un nuevo proyecto.
Para combatir mi principal debilidad de diseño, limitaré estrictamente el tiempo que dedico a la
fase de diseño o, alternativamente, limitaré la cantidad de diseños distintos que consideraré antes
de continuar. Esto puede parecer una idea peligrosa para algunos lectores. ¿No deberíamos dedicar
todo el tiempo posible a la fase de diseño antes de pasar a la codificación? ¿No fracasan la
mayoría de los proyectos porque no se dedicó suficiente tiempo al principio, lo que lleva a una
cascada de compromisos en el final? Estas inquietudes son válidas, pero recuerde que no estoy
creando una guía general para el desarrollo de software. Estoy creando mi propio plan maestro
personal para abordar los problemas de programación. Mi debilidad es el diseño excesivo, no el
insuficiente, por lo que una regla que limite el tiempo de diseño tiene sentido para mí. Para otro
programador, una regla así podría ser desastrosa, y algunos programadores pueden necesitar una
regla que los obligue a dedicar más tiempo al diseño.
202 Capítulo 8
Machine Translated by Google
Después de completar mi análisis inicial, consideraré si el proyecto presenta
oportunidades para aprender nuevas técnicas, bibliotecas, etc.
Si es así, escribiré un pequeño programa de prueba para probar estas nuevas habilidades
antes de intentar incorporarlas a mi solución en desarrollo.
Para combatir el entusiasmo excesivo, podría incorporar un paso de revisión de
código en miniatura cuando termine de codificar cada módulo. Sin embargo, eso requerirá un
ejercicio de fuerza de voluntad de mi parte; cuando complete cada módulo, querré seguir
adelante y probarlo. Simplemente esperar poder convencerme de no hacerlo cada vez es como
dejar una bolsa abierta de papas fritas al lado de un hombre hambriento y sorprenderme cuando
la bolsa se vacía. Es mejor subvertir las debilidades con un plan que no requiera que el
programador luche contra sus instintos. Entonces, ¿qué pasa si creo dos versiones del
proyecto: una versión crujiente, todo vale y una versión pulida para la entrega? Si me permito
jugar con la primera versión a voluntad pero me impido incorporar código en la versión pulida
hasta que haya sido examinada por completo, es mucho más probable que supere mi debilidad.
Abordar cualquier problema
Una vez que tenemos un plan maestro, estamos listos para cualquier cosa. En última instancia,
de eso se trata este libro: comenzar con un problema, cualquier problema, y encontrar el camino
hacia la solución. En todos los capítulos anteriores, las descripciones de los problemas nos
llevaron en una dirección inicial particular, pero en el mundo real, la mayoría de los problemas
no requieren el uso de una matriz o recursividad o de encapsular alguna parte de la funcionalidad
del programa en una clase. . En cambio, el programador toma esas decisiones como parte
del proceso de resolución de problemas.
Al principio, podría parecer que un menor número de requisitos facilitaría los problemas.
Después de todo, un requisito de diseño es una restricción, ¿y las restricciones no dificultan los
problemas? Si bien esto es cierto, también es cierto que todos los problemas tienen limitaciones.
lo que pasa es que en algunos casos se explican más explícitamente que en otros.
Por ejemplo, no saber si un problema particular requiere una estructura asignada dinámicamente
no significa que la decisión no tenga efecto. Las limitaciones generales del problema (ya sea
de rendimiento, modificabilidad, velocidad de desarrollo u otra cosa) pueden ser más difíciles,
o tal vez imposibles, de cumplir si tomamos decisiones de diseño equivocadas.
Imagina que un grupo de amigos te pide que selecciones una película para que todos la
vean. Si una amiga definitivamente quiere una comedia, a otra no le gustan las películas
antiguas y otra enumera cinco películas que acaba de ver y no quiere volver a ver, estas
limitaciones dificultarán la selección. Sin embargo, si nadie tiene sugerencias más allá de
"simplemente elegir algo bueno", su trabajo es aún más difícil y es muy probable que elija
algo que al menos a un miembro del grupo no le guste en absoluto.
Por lo tanto, los problemas más grandes, ampliamente definidos y débilmente
restringidos son los más difíciles de todos. Sin embargo, son susceptibles a las mismas
técnicas de resolución de problemas que hemos utilizado a lo largo de este libro; simplemente
toman más tiempo para resolver. Con tu conocimiento de estas técnicas y tu plan maestro en
mano podrás resolver cualquier problema.
Pensando como un programador 203
Machine Translated by Google
Para demostrar de qué estoy hablando, te guiaré a través de los primeros pasos de
un programa que juega al ahorcado, el clásico juego infantil, pero con un giro.
Antes de pasar a la descripción del problema, repasemos las reglas básicas del
juego. El primer jugador selecciona una palabra y le dice al segundo jugador cuántas letras
tiene la palabra. Luego, el segundo jugador adivina una letra. Si la letra está en la palabra,
el primer jugador muestra dónde aparece la letra en la palabra; si la carta aparece más de
una vez, se indican todas las apariciones. Si la letra no está en la palabra, el primer jugador
agrega una pieza a un dibujo de figura de palo de un hombre ahorcado. Si el segundo
jugador adivina todas las letras de la palabra, el segundo jugador gana, pero si el primer
jugador completa el dibujo, gana el primer jugador. Existen diferentes reglas sobre cuántas
piezas componen el dibujo del ahorcado, por lo que, en términos más generales, podemos
decir que los jugadores acuerdan de antemano cuántas "fallas" harán que el primer
jugador gane el juego.
Ahora que hemos cubierto las reglas básicas, veamos el problema específico, incluido
el giro desafiante.
PROBLEMA: ENGAÑAR AL AHORCADO
Escriba un programa que sea el Jugador 1 en una versión de texto del ahorcado (es decir, en
realidad no es necesario dibujar un ahorcado; simplemente lleve la cuenta del número de
conjeturas incorrectas). El jugador 2 establecerá la dificultad del juego especificando la
longitud de la palabra a adivinar, así como el número de aciertos incorrectos que harán perder el juego.
El problema es que el programa hará trampa. En lugar de elegir una palabra al comienzo
del juego, el programa puede evitar elegir una palabra, siempre que cuando el jugador 2 pierda,
el programa pueda mostrar una palabra que coincida con toda la información dada al jugador 2.
Las letras adivinadas correctamente deben aparecen en sus posiciones correctas y ninguna
de las letras adivinadas incorrectamente puede aparecer en la palabra. Cuando termine el
juego, el Jugador 1 (el programa) le dirá al Jugador 2 la palabra que fue elegida. Por lo tanto,
el jugador 2 nunca podrá probar que el juego es trampa; es sólo que la probabilidad de que
gane el jugador 2 es pequeña.
Este no es un problema del tamaño de un monstruo según los estándares del mundo
real, pero es lo suficientemente grande como para demostrar los problemas que enfrentamos
cuando lidiamos con un problema de programación que especifica resultados pero no
una metodología. Según la descripción del problema, puede iniciar su entorno de desarrollo
y comenzar a escribir código en uno de docenas de lugares diferentes. Eso, por supuesto,
sería un error porque siempre queremos programar con un plan, por lo que necesito aplicar
mi plan maestro a esta situación específica.
La primera parte de mi plan maestro es limitar la cantidad de tiempo que dedico a la
fase de diseño. Para que esto sea una realidad, necesito pensar detenidamente en el
diseño antes de trabajar en el código de producción. Sin embargo, creo que en este caso
será necesario algo de experimentación para encontrar una solución al problema. Mi plan
maestro también me permite crear dos proyectos,
204 Capítulo 8
Machine Translated by Google
un prototipo preliminar y listo y una solución final pulida. Así que me permitiré comenzar a
codificar el prototipo en cualquier momento, antes de cualquier trabajo de diseño real, pero
no permitiré ninguna codificación en la solución final hasta que crea que mi diseño está
configurado. Eso no garantiza que estaré completamente satisfecho con el diseño del
segundo proyecto, pero ofrece la mejor oportunidad para que así sea.
Ahora es el momento de empezar a analizar este problema. En capítulos anteriores,
a veces enumeramos todas las subtareas necesarias para completar un problema, por lo que
me gustaría hacer un inventario de las subtareas. En este punto, sin embargo, esto sería
difícil porque no sé qué hará realmente el programa para lograr hacer trampa. Necesito
investigar más esta área.
Encontrar una manera de hacer trampa
Hacer trampa en el ahorcado es lo suficientemente específico como para que no espero
encontrar ayuda en las fuentes normales de componentes; no existe ningún patrón
NefariousStrategy . En este punto, tengo una vaga idea de cómo se podría lograr el engaño.
Estoy pensando en elegir una palabra inicial del rompecabezas y conservarla siempre que el
jugador 2 elija letras que en realidad no están en esa palabra. Sin embargo, una vez que el
jugador 2 encuentre una letra que realmente esté en la palabra, cambiaré a otra palabra si es
posible encontrar una que no tenga ninguna de las letras seleccionadas hasta el momento.
En otras palabras, le negaré una partida al jugador 2 el mayor tiempo posible. Esa es la idea,
pero necesito más que una idea: necesito algo que pueda implementar.
Para consolidar mis ideas, voy a trabajar con un ejemplo en papel, asumiendo el
papel del Jugador 1, trabajando a partir de una lista de palabras. Para simplificar las cosas,
voy a suponer que el jugador 2 ha solicitado una palabra de tres letras y que la lista completa
de palabras de tres letras que conozco se muestra en la primera columna de la Tabla 8­1.
Asumiré que mi primera opción "palabra de rompecabezas" es la primera palabra de la lista,
murciélago. Si el jugador 2 adivina cualquier letra además de b, a o t, diré "no" y estaremos
un paso más cerca de completar la horca. Si el jugador 2 adivina una letra de la palabra,
elegiré otra palabra, una que no contenga esa letra.
Sin embargo, al mirar mi lista, no estoy tan seguro de que esta estrategia sea la mejor.
En algunas situaciones, probablemente tenga sentido. Supongamos que el jugador 2
adivina b. Ninguna otra palabra de la lista contiene b, por lo que puedo cambiar la palabra
del rompecabezas a cualquiera de ellas. Esto también significa que he minimizado el daño;
He eliminado sólo una palabra posible de mi lista. Pero ¿qué pasa si el jugador 2 adivina
a? Si simplemente digo "no", elimino todas las palabras que contienen una a, lo que me deja
solo las tres palabras de la segunda columna de la tabla 8­1 para elegir. Si, en cambio,
decidiera admitir la presencia de la letra a en la palabra del rompecabezas, me quedarían
cinco palabras entre las que podría elegir, como se muestra en la tercera columna. Sin
embargo, tenga en cuenta que esta selección ampliada existe sólo porque las cinco palabras
tienen la a en la misma posición. Una vez que declaro que una suposición es correcta, tengo
que mostrar exactamente dónde aparece la letra en la palabra. Me sentiré mucho mejor
acerca de mis posibilidades para el resto del juego si me quedan más opciones de
palabras para reaccionar ante futuras conjeturas.
Pensando como un programador 205
Machine Translated by Google
Tabla 8­1: Lista de palabras de muestra
Todas las palabras
Palabras sin
palabras con un
uno
punto
uno
auto
fosa
auto
punto
arriba
comer
comer
sierra
fosa
grifo
sierra
grifo
arriba
Además, incluso si logré evitar revelar letras al principio del juego, debo esperar que
el jugador 2 eventualmente adivine correctamente. El jugador 2 podría empezar con todas
las vocales, por ejemplo. Por lo tanto, en algún momento tendré que decidir qué hacer cuando
se revele una carta y, según mi experimento con la lista de muestra, parece que tendré que
encontrar la ubicación (o ubicaciones) donde la carta aparece con más frecuencia. A partir de
esta observación, me di cuenta de que había estado pensando en hacer trampa de manera
incorrecta. En realidad, nunca debería elegir una palabra de rompecabezas, ni siquiera
temporalmente, sino simplemente realizar un seguimiento de todas las palabras posibles
que podría elegir si fuera necesario.
Con esta idea en mente, ahora puedo definir el engaño de una manera diferente:
tantas palabras como sea posible en la lista de palabras candidatas para el rompecabezas.
Por cada suposición que haga el jugador 2, el programa tiene que tomar una decisión.
¿Afirmamos que la suposición fue un error o una coincidencia? Si fuera coincidencia, ¿en
qué posiciones aparece la letra adivinada? Haré que mi programa mantenga una lista cada
vez menor de palabras candidatas para rompecabezas y, después de cada suposición,
tomará la decisión que dejará la mayor cantidad de palabras en esa lista.
Operaciones necesarias para hacer trampa en el ahorcado
Ahora entiendo el problema lo suficientemente bien como para crear mi lista de subtareas. En
un problema de este tamaño, existe una buena probabilidad de que una lista hecha en esta
etapa temprana omita algunas operaciones. Esto está bien, porque mi plan maestro
prevé que no crearé un diseño perfecto la primera vez.
Almacenar y mantener una lista de palabras.
Este programa debe tener una lista de palabras en inglés válidas. Por tanto, el
programa tendrá que leer una lista de palabras de un archivo y almacenarlas internamente
en algún formato. Esta lista se reducirá o extraerá durante el juego a medida que el
programa haga trampa.
206 Capítulo 8
Machine Translated by Google
Crea una sublista de palabras de una longitud determinada.
Dada mi intención de mantener una lista de palabras candidatas para rompecabezas, tengo que
comenzar el juego con una lista de palabras de la longitud especificada por el Jugador 2.
Seguimiento de las letras elegidas.
El programa deberá recordar qué letras se han adivinado, cuántas de ellas fueron incorrectas y,
en el caso de las que se consideraron correctas, dónde aparecen en la palabra del rompecabezas.
Contar palabras en las que no aparece una letra.
Para facilitar las trampas, necesitaré saber cuántas palabras de la lista no contienen la letra
adivinada más recientemente. Recuerde que el programa decidirá si la letra adivinada más
recientemente aparece en la palabra del rompecabezas con el objetivo de dejar el número máximo
de palabras en la lista de palabras candidatas.
Determine la mayor cantidad de palabras según la letra y la posición.
Esta parece la operación más complicada. Supongamos que el jugador 2 acaba de adivinar la
letra d y el juego actual tiene una longitud de tres palabras de rompecabezas. Quizás la lista actual
de palabras candidatas en su conjunto contenga 10 palabras que incluyan d, pero eso no es lo
importante porque el programa tendrá que indicar dónde aparece la letra en la palabra del
rompecabezas. Llamemos patrón a la posición de las letras en una palabra. Entonces d?? es un
patrón de tres letras que especifica que la primera letra es una d y las otras dos letras no son una
d. Considere la Tabla 8­2. Supongamos que la lista de la primera columna contiene todas las
palabras de tres letras que contienen d conocidas por el programa.
Las otras columnas desglosan esta lista por patrón. El patrón que aparece con más frecuencia es ??
d, con 17 palabras. Este número, 17, se compararía con el número de palabras en la lista de
candidatos que no contienen una d para determinar si la suposición es coincidente o fallida.
Crea una sublista de palabras que coincidan con un patrón.
Cuando el programa declara que una suposición del Jugador 2 coincide, creará una nueva lista de
palabras candidatas con sólo aquellas palabras que coincidan con el patrón de letras elegido. En el
ejemplo anterior, si declaramos d una coincidencia, la tercera columna de la Tabla 8­2 se convertiría
en la nueva lista de palabras candidatas.
Sigue jugando hasta que termine el juego.
Una vez realizadas todas las demás operaciones, necesito escribir el código que une todo y jugar
el juego. El programa debe solicitar repetidamente una suposición al jugador 2 (el usuario), determinar
si la lista de palabras candidatas sería más larga al rechazar o aceptar esa suposición, reducir la
lista de palabras en consecuencia y luego mostrar la palabra del rompecabezas resultante, con las
letras adivinadas correctamente. revelado, junto con una revisión de todas las letras adivinadas
previamente. Este proceso continuaría hasta que el juego terminara, habiendo sido ganado por
un jugador u otro, cuyas condiciones también necesito determinar.
Pensando como un programador 207
Machine Translated by Google
Tabla 8­2: Palabras de tres letras
Todas las palabras
?dd
??d
¿¿d??
d?d
agregar
agregar
ayuda
día
hizo
ayuda
extraño
y
el
y
malo
gama
malo
cama
perro
cama
licitación
seco
licitación
fin
pendiente
día
alimentado
hizo
tenía
el
escondido
gama
niño
perro
condujo
seco
enojado
debido
contra
fin
viejo
alimentado
rojo
tenía
deshacerse
escondido
triste
niño
condujo
enojado
contra
extraño
viejo
rojo
deshacerse
triste
Diseño inicial
Aunque pueda parecer que la lista anterior de operaciones requeridas simplemente
enumera hechos en bruto, se están tomando decisiones de diseño. Considere la
operación "Crear una sublista de palabras que coincidan con un patrón". Esa
operación aparecerá en mi solución, o al menos en esta versión inicial, pero
estrictamente hablando, no es una operación requerida en absoluto. Tampoco lo
es "Crear una sublista de palabras de una longitud determinada". En lugar de
mantener una lista de palabras candidatas para acertijos que se hace cada vez más
pequeña, podría mantener la lista maestra original de palabras durante todo el
juego. Sin embargo, esto complicaría la mayoría de las demás operaciones. La
operación para “Contar palabras en las que no aparece una letra” no podría
simplemente recorrer la lista de palabras candidatas y contar todas las palabras sin la
letra especificada. Debido a que buscaría en la lista maestra, también tendría que verificar la longitu
208 Capítulo 8
Machine Translated by Google
coincide con las letras reveladas hasta ahora en la palabra del rompecabezas. Creo que el camino
que he elegido es más fácil en general, pero debo ser consciente de que incluso estas primeras
decisiones están afectando el diseño final.
Sin embargo, más allá del desglose inicial del problema en subtareas, tengo otras decisiones
que tomar.
Cómo almacenar las listas de palabras
La estructura de datos clave del programa será la lista de palabras, que el programa irá
reduciendo a lo largo del juego. Al elegir una estructura, hago las siguientes observaciones.
Primero, no creo que necesite acceso aleatorio a las palabras de la lista, sino que siempre
procesaré la lista como un todo, de adelante hacia atrás. En segundo lugar, no sé el tamaño
de la lista inicial que necesito. En tercer lugar, reduciré la lista con frecuencia. Cuarto y último,
los métodos de la clase de cadena estándar probablemente serán útiles en este programa. Al
reunir todas estas observaciones, decido que mi elección inicial para esta estructura será la
clase de lista de plantilla estándar , con un tipo de elemento de cadena.
Cómo rastrear letras adivinadas
Las letras elegidas son conceptualmente un conjunto, es decir, una letra ha sido elegida o no,
y ninguna letra puede elegirse más de una vez. Por lo tanto, en realidad se trata de si una
letra particular del alfabeto es miembro del conjunto "elegido". Por lo tanto, voy a representar
las letras elegidas como una matriz de bool de tamaño 26. Si la matriz se llama letras
adivinadas, entonces letras adivinadas [0] es verdadera si se ha adivinado a durante el juego
hasta el momento y falsa en caso contrario; letras adivinadas[1] es para b, y así
sucesivamente. Usaré las técnicas de conversión de rangos que hemos estado empleando
a lo largo de este libro para convertir entre una letra minúscula del alfabeto y su posición
correspondiente en la matriz. Si letra es un carácter que representa una letra minúscula,
entonces letras adivinadas[letra ­ 'a'] es la ubicación correspondiente.
Cómo almacenar patrones
Una de las operaciones que codificaré, “Crear una sublista de palabras que coincidan con un
patrón”, utilizará el patrón de las posiciones de una letra en una palabra.
Este patrón se producirá mediante otra operación, "Determinar la mayor cantidad de
palabras según la letra y la posición". Entonces, ¿qué formato usaré para esos datos? El
patrón es una serie de números que representan las posiciones en las que aparece una letra
en particular. Hay muchas maneras de almacenar estos números, pero voy a mantener las
cosas simples y usaré otra lista, esta con un tipo de elemento int.
¿Estoy escribiendo una clase?
Debido a que estoy codificando este programa en C++, puedo usar programación orientada a
objetos o no, a mi discreción. Lo primero que pensé es que muchas de las operaciones en mi
lista podrían fusionarse naturalmente en una clase, llamada wordList .
tal vez, con métodos para eliminar palabras según criterios específicos (es decir, longitud y
patrón). Sin embargo, como estoy tratando de evitar tomar decisiones de diseño ahora que
tendré que revocar más adelante, haré que mi primer programa, preliminar y listo, sea
completamente procedimental. Una vez que he trabajado
Pensando como un programador 209
Machine Translated by Google
Después de analizar todos los aspectos complicados del programa y el código realmente
escrito para todas las operaciones de mi lista, estaré en una excelente posición para
determinar la aplicabilidad de la programación orientada a objetos para la versión final.
Codificación inicial
Ahora comienza la diversión. Enciendo mi entorno de desarrollo y me pongo a trabajar.
Este programa utilizará varias clases de la biblioteca estándar, así que para mayor claridad,
permítanme configurarlas todas primero:
#incluir <iostream>
usando std::cin;
usando std::cout;
usando std::ios;
#incluir <fstream>
usando std::ifstream;
#incluir <cadena>
usando std::string;
#incluir <lista>
usando std::lista;
usando std::iterador;
#incluir <cstring>
Ahora estoy listo para comenzar a codificar las operaciones de mi lista. Hasta cierto punto,
podría codificar las operaciones en cualquier orden, pero comenzaré con una función para leer un
archivo de texto sin formato de palabras en la estructura lista<cadena> elegida . En este punto, me
doy cuenta de que necesito encontrar un archivo maestro de palabras existente; no quiero
escribirlo yo mismo. Afortunadamente, buscar en Google la lista de palabras revela una serie de
sitios que tienen listas de palabras en inglés en formato de texto sin formato, una palabra por línea del archivo.
Ya estoy familiarizado con la lectura de archivos de texto en C++, pero si no lo estuviera,
escribiría un pequeño programa de prueba solo para jugar con esa habilidad primero y luego
integraría esa habilidad en el programa tramposo del ahorcado, una práctica que analizo más
adelante. en este capítulo.
Con el archivo en mano, puedo escribir la función:
lista<cadena> readWordFile(char * nombre de archivo) {
lista<cadena> lista de palabras;
ifstream wordFile(nombre de archivo, ios::in);
if (archivopalabra == NULL) {
cout << "Error al abrir el archivo. \n";
devolver lista de palabras;
}
char palabraactual[30];
mientras (archivo de palabras >> palabra actual) {
if (strchr(palabraactual, '\'') == 0) {
temperatura de cadena (palabra actual);
lista de palabras.push_back(temp);
}
}
devolver lista de palabras;
}
210 Capítulo 8
Machine Translated by Google
Esta función es sencilla, por lo que sólo haré unos breves comentarios. Si
Como nunca has visto uno antes, un objeto ifstream
es un flujo de entrada que
funciona igual que cin, excepto que lee desde un archivo en lugar de una entrada estándar.
Si el constructor no puede abrir el archivo (generalmente esto significa que no se encontró
el archivo), el objeto será NULL, algo que reviso explícitamente . Si el archivo existe, se
procesa en un bucle
que lee cada línea del archivo en una matriz de caracteres, convierte
la matriz en un objeto de cadena y lo agrega a una lista. El archivo de palabras en inglés
que terminé usando incluía palabras con apóstrofes, que no son legales para nuestro
juego, por lo que las excluyo explícitamente .
A continuación, escribo una función para mostrar todas las palabras en mi lista
<cadena>. Esto no está en mi lista requerida de operaciones, y no lo usaría en el juego
(después de todo, eso solo ayudaría al Jugador 2, a quien estoy tratando de engañar), pero
es una buena manera de probar si mi La función readWordFile funciona correctamente:
void displayList( lista constante<cadena> & lista de palabras) {
list<cadena>::const_iterator iter;
iter = lista de palabras.begin();
while (iter != ListaPalabras.end()) {
cout << iter­>c_str() << "\n";
iter++;
}
}
Este es esencialmente el mismo código de recorrido de lista introducido en el capítulo
anterior. Tenga en cuenta que he declarado el parámetro como referencia constante .
Debido a que la lista puede ser bastante grande al principio, tener un parámetro de
referencia reduce la sobrecarga de la llamada a la función, mientras que un parámetro de
valor tendría que copiar la lista completa. Declarar ese parámetro de referencia como constante
indica que la función no cambiará la lista, lo que ayuda a la legibilidad del código. Una lista
constante requiere un iterador constante . La secuencia cout no puede generar un objeto
de cadena, por lo que este método produce el carácter equivalente terminado en nulo.
matriz usando c_str() .
Utilizo esta misma estructura básica para escribir una función que cuente las palabras
en la lista que no contienen una letra especificada:
int countWordsWithoutLetter(lista constante<cadena> & lista de palabras, letra char) {
lista<cadena>::const_iterator iter;
recuento int = 0;
iter = lista de palabras.begin();
while (iter != ListaPalabras.end()) {
if (iter­>buscar(letra) == cadena::npos) {
contar++;
}
iter++;
}
recuento de devoluciones;
}
Pensando como un programador 211
Machine Translated by Google
Como puede ver, este es el mismo bucle transversal básico. En el interior, llamo al hallazgo
método de la clase de cadena
, que devuelve la posición de su parámetro char en el objeto de
cadena , devolviendo el valor especial npos cuando no se encuentra el carácter.
Utilizo esta misma estructura básica para escribir la función que elimina todos los
palabras de mi lista de palabras que no coinciden con la longitud especificada:
void removeWordsOfWrongLength(lista<cadena> & lista de palabras,
int longitud aceptable)
{
lista<cadena>::iterador iter;
iter = lista de palabras.begin();
while (iter != ListaPalabras.end()) {
if (iter­>longitud() != Longitud aceptable) {
iter = lista de palabras.erase(iter);
} demás {
iter++;
}
}
}
Esta función es un buen ejemplo de cómo cada programa que escribe es una oportunidad
para profundizar su comprensión de cómo funcionan los programas. Esta función fue sencilla
de escribir para mí porque entendí lo que estaba sucediendo "debajo del capó" de programas
anteriores que había escrito.
Esta función emplea el código transversal básico de las funciones anteriores, pero el código
se vuelve interesante dentro del bucle. El método erase() elimina un elemento, especificado por
un iterador, de un objeto de lista . Pero por nuestra experiencia en la implementación del
patrón de iterador para una lista enlazada en el Capítulo 7, sé que es casi seguro que el iterador
es un puntero. Por nuestra experiencia con los punteros en el Capítulo 4, sé que un puntero es
inútil y, a menudo, peligroso, cuando es una referencia colgante a algo que ha sido eliminado.
Por lo tanto, sé que necesito asignar un valor válido a iter después de esta operación.
Afortunadamente, los diseñadores de erase() han anticipado este problema y hacen que el
método devuelva un nuevo iterador que apunta al elemento inmediatamente posterior al que
acabamos de borrar, por lo que puedo asignar ese valor nuevamente al iter
en cuenta que avanzo explícitamente el iter
. También tenga
solo cuando no he eliminado la cadena actual de
la lista, porque la asignación del valor de retorno de erase() efectivamente hace avanzar el
iterador y no quiero omitir ningún elemento.
Ahora viene la parte difícil: encontrar el patrón más común de una letra específica en la
lista de palabras restantes. Esta es otra oportunidad para utilizar la técnica de dividir el problema.
Sé que una de las subtareas de esta operación es determinar si una palabra concreta coincide
con un patrón concreto.
Recuerde que un patrón es una lista<int>, donde cada int representa una posición donde
aparece la letra en la palabra, y que para que una palabra coincida con un patrón, la letra no
solo debe aparecer en las posiciones especificadas en la palabra. , pero la letra no debe
aparecer en ningún otro lugar de la palabra. Con ese pensamiento en mente, voy a probar una
cadena para detectar una coincidencia atravesándola; para cada posición en la cadena, si
aparece la letra especificada, me aseguraré
212 Capítulo 8
Machine Translated by Google
esa posición está en el patrón, y si aparece alguna otra letra, me aseguraré de que esa
posición no esté en el patrón.
Para simplificar aún más las cosas, primero escribiré una función separada para
verificar si un número de posición en particular aparece en un patrón:
bool númeroInPattern(lista constante<int> & patrón, número int) {
lista<int>::const_iterator iter;
iter = patrón.begin();
mientras (iter! = patrón.end()) {
si (*iter == número) {
devolver verdadero;
}
iter++;
}
falso retorno;
}
Este código es bastante sencillo de escribir basándose en las funciones
anteriores. Simplemente recorro la lista en busca del número. O lo encuentro y vuelvo verdadero
o llego al final de la lista y devuelvo falso. Ahora puedo implementar la prueba general
de coincidencia de patrones:
bool coincide con Patrón (palabra de cadena, letra de carácter, patrón de lista <int>) {
para (int i = 0; i < palabra.longitud(); i++) {
si (palabra[i] == letra) {
if (!numberInPattern(patrón, i)) {
falso retorno;
}
} demás {
if (númeroEnPatrón(patrón, i)) {
falso retorno;
}
}
}
devolver verdadero;
}
Como puede ver, esta función sigue el plan descrito anteriormente. Para cada
carácter de la cadena, si coincide con una letra, el código verifica que la posición actual
esté en el patrón. Si el carácter no coincide con la letra, el código verifica que la posición
no esté en el patrón. Si una sola posición no coincide con el patrón, la palabra se
rechaza; de lo contrario, se llega al final de la palabra y se acepta la palabra.
En este punto, se me ocurre que encontrar el patrón más frecuente
Será más fácil si cada palabra de la lista contiene la letra especificada. Entonces
escribo una función rápida para recortar las palabras sin la letra:
void eliminarPalabrasSinLetra(lista<cadena> & lista de palabras,
carácter letra requerida) {
lista<cadena>::const_iterator iter;
iter = lista de palabras.begin();
Pensando como un programador 213
Machine Translated by Google
while (iter != ListaPalabras.end()) {
if (iter­>find(letrarequerida) == cadena::npos) {
iter = lista de palabras.erase(iter);
} demás {
iter++;
}
}
}
Este código es solo una combinación de las ideas utilizadas en las funciones anteriores.
Ahora que lo pienso, voy a necesitar también la función opuesta, una que corte todas las
palabras que tengan la letra especificada. Usaré esto para reducir la lista de palabras
candidatas cuando el programa falle la última suposición:
void removeWordsWithLetter(lista<cadena> & lista de palabras, char letra prohibida) {
lista<cadena>::const_iterator iter;
iter = lista de palabras.begin();
while (iter != ListaPalabras.end()) {
if (iter­>find(letraprohibida) != cadena::npos) {
iter = lista de palabras.erase(iter);
} demás {
iter++;
}
}
}
Ahora estoy listo para encontrar el patrón más frecuente en la lista de palabras para la
letra dada. Consideré varios enfoques y elegí el que pensé que podría implementar más
fácilmente. Primero, usaré una llamada a la función anterior para eliminar todas las
palabras sin la letra especificada. Luego, tomaré la primera palabra de la lista, determinaré
su patrón y contaré cuántas otras palabras de la lista tienen el mismo patrón. Todas estas
palabras se borrarán de la lista a medida que las cuento. Luego, el proceso se repetirá
nuevamente con la palabra que ahora esté al principio de la lista y así sucesivamente hasta
que la lista esté vacía. El resultado se ve así:
void mostFreqPatternByLetter(
list<cadena> lista de palabras, letra char,
lista<int> & maxPattern,
int & maxPatternCount) {
eliminarPalabrasSinLetra(lista de palabras, letra);
lista<cadena>::iterador iter;
maxPatternCount = 0;
mientras (listapalabras.tamaño() > 0) {
iter = lista de palabras.begin();
lista<int> patrón actual;
for (int i = 0; i < iter­>longitud(); i++) {
si ((*iter)[i] == letra) {
patrón actual.push_back(i);
}
}
int currentPatternCount = 1;
iter = lista de palabras.erase(iter);
214 Capítulo 8
Machine Translated by Google
mientras (iter != ListaPalabras.end()) {
if (matchesPattern(*iter, letra, currentPattern)) {
currentPatternCount++;
iter = lista de palabras.erase(iter);
} demás {
iter++;
}
}
if (CuentaPatrónActual > CuentaPatrónmax) {
maxPatternCount = currentPatternCount;
patrónmax = patrónactual;
}
patrón actual.clear();
}
}
La lista llega como un parámetro de valor
porque esta función va
para reducir la lista a nada durante el procesamiento, y no quiero afectar el parámetro pasado por
el código de llamada. Tenga en cuenta que maxPattern
y maxPatternCount
son parámetros
salientes únicamente; estos se utilizarán para enviar el patrón que ocurre con mayor frecuencia y su
número de apariciones al código de llamada. Elimino todas las palabras sin la letra
bucle principal de la función, que continúa mientras la lista no esté vacía
. Luego entro al
.
El código dentro del bucle tiene tres secciones principales. Primero, un bucle for construye el patrón
para la primera palabra de la lista
coinciden con ese patrón
. Luego, un bucle while cuenta cuántas palabras de la lista
. Finalmente, vemos si este recuento es mayor que el recuento más alto
visto hasta ahora, empleando la estrategia del “Rey de la colina” vista por primera vez en el Capítulo 3
.
La última función de utilidad que debería necesitar mostrará todas las letras adivinadas.
hasta ahora. Recuerde que los estoy almacenando como una matriz de 26 valores booleanos :
visualización vacía de letras adivinadas (letras bool [26]) {
cout << "Letras adivinadas: ";
para (int i = 0; i < 26; i++) {
if (letras[i]) cout << (char)('a' + i) << " ";
}
cout << "\n";
}
Tenga en cuenta que estoy sumando el valor base de un rango, en este caso, el carácter a, a un
valor de otro rango
, una técnica que empleamos por primera vez en el Capítulo 2.
Ahora tengo todas las subtareas clave completadas y estoy listo para intentar resolver todo el
problema, pero tengo muchas funciones aquí que no se han probado por completo y me gustaría
probarlas lo antes posible. Entonces, en lugar de abordar el resto del problema en un solo paso, voy a
reducirlo.
Haré esto convirtiendo algunas de las variables, como el tamaño de la palabra del rompecabezas, en
constantes.
Como voy a desechar esta versión, me siento cómodo poniendo toda la lógica del juego en
la función principal . Sin embargo, debido a que el resultado es largo, presentaré el código en etapas.
Pensando como un programador 215
Machine Translated by Google
int principal () {
lista<cadena> lista de palabras = readWordFile("lista de palabras.txt");
const int longitud de palabra = 8;
const int maxMisses = 9;
int fallas = 0;
int descubiertaLetterCount = 0;
removeWordsOfWrongLength(listadepalabras, longitud de palabra);
char revelóPalabra[palabraLongitud + 1] = "********";
bool letras adivinadas[26];
for (int i = 0; i < 26; i++) letras adivinadas[i] = false;
char siguienteLetra;
"
cout << "Palabra hasta ahora:
<< Palabra revelada << "\n";
Esta primera sección de código configura las constantes y variables que necesitaremos
para jugar. La mayor parte de este código se explica por sí mismo. La lista de palabras se crea
a partir de un archivo
el valor constante 8
y luego se reduce a la longitud de palabra especificada, en este caso,
. La variable misses
almacena el número de conjeturas erróneas
del jugador 2, mientras que discoverLetterCount
rastrea el número de posiciones reveladas
en la palabra (por lo que si d aparece dos veces, adivinar d aumenta este valor en dos). La
variable PalabraRevelada almacena la palabra del rompecabezas tal como la conoce
actualmente el Jugador 2, con asteriscos para las letras que aún no se han adivinado
El conjunto de letras adivinadas de bool
.
rastrea las letras específicas adivinadas hasta el
momento; un bucle establece todos los valores en falso. Finalmente, nextLetter
almacena
la suposición actual del Jugador 2. Envío la Palabra revelada inicial y luego estoy listo para el
bucle principal del juego.
while (descubiertaLetterCount < longitud de palabra && misses < maxMisses) {
cout << "Letra para adivinar: ";
comer >> siguienteLetra;
Letras adivinadas[letrasiguiente ­ 'a'] = verdadero;
int faltaContar = contarPalabrasSinLetra(listadepalabras, siguienteLetra);
lista<int> siguientePatrón;
int siguientePatrónCount;
mostFreqPatternByLetter(wordList, nextLetter, nextPattern, nextPatternCount);
si (cuentafaltante> siguientePatrónCount) {
removeWordsWithLetter(wordList, nextLetter);
falla ++;
} demás {
lista<int>::iterador iter = nextPattern.begin();
while (iter! = siguientePatrón.end()) {
descubiertoLetterCount++;
palabra revelada [*iter] = letra siguiente;
iter++;
}
Lista de palabras = reducirPorPatrón(Lista de palabras, siguienteLetra, siguientePatrón);
}
"
cout << "Palabra hasta ahora:
<< Palabra revelada << "\n";
mostrar letras adivinadas (letras adivinadas);
}
216 Capítulo 8
Machine Translated by Google
Hay dos condiciones que pueden terminar el juego. O el jugador 2 descubre todos
los caracteres de la palabra, de modo que el número de letras descubiertas alcanza la
longitud de la palabra, o las malas conjeturas del jugador 2 completan el ahorcado, en
cuyo caso los fallos equivaldrán al máximo de fallos. Entonces el ciclo continúa mientras
no se haya producido ninguna condición . Dentro del ciclo, después de que el usuario
lee la siguiente suposición, se actualiza la posición correspondiente en letras adivinadas
. Entonces comienza el engaño. El programa determina cuántos candidatos quedarían
en la lista de palabras si la suposición se declarara fallida usando countWordsWithoutLetter
, y determina el máximo que podría quedar si la suposición se declarara acertada usando
mostFreqPatternByLetter . Si el primero es mayor, las palabras con la letra adivinada
se eliminan y los errores se incrementan . Si este último es más grande, tomaremos el
patrón proporcionado por mostFreqPatternByLetter y actualizaremos la palabra revelada,
al mismo tiempo que eliminaremos todas las palabras de la lista que no coincidan con el patrón
si (falla == maxMisses) {
cout << "Lo siento. Perdiste. La palabra en la que estaba pensando era '";
cout << (wordList.cbegin())­>c_str() << "'.\n";
} demás {
'"
<< Palabra revelada << "'.\n";
cout << "Buen trabajo. Tú ganas. Se corrió la voz
}
devolver 0;
}
El resto del código es lo que yo llamo un bucle postmortem, donde la acción posterior
al bucle está determinada por la condición que "mató" el bucle. En este caso, nuestro
programa logró hacer trampa para lograr la victoria o el Jugador 2, contra todo pronóstico,
obligó al programa a revelar la palabra completa. Tenga en cuenta que cuando el programa
gana, al menos una palabra debe permanecer en la lista, por lo que solo muestro la primera
palabra
y afirmo que fue en la que estuve pensando todo el tiempo. Un programa más
astuto podría seleccionar aleatoriamente una de las palabras restantes para reducir la
posibilidad de que el oponente detecte la trampa.
Análisis de resultados iniciales
Reuní todo este código, lo probé y funciona, pero claramente hay muchas mejoras por
hacer. Más allá de cualquier consideración de diseño, al programa le faltan muchas
funciones. No permite al usuario especificar el tamaño de la palabra del rompecabezas o
el número de acertijos incorrectos permitidos. No comprueba si la letra adivinada ya se
ha adivinado antes. De hecho, ni siquiera verifica que el carácter de entrada sea una letra
minúscula. Le faltan muchos detalles agradables en la interfaz, como decirle al usuario
cuántos fallos más hay disponibles. Creo que también sería bueno si el programa pudiera
ofrecer jugar nuevamente, en lugar de obligar al usuario a volver a ejecutar el programa.
En cuanto al diseño, cuando empiece a pensar en la versión final del programa,
consideraré seriamente un diseño orientado a objetos. Una clase de lista de palabras
ahora parece una elección natural. La función principal me parece demasiado grande. Me
gusta un diseño modular y fácil de mantener, y eso debería dar como resultado una función
principal que sea breve y simplemente dirija el tráfico entre los subprogramas que hacen
el trabajo real. Entonces mi función principal debe dividirse en
Pensando como un programador 217
.
Machine Translated by Google
varias funciones. Es posible que sea necesario repensar algunas de mis elecciones de diseño iniciales.
Por ejemplo, en retrospectiva, almacenar patrones como list<int> parece engorroso.
¿Quizás podría probar una matriz de bool, de manera análoga a las letras adivinadas?
O quizás debería buscar otra estructura completamente diferente. Ahora también es el momento
de dar un paso atrás para ver si hay oportunidades de aprender nuevas técnicas para resolver este
problema. Me pregunto si existen estructuras de datos especializadas que aún no he considerado y
que podrían resultar útiles. Incluso si termino apegándome a mis elecciones originales, podría aprender
mucho de la investigación.
Aunque todas estas decisiones aún son inminentes, siento que estoy bien encaminado.
camino con este proyecto. Tener un programa de trabajo que cumpla con los requisitos esenciales
del problema es un excelente lugar para estar. Puedo experimentar fácilmente con las diferentes
ideas de diseño en esta versión preliminar, con la confianza que surge al saber que ya tengo una
solución y que solo estoy buscando una solución mejor.
CREAR UN PUNTO DE RESTAURACIÓN
El sistema operativo Microsoft Windows crea lo que llama un punto de restauración antes de instalar o
modificar componentes del sistema. El punto de restauración contiene copias de seguridad de archivos
clave, como el registro. Si una instalación o actualización genera un problema grave, se puede
"revertir" o deshacer copiando los archivos desde el punto de restauración.
Recomiendo encarecidamente adoptar el mismo enfoque con su propio código fuente.
Cuando tenga un programa en funcionamiento que espera modificar más adelante, haga una copia de
todo el proyecto y modifique sólo la copia. Es rápido de hacer y puede ahorrarle un tiempo
considerable más adelante si sus modificaciones salen mal. Los programadores pueden caer
fácilmente en la trampa de pensar: “Lo logré una vez; por lo tanto, puedo hacerlo de nuevo”. Esto
suele ser cierto, pero hay una gran diferencia entre saber que puedes hacer algo nuevamente y
poder recuperar el código fuente antiguo para referencia instantánea.
También puede utilizar un software de control de versiones, que automatiza la copia y el
almacenamiento de archivos de proyecto. El software de control de versiones realiza más que la
función de "punto de restauración"; también puede permitir que varios programadores trabajen de
forma independiente en los mismos archivos, por ejemplo. Si bien estas herramientas están más allá
del alcance de este libro, son algo que debes investigar a medida que te desarrollas como programador.
El arte de resolver problemas
¿Reconoció todas las técnicas de resolución de problemas que empleé en mi solución hasta ahora?
Tenía un plan para resolver el problema. Como siempre, esta es la más crucial de todas las técnicas
de resolución de problemas. Decidí comenzar con lo que sabía para la primera versión de mi solución,
empleando un par de estructuras de datos con las que estaba muy familiarizado, matrices y la clase
lista . Reduje la funcionalidad para que fuera más fácil escribir mi versión preliminar y para permitirme
probar mi código antes de lo que podría hacerlo de otra manera. Dividí el problema en operaciones e
hice de cada operación una función diferente, lo que me permitió trabajar en partes del programa por
separado. Cuando no estaba seguro de cómo hacer trampa, experimenté, lo que me permitió reformular
"hacer trampa" como "maximizar el tamaño del candidato".
218 Capítulo 8
Machine Translated by Google
lista de palabras”, que era un concepto concreto para codificar. En los detalles de la
codificación de las operaciones, empleé técnicas análogas a las utilizadas a lo largo de
este libro.
También evité con éxito frustrarme, aunque supongo que tendrás que confiar en mi
palabra.
Antes de continuar, permítanme dejar claro que he demostrado los pasos que
tomó llegar a esta etapa en el proceso de solución de este problema. Estos no son
necesariamente los mismos pasos que seguiría para resolver este problema. El código
que se muestra arriba no es la mejor solución al problema y no es necesariamente mejor
de lo que se le ocurriría. Lo que espero que demuestre es que cualquier problema, sin
importar el tamaño, se puede resolver utilizando variaciones de las mismas técnicas
básicas utilizadas a lo largo de este libro. Si estuvieras abordando un problema dos
veces más grande que éste, o 10 veces más grande, podría poner a prueba tu paciencia,
pero podrías resolverlo.
Aprender nuevas habilidades de programación
Hay un tema más para discutir. Al dominar las técnicas de resolución de problemas de este
libro, estará dando un paso clave en el camino de la vida como programador. Sin embargo,
como ocurre con la mayoría de las profesiones, este es un camino sin destino, ya que
siempre debes esforzarte por mejorar como programador. Como ocurre con todo lo demás
en programación, debes tener un plan sobre cómo aprenderás nuevas habilidades y
técnicas, en lugar de simplemente confiar en que aprenderás cosas nuevas aquí y allá a lo
largo del camino.
En esta sección, analizaremos algunas de las áreas en las que es posible que desee
adquirir nuevas habilidades y algunos enfoques sistemáticos para cada una. El hilo
conductor de todas las áreas es que debes poner en práctica lo que quieres aprender. Es
por eso que cada capítulo de este libro termina con ejercicios:
y has estado trabajando en esos ejercicios, ¿verdad? Leer sobre nuevas ideas en
programación es un primer paso vital para aprenderlas, pero es sólo el primer paso. Para
llegar al punto en el que pueda emplear con confianza una nueva técnica en la solución de
un problema del mundo real, primero debe probar la técnica en un problema sintético más
pequeño. Recuerde que una de nuestras técnicas básicas de resolución de problemas
es descomponer problemas complejos, ya sea dividiendo el problema o reduciéndolo
temporalmente para que cada estado con el que estemos tratando tenga solo un elemento
no trivial. No querrás intentar resolver un problema no trivial al mismo tiempo que aprendes
la habilidad que será fundamental para tu solución porque entonces tu atención se dividirá
entre dos problemas difíciles.
Nuevos idiomas
Creo que C++ es un gran lenguaje de programación para código de producción y
expliqué en el primer capítulo por qué creo que también es un gran lenguaje para
aprender. Dicho esto, ningún lenguaje de programación es superior en todas las situaciones;
Por lo tanto, los buenos programadores deben aprender varios.
Pensando como un programador 219
Machine Translated by Google
Tómese el tiempo para aprender
Siempre que sea posible, debes darte tiempo para estudiar un nuevo lenguaje antes
de intentar escribir código de producción con uno. Si intenta resolver un problema no
trivial en un idioma que nunca antes ha usado, rápidamente irá en contra de una
importante regla de resolución de problemas: evitar la frustración. Fíjese la tarea de
aprender un idioma y complétela antes de asignarse cualquier programa "real" en ese
idioma.
Por supuesto, en el mundo real, a veces no tenemos el control total.
de cuando nos asignan proyectos. En cualquier momento, alguien podría solicitar
que escribiéramos un programa en un idioma en particular, y esa solicitud podría ir
acompañada de una fecha límite que nos impediría estudiar tranquilamente el idioma
antes de abordar el problema real. La mejor defensa contra esta situación es comenzar
a estudiar otros lenguajes de programación antes de que sea absolutamente
necesario conocerlos. Investiga los idiomas que te interesen o que se utilicen en áreas
en las que esperas programar durante tu carrera. Ésta es otra situación en la que una
actividad que parece un mal uso del tiempo en el corto plazo pagará grandes
dividendos en el largo plazo. Incluso si resulta que no necesitas el idioma que has
estudiado en un futuro cercano, estudiar otro idioma puede mejorar tus habilidades
con los otros idiomas que ya conoces porque te obliga a pensar de maneras nuevas
y diferentes, rompiendo sacarte de viejos hábitos y brindarte nuevas perspectivas
sobre tus habilidades y técnicas. Piense en ello como el equivalente en programación
del entrenamiento cruzado.
Comience con lo que sabe
Cuando empiezas a aprender un nuevo lenguaje de programación, por definición no
sabes nada al respecto. Sin embargo, si no es tu primer lenguaje de programación,
sabes mucho sobre programación. Entonces, un buen primer paso para aprender un
nuevo idioma es comprender cómo el código que ya sabes escribir en otro idioma se
puede escribir en el nuevo idioma.
Como se dijo antes, usted quiere aprender esto haciendo, no solo leyendo. Tome
programas que haya escrito en otros idiomas y reescríbalos en el nuevo idioma.
Investigue sistemáticamente elementos individuales del lenguaje, como declaraciones
de control, clases, otras estructuras de datos, etc. El objetivo es transferir la mayor
cantidad posible de conocimientos previos al nuevo idioma.
Investiga qué es diferente
El siguiente paso es estudiar qué tiene de diferente el nuevo idioma. Si bien dos
lenguajes de programación de alto nivel pueden tener grandes similitudes, algo
debe ser diferente con el nuevo idioma, o no habría razón para elegir este idioma
sobre cualquier otro. Nuevamente, aprenda haciendo. Simplemente leer, por ejemplo,
que la declaración de selección múltiple de un lenguaje permite rangos (en lugar de los
valores individuales de una declaración de cambio de C++ ) no es tan útil para su
desarrollo como escribir código que emplee de manera significativa esta capacidad.
220 Capítulo 8
Machine Translated by Google
Este paso es obviamente importante para lenguajes que son notablemente
diferentes, pero es igualmente importante para lenguajes que tienen un ancestro
común, como C++, C# y Java, que son todos descendientes de C orientados a
objetos. Las similitudes de sintaxis pueden engañarlo. Creer que sabes más sobre el
nuevo idioma de lo que realmente sabes. Considere el siguiente código:
lista de enterosLista de números de clase;
lista de números.addInteger(15);
Si estas líneas se le presentaran como código C++, comprendería que la primera
línea construye un objeto, numberList, de una clase, integerListClass, y la segunda
línea invoca un método addInteger en ese objeto. Si esa clase realmente existe y tiene
un método con ese nombre que toma un parámetro int , este código tiene mucho
sentido. Ahora supongamos que le dije que este código se había escrito en Java, no
en C++. Sintácticamente, no hay nada ilegal en estas dos líneas. Sin embargo, en
Java, una mera declaración de variable de un objeto de clase en realidad no construye
el objeto porque las variables del objeto son en realidad referencias.
es decir, se comportan de forma análoga a los punteros. Para realizar los pasos
equivalentes en Java, el código correcto sería:
integerListClass numberList = nueva integerListClass;
lista de números.addInteger(15);
Probablemente se dé cuenta rápidamente de esta diferencia particular entre Java
y C++, pero muchas otras diferencias podrían ser bastante sutiles. Si no se toma el
tiempo para descubrirlos, pueden dificultar mucho la depuración en el nuevo idioma.
A medida que escanea su código, su intérprete interno de lenguaje de programación
le proporcionará información incorrecta sobre lo que está leyendo.
Estudiar código bien escrito
A lo largo de este libro he señalado que no deberías intentar aprender a programar
tomando el código de otra persona y modificándolo. Hay ocasiones, sin embargo, en
las que el estudio del código de otra persona es vital. Si bien puedes desarrollar tus
habilidades en un nuevo idioma escribiendo una serie de programas originales, para
alcanzar un nivel de dominio, querrás buscar código escrito por un programador
experto en ese idioma.
No estás buscando "cubrir" este código; No vas a tomar prestado este código
para resolver un problema específico. En lugar de eso, estás mirando el código
existente para descubrir las "mejores prácticas" en ese lenguaje. Mire el código de un
programador experto y pregúntese no sólo qué está haciendo el programador sino
también por qué lo está haciendo. Si el código va acompañado de las explicaciones
del programador, mucho mejor. Diferenciar entre opciones de estilo y beneficios para
el rendimiento. Al completar este paso, evitará un error común.
Con demasiada frecuencia, los programadores aprenderán lo suficiente en un nuevo lenguaje para sobrevivir,
Pensando como un programador 221
Machine Translated by Google
y el resultado es un código débil que no utiliza todas las características del lenguaje.
Si es un programador de C++ y necesita escribir código en Java, por ejemplo, no querrá
conformarse con escribir código en pidgin C++; en su lugar, desea aprender a escribir
código Java real como lo haría un programador de Java.
Como con todo lo demás, pon en práctica lo que aprendes. Tome el código original
y modifíquelo para hacer algo nuevo. Guarde el código fuera de la vista e intente
reproducirlo. El objetivo es sentirse lo suficientemente cómodo con el código como para
poder responder preguntas al respecto de otro programador.
Es importante enfatizar que este paso viene después de los demás. Antes
Llegamos a la etapa de estudiar el código de otra persona en un nuevo idioma, ya
hemos aprendido la sintaxis y la gramática del nuevo idioma y hemos aplicado las
habilidades de resolución de problemas que aprendimos en otro idioma al nuevo idioma.
Si intentamos acortar el proceso comenzando el estudio del nuevo lenguaje con el
estudio de muestras de programas largos y la modificación de esas muestras, existe
un riesgo real de que eso sea todo lo que podamos hacer.
Nuevas habilidades para un idioma que ya conoces
El hecho de que llegues al punto en el que puedas decir que “conoces” un idioma no
significa que sepas todo sobre ese idioma. Incluso una vez que haya dominado la
sintaxis del idioma, siempre habrá nuevas formas de combinar las características
existentes del idioma para resolver problemas. La mayoría de estas nuevas formas se
incluirán en uno de los títulos de “componentes” del capítulo anterior, en el que
analizamos cómo desarrollar el conocimiento de los componentes. El factor importante
es el esfuerzo. Una vez que seas bueno resolviendo problemas de ciertas maneras,
es fácil confiar en lo que ya sabes y dejar de crecer como programador.
En ese punto, eres como un lanzador de béisbol que lanza una mala bola rápida pero
no sabe lanzar nada más. Algunos lanzadores han tenido carreras profesionales
exitosas con un solo lanzamiento, pero el lanzador que quiere pasar de relevista a
abridor necesita más.
Para ser el mejor programador posible, necesitas buscar nuevos conocimientos.
y nuevas técnicas y ponerlas en práctica. Busca desafíos y supéralos. Investigue el
trabajo de programadores expertos en los idiomas elegidos.
Recuerda que la necesidad es la madre de la invención. Busque problemas que
no puedan resolverse satisfactoriamente con sus habilidades actuales. A veces puedes
modificar problemas que ya has resuelto para ofrecer nuevos desafíos. Por ejemplo, es
posible que haya escrito un programa que funcione bien cuando el conjunto de datos
es pequeño, pero ¿qué sucede cuando permite que los datos crezcan hasta
proporciones gigantescas? ¿O qué sucede si ha escrito un programa que almacena
sus datos en el disco duro local, pero desea que los datos se almacenen de forma
remota? ¿Qué sucede si necesita múltiples ejecuciones del programa que puedan
acceder y actualizar los datos remotos simultáneamente? Al comenzar con un
programa funcional y agregar nuevas funciones, puede concentrarse solo en los aspectos nuevos de l
222 Capítulo 8
Machine Translated by Google
Nuevas bibliotecas
Los lenguajes de programación modernos son inseparables de sus bibliotecas principales.
Cuando aprenda C++, inevitablemente aprenderá algo sobre las bibliotecas de plantillas
estándar, por ejemplo, y cuando estudie Java, aprenderá sobre las clases estándar de Java.
Sin embargo, más allá de las bibliotecas incluidas con el idioma, necesitarás estudiar
bibliotecas de terceros. A veces se trata de marcos de aplicaciones generales, como el
marco .NET de Microsoft, que se puede utilizar con varios lenguajes de alto nivel
diferentes. En otros casos, la biblioteca es específica de un área particular, como OpenGL
para gráficos, o es parte de un paquete de software propietario de terceros.
Al igual que con el aprendizaje de un nuevo idioma, no debe intentar aprender una
nueva biblioteca durante un proyecto importante que requiera esa biblioteca. En su lugar,
aprenda los componentes principales de la biblioteca por separado en un proyecto de
prueba de importancia cero antes de emplearlos en un proyecto real. Asígnate una
progresión de problemas cada vez más difíciles de resolver. Recuerde que el objetivo
no es necesariamente completar ninguno de esos problemas, sólo aprender del proceso,
por lo que no necesita pulir las soluciones o incluso completarlas una vez que haya
empleado con éxito esa parte de la biblioteca en su programa. Estos programas pueden
servir luego como referencia para trabajos posteriores. Cuando te quedas atascado porque
no puedes recordar cómo, digamos, superponer una pantalla 2D sobre una escena 3D en
OpenGL, no hay nada mejor que poder abrir un programa antiguo que fue creado solo para
demostrar esa misma tecnología. ­nique y está escrito en tu propio estilo porque fue escrito
por ti.
Además, al igual que con el aprendizaje de un nuevo idioma, una vez que se sienta
cómodo con los conceptos básicos de una biblioteca, debe revisar el código escrito por
expertos en el uso de esa biblioteca. La mayoría de las bibliotecas grandes tienen
idiosincrasias y advertencias que no están expuestas en la documentación oficial y que,
aparte de una larga experiencia, solo pueden descubrirse a través de otros programadores.
En verdad, para avanzar mucho con algunas bibliotecas se requiere el uso inicial de un
marco proporcionado por otro programador. Lo importante es no confiar en el código de
otros más de lo necesario y llegar rápidamente a la etapa en la que recrea el código que se
le mostró originalmente. Es posible que se sorprenda de lo mucho que aprende del proceso
de recrear el código existente de otra persona. Es posible que vea una llamada a una
función de biblioteca en el código original y comprenda que los argumentos pasados en
esta llamada producen un resultado determinado. Sin embargo, cuando dejas de lado ese
código e intentas reproducir ese efecto por tu cuenta, te verás obligado a investigar la
documentación de la función, todos los valores particulares que los argumentos podrían
tomar y por qué tienen que ser lo que deben obtener. el efecto deseado.
Tomar una clase
Como educador desde hace mucho tiempo, siento que tengo que concluir esta sección
hablando de clases, no en el sentido de programación orientada a objetos, sino en el sentido
de un curso en una escuela. Cualquiera que sea el área de programación que desee
aprender, encontrará a alguien que se ofrecerá a enseñarle, ya sea en un aula tradicional.
Pensando como un programador 223
Machine Translated by Google
o en algún entorno en línea. Sin embargo, una clase es un catalizador del aprendizaje,
no el aprendizaje en sí, especialmente en un área como la programación. No importa
cuán conocedor o entusiasta sea un instructor de programación, cuando realmente
aprenda nuevas habilidades de programación, sucederá mientras está sentado frente
a su computadora, no como si estuviera sentado en una sala de conferencias. Como
reitero a lo largo de este libro, debes poner en práctica las ideas de programación y
debes hacerlas tuyas para aprenderlas verdaderamente.
Esto no quiere decir que las clases no tengan valor, porque a menudo tienen un
valor tremendo. Algunos conceptos de programación son inherentemente difíciles o
confusos, y si tiene acceso a un instructor con talento para explicar conceptos difíciles,
eso puede ahorrarle mucho tiempo y frustración. Además, las clases proporcionan una
evaluación de tu aprendizaje. Si vuelve a tener suerte con su instructor, podrá aprender
mucho de la evaluación de su código, lo que agilizará el proceso de aprendizaje.
Finalmente, completar con éxito una clase proporciona a los empleadores actuales o
futuros alguna evidencia de que usted comprende las materias impartidas (si no tiene
suerte y tiene un instructor deficiente, al menos puede consolarse con eso).
Solo recuerda que tu educación en programación es tu responsabilidad, incluso
cuando tomas una clase. Un curso proporcionará un marco para adquirir una calificación
y un crédito al final del trimestre, pero ese marco no te limita en tu aprendizaje. Piense
en su tiempo en la clase como una gran oportunidad para aprender tanto como sea
posible sobre el tema, más allá de los objetivos enumerados en el programa del curso.
Conclusión
Recuerdo con cariño mi primera experiencia en programación. Escribí una breve
simulación basada en texto de una máquina de pinball, y no, eso tampoco tiene ningún
sentido para mí, pero debió tenerlo en ese momento. Entonces no tenía computadora.
¿Quién lo hizo en 1976? Pero en la oficina de mi padre había una terminal de teletipo,
esencialmente una enorme impresora matricial con un teclado de clic­clac, que se
comunicaba con la computadora central de la universidad local a través de un módem
acústico. (Levantaste el teléfono para marcar a mano y cuando escuchaste un chirrido
electrónico, dejaste caer el auricular en un soporte especial conectado al terminal).
Por muy primitiva e inútil que fuera mi simulación de pinball, en el momento en que el
programa funcionó y la computadora actuó según mis instrucciones, quedé enganchado.
La sensación que tuve ese día (que una computadora era como una pila infinita
de Legos, juegos de construcción y troncos Lincoln, todos para que pudiera construir
cualquier cosa que pudiera imaginar) es lo que impulsa mi amor por la programación.
Cuando mi entorno de desarrollo anuncia una compilación limpia y mis dedos alcanzan
la tecla que comenzará la ejecución de mi programa, siempre estoy emocionado,
anticipando el éxito o el fracaso, y ansioso por ver los resultados de mis esfuerzos,
ya sea que esté escribiendo un proyecto de prueba simple o dando los toques finales
a una solución grande, o si estoy creando hermosos gráficos o simplemente
construyendo la interfaz de una aplicación de base de datos.
Espero que tengas sentimientos similares cuando programes. Incluso si todavía
tienes dificultades con algunas de las áreas cubiertas en este libro, espero que ahora
entiendas que mientras la programación te entusiasme tanto, siempre querrás
224 Capítulo 8
Machine Translated by Google
Para seguir adelante, no hay problema que no puedas resolver. Todo lo que se requiere
es la voluntad de esforzarse y emprender el proceso de la manera correcta. El tiempo
se encarga del resto.
¿Ya estás pensando como un programador? Si ha resuelto los ejercicios al final
de estos capítulos, entonces debería pensar como un programador y tener confianza
en su capacidad para resolver problemas. Si no has resuelto muchos de los ejercicios,
entonces tengo una sugerencia para ti y apuesto a que puedes adivinar cuál es:
Resuelve más ejercicios. Si se ha saltado algunos de los capítulos anteriores, no
comience con los ejercicios de este capítulo; vuelva al punto donde lo dejó y avance
desde allí. Si no quieres hacer más ejercicios porque no te gusta la programación,
entonces no puedo ayudarte.
Una vez que esté pensando como programador, siéntase orgulloso de sus
habilidades. Si alguien lo llama codificador en lugar de programador, diga que a un
pájaro bien entrenado se le podría enseñar a picotear código: no solo escribe código,
sino que lo usa para resolver problemas. Cuando esté sentado frente a una mesa de
entrevistas con un futuro empleador o cliente, sabrá que cualquier cosa que requiera
el trabajo, puede resolverlo.
Ejercicios
Tenías que saber que habría una última serie de ejercicios. Estos son, por supuesto,
más difíciles y más abiertos que cualquiera de los capítulos anteriores.
8­1. Escribe una implementación completa para el problema del ahorcado tramposo que sea
mejor que la mía.
8­2. Expande tu programa del ahorcado para que el usuario pueda elegir ser el Jugador 1.
El usuario aún selecciona el número de letras de la palabra y el número de conjeturas
fallidas, pero el programa adivina.
8­3.
Vuelva a escribir su programa del ahorcado en otro idioma, uno del que actualmente
sepa poco o nada.
8­4. Haz que tu juego del ahorcado sea gráfico, mostrando la horca y el ahorcado mientras se
construye. Estás intentando pensar como un programador, no como un artista, así que
no te preocupes por la calidad del arte. Sin embargo, debes crear un programa gráfico
real. No dibujes al verdugo usando texto ASCII; es demasiado fácil. Es posible que
desees investigar bibliotecas de gráficos 2D para C++ o elegir una plataforma diferente
que esté más orientada gráficamente para empezar, como Flash. Tener un ahorcado
gráfico puede requerir limitar el número de conjeturas erróneas, pero puede haber una
manera de ofrecer al menos una variedad de opciones para este número.
8­5. Diseña tu propio ejercicio: emplea las habilidades que aprendiste en el ahorcado
problema para resolver algo completamente diferente que implique manipular una
lista de palabras, como otro juego que use palabras, como Scrabble, un corrector
ortográfico o cualquier otra cosa que se te ocurra.
8­6. Diseña tu propio ejercicio: Busca un problema de programación en C++ de tal tamaño o
dificultad que estés seguro de que alguna vez hubieras considerado imposible
resolver con tus habilidades, y resuélvelo.
Pensando como un programador 225
Machine Translated by Google
8­7. Diseña tu propio ejercicio: Encuentra una biblioteca o API que te interese pero que
todavía tienes que usarlo en un programa. Luego investigue esa biblioteca o API y úsela
en un programa útil. Si está interesado en la programación general, considere la biblioteca
Microsoft .NET o una biblioteca de bases de datos de código abierto. Si te gustan los
gráficos de bajo nivel, considera OpenGL o DirectX. Si quieres intentar crear juegos,
considera un motor de juegos de código abierto como Ogre. Piensa en los tipos de
programas que te gustaría escribir, encuentra una biblioteca que se ajuste y ponte manos a la obra.
8­8. Diseña tu propio ejercicio: escribe un programa útil para una nueva plataforma (una
(eso es nuevo para usted), por ejemplo, programación web o móvil.
226 Capítulo 8
Machine Translated by Google
ÍNDICE
Números y símbolos
Operador && (lógico y), 48
evaluación de cortocircuito de, 129, 132,
133
& operador (dirección de), 85
& símbolo (parámetro de referencia), 84–
85, 137, 211, 213
*
operador (desreferencia), 59–60, 82, 128–
129, 138, 213–216
* símbolo (declaración de puntero), 59, 75,
82, 85, 99–100, 160, 177–
178, 186, 192
puntero a función, 177­178
== operador (igualdad), 197–198
= operador (asignación), 137–138, 197–
198
­> operador (deferencia de estructura),
102, 128
% operador (módulo), 33–34, 39–40,
50–52
matrices, 56
ARRAY_SIZE constante, 58
estadísticas agregadas, 61–62
operaciones básicas, 56–62
de bool, 209, 215
media informática, 61
declaración de matriz constante , 67
copiando, 57
asignación dinámica, 93, 97, 98
elemento, 56
encontrar el valor más grande en, 58–59,
66, 70–71, 73
de datos fijos, 67–69
inicialización, 57, 70, 71
mediana, 67
moda (estadística), 62–65
multidimensional, 71–74
tratar como una matriz de matrices,
72–74
cuándo usar, 71–72
no escalar, 69–71
procesamiento recursivo de, 153­155
A
tipo de datos abstractos, 116, 175, 183,
188–189
especificador de acceso, 112, 119, 125, 127
registro de activación, 86–87, 89–90
dirección del operador (&), 85
algoritmo, xv, 173–174, 176–177, 182–183,
188–193
buscando
basado en criterios, 58–59
por valor específico, 58
clasificación, 59–61, 189–193
clasificación por inserción,
60–61, 190–192, 193
qsort, 59–60, 192–193
de cuerda, 123
analogía. Ver encontrar una analogía
de estructura, 69–71
y (lógica booleana), 48
evaluación de cortocircuito de, 129, 132,
subíndice, 56, 66
frente a vectores, 75–76
133
interfaz de programación de aplicaciones (API),
176
cuándo usar, 74–78
operador de asignación (=), 137–138, 197–198
Machine Translated by Google
evitando la frustración, 21–22, 95–96, 201, 220,
224 dividiendo
validación de suma de comprobación,
31–32 flujo estándar cin, 26
problemas, 41
especificador de acceso de clase, 112,
119, 125,
B
127 marco básico, 119–122
excepción bad_alloc , 89 malos
olores, 65, 97, 192 caso base,
144, 162
composición, 126
constructor, 112–113, 119, 121–122,
126–127
Gran idea recursiva (BRI), 143, 152–155
árbol
binario vacío,
prueba de, 162 hoja, 163
procesamiento recursivo, 160–165, 166–
167 nodo
raíz, 161 subárbol,
161
miembro de datos, 112
declaración, 112–113 copia
profunda, 134–137
destructor, 133–134
estructuras de datos dinámicas,
125–140
encapsulación, 114, 126, 180
expresividad, 117–118, 121, 128 falso, 140–
141 método amigo ,
184 obtener y configurar,
C
119–121 objetivos de uso,
113–118 ocultación de
Declaración de matriz C++, 55
información, 115, 180 interfaz, 115
inicialización de matriz, 57
método, 112
como opción para este libro, xvii flujo
nombres de
estándar cin , 26 declaración de
métodos, elección, 117, 119–120 sobrecarga
de
clase, 112–113 flujo estándar cout ,
26 operador de eliminación , 83
operadores, 137 miembro privado,
excepción, 130
112 miembro protegido, 112
procesamiento de
miembro público, 112 copia
archivos, 210–211 función libre ,
superficial, 135 tarea única,
88 amigo palabra clave,
141 subclase, 112
184 método get , 34 archivos
método de soporte, 122
de encabezado para
plantilla, 141
entrada/salida, 26 clase de lista , 182–183,
validación, 121, 124 función
210–214, 216, 218 función malloc , 88
nuevo
operador, 75, 82, 97, 98
contenedora, 163–
165 rompecabezas clásicos
el Zorro, el Ganso y el
declaración de puntero, 82 requisitos
previos, xv parámetros de
referencia, 84
Maíz, 3–7, 15, 17, 20
evaluación de cortocircuito, 129,
rompecabezas de números móviles, 7–11,
132, 133
18 sudoku, 11–13
Bloqueo Quarrasi, 13–15, 20
Biblioteca de plantillas estándar, 175 esta
bloques de códigos,
palabra clave, 120
173 reutilización de códigos, 53, 172–173
palabra clave typedef , 91, 101, 127, 160,
177 códigos
de caracteres, 34–35
228 ÍNDICE
tipo de datos abstractos, 175
algoritmo, 173–174
aprendizaje según sea necesario, 180–188
Machine Translated by Google
uso de clase, 114
bloque de código,
copia profunda, 134–137
constructor predeterminado, 113, 122, 179
173 componente, 173
desreferenciación, 82
elección, 188–193
búsqueda, 182–183
patrón de diseño. Ver destructor
de patrones, 133–134
biblioteca, 175–176
diagramas, puntero, 92, 94, 96, 103
recursividad directa, 144
patrón, 174
DirectX, 176
propiedades, deseado, 172
función del despachador, 153–154
guardar código para uso posterior,
44, 67,
problemas de división, 17–18, 31–41, 41–
53 uso
aprendizaje exploratorio, 176–180
Validación de código 218. Consulte
de clases, 115
la función del comparador de
deslizamiento rompecabezas
pruebas, 59
de mosaicos, 8–11 división por
componentes, 173
tipos, 173–176 flexibilidad
de, 188–189 composición, 126
cero, 108, 198 lista
doblemente enlazada, 131 registro ficticio,
129, 179, 181, 186 estructuras de datos dinámicas, 158–165
constante
matrices, 67–69, 71
tipos numéricos, 58
parámetros, 59, 211
restricciones, 1–2, 6, 11–13, 19, 31, 33, 38,
40–41, 203
Y
eficiencia, 181–182, 193
encapsulación, 114, 126, 180 final de
línea
código de caracteres para,
importancia de, 26
constructor, 112–113, 119, 121–122, 126–127
constructor
de copia, 138 constructor
predeterminado, 113, 122, 179
conversión
entre rangos
37 búsqueda en flujo de caracteres, 38
operador de igualdad (==), 197–198
excepción, 130
experimentar con programas, 20–21,
28, 30, 37 expresividad,
117–118
dígito de carácter a entero, 35, 43–48
número
F
a letra del alfabeto, 49 trabajo de copiar y
clase falsa, 140–141
pegar, 173 constructor de
aprende rápido, 200–201
copias, 138 secuencia
estándar de cout , 26
codificador rápido, 200–
características progresivas,
cerca, 196 procesamiento de
201 enlaces cruzados, 100, 103, 134–135
archivos, 210–211 encuentra una analogía,
2, 20, 62,
cruces ­entrenamiento,
201 error de poste de
método 220 c_str , 211
93, 182, 191 crea tu propia analogía,
38–39
D
problemas de bucle, 29–30
referencia colgante, 90, 100, 125,
212
causada por entrecruzamiento, 136
miembro de datos, 112, 119–120
Problema de bloqueo de Quarrasi, 13­15
método de búsqueda (cadena), 211–
212 flexibilidad, 93, 154, 160, 188–189 el
zorro, el ganso y el maíz, 3–7, 15, 17, 20
redundancia de datos, 123–124
ÍNDICE 229
Machine Translated by Google
funciones
registro de activación, 86
comparador, 59
despachador, 153–154
salidas múltiples, 132
nombres, elección, 117, 119–120
j
Java, xiv, 111, 176, 221
JDBC, 176
k
puntero a, 177
recursivo, 152–165
Algoritmo del rey de la colina , 58, 66,
70–71, 73, 214–215
contenedor, 163–165
Kobayashi Maru, 2, 19, 26
frustración, 21. Véase también evitar la
frustración
l
aprender nuevas habilidades, 219–
224 trabajo de clase,
GRAMO
223–224 para idiomas conocidos,
222 bibliotecas,
obtener método (general), 119
obtener método (iostream), 34
h
223 nuevos idiomas, 219–222
lado izquierdo, 137
ahorcado, 204–218
biblioteca, 175–176, 223
de por vida,
puntero de cabeza, 103, 123, 127, 137
recursividad de cabeza, 144, 146–
147, 151–
90 listas enlazadas, 101–108,
175 agregar nodo a, 104–106, 128
edificio, 101–103
152 montón,
87–88
diagrama, 103
lista doblemente enlazada,
desbordamiento, 89
131 vacío, prueba de, 108
función auxiliar, 98 histograma, 65–66
puntero principal, 103, 123, 127, 137
iterador, 182–187
I
nodo, 101, 127
recursividad indirecta, 144
Terminador NULL , 103
ineficiencia en
recursividad, 168–169
procesamiento recursivo, 158–160
el espacio, 77
en el tiempo, 77, 181–
eliminación de nodos, 130–133
recorrido inverso, 168–169
182 ocultación de información, 115–
acceso secuencial, 103
recorrido, 106–108, 129, 168–169, 179,
117 procesamiento de
entrada, 31–41 iteración, 25.
Consulte también clase de
181 clase
iterador de bucle , 183,
de lista , 182 –183, 210–214, 216,
210 método de inicio ,
183 const_iterator,
de búsqueda, 67
211 final método, 183
bucles, 26–41, 71, 94 bucle
método de borrado , 212
postmortem, 217
218 tabla
método de búsqueda , 211–
212 patrón de iterador, 183–187
avanzar al
METRO
plan maestro, 196–203
siguiente nodo,
mediana, 67
185 beneficios, 183 inicialización, 185 métodos, 184
miembros, 112
230 ÍNDICE
Machine Translated by Google
registro de activación
de asignación de
igualdad (==), 197–198
memoria, 86
lógico y (&&), 48
evaluación de cortocircuito de,
matriz, 74, 97 excepción
bad_alloc , 89 en
módulo (%), 33–34, 39–40, 50–52
clases, 125–140 referencia
colgante, 90, 100
operador de eliminación ,
83 fragmentación,
129, 132, 133
sobrecarga, 137–138
exceso de confianza,
199 desbordamiento
montón,
89 pila, 89–90
87–88 función
libre , 88 montón, 87–
88 desbordamiento de
montón, 89
fuga (ver pérdida de
memoria) vida útil, 90 función
malloc , 88 nuevo operador, 75,
82, 97, 98 razones para
minimizar, 88–
90 pila, 86–87, 89–90 paliza, 89
sobrecarga, 137–138
PAG
parámetros
funciones recursivas, uso en,
155–156
referencia, 84
patrón, 174
iterador, 183–187
fragmentación de
política, 176–180
memoria, 87–
88 pérdida de memoria,
singleton, 174
75, 90 evitando, 95
función contenedora, 174
conjunto mínimo de datos, 160 modo
(estadística),
estrategia, 176–180
ineficiencia de
rendimiento en el espacio, 77, 85
62 operador de módulo (%), 33–34,
ineficiencia en el tiempo, 77,
37, 39–40, 50–52 variable más restringida, 12 matriz multidimensional,
181–182,71–74
193
tratar como una matriz de matrices, 72–
74, cuándo usar, 71–72
sintonización,
77 planificación, 16–17, 33, 95–96, 173
individualidad de, 40
plan maestro, 196–203
norte
nuevo operador, 75, 82, 97, árbol
binario
de 98 nodos, 160–161, 163
lista enlazada, 101, 127
carga útil, 102, 145
valor npos , 211–212
consejos
beneficios de, 83–84
entrecruzamiento,
100 declaración, 59, 75 , 82, 85,
99–100, 160, 177–178,
186, 192
Puntero NULO , 90
desreferenciación, 59–60, 82,
128–129, 138, 213–216
oh
diagramas, 92, 94, 96, 103
para funcionar, 177
OpenGL, 223
Puntero NULL , 90
operadores
dirección de (&), 85
parámetros de referencia, 84
cuándo usar, 84
asignación (=), 137–138, 197–
198
desreferenciación (*), 59–60, 82,
128–129, 138, 213–216
política, 176–180
miembro público, 112
método push_back , 76
miembro privado, 112
ÍNDICE 231
Machine Translated by Google
protegido, 112 requisitos previos,
punto de restauración, 218
reutilización. Consulte
propiedad xv (C#), 120
reutilización de código en el
pseudocódigo, 63 conversión
lado derecho, 137 programas robustos, definición
a documentación, 64
de, 96 nodo raíz, 161
resolución de problemas, xiii–xv, 2, 203–219 miembro
resolución de problemas con, 63–64
estructura de datos del tamaño de tiempo de ejecución, 83
S
q
variable escalar, 55 acceso
qsort, 59–60, 65, 192–193 función de
comparación, 59, 192
Problema de bloqueo de Quarrasi, 13­15, 20
secuencial, 103 búsqueda
secuencial, 58 método de
configuración , 119
copia superficial, 135
evaluación de cortocircuito, 129 tarea
R
acceso aleatorio, 56, 78 creación
rápida de prototipos, 201
legibilidad, 117
recursividad, 143
caso base, 144 Gran
única, 141 singleton, 174
rompecabezas de
números deslizantes, 7–11, 18 resolución por
caso de muestra, 92– 96 clasificación, 59,
176–177, 189–193 clasificación por inserción,
60–61, 190–192, 193 qsort, 59–
idea recursiva, 143, 152–155 árbol binario, 160–
165 ruta de navegación, 166–
169 errores comunes, 155–158 directo,
144 estructuras de datos dinámicas, que
se aplican a,
158–165 cabeza, 144, 146–147,
151–152 indirecta, 144 lista
enlazada, 158–160 frente a pila, 166–169
cola, 144, 145–
146, 149–150 cuándo uso,
165–169 función
contenedora, 163–165 problemas de
reducción, 19–20, 41–53, 63,
190 problemas de bucle, 26–29 datos
redundantes, 123–124 refactorización, 65–67, 180,
200
parámetros de referencia, 84 –
85, 137, 211, 213 const, 211, 213
estructura de datos redimensionable, 83
problemas de reformulación, 17, 33, 42, 182, 193
el zorro, el
ganso y el maíz, 5–7
problemas de bucle, 31
60, 192–193 casos
especiales, 96 verificación, 96–
97, 100, 124, 128, 132,
198­199
pila, 86 lista
enlazada, 175
desbordamiento, 89–
90 tiempo de ejecución,
86–87 comenzando con lo que sabe, 18–19, 62, 92
problemas de bucle, 29–30
variable más restringida, 12 sudoku, 11–13
estrategia, 176– 180
clase de cadena , 119
matriz, 123 método
c_str , 211
método de búsqueda ,
211–212 valor npos , 211–212
cadenas, 91 implementación
de matriz, 91–
100 copia, 98
Estilo C, 178
implementación de lista enlazada, 101–
107
terminador, 93
232 ÍNDICE
Machine Translated by Google
estructura,
69 deferencia de estructura (­>), 102,
128 subclase,
122 subíndice,
56 sudoku, 11–
13 método de soporte, 122–125
t
recursividad de cola, 144, 145–146,
149–150
clase de plantilla, 141
desarrollo basado en pruebas, 200
pruebas, 124, 190, 199–200, 215
pérdidas de memoria,
95 promoción de la facilidad de, 34,
57, 66,
70, 218 almacenar programas
de prueba, 44 casos de prueba,
codificación, 93, 98–
100, 130–134, 186–
187 esta palabra
clave, 120 paliza, 89
estado de seguimiento, 50­51 recorrido, lista vinculada, 106–108, 129,
168–169, 179, 181
palabra clave typedef , 91, 101–102,
127, 160, 177
EN
suma de
comprobación de
validación 31–32
código (ver pruebas) datos, 61–62, 92,
96, 121, 124–
125 vectores, 55 vs.
matrices, 75–76
declaración, 76 método push_back , 76
EN
debilidades
debilidades de codificación, 196, 197–199
debilidades de diseño, 196, 199–200
espacios en
blanco, 34 función contenedora, 163–165, 174
ÍNDICE 233
Machine Translated by Google
Machine Translated by Google
Think Like a Programmer está ambientado en New Baskerville, TheSansMono
Condensed, Futura y Dogma.
Este libro fue impreso y encuadernado en Edwards Brothers Malloy en Ann
Arbor, Michigan. El papel es Glatfelter Spring Forge 60# Antique, certificado por la
Sustainable Forestry Initiative (SFI). El libro utiliza una encuadernación
RepKover, que le permite permanecer plano cuando está abierto.
Machine Translated by Google
La Electronic Frontier Foundation (EFF) es la organización líder en defensa de
las libertades civiles en el mundo digital. Defendemos la libertad de expresión
en Internet, luchamos contra la vigilancia ilegal, promovemos los derechos de
los innovadores a desarrollar nuevas tecnologías digitales y trabajamos para
garantizar que los derechos y libertades que disfrutamos mejoren (en
lugar de erosionarse) a medida que crece nuestro uso de la tecnología.
PRIVACIDAD
EFF ha demandado al gigante de las telecomunicaciones AT&T por dar a la NSA acceso ilimitado a las
comunicaciones privadas de millones de sus clientes. eff.org/nsa
LIBERTAD DE EXPRESIÓN
El Proyecto de Derechos de los Codificadores de la EFF defiende los derechos de los programadores e
investigadores de seguridad a publicar sus hallazgos sin temor a desafíos legales. eff.org/
libertad de expresión
INNOVACIÓN
El Proyecto Anti Patentes de la EFF desafía las patentes excesivamente amplias que amenazan
la innovación tecnológica. eff.org/patent
USO JUSTO
La EFF está luchando contra estándares prohibitivos que le quitarían el derecho a recibir y utilizar transmisiones
de televisión por aire de la forma que elija. eff.org/IP/fairuse
TRANSPARENCIA
EFF ha desarrollado la herramienta de prueba de red de Suiza para brindar a las personas las herramientas para
probar el filtrado de tráfico encubierto. eff.org/transparencia
INTERNACIONAL
EFF está trabajando para garantizar que los tratados internacionales no restrinjan nuestra libertad de expresión,
privacidad o derechos de los consumidores digitales. eff.org/global
EFF es una organización apoyada por sus miembros. ¡Únete ahora! www.eff.org/support
Machine Translated by Google
Más libros sensatos de
SIN PRENSA DE ALMIDÓN
¡APRENDE UN HASKELL
TIERRA DE LISP
JAVASCRIPT ELOCUENTE
PARA UN GRAN BIEN!
¡Aprenda a programar en
Lisp, un juego a la
Una introducción moderna a la
Una guía para principiantes
de MIRAN LIPOVAČA
ABRIL DE 2011, 400 págs., 44,95
dólares ISBN 978­1­59327­283­8
programación por
vez! por CONRAD BARSKI, MD
MARIJN HAVERBEKE
OCTUBRE DE 2010, 504 págs., 49,95
dólares ISBN 978­1­59327­281­4
ENERO DE 2011, 224 págs., 29,95
dólares ISBN 978­1­59327­282­1
HACKING, 2DA EDICIÓN El Arte de la
PYTHON PARA NIÑOS Una
EL ARTE DE LA PROGRAMACIÓN R Un recorrido
Explotación por JON ERICKSON
introducción lúdica a la programación por JASON R.
por el diseño de software estadístico por NORMAN
FEBRERO 2008, 488
PP. Con CD, $49,95 ISBN 978­1­59327­144­2
BRIGGS NOVIEMBRE
MATLOFF OCTUBRE DE
2011, 400 págs., 39,95 dólares ISBN
978­1­59327­384­2
DE 2012, 346 PP. a todo color, $29,95 ISBN
978­1­59327­407­8
TELÉFONO:
800.420.7240 O 415.863.9900
CORREO ELECTRÓNICO:
VENTAS@NOSTARCH.COM
WEB:
WWW.NOSTARCH.COM
Machine Translated by Google
ACTUALIZACIONES
Visite http://nostarch.com/thinklikeaprogrammer para obtener actualizaciones, erratas y
otra información.
Machine Translated by Google
l
YYPAG
TU CEREBRO TU CEREBRO
El verdadero desafío de la programación no es aprender un
la sintaxis del lenguaje: es aprender a resolver problemas
creativamente para poder construir algo grandioso.
En este texto único en su tipo, el autor V. Anton Spraul analiza las formas
en que los programadores resuelven problemas y
te enseña lo que otros libros introductorios a menudo ignoran:
cómo pensar como un programador. Cada capítulo aborda
un único concepto de programación, como clases, punteros,
y recursividad, y ejercicios abiertos a lo largo
Retarte a aplicar tus conocimientos.
También aprenderá cómo:
• Dominar herramientas de programación más avanzadas como la recursividad.
y memoria dinámica
• Organice sus pensamientos y desarrolle estrategias para abordar
tipos particulares de problemas
Aunque los ejemplos del libro están escritos en C++, el
Los conceptos creativos de resolución de problemas que ilustran van más allá.
cualquier idioma en particular; de hecho, a menudo van más allá del ámbito de
la informática. Como saben los programadores más hábiles, escribir código
excelente es un arte creativo, y el primero
El primer paso para crear tu obra maestra es aprender a pensar como un
programador.
• Dividir los problemas en componentes discretos para que sean más
SOBRE EL AUTOR
fáciles de resolver.
• Aprovechar al máximo la reutilización de código con funciones, clases,
y bibliotecas
PROGRAMACIÓN/
GENERALES
GUARDAR
EN:
S
oh
C
t
RENOVAR EL ALAMBRADO DE
A
DY
Y
Machine Translated by Google
V. Anton Spraul ha enseñado programación introductoria y
informática desde hace más de 15 años. Este libro es un
destilación de las técnicas que ha utilizado y perfeccionado
• Elija la estructura de datos perfecta para un trabajo en particular
Muchas sesiones individuales con programadores con dificultades.
También es autor de Computer Science Made Simple.
LO MEJOR EN GE EKE NTERTA INME NT ™
www.nostarch.com
“ME VIVO PLANO”.
Este libro utiliza RepKover, una encuadernación duradera que no se cierra de golpe.
$34.95 ($36.95 CDN)
Download