UdelaR  ·  Máster en Análisis de Datos

Introducción a Python

Clase 3 — Estructuras de datos

Asignatura  Introducción a Python Profesor  Daniel Miles Touya Universidad  UdelaR-Uvigo
Parte I

Secuencias: el concepto unificador

Containers: objetos que contienen referencias a otros objetos teoría

En la Clase 2 vimos que todo en Python es un objeto y que las variables son etiquetas que apuntan a objetos. Ahora damos un paso más: algunos objetos contienen referencias a otros objetos. La documentación oficial los llama containers.

"Some objects contain references to other objects; these are called containers. Examples of containers are tuples, lists and dictionaries. The references are part of a container's value." → fuente

Un container no "guarda valores" — guarda referencias a objetos, exactamente igual que una variable. La diferencia es que un container puede guardar muchas referencias a la vez, organizadas según su tipo: por posición (secuencias), por clave (mappings) o sin orden (sets).

— Un container guarda referencias, no valores —

# Una lista es un container: guarda referencias a objetos
lista = [10, 20, 30]

# Cada elemento de la lista es un objeto independiente
print(id(lista[0]))   # id del objeto 10
print(id(lista[1]))   # id del objeto 20
print(id(lista[2]))   # id del objeto 30

# El objeto 10 existe independientemente de la lista
x = 10
print(id(x) == id(lista[0]))  # ¿mismo objeto?

x y lista[0] apuntan al mismo objeto 10 en memoria. La lista no ha copiado el valor — ha guardado una referencia al mismo objeto al que apunta x. Esto tiene consecuencias importantes cuando el objeto referenciado es mutable.

— La trampa del container inmutable con contenido mutable —

La documentación oficial describe un caso que sorprende a casi todo el mundo: una tupla es inmutable, pero si contiene una lista (mutable), el valor de la tupla puede cambiar.

"The value of an immutable container object that contains a reference to a mutable object can change when the latter's value is changed; however the container is still considered immutable, because the collection of objects it contains cannot be changed." → fuente

# Una tupla inmutable que contiene una lista mutable
tupla = (1, [2, 3], 4)
print(f"Antes:  {tupla}")
print(f"id(tupla): {hex(id(tupla))}")
print(f"id(tupla[1]): {hex(id(tupla[1]))}")

# Modificamos la lista dentro de la tupla
tupla[1].append(99)
print(f"\nDespués: {tupla}")
print(f"id(tupla): {hex(id(tupla))}")       # mismo id
print(f"id(tupla[1]): {hex(id(tupla[1]))}")  # mismo id

# Pero no podemos cambiar a qué objetos apunta la tupla
try:
    tupla[1] = [99]   # ¿qué error produce?
except TypeError as e:
    print(f"\nError: {e}")

La tupla sigue siendo inmutable: no puedes cambiar a qué objetos apunta — tupla[1] = algo lanza un TypeError. Pero la lista a la que apunta tupla[1] es mutable: puedes modificar su contenido. La tupla sigue apuntando al mismo objeto lista — lo que ha cambiado es el contenido de ese objeto.

Dicho de otra forma: la inmutabilidad de la tupla garantiza que sus referencias no cambian. No garantiza que los objetos referenciados no cambien.

— Las tres categorías de containers en Python —

El data model de Python organiza los containers en tres categorías según cómo se accede a sus elementos:

Sequences

Acceso por índice entero

list
tuple
str

Mappings

Acceso por clave arbitraria

dict
defaultdict
OrderedDict

Sets

Sin orden, sin duplicados

set
frozenset

# Sequence: acceso por índice entero
lista = [10, 20, 30]
print(lista[0])         # 10 — primer elemento

# Mapping: acceso por clave
alumno = {"nombre": "Ana", "nota": 8.5}
print(alumno["nombre"])  # "Ana" — acceso por clave

# Set: sin índice, sin clave — solo pertenencia
categorias = {"A", "B", "A", "C"}
print(categorias)        # ¿cuántos elementos?
print("A" in categorias)  # pertenencia

# ¿Qué tipo es cada uno?
print(type(lista), type(alumno), type(categorias))
  1. Ejecuta el primer bloque. ¿x y lista[0] tienen el mismo id? ¿Qué confirma eso sobre cómo guarda referencias un container?
  2. Antes de ejecutar el bloque de la tupla con lista: predice qué ocurre cuando haces tupla[1].append(99). ¿Cambia el id de la tupla? ¿Y el id de tupla[1]? ¿Por qué la tupla sigue siendo inmutable aunque su contenido haya cambiado?
  3. En el bloque de las tres categorías: ¿cuántos elementos tiene categorias aunque hayas puesto "A" dos veces? ¿Qué nos dice eso sobre la naturaleza de un set?
  4. ¿Qué ocurre si intentas acceder a lista["nombre"] o a alumno[0]? ¿Qué tipo de error produce cada caso y qué nos dice sobre la diferencia entre sequences y mappings?
Respuesta orientativa
a

Sí, tienen el mismo id — para enteros pequeños como 10, Python los tiene precreados en memoria y tanto x como lista[0] apuntan al mismo objeto. Esto confirma que la lista no ha copiado el valor 10 — ha guardado una referencia al objeto 10 que ya existía en memoria.

b

El id de la tupla no cambia — sigue siendo el mismo objeto en memoria. El id de tupla[1] tampoco cambia — la lista sigue siendo el mismo objeto, solo que ahora tiene un elemento más.

La tupla sigue siendo inmutable porque lo que no puedes cambiar es a qué objetos apunta — sus referencias. tupla[1] sigue apuntando a la misma lista. Lo que ha cambiado es el contenido de esa lista — y eso es responsabilidad de la lista, no de la tupla.

La distinción clave: inmutabilidad significa que las referencias del container no cambian. No significa que los objetos referenciados no puedan cambiar.
c

categorias tiene tres elementos: "A", "B" y "C". El segundo "A" se ignora porque un set no permite duplicados — cada elemento puede aparecer solo una vez. Esta propiedad lo hace ideal para eliminar duplicados de una lista o verificar pertenencia de forma eficiente.

d

lista["nombre"] lanza un TypeError: los índices de una lista deben ser enteros, no strings. alumno[0] lanza un KeyError: el diccionario no tiene la clave 0.

La diferencia fundamental: las sequences usan índices enteros y el error es un TypeError si el índice no es entero. Los mappings usan claves arbitrarias y el error es un KeyError si la clave no existe. Saber qué error esperar es la primera pista para entender qué tipo de container tienes delante cuando lees código ajeno.
Indexación y slicing: el mecanismo común a todas las secuencias teoría

Las secuencias — str, list y tuple — comparten un mecanismo de acceso común: la indexación. Cada elemento tiene una posición numérica que permite acceder a él directamente.

La documentación oficial define las secuencias como objetos cuyo conjunto de índices son los números enteros no negativos. El acceso a un elemento se hace con objeto[índice]. → fuente

— ¿Por qué el índice empieza en 0? —

No es arbitrario. El índice representa el desplazamiento desde el inicio de la secuencia. El primer elemento está a desplazamiento 0 del inicio — por eso es [0], no [1]. El segundo elemento está a desplazamiento 1 — es [1]. Y así sucesivamente.

Esto significa que para una secuencia de n elementos, los índices válidos son de 0 a n-1. Intentar acceder al índice n produce un IndexError.

ciudad = "Madrid"   # 6 caracteres: índices 0..5

#  M  a  d  r  i  d
#  0  1  2  3  4  5   ← índices positivos
# -6 -5 -4 -3 -2 -1   ← índices negativos

print(ciudad[0])    # 'M' — primer carácter
print(ciudad[5])    # 'd' — último carácter
print(ciudad[6])    # ¿qué error produce?

— Índices negativos: contar desde el final —

Python permite índices negativos — cuentan desde el final de la secuencia. [-1] es siempre el último elemento, [-2] el penúltimo, y así sucesivamente. Son especialmente útiles cuando no sabes la longitud de la secuencia de antemano.

ventas = [100, 200, 150, 300, 250]

print(ventas[-1])    # último: 250
print(ventas[-2])    # penúltimo: 300
print(ventas[-5])    # primero: 100 — equivale a [0]

# Funciona igual con strings y tuplas
tupla = ("ene", "feb", "mar", "abr")
print(tupla[-1])     # "abr"
print(tupla[-2])     # "mar"

# Relación entre índice positivo y negativo
n = len(ventas)
print(ventas[-1] == ventas[n - 1])   # siempre True

— Slicing: extraer subsecuencias con [start:stop:step] —

El slicing extrae una porción de la secuencia. La sintaxis es [start:stop:step]:

start — índice donde empieza (incluido). Por defecto: 0.

stop — índice donde termina (excluido). Por defecto: fin de la secuencia.

step — salto entre elementos. Por defecto: 1.

El stop es exclusivo — es la fuente de confusión más frecuente con el slicing. lista[1:4] devuelve los elementos en las posiciones 1, 2 y 3 — no el 4.

meses = ["ene", "feb", "mar", "abr", "may", "jun"]
#          0      1      2      3      4      5

# Slicing básico
print(meses[1:4])     # ['feb', 'mar', 'abr'] — stop excluido
print(meses[:3])      # ['ene', 'feb', 'mar'] — desde el inicio
print(meses[3:])      # ['abr', 'may', 'jun'] — hasta el final
print(meses[:])       # copia completa — importante con listas

# Con step
print(meses[::2])     # elementos pares: ['ene', 'mar', 'may']
print(meses[::-1])    # invertida: ['jun', 'may', 'abr', 'mar', 'feb', 'ene']

# Con índices negativos
print(meses[-3:])     # últimos 3: ['abr', 'may', 'jun']
print(meses[-3:-1])   # predice antes de ejecutar

# Funciona igual con strings
texto = "Python"
print(texto[1:4])     # 'yth'
print(texto[::-1])    # 'nohtyP' — string invertido

— El slicing siempre crea un objeto nuevo —

A diferencia de la indexación simple — que devuelve una referencia al objeto en esa posición — el slicing siempre crea un objeto nuevo. Esto es importante para la mutabilidad: modificar el slice no modifica la secuencia original. Pero ojo: es una copia superficial — los elementos dentro siguen siendo referencias al mismo objeto.

original = [1, 2, 3, 4, 5]
trozo = original[1:4]

print(f"original: {original}")
print(f"trozo:    {trozo}")
print(f"¿Mismo objeto? {original is trozo}")
print(f"id(original): {hex(id(original))}")
print(f"id(trozo):    {hex(id(trozo))}")

# Modificar trozo no afecta a original
trozo[0] = 99
print(f"\nDespués de trozo[0] = 99:")
print(f"original: {original}")   # ¿cambia?
print(f"trozo:    {trozo}")

— Operaciones comunes a todas las secuencias —

lista = [3, 1, 4, 1, 5, 9, 2, 6]
texto = "Mississippi"

# len() — longitud
print(len(lista))          # 8
print(len(texto))          # 11

# in — pertenencia
print(4 in lista)          # True
print("ss" in texto)       # True — busca subsecuencia

# count() — cuántas veces aparece
print(lista.count(1))      # 2
print(texto.count("s"))    # ¿cuántas s tiene Mississippi?

# index() — posición de la primera aparición
print(lista.index(1))      # 1 — primera aparición
print(texto.index("s"))    # ¿en qué posición está la primera s?

# Concatenación y repetición
print([1, 2] + [3, 4])    # [1, 2, 3, 4]
print("ab" * 3)            # 'ababab'
print((0,) * 5)            # (0, 0, 0, 0, 0) — tupla de ceros

# min(), max(), sum()
print(min(lista), max(lista), sum(lista))
  1. Ejecuta el bloque de índices. ¿Qué error produce ciudad[6]? ¿Cómo accederías al último carácter sin saber de antemano la longitud del string?
  2. Antes de ejecutar: predice qué devuelve meses[-3:-1]. Luego verifica. ¿El elemento en la posición -1 está incluido o excluido? ¿Por qué?
  3. Ejecuta el bloque de slicing con step. ¿Qué devuelve meses[::-1]? ¿Cómo usarías esto para invertir un string? ¿Y para verificar si un string es un palíndromo?
  4. En el bloque de slicing y objeto nuevo: después de trozo[0] = 99, ¿cambia original? ¿Por qué? Verifica con id().
  5. Dado este fragmento de código de análisis de datos, predice el resultado de cada línea antes de ejecutar:
    precios = [12.5, 8.0, 15.3, 9.7, 11.2]
    print(precios[1:3])
    print(precios[-2:])
    print(sum(precios[:3]) / 3)
    print(max(precios[1:4]))
Respuesta orientativa
a

