Mutabilidad, copias y matrices en Python — ejercicios con una lista de la compra, un ticket y un stock de almacenes (y el producto que zip se comió)

Preparé el ticket de una compra de cuatro productos: pan, leche, huevos y arroz. Copié los precios de un papel, ejecuté el programa y me dio un total de 4.55. Me pareció poco, pero pensé «será una compra pequeña». No había ningún error, ni un aviso. Cuando por fin repasé las listas vi que tenía cuatro nombres y solo tres precios: se me había olvidado el del arroz. Y zip no me avisó de nada: simplemente dejó de recorrer cuando se acabó la lista más corta y el arroz se evaporó del ticket.

Los errores de este tema tienen todos el mismo aire: silenciosos, sin excepciones y con una salida que casi tiene sentido. Hoy practicamos con tres ejercicios de dificultad creciente, tres fragmentos para depurar y un chuletario para llevarte.

Estos ejercicios usan la teoría de mutabilidad, copias y matrices y repasan lo de la práctica. Puedes hacerlos sin ellas, pero te costará más.

Cómo usar este artículo

  1. Lee el enunciado de un nivel y no mires la solución todavía.
  2. Escribe tu versión, ejecútala y compara tu salida con la esperada.
  3. Si te atascas, mira solo la 🔵 pista y vuelve a intentarlo.
  4. Cuando termines, compara con la solución y fíjate en por qué está hecha así, no solo en que dé la misma salida.

Ningún ejercicio usa input(). Los datos van fijos en el código para que tu salida sea idéntica a la esperada.

Nivel 1 · Básico: modificar una lista de la compra

Enunciado. Empieza con la lista compra = ["leche", "pan", "huevos"] y, modificándola en el sitio y en este orden: añade "arroz" al final, inserta "fruta" en la posición 1, quita el "pan", ordénala alfabéticamente y muéstrala. Después quita el último elemento guardándolo en una variable, muestra esa variable junto con la lista y, por último, muestra cuántos elementos quedan.

Salida esperada:

['arroz', 'fruta', 'huevos', 'leche']
leche ['arroz', 'fruta', 'huevos']
3

Necesitas append, insert, remove y sort (todos modifican la lista, así que no guardes su resultado en ninguna variable). Para quitar el último y quedártelo, pop() hace las dos cosas. Antes de ordenar, la lista es ["leche", "fruta", "huevos", "arroz"].

Nivel 2 · Intermedio: el ticket de la compra

Enunciado. Con estos datos:

PRODUCTOS = ["Pan", "Leche", "Huevos", "Arroz"]
PRECIOS = [1.20, 0.95, 2.40, 1.75]
CANTIDADES = [2, 3, 1, 4]

escribe una función ticket(precios, cantidades) que devuelva una tupla (importes, total), donde importes es la lista con el precio por la cantidad de cada línea (calculada con zip y una list comprehension) y total es su suma. Después muestra cada producto con su importe a dos decimales, el total y una lista con los productos cuyo importe supera los 3 €.

Salida esperada:

Pan 2.40
Leche 2.85
Huevos 2.40
Arroz 7.00
Total: 14.65
Más de 3 €: ['Arroz']

zip(precios, cantidades) te da parejas (precio, cantidad), y la comprehension multiplica cada una. Para mostrar los productos junto a los importes, otro zip, esta vez de PRODUCTOS con importes. La lista de los caros es una comprehension con un if al final.

Nivel 3 · Desafío: el stock de tres almacenes

Enunciado. El stock de cuatro productos en tres almacenes es una matriz (una fila por almacén):

STOCK = [
    [12, 5, 0, 8],
    [3, 9, 14, 0],
    [7, 0, 6, 10],
]

Escribe cuatro funciones: totales_por_almacen(stock) (la suma de cada fila), totales_por_producto(stock) (la suma de cada columna), transponer(stock) (la matriz con filas y columnas intercambiadas) y reponer(stock, almacen, cantidad), que devuelve un stock nuevo con cantidad unidades más de cada producto en ese almacén sin modificar el original. Muestra los tres primeros resultados, el almacén 1 repuesto con 5 unidades y el almacén 1 original para comprobar que no ha cambiado.

