Clase 3 — Estructuras de datos
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))
x
y lista[0] tienen el mismo id?
¿Qué confirma eso sobre cómo guarda referencias
un container?
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?
categorias
aunque hayas puesto "A" dos veces?
¿Qué nos dice eso sobre la naturaleza de un set?
lista["nombre"] o a
alumno[0]?
¿Qué tipo de error produce cada caso
y qué nos dice sobre la diferencia
entre sequences y mappings?
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.
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.
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.
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.
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.
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))
ciudad[6]?
¿Cómo accederías al último carácter
sin saber de antemano la longitud del string?
meses[-3:-1]. Luego verifica.
¿El elemento en la posición -1
está incluido o excluido? ¿Por qué?
meses[::-1]?
¿Cómo usarías esto para invertir un string?
¿Y para verificar si un string es un palíndromo?
trozo[0] = 99,
¿cambia original?
¿Por qué? Verifica con id().
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]))
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.
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:].
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.
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
[::-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.
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.
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.
sum(),
max(), min()
es una forma concisa de calcular
estadísticas sobre subconjuntos de datos
sin necesidad de pandas.
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
— ¿Cuál usar? Árbol de decisión —
set es más eficiente que
la versión con list para encontrar
productos únicos? ¿Qué ocurre internamente
en cada caso?
df["ventas"]?
¿Y df.iloc[0].to_dict()?
¿Qué estructura de datos de Python
se parece más a un DataFrame?
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.
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.
set
primero. elemento in set
es órdenes de magnitud más rápido
que elemento in list.
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.
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.
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}")
list("Python")?
¿Y list(range(5))?
¿Qué tipo tiene cada elemento
de la lista mixta?
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?
contenedor después de
sublista.append(4).
¿Cambia el id de
contenedor[0]?
¿Por qué?
index() para encontrarlo.
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.
None mezclado con números.
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.
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.
.copy()
antes de guardarla.
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.
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}")
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?
remove(2),
¿cuántos 2 quedan en la lista?
¿Qué método usarías para eliminar
todas las ocurrencias de un valor?
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)?
registros al final:
registros = ["Ana", "Bob", "error", "Carlos", "error"]
registros.remove("error")
registros.insert(0, "Directora")
ultimo = registros.pop()
registros.sort()
print(registros)
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.
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.
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]
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.
lista.append(4) ✓
lista = lista.append(4) ✗
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"].
"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.
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}")
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.
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])
max() y la list
comprehension del ejemplo?
original cambia
después de copia[0].append(99).
¿Por qué? Verifica con id().
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)
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.
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
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].
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.
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.
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)
(42)?
¿Y (42,)?
¿Qué devuelve tuple("Python")?
¿Cuántos elementos tiene?
registro[1].append(6.5)
aunque la tupla sea inmutable?
namedtuple?
Lo veremos en el Bloque 3.
(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.
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.
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.
hash((1, 2)) devuelve
un número fijo.
hash([1, 2]) lanza
TypeError: unhashable type: 'list'.
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.
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, 2, 3) en dos variables?
¿Cómo lo resolverías con *?
*:
¿qué tipo tiene siempre la variable
que recibe el *?
¿Aunque la secuencia original sea una tupla?
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?
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.
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
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.
Devuelve una tuple.
Para recibir solo la media:
_, _, media = estadisticas(ventas)
# o más explícito
media = estadisticas(ventas)[2]
_ para indicar
que ese valor se ignora intencionalmente.
Es una convención universal en Python
que mejora la legibilidad.
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}")
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))
{}?
¿Cómo crearías un set vacío?
¿Qué devuelve
dict(zip(["a","b","c"], [1,2,3]))?
{True: "bool", 1: "int"}.
¿Por qué solo hay una clave?
¿Qué valor tiene?
{} 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.
{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.
True/False
con 1/0 como claves
del mismo diccionario — producen colisiones
silenciosas.
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]).
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.
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)
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á?
precios.keys()?
¿Por qué kiwi aparece
en claves_vista aunque
la vista se creó antes de añadirlo?
inventario["manzanas"] = 75
y inventario.update({"manzanas": 75})?
¿Cuándo usarías cada uno?
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))
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.
get() —
no puedes garantizar que todas
las claves existan.
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.
list(precios.keys()).
Una lista no cambia cuando cambia el dict.
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).
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.
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)}")
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.
.get() encadenado.
"el gato come el ratón y el perro come el hueso".
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)
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?
# 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)
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)]
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).
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))
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 | ✗ |
defaultdict(int)
en lugar de d.get(k, 0) + 1.
¿Cuántas líneas ahorras?
¿El resultado es el mismo?
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.
p._replace(z=10.0)
y p.z = 10.0?
¿Qué produce cada uno?
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)
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.
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}")
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.
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.
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.
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, 2, 2, 3, 3, 3, 4}?
¿Qué devuelve set("mississippi")?
¿Por qué el orden de los elementos
puede variar cada vez que ejecutas?
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?
remove() y discard()?
¿Cuándo usarías cada uno?
{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.
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.
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.
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.
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
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)).
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))
— 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}")
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)
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.
visitas = ["pag1", "pag2", "pag1", "pag3", "pag2"]
paginas_vistas = set()
for p in visitas:
paginas_vistas.add(p)
print(len(paginas_vistas))
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.
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.
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}
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)))
set(iterable) hace
lo mismo directamente.
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:
.get(),
setdefault() o
defaultdict?
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:
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:
dict,
namedtuple o clase?
¿Cuál es más adecuada y por qué?
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:
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.
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?
— Tarea 1: Inventario de ventas —
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.
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}.
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 —
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().
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.
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 —
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.
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.
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 —
Ambas en ambas → intersección
A & B.
Exclusivos A → diferencia
A - B.
Exclusivos B → diferencia
B - A.
Total únicos → unión
A | B.
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.
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.
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.