Produce un IndexError: "string index out of range". Para acceder al último carácter sin saber la longitud: ciudad[-1] o ciudad[len(ciudad)-1]. La primera forma es más pythónica y más frecuente en código real.

b

meses[-3:-1] devuelve ['abr', 'may']. El elemento en posición -1 — 'jun' — está excluido porque el stop siempre es exclusivo, independientemente de si es positivo o negativo. Para incluir el último elemento: meses[-3:].

La trampa más frecuente: lista[1:4] devuelve los elementos 1, 2 y 3 — no el 4. Cuando leas slicing en código ajeno, recuerda siempre que el stop es el primer elemento que NO se incluye.
c

meses[::-1] devuelve la lista completa en orden inverso. Para invertir un string: texto[::-1]. Para verificar si es palíndromo:

palabra = "reconocer"
print(palabra == palabra[::-1])   # True
Patrón frecuente: [::-1] es la forma pythónica de invertir cualquier secuencia. Lo verás en código real y en código generado por IA constantemente.
d

original no cambia — sigue siendo [1, 2, 3, 4, 5]. El slicing creó un objeto nuevo con su propio id, así que modificar trozo no afecta a original. Sin embargo, si los elementos fueran objetos mutables — listas dentro de listas — sí habría efecto porque el slicing es una copia superficial.

e

precios[1:3] → [8.0, 15.3] — posiciones 1 y 2. precios[-2:] → [9.7, 11.2] — últimos dos. sum(precios[:3]) / 3 → media de los tres primeros: (12.5 + 8.0 + 15.3) / 3 = 11.93. max(precios[1:4]) → máximo de posiciones 1, 2 y 3: max(8.0, 15.3, 9.7) = 15.3.

Patrón de análisis de datos: combinar slicing con sum(), max(), min() es una forma concisa de calcular estadísticas sobre subconjuntos de datos sin necesidad de pandas.
El mapa completo: cuándo usar cada estructura teoría

Python tiene cuatro estructuras de datos principales. No son intercambiables — cada una existe por una razón concreta y tiene un caso de uso natural. Elegir mal la estructura no produce ningún error, pero produce código más lento, más difícil de leer y más propenso a errores silenciosos.

Antes de entrar en cada una en detalle, necesitas tener el mapa completo.

— Las cuatro estructuras: propiedades clave —

list tuple dict set
Orden garantizado ✓ ✓ ✓ (3.7+) ✗
Mutable ✓ ✗ ✓ ✓
Duplicados ✓ ✓ claves: ✗ ✗
Acceso por índice índice clave solo in
Uso típico colección ordenada dato fijo clave→valor unicidad
Sintaxis [1, 2] (1, 2) {"a": 1} {1, 2}

— El mismo problema con la estructura correcta e incorrecta —

El siguiente ejemplo muestra cómo la elección de estructura afecta a la claridad del código y a la eficiencia. El problema: dado un listado de ventas por producto, encontrar los productos únicos y acceder a la venta de un producto concreto.

# Datos de ventas
ventas = [
    ("manzana", 150),
    ("pera",    80),
    ("manzana", 200),
    ("naranja", 120),
    ("pera",    95),
]

# ❌ Con lista: buscar productos únicos es lento y feo
productos_lista = []
for producto, _ in ventas:
    if producto not in productos_lista:
        productos_lista.append(producto)
print(f"Únicos (lista): {productos_lista}")

# ✓ Con set: directo y eficiente
productos_set = {producto for producto, _ in ventas}
print(f"Únicos (set):   {productos_set}")

# ❌ Con lista: acceder a la venta de "pera" requiere recorrer todo
total_pera_lista = 0
for producto, cantidad in ventas:
    if producto == "pera":
        total_pera_lista += cantidad
print(f"Total pera (lista): {total_pera_lista}")

# ✓ Con dict: acceso directo por clave
totales = {}
for producto, cantidad in ventas:
    totales[producto] = totales.get(producto, 0) + cantidad
print(f"Totales (dict): {totales}")
print(f"Total pera:     {totales['pera']}")

— Estas estructuras en pandas y en datos reales —

Las cuatro estructuras aparecen constantemente en el trabajo con datos. Reconocerlas en código ajeno — y en código generado por IA — es lo que te permite entender qué está haciendo el programa sin ejecutarlo.

import pandas as pd

# Un DataFrame se construye desde un dict de listas
df = pd.DataFrame({
    "producto": ["manzana", "pera", "naranja"],  # list
    "ventas":   [350, 175, 120],                 # list
    "activo":   [True, True, False]               # list
})

# El DataFrame es esencialmente un dict de Series
print(type(df))
print(type(df["ventas"]))       # Series — como una lista ordenada

# Las columnas son una estructura similar a una lista
print(type(df.columns))         # Index
print(df.columns.tolist())      # convertir a lista

# Los valores únicos de una columna son como un set
categorias = set(df["producto"])
print(f"Categorías únicas: {categorias}")

# Acceder a una fila como dict
primera_fila = df.iloc[0].to_dict()
print(f"Primera fila: {primera_fila}")
print(type(primera_fila))       # dict
El patrón que verás siempre: los datos llegan como listas o dicts, se procesan con DataFrames de pandas, y se usan sets para verificar unicidad o membresía. Las tuplas aparecen como retornos de funciones y como claves de diccionarios. Conocer las cuatro estructuras es conocer el vocabulario básico del análisis de datos en Python.

— ¿Cuál usar? Árbol de decisión —

¿Necesitas asociar claves a valores?
    → Sí → dict
    → No → ¿Necesitas garantizar unicidad?
            → Sí → set
            → No → ¿Los datos cambiarán?
                    → Sí → list
                    → No → tuple
  1. Mira la tabla comparativa. ¿Cuál es la única estructura que no permite acceso por posición ni por clave? ¿Qué operación sí permite? ¿Para qué caso de uso es ideal?
  2. En el ejemplo de ventas, ¿por qué la versión con set es más eficiente que la versión con list para encontrar productos únicos? ¿Qué ocurre internamente en cada caso?
  3. Ejecuta el bloque de pandas. ¿Qué tipo tiene df["ventas"]? ¿Y df.iloc[0].to_dict()? ¿Qué estructura de datos de Python se parece más a un DataFrame?
  4. Usando el árbol de decisión, elige la estructura más adecuada para cada caso y justifica:
    — Un listado de temperaturas horarias de los últimos 30 días.
    — Las coordenadas GPS de una ciudad (latitud, longitud).
    — El número de veces que aparece cada palabra en un texto.
    — Los países que han participado en un estudio, sin repetición.
Respuesta orientativa
a

El set es la única estructura sin acceso por posición ni por clave. Solo permite verificar pertenencia con in y operaciones de conjuntos (union, intersection, difference). Es ideal cuando lo único que necesitas saber es si un elemento está o no está — sin importar el orden ni la posición.

b

Con list, verificar si un elemento ya existe requiere recorrer toda la lista — en el peor caso, comparar con cada elemento. Si la lista tiene 1000 productos, puede hacer hasta 1000 comparaciones por cada nuevo producto.

Con set, la verificación de pertenencia es casi instantánea — usa una tabla hash internamente y no necesita recorrer ninguna colección. Para 1 millón de elementos, la diferencia de velocidad es de segundos vs microsegundos.

Regla práctica: si necesitas verificar pertenencia muchas veces, convierte a set primero. elemento in set es órdenes de magnitud más rápido que elemento in list.
c

df["ventas"] es una pd.Series — una secuencia ordenada con índice, similar a una lista pero con más funcionalidades. df.iloc[0].to_dict() es un dict donde las claves son los nombres de columna y los valores son los datos de esa fila. Un DataFrame se parece más a un dict de listas — cada clave es el nombre de columna y cada valor es la lista de datos de esa columna.

d

Temperaturas horarias → list: datos ordenados que pueden actualizarse, el orden importa y puede haber duplicados.

Coordenadas GPS → tuple: dato fijo que no debería cambiar, tiene exactamente dos elementos con significado posicional claro. Las tuplas son además hashables — pueden usarse como claves de diccionario.

Frecuencia de palabras → dict: asocia cada palabra (clave) con su número de apariciones (valor). Acceso directo por palabra.

Países participantes → set: solo importa si un país está o no, sin orden ni duplicados.

La elección correcta no es solo estética: afecta a la velocidad del programa, a la claridad del código y a la facilidad de detectar errores. Cuando leas código ajeno, identificar la estructura usada te dice inmediatamente qué operaciones espera hacer el autor.
Parte II

List: la secuencia mutable

list: secuencia mutable de referencias a objetos teoría

La documentación oficial define las listas como secuencias mutables, típicamente usadas para almacenar colecciones de elementos homogéneos — aunque en Python una lista puede contener objetos de cualquier tipo. → fuente

Desde el modelo de objetos de la Clase 2: una lista es un container mutable que guarda referencias a objetos, no los objetos en sí. Cada posición de la lista contiene una referencia — una etiqueta — que apunta a un objeto en memoria. Esos objetos pueden ser de cualquier tipo: números, strings, booleanos, otras listas, funciones.

Tres propiedades definen una lista: está ordenada — los elementos mantienen el orden en que se insertaron; es mutable — puedes añadir, eliminar y modificar elementos después de crearla; permite duplicados — el mismo objeto puede aparecer en varias posiciones.

— Formas de crear una lista —

# Forma más común: literales con corchetes
ventas = [100, 200, 150, 300]
meses  = ["ene", "feb", "mar"]
vacia  = []

# Una lista puede contener objetos de tipos distintos
mixta = [42, "hola", True, 3.14, None]
print(mixta)
print([type(x).__name__ for x in mixta])  # tipo de cada elemento

# list() — convierte otros objetos iterables a lista
desde_string = list("Python")    # cada carácter es un elemento
desde_rango  = list(range(5))    # [0, 1, 2, 3, 4]
desde_tupla  = list((1, 2, 3))   # convierte tupla a lista

print(desde_string)
print(desde_rango)
print(desde_tupla)

— La lista guarda referencias, no copias —

Cada elemento de una lista es una referencia a un objeto — exactamente igual que una variable. Esto tiene dos consecuencias prácticas importantes que ya vimos en la Clase 2 y que ahora se vuelven más concretas.

# Consecuencia 1: el elemento y la variable apuntan al mismo objeto
x = 42
lista = [x, 10, 20]
print(id(x) == id(lista[0]))   # True: mismo objeto

# Reasignar x no afecta a la lista
x = 99
print(lista[0])   # sigue siendo 42

# Consecuencia 2: si el elemento es mutable, modificarlo afecta a la lista
sublista = [1, 2, 3]
contenedor = [sublista, "hola"]
print(f"Antes:  {contenedor}")

sublista.append(4)   # modificamos sublista
print(f"Después: {contenedor}")   # ¿cambia contenedor?

Sí, contenedor cambia — porque contenedor[0] es una referencia a sublista, no una copia. Cuando modificas sublista, la lista contenedor lo ve porque apunta al mismo objeto. Es la misma mecánica del Bloque 1 de la Parte I — la tupla inmutable con contenido mutable — pero ahora con listas.

— Propiedades básicas: len, in, type —

precios = [12.5, 8.0, 15.3, 9.7, 11.2]

# Longitud
print(f"Elementos: {len(precios)}")

# Pertenencia
print(f"¿Está 8.0?  {8.0 in precios}")
print(f"¿Está 99?   {99 in precios}")

# Tipo
print(f"Tipo: {type(precios)}")

# min, max, sum — solo si los elementos son comparables
print(f"Mínimo:  {min(precios)}")
print(f"Máximo:  {max(precios)}")
print(f"Suma:    {sum(precios)}")
print(f"Media:   {sum(precios) / len(precios):.2f}")
  1. Ejecuta el bloque de creación. ¿Qué devuelve list("Python")? ¿Y list(range(5))? ¿Qué tipo tiene cada elemento de la lista mixta?
  2. En el bloque de referencias: después de x = 99, ¿por qué lista[0] sigue siendo 42? ¿Qué habría pasado si en lugar de reasignar x hubieras hecho lista[0] = 99 directamente?
  3. Antes de ejecutar: predice qué vale contenedor después de sublista.append(4). ¿Cambia el id de contenedor[0]? ¿Por qué?
  4. Crea una lista con las ventas mensuales de un producto durante un año (12 valores numéricos de tu elección). Calcula la media, el máximo y el mínimo. ¿En qué mes fueron máximas las ventas? Usa index() para encontrarlo.
Respuesta orientativa
a

list("Python") devuelve ['P', 'y', 't', 'h', 'o', 'n'] — cada carácter del string se convierte en un elemento de la lista. list(range(5)) devuelve [0, 1, 2, 3, 4]. Los tipos de mixta son: int, str, bool, float, NoneType — cinco tipos distintos en la misma lista.