Salida esperada:

Por almacén: [25, 26, 23]
Por producto: [22, 14, 20, 18]
Transpuesta: [[12, 3, 7], [5, 9, 0], [0, 14, 6], [8, 0, 10]]
Almacén 1 repuesto: [8, 14, 19, 5]
Original: [3, 9, 14, 0]

Las sumas por filas son una comprehension con sum(fila). Para las columnas necesitas recorrer los índices de columna con range(len(stock[0])) y sacar fila[j] de cada fila; la transpuesta usa la misma idea, pero guardando la columna entera como fila. En reponer, empieza por copiar la matriz fila a fila.

¿Qué falla aquí?

Tres fragmentos con un fallo cada uno. Antes de leer la explicación, haz dos cosas: predice si da error y, si no lo da, qué imprime.

Fragmento 1

ticket.py

nombres = ["Pan", "Leche", "Huevos", "Arroz"]
precios = [1.20, 0.95, 2.40]

total = 0
for nombre, precio in zip(nombres, precios):
    total += precio
print(total)
print(len(nombres), len(precios))

*Piénsalo antes de seguir: queremos sumar los precios de los cuatro productos. ¿Qué imprime python3 ticket.py?*

Explicación. No hay ningún error, pero la respuesta es incorrecta:

4.55
4 3

La primera línea es el total (¡debería ser 6.3!) y la segunda muestra el motivo: hay 4 nombres y solo 3 precios. zip para cuando se acaba la más corta, así que el arroz nunca llega al bucle.

Usar zip con listas de distinta longitud. No hay excepción: los elementos que sobran se descartan en silencio.

Arreglo. Completa el precio que faltaba y protege el programa con un assert que compruebe las longitudes antes de empezar:

nombres = ["Pan", "Leche", "Huevos", "Arroz"]
precios = [1.20, 0.95, 2.40, 1.75]
assert len(nombres) == len(precios), "faltan o sobran precios"

total = 0
for nombre, precio in zip(nombres, precios):
    total += precio
print(round(total, 2))
print(len(nombres), len(precios))
6.3
4 4

Cuando dos listas deben ir «en paralelo», un assert len(a) == len(b) al principio convierte un fallo silencioso en un error con mensaje.

Fragmento 2

anadir.py

def anadir(producto, carrito=[]):
    carrito.append(producto)
    return carrito


lunes = anadir("pan")
martes = anadir("leche")
print(lunes)
print(martes)

¿Da error? ¿Qué imprime? Cada día debería empezar con un carrito vacío.

Explicación. Tampoco da error, y por eso es traicionero:

['pan', 'leche']
['pan', 'leche']

El carrito del martes tiene el pan del lunes, y de hecho lunes y martes muestran lo mismo: son la misma lista. El valor por defecto carrito=[] se crea una sola vez, cuando Python lee la definición de la función, no en cada llamada. Todas las llamadas que no pasan carrito comparten esa misma lista, y cada append la va llenando.

Usar una lista (o cualquier objeto mutable) como valor por defecto de un parámetro. Es un alias escondido que sobrevive entre llamadas.

Arreglo. El valor por defecto es None, y la lista se crea dentro de la función, en cada llamada:

def anadir(producto, carrito=None):
    if carrito is None:
        carrito = []
    carrito.append(producto)
    return carrito


lunes = anadir("pan")
martes = anadir("leche")
print(lunes)
print(martes)
['pan']
['leche']

La receta de siempre es parametro=None y, dentro, if parametro is None: parametro = [].

Fragmento 3

agotados.py

carrito = ["pan", "agotado", "agotado", "leche"]
for producto in carrito:
    if producto == "agotado":
        carrito.remove(producto)
print(carrito)

¿Da error? ¿Qué imprime? Quiero quitar todos los productos agotados.

Explicación. Se ejecuta sin errores, pero queda un «agotado» en el carrito:

['pan', 'agotado', 'leche']

Estás modificando la lista mientras la recorres. Al quitar el primer "agotado" (posición 1), el segundo se desplaza a la posición 1, pero el for ya va por la 2: lo salta sin mirarlo. Con dos elementos iguales seguidos, siempre se escapa uno.

