Diccionarios y conjuntos
El capítulo anterior terminó con una pregunta que no pudimos responder: cuántas veces aparece cada palabra de un texto. Aquí la respondemos con el diccionario, la colección que asocia cada clave con un valor, y con su pariente cercano, el conjunto, que guarda elementos sin repetir. El proyecto del capítulo es un programa que lee un archivo y reporta las diez palabras más frecuentes.
Trabajamos sobre dos archivos que están en la carpeta conferences/2026/libro del repositorio del curso: quijote.txt, los tres
párrafos del capítulo anterior, y quijote-cap1.txt, el primer capítulo completo del
Quijote, tomado de la edición del Proyecto Gutenberg. El primero es pequeño para poder
seguirlo a mano; el segundo es lo bastante grande para que las diferencias de velocidad
se vean.
1. El problema, con lo que ya sabemos#
Empecemos por lo que sí sabemos hacer. Con dos listas en paralelo, una de palabras
vistas y otra de conteos, la palabra de la posición i en la primera tiene su conteo en
la posición i de la segunda. Las funciones limpiar y palabras_de son las del
capítulo anterior:
SIGNOS = ".,;:¿?¡!()«»\"'—-" def limpiar(texto): """Devuelve el texto en minúsculas y sin signos de puntuación.""" texto = texto.lower() for signo in SIGNOS: texto = texto.replace(signo, " ") return texto def palabras_de(ruta): """Devuelve la lista de palabras de un archivo de texto.""" with open(ruta, encoding="utf-8") as archivo: return limpiar(archivo.read()).split() def frecuencias_con_listas(palabras): """Devuelve dos listas paralelas: las palabras distintas y sus conteos.""" vistas = [] conteos = [] for palabra in palabras: if palabra in vistas: i = vistas.index(palabra) conteos[i] += 1 else: vistas.append(palabra) conteos.append(1) return vistas, conteos vistas, conteos = frecuencias_con_listas(palabras_de("quijote.txt")) print(len(vistas), "palabras distintas") print(vistas[:6]) print(conteos[:6])
126 palabras distintas ['en', 'un', 'lugar', 'de', 'la', 'mancha'] [5, 3, 1, 20, 4, 1]
Funciona. El método index de una lista devuelve la posición de un elemento, y la tupla
que devuelve la función se desempaqueta en dos nombres, como vimos con las listas.
Pero mira lo que hace cada vuelta del ciclo. palabra in vistas recorre la lista de
palabras vistas de una en una hasta encontrarla, y después index la recorre otra vez
para saber dónde estaba. Cada palabra del texto paga un recorrido de todas las palabras
distintas encontradas hasta ese momento. Y el programa es incómodo de leer, porque la
relación entre una palabra y su conteo no está escrita en ningún sitio: solo la sostiene
la coincidencia de posiciones.
Lo que de verdad queremos es una tabla donde se pueda preguntar "¿cuánto vale "de"?" y
obtener la respuesta directamente.
2. El diccionario#
Un diccionario es una colección de parejas clave: valor. Se escribe entre llaves, con dos puntos entre la clave y su valor:
conteo = {"de": 20, "que": 9, "hidalgo": 2} print(conteo) print(conteo["de"]) print(len(conteo))
{'de': 20, 'que': 9, 'hidalgo': 2}
20
3
len cuenta las parejas.Se indexa con corchetes, igual que una lista, pero lo que va dentro de los corchetes no
es una posición sino una clave. Pedir una clave que no existe es un error, el
equivalente al IndexError de las listas:
conteo = {"de": 20, "que": 9, "hidalgo": 2} print(conteo["rocín"])
Traceback (most recent call last):
File "<stdin>", line 54, in <module>
KeyError: 'rocín'
Asignar a una clave hace una de dos cosas. Si la clave no estaba, agrega la pareja; si estaba, reemplaza su valor. Es la misma instrucción para los dos casos:
conteo = {"de": 20, "que": 9, "hidalgo": 2} conteo["rocín"] = 1 conteo["de"] = 21 conteo["hidalgo"] += 1 print(conteo)
{'de': 21, 'que': 9, 'hidalgo': 3, 'rocín': 1}
Esto es lo que en una lista no se puede hacer: lista[10] = x en una lista de tres
elementos da IndexError, mientras que en un diccionario la clave nueva simplemente
aparece.
Para preguntar si una clave está se usa in, que en un diccionario mira solo las
claves, nunca los valores. Para quitar una pareja se usa del:
conteo = {"de": 20, "que": 9, "hidalgo": 2} print("hidalgo" in conteo) print(20 in conteo) del conteo["que"] print(conteo)
True
False
{'de': 20, 'hidalgo': 2}
Y un diccionario vacío se escribe {}, que es por donde empieza casi todo diccionario
que se llena dentro de un ciclo.
Las claves no pueden cambiar#
Un diccionario se modifica, igual que una lista: se agregan parejas, se cambian valores y se quitan. Pero las claves tienen que ser de un tipo que no se pueda modificar: números, cadenas, tuplas. Una lista no sirve como clave:
posiciones = {} posiciones[[3, 4]] = "reina"
Traceback (most recent call last):
File "<stdin>", line 68, in <module>
TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
La tupla, en cambio, sí:
tablero = {} tablero[(3, 4)] = "reina" tablero[(0, 0)] = "torre" print(tablero[(3, 4)]) print((5, 5) in tablero)
reina False
Ahí tienes un uso de la tupla que no vimos en el capítulo 4: una pareja de coordenadas como clave. El porqué de la regla, y de la palabra unhashable en el error, lo vemos en la sección 7.
3. Recorrer un diccionario#
Un for sobre un diccionario recorre sus claves, en el orden en que se agregaron:
conteo = {"de": 20, "que": 9, "hidalgo": 2} for palabra in conteo: print(palabra, conteo[palabra])
de 20 que 9 hidalgo 2
Cuando hacen falta la clave y el valor a la vez, que es casi siempre, el método items
da las parejas como tuplas, y el for las desempaqueta igual que con enumerate:
conteo = {"de": 20, "que": 9, "hidalgo": 2} for palabra, veces in conteo.items(): print(f"{palabra:>8} aparece {veces} veces")
de aparece 20 veces
que aparece 9 veces
hidalgo aparece 2 veces
Los métodos keys y values dan solo las claves o solo los valores. El segundo es el
útil, porque los valores no se pueden pedir de otro modo:
conteo = {"de": 20, "que": 9, "hidalgo": 2} print(sum(conteo.values())) print(max(conteo.values()))
31 20
4. El patrón de conteo#
Ya podemos escribir el conteo. Para cada palabra, si ya tiene entrada se le suma uno, y si no la tiene se crea con uno:
def frecuencias(palabras): """Devuelve un diccionario de cada palabra a las veces que aparece.""" conteo = {} for palabra in palabras: if palabra in conteo: conteo[palabra] += 1 else: conteo[palabra] = 1 return conteo print(frecuencias("de los de lanza en astillero".split()))
{'de': 2, 'los': 1, 'lanza': 1, 'en': 1, 'astillero': 1}
"de" no agrega nada, incrementa.El if y el else se pueden fundir en una línea con el método get, que devuelve el
valor de una clave o, si la clave no está, el valor por defecto que le pases en lugar de
dar error:
conteo = {"de": 2, "los": 1} print(conteo.get("de", 0)) print(conteo.get("rocín", 0))
2 0
Con get, el conteo entero queda así:
def frecuencias(palabras): """Devuelve un diccionario de cada palabra a las veces que aparece.""" conteo = {} for palabra in palabras: conteo[palabra] = conteo.get(palabra, 0) + 1 return conteo
Las dos versiones hacen lo mismo. La primera dice mejor lo que pasa; la segunda es la que vas a ver escrita en todas partes, y conviene reconocerla.
Contra las listas paralelas#
Pongamos las dos soluciones a correr sobre el capítulo entero, con el reloj de la capítulo 3:
import time palabras = palabras_de("quijote-cap1.txt") inicio = time.perf_counter() vistas, conteos = frecuencias_con_listas(palabras) con_listas = time.perf_counter() - inicio inicio = time.perf_counter() conteo = frecuencias(palabras) con_diccionario = time.perf_counter() - inicio print(f"{len(palabras)} palabras, {len(conteo)} distintas") print(f"con dos listas: {con_listas * 1000:.2f} ms") print(f"con un diccionario: {con_diccionario * 1000:.2f} ms") print(f"el diccionario es {con_listas / con_diccionario:.0f} veces más rápido")
1895 palabras, 719 distintas con dos listas: 7.09 ms con un diccionario: 0.24 ms el diccionario es 29 veces más rápido
El resultado es el mismo y el diccionario es decenas de veces más rápido sobre un solo capítulo. La diferencia crece con el texto, porque con las listas cada palabra recorre una lista de vistas que no para de crecer, y con el diccionario no recorre nada. Por qué no recorre nada es la pregunta de la sección 7.
5. Las diez más comunes#
Tener el conteo no es tener la respuesta. Falta encontrar las palabras con más apariciones, y eso es el acumulador del capítulo anterior, el que guardaba la palabra más larga vista hasta ahora, cambiando la longitud por el conteo:
def mas_comun(conteo): """Devuelve la pareja (palabra, veces) con más apariciones.""" mejor, maximo = "", 0 for palabra, veces in conteo.items(): if veces > maximo: mejor, maximo = palabra, veces return mejor, maximo print(mas_comun(frecuencias(palabras_de("quijote.txt"))))
('de', 20)
Para las diez primeras basta repetir eso diez veces, sacando del diccionario cada ganador para que la vuelta siguiente encuentre al segundo:
def mas_comunes(conteo, k): """Devuelve las k parejas (palabra, veces) más frecuentes, de mayor a menor.""" quedan = dict(conteo) resultado = [] for vuelta in range(k): palabra, veces = mas_comun(quedan) resultado.append((palabra, veces)) del quedan[palabra] return resultado for palabra, veces in mas_comunes(frecuencias(palabras_de("quijote-cap1.txt")), 10): print(f"{palabra:>6} {veces:4}")
de 122
y 106
que 89
a 44
el 40
en 38
la 35
su 35
se 30
con 27
Mira la primera línea de la función. dict(conteo) hace una copia del diccionario, y
es imprescindible: el diccionario se modifica igual que una lista, y si borráramos
directamente de conteo vaciaríamos el diccionario del que nos llamó. Es la sorpresa de
los dos nombres para una misma lista del capítulo 4, ahora con diccionarios.
Este método de buscar el mayor, apartarlo y volver a buscar es una forma de ordenar, y el capítulo siguiente le pone nombre y le contamos las operaciones.
El resultado, de todos modos, decepciona: de, y, que, a, el. Las palabras más
frecuentes de cualquier texto en español son las que no dicen nada. Para encontrar las
que hablan del capítulo hay que sacar esas primero, y para eso hace falta una colección
nueva.
6. Conjuntos#
Un conjunto es una colección sin repeticiones y sin orden, como los conjuntos de la matemática. Se escribe entre llaves, sin los dos puntos:
vocales = {"a", "e", "i", "o", "u"} dados = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5} print(len(vocales)) print(dados) print(4 in dados)
5
{1, 2, 3, 4, 5, 6, 9}
True
El 1, el 3 y el 5 aparecen varias veces en lo que escribimos, y el conjunto los guarda una sola vez. Esa es la operación por la que más se usan: convertir una lista en conjunto elimina los repetidos.
palabras = palabras_de("quijote-cap1.txt") print(len(palabras), "palabras") print(len(set(palabras)), "palabras distintas")
1895 palabras 719 palabras distintas
Un conjunto vacío se escribe set(), porque {} ya estaba tomado por el diccionario
vacío. Se le agregan elementos con add, y agregar uno que ya estaba no hace nada:
vistas = set() vistas.add("rocín") vistas.add("hidalgo") vistas.add("rocín") print(vistas) print(type({}))
{'rocín', 'hidalgo'}
<class 'dict'>
Lo que un conjunto no tiene es posiciones. No se puede pedir vistas[0], porque no hay
primer elemento, y el orden en que un conjunto imprime sus elementos no tiene por qué ser
el orden en que se agregaron:
print(vistas[0])
Traceback (most recent call last):
File "<stdin>", line 184, in <module>
TypeError: 'set' object is not subscriptable
Las operaciones de conjuntos#
Los conjuntos traen las operaciones de la matemática, escritas con operadores: | para
la unión, & para la intersección y - para la diferencia. Con ellas se comparan
vocabularios. Partamos quijote.txt en sus tres párrafos, que en el archivo están
separados por una línea vacía, y saquemos el vocabulario de cada uno:
with open("quijote.txt", encoding="utf-8") as archivo: parrafos = archivo.read().split("\n\n") primero = set(limpiar(parrafos[0]).split()) tercero = set(limpiar(parrafos[2]).split()) print(len(primero), len(tercero)) print(len(primero | tercero), "en alguno de los dos") print(sorted(primero & tercero)) print(len(primero - tercero), "solo en el primero")
50 49 92 en alguno de los dos ['de', 'en', 'hidalgo', 'la', 'los', 'que', 'y'] 43 solo en el primero
hidalgo.sorted devuelve una lista ordenada, y aquí sirve para que el resultado se imprima en un
orden fijo. La intersección es reveladora. Lo que dos párrafos cualesquiera tienen en
común son las palabras vacías: artículos, preposiciones, conjunciones. Es la misma lista
que encabezaba el conteo.
Sacar las palabras vacías#
Así que la solución es un conjunto con las palabras que no queremos contar, y una
pregunta con in antes de contar cada palabra:
VACIAS = { "a", "al", "así", "bien", "como", "con", "cual", "cuando", "de", "del", "dél", "el", "él", "en", "era", "estaba", "fue", "ha", "había", "la", "las", "le", "les", "lo", "los", "más", "mas", "me", "mi", "muy", "ni", "no", "o", "para", "pero", "por", "porque", "que", "quien", "se", "ser", "si", "sí", "sido", "sin", "su", "sus", "tan", "tenía", "todo", "todos", "un", "una", "y", "ya", } def frecuencias_sin_vacias(palabras): """Como `frecuencias`, pero sin contar las palabras de VACIAS.""" conteo = {} for palabra in palabras: if palabra not in VACIAS: conteo[palabra] = conteo.get(palabra, 0) + 1 return conteo diez = mas_comunes(frecuencias_sin_vacias(palabras_de("quijote-cap1.txt")), 10) for palabra, veces in diez: print(f"{palabra:>9} {veces:4}")
nombre 13
caballero 13
don 6
razón 6
vino 6
rocín 5
vuestra 5
amadís 5
dio 5
celada 5
Ahora sí habla el capítulo: el hidalgo pasa días pensando qué nombre ponerse como
caballero andante, lo mismo hace con su rocín y con su dama, y el modelo que tiene
en la cabeza es Amadís de Gaula. La lista VACIAS no es una verdad del idioma sino
una decisión: la armamos mirando el resultado y agregando lo que estorbaba. Así se
trabaja con datos reales.
Fíjate que VACIAS es un conjunto y no una lista. Funcionaría igual con corchetes, pero
la pregunta palabra not in VACIAS se hace una vez por cada palabra del texto, y en un
conjunto esa pregunta es mucho más barata. Cuánto, es lo que sigue.
7. Por qué buscar una clave no cuesta nada#
Medimos. La función de abajo pregunta muchas veces si un valor está en una colección y
devuelve cuánto tardó cada pregunta. Buscamos el -1, que no está, así que la lista
tiene que mirar todos sus elementos para poder decir que no:
import time def segundos_por_busqueda(coleccion, x, veces): """Devuelve el tiempo medio que tarda `x in coleccion`.""" inicio = time.perf_counter() for vuelta in range(veces): encontrado = x in coleccion return (time.perf_counter() - inicio) / veces medidas = [] for n in [1_000, 10_000, 100_000, 1_000_000]: lista = list(range(n)) conjunto = set(lista) en_lista = segundos_por_busqueda(lista, -1, 50) en_conjunto = segundos_por_busqueda(conjunto, -1, 100_000) medidas.append((n, en_lista, en_conjunto)) print(f"{n:>9} elementos: lista {en_lista * 1e3:7.3f} ms," f" conjunto {en_conjunto * 1e9:4.0f} ns")
1000 elementos: lista 0.007 ms, conjunto 50 ns
10000 elementos: lista 0.071 ms, conjunto 32 ns
100000 elementos: lista 0.832 ms, conjunto 38 ns
1000000 elementos: lista 9.753 ms, conjunto 33 ns
Un guion bajo dentro de un número, como en 1_000_000, no cambia su valor; solo lo hace
más fácil de leer.
La lista crece en proporción a su tamaño: diez veces más elementos, diez veces más tiempo. El conjunto tarda lo mismo con mil elementos que con un millón. Un diccionario se comporta exactamente igual al buscar una clave, porque por dentro están hechos de la misma manera.
La idea, sin entrar en cómo se implementa, es esta. Una lista guarda sus elementos en fila y, para encontrar uno, no le queda más remedio que mirarlos de uno en uno. Un conjunto o un diccionario, en cambio, calcula a partir del propio valor un número que dice en qué casillero guardarlo. Al buscar, hace la misma cuenta, va directo a ese casillero y mira solo ahí. Es como un estante de casilleros con el nombre de cada estudiante, donde no hace falta revisar todos los casilleros para encontrar el tuyo: tu nombre te dice cuál es.
Ese número calculado a partir del valor se llama en inglés hash, y ahí está la razón de la regla de la sección 2. Si una clave pudiera cambiar después de guardarla, su número cambiaría con ella y el diccionario la buscaría en el casillero equivocado. Por eso una lista, que se modifica, es unhashable: no se le puede calcular un casillero fijo. Cómo se calcula ese número y qué pasa cuando dos valores caen en el mismo casillero es materia del segundo semestre.
8. El proyecto completo#
El programa pide la ruta del archivo, atiende el error si no existe y reporta las diez palabras más frecuentes, con sus conteos alineados en columna:
def reporte(ruta, k=10): """Imprime las k palabras más frecuentes del archivo, sin las vacías.""" palabras = palabras_de(ruta) conteo = frecuencias_sin_vacias(palabras) print(f"{len(palabras)} palabras, {len(set(palabras))} distintas") for palabra, veces in mas_comunes(conteo, k): print(f" {palabra:<10} {veces:3}") reporte("quijote-cap1.txt")
1895 palabras, 719 distintas nombre 13 caballero 13 don 6 razón 6 vino 6 rocín 5 vuestra 5 amadís 5 dio 5 celada 5
ruta = input("Archivo: ") try: reporte(ruta) except FileNotFoundError: print(f"No encontré el archivo {ruta}")
El <10 dentro del f-string alinea la palabra a la izquierda en diez espacios, y el 3
alinea el número a la derecha en tres. Es lo que convierte una lista de valores en una
tabla legible.
El programa tiene un fallo que dejamos como ejercicio. Si pides más palabras de las que
el texto tiene distintas, mas_comun recibe un diccionario vacío, devuelve la pareja
("", 0), y el del de la línea siguiente falla.
9. Resumen#
- Un diccionario asocia claves con valores.
d[clave]lee,d[clave] = valoragrega o reemplaza,del d[clave]quita, y una clave que no está daKeyError. inen un diccionario pregunta por las claves, nunca por los valores.get(clave, defecto)devuelve el valor o el defecto sin dar error. El patrón de conteo esconteo[x] = conteo.get(x, 0) + 1.for clave, valor in d.items()recorre las parejas, en el orden en que se agregaron.- Las claves tienen que ser inmutables: números, cadenas o tuplas. Una lista no sirve.
- Un diccionario se modifica como una lista, así que dos nombres pueden compartirlo.
dict(d)hace una copia. - Un conjunto guarda elementos sin repetir y sin orden.
set(lista)quita los repetidos;set()es el conjunto vacío, porque{}es el diccionario vacío. |,&y-son la unión, la intersección y la diferencia.- Buscar en un conjunto o en un diccionario cuesta lo mismo con diez elementos que con un millón. Buscar en una lista cuesta en proporción a su tamaño.
Ejercicios#
-
Escribe
frecuencia_de_letras(ruta)que devuelva un diccionario de cada letra a las veces que aparece en el archivo, sin contar espacios ni signos. ¿Cuál es la vocal más frecuente enquijote-cap1.txt? ¿Y la consonante? -
Corrige
mas_comunespara que, sikes mayor que el número de palabras distintas, devuelva todas las que hay en lugar de fallar. -
Escribe
agrupar_por_longitud(palabras)que devuelva un diccionario de cada longitud a la lista de palabras distintas de esa longitud. Por ejemplo, la clave11del primer capítulo debe incluirentresemana. -
Dos palabras son anagramas si tienen las mismas letras con las mismas repeticiones, como
romayamor. Escribeson_anagramas(a, b)comparando dos diccionarios de conteo de letras. Después escribegrupos_de_anagramas(palabras)usando como clave"".join(sorted(palabra)), y busca los grupos de más de una palabra en el capítulo. -
Escribe
invertir(conteo)que reciba un diccionario de palabras a conteos y devuelva uno de cada conteo a la lista de palabras con ese conteo. ¿Cuántas palabras del capítulo aparecen una sola vez? -
Tienes la lista de los estudiantes de un grupo y las listas de los que aprobaron el primer y el segundo examen. Con operaciones de conjuntos, di quiénes aprobaron los dos, quiénes aprobaron exactamente uno y quiénes no aprobaron ninguno.
-
Escribe
traducir(frase, diccionario)que traduzca palabra a palabra con un diccionario como{"hidalgo": "nobleman", "rocín": "nag"}, dejando igual las palabras que no estén. Resuélvelo congety sin ningúnif. -
Sin ejecutarlos, di qué imprime cada uno de estos tres programas. Después compruébalo.
(a)
d = {"a": 1, "b": 2} d["a"] = d.get("c", 10) + d["b"] print(d)(b)
print(len({1, 2, 2, 3, 3, 3})) print(len({"uno": 1, "uno": 2}))(c)
a = {"x": 1} b = a b["x"] = 2 print(a)