Python no restringe los tipos dentro de una lista. En análisis de datos esto aparece frecuentemente cuando los datos tienen valores faltantes — None mezclado con números.
b

lista[0] sigue siendo 42 porque x = 99 solo mueve la etiqueta x al objeto 99. La lista sigue teniendo una referencia al objeto 42 — esa referencia no se ve afectada por reasignar x.

Si hubieras hecho lista[0] = 99 directamente, sí habría cambiado — estarías modificando la referencia que guarda la lista en esa posición, no la etiqueta x.

c

contenedor pasa a ser [[1, 2, 3, 4], "hola"]. El id de contenedor[0] no cambia — sigue siendo la misma lista sublista en la misma dirección de memoria. Lo que ha cambiado es el contenido de ese objeto, no su identidad.

Patrón peligroso en datos: si guardas una lista en un container y luego la modificas desde fuera, el container lo verá. Solución: usar .copy() antes de guardarla.
d
ventas = [120, 95, 140, 180, 210, 250,
          230, 195, 160, 140, 110, 90]

media = sum(ventas) / len(ventas)
maximo = max(ventas)
minimo = min(ventas)
mes_max = ventas.index(maximo) + 1  # +1 porque índice empieza en 0

print(f"Media:   {media:.1f}")
print(f"Máximo:  {maximo} (mes {mes_max})")
print(f"Mínimo:  {minimo}")

El +1 es necesario porque index() devuelve la posición en base 0 y los meses van de 1 a 12. Este tipo de corrección de índice aparece constantemente en análisis de datos.

Métodos de modificación: añadir, eliminar, ordenar práctica

La mutabilidad de las listas se materializa en sus métodos de modificación. A diferencia de los strings — donde cualquier operación crea un objeto nuevo — los métodos de lista modifican el objeto en su lugar: el id no cambia.

Hay una distinción importante que conviene tener clara desde el principio: algunos métodos modifican la lista y devuelven None — no devuelven la lista modificada. Este es uno de los errores más frecuentes en código de principiantes y en código generado por IA.

— Añadir elementos: append, insert, extend —

Tres formas de añadir elementos, cada una con un propósito distinto: append añade un elemento al final, insert lo añade en una posición concreta, extend añade todos los elementos de otro iterable.

compras = ["pan", "leche"]
print(f"Original:  {compras}, id: {hex(id(compras))}")

# append: añade UN elemento al final
compras.append("huevos")
print(f"append:    {compras}, id: {hex(id(compras))}")  # mismo id

# insert: añade en posición concreta
compras.insert(1, "mantequilla")   # posición 1
print(f"insert:    {compras}")

# extend: añade TODOS los elementos de otro iterable
nuevos = ["café", "azúcar"]
compras.extend(nuevos)
print(f"extend:    {compras}")

# La trampa: append de una lista vs extend
lista_a = [1, 2, 3]
lista_b = [1, 2, 3]
lista_a.append([4, 5])   # añade la lista como un elemento
lista_b.extend([4, 5])   # añade los elementos de la lista
print(f"append lista: {lista_a}")  # predice antes de ejecutar
print(f"extend lista: {lista_b}")  # predice antes de ejecutar

— Eliminar elementos: remove, pop, del, clear —

Cuatro formas de eliminar, cada una con su caso de uso: remove elimina por valor, pop elimina por posición y devuelve el elemento, del elimina por posición o slice, clear vacía la lista completa.

numeros = [1, 2, 3, 2, 4, 2, 5]
print(f"Original: {numeros}")

# remove: elimina la PRIMERA ocurrencia del valor
numeros.remove(2)
print(f"remove(2):  {numeros}")   # ¿cuántos 2 quedan?

# pop: elimina por posición y DEVUELVE el elemento
elemento = numeros.pop(1)
print(f"pop(1):     {numeros}, eliminado: {elemento}")

# pop sin argumento: elimina el último
ultimo = numeros.pop()
print(f"pop():      {numeros}, eliminado: {ultimo}")

# del: elimina por índice o slice
del numeros[0]
print(f"del [0]:    {numeros}")

# Eliminar con slice — muy útil en datos
datos = [10, 20, 30, 40, 50]
del datos[1:3]   # elimina posiciones 1 y 2
print(f"del [1:3]:  {datos}")

# clear: vacía la lista — el objeto sigue existiendo
datos.clear()
print(f"clear():    {datos}, tipo: {type(datos)}")

— La trampa: los métodos devuelven None —

Los métodos que modifican la lista en su lugar devuelven None, no la lista modificada. Este es uno de los errores más frecuentes — y más silenciosos — en código de principiantes y en código generado por IA.

# Error típico: guardar el resultado de append
lista = [1, 2, 3]
resultado = lista.append(4)   # ¿qué guarda resultado?
print(f"resultado: {resultado}")   # None — no la lista
print(f"lista:     {lista}")       # la lista sí ha cambiado

# El mismo error con sort
numeros = [3, 1, 4, 1, 5]
ordenada = numeros.sort()         # ¿qué guarda ordenada?
print(f"ordenada:  {ordenada}")   # None
print(f"numeros:   {numeros}")    # la lista sí se ordenó

— sort vs sorted: en lugar vs objeto nuevo —

sort() modifica la lista en su lugar y devuelve None. sorted() devuelve una lista nueva ordenada sin tocar la original. La elección entre los dos depende de si necesitas conservar el orden original.

precios = [15.3, 8.0, 12.5, 9.7, 11.2]

# sorted: devuelve lista nueva — original intacta
ordenados = sorted(precios)
print(f"original:  {precios}")    # sin cambios
print(f"ordenados: {ordenados}")

# sort: modifica en lugar — original cambia
precios.sort()
print(f"después sort: {precios}")  # ordenada

# Orden descendente
precios.sort(reverse=True)
print(f"descendente: {precios}")

# sorted con key — ordenar por criterio personalizado
productos = ["banana", "manzana", "kiwi", "fresa"]
por_longitud = sorted(productos, key=len)
print(f"por longitud: {por_longitud}")
  1. Antes de ejecutar el bloque de append vs extend: predice qué devuelve lista_a después de append([4, 5]) y qué devuelve lista_b después de extend([4, 5]). ¿Cuál tiene 4 elementos y cuál tiene 5?
  2. En el bloque de eliminación: después de remove(2), ¿cuántos 2 quedan en la lista? ¿Qué método usarías para eliminar todas las ocurrencias de un valor?
  3. Ejecuta el bloque de la trampa de None. ¿Qué devuelve lista.append(4)? ¿Y numeros.sort()? ¿Qué error produce este patrón frecuente en código generado por IA: lista = lista.append(4)?
  4. Dado este fragmento de código de limpieza de datos, predice el estado de registros al final:
    registros = ["Ana", "Bob", "error", "Carlos", "error"]
    registros.remove("error")
    registros.insert(0, "Directora")
    ultimo = registros.pop()
    registros.sort()
    print(registros)
Respuesta orientativa
a

lista_a tiene 4 elementos: [1, 2, 3, [4, 5]] — append añade la lista [4, 5] como un único elemento, que queda anidado. lista_b tiene 5 elementos: [1, 2, 3, 4, 5] — extend añade cada elemento de [4, 5] por separado.

Error frecuente: usar append cuando se quiere extend. Si ves una lista con una lista dentro cuando no la esperabas, casi siempre es un append donde debería ir un extend.
b

Después de remove(2) quedan dos 2 — remove solo elimina la primera ocurrencia. Para eliminar todas las ocurrencias de un valor no hay un método directo — hay que usar un bucle o una list comprehension, que veremos en la Clase 4:

# Anticipando la Clase 4
numeros = [x for x in numeros if x != 2]
c

lista.append(4) devuelve None — no la lista modificada. numeros.sort() también devuelve None. El patrón lista = lista.append(4) produce un error silencioso devastador: lista pasa a ser None — has perdido la referencia a tu lista. La siguiente vez que intentes usar lista obtendrás un AttributeError o TypeError confuso.

La regla: los métodos que modifican en lugar se llaman solos, sin asignar su resultado. lista.append(4) ✓ lista = lista.append(4) ✗
d

Paso a paso: remove("error") elimina la primera ocurrencia → queda ["Ana", "Bob", "Carlos", "error"]. insert(0, "Directora") → ["Directora", "Ana", "Bob", "Carlos", "error"]. pop() elimina el último → queda ["Directora", "Ana", "Bob", "Carlos"]. sort() ordena alfabéticamente → resultado final: ["Ana", "Bob", "Carlos", "Directora"].

Nota: el segundo "error" desaparece con pop() porque estaba al final — no porque remove lo eliminara. Si el segundo "error" no hubiera estado al final, habría permanecido en la lista.
Listas anidadas: listas dentro de listas teoría

Una lista puede contener cualquier objeto — incluyendo otras listas. Esto se llama anidamiento y es la base de estructuras de datos más complejas: matrices, tablas, registros.

Desde el modelo de objetos: una lista anidada es simplemente una lista que guarda una referencia a otra lista. No hay nada especial en la sintaxis — es el mismo mecanismo de referencias que hemos visto desde la Clase 2. Las consecuencias para la mutabilidad y las copias, sin embargo, son importantes.

— Crear y acceder: indexación doble —

Para acceder a un elemento de una lista anidada se usan dos índices: el primero selecciona la sublista, el segundo selecciona el elemento dentro de esa sublista. matriz[fila][columna].

# Una matriz 3x3
matriz = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# Acceso por fila
print(matriz[0])        # primera fila: [1, 2, 3]
print(matriz[-1])       # última fila:  [7, 8, 9]

# Acceso por fila y columna
print(matriz[0][0])     # esquina superior izquierda: 1
print(matriz[1][1])     # centro: 5
print(matriz[2][-1])    # esquina inferior derecha: 9

# Modificar un elemento
matriz[0][0] = 99
print(matriz[0])        # [99, 2, 3]

# Dimensiones
filas = len(matriz)
columnas = len(matriz[0])
print(f"Matriz {filas}x{columnas}")

— Caso real: tabla de datos antes de pandas —

Antes de cargar datos en pandas, muchas veces los datos llegan como listas de listas — por ejemplo, al leer un CSV manualmente o al recibir datos de una API. Cada sublista es una fila, cada posición dentro de la sublista es una columna. Es exactamente la estructura que pandas convierte en un DataFrame.

# Tabla de ventas: cada fila es [producto, mes, ventas]
ventas = [
    ["manzana", "ene", 150],
    ["pera",    "ene", 80],
    ["naranja", "ene", 120],
    ["manzana", "feb", 200],
    ["pera",    "feb", 95],
]

# Acceder a la primera fila completa
print(ventas[0])             # ['manzana', 'ene', 150]

# Acceder a la columna de ventas (índice 2)
print(ventas[0][2])          # 150
print(ventas[3][2])          # 200

# Extraer todos los valores de ventas
solo_ventas = [fila[2] for fila in ventas]
print(f"Ventas: {solo_ventas}")
print(f"Total:  {sum(solo_ventas)}")
print(f"Media:  {sum(solo_ventas) / len(solo_ventas):.1f}")

# Convertir a DataFrame — la estructura es directamente compatible
import pandas as pd
df = pd.DataFrame(ventas, columns=["producto", "mes", "ventas"])
print(df)

— El peligro: copy() no es suficiente con listas anidadas —

En la Clase 2 vimos que .copy() crea un objeto nuevo. Pero con listas anidadas hay una trampa: .copy() es una copia superficial — copia las referencias de la lista exterior, pero no crea copias de las sublistas. Las sublistas siguen siendo los mismos objetos.

# Copia superficial: las sublistas son el mismo objeto
original = [[1, 2], [3, 4]]
copia = original.copy()

print(f"original is copia: {original is copia}")          # False — objetos distintos
print(f"original[0] is copia[0]: {original[0] is copia[0]}") # True — misma sublista

# Modificar la sublista afecta a ambas
copia[0].append(99)
print(f"original: {original}")  # ¿cambia?
print(f"copia:    {copia}")

# Solución: deepcopy para copias completamente independientes
import copy
original2 = [[1, 2], [3, 4]]
copia_prof = copy.deepcopy(original2)