Hacer remove o pop sobre una lista dentro de un for que la recorre. Los índices se mueven y el bucle se salta elementos.

Arreglo. En vez de quitar, construye una lista nueva solo con lo que quieres conservar:

carrito = ["pan", "agotado", "agotado", "leche"]
carrito = [producto for producto in carrito if producto != "agotado"]
print(carrito)
['pan', 'leche']

Una comprehension con if al final es la forma más corta de «quítame todos los que cumplan esto». Y como crea una lista nueva, no hay nada que se desplace.

Soluciones

Mira esta sección solo cuando hayas intentado el ejercicio. Si tu código es distinto pero da la misma salida y cumple los requisitos, también es una solución válida.

Solución del Nivel 1

compra.py

compra = ["leche", "pan", "huevos"]

compra.append("arroz")
compra.insert(1, "fruta")
compra.remove("pan")
compra.sort()
print(compra)

ultimo = compra.pop()
print(ultimo, compra)
print(len(compra))

Por qué está hecho así:

  • Todos los métodos (append, insert, remove, sort) modifican la lista en el sitio y devuelven None, por eso se llaman como instrucciones sueltas, sin x = …. Es justo el error de la lista que era None.
  • La secuencia paso a paso es: ["leche", "pan", "huevos"] → con append queda […, "arroz"] → con insert(1, "fruta") la fruta se cuela en la posición 1 → remove("pan") quita el primero que encuentra → sort() ordena alfabéticamente y deja ['arroz', 'fruta', 'huevos', 'leche'].
  • pop() hace dos cosas a la vez: quita el último (leche) y lo devuelve, y por eso lo guardamos en ultimo. Es la excepción a «los métodos que modifican devuelven None«.
  • len(compra) cuenta lo que queda tras el pop: tres productos.

Solución del Nivel 2

ticket.py

def ticket(precios, cantidades):
    """Devuelve (importes, total) con un importe por línea."""
    importes = [precio * cantidad for precio, cantidad in zip(precios, cantidades)]
    return importes, sum(importes)


PRODUCTOS = ["Pan", "Leche", "Huevos", "Arroz"]
PRECIOS = [1.20, 0.95, 2.40, 1.75]
CANTIDADES = [2, 3, 1, 4]

importes, total = ticket(PRECIOS, CANTIDADES)
for producto, importe in zip(PRODUCTOS, importes):
    print(producto, f"{importe:.2f}")
print("Total:", f"{total:.2f}")

caros = [producto for producto, importe in zip(PRODUCTOS, importes) if importe > 3]
print("Más de 3 €:", caros)

Por qué está hecho así:

  • La función devuelve (importes, total) y no modifica sus argumentos: crea la lista de importes nueva con una comprehension. Se puede llamar con los mismos datos las veces que haga falta.
  • zip(precios, cantidades) empareja el precio y la cantidad de cada línea en la misma posición. Con precio * cantidad obtenemos el importe de cada una.
  • return importes, sum(importes) empaqueta dos valores en una tupla sin paréntesis, y la llamada los desempaqueta en importes, total.
  • El for posterior vuelve a usar zip, esta vez para recorrer a la vez PRODUCTOS e importes, sin índices. El f-string {importe:.2f} da dos decimales: así 7.0 se ve como 7.00.
  • caros es una comprehension con filtro: el if va al final y solo deja pasar los importes mayores que 3. Solo el arroz (4 × 1.75 = 7.00) lo cumple; la leche se queda en 2.85.
  • Las cuentas cuadran: 2.40 + 2.85 + 2.40 + 7.00 = 14.65.

Solución del Nivel 3

almacenes.py

STOCK = [
    [12, 5, 0, 8],     # almacén 0
    [3, 9, 14, 0],     # almacén 1
    [7, 0, 6, 10],     # almacén 2
]


def totales_por_almacen(stock):
    return [sum(fila) for fila in stock]


def totales_por_producto(stock):
    return [sum([fila[j] for fila in stock]) for j in range(len(stock[0]))]


