Ejercicios — Programación II

Programación II combina estructuras (listas, dicts, sets) + paradigmas (OOP, recursión) + I/O. Estos ejercicios mezclan los temas; resolvelos en Python 3.


Cap. 1 — Listas y tuplas

1.1 — Manejo básico (básico)

Dada nums = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]:

a) Sin usar set, devolvé los elementos únicos preservando el orden de aparición. b) Devolvé los elementos que aparecen exactamente una vez.

✅ Solución
nums = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]

# a) únicos preservando orden
unicos = []
for x in nums:
    if x not in unicos:
        unicos.append(x)
# Para n grande, mejor con set auxiliar:
visto = set(); unicos = []
for x in nums:
    if x not in visto:
        unicos.append(x); visto.add(x)

# b) los que aparecen 1 vez
from collections import Counter
once = [x for x, c in Counter(nums).items() if c == 1]
# → [4, 9, 2, 6]

1.2 — Comprehension (intermedio)

Reescribí estos bucles como list comprehensions:

# a)
result = []
for x in range(20):
    if x % 2 == 0:
        result.append(x * x)

# b) matriz traspuesta
m = [[1, 2, 3], [4, 5, 6]]
t = []
for i in range(len(m[0])):
    fila = []
    for f in m:
        fila.append(f[i])
    t.append(fila)
✅ Solución
# a)
result = [x*x for x in range(20) if x % 2 == 0]

# b)
t = [[f[i] for f in m] for i in range(len(m[0]))]
# Más Pythonico: t = list(zip(*m))     (devuelve tuplas)

zip(*m) se basa en el operador unpack * para descomponer m y volverlo a juntar transpuesto.


Cap. 2 — Diccionarios y conjuntos

2.1 — Conteo de palabras (intermedio)

Dada una cadena, devolvé las 5 palabras más frecuentes (case-insensitive, ignorando puntuación).

✅ Solución
import re
from collections import Counter

def top5(texto):
    palabras = re.findall(r'\w+', texto.lower())
    return Counter(palabras).most_common(5)

print(top5("El gato y el perro. El gato es feliz, el perro también."))
# [('el', 4), ('gato', 2), ('perro', 2), ('y', 1), ('es', 1)]

Counter.most_common(n) ya está optimizado.

2.2 — Set operations (básico)

Dadas las listas de estudiantes inscritos en MAT115 y PRO115, devolvé:

a) Los que están en ambos. b) Los que están solo en MAT. c) Los que están en alguno (sin duplicados).

✅ Solución
mat = {"Ana", "Bryan", "Carla", "Daniel"}
pro = {"Bryan", "Daniel", "Erika", "Fer"}

ambos = mat & pro          # {"Bryan", "Daniel"}
solo_mat = mat - pro       # {"Ana", "Carla"}
alguno = mat | pro         # los 6
solo_uno = mat ^ pro       # XOR: {"Ana", "Carla", "Erika", "Fer"}

Cap. 3 — Recursión

3.1 — Suma de dígitos (básico)

Sin convertir a string, sumá los dígitos de un entero positivo. Recursivo.