print(f"\noriginal2[0] is copia_prof[0]: {original2[0] is copia_prof[0]}")  # False
copia_prof[0].append(99)
print(f"original2:  {original2}")   # no cambia
print(f"copia_prof: {copia_prof}")
Resumen de copias: b = a — misma referencia, todo compartido. b = a.copy() — lista nueva, sublistas compartidas. b = copy.deepcopy(a) — todo independiente, ningún objeto compartido. En análisis de datos, si modificas un DataFrame o una lista de listas que viene de otra variable, usa siempre .copy() o deepcopy() según el nivel de anidamiento.
  1. Dada la matriz del primer bloque, predice el resultado de cada acceso antes de ejecutar:
    m = [[1,2,3],[4,5,6],[7,8,9]]
    print(m[1][0])
    print(m[0][-1])
    print(m[-1][-1])
    print(m[1][1] * m[0][0])
  2. En la tabla de ventas, ¿cómo accederías al nombre del producto de la última fila? ¿Y a la venta más alta de toda la tabla usando max() y la list comprehension del ejemplo?
  3. Antes de ejecutar el bloque de copia superficial: predice si original cambia después de copia[0].append(99). ¿Por qué? Verifica con id().
  4. Dado este fragmento típico de procesamiento de datos, ¿qué vale datos al final y por qué?
    datos = [[1, 2], [3, 4]]
    backup = datos.copy()
    datos[0][0] = 99
    datos[1] = [10, 20]
    print(datos)
    print(backup)
Respuesta orientativa
a

m[1][0] → 4: segunda fila, primer elemento. m[0][-1] → 3: primera fila, último elemento. m[-1][-1] → 9: última fila, último elemento. m[1][1] * m[0][0] → 5 * 1 = 5: centro por esquina superior izquierda.

b

Nombre del producto de la última fila: ventas[-1][0] → "pera". Venta más alta:

max_venta = max(fila[2] for fila in ventas)
print(max_venta)   # 200
c

Sí, original cambia — pasa a ser [[1, 2, 99], [3, 4]]. .copy() creó una lista nueva pero las sublistas son los mismos objetos: original[0] is copia[0] es True. Al hacer copia[0].append(99) estás modificando la sublista compartida, que también es original[0].

d

datos vale [[99, 2], [10, 20]]. backup vale [[99, 2], [3, 4]].

datos[0][0] = 99 modifica la sublista compartida — tanto datos[0] como backup[0] la ven porque .copy() es superficial. datos[1] = [10, 20] reasigna la referencia en posición 1 de datos — backup[1] no se ve afectado porque es una reasignación de etiqueta, no una modificación del objeto.

La distinción clave: datos[1] = [10, 20] mueve la etiqueta — no afecta a backup. datos[0][0] = 99 modifica el objeto — sí afecta a backup. La misma distinción de la Clase 2, ahora dentro de una lista anidada.
Parte III

Tuple: inmutabilidad como garantía

tuple: inmutabilidad como garantía semántica teoría

La pregunta natural al conocer las tuplas es: si ya existen las listas, ¿para qué sirve una tupla? La respuesta no es técnica sino semántica.

Una tupla no es una lista a la que le han quitado funcionalidades. Es una estructura con un significado distinto: representa un dato compuesto y fijo — un conjunto de valores que van siempre juntos y que no deberían cambiar. Una coordenada geográfica, una fecha, las dimensiones de una imagen, los datos de un registro.

Cuando usas una tupla en lugar de una lista estás comunicando algo al lector del código: "estos valores van juntos y no deberían modificarse". La inmutabilidad no es una restricción — es una promesa.

La documentación oficial la define como secuencia inmutable, tipicamente usada para almacenar colecciones de elementos heterogéneos. → fuente

— Crear tuplas: cuatro formas —

# Con paréntesis — la forma más común
coordenadas = (40.4168, -3.7038)
persona = ("Ana", 28, 1.65, True)

# Sin paréntesis — el operador coma crea la tupla
punto = 10, 20
print(type(punto))   # tuple — aunque no hay paréntesis

# Tupla vacía
vacia = ()
print(type(vacia))

# Tupla de UN solo elemento — la trampa más frecuente
no_es_tupla = (42)      # ¿qué tipo es esto?
si_es_tupla = (42,)     # la coma final es obligatoria
print(type(no_es_tupla))
print(type(si_es_tupla))

# tuple() — convierte cualquier iterable
desde_lista  = tuple([1, 2, 3])
desde_string = tuple("Python")
print(desde_lista)
print(desde_string)

(42) no es una tupla — es simplemente el número 42 entre paréntesis, como en una expresión matemática. Para crear una tupla de un elemento, la coma final es obligatoria: (42,). Este es uno de los errores más frecuentes y más difíciles de detectar porque no produce ningún error de sintaxis.

— Inmutabilidad: qué puedes y qué no puedes hacer —

coordenadas = (40.4168, -3.7038)

# Puedes leer
print(coordenadas[0])    # latitud
print(coordenadas[-1])   # longitud
print(len(coordenadas))
print(40.4168 in coordenadas)

# No puedes modificar
try:
    coordenadas[0] = 0.0   # ¿qué error produce?
except TypeError as e:
    print(f"Error: {e}")

# La trampa: tupla inmutable con lista mutable dentro
registro = ("Ana", [8.5, 7.0, 9.0])
print(f"Antes:  {registro}")

registro[1].append(6.5)   # modifica la lista dentro
print(f"Después: {registro}")  # ¿cambia la tupla?

try:
    registro[1] = []   # esto sí falla
except TypeError as e:
    print(f"Error: {e}")

— Cuándo usar tupla en lugar de lista —

Tres casos donde la tupla es la elección correcta:

# 1. Datos que representan un registro fijo
ciudad = ("Madrid", "España", 40.4168, -3.7038)
dimension = (1920, 1080)   # resolución de pantalla
rgb = (255, 128, 0)         # color naranja

# 2. Como clave de diccionario — las listas no pueden ser claves
distancias = {
    ("Madrid", "Barcelona"): 621,
    ("Madrid", "Sevilla"):   530,
    ("Barcelona", "Valencia"): 349,
}
print(distancias[("Madrid", "Barcelona")])

try:
    d = {["Madrid", "Barcelona"]: 621}  # ¿por qué falla?
except TypeError as e:
    print(f"Error: {e}")

# 3. Retorno múltiple de funciones — lo veremos en el Bloque 2
def minmax(lista):
    return min(lista), max(lista)   # devuelve una tupla implícita

resultado = minmax([3, 1, 4, 1, 5])
print(type(resultado), resultado)
  1. Ejecuta el bloque de creación. ¿Qué tipo tiene (42)? ¿Y (42,)? ¿Qué devuelve tuple("Python")? ¿Cuántos elementos tiene?
  2. En el bloque de inmutabilidad: ¿qué error produce intentar modificar una tupla? ¿Y por qué sí puedes hacer registro[1].append(6.5) aunque la tupla sea inmutable?
  3. ¿Por qué una lista no puede ser clave de un diccionario pero una tupla sí? Conecta con la propiedad de hashability — solo los objetos inmutables pueden ser hasheables.
  4. Crea una tupla que represente un producto de una tienda: nombre, precio, stock y categoría. Accede a cada campo por índice. ¿Qué inconveniente tiene este enfoque frente a una namedtuple? Lo veremos en el Bloque 3.
Respuesta orientativa
a

(42) tiene tipo int — los paréntesis son solo agrupación, no crean una tupla. (42,) tiene tipo tuple — la coma es lo que crea la tupla. tuple("Python") devuelve ('P', 'y', 't', 'h', 'o', 'n') — 6 elementos, uno por carácter.

b

Intentar modificar una tupla produce TypeError: 'tuple' object does not support item assignment.

registro[1].append(6.5) funciona porque la inmutabilidad de la tupla garantiza que sus referencias no cambian — registro[1] sigue apuntando a la misma lista. Pero esa lista es mutable y puede modificar su contenido. Es la misma trampa del Bloque 1 de la Parte I.

c

Las claves de un diccionario deben ser hasheables — deben tener un valor hash fijo que no cambie. Solo los objetos inmutables garantizan esto. Una lista es mutable — si cambiaras su contenido después de usarla como clave, el diccionario no podría encontrarla. Una tupla es inmutable — su hash nunca cambia, por eso puede ser clave.

Verificable: hash((1, 2)) devuelve un número fijo. hash([1, 2]) lanza TypeError: unhashable type: 'list'.
d
producto = ("Manzana", 1.50, 100, "fruta")
print(f"Nombre:    {producto[0]}")
print(f"Precio:    {producto[1]}")
print(f"Stock:     {producto[2]}")
print(f"Categoría: {producto[3]}")

El inconveniente es que los índices no dicen nada sobre el significado del campo. producto[1] no es tan legible como producto.precio. Si alguien reordena los campos de la tupla, todo el código que accede por índice se rompe. La namedtuple resuelve exactamente este problema.

Unpacking y retorno múltiple de funciones práctica

El unpacking es la característica más poderosa de las tuplas — y una de las más elegantes de Python. Permite asignar los elementos de una tupla a variables individuales en una sola línea.

Técnicamente el unpacking no es exclusivo de las tuplas — funciona con cualquier iterable. Pero las tuplas son su contexto natural porque representan datos compuestos con un número fijo de elementos.

— Unpacking básico —

# Sin unpacking — verboso y poco legible
coordenadas = (40.4168, -3.7038)
latitud  = coordenadas[0]
longitud = coordenadas[1]

# Con unpacking — directo y legible
latitud, longitud = coordenadas
print(f"Latitud:  {latitud}")
print(f"Longitud: {longitud}")

# Funciona con cualquier secuencia
nombre, edad, ciudad = ("Ana", 28, "Madrid")
print(f"{nombre}, {edad} años, {ciudad}")

# Intercambio de variables — ya lo vimos en Clase 2
a, b = 10, 20
a, b = b, a   # el lado derecho crea una tupla implícita
print(f"a={a}, b={b}")

# Error si el número de variables no coincide
try:
    x, y = (1, 2, 3)   # demasiados valores
except ValueError as e:
    print(f"Error: {e}")

— Unpacking con *: capturar el resto —

El operador * en el unpacking captura todos los elementos que no están asignados a una variable específica. Es especialmente útil cuando no sabes cuántos elementos tiene la secuencia o cuando solo te interesan el primero y el último.

datos = (1, 2, 3, 4, 5, 6, 7)

# Capturar el primero y el resto
primero, *resto = datos
print(f"primero: {primero}")
print(f"resto:   {resto}")
print(type(resto))   # el * siempre produce una lista

# Capturar el primero, el último y el medio
primero, *medio, ultimo = datos
print(f"primero: {primero}, medio: {medio}, ultimo: {ultimo}")

# Ignorar valores con _
nombre, _, ciudad = ("Ana", 28, "Madrid")
print(f"{nombre} vive en {ciudad}")   # ignoramos la edad

# Caso real: parsear una línea de datos
linea = ("2024-01-15", "manzana", 150, 1.20, "Madrid")
fecha, producto, *numericos, origen = linea
print(f"Fecha: {fecha}, producto: {producto}")
print(f"Numéricos: {numericos}, origen: {origen}")

— Retorno múltiple de funciones —

En Python, cuando una función devuelve varios valores separados por comas, está devolviendo una tupla implícitamente. El unpacking permite recibirlos directamente en variables separadas. Es uno de los patrones más frecuentes en código real y en código generado por IA.

# Una función que devuelve estadísticas
def estadisticas(datos):
    """Devuelve (min, max, media) de una lista de números."""
    return min(datos), max(datos), sum(datos) / len(datos)

ventas = [120, 95, 200, 150, 80, 175]

# Recibir como tupla
resultado = estadisticas(ventas)
print(type(resultado))   # tuple
print(resultado)

# Recibir con unpacking — más legible
minimo, maximo, media = estadisticas(ventas)
print(f"Mínimo: {minimo}, Máximo: {maximo}, Media: {media:.1f}")

# Ignorar valores que no necesitas
_, maximo, _ = estadisticas(ventas)
print(f"Solo el máximo: {maximo}")

# Patrón frecuente en pandas: iterrows devuelve tuplas
import pandas as pd
df = pd.DataFrame({
    "producto": ["A", "B", "C"],
    "ventas":   [100, 200, 150]
})

for indice, fila in df.iterrows():   # devuelve tuplas (índice, Serie)
    print(f"  {fila['producto']}: {fila['ventas']}")
  1. Ejecuta el bloque de unpacking básico. ¿Qué error produce intentar desempaquetar (1, 2, 3) en dos variables? ¿Cómo lo resolverías con *?
  2. En el bloque de *: ¿qué tipo tiene siempre la variable que recibe el *? ¿Aunque la secuencia original sea una tupla?
  3. La función estadisticas() devuelve min, max, media. ¿Qué tipo tiene el objeto que devuelve? ¿Cómo recibirías solo la media sin guardar el mínimo ni el máximo?
  4. Escribe una función separar_pares_impares(lista) que reciba una lista de números y devuelva dos listas: una con los pares y otra con los impares. Llámala con [1, 2, 3, 4, 5, 6, 7, 8] y recibe el resultado con unpacking.
