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 O(1)O(1), contra O(n)O(n) 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:

  1. Calcula hash("clave").
  2. Lo reduce a una posición: pos = hash % tamaño_array.
  3. Guarda ("clave", valor) en array[pos].

Para buscar d["clave"]:

  1. Calcula hash.
  2. Va directo a array[pos].
  3. Verifica que la clave coincida (por si hay colisión).

Resultado: búsqueda en tiempo constante O(1)O(1) 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 O(1)O(1).

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.
  • hash debe 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:

  1. Búsqueda por código instantánea.
  2. Inventario descontado automáticamente.
  3. Acumulación si el cliente pide el mismo producto dos veces.
  4. 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 O(n)O(n) O(1)O(1) O(1)O(1)
Insertar O(1)O(1) al final O(1)O(1) O(1)O(1)
Mantener orden Sí (3.7+) No
Permite duplicados Claves no No

2.10 Ejercicios

✏️ Ejercicio 2.1 — Contar letras

Dada una frase, contar cuántas veces aparece cada letra (ignorando mayúsculas).

✏️ 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.

✏️ Ejercicio 2.3 — Buscar duplicados

Dada una lista, devolvé el conjunto de elementos que aparecen más de una vez.

✏️ 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?

✏️ 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.

2.11 Para profundizar


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.