✅ Solución
def suma_digitos(n: int) -> int:
    if n < 10:
        return n
    return n % 10 + suma_digitos(n // 10)

# suma_digitos(2024) → 4 + 2 + 0 + 2 = 8

3.2 — Permutaciones (intermedio)

Devolvé todas las permutaciones de una lista. Recursivo.

💡 Pista

Para cada elemento, "fijalo" como primero y permutá el resto.

✅ Solución
def permutaciones(lista):
    if len(lista) <= 1:
        return [lista]
    result = []
    for i, x in enumerate(lista):
        resto = lista[:i] + lista[i+1:]
        for p in permutaciones(resto):
            result.append([x] + p)
    return result

# permutaciones([1,2,3]) →
# [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

n!n! permutaciones, así que la complejidad es factorial. Bonus: itertools.permutations ya lo hace.

3.3 — Torre de Hanoi (avanzado)

Imprimí los movimientos para resolver la Torre de Hanoi con nn discos.

✅ Solución
def hanoi(n, origen, auxiliar, destino):
    if n == 1:
        print(f"Mover disco 1 de {origen} a {destino}")
        return
    hanoi(n-1, origen, destino, auxiliar)
    print(f"Mover disco {n} de {origen} a {destino}")
    hanoi(n-1, auxiliar, origen, destino)

hanoi(3, 'A', 'B', 'C')

Mínimo de movimientos: 2n12^n - 1. Para 64 discos: ~5.8 mil millones de años a un movimiento por segundo (la leyenda de Brahma).


Cap. 4 — Archivos y excepciones

4.1 — Procesar CSV (básico)

Dado un CSV con columnas nombre, edad, ciclo, calculá la edad promedio por ciclo.

✅ Solución
import csv
from collections import defaultdict

def promedio_por_ciclo(path):
    suma = defaultdict(int)
    cuenta = defaultdict(int)
    with open(path, encoding='utf-8') as f:
        reader = csv.DictReader(f)
        for fila in reader:
            ciclo = fila['ciclo']
            suma[ciclo] += int(fila['edad'])
            cuenta[ciclo] += 1
    return {c: suma[c] / cuenta[c] for c in suma}

Usá with open siempre — garantiza cierre del archivo aunque haya excepción.

4.2 — Manejo robusto de errores (intermedio)

Implementá una función que lea un archivo y devuelva su contenido, manejando estos casos:

✅ Solución
import logging
from pathlib import Path

def leer_robusto(path: Path | str) -> str | None:
    try:
        with open(path, encoding='utf-8') as f:
            return f.read()
    except FileNotFoundError:
        return None
    except PermissionError:
        raise                                # propagar — es un bug del sistema, no del archivo
    except Exception as e:
        logging.exception(f"Error leyendo {path}")
        return None

Trampa común: capturar Exception indiscriminadamente esconde bugs. Capturá lo específico y dejá pasar lo inesperado (o, como acá, lo logueás antes).


Cap. 5 — Clases y POO

5.1 — Clase Persona (básico)

Implementá una clase Persona con atributos nombre, edad, método saludar() que devuelva "Hola, soy <nombre>". Implementá __str__ y __eq__ (dos personas son iguales si nombre y edad coinciden).

✅ Solución
from dataclasses import dataclass

@dataclass
class Persona:
    nombre: str
    edad: int

    def saludar(self) -> str:
        return f"Hola, soy {self.nombre}"

    # __init__, __repr__, __eq__ ya los da @dataclass

# Sin dataclass:
class PersonaManual:
    def __init__(self, nombre: str, edad: int):
        self.nombre, self.edad = nombre, edad

    def saludar(self):
        return f"Hola, soy {self.nombre}"

    def __str__(self):
        return f"Persona({self.nombre}, {self.edad})"

    def __eq__(self, otra):
        if not isinstance(otra, PersonaManual): return NotImplemented
        return self.nombre == otra.nombre and self.edad == otra.edad

@dataclass te ahorra mucho boilerplate. Para POO en producción, usalo.

5.2 — Herencia (intermedio)

Modelá una jerarquía: Cuenta (base, con saldo, métodos depositar/retirar), CuentaAhorro (interés mensual del 0.5 %), CuentaCorriente (descubierto autorizado de $500).

✅ Solución
class Cuenta:
    def __init__(self, titular: str, saldo: float = 0):
        self.titular = titular
        self.saldo = saldo

    def depositar(self, monto: float):
        if monto <= 0: raise ValueError("Monto debe ser positivo")
        self.saldo += monto

    def retirar(self, monto: float):
        if monto <= 0: raise ValueError("Monto debe ser positivo")
        if monto > self.saldo:
            raise ValueError("Saldo insuficiente")
        self.saldo -= monto


class CuentaAhorro(Cuenta):
    INTERES_MENSUAL = 0.005

    def aplicar_interes(self):
        self.saldo *= (1 + self.INTERES_MENSUAL)


class CuentaCorriente(Cuenta):
    DESCUBIERTO = 500

    def retirar(self, monto: float):
        if monto > self.saldo + self.DESCUBIERTO:
            raise ValueError("Excede descubierto autorizado")
        self.saldo -= monto       # puede quedar negativo

Notá que CuentaCorriente.retirar sobreescribe el de Cuenta: el descubierto cambia la regla.

5.3 — Composición vs herencia (avanzado)

Convertí la siguiente jerarquía profunda en composición:

class Animal:
    def comer(self): pass

class Volador(Animal):
    def volar(self): pass

class Nadador(Animal):
    def nadar(self): pass

# ¿Y si un animal nada Y vuela? ¿Pato?
💡 Pista

En vez de heredar capacidades, inyectalas como atributos componibles.

✅ Solución
class HabilidadComer:
    def comer(self): print("come")

class HabilidadVolar:
    def volar(self): print("vuela")

class HabilidadNadar:
    def nadar(self): print("nada")

class Animal:
    def __init__(self, nombre, habilidades):
        self.nombre = nombre
        self.habilidades = habilidades

    def __getattr__(self, nombre):
        for h in self.habilidades:
            if hasattr(h, nombre):
                return getattr(h, nombre)
        raise AttributeError(nombre)

pato = Animal("pato", [HabilidadComer(), HabilidadVolar(), HabilidadNadar()])
pato.volar()  # vuela
pato.nadar()  # nada

Composición es más flexible que herencia para combinar comportamientos.


Reto integrador

R.1 — Sistema de biblioteca con persistencia

Implementá un mini-sistema de biblioteca:

  1. Clases: Libro, Usuario, Prestamo, Biblioteca.
  2. Biblioteca.prestar(libro_id, usuario_id) → registra y baja stock.
  3. Biblioteca.devolver(prestamo_id) → registra devolución y sube stock.
  4. Persistencia: guardar/cargar todo de un biblioteca.json.
  5. CLI interactiva con menú.
  6. Manejo de errores: usuario inexistente, libro sin stock, doble devolución, etc.
💡 Pista de arquitectura
  • @dataclass para los modelos.
  • Biblioteca como agregador con métodos prestar, devolver, save, load.
  • json.dump para persistir. Convertí dataclasses con asdict.
  • Excepciones custom: LibroNoExiste, UsuarioNoExiste, SinStock.
✅ Solución (bosquejo)
import json
from dataclasses import dataclass, asdict
from datetime import date
from pathlib import Path

@dataclass
class Libro:
    id: int; titulo: str; autor: str; stock: int = 1

@dataclass
class Usuario:
    id: int; nombre: str

@dataclass
class Prestamo:
    id: int; libro_id: int; usuario_id: int
    fecha_prestamo: str
    fecha_devolucion: str | None = None

class Biblioteca:
    def __init__(self):
        self.libros: dict[int, Libro] = {}
        self.usuarios: dict[int, Usuario] = {}
        self.prestamos: dict[int, Prestamo] = {}
        self._next_id = 1

    def prestar(self, libro_id, usuario_id):
        if libro_id not in self.libros: raise KeyError("libro")
        if usuario_id not in self.usuarios: raise KeyError("usuario")
        libro = self.libros[libro_id]
        if libro.stock <= 0: raise ValueError("sin stock")
        libro.stock -= 1
        p = Prestamo(self._next_id, libro_id, usuario_id, str(date.today()))
        self.prestamos[p.id] = p
        self._next_id += 1
        return p.id

    def devolver(self, prestamo_id):
        p = self.prestamos.get(prestamo_id)
        if not p: raise KeyError("prestamo")
        if p.fecha_devolucion: raise ValueError("ya devuelto")
        p.fecha_devolucion = str(date.today())
        self.libros[p.libro_id].stock += 1

    def save(self, path):
        Path(path).write_text(json.dumps({
            'libros': {k: asdict(v) for k, v in self.libros.items()},
            'usuarios': {k: asdict(v) for k, v in self.usuarios.items()},
            'prestamos': {k: asdict(v) for k, v in self.prestamos.items()},
            'next_id': self._next_id,
        }, indent=2))

    @classmethod
    def load(cls, path):
        data = json.loads(Path(path).read_text())
        b = cls()
        b.libros = {int(k): Libro(**v) for k, v in data['libros'].items()}
        b.usuarios = {int(k): Usuario(**v) for k, v in data['usuarios'].items()}
        b.prestamos = {int(k): Prestamo(**v) for k, v in data['prestamos'].items()}
        b._next_id = data['next_id']
        return b

Bonus: agregá tests con pytest. Crear biblioteca, prestar, intentar prestar sin stock, devolver, guardar, cargar, verificar igualdad.


Más práctica: implementá los proyectos en GitHub. La práctica > la teoría.