Respuesta orientativa
a

Produce ValueError: too many values to unpack (expected 2). Con *:

x, y, *_ = (1, 2, 3)   # ignoramos el resto
# o
x, *_, z = (1, 2, 3)   # primero y último
b

La variable con * siempre recibe una list — aunque la secuencia original sea una tupla, un string o cualquier otro iterable. Es una decisión de diseño de Python: el resultado del * es siempre mutable y operable.

c

Devuelve una tuple. Para recibir solo la media:

_, _, media = estadisticas(ventas)
# o más explícito
media = estadisticas(ventas)[2]
Convención: usar _ para indicar que ese valor se ignora intencionalmente. Es una convención universal en Python que mejora la legibilidad.
d
def separar_pares_impares(lista):
    pares   = [x for x in lista if x % 2 == 0]
    impares = [x for x in lista if x % 2 != 0]
    return pares, impares   # tupla implícita

pares, impares = separar_pares_impares([1,2,3,4,5,6,7,8])
print(f"Pares:   {pares}")
print(f"Impares: {impares}")
Parte IV

Dictionary: clave-valor

dict: el mapping fundamental de Python teoría

La documentación oficial define los diccionarios como el único tipo de mapping built-in de Python. Un mapping es un container que asocia claves a valores — el acceso no es por posición sino por clave arbitraria. → fuente

Desde Python 3.7, los diccionarios mantienen el orden de inserción — los elementos aparecen en el orden en que se añadieron. Pero el acceso siempre es por clave, nunca por posición: d["nombre"], no d[0].

Las claves deben ser hasheables — inmutables. Strings, números y tuplas pueden ser claves. Las listas no pueden. Los valores pueden ser cualquier objeto: números, strings, listas, otros diccionarios, funciones.

— Formas de crear un diccionario —

# Forma más común: literales con llaves
estudiante = {
    "nombre":  "Ana García",
    "edad":    28,
    "carrera": "Economía",
    "activo":  True
}
print(type(estudiante))
print(estudiante)

# dict() con argumentos keyword
punto = dict(x=10, y=20, z=0)
print(punto)

# dict() desde una lista de tuplas
pares = [("madrid", 621), ("barcelona", 349), ("sevilla", 530)]
distancias = dict(pares)
print(distancias)

# zip(): combinar dos listas en un diccionario
claves  = ["nombre", "edad", "ciudad"]
valores = ["Pedro", 25, "Barcelona"]
persona = dict(zip(claves, valores))
print(persona)

# Diccionario vacío
vacio = {}
print(type(vacio))   # dict — no confundir con set vacío

{} crea un diccionario vacío, no un set vacío. Para crear un set vacío hay que usar set(). Es una fuente frecuente de confusión.

— Claves: cualquier objeto hasheable —

# Claves de distintos tipos
variado = {
    "string":       "valor con clave string",
    42:             "valor con clave int",
    (1, 2):          "valor con clave tupla",
    True:           "valor con clave bool",
}
print(variado["string"])
print(variado[42])
print(variado[(1, 2)])

# Las listas NO pueden ser claves
try:
    d = {[1, 2]: "valor"}
except TypeError as e:
    print(f"Error: {e}")

# Curiosidad: True == 1 en Python
# Si usas True y 1 como claves, son la misma clave
d = {True: "bool", 1: "int"}
print(d)   # predice antes de ejecutar

— El diccionario como base de pandas —

Un DataFrame de pandas es esencialmente un diccionario de Series — cada clave es el nombre de una columna y cada valor es la Serie de datos de esa columna. Entender los diccionarios es entender la estructura interna de pandas.

import pandas as pd

# Un DataFrame SE CONSTRUYE desde un dict
datos = {
    "producto": ["manzana", "pera", "naranja"],
    "ventas":   [150, 80, 120],
    "precio":   [1.20, 0.90, 0.80]
}
df = pd.DataFrame(datos)
print(df)
print()

# Acceder a una columna es como acceder a una clave del dict
print(df["ventas"])          # igual que datos["ventas"]
print(type(df["ventas"]))    # Series, no lista

# Convertir una fila a dict
fila = df.iloc[0].to_dict()
print(fila)
print(type(fila))
  1. Ejecuta el bloque de creación. ¿Qué tipo tiene {}? ¿Cómo crearías un set vacío? ¿Qué devuelve dict(zip(["a","b","c"], [1,2,3]))?
  2. En el bloque de claves: predice antes de ejecutar qué devuelve {True: "bool", 1: "int"}. ¿Por qué solo hay una clave? ¿Qué valor tiene?
  3. ¿Por qué una lista no puede ser clave de un diccionario? ¿Qué error produce? ¿Cómo lo resolverías si necesitas usar una colección como clave?
  4. Crea un diccionario que represente un alumno del máster con al menos cinco campos de tipos distintos (string, int, float, bool y lista). Luego conviértelo a DataFrame de pandas con una sola línea.
Respuesta orientativa
a

{} tiene tipo dict. Para un set vacío: set(). dict(zip(["a","b","c"], [1,2,3])) devuelve {'a': 1, 'b': 2, 'c': 3} — zip empareja elemento a elemento y dict convierte los pares en clave-valor.

b

{True: "bool", 1: "int"} devuelve {True: "int"} — solo una clave con el valor de la segunda asignación. Porque en Python True == 1 y hash(True) == hash(1) — son la misma clave para el diccionario. La segunda asignación sobreescribe la primera.

Consecuencia práctica: nunca mezcles True/False con 1/0 como claves del mismo diccionario — producen colisiones silenciosas.
c

Una lista no puede ser clave porque no es hasheable — es mutable y su contenido puede cambiar. Si el hash de una clave cambiara después de insertarla, el diccionario no podría encontrarla. Produce TypeError: unhashable type: 'list'. La solución es convertir la lista a tupla antes de usarla como clave: tuple([1, 2]).

d
alumno = {
    "nombre":   "Carlos López",
    "edad":     24,
    "nota":     8.5,
    "activo":   True,
    "materias": ["Python", "Econometría", "ML"]
}

# Una sola línea — pandas acepta directamente el dict
df = pd.DataFrame([alumno])
print(df)

El [alumno] convierte el diccionario en una lista de un elemento — pandas lo interpreta como una tabla de una fila. Sin los corchetes, pandas intentaría crear una columna por cada valor del diccionario.

Acceso, modificación y métodos principales práctica

Hay dos formas de acceder a un valor en un diccionario. La diferencia entre ellas es una de las primeras cosas que hay que entender para leer código ajeno sin errores:

d["clave"] — acceso directo. Si la clave no existe lanza un KeyError que detiene el programa.

d.get("clave") — acceso seguro. Si la clave no existe devuelve None por defecto, o el valor que especifiques. Nunca lanza error.

— Acceso directo vs get() —

notas = {"Ana": 8.5, "Luis": 7.2, "María": 9.1}

# Acceso directo — KeyError si no existe
print(notas["Ana"])       # 8.5

try:
    print(notas["Pedro"])   # KeyError
except KeyError as e:
    print(f"KeyError: {e}")

# get() — nunca lanza error
print(notas.get("Pedro"))          # None
print(notas.get("Pedro", 0.0))     # 0.0 — valor por defecto
print(notas.get("Ana", 0.0))       # 8.5 — clave existe

# Verificar si una clave existe antes de acceder
alumno = "María"
if alumno in notas:
    print(f"Nota de {alumno}: {notas[alumno]}")
else:
    print(f"{alumno} no tiene nota registrada")

— keys(), values(), items() —

Tres métodos que devuelven vistas del diccionario — no copias sino ventanas al contenido actual. Si el diccionario cambia, las vistas reflejan el cambio automáticamente. Son los más usados para iterar sobre un diccionario.

precios = {"manzana": 1.20, "pera": 0.90, "naranja": 0.80}

# keys() — vista de las claves
print(precios.keys())
print(type(precios.keys()))
print(list(precios.keys()))   # convertir a lista

# values() — vista de los valores
print(precios.values())
print(sum(precios.values()))  # operar sobre los valores

# items() — vista de pares (clave, valor) como tuplas
print(precios.items())

# Iterar sobre pares — patrón muy frecuente
for producto, precio in precios.items():
    print(f"  {producto:10} → {precio:.2f}€")

# Las vistas reflejan cambios en tiempo real
claves_vista = precios.keys()
precios["kiwi"] = 1.50
print(claves_vista)   # kiwi aparece aunque la vista se creó antes

— Añadir, modificar y eliminar —

inventario = {"manzanas": 50, "naranjas": 30, "plátanos": 20}

# Añadir nueva clave
inventario["peras"] = 40
print(inventario)

# Modificar valor existente
inventario["manzanas"] = 75
print(inventario["manzanas"])

# update(): añadir/modificar varios de golpe
inventario.update({"kiwis": 15, "naranjas": 60})
print(inventario)

# pop(): eliminar y devolver el valor
cantidad = inventario.pop("plátanos")
print(f"Eliminado: plátanos ({cantidad} unidades)")
print(inventario)

# pop() con valor por defecto — no lanza KeyError
val = inventario.pop("mangos", 0)
print(f"Mangos: {val}")   # 0 — no existía

— setdefault(): insertar solo si no existe —

setdefault(clave, valor) devuelve el valor de la clave si existe, o la inserta con el valor por defecto si no existe. Es el método más elegante para el patrón de acumulación — inicializar una clave la primera vez que aparece.

# Sin setdefault — verboso
ventas = {}
productos = ["manzana", "pera", "manzana", "naranja", "pera"]

for p in productos:
    if p not in ventas:
        ventas[p] = 0
    ventas[p] += 1
print(ventas)

# Con setdefault — más limpio
ventas2 = {}
for p in productos:
    ventas2.setdefault(p, 0)
    ventas2[p] += 1
print(ventas2)

# Patrón más frecuente: agrupar elementos en listas
por_categoria = {}
datos = [("fruta", "manzana"), ("verdura", "zanahoria"),
         ("fruta", "pera"), ("verdura", "pimiento")]

for categoria, producto in datos:
    por_categoria.setdefault(categoria, []).append(producto)

print(por_categoria)
  1. ¿Cuándo usarías d["clave"] y cuándo d.get("clave")? ¿Qué devuelve notas.get("Pedro", 0.0) si "Pedro" no está en el diccionario? ¿Y si sí está?
  2. Ejecuta el bloque de vistas. ¿Qué tipo tiene precios.keys()? ¿Por qué kiwi aparece en claves_vista aunque la vista se creó antes de añadirlo?
  3. En el bloque de modificación: ¿qué diferencia hay entre inventario["manzanas"] = 75 y inventario.update({"manzanas": 75})? ¿Cuándo usarías cada uno?
  4. Dado este fragmento de código de análisis, predice el resultado antes de ejecutar:
    df_dict = {"a": 1, "b": 2, "c": 3}
    df_dict.update({"b": 99, "d": 4})
    val = df_dict.pop("a")
    print(df_dict)
    print(val)
    print(df_dict.get("z", -1))
Respuesta orientativa
a

Usa d["clave"] cuando estás seguro de que la clave existe y quieres que el programa falle visiblemente si no es así. Usa d.get("clave") cuando la clave puede no existir y quieres manejar ese caso sin interrumpir el programa.

notas.get("Pedro", 0.0) devuelve 0.0 si Pedro no está — el valor por defecto. Si Pedro sí está, devuelve su nota.

Regla práctica: en análisis de datos con datos que vienen de fuentes externas, usa siempre get() — no puedes garantizar que todas las claves existan.
b

precios.keys() tiene tipo dict_keys — no es una lista, es una vista dinámica. kiwi aparece en la vista porque las vistas no son copias — son referencias al diccionario original. Cuando el diccionario cambia, todas las vistas lo reflejan inmediatamente.

Atención: si necesitas una copia fija de las claves en un momento determinado, convierte a lista: list(precios.keys()). Una lista no cambia cuando cambia el dict.
c

Funcionalmente son equivalentes para modificar una sola clave. La diferencia es de escala y legibilidad: d["clave"] = valor modifica una clave de forma explícita. update() modifica o añade varias claves de golpe — es más limpio cuando tienes varios cambios que aplicar. También acepta otro diccionario o argumentos keyword: d.update(a=1, b=2).

d

Paso a paso: update({"b": 99, "d": 4}) modifica b y añade d → {"a": 1, "b": 99, "c": 3, "d": 4}. pop("a") elimina a y devuelve 1 → diccionario queda {"b": 99, "c": 3, "d": 4}. get("z", -1) devuelve -1 porque z no existe.

Diccionarios anidados y patrones frecuentes práctica

