Corrector ortográfico
Problema a resolver
Para este problema, implementarás un programa que corrija la ortografía de un archivo, como el siguiente, utilizando una tabla hash.
Demostración
Código de distribución
Para este problema, ampliarás la funcionalidad del código proporcionado por el personal de CS50.
Inicia sesión en cs50.dev, haz clic en la ventana del terminal y ejecuta cd
por sí solo. Deberías encontrar que el indicador de la ventana de tu terminal se asemeja al siguiente:
$
A continuación, ejecuta
wget https://cdn.cs50.net/2023/fall/psets/5/speller.zip
para descargar un ZIP llamado speller.zip
en tu espacio de códigos.
A continuación, ejecuta
unzip speller.zip
para crear una carpeta llamada speller
. Ya no necesitas el archivo ZIP, por lo que puedes ejecutar
rm speller.zip
y responde con "y" seguido de Enter en el indicador para eliminar el archivo ZIP descargado.
Ahora escribe
cd speller
seguido de Enter para moverte (es decir, abrir) ese directorio. Tu indicador ahora debería parecerse al siguiente.
speller/ $
Ejecuta ls
por sí solo y deberías ver algunos archivos y carpetas:
dictionaries/ dictionary.c dictionary.h keys/ Makefile speller.c speller50 texts/
Si tienes algún problema, sigue estos mismos pasos de nuevo y ¡observa si puedes determinar dónde te equivocaste!
Antecedentes
Dados los numerosos archivos de este programa, es importante leer esta sección en su totalidad antes de comenzar. ¡Entonces sabrás qué hacer y cómo hacerlo!
Teóricamente, con una entrada de tamaño n, un algoritmo con un tiempo de ejecución de n es "asintóticamente equivalente", en términos de O, a un algoritmo con un tiempo de ejecución de 2n. De hecho, al describir el tiempo de ejecución de un algoritmo, normalmente nos centramos en el término dominante (es decir, el más impactante) (es decir, n en este caso, ya que n podría ser mucho mayor que 2). Sin embargo, en el mundo real, el hecho es que 2n se siente el doble de lento que n.
El desafío que tienes por delante es implementar el corrector ortográfico más rápido que puedas. Sin embargo, por "más rápido", nos referimos al "muro del reloj" real, no al tiempo asintótico.
En speller.c
, hemos creado un programa diseñado para corregir la ortografía de un archivo después de cargar un diccionario de palabras del disco a la memoria. Ese diccionario, mientras tanto, se implementa en un archivo llamado dictionary.c
. (Simplemente podría implementarse en speller.c
, pero a medida que los programas se vuelven más complejos, a menudo es conveniente dividirlos en varios archivos). Los prototipos para las funciones incluidas, mientras tanto, se definen no en dictionary.c
sino en dictionary.h
. De esa manera, tanto speller.c
como dictionary.c
pueden #include
el archivo. Desafortunadamente, no llegamos a implementar la parte de carga. O la parte de verificación. ¡Ambos (y un poco más) te lo dejamos a ti! Pero primero, una visita.
Entendiendo
dictionary.h
Abre dictionary.h
y verás una nueva sintaxis, incluidas algunas líneas que mencionan DICTIONARY_H
. No hay necesidad de preocuparse por esos, pero, si tienes curiosidad, esas líneas solo aseguran que, aunque dictionary.c
y speller.c
(que verás en un momento) #include
este archivo, clang
solo lo compilará una vez.
A continuación, observa cómo #include
un archivo llamado stdbool.h
. Ese es el archivo en el que se define bool
. No lo has necesitado antes, ya que la biblioteca CS50 solía #include
eso por ti.
También observa nuestro uso de #define
, una "directiva de preprocesador" que define una "constante" llamada LENGTH
que tiene un valor de 45
. Es una constante en el sentido de que no puedes (accidentalmente) cambiarla en tu propio código. De hecho, clang
reemplazará cualquier mención de LENGTH
en tu propio código con, literalmente, 45
. En otras palabras, no es una variable, solo un truco de búsqueda y reemplazo.
Finalmente, observa los prototipos para cinco funciones: check
, hash
, load
, size
y unload
. Observa cómo tres de ellos toman un puntero como argumento, según el *
:
bool check(const char *word);
unsigned int hash(const char *word);
bool load(const char *dictionary);
Recuerda que char *
es lo que solíamos llamar string
. Por lo tanto, esos tres prototipos son esencialmente solo:
bool check(const string word);
unsigned int hash(const string word);
bool load(const string dictionary);
Y const
, mientras tanto, solo dice que esas cadenas, cuando se pasan como argumentos, deben permanecer constantes; ¡no podrás cambiarlas, accidental o de otra manera!
dictionary.c
Ahora abre dictionary.c
. Observa cómo, en la parte superior del archivo, hemos definido una struct
llamada node
que representa un nodo en una tabla hash. Y hemos declarado una matriz de punteros global, table
, que (pronto) representará la tabla hash que usarás para llevar un registro de las palabras en el diccionario. La matriz contiene N
punteros de nodo, y hemos establecido N
igual a 26
por ahora, para que coincida con la función hash
predeterminada como se describe a continuación. Es probable que desees aumentar esto según tu propia implementación de hash
.
A continuación, observa que hemos implementado load
, check
, size
y unload
, pero apenas lo suficiente para que se compile el código. Observa también que hemos implementado hash
con un algoritmo de muestra basado en la primera letra de la palabra. Tu trabajo, en última instancia, es reimplementar esas funciones de la manera más inteligente posible para que este corrector ortográfico funcione como se anuncia. ¡Y rápido!
speller.c
Bien, ahora abre speller.c
y dedica algo de tiempo a revisar el código y los comentarios contenidos en él. No necesitarás cambiar nada en este archivo y no necesitas entender su totalidad, pero intenta hacerte una idea de su funcionalidad de todos modos. Observa cómo, por medio de una función llamada getrusage
, usaremos “evaluadores comparativos” (por ejemplo, mediremos la ejecución de) tus implementaciones de check
, load
, size
y unload
. Observa también cómo pasamos a check
, palabra por palabra, los contenidos de un archivo para que se revise su ortografía. Finalmente, informamos cada error de ortografía en ese archivo junto con un montón de estadísticas.
Observa, por cierto, que hemos definido el uso de speller
como:
Uso: speller [diccionario] texto
donde se asume que diccionario
es un archivo que contiene una lista de palabras en minúsculas, una por línea, y texto
es un archivo que se revisará su ortografía. Como sugiere el corchete, la provisión de diccionario
es opcional; si se omite este argumento, speller
usará dictionaries/large
de forma predeterminada. En otras palabras, ejecutar:
./speller texto
será equivalente a ejecutar:
./speller dictionaries/large texto
donde texto
es el archivo que deseas revisar su ortografía. Baste decir que el anterior es más fácil de escribir. (Por supuesto, speller
no podrá cargar ningún diccionario hasta que implementes load
en dictionary.c
. Hasta entonces, verás No se pudo cargar
).
Dentro del diccionario predeterminado, debes tener en cuenta que hay 143,091 palabras, ¡todas las cuales deben cargarse en la memoria! De hecho, echa un vistazo a ese archivo para hacerte una idea de su estructura y tamaño. Observa que cada palabra en ese archivo aparece en minúsculas (incluso, por simplicidad, nombres propios y acrónimos). De arriba hacia abajo, el archivo está ordenado lexicográficamente, con solo una palabra por línea (cada una de las cuales termina con \n
). Ninguna palabra tiene más de 45 caracteres y ninguna palabra aparece más de una vez. Durante el desarrollo, puedes encontrar útil proporcionar a speller
un diccionario
propio que contenga muchas menos palabras, para no tener que esforzarte en depurar una estructura enormemente grande en la memoria. En dictionaries/small
se encuentra uno de esos diccionarios. Para usarlo, ejecuta:
./speller dictionaries/small texto
donde texto
es el archivo que deseas revisar su ortografía. ¡No sigas adelante hasta que estés seguro de entender cómo funciona speller
!
Es probable que no hayas dedicado suficiente tiempo a revisar speller.c
. ¡Retrocede un cuadrado y revísalo de nuevo!
texts/
Para que puedas probar tu implementación de speller
, también te hemos proporcionado un montón de textos, entre ellos el guion de La La Land, el texto de la Ley de Atención Médica Asequible, tres millones de bytes de Tolstoi, algunos extractos de The Federalist Papers y Shakespeare, y más. Para que sepas qué esperar, abre y hojea cada uno de esos archivos, todos los cuales se encuentran en un directorio llamado texts
dentro de tu directorio pset5
.
Ahora, como debes saber por haber leído speller.c
cuidadosamente, el resultado de speller
, si se ejecuta con, digamos:
./speller texts/lalaland.txt
eventualmente se parecerá a lo siguiente.
A continuación, se muestra parte de la salida que verás. A modo de información, hemos extraído algunos ejemplos de "errores ortográficos". Y para no estropear la diversión, hemos omitido nuestras propias estadísticas por ahora.
PALABRAS MAL ESCRITAS
[...]
AHHHHHHHHHHHHHHHHHHHHHHHHHHHT
[...]
Shangri
[...]
fianc
[...]
Sebastian's
[...]
PALABRAS MAL ESCRITAS:
PALABRAS EN EL DICCIONARIO:
PALABRAS EN EL TEXTO:
TIEMPO EN `load`:
TIEMPO EN `check`:
TIEMPO EN `size`:
TIEMPO EN `unload`:
TIEMPO TOTAL:
TIEMPO EN load
representa el número de segundos que speller
dedica a ejecutar tu implementación de load
. TIEMPO EN check
representa el número de segundos que speller
dedica, en total, a ejecutar tu implementación de check
. TIEMPO EN size
representa el número de segundos que speller
dedica a ejecutar tu implementación de size
. TIEMPO EN unload
representa el número de segundos que speller
dedica a ejecutar tu implementación de unload
. TIEMPO TOTAL
es la suma de esas cuatro mediciones.
Ten en cuenta que estos tiempos pueden variar algo entre las ejecuciones de speller
, según lo que esté haciendo tu zona de trabajo de códigos, incluso si no cambias tu código.
Por cierto, para ser claros, con "mal escrito" simplemente queremos decir que alguna palabra no está en el diccionario
proporcionado.
Makefile
Y, por último, recuerda que make
automatiza la compilación de tu código para que no tengas que ejecutar clang
manualmente junto con un montón de interruptores. Sin embargo, a medida que tus programas crezcan en tamaño, make
ya no podrá inferir del contexto cómo compilar tu código; tendrás que empezar a indicarle a make
cómo compilar tu programa, particularmente cuando involucran múltiples fuentes (por ejemplo, archivos .c
), como en el caso de este problema. Así que utilizaremos un Makefile
, un archivo de configuración que le dice a make
exactamente qué hacer. Abre Makefile
y deberías ver cuatro líneas:
- La primera línea le dice a
make
que ejecute las siguientes líneas cada vez que tú mismo ejecutesmake speller
(o simplementemake
). - La segunda línea le dice a
make
cómo compilarspeller.c
en código máquina (es decir,speller.o
). - La tercera línea le dice a
make
cómo compilardictionary.c
en código máquina (es decir,dictionary.o
). - La cuarta línea le dice a
make
que vinculespeller.o
ydictionary.o
en un archivo llamadospeller
.
Asegúrate de compilar speller
ejecutando make speller
(o simplemente make
). ¡Ejecutar make dictionary
no funcionará!
Especificaciones
De acuerdo, el desafío ahora ante ti es implementar, en orden, load
, hash
, size
, check
y unload
de la manera más eficiente posible usando una tabla hash de tal manera que TIME IN load
, TIME IN check
, TIME IN size
y TIME IN unload
sean minimizados. Para estar seguros, no es obvio lo que significa minimizado, ya que estos benchmarks ciertamente variarán a medida que pases a speller
diferentes valores para dictionary
y text
. Pero ahí radica el desafío, si no la diversión, de este problema. Este problema es tu oportunidad de diseñar. Aunque te invitamos a minimizar el espacio, tu enemigo definitivo es el tiempo. Pero antes de sumergirte, algunas especificaciones de nosotros.
- No puedes modificar
speller.c
oMakefile
. - Puedes modificar
dictionary.c
(y, de hecho, debes hacerlo para completar las implementaciones deload
,hash
,size
,check
yunload
), pero no puedes modificar las declaraciones (es decir, prototipos) deload
,hash
,size
,check
ounload
. Sin embargo, puedes añadir nuevas funciones y variables (locales o globales) adictionary.c
. - Puedes cambiar el valor de
N
endictionary.c
, para que tu tabla hash pueda tener más cubos. - Puedes modificar
dictionary.h
, pero no puedes modificar las declaraciones deload
,hash
,size
,check
ounload
. - Tu implementación de
check
debe ser insensible a mayúsculas y minúsculas. En otras palabras, sifoo
está en el diccionario, entoncescheck
debe devolver verdadero dado cualquier uso de mayúsculas y minúsculas; ninguno defoo
,foO
,fOo
,fOO
,fOO
,Foo
,FoO
,FOo
yFOO
debe considerarse mal escrito. - Dejando de lado las mayúsculas y minúsculas, tu implementación de
check
sólo debería devolvertrue
para palabras que estén realmente endictionary
. Ten cuidado con la codificación de palabras comunes (por ejemplo,the
), no sea que pasemos tu implementación de undictionary
sin esas mismas palabras. Además, los únicos posesivos permitidos son los que están realmente endictionary
. En otras palabras, incluso sifoo
está endictionary
,check
debe devolverfalse
dadofoo's
sifoo's
no está también endictionary
. - Puedes asumir que cualquier
dictionary
pasado a tu programa estará estructurado exactamente como el nuestro, ordenado alfabéticamente de arriba a abajo con una palabra por línea, cada una de las cuales termina con\n
. También puedes suponer quedictionary
contendrá al menos una palabra, que ninguna palabra tendrá más deLENGTH
(una constante definida endictionary.h
) caracteres, que ninguna palabra aparecerá más de una vez, que cada palabra contendrá sólo caracteres alfabéticos en minúsculas y posiblemente apóstrofes, y que ninguna palabra comenzará con un apóstrofe. - Puedes asumir que
check
sólo recibirá palabras que contengan caracteres alfabéticos (en mayúsculas o minúsculas) y posiblemente apóstrofes. - Tu corrector ortográfico sólo puede tomar
text
y, opcionalmente,dictionary
como entrada. Aunque podrías estar inclinado (especialmente si estás entre los más cómodos) a "preprocesar" nuestro diccionario predeterminado para derivar una "función hash ideal" para él, no puedes guardar la salida de dicho preprocesamiento en el disco para volver a cargarlo en la memoria en ejecuciones posteriores de tu corrector ortográfico para obtener una ventaja. - Tu corrector ortográfico no debe perder memoria. Asegúrate de comprobar las pérdidas con
valgrind
. - La función hash que escribas debería ser finalmente tuya, no una que busques en línea.
Bien, ¿listo para empezar?
- Implementa
load
. - Implementa
hash
. - Implementa
size
. - Implementa
check
. - Implementa
unload
.
Sugerencias
Implementa load
Completa la función load
. load
debe cargar el diccionario en la memoria (¡en particular, en una tabla hash!). load
debe devolver true
si tiene éxito y false
si no.
Considera que este problema está compuesto sólo por problemas más pequeños:
- Abre el archivo del diccionario
- Lee cada palabra en el archivo 1. Añade cada palabra a la tabla hash
- Cierra el archivo del diccionario
Escribe un pseudocódigo para recordarte que hagas precisamente eso:
bool load(const char *dictionary)
{
// Abre el archivo del diccionario
// Lee cada palabra en el archivo
// Añade cada palabra a la tabla hash
// Cierra el archivo del diccionario
}
Considera primero cómo abrir el archivo del diccionario. fopen
es una opción natural. Puedes usar el modo r
, dado que sólo necesitas leer palabras del archivo del diccionario (no escribirlas o añadirlas).
bool load(const char *dictionary)
{
// Abre el archivo del diccionario
FILE *source = fopen(dictionary, "r");
// Lee cada palabra en el archivo
// Añade cada palabra a la tabla hash
// Cierra el archivo del diccionario
}
Antes de continuar, debes escribir código para comprobar si el archivo se abrió correctamente. ¡Eso depende de ti! También es mejor asegurarse de cerrar todos los archivos que abras, así que ahora es un buen momento para escribir el código para cerrar el archivo del diccionario:
bool load(const char *dictionary)
{
// Abre el archivo del diccionario
FILE *source = fopen(dictionary, "r");
// Lee cada palabra en el archivo
// Añade cada palabra a la tabla hash
// Cierra el archivo del diccionario
fclose(source);
}
Lo que queda es leer cada palabra del archivo y añadirla a la tabla hash. Devuelve true
cuando toda la operación sea exitosa y false
si falla alguna vez. Considera seguir el recorrido de este problema y continúa rompiendo los subproblemas en problemas aún más pequeños. Por ejemplo, añadir cada palabra a la tabla hash podría ser sólo cuestión de implementar unos pocos pasos aún más pequeños:
- Crea espacio para un nuevo nodo de tabla hash
- Copia la palabra en el nuevo nodo
- Convierte la palabra en un hash para obtener su valor de hash
- Inserta el nuevo nodo en la tabla hash (usando el índice especificado por su valor de hash)
Por supuesto, hay más de una manera de abordar este problema, cada una con sus propias ventajas y desventajas de diseño. Por esa razón, ¡el resto del código depende de ti!
Implementa hash
Completa la función hash
. hash
debe tomar una cadena, word
, como entrada y devolver un int
positivo ("sin signo").
La función hash que te damos devuelve un int
entre 0 y 25, inclusive, basado en el primer carácter de word
. Sin embargo, hay muchas maneras de implementar una función hash más allá de usar el primer carácter (o caracteres) de una palabra. Considera una función hash que usa una suma de valores ASCII o la longitud de una palabra. Una buena función hash reduce las "colisiones" y tiene una (¡casi siempre!) distribución uniforme en los "cubos" de la tabla hash.
Implementa size
Completa la función size
. size
debe devolver el número de palabras cargadas en el diccionario. Considera dos enfoques para este problema:
- Cuenta cada palabra cargada en el diccionario. Devuelve ese conteo cuando se llama a
size
. - Cada vez que se llama a la función
size
, itera mediante las palabras en la tabla hash para contarlas. Devuelve ese conteo.
¿Cuál te parece más eficiente? El que elijas, te dejaremos el código a ti.
Implementa check
Completa la función check
. check
debe devolver true
si una palabra se encuentra en el diccionario; de lo contrario, false
.
Considera que este problema también se compone de problemas menores. Si has implementado una tabla hash, encontrar una palabra requiere solo unos pasos:
- Establece un hash para la palabra para obtener su valor hash.
- Busca en la tabla hash en la ubicación especificada por el valor hash de la palabra.
1. Devuelve
true
si se encuentra la palabra. - Devuelve
false
si no se encuentra ninguna palabra.
Para comparar dos cadenas sin distinción entre mayúsculas y minúsculas, puedes encontrar útil strcasecmp
(declarada en strings.h
). También es probable que quieras asegurarte de que tu función hash no distinga entre mayúsculas y minúsculas; de modo que foo
y FOO
tengan el mismo valor hash.
Implementa unload
Completa la función unload
. Asegúrate de liberar
en unload
cualquier memoria que hayas asignado en load
.
Recuerda que valgrind
es tu mejor amigo de ahora en adelante. Ten en cuenta que valgrind
observa las fugas mientras tu programa está en ejecución, así que asegúrate de proporcionar argumentos de línea de comandos si quieres que valgrind
analice speller
mientras usas un diccionario
y/o texto en particular, como se muestra a continuación. Sin embargo, es mejor usar un texto pequeño, de lo contrario, valgrind podría tardar bastante en ejecutarse.
valgrind ./speller texts/cat.txt
Si ejecutas valgrind
sin especificar un texto
para speller
, tus implementaciones de load
y unload
no se ejecutarán (y, por lo tanto, no se analizarán).
Si no estás seguro de cómo interpretar el resultado de valgrind
, pide ayuda a help50
:
help50 valgrind ./speller texts/cat.txt
Guías
Ten en cuenta que hay 6 videos en esta lista.
Cómo realizar la prueba
¿Cómo verificar si tu programa está generando las palabras mal escritas correctas? Puedes consultar las "claves de respuesta" que se encuentran en el directorio keys
dentro de tu directorio speller
. Por ejemplo, dentro de keys/lalaland.txt
están todas las palabras que tu programa debe considerar mal escritas.
Por lo tanto, podrías ejecutar tu programa en un texto en una ventana, como se muestra a continuación.
./speller texts/lalaland.txt
Y luego podrías ejecutar la solución del personal en el mismo texto en otra ventana, como se muestra a continuación.
./speller50 texts/lalaland.txt
Y luego podrías comparar las ventanas visualmente una al lado de la otra. Sin embargo, eso podría volverse tedioso. Por lo tanto, es posible que desees "redirigir" la salida de tu programa a un archivo, como se muestra a continuación.
./speller texts/lalaland.txt > student.txt
./speller50 texts/lalaland.txt > staff.txt
Luego, puedes comparar ambos archivos uno al lado del otro en la misma ventana con un programa como diff
, como se muestra a continuación.
diff -y student.txt staff.txt
Alternativamente, para ahorrar tiempo, podrías comparar la salida de tu programa (suponiendo que la redirigiste a, por ejemplo, student.txt
) con una de las claves de respuesta sin ejecutar la solución del personal, como se muestra a continuación.
diff -y student.txt keys/lalaland.txt
Si la salida de tu programa coincide con la del personal, diff
generará dos columnas que deberían ser idénticas, excepto, quizás, por los tiempos de ejecución en la parte inferior. Sin embargo, si las columnas difieren, verás un >
o |
donde difieren. Por ejemplo, si ves
MISSPELLED WORDS MISSPELLED WORDS
TECHNO TECHNO
L L
> Thelonious
Prius Prius
> MIA
L L
significa que tu programa (cuya salida está a la izquierda) no cree que Thelonious
o MIA
estén mal escritas, aunque la salida del personal (a la derecha) sí lo hace, como lo implica la ausencia de, por ejemplo, Thelonious
en la columna de la izquierda y la presencia de Thelonious
en la columna de la derecha.
Por último, asegúrate de realizar la prueba con ambos diccionarios, el pequeño y el grande predeterminados. Ten cuidado de no asumir que si tu solución se ejecuta correctamente con el diccionario grande, también se ejecutará correctamente con el pequeño. Aquí tienes cómo probar el diccionario pequeño:
./speller dictionaries/small texts/cat.txt
Corrección
check50 cs50/problems/2024/x/speller
Estilo
style50 dictionary.c
Solución del personal
¿Cómo evaluar qué tan rápido (y correcto) es tu código? Bueno, como siempre, siéntete libre de jugar con la solución del personal, como se muestra a continuación, y comparar sus números con los tuyos.
./speller50 texts/lalaland.txt
Cómo enviar
submit50 cs50/problems/2024/x/speller