Contenedores asociativos en Python — ejercicios con duraciones, géneros musicales y un recomendador de canciones (y las llaves vacías que no eran un conjunto)
Quería contar cuántas canciones distintas había escuchado en un día. Lo tenía clarísimo: un conjunto, porque los conjuntos no guardan repetidos. Empecé con las llaves vacías, vistas = {}, recorrí el historial haciendo vistas.add(cancion) y Python me contestó: AttributeError: 'dict' object has no attribute 'add'.
«¿Dict? Si yo he hecho un conjunto.» No. Las llaves vacías {} crean un diccionario vacío, no un conjunto, y los diccionarios no tienen add. Para un conjunto vacío hay que escribir set(). Es uno de esos fallos que te comes una vez y ya no se te olvida nunca, y es el primer fragmento de «¿Qué falla aquí?».
Hoy practicamos con tres ejercicios de dificultad creciente, tres fragmentos para depurar y un chuletario para llevarte. Estos ejercicios usan la teoría de contenedores asociativos y repasan lo de la práctica. Puedes hacerlos sin ellas, pero te costará más.
Tabla de Contenidos
Cómo usar este artículo
- Lee el enunciado de un nivel y no mires la solución todavía.
- Escribe tu versión, ejecútala y compara tu salida con la esperada.
- Si te atascas, mira solo la 🔵 pista y vuelve a intentarlo.
- Cuando termines, compara con la solución y fíjate en por qué está hecha así, no solo en que dé la misma salida.
Ningún ejercicio usa input(): los datos van fijos en el código. Cuando muestres un conjunto, hazlo con sorted(…), porque un set no tiene un orden fijo y tu salida podría no coincidir con la esperada.
Nivel 1 · Básico: las duraciones
Enunciado. Parte del diccionario duraciones = {"Luz de neón": 215, "Café a las 3": 187} (título → segundos) y haz, en este orden:
- Añade
"Pixel"con 176 segundos. - Cambia la duración de
"Café a las 3"a 190. - Quita
"Luz de neón"guardando su duración en una variable. Muestra el diccionario y esa variable. - Muestra la duración de
"Marzo", que no está, sin que salte ningún error: tiene que salir 0. - Muestra si
"Pixel"y"Marzo"están en el diccionario. - Recorre el diccionario en orden alfabético mostrando cada canción en formato
minutos:segundos(con dos cifras en los segundos) y, al final, el total de segundos.
Salida esperada:
{'Café a las 3': 190, 'Pixel': 176} 215
0
True False
Café a las 3: 3:10
Pixel: 2:56
Total: 366 segundos
Para quitar una pareja y quedarte con su valor, pop. Para leer sin error, get con un valor por defecto. Para recorrer en orden, sorted(duraciones) te da las claves ordenadas. Y para los segundos con dos cifras, el formato :02d.
Nivel 2 · Intermedio: géneros y artistas
Enunciado. Tienes el catálogo como una lista de tuplas (título, artista, género):
CANCIONES = [
("Luz de neón", "Marea Alta", "pop"),
("Café a las 3", "Los Turnos", "indie"),
("Domingo lento", "Noa Vela", "indie"),
("Kilómetro 0", "Marea Alta", "pop"),
("Pixel", "Bit Bang", "electrónica"),
("Volver a casa", "Lía Sur", "indie"),
("Bajo cero", "Bit Bang", "pop"),
]
Escribe tres funciones:
contar_generos(canciones): diccionario género → número de canciones.artistas_por_genero(canciones): diccionario género → conjunto de artistas de ese género.mas_variado(artistas): recibe el diccionario anterior y devuelve el género con más artistas distintos (en caso de empate, el primero que aparezca).
Muestra el recuento, los artistas de cada género (géneros en orden alfabético y artistas ordenados), el género más variado y los artistas que tienen canciones a la vez en pop y en electrónica.
Salida esperada:
{'pop': 3, 'indie': 3, 'electrónica': 1}
electrónica: ['Bit Bang']
indie: ['Los Turnos', 'Lía Sur', 'Noa Vela']
pop: ['Bit Bang', 'Marea Alta']
Género con más artistas: indie
En pop y en electrónica: ['Bit Bang']
contar_generos es el contador de siempre. En artistas_por_genero, crea el conjunto vacío del género la primera vez que aparezca (con set()) y luego haz add. Pop e indie tienen 3 canciones cada uno, pero no 3 artistas distintos: ahí está la gracia de usar conjuntos. Y para «a la vez en pop y en electrónica», piensa en una operación de conjuntos.
Nivel 3 · Desafío: un recomendador de canciones
Enunciado. Cada usuario tiene un conjunto con sus canciones guardadas:
GUARDADAS = {
"ana": {"Luz de neón", "Café a las 3", "Pixel", "Domingo lento"},
"luis": {"Pixel", "Café a las 3", "Volver a casa"},
"sara": {"Domingo lento", "Luz de neón", "Café a las 3", "Bajo cero"},
"pablo": {"Marzo"},
}
Escribe tres funciones:
afinidad(a, b): la parte común de dos conjuntos, de 0 a 1:len(a & b) / len(a | b). Si los dos están vacíos, 0.mas_parecido(usuario, guardadas): el otro usuario con más afinidad. Si nadie comparte nada con él,None.recomendar(usuario, guardadas): una tupla con el usuario más parecido y la lista ordenada de sus canciones queusuariotodavía no tiene. Si no hay nadie parecido,(None, []).
Recorre todos los usuarios mostrando su recomendación (o sin recomendaciones) y, al final, la afinidad entre Ana y Sara en porcentaje.
Salida esperada:
ana: se parece a sara -> ['Bajo cero'] luis: se parece a ana -> ['Domingo lento', 'Luz de neón'] sara: se parece a ana -> ['Pixel'] pablo: sin recomendaciones Afinidad ana-sara: 60%
En mas_parecido, recorre guardadas.items() y salta al propio usuario con continue. Empieza con la mejor afinidad en 0 y compara con > estricto: así, alguien con afinidad 0 nunca gana y Pablo se queda sin recomendaciones. Lo que recomiendas es una diferencia de conjuntos.
¿Qué falla aquí?
Tres fragmentos con un fallo cada uno. Antes de leer la explicación, haz dos cosas: predice si da error y, si no lo da, qué imprime.
Fragmento 1
vistas.py
historial = ["Pixel", "Marzo", "Pixel"]
vistas = {}
for cancion in historial:
vistas.add(cancion)
print(len(vistas))
*Piénsalo antes de seguir: queremos contar las canciones distintas. ¿Qué imprime python3 vistas.py?*
Explicación. Es el fallo del principio:
Traceback (most recent call last):
File "vistas.py", line 4, in <module>
vistas.add(cancion)
^^^^^^^^^^
AttributeError: 'dict' object has no attribute 'add'
{} crea un diccionario vacío. El mensaje lo dice claramente: el objeto es un 'dict', y los diccionarios no tienen método add (en un diccionario se añade con vistas[clave] = valor). Las llaves vacías son del diccionario porque llegó antes al lenguaje que los conjuntos.
Crear un conjunto vacío con {}. Con elementos, {"Pixel", "Marzo"} sí es un conjunto, porque no lleva dos puntos; vacío, no.
Arreglo. Un conjunto vacío se crea con set():
historial = ["Pixel", "Marzo", "Pixel"]
vistas = set() # conjunto vacío de verdad
for cancion in historial:
vistas.add(cancion) # la segunda "Pixel" no se añade otra vez
print(len(vistas))
2
Si alguna vez dudas de qué tienes entre manos, print(type(vistas)) te lo dice al momento.
Fragmento 2
favoritas.py
valoraciones = {}
valoraciones[["Pixel", "Bit Bang"]] = 5
print(valoraciones)
¿Da error? ¿Qué imprime? Queremos valorar una canción identificándola por título y artista.
Explicación. Sí hay error:
Traceback (most recent call last):
File "favoritas.py", line 2, in <module>
valoraciones[["Pixel", "Bit Bang"]] = 5
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'list'
Unhashable significa que ese objeto no puede ser clave. Las claves de un diccionario tienen que ser inmutables, y una lista puede cambiar después de usarla como clave (con un append, por ejemplo). Si eso pasara, Python ya no sabría dónde la guardó, así que directamente no lo permite.
Usar una lista como clave de un diccionario o como elemento de un conjunto. Pasa mucho cuando la clave tiene que ser «compuesta», como título y artista.
Arreglo. Una tupla guarda lo mismo y sí es inmutable:
valoraciones = {}
valoraciones[("Pixel", "Bit Bang")] = 5 # una tupla sí puede ser clave
print(valoraciones)
print(valoraciones[("Pixel", "Bit Bang")])
{('Pixel', 'Bit Bang'): 5}
5
Para leer el valor tienes que usar la misma tupla como clave. valoraciones[("Bit Bang", "Pixel")] sería otra clave distinta, porque en las tuplas el orden importa.
Fragmento 3
buscar.py
canciones = {"c01": "Luz de neón", "c02": "Café a las 3"}
if "Café a las 3" in canciones:
print("La tengo")
else:
print("No la tengo")
¿Da error? ¿Qué imprime? Queremos saber si tenemos la canción «Café a las 3».
Explicación. No da error, pero la respuesta es incorrecta:
No la tengo
La canción está, pero como valor. in sobre un diccionario solo mira las claves, que aquí son los códigos "c01" y "c02". Para Python, la pregunta era «¿hay alguna clave que se llame Café a las 3?», y la respuesta correcta es que no.
Usar in con un diccionario para buscar un valor. No da error, así que el fallo pasa desapercibido hasta que la respuesta no cuadra.
Arreglo. Pregunta explícitamente por los valores:
canciones = {"c01": "Luz de neón", "c02": "Café a las 3"}
if "Café a las 3" in canciones.values(): # buscar entre los VALORES
print("La tengo")
else:
print("No la tengo")
La tengo
Buscar con in entre las claves es instantáneo; buscar entre los valores obliga a Python a recorrerlos todos. Si vas a preguntar muchas veces por títulos, quizá el título debería ser la clave.
Soluciones
Mira esta sección solo cuando hayas intentado el ejercicio. Si tu código es distinto pero da la misma salida, también es una solución válida.
Solución del Nivel 1
duraciones.py
duraciones = {"Luz de neón": 215, "Café a las 3": 187}
duraciones["Pixel"] = 176 # añadir una pareja nueva
duraciones["Café a las 3"] = 190 # cambiar el valor de una clave que ya existe
quitada = duraciones.pop("Luz de neón") # quitarla y quedarnos con su valor
print(duraciones, quitada)
print(duraciones.get("Marzo", 0)) # clave que no está: 0, sin error
print("Pixel" in duraciones, "Marzo" in duraciones)
total = 0
for titulo in sorted(duraciones): # las claves, en orden alfabético
segundos = duraciones[titulo] # tenemos la clave: consulta directa
total += segundos
print(f"{titulo}: {segundos // 60}:{segundos % 60:02d}")
print("Total:", total, "segundos")
Por qué está hecho así:
- Las líneas 3 y 4 tienen exactamente la misma forma,
duraciones[clave] = valor, pero hacen cosas distintas: la primera añade (Pixel no existía) y la segunda sustituye (Café a las 3 sí). popquita la pareja y devuelve el valor, así quequitadavale 215. Condella pareja también desaparecería, pero perderíamos el valor.get("Marzo", 0)devuelve el valor por defecto porque la clave no existe. Conduraciones["Marzo"]habría saltado unKeyError.- En el bucle,
sorted(duraciones)da una lista con las claves ordenadas, y como tenemos cada clave, leemos su valor directamente con corchetes. No hace faltaget: sabemos que la clave existe, porque la acabamos de sacar del propio diccionario. - 190 segundos son
190 // 60 = 3minutos y190 % 60 = 10segundos:3:10. El total es 190 + 176 = 366.
Solución del Nivel 2
generos.py
CANCIONES = [
("Luz de neón", "Marea Alta", "pop"),
("Café a las 3", "Los Turnos", "indie"),
("Domingo lento", "Noa Vela", "indie"),
("Kilómetro 0", "Marea Alta", "pop"),
("Pixel", "Bit Bang", "electrónica"),
("Volver a casa", "Lía Sur", "indie"),
("Bajo cero", "Bit Bang", "pop"),
]
def contar_generos(canciones):
"""Diccionario género -> número de canciones."""
cuentas = {}
for titulo, artista, genero in canciones:
cuentas[genero] = cuentas.get(genero, 0) + 1 # el contador de siempre
return cuentas
def artistas_por_genero(canciones):
"""Diccionario género -> conjunto de artistas (sin repetir)."""
resultado = {}
for titulo, artista, genero in canciones:
if genero not in resultado: # primera vez que sale el género
resultado[genero] = set() # su conjunto vacío
resultado[genero].add(artista) # el set ya se encarga de no repetir
return resultado
def mas_variado(artistas):
"""Género con más artistas distintos."""
mejor = None
for genero, conjunto in artistas.items():
if mejor is None or len(conjunto) > len(artistas[mejor]):
mejor = genero
return mejor
print(contar_generos(CANCIONES))
artistas = artistas_por_genero(CANCIONES)
for genero in sorted(artistas):
print(f"{genero}: {sorted(artistas[genero])}")
print("Género con más artistas:", mas_variado(artistas))
print("En pop y en electrónica:", sorted(artistas["pop"] & artistas["electrónica"]))
Por qué está hecho así:
- El
forde las dos primeras funciones desempaqueta cada tupla en tres variables, aunque cada función use solo algunas. Si escribieras dos variables, el desempaquetado fallaría. contar_generoses el contador conget(genero, 0) + 1. Pop e indie salen con 3 canciones.artistas_por_generoconstruye un diccionario de conjuntos. Sigue los mismos dos pasos que el diccionario de listas de la práctica (asegurar que existe el conjunto y luego añadir), pero ahora no hace falta comprobar repetidos: Marea Alta sale dos veces en pop y eladdrepetido no hace nada.- Por eso pop, con 3 canciones, solo tiene 2 artistas (Marea Alta y Bit Bang), mientras que indie tiene 3. El género más variado es indie, aunque los dos tengan las mismas canciones.
mas_variadoes el patrón del máximo a mano, pero comparando tamaños de conjuntos:len(conjunto) > len(artistas[mejor]). Guarda solo el nombre del género y consulta su conjunto cuando lo necesita.- La última línea es una intersección: Bit Bang tiene una canción pop (Bajo cero) y una electrónica (Pixel).
¿te has fijado en que, en indie, Lía Sur sale después de Los Turnos? sorted compara los strings carácter a carácter según su código Unicode, y la í con tilde va después de todas las letras sin tilde, incluida la o. Por eso «Lí…» queda detrás de «Lo…». Para un orden alfabético «de diccionario» hay herramientas específicas, pero para este tema nos basta con saber por qué pasa.
Solución del Nivel 3
recomendador.py
GUARDADAS = {
"ana": {"Luz de neón", "Café a las 3", "Pixel", "Domingo lento"},
"luis": {"Pixel", "Café a las 3", "Volver a casa"},
"sara": {"Domingo lento", "Luz de neón", "Café a las 3", "Bajo cero"},
"pablo": {"Marzo"},
}
def afinidad(a, b):
"""Parte común de dos conjuntos, de 0 a 1 (0 si los dos están vacíos)."""
union = a | b
if not union: # un conjunto vacío es "falso"
return 0
return len(a & b) / len(union)
def mas_parecido(usuario, guardadas):
"""Otro usuario con más afinidad, o None si nadie comparte nada."""
mejor, mejor_afinidad = None, 0
for otro, canciones in guardadas.items():
if otro == usuario: # no se compara consigo mismo
continue
valor = afinidad(guardadas[usuario], canciones)
if valor > mejor_afinidad: # con > estricto, afinidad 0 nunca gana
mejor, mejor_afinidad = otro, valor
return mejor
def recomendar(usuario, guardadas):
"""(usuario más parecido, canciones suyas que este no tiene, ordenadas)."""
otro = mas_parecido(usuario, guardadas)
if otro is None:
return None, []
return otro, sorted(guardadas[otro] - guardadas[usuario])
for usuario in GUARDADAS:
otro, lista = recomendar(usuario, GUARDADAS)
if otro is None:
print(f"{usuario}: sin recomendaciones")
else:
print(f"{usuario}: se parece a {otro} -> {lista}")
print(f"Afinidad ana-sara: {afinidad(GUARDADAS['ana'], GUARDADAS['sara']):.0%}")
Este es el mapa de afinidades que calcula el programa. Cada fila es un usuario, y en coral está la columna que gana para esa fila:
Por qué está hecho así:
afinidadcalcula primero la unión y comprueba si está vacía conif not union:. Un conjunto vacío cuenta como falso, así que la condición se cumple solo cuando los dos conjuntos están vacíos. Sin esa comprobación, dividiríamos entre 0.- Ana y Sara comparten 3 canciones (Luz de neón, Café a las 3 y Domingo lento) y entre las dos tienen 5 distintas: 3 / 5 = 0,6, el 60%.
mas_parecidousa dos variables a la vez,mejorymejor_afinidad, que se asignan juntas con una asignación múltiple. Elcontinuesalta al propio usuario: si no, todos se parecerían más a sí mismos (afinidad 1).- Empezar
mejor_afinidaden 0 y comparar con>hace que una afinidad de 0 nunca gane. Pablo no comparte nada con nadie, así quemejorse queda enNone. - Fíjate en la fila de Luis en el dibujo: su afinidad con Ana es 40% y con Sara, 17%. Gana Ana, y la recomendación es lo que tiene Ana y Luis no:
ANA - LUIS, ordenado. recomendardevuelve siempre una tupla de dos elementos, también cuando no hay nadie parecido (None, []). Así, el bucle de abajo puede desempaquetar siempre enotro, listasin preocuparse de qué caso es.- El bucle final recorre
GUARDADASdirectamente, es decir, sus claves: los nombres de los usuarios.
Míralo paso a paso en Python Tutor
El fallo del fragmento 3 se entiende mucho mejor en vivo. Python Tutor ejecuta el código línea a línea y dibuja el diccionario como una tabla con las claves a la izquierda y los valores a la derecha. Elige Python 3 y pega este programa:
canciones = {"c01": "Luz de neón", "c02": "Café a las 3"}
print("c02" in canciones)
print("Café a las 3" in canciones)
print("Café a las 3" in canciones.values())
for codigo in canciones:
print(codigo, canciones[codigo])
Salida esperada:
True False True c01 Luz de neón c02 Café a las 3
Qué observar, paso a paso:
- Nada más crear
canciones, Python Tutor dibuja dos filas. Fíjate en qué hay a la izquierda ("c01","c02"): esas son las claves, lo único que mirain. - La primera comprobación busca
"c02"en esa columna de la izquierda y lo encuentra:True. - La segunda busca
"Café a las 3"en la misma columna. Aunque lo ves en la tabla, está en la columna de la derecha:False. - La tercera usa
.values(), que es justo la columna de la derecha: ahora sí,True. - En el bucle final, mira cómo
codigova tomando las claves una a una y cómocanciones[codigo]salta a la fila correspondiente para sacar el título.
Chuletario: Contenedores asociativos
Lo esencial en 30 segundos
- Diccionario: parejas
clave: valor. Se busca por clave, y buscar por clave es instantáneo. - **
d[k] = vañade si la clave es nueva y sustituye si ya existía. Leer** una clave que no existe daKeyError. - **
inyformiran las claves.** Para valores,.values(); para parejas,.items(). - **
get(k, defecto)** lee sin miedo. El contador esd[k] = d.get(k, 0) + 1. - Las claves deben ser inmutables: strings, números, tuplas. Nunca listas.
- Conjunto: elementos únicos y sin orden. Vacío con
set(), nunca con{}. - Álgebra:
|unión,&intersección,-diferencia,^simétrica.
Sintaxis de diccionarios
| Quiero… | Código | Resultado |
|---|---|---|
| Crear vacío | d = {} o dict() | {} |
| Crear con datos | {"Pixel": 176, "Marzo": 198} | Diccionario con 2 parejas |
| Desde parejas | dict([("pop", 3), ("indie", 2)]) | {'pop': 3, 'indie': 2} |
| Añadir o cambiar | d["Pixel"] = 180 | Crea o sustituye |
| Leer (seguro que existe) | d["Pixel"] | El valor o KeyError |
| Leer sin error | d.get("Pixel", 0) | El valor o 0 |
| ¿Existe la clave? | "Pixel" in d | True / False |
| ¿Existe el valor? | 176 in d.values() | True / False |
| Quitar y quedarse el valor | d.pop("Pixel") | El valor quitado |
| Quitar sin más | del d["Pixel"] | — |
| Número de parejas | len(d) | Entero |
| Recorrer claves | for k in d: | Claves en orden de inserción |
| Recorrer parejas | for k, v in d.items(): | Tuplas desempaquetadas |
| Claves ordenadas | sorted(d) | Lista |
| Suma de los valores | sum(d.values()) | Número |
| Contador | d[k] = d.get(k, 0) + 1 | Suma 1 a la clave |
| Anidado | d["c02"]["titulo"] | Un corchete por nivel |
Sintaxis de conjuntos
| Quiero… | Código | Resultado |
|---|---|---|
| Crear vacío | set() | set() |
| Crear con datos | {"pop", "indie"} | Conjunto de 2 elementos |
| Quitar repetidos de una lista | set(lista) | Conjunto |
| Añadir uno | s.add("rock") | Si ya estaba, no cambia |
| Añadir varios | s.update(["jazz", "funk"]) | Añade cada elemento |
| Quitar (debe estar) | s.remove("rock") | KeyError si no está |
| Quitar (si está) | s.discard("rock") | Nunca da error |
| Unión | a | b | Lo de alguno de los dos |
| Intersección | a & b | Lo de los dos |
| Diferencia | a - b | Lo de a que no está en b |
| Diferencia simétrica | a ^ b | Lo que no comparten |
| Subconjunto | a <= b / a < b | True / False |
| Mostrar en orden fijo | sorted(s) | Lista ordenada |
Plantillas de los ejercicios
Contador (Niveles 1 y 2):
cuentas = {}
for elemento in datos:
cuentas[elemento] = cuentas.get(elemento, 0) + 1
Diccionario de conjuntos (o de listas) (Nivel 2):
resultado = {}
for titulo, artista, genero in canciones:
if genero not in resultado:
resultado[genero] = set() # o [] si quieres una lista
resultado[genero].add(artista) # o .append(…) con listas
Máximo a mano sobre un diccionario (Nivel 2):
mejor = None
for clave, valor in d.items():
if mejor is None or valor > d[mejor]:
mejor = clave
Afinidad y recomendación con conjuntos (Nivel 3):
def afinidad(a, b):
union = a | b
if not union:
return 0
return len(a & b) / len(union)
recomendadas = sorted(guardadas[otro] - guardadas[usuario])
Errores que más se repiten
| Síntoma | Causa probable |
|---|---|
KeyError: 'Pixel' | Lees una clave que no existe: usa in o get |
KeyError en un contador con += | d[k] += 1 necesita leer antes: usa d[k] = d.get(k, 0) + 1 |
KeyError al hacer append | La clave aún no tiene su lista: créala antes (if k not in d: d[k] = []) |
AttributeError: 'dict' object has no attribute 'add' | Creaste el conjunto vacío con {}: usa set() |
TypeError: unhashable type: 'list' | Usas una lista como clave o como elemento de un conjunto: usa una tupla |
in dice False y el dato está | Lo buscas entre las claves y es un valor: usa .values() |
RuntimeError: dictionary changed size during iteration | Añades o borras claves mientras recorres: recorre list(d) |
ValueError: too many values to unpack | Recorres el diccionario sin .items() y desempaquetas en dos variables |
| El conjunto sale en otro orden cada vez | Los set no tienen orden: muéstralo con sorted |
TypeError: unsupported operand type(s) for +: 'set' and 'set' | La unión se escribe con |, no con + |
Antes de entregar, pregúntate
- ¿Puede faltar alguna de las claves que leo? Si puede, ¿la protejo con
ino conget? - ¿Busco por la clave (directo) o entre los valores (hay que recorrer)? ¿Estoy usando
inen el sitio correcto? - ¿Mis conjuntos vacíos están creados con
set()y mis claves son inmutables? - ¿Muestro los conjuntos con
sortedpara que la salida sea siempre la misma?
Si quieres repasar el tema desde la base, vuelve a la teoría de contenedores asociativos.
Sigue aprendiendoTodos los artículos de Python¿Te ha servido este artículo? Así puedes apoyar SergioLearns →