Los valores de un diccionario pueden ser cualquier objeto — incluyendo otros diccionarios o listas. Esto permite representar estructuras de datos complejas que aparecen constantemente en análisis de datos: registros anidados, respuestas de APIs en formato JSON, agrupaciones de datos por categoría.

Junto con los diccionarios anidados, hay tres patrones de uso que verás en prácticamente todo el código de análisis de datos: el conteo de frecuencias, la agrupación por categoría y la acumulación de valores. Reconocerlos es reconocer el 80% de los diccionarios que aparecen en código real.

— Diccionarios anidados: acceso encadenado —

# Un restaurante con platos anidados
restaurante = {
    "nombre": "La Taberna",
    "menu": {
        "entrante": {
            "nombre":       "Gazpacho",
            "precio":       4.50,
            "disponible":   True,
            "ingredientes": ["tomate", "pepino", "pimiento"]
        },
        "principal": {
            "nombre":       "Cocido madrileño",
            "precio":       12.00,
            "disponible":   False,
            "ingredientes": ["garbanzos", "chorizo", "pollo"]
        }
    }
}

# Acceso encadenado
print(restaurante["nombre"])
print(restaurante["menu"]["entrante"]["precio"])
print(restaurante["menu"]["entrante"]["ingredientes"][0])

# Modificar un valor anidado
restaurante["menu"]["principal"]["disponible"] = True
print(restaurante["menu"]["principal"]["disponible"])

# Acceso seguro en estructuras anidadas
precio = restaurante.get("menu", {}).get("postre", {}).get("precio", 0)
print(f"Precio postre: {precio}")   # 0 — no existe

El patrón .get({}).get({}).get() encadenado es la forma segura de navegar por estructuras anidadas sin riesgo de KeyError. Es especialmente útil cuando procesas respuestas de APIs donde no todas las claves están garantizadas.

— Patrón 1: conteo de frecuencias —

# Contar cuántas veces aparece cada elemento
palabras = ["python", "datos", "python", "ia",
            "datos", "python", "ml", "datos"]

frecuencias = {}
for palabra in palabras:
    frecuencias[palabra] = frecuencias.get(palabra, 0) + 1

print(frecuencias)

# Ordenar por frecuencia
ordenado = sorted(frecuencias.items(), key=lambda x: x[1], reverse=True)
for palabra, cuenta in ordenado:
    print(f"  {palabra:10}: {cuenta}")

# En datos reales: contar valores de una columna
import pandas as pd
df = pd.DataFrame({"categoria": ["A", "B", "A", "C", "B", "A"]})
print(df["categoria"].value_counts().to_dict())

— Patrón 2: agrupación por categoría —

# Agrupar productos por categoría
productos = [
    ("manzana",    "fruta"),
    ("zanahoria",  "verdura"),
    ("pera",       "fruta"),
    ("pimiento",   "verdura"),
    ("naranja",    "fruta"),
    ("lechuga",    "verdura"),
]

por_categoria = {}
for producto, categoria in productos:
    por_categoria.setdefault(categoria, []).append(producto)

print(por_categoria)
print(f"Frutas:   {por_categoria['fruta']}")
print(f"Verduras: {por_categoria['verdura']}")

# Equivalente en pandas con groupby
df2 = pd.DataFrame(productos, columns=["producto", "categoria"])
grupos = df2.groupby("categoria")["producto"].apply(list).to_dict()
print(grupos)

— Patrón 3: acumulación de valores —

# Acumular ventas por producto
ventas = [
    ("manzana", 150), ("pera", 80),  ("manzana", 200),
    ("naranja", 120), ("pera", 95),  ("manzana", 175),
]

totales = {}
for producto, cantidad in ventas:
    totales[producto] = totales.get(producto, 0) + cantidad

print(totales)
print(f"Total manzanas: {totales['manzana']}")
print(f"Producto más vendido: {max(totales, key=totales.get)}")
Los tres patrones en una frase: conteo → d.get(k, 0) + 1, agrupación → d.setdefault(k, []).append(v), acumulación → d.get(k, 0) + valor. Son tres variaciones del mismo mecanismo — inicializar si no existe, luego actualizar. El defaultdict que veremos en el Bloque 4 automatiza este patrón.
  1. En el diccionario del restaurante, accede a: el nombre del entrante, el segundo ingrediente del principal, y el precio del entrante usando .get() encadenado.
  2. Dado este texto, cuenta la frecuencia de cada palabra y muestra las 3 más frecuentes: "el gato come el ratón y el perro come el hueso".
  3. Dado este fragmento, predice qué vale resultado:
    datos = [("A", 10), ("B", 20), ("A", 30), ("B", 40), ("C", 50)]
    resultado = {}
    for k, v in datos:
        resultado[k] = resultado.get(k, 0) + v
    print(resultado)
  4. ¿Qué devuelve max(totales, key=totales.get)? ¿Qué hace el argumento key en este contexto? ¿Cómo lo usarías para encontrar el producto con menos ventas?
Respuesta orientativa
a
# Nombre del entrante
print(restaurante["menu"]["entrante"]["nombre"])

# Segundo ingrediente del principal
print(restaurante["menu"]["principal"]["ingredientes"][1])

# Precio del entrante con get() encadenado
precio = restaurante.get("menu", {}).get("entrante", {}).get("precio", 0)
print(precio)
b
texto = "el gato come el ratón y el perro come el hueso"
palabras = texto.split()
freq = {}
for p in palabras:
    freq[p] = freq.get(p, 0) + 1

top3 = sorted(freq.items(), key=lambda x: x[1], reverse=True)[:3]
print(top3)   # [('el', 4), ('come', 2), ('gato', 1)]
c

resultado vale {'A': 40, 'B': 60, 'C': 50}. Es el patrón de acumulación: para cada par (k, v), suma v al valor existente de k (o a 0 si no existe).

d

max(totales, key=totales.get) devuelve la clave del diccionario cuyo valor es el máximo. El argumento key indica la función que se aplica a cada elemento antes de comparar — aquí totales.get devuelve el valor de cada clave. Para el mínimo:

print(min(totales, key=totales.get))
Patrón frecuente: max(d, key=d.get) y min(d, key=d.get) son la forma pythónica de encontrar la clave con el valor máximo o mínimo. Aparece constantemente en código de análisis de datos.
defaultdict y namedtuple: estructuras especializadas teoría

En el Bloque 3 vimos que los patrones de conteo y agrupación requieren inicializar una clave la primera vez que aparece — d.get(k, 0) o d.setdefault(k, []). El defaultdict del módulo collections automatiza exactamente eso: si accedes a una clave que no existe, la crea automáticamente con un valor por defecto.

— defaultdict: eliminar el KeyError de los patrones de acumulación —

from collections import defaultdict

# dict normal: KeyError si la clave no existe
d_normal = {}
try:
    d_normal["nueva"] += 1
except KeyError as e:
    print(f"KeyError: {e}")

# defaultdict(int): valor por defecto 0
conteo = defaultdict(int)
palabras = ["python", "datos", "python", "ia", "datos", "python"]
for p in palabras:
    conteo[p] += 1   # sin get(), sin setdefault()
print(dict(conteo))

# defaultdict(list): valor por defecto []
grupos = defaultdict(list)
productos = [("fruta", "manzana"), ("verdura", "zanahoria"),
             ("fruta", "pera"),    ("verdura", "pimiento")]
for cat, prod in productos:
    grupos[cat].append(prod)   # sin setdefault()
print(dict(grupos))

# defaultdict(float): valor por defecto 0.0
totales = defaultdict(float)
ventas = [("A", 100.0), ("B", 200.0), ("A", 150.0)]
for prod, val in ventas:
    totales[prod] += val
print(dict(totales))

defaultdict se comporta exactamente igual que un dict normal para todas las operaciones — acceso, modificación, iteración. La única diferencia es que cuando accedes a una clave que no existe, en lugar de lanzar KeyError la crea con el valor por defecto que especificaste al crear el defaultdict.

El argumento es una función que se llama sin argumentos para producir el valor por defecto: int devuelve 0, list devuelve [], float devuelve 0.0, str devuelve "".

— namedtuple: acceso por nombre en lugar de por índice —

En la Parte III vimos que una tupla accede a sus campos por índice: producto[0], producto[1]. Funciona, pero no dice nada sobre qué representa cada posición.

Un diccionario accede por clave: producto["nombre"], producto["precio"]. Más legible, pero mutable y más pesado en memoria.

La namedtuple del módulo collections combina lo mejor de los dos: acceso por nombre como un diccionario, inmutabilidad y eficiencia de memoria como una tupla. Es la estructura ideal para representar registros fijos con campos nombrados.

from collections import namedtuple

# Definir la estructura — como una clase ligera
Producto = namedtuple("Producto", ["nombre", "precio", "stock"])

# Crear instancias
p1 = Producto("Manzana", 1.20, 100)
p2 = Producto("Pera",    0.90, 50)

# Acceso por nombre — mucho más legible que p1[0]
print(f"{p1.nombre}: {p1.precio}€ ({p1.stock} unidades)")

# Acceso por índice — también funciona
print(p1[0], p1[1])

# Inmutable — no se puede modificar
try:
    p1.precio = 2.00
except AttributeError as e:
    print(f"Error: {e}")

# Unpacking — funciona como cualquier tupla
nombre, precio, stock = p1
print(f"Unpacking: {nombre}, {precio}, {stock}")

— Métodos especiales: _asdict, _replace, _fields —

Punto = namedtuple("Punto", ["x", "y", "z"])
p = Punto(1.0, 2.0, 3.0)

# _fields: ver los nombres de los campos
print(Punto._fields)   # ('x', 'y', 'z')

# _asdict(): convertir a diccionario
d = p._asdict()
print(d)
print(type(d))   # dict

# _replace(): crear una copia con campos modificados
# no modifica el original — crea un objeto nuevo
p2 = p._replace(z=10.0)
print(f"original: {p}")    # sin cambios
print(f"nuevo:    {p2}")

# Caso real: función que devuelve un resultado nombrado
Estadisticas = namedtuple("Estadisticas", ["minimo", "maximo", "media", "n"])

def calcular(datos):
    return Estadisticas(
        minimo = min(datos),
        maximo = max(datos),
        media  = sum(datos) / len(datos),
        n      = len(datos)
    )

ventas = [120, 95, 200, 150, 80]
stats = calcular(ventas)
print(f"Media: {stats.media:.1f}, Máximo: {stats.maximo}")
print(f"Como dict: {stats._asdict()}")

— ¿Cuándo usar cada estructura? —

Situación tuple namedtuple dict defaultdict
Campos fijos, acceso por índice ✓ ✓ ✗ ✗
Campos fijos, acceso por nombre ✗ ✓ ✓ ✗
Claves dinámicas, mutable ✗ ✗ ✓ ✓
Acumulación / conteo / agrupación ✗ ✗ ✓ ✓✓
Retorno múltiple de función ✓ ✓✓ posible ✗
  1. Reescribe el patrón de conteo del Bloque 3 usando defaultdict(int) en lugar de d.get(k, 0) + 1. ¿Cuántas líneas ahorras? ¿El resultado es el mismo?
  2. Crea una namedtuple llamada Alumno con campos nombre, expediente y nota_media. Crea tres instancias y ordénalas por nota_media usando sorted() con key=lambda a: a.nota_media.
  3. ¿Cuál es la diferencia entre p._replace(z=10.0) y p.z = 10.0? ¿Qué produce cada uno?
  4. Dado este fragmento de código generado por IA, identifica qué estructura usa y si es la más adecuada para el caso:
    from collections import defaultdict
    resultado = defaultdict(list)
    datos = [("madrid", 22), ("barcelona", 18),
             ("madrid", 25), ("barcelona", 20)]
    for ciudad, temp in datos:
        resultado[ciudad].append(temp)
    medias = {c: sum(v)/len(v) for c, v in resultado.items()}
    print(medias)
Respuesta orientativa
a
from collections import defaultdict
conteo = defaultdict(int)
for p in palabras:
    conteo[p] += 1
print(dict(conteo))

El resultado es idéntico pero el código es más limpio — no necesitas .get() ni setdefault(). El defaultdict se encarga de inicializar a 0 la primera vez que aparece cada clave.

b
Alumno = namedtuple("Alumno", ["nombre", "expediente", "nota_media"])

alumnos = [
    Alumno("Ana",    "E001", 8.5),
    Alumno("Carlos", "E002", 7.2),
    Alumno("María",  "E003", 9.1),
]

ordenados = sorted(alumnos, key=lambda a: a.nota_media, reverse=True)
for a in ordenados:
    print(f"{a.nombre}: {a.nota_media}")
c

p._replace(z=10.0) crea un objeto nuevo con z=10.0 y deja p intacto. Es la única forma correcta de "modificar" una namedtuple.