def transponer(stock):
    return [[fila[j] for fila in stock] for j in range(len(stock[0]))]


def reponer(stock, almacen, cantidad):
    """Devuelve un stock NUEVO con 'cantidad' unidades más de cada producto en ese almacén."""
    nuevo = [fila[:] for fila in stock]
    nuevo[almacen] = [unidades + cantidad for unidades in nuevo[almacen]]
    return nuevo


print("Por almacén:", totales_por_almacen(STOCK))
print("Por producto:", totales_por_producto(STOCK))
print("Transpuesta:", transponer(STOCK))
repuesto = reponer(STOCK, 1, 5)
print("Almacén 1 repuesto:", repuesto[1])
print("Original:", STOCK[1])

Por qué está hecho así:

  • totales_por_almacen recorre por filas: cada fila es una lista y sum la suma entera. Una comprehension y listo.
  • totales_por_producto recorre por columnas: no existe «la columna» como objeto, así que se recorren los índices j y se construye cada columna con [fila[j] for fila in stock], que se suma. Fíjate en que el rango sale de len(stock[0]), el número de columnas.
  • transponer usa exactamente la misma idea, pero en lugar de sumar guarda cada columna como una fila nueva: una comprehension dentro de otra. Las 3 filas de 4 elementos se convierten en 4 filas de 3.
  • reponer no modifica stock: empieza con nuevo = [fila[:] for fila in stock], que copia cada fila, y reemplaza la fila del almacén por otra lista nueva con las unidades sumadas. Aquí, como reemplazamos la fila entera, bastaría con copiar la lista exterior; copiamos las filas igualmente para que la función siga siendo segura el día que la cambies para modificar elementos sueltos.
  • La última línea de la salida lo demuestra: el almacén 1 repuesto tiene [8, 14, 19, 5], y STOCK[1] sigue siendo [3, 9, 14, 0].
  • Las cuentas cuadran: la columna 0 suma 12 + 3 + 7 = 22, y la fila 1 repuesta es 3 + 5, 9 + 5, 14 + 5 y 0 + 5.

Míralo paso a paso en Python Tutor

El fallo del fragmento 3 se ve mejor en vivo. Python Tutor ejecuta el código línea a línea y dibuja las listas y sus cambios. Elige Python 3 y pega este programa, que quita los agotados de dos maneras:

carrito = ["pan", "agotado", "agotado", "leche"]
for producto in carrito:
    if producto == "agotado":
        carrito.remove(producto)
print(carrito)

otro = ["pan", "agotado", "agotado", "leche"]
limpio = [p for p in otro if p != "agotado"]
print(limpio, otro)

Salida esperada:

['pan', 'agotado', 'leche']
['pan', 'leche'] ['pan', 'agotado', 'agotado', 'leche']

Qué observar, paso a paso:

  1. En el primer for, carrito es una lista de cuatro elementos y producto recorre las posiciones 0, 1, 2 y 3 de la lista que existe en cada momento. En la primera vuelta, producto vale "pan" y no pasa nada.
  2. En la segunda vuelta, producto vale "agotado" (posición 1) y el if se cumple. Tras el remove, la lista encoge: ahora tiene tres elementos y el segundo "agotado" ha pasado a la posición 1.
  3. La tercera vuelta mira la posición 2, que ahora es "leche". El segundo "agotado" está ahora en la posición 1, que el bucle ya dejó atrás, y nunca se examina.
  4. El primer print muestra la lista con un "agotado" que no se ha ido. Sin ninguna excepción: el bucle simplemente se saltó un elemento.
  5. En la segunda parte, otro no cambia nunca (compruébalo en el dibujo) y limpio es una lista nueva con solo "pan" y "leche". Construir en lugar de modificar evita el problema.

Chuletario: Mutabilidad, copias y matrices

Lo esencial en 30 segundos

  • Mutable: list (también dict y set). Inmutable: números, str y tuple.
  • **b = a no copia:** crea un alias. Un cambio por b se ve en a.
  • Copia superficial: l[:], list(l) o l.copy(). Para listas de listas, copy.deepcopy(l) o copiar cada fila.
  • **Los métodos que modifican devuelven None** (sort, append, remove…); pop es la excepción, porque devuelve lo que quita.
  • Ordenar sin tocar la original: sorted(l). Ordenar en el sitio: l.sort().
  • **zip para en la lista más corta.** Si deben medir lo mismo, comprueba las longitudes.
  • Matriz nueva: [[0] * n for _ in range(m)], nunca [[0] * n] * m.

