Diccionarios y conjuntos
"Si tu programa busca cosas en una lista de mil elementos, andás lento. Si las busca en un diccionario, andás rápido."
Qué vas a aprender en este capítulo
Las listas son ideales cuando los datos van en orden. Pero ¿y cuando querés "el precio del producto X"? Recorrer la lista hasta encontrar X es lento si la lista es grande. Para eso existen los diccionarios: mapeos clave → valor con búsqueda instantánea. Y los conjuntos: colecciones sin duplicados, optimizadas para preguntar "¿X está acá?". Ambos se basan en una idea brillante llamada hashing, que vas a entender al final del capítulo.
2.1 La idea: agenda telefónica
💡 Intuición
Imaginate buscando un número en una lista de 1000 contactos. Tenés que recorrerlos todos hasta encontrar a "Carlos". En el peor caso, miráis los 1000.
Ahora imaginá una agenda de papel: tiene secciones por letra inicial. Buscás "C", abrís ahí, encontrás Carlos en segundos. No miraste mil — miraste decenas.
Un diccionario en Python hace algo aún más mágico: encuentra "Carlos" en tiempo constante, sin importar si tiene 10 o 10 millones de entradas. Para conseguir eso, paga el costo en complejidad interna: usa una técnica llamada hashing.
Por fuera, lo usás como una agenda mental: le pedís "el valor de la clave X" y te lo entrega. Por dentro, hace alquimia con tablas y funciones hash. Veremos los detalles, pero la idea fundamental: un diccionario es lookup rápido por clave.
Los conjuntos son su prima hermana: un set guarda claves únicas sin valores asociados. Útil para "tengo este elemento" tipo de chequeo.
2.2 Diccionarios: crear y acceder
📐 Fundamento
Sintaxis:
precios = {
"queso": 0.50,
"revuelta": 0.60,
"chicharron": 0.60,
}
vacio = {}
Las llaves: clave: valor. Las claves son únicas — si las repetís, gana la última.
Acceder:
precios["queso"] # 0.50
precios["mole"] # ❌ KeyError: 'mole'
Acceso seguro con .get:
precios.get("mole") # None (default)
precios.get("mole", 0.0) # 0.0 (default custom)
.get nunca lanza error. Útil cuando querés un valor por defecto.
Modificar:
precios["queso"] = 0.55 # actualizar
precios["frijol"] = 0.55 # agregar nueva clave
del precios["chicharron"] # eliminar
Comprobar pertenencia:
"queso" in precios # True
"mole" not in precios # True
in es rápido en dicts — chequea claves directamente sin recorrer.
Iterar:
# Solo claves
for k in precios:
print(k)
# Solo valores
for v in precios.values():
print(v)
# Claves y valores juntos
for k, v in precios.items():
print(f"{k}: ${v:.2f}")
.items() es la forma más útil — siempre que necesités ambos.
2.3 Restricciones de las claves
📐 Fundamento
Las claves deben ser hashables. Eso significa inmutables (más una propiedad técnica: poder computar un hash estable).
Hashables (válidas como claves):
int,float,bool,str,tuple(de hashables),frozenset.
No hashables (inválidas):
list,dict,set— todo lo mutable.
d = {(1, 2): "a", "x": 5} # OK
d = {[1, 2]: "a"} # ❌ TypeError: unhashable type: 'list'
Por qué. Si modificás una clave después de meterla, su "posición" interna cambia y el dict ya no la encuentra. Python lo prohíbe directamente para ahorrarte ese bug.
Los valores no tienen restricción: pueden ser cualquier cosa.
2.4 Operaciones útiles
📐 Fundamento
| Operación | Qué hace |
|---|---|
len(d) |
Cantidad de pares |
d[k] |
Valor de la clave k (KeyError si no está) |
d.get(k, default) |
Idem pero con default |
d.setdefault(k, v) |
Si k está, devuelve. Si no, la crea con v y devuelve v. |
d.update(otro_dict) |
Mezcla otro_dict en d (sobrescribe) |
d.pop(k) |
Quita y devuelve el valor |
d.clear() |
Vacía |
dict.fromkeys(iter, valor) |
Crea dict con esas claves y mismo valor |
**d |
Desempacar como kwargs (en llamadas/literales) |
setdefault truco útil:
agrupados = {}
for nombre, edad in personas:
agrupados.setdefault(edad, []).append(nombre)
Mucho más limpio que:
if edad not in agrupados:
agrupados[edad] = []
agrupados[edad].append(nombre)
Mezclar dicts (Python 3.9+):
a = {"x": 1, "y": 2}
b = {"y": 5, "z": 3}
c = a | b # {"x": 1, "y": 5, "z": 3} — b gana en colisión
a |= b # actualiza a in-place
Comprehensions de dict:
cuadrados = {x: x ** 2 for x in range(10)}
# {0: 0, 1: 1, 2: 4, ..., 9: 81}
# Invertir un dict
inverso = {v: k for k, v in d.items()}
# Filtrar
caros = {nombre: precio for nombre, precio in precios.items() if precio > 0.50}
2.5 Patrones clásicos con dict
🛠️ En la práctica
Contador
Contar cuántas veces aparece cada elemento.
texto = "ana ana beto carla ana beto"
palabras = texto.split()
conteo = {}
for w in palabras:
conteo[w] = conteo.get(w, 0) + 1
print(conteo) # {'ana': 3, 'beto': 2, 'carla': 1}
Mejor con collections.Counter:
from collections import Counter
conteo = Counter(palabras)
print(conteo) # Counter({'ana': 3, 'beto': 2, 'carla': 1})
print(conteo.most_common(2)) # [('ana', 3), ('beto', 2)]
Agrupador
Agrupar elementos por una clave derivada.
gente = [("Ana", 25), ("Beto", 30), ("Carla", 25), ("Dani", 30)]
por_edad = {}
for nombre, edad in gente:
por_edad.setdefault(edad, []).append(nombre)
print(por_edad) # {25: ['Ana', 'Carla'], 30: ['Beto', 'Dani']}
Con collections.defaultdict:
from collections import defaultdict
por_edad = defaultdict(list)
for nombre, edad in gente:
por_edad[edad].append(nombre)
Cache / memoización
Guardar resultados de cómputos caros.
cache = {}
def fibonacci(n):
if n in cache:
return cache[n]
if n < 2:
return n
r = fibonacci(n - 1) + fibonacci(n - 2)
cache[n] = r
return r
print(fibonacci(100)) # rápido!
(En realidad Python tiene functools.lru_cache que hace esto en un decorador.)
Lookup table
Reemplazar cadenas de if/elif con un dict.
# Antes: feo
def saludar(idioma):
if idioma == "es":
return "Hola"
elif idioma == "en":
return "Hello"
elif idioma == "pt":
return "Olá"
else:
return "?"
# Después: limpio
SALUDOS = {"es": "Hola", "en": "Hello", "pt": "Olá"}
def saludar(idioma):
return SALUDOS.get(idioma, "?")
2.6 Conjuntos (sets)
📐 Fundamento
Un set es una colección sin orden y sin duplicados de elementos hashables.
Sintaxis:
s = {1, 2, 3}
vacio = set() # ¡no `{}` que es un dict vacío!
de_lista = set([1, 2, 2, 3, 3, 3]) # {1, 2, 3}
Operaciones:
s.add(5)
s.remove(2) # KeyError si no está
s.discard(2) # silencioso si no está
2 in s # rápido
len(s)
Operaciones de conjuntos clásicas:
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
a | b # unión: {1, 2, 3, 4, 5, 6}
a & b # intersección: {3, 4}
a - b # diferencia (en a, no en b): {1, 2}
a ^ b # diferencia simétrica (en uno o el otro, no ambos): {1, 2, 5, 6}
a.issubset(b) # ¿a ⊆ b?
a.issuperset(b) # ¿a ⊇ b?
a.isdisjoint(b) # ¿no comparten?
Casos de uso:
- Eliminar duplicados.
list(set(lista))— pero pierde orden. - Pertenencia rápida.
if x in set:es , contra en lista. - Operaciones de conjuntos. Cuando los datos son matemáticamente conjuntos.
Frozenset. Versión inmutable de set. Hashable → puede ser clave de dict.
fs = frozenset([1, 2, 3])
d = {fs: "valor"}
2.7 Cómo funciona internamente: hashing
📐 Fundamento
Hashing es la magia detrás de la velocidad de dicts y sets.
Función hash
hash(x) → un entero. Misma x, mismo hash. Hashes distintos → valores distintos. Mismos hashes → posiblemente mismo valor (colisión).
hash("ana") # algún entero, p.ej. -3471234567
hash("beto") # otro entero
hash(42) # 42 (Python hashea ints a sí mismos)
Tabla hash
Internamente un dict es un array de "huecos" (slots). Para guardar d["clave"] = valor:
- Calcula
hash("clave"). - Lo reduce a una posición:
pos = hash % tamaño_array. - Guarda
("clave", valor)enarray[pos].
Para buscar d["clave"]:
- Calcula
hash. - Va directo a
array[pos]. - Verifica que la clave coincida (por si hay colisión).
Resultado: búsqueda en tiempo constante promedio. Sin importar si el dict tiene 10 o 10 millones.
Colisiones
A veces dos claves distintas dan el mismo pos. Estrategias:
- Chaining. Cada slot guarda una lista. Si colisionan, se anexan.
- Open addressing. Si el slot está ocupado, prueba el siguiente. Es lo que usa Python.
Cuando la tabla se llena (>2/3 lleno típicamente), Python crece la tabla — operación cara, pero raras veces necesaria. Por eso "amortizado" sigue siendo .
Implicaciones
- No hay orden garantizado (aunque Python 3.7+ mantiene el orden de inserción).
- Las claves deben ser hashables. Por eso no podés usar list como clave.
hashdebe ser estable durante la vida del objeto.
Esto explica todas las restricciones que veías sin entender hasta ahora.
2.8 Proyecto: pupusería con dict de productos
🏗️ Avance del proyecto
Refactorizamos el sistema. El catálogo ahora es un dict (búsqueda por código), y agregamos inventario:
# pupuseria_v2.py - dicts e inventario
productos = {
"Q": {"nombre": "Pupusa de queso", "precio": 0.50, "stock": 100},
"R": {"nombre": "Pupusa revuelta", "precio": 0.60, "stock": 80},
"CH": {"nombre": "Pupusa de chicharron", "precio": 0.60, "stock": 50},
"F": {"nombre": "Pupusa de frijol", "precio": 0.55, "stock": 60},
"CU": {"nombre": "Curtido extra", "precio": 0.25, "stock": 200},
"RT": {"nombre": "Refresco tamarindo", "precio": 1.00, "stock": 40},
}
IVA = 0.13
def imprimir_menu():
print("\n=== Menú ===")
for codigo, p in productos.items():
marker = " " if p["stock"] > 0 else "🚫"
print(f" [{codigo:3}] {p['nombre']:30} ${p['precio']:.2f} {marker}stock: {p['stock']}")
print()
def pedir_pedido():
"""Devuelve dict {codigo: cantidad}."""
pedido = {}
while True:
s = input("Código (Enter para terminar): ").strip().upper()
if not s:
break
if s not in productos:
print(f" No tenemos '{s}'")
continue
if productos[s]["stock"] <= 0:
print(f" Sin stock de {productos[s]['nombre']}")
continue
cant_s = input(" Cantidad: ").strip()
if not cant_s.isdigit() or int(cant_s) <= 0:
print(" Cantidad inválida")
continue
cant = int(cant_s)
if cant > productos[s]["stock"]:
print(f" Solo tenemos {productos[s]['stock']}")
continue
# acumular si lo pidió antes
pedido[s] = pedido.get(s, 0) + cant
return pedido
def calcular_y_descontar(pedido):
lineas = []
subtotal = 0
for codigo, cant in pedido.items():
p = productos[codigo]
importe = cant * p["precio"]
subtotal += importe
lineas.append((cant, p["nombre"], p["precio"], importe))
productos[codigo]["stock"] -= cant # descontamos inventario
return lineas, subtotal
def imprimir_recibo(lineas, subtotal):
print("\n=== RECIBO ===")
for cant, nombre, precio, importe in lineas:
print(f" {cant:3}× {nombre:25} @<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>p</mi><mi>r</mi><mi>e</mi><mi>c</mi><mi>i</mi><mi>o</mi><mo>:</mo><mn>.2</mn><mi>f</mi></mrow><annotation encoding="application/x-tex">{precio:.2f} </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">ec</span><span class="mord mathnormal">i</span><span class="mord mathnormal">o</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mord">.2</span><span class="mord mathnormal" style="margin-right:0.1076em;">f</span></span></span></span></span>{importe:>6.2f}")
iva = subtotal * IVA
total = subtotal + iva
print("-" * 56)
print(f" Subtotal{'':38} ${subtotal:>6.2f}")
print(f" IVA (13%){'':37} ${iva:>6.2f}")
print(f" TOTAL{'':41} ${total:>6.2f}")
def main():
imprimir_menu()
pedido = pedir_pedido()
if not pedido:
print("Sin pedido. Bye.")
return
lineas, subtotal = calcular_y_descontar(pedido)
imprimir_recibo(lineas, subtotal)
imprimir_menu() # mostrar inventario actualizado
if __name__ == "__main__":
main()
Mejoras nuevas:
- Búsqueda por código instantánea.
- Inventario descontado automáticamente.
- Acumulación si el cliente pide el mismo producto dos veces.
- Datos enriquecidos (cada producto es un dict con nombre, precio, stock).
Próximo capítulo: vamos a calcular descuentos por niveles con recursión.
2.9 Resumen visual
| Estructura | Para qué |
|---|---|
dict |
Mapeo clave → valor |
set |
Colección sin duplicados, lookup rápido |
frozenset |
Set inmutable, hashable |
Counter |
Contador automático |
defaultdict |
Dict con valor default |
| Operación | Lista | Dict | Set |
|---|---|---|---|
| Buscar | |||
| Insertar | al final | ||
| Mantener orden | Sí | Sí (3.7+) | No |
| Permite duplicados | Sí | Claves no | No |
2.10 Ejercicios
✏️ Ejercicio 2.1 — Contar letras
Dada una frase, contar cuántas veces aparece cada letra (ignorando mayúsculas).
Solución
from collections import Counter
frase = "El que persevera alcanza"
conteo = Counter(c for c in frase.lower() if c.isalpha())
print(conteo.most_common())
✏️ Ejercicio 2.2 — Inventario
Tenés un dict inventario = {"manzanas": 10, "naranjas": 5}. Escribí función vender(producto, cantidad) que descuente del inventario o devuelva False si no alcanza.
Solución
inventario = {"manzanas": 10, "naranjas": 5}
def vender(producto, cantidad):
if producto not in inventario:
print(f"No tenemos {producto}")
return False
if inventario[producto] < cantidad:
print(f"Solo {inventario[producto]} de {producto}")
return False
inventario[producto] -= cantidad
return True
vender("manzanas", 3) # True
vender("manzanas", 50) # False
✏️ Ejercicio 2.3 — Buscar duplicados
Dada una lista, devolvé el conjunto de elementos que aparecen más de una vez.
Solución
from collections import Counter
def duplicados(lista):
return {x for x, n in Counter(lista).items() if n > 1}
print(duplicados([1, 2, 3, 2, 4, 5, 3, 6])) # {2, 3}
{x for ...} es una set comprehension — la prima del list comprehension.
✏️ Ejercicio 2.4 — Operaciones con sets
Dos cursos: A tomó "ana, beto, carla, dani". B tomó "beto, ema, carla, fran".
a. ¿Quiénes tomaron ambos? b. ¿Quiénes solo tomaron A? c. ¿Cuántos en total tomaron al menos uno?
Solución
A = {"ana", "beto", "carla", "dani"}
B = {"beto", "ema", "carla", "fran"}
ambos = A & B # {beto, carla}
solo_A = A - B # {ana, dani}
total = len(A | B) # 6
print(ambos, solo_A, total)
✏️ Ejercicio 2.5 — Index inverso (vistazo a info retrieval)
Construí un índice inverso: dado un diccionario documentos = {1: "ana fue al mercado", 2: "carla fue al mercado", 3: "ana compra pupusas"}, generá un dict palabra → set de docs donde aparece.
Solución
from collections import defaultdict
documentos = {
1: "ana fue al mercado",
2: "carla fue al mercado",
3: "ana compra pupusas",
}
indice = defaultdict(set)
for doc_id, texto in documentos.items():
for palabra in texto.split():
indice[palabra].add(doc_id)
# Buscar "ana"
print(indice["ana"]) # {1, 3}
print(indice["mercado"]) # {1, 2}
# Buscar varios términos (intersección)
print(indice["ana"] & indice["pupusas"]) # {3}
Esto es literalmente la base de un buscador como Google (en muy pequeño).
2.11 Para profundizar
- Documentación oficial: https://docs.python.org/es/3/tutorial/datastructures.html
collectionsmodule: Counter, defaultdict, OrderedDict, namedtuple.collections.abcpara entender las "interfaces" abstractas.- Próximo capítulo: Recursión — funciones que se llaman a sí mismas, una de las ideas más profundas de la programación.
Definiciones nuevas: diccionario, dict, clave, valor, hashable, hash, tabla hash, colisión, set, conjunto, operaciones de conjuntos, frozenset, Counter, defaultdict, comprehension de dict/set, índice inverso.