p.z = 10.0 lanza un AttributeError porque las namedtuples son inmutables — no puedes asignar directamente a sus atributos.

Patrón correcto: p = p._replace(z=10.0) — reasignas la etiqueta p al objeto nuevo. El objeto original sigue existiendo hasta que el recolector de basura lo elimine.
d

Usa defaultdict(list) para agrupar temperaturas por ciudad, luego calcula la media de cada grupo con un dict comprehension. Es exactamente el patrón de agrupación del Bloque 3 — y es la estructura más adecuada para este caso.

El resultado es {'madrid': 23.5, 'barcelona': 19.0}. El código es correcto, eficiente y bien estructurado. Un dict normal habría requerido setdefault(ciudad, []) en cada iteración — el defaultdict lo elimina.

Parte V

Set: sin orden, sin duplicados

set: sin orden, sin duplicados, pertenencia eficiente teoría

La documentación oficial define los sets como colecciones no ordenadas de objetos únicos hasheables. → fuente

Tres propiedades definen un set: sin orden — los elementos no tienen posición, no puedes acceder por índice; sin duplicados — cada elemento puede aparecer solo una vez; elementos hasheables — por la misma razón que las claves de diccionario, solo objetos inmutables pueden ser elementos.

El set existe por dos razones concretas: garantizar unicidad — eliminar duplicados de forma automática — y verificar pertenencia de forma eficiente — elemento in set es casi instantáneo independientemente del tamaño del set, mientras que elemento in lista recorre toda la lista en el peor caso.

— Crear sets y propiedades básicas —

# Con llaves — igual que dict pero sin clave:valor
vocales = {"a", "e", "i", "o", "u"}
print(type(vocales))
print(vocales)        # el orden puede variar

# Sin orden: no puedes acceder por índice
try:
    print(vocales[0])
except TypeError as e:
    print(f"Error: {e}")

# Sin duplicados: se eliminan automáticamente
con_duplicados = {1, 2, 2, 3, 3, 3, 4}
print(con_duplicados)   # ¿cuántos elementos?

# set() — convertir desde cualquier iterable
desde_lista  = set([1, 2, 2, 3])
desde_string = set("mississippi")   # letras únicas
desde_tupla  = set((1, 2, 1, 3))
print(desde_lista)
print(desde_string)
print(desde_tupla)

# Set vacío — DEBE usarse set(), no {}
vacio = set()
print(type(vacio))   # set, no dict

— Pertenencia: la operación más importante —

La verificación de pertenencia con in es la operación principal de un set. Internamente usa una tabla hash — igual que las claves de un diccionario — lo que hace que la verificación sea O(1): prácticamente instantánea sin importar el tamaño del set. Con una lista, la misma operación es O(n): tiene que recorrer todos los elementos en el peor caso.

import time

# Demostración de eficiencia: lista vs set
n = 1_000_000
lista_grande = list(range(n))
set_grande   = set(range(n))
objetivo     = n - 1   # último elemento — peor caso para lista

t0 = time.time()
_ = objetivo in lista_grande
t1 = time.time()
_ = objetivo in set_grande
t2 = time.time()

print(f"Lista: {(t1-t0)*1000:.2f} ms")
print(f"Set:   {(t2-t1)*1000:.4f} ms")

# Verificación básica
vocales = {"a", "e", "i", "o", "u"}
for letra in "python":
    if letra in vocales:
        print(f"'{letra}' es vocal")
    else:
        print(f"'{letra}' es consonante")

— Añadir y eliminar elementos —

tecnologias = {"Python", "SQL", "pandas"}

# add(): añade un elemento
tecnologias.add("numpy")
tecnologias.add("Python")   # ya existe — sin efecto
print(tecnologias)

# update(): añade múltiples elementos
tecnologias.update(["matplotlib", "scikit-learn"])
print(tecnologias)

# remove(): elimina — KeyError si no existe
tecnologias.remove("SQL")
try:
    tecnologias.remove("Java")   # no existe
except KeyError as e:
    print(f"KeyError: {e}")

# discard(): elimina sin error si no existe
tecnologias.discard("Java")   # silencioso
tecnologias.discard("pandas") # existe — lo elimina
print(tecnologias)

— Caso real: deduplicación y unicidad —

# Eliminar duplicados de una lista manteniendo el tipo
emails = [
    "ana@email.com", "bob@email.com", "ana@email.com",
    "carlos@email.com", "bob@email.com", "diana@email.com"
]
print(f"Con duplicados:    {len(emails)} emails")
emails_unicos = list(set(emails))
print(f"Sin duplicados:    {len(emails_unicos)} emails")

# Verificar si hay duplicados en una lista
def tiene_duplicados(lista):
    return len(lista) != len(set(lista))

print(tiene_duplicados([1, 2, 3]))        # False
print(tiene_duplicados([1, 2, 2, 3]))     # True

# Letras únicas de un texto
texto = "python es genial para programar"
letras = set(texto.replace(" ", ""))
vocales_texto = letras & {"a", "e", "i", "o", "u"}
print(f"Letras únicas: {len(letras)}")
print(f"Vocales únicas: {vocales_texto}")

— frozenset: el set inmutable —

frozenset es la versión inmutable del set — igual que la tupla es la versión inmutable de la lista. No puedes añadir ni eliminar elementos. Su utilidad principal: puede ser elemento de otro set o clave de un diccionario, cosa que un set normal no puede hacer porque no es hasheable.

# frozenset — inmutable
fs = frozenset({1, 2, 3})
print(type(fs))
print(fs)

try:
    fs.add(4)   # no se puede modificar
except AttributeError as e:
    print(f"Error: {e}")

# frozenset como clave de diccionario
permisos = {
    frozenset({"leer", "escribir"}): "editor",
    frozenset({"leer"}):            "lector",
    frozenset({"leer", "escribir", "admin"}): "administrador"
}
mis_permisos = frozenset({"leer", "escribir"})
print(permisos[mis_permisos])   # "editor"
  1. Ejecuta el bloque de creación. ¿Cuántos elementos tiene {1, 2, 2, 3, 3, 3, 4}? ¿Qué devuelve set("mississippi")? ¿Por qué el orden de los elementos puede variar cada vez que ejecutas?
  2. Ejecuta el bloque de eficiencia. ¿Cuánto más rápido es in con un set que con una lista de un millón de elementos? ¿Qué implica esto para el análisis de datos cuando tienes que verificar pertenencia muchas veces?
  3. ¿Qué diferencia hay entre remove() y discard()? ¿Cuándo usarías cada uno?
  4. Dado una lista de códigos de producto con duplicados, escribe el código más conciso para: contar cuántos productos únicos hay, verificar si hay duplicados, y obtener la lista sin duplicados.
Respuesta orientativa
a

{1, 2, 2, 3, 3, 3, 4} tiene 4 elementos — los duplicados se eliminan automáticamente. set("mississippi") devuelve {'m', 'i', 's', 'p'} — las 4 letras únicas. El orden varía porque los sets usan tablas hash internamente y no garantizan ningún orden de iteración.

b

El set es típicamente entre 100 y 1000 veces más rápido para la verificación de pertenencia. La implicación práctica es directa: si tienes que verificar si miles de elementos pertenecen a una colección de referencia — por ejemplo, si un código de producto es válido, si un email está en una lista negra — convierte primero la colección a set.

Patrón de optimización: si necesitas hacer x in coleccion muchas veces y la colección no cambia, coleccion = set(coleccion) antes del bucle puede reducir el tiempo de horas a segundos.
c

remove() lanza KeyError si el elemento no existe — úsalo cuando estás seguro de que el elemento está y quieres que el programa falle visiblemente si no es así. discard() no lanza ningún error — úsalo cuando el elemento puede o no existir y quieres ignorar ese caso silenciosamente.

d
codigos = ["A01", "B02", "A01", "C03", "B02", "D04"]

print(len(set(codigos)))                      # únicos: 4
print(len(codigos) != len(set(codigos)))      # duplicados: True
print(list(set(codigos)))                      # sin duplicados
El patrón más conciso: set(lista) hace las tres cosas implícitamente. Convertir a list al final recupera el tipo si es necesario. Nota: el orden no se preserva — si necesitas preservar el orden de aparición original usa list(dict.fromkeys(lista)).
Operaciones de conjuntos: unión, intersección, diferencia práctica

Los sets implementan las operaciones clásicas de la teoría de conjuntos. Cada operación tiene dos formas equivalentes: un operador simbólico (|, &, -, ^) y un método (union, intersection, difference, symmetric_difference).

La diferencia práctica entre operador y método: el operador solo acepta sets como operandos, el método acepta cualquier iterable. En análisis de datos estas operaciones aparecen constantemente para comparar conjuntos de clientes, productos, categorías o identificadores.

— Las cuatro operaciones fundamentales —

matematicas = {"Ana", "Bob", "Carlos", "Diana"}
fisica      = {"Bob", "Diana", "Eva", "Frank"}

# Unión: todos los elementos de ambos sets
print("Unión:", matematicas | fisica)
print("Unión:", matematicas.union(fisica))

# Intersección: solo los que están en ambos
print("Intersección:", matematicas & fisica)
print("Intersección:", matematicas.intersection(fisica))

# Diferencia: los de A que no están en B
print("Solo matemáticas:", matematicas - fisica)
print("Solo física:",       fisica - matematicas)

# Diferencia simétrica: los que están en uno pero no en ambos
print("En uno solo:", matematicas ^ fisica)
print("En uno solo:", matematicas.symmetric_difference(fisica))
A | B  → todos los de A más todos los de B
A & B  → solo los que están en A y en B
A - B  → los de A que no están en B
A ^ B  → los que están en A o en B pero no en ambos

— Subsets y supersets —

todos    = {1, 2, 3, 4, 5, 6}
pares    = {2, 4, 6}
impares  = {1, 3, 5}
pequeños = {1, 2, 3}

# issubset: ¿todos los elementos de A están en B?
print(pares.issubset(todos))      # True: pares ⊆ todos
print(pares <= todos)             # equivalente con operador

# issuperset: ¿todos los elementos de B están en A?
print(todos.issuperset(pares))    # True: todos ⊇ pares
print(todos >= pares)             # equivalente con operador

# isdisjoint: ¿no tienen ningún elemento en común?
print(pares.isdisjoint(impares))  # True: ningún elemento común
print(pares.isdisjoint(pequeños)) # False: 2 está en ambos

— Caso real: análisis de clientes y productos —

Las operaciones de conjuntos resuelven preguntas de negocio muy frecuentes en análisis de datos: ¿qué clientes compraron A pero no B? ¿Qué productos están en ambos catálogos? ¿Qué usuarios son nuevos este mes? Estas preguntas que en SQL requieren JOINs complejos, con sets se resuelven en una línea.

# Clientes por mes
clientes_enero  = {"Ana", "Bob", "Carlos", "Diana", "Eva"}
clientes_febrero = {"Bob", "Diana", "Frank", "Grace", "Eva"}

# Clientes que compraron en ambos meses (fieles)
fieles = clientes_enero & clientes_febrero
print(f"Clientes fieles:   {fieles}")

# Clientes nuevos en febrero (no compraron en enero)
nuevos = clientes_febrero - clientes_enero
print(f"Clientes nuevos:   {nuevos}")

# Clientes perdidos en febrero (compraron en enero pero no en febrero)
perdidos = clientes_enero - clientes_febrero
print(f"Clientes perdidos: {perdidos}")

# Todos los clientes vistos
todos = clientes_enero | clientes_febrero
print(f"Total clientes:    {len(todos)}")

# Análisis con tres grupos — membresía en clubes
club_a = {"Ana", "Bob", "Carlos"}
club_b = {"Bob", "Diana", "Eva"}
club_c = {"Carlos", "Eva", "Frank"}

# En los tres clubes
en_todos = club_a & club_b & club_c
print(f"En los tres:  {en_todos}")

# Solo en club_a
solo_a = club_a - club_b - club_c
print(f"Solo en A:    {solo_a}")

# En exactamente dos clubes
en_dos = (club_a & club_b) | (club_a & club_c) | (club_b & club_c)
en_dos -= en_todos   # quitar los que están en los tres
print(f"En exactamente dos: {en_dos}")

— Operaciones in-place: modificar el set original —

activos = {"A", "B", "C"}
nuevos  = {"C", "D", "E"}

# |= equivale a activos = activos | nuevos pero modifica en lugar
activos |= nuevos
print(f"Tras |=:  {activos}")

# &= — intersección en lugar
activos &= {"B", "C", "D"}
print(f"Tras &=:  {activos}")

# -= — diferencia en lugar
activos -= {"C"}
print(f"Tras -=:  {activos}")
  1. Dados estos dos sets, predice el resultado de cada operación antes de ejecutar:
    A = {1, 2, 3, 4}
    B = {3, 4, 5, 6}
    print(A | B)
    print(A & B)
    print(A - B)
    print(B - A)
    print(A ^ B)
  2. En el análisis de clientes: ¿qué operación usarías para encontrar los clientes que compraron en enero o en febrero (o en ambos)? ¿Y los que compraron en uno solo de los dos meses?
  3. Tienes dos listas de IDs de productos: catalogo_es = [101, 102, 103, 104, 105] y catalogo_pt = [103, 104, 105, 106, 107]. Usando sets, encuentra en una sola línea cada caso: productos en ambos catálogos, solo en España, solo en Portugal, y en alguno de los dos.
  4. Dado este fragmento de código generado por IA, ¿qué hace y es la estructura más adecuada?
    visitas = ["pag1", "pag2", "pag1", "pag3", "pag2"]
    paginas_vistas = set()
    for p in visitas:
        paginas_vistas.add(p)
    print(len(paginas_vistas))
Respuesta orientativa
a

A | B → {1, 2, 3, 4, 5, 6} — todos. A & B → {3, 4} — comunes. A - B → {1, 2} — en A pero no en B. B - A → {5, 6} — en B pero no en A. A ^ B → {1, 2, 5, 6} — en uno solo.

b

Clientes que compraron en enero o en febrero (o en ambos): clientes_enero | clientes_febrero — unión. Clientes que compraron en uno solo de los dos meses: clientes_enero ^ clientes_febrero — diferencia simétrica.

c
es = set(catalogo_es)
pt = set(catalogo_pt)

print(f"Ambos:        {es & pt}")    # {103, 104, 105}
print(f"Solo España:  {es - pt}")    # {101, 102}
print(f"Solo Portugal:{pt - es}")    # {106, 107}
print(f"Alguno:       {es | pt}")    # {101..107}
d

El código cuenta cuántas páginas únicas se visitaron — construye un set con las páginas distintas y cuenta sus elementos. El resultado es 3.

Es correcto pero más verboso de lo necesario. La forma más concisa es equivalente:

print(len(set(visitas)))
La estructura es adecuada: un set es exactamente lo correcto para contar elementos únicos. Lo que se puede mejorar es la verbosidad — no hace falta el bucle cuando set(iterable) hace lo mismo directamente.
Parte VI

Tareas para casa

Tareas para antes de la Clase 4 tareas

Las tareas de esta clase siguen el flujo del curso: primero escribes un pseudocódigo, luego se lo das a la IA para que genere el código Python, y finalmente lees, interpretas y ejecutas lo que ha generado.

El objetivo no es escribir el código — es entenderlo. Antes de ejecutar cada script, intenta predecir qué estructura usará la IA y por qué. Después de ejecutarlo, verifica si la elección fue la más adecuada.

— Tarea 1 (obligatoria) —

Pseudocódigo — Inventario de ventas

"""
Tengo una lista de transacciones. Cada transacción
es un par (producto, cantidad_vendida). Un mismo
producto puede aparecer en varias transacciones.

Quiero:
1. Calcular el total vendido por cada producto
2. Mostrar los productos ordenados de mayor a menor venta
3. Identificar el producto más vendido
4. Calcular el total general de todas las ventas

Los datos de ejemplo son:
  manzana  → 150, pera → 80, manzana → 200,
  naranja  → 120, pera → 95, manzana → 175,
  naranja  → 90,  pera → 60
"""

Antes de darle este pseudocódigo a la IA, responde estas preguntas:

  1. ¿Qué estructura de datos usará la IA para acumular las ventas por producto? ¿Por qué no una lista?
  2. ¿Qué método o patrón usará para acumular — .get(), setdefault() o defaultdict?
  3. ¿Cómo ordenará los productos de mayor a menor venta?

Después de obtener el código, verifica: ¿usó la estructura que predijiste? ¿Es la más adecuada para este caso? ¿Podrías reescribirlo con defaultdict(int) si usó dict normal?

— Tarea 2 (obligatoria) —

Pseudocódigo — Limpieza de registros

"""
Tengo una lista de emails registrados en un formulario.
Algunos están duplicados porque el mismo usuario
se registró varias veces. Algunos tienen espacios
al principio o al final por errores de escritura.
Todos deberían estar en minúsculas pero algunos
tienen mayúsculas.

Quiero:
1. Limpiar cada email: quitar espacios y convertir
   a minúsculas
2. Eliminar los duplicados
3. Mostrar cuántos emails únicos y válidos quedan
4. Verificar si un email concreto está en la lista

Los datos de ejemplo son:
  "Ana@Email.com", "  bob@email.com", "ANA@EMAIL.COM",
  "carlos@email.com", "BOB@email.com  ", "diana@email.com",
  "carlos@email.com"
"""

Antes de darle este pseudocódigo a la IA, responde estas preguntas:

  1. ¿Qué estructura usará la IA para eliminar duplicados después de limpiar? ¿En qué momento del proceso hay que limpiar — antes o después de crear el set?
  2. ¿Qué métodos de string usará para limpiar cada email?
  3. ¿Qué operación usará para verificar si un email está en la lista? ¿Sería más eficiente sobre una lista o sobre un set?

Después de obtener el código, verifica: ¿limpió antes de deduplicar? Si no lo hizo, ¿qué problema produce? ¿Cuántos emails únicos hay realmente después de limpiar bien?

— Tarea 3 (opcional) —

Pseudocódigo — Registro de alumnos

"""
Tengo un registro de alumnos del máster. Cada alumno
tiene: nombre, número de expediente y una lista
de notas de sus asignaturas.

Quiero representar cada alumno como una estructura
de datos fija con campos nombrados — no quiero
acceder por índice sino por nombre de campo.

Quiero:
1. Crear la estructura adecuada para representar
   un alumno con campos nombrados
2. Calcular la nota media de cada alumno
3. Ordenar los alumnos de mayor a menor nota media
4. Mostrar el ranking final con nombre y media

Los datos de ejemplo son:
  Ana López,       exp-001, notas: [8.5, 7.0, 9.0, 8.0]
  Carlos García,   exp-002, notas: [6.5, 7.5, 6.0, 7.0]
  María Fernández, exp-003, notas: [9.5, 9.0, 8.5, 9.5]
  Luis Martínez,   exp-004, notas: [7.0, 8.0, 7.5, 6.5]
"""

Antes de darle este pseudocódigo a la IA, responde estas preguntas:

  1. ¿Qué estructura usará la IA para representar cada alumno con campos nombrados — dict, namedtuple o clase? ¿Cuál es más adecuada y por qué?
  2. ¿Cómo calculará la nota media de cada alumno a partir de la lista de notas?
  3. ¿Cómo ordenará los alumnos por nota media si los ha guardado como namedtuple?

Después de obtener el código, verifica: ¿usó namedtuple o un dict? Si usó dict, ¿podrías reescribirlo con namedtuple? ¿Qué ventajas e inconvenientes tiene cada elección?

— Tarea 4 (opcional) —

Pseudocódigo — Análisis de solapamiento

"""
Tengo los IDs de clientes de dos tiendas online
del mismo grupo empresarial — tienda A y tienda B.

Quiero analizar el solapamiento entre los dos
grupos de clientes para decidir si lanzar
una campaña cruzada.

Quiero saber:
1. Cuántos clientes compran en ambas tiendas
2. Cuántos son exclusivos de la tienda A
3. Cuántos son exclusivos de la tienda B
4. El total de clientes únicos del grupo
5. Qué porcentaje del total compra en ambas tiendas

Los datos de ejemplo son:
  Tienda A: IDs del 1 al 8  → {1, 2, 3, 4, 5, 6, 7, 8}
  Tienda B: IDs del 5 al 12 → {5, 6, 7, 8, 9, 10, 11, 12}
"""

Antes de darle este pseudocódigo a la IA, responde estas preguntas:

  1. ¿Qué operación de conjuntos corresponde a cada pregunta — intersección, diferencia, unión?
  2. Predice los resultados numéricos con los datos de ejemplo antes de ejecutar.
  3. ¿Cómo calculará el porcentaje de clientes que compran en ambas? ¿Qué dos valores necesita dividir?

Después de obtener el código, verifica: ¿usó operadores simbólicos (|, &, -) o métodos (union, intersection)? ¿Son equivalentes? ¿Coinciden los resultados con tu predicción?

Para todas las tareas: cuando la IA genere el código, léelo antes de ejecutarlo. Identifica: qué estructuras usa, por qué crees que las eligió, si hay algo que no entiendes. Si el código usa algún concepto que no hemos visto todavía — funciones, bucles complejos, módulos — anótalo para estructurar prompts para el LLM.

En el vídeo se entrevista a Andrej Karpathy —cofundador de OpenAI y exdirector de IA en Tesla— reflexiona sobre cómo los modelos de lenguaje están cambiando la forma de programar.

El vídeo introduce ideas como vibe coding, los agentes de IA y lo que Karpathy llama Software 3.0: una forma de desarrollo donde programar consiste cada vez más en diseñar contexto, supervisar sistemas y evaluar resultados generados por modelos.

▶ Ver vídeo — Andrej Karpathy on AI Coding

Mientras lo ves, intenta identificar qué partes conectan con la metodología del curso: ¿qué significa realmente “saber programar” cuando la IA puede generar código funcional? ¿Qué habilidades siguen siendo imprescindibles? ¿Dónde termina la automatización y empieza el criterio técnico?


Respuesta orientativa

— Tarea 1: Inventario de ventas —

1

La IA usará un dict o un defaultdict(int) — es la única estructura que permite asociar cada producto (clave) con su total de ventas (valor) y acceder directamente por nombre de producto. Una lista no sirve porque no tiene acceso por nombre.

2

Lo más probable es que use d.get(producto, 0) + cantidad o defaultdict(int) con d[producto] += cantidad. Ambos son correctos — el segundo es más limpio. El resultado esperado es: {'manzana': 525, 'pera': 235, 'naranja': 210}.

3

Usará sorted(totales.items(), key=lambda x: x[1], reverse=True) para ordenar por valor descendente, y max(totales, key=totales.get) para el producto más vendido. El total general: sum(totales.values()) → 970.

— Tarea 2: Limpieza de registros —

1

Hay que limpiar antes de crear el set — si creas el set primero, "Ana@Email.com" y "ANA@EMAIL.COM" se tratan como emails distintos y no se eliminan como duplicados. El orden correcto es: limpiar cada email → convertir a set. La IA debería usar una list comprehension o un bucle de limpieza antes del set().

2

Usará .strip() para quitar espacios y .lower() para convertir a minúsculas. El patrón típico es: email.strip().lower() en cada elemento. Después de limpiar, los 7 emails originales se reducen a 4 únicos: ana@email.com, bob@email.com, carlos@email.com, diana@email.com.

3

Usará email in emails_set. Es mucho más eficiente sobre un set que sobre una lista — O(1) vs O(n). Para listas pequeñas la diferencia es imperceptible, pero si el registro tiene millones de emails la diferencia es de microsegundos vs segundos.

— Tarea 3: Registro de alumnos —

1

La IA probablemente usará un dict por ser lo más común, pero la elección más adecuada es namedtuple — los datos de un alumno son fijos y con campos nombrados. Si usa dict, el acceso es alumno["nombre"]. Con namedtuple es alumno.nombre — más legible y sin riesgo de KeyError por typos.

2

Usará sum(notas) / len(notas). Las medias esperadas son: Ana → 8.13, Carlos → 6.75, María → 9.13, Luis → 7.25. Ranking: María, Ana, Luis, Carlos.

3

Con namedtuple: sorted(alumnos, key=lambda a: a.nota_media, reverse=True). Con dict: sorted(alumnos, key=lambda a: a["nota_media"], reverse=True). Ambos producen el mismo resultado pero el primero es más legible.

— Tarea 4: Análisis de solapamiento —

1

Ambas en ambas → intersección A & B. Exclusivos A → diferencia A - B. Exclusivos B → diferencia B - A. Total únicos → unión A | B.

2

Con los datos de ejemplo: ambas tiendas → {5,6,7,8} → 4 clientes. Solo A → {1,2,3,4} → 4 clientes. Solo B → {9,10,11,12} → 4 clientes. Total → {1..12} → 12 clientes.

3

Porcentaje: len(A & B) / len(A | B) * 100 → 4 / 12 * 100 = 33.3%. Es el número de clientes compartidos dividido entre el total de clientes únicos.

Operadores vs métodos: A & B y A.intersection(B) son completamente equivalentes con sets. La diferencia es que los métodos aceptan cualquier iterable como argumento — por ejemplo, A.intersection([5,6,7]) funciona aunque la lista no sea un set.