Sintaxis

Quiero…CódigoResultado
Añadir al finall.append(x)Modifica l; devuelve None
Insertar en una posiciónl.insert(pos, x)Empuja el resto a la derecha
Añadir variosl.extend(otra)Cada elemento de otra
Quitar por valorl.remove(x)El primero; ValueError si no está
Quitar y quedarme con éll.pop() / l.pop(pos)Devuelve el elemento
Ordenar sin tocar la originalsorted(l, reverse=True)Lista nueva
Ordenar la propia listal.sort()Modifica; devuelve None
Copiarl[:] / list(l) / l.copy()Lista nueva (superficial)
Copiar en profundidadcopy.deepcopy(l)Todo el árbol duplicado
Mismo contenido / mismo objetoa == b / a is bTrue o False
Numerarfor pos, x in enumerate(l, 1):Parejas (pos, x) desde 1
Recorrer dos listasfor a, b in zip(l1, l2):Para en la más corta
Transformar o filtrar[f(x) for x in l if cond]Lista nueva
Elemento de una matrizm[i][j]Fila i, columna j
Una columna[f[j] for f in m]Lista con la columna j
Crear una matriz[[0] * n for _ in range(m)]m filas independientes

Plantillas de los ejercicios

**Ticket con zip y comprehension** (Nivel 2):

importes = [precio * cantidad for precio, cantidad in zip(precios, cantidades)]
total = sum(importes)
caros = [p for p, i in zip(productos, importes) if i > 3]

Totales por filas y por columnas (Nivel 3):

por_fila = [sum(fila) for fila in m]
por_columna = [sum([fila[j] for fila in m]) for j in range(len(m[0]))]

Transponer una matriz (Nivel 3):

transpuesta = [[fila[j] for fila in m] for j in range(len(m[0]))]

Copiar una matriz sin alias (Nivel 3):

copia = [fila[:] for fila in m]

Parámetro con valor por defecto seguro (Fragmento 2):

def anadir(producto, carrito=None):
    if carrito is None:
        carrito = []
    carrito.append(producto)
    return carrito

Errores que más se repiten

SíntomaCausa probable
La «copia» cambia cuando cambia la originalEs un alias (copia = lista): usa l[:] o l.copy()
Una variable vale None tras ordenar o añadirx = l.sort() o x = l.append(…): esos métodos devuelven None
ValueError: list.remove(x): x not in listremove de un valor ausente: comprueba antes con in
Cambias una fila de la matriz y cambian todas[[0] * n] * m repite la misma fila: crea las filas con una comprehension
La copia de una matriz cambia con la originalCopia superficial: copia cada fila o usa copy.deepcopy
El ticket tiene menos líneas de las esperadaszip con listas de distinta longitud: compara los len
Una función «recuerda» datos de llamadas anterioresParámetro por defecto mutable (carrito=[]): usa None
Quedan elementos sin quitar tras un for con removeModificas la lista mientras la recorres: usa una comprehension
La función no cambia mi listaReasignas el parámetro (l = l + […]) en lugar de modificar el objeto
TypeError: 'tuple' object does not support item assignmentIntentas cambiar un elemento de una tupla: es inmutable

Antes de entregar, pregúntate

  1. ¿Estoy modificando la lista o creando una nueva? ¿Lo hace evidente mi función (nombre y docstring)?
  2. Si copio algo, ¿de verdad es una copia, y llega hasta el nivel que voy a modificar (filas incluidas)?
  3. Si uso zip, ¿miden lo mismo mis listas?
  4. ¿He guardado en una variable el resultado de un método que devuelve None (sort, append, remove…)?

Si quieres repasar el tema desde la base, vuelve a la teoría de mutabilidad, copias y matrices.

Sigue aprendiendoTodos los artículos de Python

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *