Ejercicio media y estadística

import numpy as np

# Clase A — 1º de Bachillerato
clase_a = np.array([
    171, 173, 170, 172, 174, 171, 173, 172, 171, 173,
    172, 174, 170, 173, 172, 171, 172, 173, 174, 172,
    171, 172, 173, 170, 174, 172, 171, 173, 172, 174,
    172, 171, 173, 172, 170, 173, 174, 172, 171, 172,
    173, 172, 174, 171, 172, 173, 170, 172, 171, 173
])

# Clase B — 2º de la ESO 
clase_b = np.array([
    148, 168, 155, 172, 151, 165, 161, 170, 153, 167,
    158, 173, 149, 162, 157, 169, 155, 164, 171, 150,
    166, 154, 160, 174, 152, 163, 156, 168, 159, 172,
    147, 165, 153, 170, 158, 162, 155, 167, 149, 173,
    161, 156, 168, 152, 164, 170, 154, 159, 163, 169
])

# Clase C — Optativa de deportes mezclada
clase_c = np.array([
    142, 156, 180, 148, 188, 162, 174, 139, 185, 153,
    178, 144, 167, 191, 150, 172, 138, 183, 158, 176,
    145, 169, 195, 141, 160, 186, 154, 173, 143, 179,
    163, 147, 188, 155, 171, 140, 182, 158, 176, 149,
    165, 193, 144, 170, 152, 184, 146, 161, 178, 157
])

Otro ejemplo

import numpy as np  # NumPy es la librería de Python para cálculos matemáticos con listas/arrays.

# Alternativa sin instalar nada: import statistics (librería estándar de Python)

# Lista con 100 alturas en cm. En la vida real esto vendría de un archivo CSV o base de datos.
# Alternativa: pd.read_csv("alturas.csv")["altura"].tolist()
alturas =[172, 173, 154, 168, 175, 177, 168, 164, 167, 162, 173, 159,
          182, 160, 161, 177, 154, 154, 167, 179, 179, 178, 172, 165,
          173, 174, 165, 172, 166, 187, 179, 176, 171, 155, 163, 160,
          187, 167, 167, 171, 168, 158, 170, 181, 174, 169, 176, 164,
          175, 166, 167, 176, 171, 159, 166, 177, 159, 162, 167, 169,
          161, 185, 165, 173, 171, 173, 172, 177, 164, 169, 162, 169,
          181, 174, 169, 175, 174, 173, 185, 172, 166, 148, 159, 167,
          163, 167, 164, 169, 173, 189, 169, 171, 177, 161, 168, 178, 170, 181, 158, 188]

# np.mean() suma todos los valores y divide entre el total. Igual que: sum(alturas)/len(alturas)
# Alternativa: statistics.mean(alturas)
media = np.mean(alturas)
mediana=np.median(alturas)
print(f'Media de altura: {media:.2f}')
print(f'Mediana de altura: {mediana:.2f}')
maximo=max(alturas)
minimo=min(alturas)
rango=maximo-minimo
print(f"El máximo es {maximo}, el mínimo {minimo} y el rango {rango}")
sigma=np.std(alturas)
print(f'Desviación típica: {sigma:.2f}')
print(np.std([2,4,6]))









# Tienda A — ventas muy estables (panadería de barrio, clientela fija)
tienda_a = np.array([198, 205, 201, 197, 203, 199, 202])

# Tienda B — ventas moderadamente variables (restaurante, depende del día)
tienda_b = np.array([150, 185, 210, 165, 230, 175, 195])

# Tienda C — ventas muy irregulares (tienda de souvenirs, turismo estacional)
tienda_c = np.array([80, 320, 45, 410, 95, 280, 170])

for nombre, datos in [("Tienda A", tienda_a), ("Tienda B", tienda_b), ("Tienda C", tienda_c)]:
    print(f"{nombre} → Media: {np.mean(datos):.1f} €  |  σ = {np.std(datos):.1f} €  |  Datos: {datos}")

Ejemplo Outliers

import numpy as np          # Para cálculos matemáticos con arrays
import pandas as pd         # Para trabajar con tablas de datos (DataFrames)
import matplotlib.pyplot as plt  # Para crear gráficos
                            # Alternativa moderna a matplotlib: import seaborn as sns (más bonito por defecto)

# Semilla para reproducibilidad (siempre obtendremos los mismos resultados)
# Alternativa: omitirla si quieres datos distintos cada vez que ejecutes el código
np.random.seed(42)

# Creamos 97 datos normales (media=50, desviacion tipica=8)
# loc = centro de la campana, scale = ancho, size = número de valores generados
# Alternativa con pandas: pd.Series(np.random.normal(50, 8, 97))
datos_normales = np.random.normal(loc=50, scale=8, size=97)

# Anadimos 3 outliers evidentes
# Son valores que claramente se salen del rango normal (50±8 → esperamos valores entre ~26 y ~74)
# Alternativa: outliers_manuales = np.array([datos_normales.mean() - 6*datos_normales.std(), ...])
outliers_manuales = np.array([5, 95, 102])

# Combinamos todo en un array
# np.concatenate une dos arrays como si pegases dos listas una detrás de otra
# Alternativa con listas puras: datos = datos_normales.tolist() + outliers_manuales.tolist()
datos = np.concatenate([datos_normales, outliers_manuales])

# len() cuenta el número total de elementos. Para un array NumPy también puedes usar datos.shape[0]
print(f'Total de datos: {len(datos)}')
# .min() y .max() son métodos de NumPy. Alternativa: np.min(datos), np.max(datos)
print(f'Minimo: {datos.min():.2f}')   # :.2f → muestra solo 2 decimales
print(f'Maximo: {datos.max():.2f}')

# Calculamos los percentiles 25 y 75 (= Q1 y Q3)
# Percentil 25 significa: el valor por debajo del cual está el 25% de los datos
# Alternativa con pandas: Q1 = pd.Series(datos).quantile(0.25)
Q1 = np.percentile(datos, 25)
Q3 = np.percentile(datos, 75)

# Calculamos el IQR
# IQR = distancia entre el "centro" del 50% de los datos. Cuanto mayor, más dispersos están.
# A diferencia de la sigma, el IQR no se ve afectado por los outliers extremos.
IQR = Q3 - Q1

print(f'Q1 (percentil 25): {Q1:.2f}')
print(f'Q3 (percentil 75): {Q3:.2f}')
print(f'IQR = Q3 - Q1:     {IQR:.2f}')

# Creamos un DataFrame de pandas
# Un DataFrame es como una tabla Excel: filas y columnas con nombre.
# Aquí tiene una sola columna llamada 'valor' con los 100 datos.
# Alternativa: df = pd.DataFrame(datos, columns=['valor'])
df = pd.DataFrame({'valor': datos})


# Funcion reutilizable para detectar outliers con IQR
# Recibe una Serie de pandas y un factor (por defecto 1.5, el estándar de Tukey).
# Aumentar el factor (ej: 3.0) hace la detección más permisiva (menos outliers detectados).
# Alternativa: sklearn.preprocessing.RobustScaler para normalizar eliminando outliers automáticamente
def detectar_outliers_iqr(serie, factor=1.5):
    Q1 = serie.quantile(0.25)   # equivale a np.percentile pero opera sobre Series de pandas
    Q3 = serie.quantile(0.75)
    IQR = Q3 - Q1
    limite_inf = Q1 - factor * IQR  # Por debajo de esto → outlier. Con factor=1.5: regla de Tukey
    limite_sup = Q3 + factor * IQR  # Por encima de esto → outlier
    # El operador | es OR lógico: True si el valor es menor que el límite inferior O mayor que el superior
    # Alternativa: mascara = ~serie.between(limite_inf, limite_sup)
    mascara = (serie < limite_inf) | (serie > limite_sup)
    return mascara, limite_inf, limite_sup  # Devuelve 3 valores a la vez (tupla)


# Usamos la funcion
# Python permite recoger los 3 valores devueltos directamente en 3 variables
# mascara es una Serie de True/False con la misma longitud que df
mascara, lim_inf, lim_sup = detectar_outliers_iqr(df['valor'])

# Anadimos una columna al DataFrame
# df['nueva_columna'] = valores crea una nueva columna. Muy habitual en pandas.
# Alternativa: df = df.assign(es_outlier=mascara)
df['es_outlier'] = mascara

# Ver solo los outliers
# df[condicion] filtra las filas donde la condición es True. Igual que un WHERE en SQL.
# Alternativa más corta: df[df['es_outlier']]  (True/False ya es suficiente, sin == True)
print(df[df['es_outlier'] == True])

# plt.subplots(1, 2) crea una figura con 1 fila y 2 columnas de gráficos (dos gráficos lado a lado)
# figsize=(12, 5) → ancho=12 pulgadas, alto=5 pulgadas
# Alternativa con seaborn: fig, axes = plt.subplots(1, 2) + sns.boxplot(..., ax=axes[0])
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# --- Grafico 1: Diagrama de caja (Boxplot) ---
ax1 = axes[0]
# patch_artist=True → rellena la caja con color (por defecto está vacía)
# boxprops, medianprops → diccionarios para personalizar el estilo visual
# Alternativa más sencilla: pd.Series(datos).plot.box(ax=ax1)
ax1.boxplot(datos, patch_artist=True,
            boxprops=dict(facecolor='lightblue'),
            medianprops=dict(color='red', linewidth=2))
# axhline dibuja una línea horizontal en y=valor. Muy útil para marcar umbrales.
# Alternativa: ax1.fill_betweenx para sombrear la zona válida en vez de marcar los límites
ax1.axhline(y=lim_inf, color='red', linestyle='--', label=f'Limite inf: {lim_inf:.1f}')
ax1.axhline(y=lim_sup, color='red', linestyle='--', label=f'Limite sup: {lim_sup:.1f}')
ax1.set_title('Diagrama de Caja')
ax1.legend()  # Muestra la leyenda con los labels definidos arriba

# --- Grafico 2: Histograma ---
ax2 = axes[1]
# ~ es el operador NOT para arrays booleanos: invierte True↔False
# datos[~mascara] → solo los valores donde mascara es False (= los datos normales)
normales = datos[~mascara]
# mascara.values convierte la Serie de pandas a array NumPy para poder indexar datos con ella
# Alternativa: outs = df[df['es_outlier']]['valor'].values
outs = datos[mascara.values]
# Superponemos dos histogramas en el mismo eje: uno azul (normales) y uno rojo (outliers)
# bins=20 → divide el rango en 20 barras. Más bins = más detalle, menos bins = más suavizado
ax2.hist(normales, bins=20, color='steelblue', label='Datos normales')
ax2.hist(outs, bins=5, color='red', label=f'Outliers ({len(outs)})')
# axvline dibuja líneas verticales (igual que axhline pero en vertical) para marcar los límites
ax2.axvline(lim_inf, color='darkred', linestyle='--')
ax2.axvline(lim_sup, color='darkred', linestyle='--')
ax2.set_title('Histograma con Outliers marcados')
ax2.legend()

# tight_layout() ajusta automáticamente los márgenes para que los gráficos no se solapen
# Alternativa: plt.subplots_adjust(wspace=0.3) para controlar el espacio manualmente
plt.tight_layout()
plt.show()  # Muestra la figura. En Jupyter Notebook no hace falta, se muestra automáticamente.

Ejercicio campana

Con estos datos:

[172, 173, 154, 168, 175, 177, 168, 164, 167, 162, 173, 159, 182, 160, 161, 177, 154, 154, 167, 179, 179, 178, 172, 165, 173, 174, 165, 172, 166, 187, 179, 176, 171, 155, 163, 160, 187, 167, 167, 171, 168, 158, 170, 181, 174, 169, 176, 164, 175, 166, 167, 176, 171, 159, 166, 177, 159, 162, 167, 169, 161, 185, 165, 173, 171, 173, 172, 177, 164, 169, 162, 169, 181, 174, 169, 175, 174, 173, 185, 172, 166, 148, 159, 167, 163, 167, 164, 169, 173, 189, 169, 171, 177, 161, 168, 178, 170, 181, 158, 188]

Calcula la media, la desviación típica y cuenta cuantos alumnos miden entre 162 y 178.

import numpy as np  # NumPy es la librería de Python para cálculos matemáticos con listas/arrays.
                    # Alternativa sin instalar nada: import statistics (librería estándar de Python)

# Lista con 100 alturas en cm. En la vida real esto vendría de un archivo CSV o base de datos.
# Alternativa: pd.read_csv("alturas.csv")["altura"].tolist()
alturas=[172, 173, 154, 168, 175, 177, 168, 164, 167, 162, 173, 159, 182, 160, 161, 177, 154, 154, 167, 179, 179, 178, 172, 165, 173, 174, 165, 172, 166, 187, 179, 176, 171, 155, 163, 160, 187, 167, 167, 171, 168, 158, 170, 181, 174, 169, 176, 164, 175, 166, 167, 176, 171, 159, 166, 177, 159, 162, 167, 169, 161, 185, 165, 173, 171, 173, 172, 177, 164, 169, 162, 169, 181, 174, 169, 175, 174, 173, 185, 172, 166, 148, 159, 167, 163, 167, 164, 169, 173, 189, 169, 171, 177, 161, 168, 178, 170, 181, 158, 188]

# np.mean() suma todos los valores y divide entre el total. Igual que: sum(alturas)/len(alturas)
# Alternativa: statistics.mean(alturas)
media=np.mean(alturas)

# np.std() calcula la desviación típica (cuánto se dispersan los datos respecto a la media).
# ⚠️ Por defecto usa ddof=0 (población completa). Si fuera una muestra: np.std(alturas, ddof=1)
# Alternativa: statistics.stdev(alturas)  <-- usa ddof=1 automáticamente
sigma=np.std(alturas)

# f-string: forma moderna de insertar variables dentro de un texto.
# Alternativa antigua: print("La media es: " + str(media) + " y la desviación típica es: " + str(sigma))
print(f"La media es: {media} y la desviación típica es: {sigma}")

# ¿Cuantos hay entre 1, 2 y 3 sigmas
# Contadores inicializados a 0. Irán sumando 1 por cada altura que caiga en cada rango.
sigma1=0
sigma2=0
sigma3=0

# Bucle clásico: recorre cada altura de la lista una por una.
# Alternativa más rápida para datos grandes: usar NumPy (ver abajo con arr = np.array())
for altura in alturas:
    # Python permite encadenar comparaciones: a < x < b es lo mismo que x > a and x < b
    # Rango ±1σ: contiene teóricamente el 68% de los datos en una distribución normal
    if media+sigma >altura > media-sigma:
        sigma1+=1  # += 1 es lo mismo que sigma1 = sigma1 + 1
    # Rango ±2σ: contiene teóricamente el 95% de los datos
    if media + sigma*2 > altura > media - sigma*2:
        sigma2 += 1
    # Rango ±3σ: contiene teóricamente el 99.7% de los datos
    if media + sigma*3 > altura > media - sigma*3:
        sigma3 += 1

# Imprime los tres conteos separados por espacio. Deberían acercarse a 68, 95 y 100.
print(sigma1,sigma2,sigma3)

# ── Segunda versión: más compacta con list comprehension ─────────────────────
# Hace exactamente lo mismo que el bucle for de arriba, pero en una sola línea.
# [x for x in lista if condicion] filtra la lista y len() cuenta cuántos pasaron el filtro.
# Alternativa NumPy (más eficiente): np.sum((arr >= media-sigma) & (arr <= media+sigma))
sigma1=len([altura for altura in alturas if media+sigma >altura > media-sigma])
sigma2=len([altura for altura in alturas if media + sigma*2 > altura > media - sigma*2])
sigma3=len([altura for altura in alturas if media + sigma*3 > altura > media - sigma*3])

print(sigma1,sigma2,sigma3)

# ── Tercera versión: genera datos aleatorios para verificar la regla 68-95-99.7 ──
# Sobreescribe la lista original con 1000 alturas simuladas siguiendo una distribución normal.
# loc=170 → media de 170cm, scale=8 → sigma de 8cm, size=1000 → genera 1000 valores
# ⚠️ Tras esta línea, media y sigma siguen siendo los de las 100 alturas reales, no de estos datos.
alturas = np.random.normal(loc=170, scale=8,  size=1000)  # Datos aleatorios con distribución normal

# Lista vacía donde se irán añadiendo los porcentajes para sigma 1, 2 y 3.
# Alternativa: sigmas = {f"sigma{i}": ... for i in range(1,4)}  (diccionario más descriptivo)
sigmas=[]

# range(1,4) genera [1, 2, 3]. Para cada i, cuenta cuántas alturas caen dentro de ±i*sigma
# y divide entre 10 para convertirlo en porcentaje (1000 datos → dividir/10 = % directamente).
# ⚠️ Truco del /10: solo funciona porque hay exactamente 1000 datos. Para N genérico: /len(alturas)*100
for i in range(1,4):
    sigmas.append(len([altura for altura in alturas if media+sigma*i >altura > media-sigma*i])/10)

# Debería imprimir algo cercano a [68.0, 95.0, 99.7] si los datos son suficientemente normales.
# Alternativa más clara: print(f"±1σ: {sigmas[0]}%  ±2σ: {sigmas[1]}%  ±3σ: {sigmas[2]}%")
print(sigmas)

Comprensión de listas

# ─────────────────────────────────────────────
# COMPRENSIÓN DE LISTAS (List Comprehensions)
# ─────────────────────────────────────────────
# Es una forma compacta de crear una lista nueva a partir de otra.
# Sintaxis básica:
#
#   nueva_lista = [expresion for elemento in iterable]
#
# Equivale exactamente a:
#
#   nueva_lista = []
#   for elemento in iterable:
#       nueva_lista.append(expresion)


# ─────────────────────────────────────────────
# TRANSFORMACIÓN: aplicar una operación a cada elemento
# ─────────────────────────────────────────────

lista = [1, 2, 3, 4, 5, 6]

# Por cada número de la lista, guardamos ese número multiplicado por 2
dobles = [numero * 2 for numero in lista]
print(dobles)   # → [2, 4, 6, 8, 10, 12]

# 💡 EQUIVALENTE con for tradicional (hace exactamente lo mismo):
dobles = []
for numero in lista:
    dobles.append(numero * 2)


# ─────────────────────────────────────────────
# TRANSFORMAR CADENAS
# ─────────────────────────────────────────────

alumnos = ["ana", "jUAN", "pep", "IU", "Eva"]

# .title() pone en mayúscula la primera letra de cada palabra y el resto en minúscula
alumnos_bien = [alumno.title() for alumno in alumnos]
print(alumnos_bien)   # → ['Ana', 'Juan', 'Pep', 'Iu', 'Eva']

# 💡 ALTERNATIVAS a .title() para normalizar texto:
# "jUAN".upper()      → "JUAN"     todo mayúsculas
# "jUAN".lower()      → "juan"     todo minúsculas
# "jUAN".capitalize() → "Juan"     solo la primera letra en mayúscula
# "jUAN".title()      → "Juan"     mayúscula al inicio de cada palabra

# [0:2] → slice con los dos primeros caracteres de cada nombre
alumnos_comienzos = [alumno[0:2] for alumno in alumnos]
print(alumnos_comienzos)   # → ['an', 'jU', 'pe', 'IU', 'Ev']


# ─────────────────────────────────────────────
# USAR EL ELEMENTO COMO CANTIDAD
# ─────────────────────────────────────────────

lista = [2, 4, 5, 3]

# El número indica cuántas veces repetimos el carácter "*"
# "*" * 3  →  "***"
asteriscos = ["*" * n for n in lista]
print(asteriscos)   # → ['**', '****', '*****', '***']

# 💡 ALTERNATIVA: usar otro carácter o construir una barra de progreso
barras = ["█" * n for n in lista]
print(barras)   # → ['██', '████', '█████', '███']


# ─────────────────────────────────────────────
# COMBINAR SLICE Y COMPRENSIÓN
# ─────────────────────────────────────────────

letras = "abcdefghijklmnopqrstuvwxyz"

# Para cada n de la lista, tomamos los primeros n caracteres del abecedario
# letras[0:2] → "ab" | letras[0:4] → "abcd" | letras[0:5] → "abcde"
comienzos = [letras[0:n] for n in lista]
print(comienzos)   # → ['ab', 'abcd', 'abcde', 'abc']


# ─────────────────────────────────────────────
# COPIA DE UNA LISTA
# ─────────────────────────────────────────────

# Si la expresión es simplemente el propio elemento, obtenemos una copia
lista_igual = [n for n in lista]
print(lista_igual)   # → [2, 4, 5, 3]

# 💡 ALTERNATIVA más directa para copiar una lista:
# lista_igual = lista.copy()
# lista_igual = lista[:]


# ─────────────────────────────────────────────
# FILTRADO: añadir una condición con 'if'
# ─────────────────────────────────────────────
# Sintaxis con filtro:
#
#   nueva_lista = [expresion for elemento in iterable if condicion]
#
# Solo se incluyen los elementos que cumplen la condición.

palabras = ["patata", "bustrofedónico", "alubia",
            "otorrinolaringólogo", "mesa", "supercalifragilísticoespialidoso"]

# Solo incluimos la palabra si su longitud (len) es mayor de 10
palabras_largas = [palabra for palabra in palabras if len(palabra) &gt; 10]
print(palabras_largas)   # → ['bustrofedónico', 'otorrinolaringólogo', 'supercalifragilísticoespialidoso']

# 💡 ALTERNATIVAS de filtrado:
palabras_cortas  = [p for p in palabras if len(p) &lt;= 5]     # longitud ≤ 5
con_a            = [p for p in palabras if p.startswith('a')] # que empiecen por 'a'
con_vocal_final  = [p for p in palabras if p[-1] in 'aeiou'] # acaban en vocal


# ─────────────────────────────────────────────
# COMBINAR FILTRADO Y TRANSFORMACIÓN
# ─────────────────────────────────────────────
# La expresión y la condición pueden ser distintas:
# · la condición decide SI se incluye el elemento
# · la expresión decide QUÉ se guarda de ese elemento

# Objetivo: de una lista de nombres, quedarse solo con los de
# longitud PAR e invertirlos.
# ["Pep","Iu","Eva","Juan"] → longitud par: "Iu"(2), "Juan"(4) → invertidos: ["uI","nauJ"]

lista = ["Pep", "Iu", "Eva", "Juan"]

# Paso a paso:
#   1. if len(i) % 2 == 0 → filtra: solo "Iu" y "Juan" tienen longitud par
#   2. i[::-1]            → transforma: invierte cada cadena seleccionada
par_invertidas = [i[::-1] for i in lista if len(i) % 2 == 0]
print(par_invertidas)   # → ['uI', 'nauJ']

# 💡 EQUIVALENTE con for tradicional, más explicado:
par_invertidas = []
for i in lista:
    if len(i) % 2 == 0:       # condición: longitud par
        par_invertidas.append(i[::-1])   # transformación: invertir

# 💡 ALTERNATIVA: guardar también el original para comparar
pares_con_original = [(i, i[::-1]) for i in lista if len(i) % 2 == 0]
print(pares_con_original)   # → [('Iu', 'uI'), ('Juan', 'nauJ')]

Anatomía de una comprensión de lista

resultado = [ expresión   for elemento in iterable   if condición ]
               │                │             │            │
               │                │             │            └── (opcional) filtro:
               │                │             │                solo los que cumplan esto
               │                │             └─────────────── de dónde vienen los datos
               │                └───────────────────────────── nombre temporal
               └────────────────────────────────────────────── qué guardamos

Comparativa: for tradicional vs comprensión

Con for tradicional Con comprensión
resultado = [] + for + append() Todo en una línea
Más fácil de leer al principio Más compacto y expresivo
Fácil de depurar paso a paso Ideal cuando la lógica es simple
Mejor si hay lógica compleja Evitar si hay más de una condición

Los tres sabores de la comprensión

# 1. Solo transformación
[expresion for x in lista]

# 2. Solo filtrado
[x for x in lista if condicion]

# 3. Transformación + filtrado
[expresion for x in lista if condicion]

💡 Regla de oro: si el for + if cabe en una línea corta y se lee de forma natural, usa comprensión. Si necesitas anidar dos for, añadir varios if o la lógica es compleja, el for tradicional será más claro y fácil de mantener.

Datos anidados



# ─────────────────────────────────────────────
# DICCIONARIOS CON LISTAS DENTRO
# ─────────────────────────────────────────────
# Un valor de un diccionario puede ser cualquier tipo:
# un número, una cadena, una lista, otro diccionario…

alumno = {
    'nombre': 'Ana',
    'email':  'ana@ana.com',
    'notas':  [6, 7, 5, 8]      # ← el valor es una lista
}

# Para acceder a la lista usamos la clave 'notas'
# y luego la recorremos con un for como cualquier otra lista
print(f"El alumno {alumno['nombre']} tiene las siguientes notas:")
for nota in alumno['notas']:
    print(nota)

# &#x1f4a1; ALTERNATIVA: calcular la media al vuelo
notas = alumno['notas']
print(f"Media: {sum(notas) / len(notas):.1f}")  # :.1f → un decimal


# ─────────────────────────────────────────────
# DICCIONARIOS ANIDADOS (diccionario dentro de diccionario)
# ─────────────────────────────────────────────
# Un valor también puede ser otro diccionario.
# Para acceder a sus datos encadenamos corchetes: d['clave1']['clave2']

cliente = {
    'nombre': 'Ana',
    'email':  'ana@gmail.com',
    'direccion': {                  # ← valor que es otro diccionario
        'calle':   'Agla',
        'numero':  6,
        'cp':      '08001',
        'ciudad':  'Barcelona'
    }
}

# Acceso en cadena: primero entramos en 'direccion', luego en 'calle' y 'numero'
print(f"El cliente {cliente['nombre']} vive en "
      f"{cliente['direccion']['calle']} número {cliente['direccion']['numero']}")
# → El cliente Ana vive en Agla número 6

# &#x1f4a1; ALTERNATIVA: extraer el sub-diccionario en una variable auxiliar
#    para no repetir cliente['direccion'] varias veces
dir = cliente['direccion']
print(f"{dir['calle']}, {dir['numero']}, {dir['cp']} {dir['ciudad']}")


# ─────────────────────────────────────────────
# LISTA DE DICCIONARIOS
# ─────────────────────────────────────────────
# Patrón muy habitual: una lista donde cada elemento
# es un diccionario que representa un "registro" (alumno, producto, usuario…)

alumnos = [
    {'nombre': 'Ana', 'email': 'ana@gmail.com'},
    {'nombre': 'Pep', 'email': 'pep@pepe.com'},
    {'nombre': 'Iu',  'email': 'iu@iu.com'}
]

# Recorrer la lista y acceder a una clave de cada diccionario
for alumno in alumnos:
    print(alumno['nombre'])    # → Ana / Pep / Iu

# Índice negativo -1: último elemento de la lista
print(alumnos[-1]['nombre'])   # → Iu

# &#x1f4a1; ALTERNATIVA: buscar un alumno concreto con next()
ana = next((a for a in alumnos if a['nombre'] == 'Ana'), None)
print(ana)   # → {'nombre': 'Ana', 'email': 'ana@gmail.com'}
#  · next() devuelve el primer resultado que cumpla la condición
#  · el None al final evita un error si no se encuentra ninguno

# &#x1f4a1; ALTERNATIVA: filtrar varios con comprensión de lista
emails = [a['email'] for a in alumnos]
print(emails)   # → ['ana@gmail.com', 'pep@pepe.com', 'iu@iu.com']


# ─────────────────────────────────────────────
# LISTAS DE LISTAS (matriz / cuadrícula)
# ─────────────────────────────────────────────
# Una lista puede contener otras listas → se comporta como una tabla 2D.
# Se accede con dos índices:  matriz[fila][columna]

cuadrado = [
    [1, 2, 3],   # fila 0
    [4, 5, 6],   # fila 1
    [7, 8, 9]    # fila 2
]

# Recorrer con for anidado: el primer for va fila a fila,
# el segundo va elemento a elemento dentro de cada fila
for linea in cuadrado:
    print(linea)           # imprime la fila entera como lista
    for numero in linea:
        print(numero)      # imprime cada número por separado

# Acceso directo a un elemento: [fila][columna]
print(cuadrado[1][1])   # → 5  (fila 1, columna 1 → el centro del cuadrado)
print(cuadrado[0][2])   # → 3  (fila 0, columna 2)
print(cuadrado[2][0])   # → 7  (fila 2, columna 0)

# &#x1f4a1; ALTERNATIVA: imprimir el cuadrado de forma más visual
for fila in cuadrado:
    print(' '.join(str(n) for n in fila))
# → 1 2 3
# → 4 5 6
# → 7 8 9


# ─────────────────────────────────────────────
# ESTRUCTURA DE DATOS COMPLEJA (caso real)
# ─────────────────────────────────────────────
# En proyectos reales anidamos todos estos conceptos:
# diccionarios, listas y sub-diccionarios a varios niveles.
# La clave es ir "entrando" nivel a nivel con corchetes.

empresa = {
    'nombre': 'TechCorp',
    'departamentos': {                          # nivel 1: dict de departamentos
        'Desarrollo': {
            'jefe': 'María Ruiz',
            'presupuesto': 150000,
            'empleados': [                      # nivel 2: lista de empleados
                {
                    'id': 'E001',
                    'nombre': 'Ana García',
                    'salario': 35000,
                    'habilidades': ['Python', 'Django', 'SQL'],
                    'proyectos': ['WebApp', 'API REST'],
                    'notas_evaluacion': [8, 9, 8, 9],
                },
                {
                    'id': 'E002',
                    'nombre': 'Carlos López',
                    'salario': 32000,
                    'habilidades': ['JavaScript', 'React', 'CSS'],
                    'proyectos': ['WebApp', 'Dashboard'],
                    'notas_evaluacion': [7, 8, 7, 6],
                },
            ],
        },
        'Marketing': {
            'jefe': 'Pedro Sanz',
            'presupuesto': 80000,
            'empleados': [
                {
                    'id': 'E003',
                    'nombre': 'Lucía Mora',
                    'salario': 28000,
                    'habilidades': ['PowerBI', 'Excel', 'Photoshop'],
                    'proyectos': ['Campaña Q1', 'Redes Sociales'],
                    'notas_evaluacion': [9, 9, 8, 9],
                },
            ],
        },
    }
}

# ── Acceso profundo paso a paso ──────────────────────────────────
# Vamos entrando nivel a nivel hasta llegar a lo que queremos:
#
#  empresa
#    └─ ['departamentos']          → dict de departamentos
#         └─ ['Desarrollo']        → dict del departamento
#              └─ ['empleados']    → lista de empleados
#                   └─ [0]         → primer empleado (Ana García)
#                        └─ ['notas_evaluacion']  → [8, 9, 8, 9]

notas = empresa['departamentos']['Desarrollo']['empleados'][0]['notas_evaluacion']
media = sum(notas) / len(notas)
print(media)   # → 8.5


# &#x1f4a1; ALTERNATIVAS Y USOS PRÁCTICOS sobre la estructura 'empresa'

# ── Listar todos los empleados de todos los departamentos
for nombre_depto, depto in empresa['departamentos'].items():
    print(f"\n── {nombre_depto} (jefe: {depto['jefe']}) ──")
    for emp in depto['empleados']:
        print(f"  {emp['id']} | {emp['nombre']} | {emp['salario']}€")

# ── Calcular la media de evaluación de cada empleado
for nombre_depto, depto in empresa['departamentos'].items():
    for emp in depto['empleados']:
        notas = emp['notas_evaluacion']
        media = sum(notas) / len(notas)
        print(f"{emp['nombre']}: media {media:.1f}")

# ── Acceso seguro con get() cuando no sabemos si la clave existe
salario = empresa['departamentos']['Desarrollo']['empleados'][0].get('bonus', 0)
print(f"Bonus: {salario}€")   # → Bonus: 0€  (no existe la clave, devuelve 0)
# ─────────────────────────────────────────────
# FUNCIONES QUE BUSCAN DENTRO DE ESTRUCTURAS ANIDADAS
# ─────────────────────────────────────────────
# Estas funciones reciben el diccionario 'empresa' del ejercicio anterior
# y devuelven una lista con los empleados que coinciden con la búsqueda.
# Si no encuentran nada, devuelven una lista vacía [].


# ─────────────────────────────────────────────
# BUSCAR EMPLEADOS POR NOMBRE
# ─────────────────────────────────────────────

def buscar_empleado(empresa, nombre):
    resultado = []                              # lista donde iremos guardando los encontrados

    departamentos = empresa['departamentos']    # extraemos el dict de departamentos

    for departamento in departamentos.values():  # recorremos cada departamento
        for empleado in departamento['empleados']:  # recorremos cada empleado del departamento

            # 'in' sobre una cadena comprueba si nombre está CONTENIDO en empleado['nombre']
            # Por eso 'ía' encuentra 'Ana García' y 'María Ruiz'
            # Es sensible a mayúsculas: 'ana' NO encontraría 'Ana'
            if nombre in empleado['nombre']:
                resultado.append(empleado)      # añadimos el dict completo del empleado

    return resultado    # devuelve [] si no encontró nadie, o una lista de coincidencias


# Encuentra a Ana García y María Ruiz porque ambas contienen 'ía'
print(buscar_empleado(empresa, 'ía'))

# Devuelve [] porque no existe ningún 'Carlos González' (hay 'Carlos López')
print(buscar_empleado(empresa, 'Carlos González'))


# &#x1f4a1; ALTERNATIVA: búsqueda sin distinguir mayúsculas/minúsculas
#    Convirtiendo ambas cadenas a minúsculas antes de comparar
def buscar_empleado_v2(empresa, nombre):
    resultado = []
    for departamento in empresa['departamentos'].values():
        for empleado in departamento['empleados']:
            if nombre.lower() in empleado['nombre'].lower():  # .lower() → todo minúsculas
                resultado.append(empleado)
    return resultado

# Ahora 'ana' encuentra 'Ana García', 'ANA' también, etc.
print(buscar_empleado_v2(empresa, 'ana'))


# &#x1f4a1; ALTERNATIVA: devolver solo el dato que interesa, no el dict entero
def buscar_nombres(empresa, nombre):
    return [
        emp['nombre']                                  # solo el nombre
        for depto in empresa['departamentos'].values()
        for emp in depto['empleados']
        if nombre.lower() in emp['nombre'].lower()     # búsqueda insensible
    ]
# Comprensión de lista con dos for anidados: más compacta, misma lógica


# ─────────────────────────────────────────────
# BUSCAR EMPLEADOS POR HABILIDAD
# ─────────────────────────────────────────────

def buscar_empleado_habilidades(empresa, habilidad):
    resultado = []

    departamentos = empresa['departamentos']

    for departamento in departamentos.values():
        for empleado in departamento['empleados']:

            # Aquí 'in' comprueba si habilidad es un elemento de la LISTA
            # empleado['habilidades'] es ['Python', 'Django', 'SQL'] etc.
            # Esto busca coincidencia EXACTA: 'React' sí, 'react' NO
            if habilidad in empleado['habilidades']:
                resultado.append(empleado)

    return resultado


# Encuentra a Carlos López porque tiene 'React' en su lista de habilidades
print(buscar_empleado_habilidades(empresa, 'React'))

# Devuelve [] porque nadie tiene 'C#' en sus habilidades
print(buscar_empleado_habilidades(empresa, 'C#'))


# &#x1f4a1; ALTERNATIVA: búsqueda insensible a mayúsculas en la lista de habilidades
def buscar_empleado_habilidades_v2(empresa, habilidad):
    resultado = []
    for departamento in empresa['departamentos'].values():
        for empleado in departamento['empleados']:
            # Convertimos cada habilidad a minúsculas antes de comparar
            habilidades_lower = [h.lower() for h in empleado['habilidades']]
            if habilidad.lower() in habilidades_lower:
                resultado.append(empleado)
    return resultado

print(buscar_empleado_habilidades_v2(empresa, 'python'))  # Encuentra a Ana García


# &#x1f4a1; ALTERNATIVA: buscar empleados que tengan VARIAS habilidades a la vez
def buscar_por_varias_habilidades(empresa, habilidades_buscadas):
    resultado = []
    for departamento in empresa['departamentos'].values():
        for empleado in departamento['empleados']:
            # set y operador <= comprueban si un conjunto está contenido en otro
            # {'Python','SQL'} <= {'Python','Django','SQL'}  → True
            if set(habilidades_buscadas) <= set(empleado['habilidades']):
                resultado.append(empleado['nombre'])
    return resultado

print(buscar_por_varias_habilidades(empresa, ['Python', 'SQL']))
# → ['Ana García']


# &#x1f4a1; ALTERNATIVA: función genérica que busca por CUALQUIER campo
def buscar_por_campo(empresa, campo, valor):
    resultado = []
    for departamento in empresa['departamentos'].values():
        for empleado in departamento['empleados']:
            if empleado.get(campo) == valor:   # get() evita error si el campo no existe
                resultado.append(empleado['nombre'])
    return resultado

print(buscar_por_campo(empresa, 'salario', 32000))  # → ['Carlos López']

Patrón general: «recorrer y filtrar»

def buscar_X(estructura, criterio):
    resultado = []                          # 1. empezamos con lista vacía

    for nivel1 in estructura.values():      # 2. recorremos el nivel 1
        for item in nivel1['coleccion']:    # 3. recorremos el nivel 2

            if criterio cumple condicion:   # 4. comprobamos el criterio
                resultado.append(item)      # 5. guardamos el encontrado

    return resultado  

Diccionarios


# ─────────────────────────────────────────────
# DICCIONARIOS EN PYTHON
# ─────────────────────────────────────────────
# Un diccionario guarda pares  clave: valor
# · Las claves son únicas (no puede haber dos iguales)
# · Los valores pueden ser de cualquier tipo
# · Se definen con llaves  { }
# · A diferencia de las listas, NO se accede por posición
#   sino por el nombre de la clave

producto = {
    'referencia': 'SK789',
    'precio': 200
}
print(producto)   # → {'referencia': 'SK789', 'precio': 200}

clase = {'nombre': 'WEB', 'capacidad': 20}
print(clase)   # → {'nombre': 'WEB', 'capacidad': 20}


# ─────────────────────────────────────────────
# LEER Y MODIFICAR VALORES
# ─────────────────────────────────────────────

# Acceder a un valor con su clave entre corchetes
print(clase['nombre'])      # → WEB
print(clase['capacidad'])   # → 20

# Modificar un valor existente (misma sintaxis que al leer)
clase['capacidad'] = 15
print(clase)   # → {'nombre': 'WEB', 'capacidad': 15}


camisa = {
    'talla':   'L',
    'color':   'rojo',
    'precio':  20,
    'moneda':  '€'
}

# f-string con acceso al diccionario dentro de las llaves { }
print(f"La talla de la camisa es {camisa['talla']}")   # → La talla de la camisa es L


# ─────────────────────────────────────────────
# ACCESO SEGURO: get()
# ─────────────────────────────────────────────

# Si accedemos a una clave que NO existe con corchetes → ERROR (KeyError)
# print(camisa['stock'])   ← esto lanzaría un KeyError y detendría el programa

# get(clave) devuelve el valor si existe, o None si no existe (sin error)
print(camisa.get('stock'))      # → None

# get(clave, valor_por_defecto) devuelve el valor por defecto si no existe
print(camisa.get('stock', 0))   # → 0   ← mucho más útil en la práctica

# 💡 ALTERNATIVA: comprobar antes con 'in'
if 'stock' in camisa:
    print(camisa['stock'])
else:
    print("La clave 'stock' no existe")


# ─────────────────────────────────────────────
# AÑADIR CLAVES NUEVAS
# ─────────────────────────────────────────────

# Asignar a una clave que no existe → la crea automáticamente
camisa['stock'] = 20
print(camisa)
# → {'talla':'L','color':'rojo','precio':20,'moneda':'€','stock':20}


# ─────────────────────────────────────────────
# UPDATE: añadir o modificar varias claves a la vez
# ─────────────────────────────────────────────

# update() acepta argumentos con nombre (clave=valor)
camisa.update(almacen='Central', activo=True)
print(camisa)
# → {..., 'almacen': 'Central', 'activo': True}

# 💡 ALTERNATIVA: pasarle otro diccionario
# camisa.update({'almacen': 'Central', 'activo': True})

# 💡 ALTERNATIVA desde Python 3.9: operador |=
# camisa |= {'almacen': 'Central', 'activo': True}


# ─────────────────────────────────────────────
# VISTAS: keys(), values(), items()
# ─────────────────────────────────────────────
# Estas tres funciones devuelven "vistas" del diccionario.
# No son listas normales, pero se pueden recorrer con for
# y se actualizan automáticamente si el diccionario cambia.

claves   = camisa.keys()     # Todas las claves
valores  = camisa.values()   # Todos los valores
elementos = camisa.items()   # Pares (clave, valor) como tuplas

print(claves)     # → dict_keys(['talla', 'color', 'precio', ...])
print(valores)    # → dict_values(['L', 'rojo', 20, ...])
print(elementos)  # → dict_items([('talla','L'), ('color','rojo'), ...])

# 💡 Si necesitas una lista real puedes convertirlas:
# list(camisa.keys())    → ['talla', 'color', 'precio', ...]
# list(camisa.values())  → ['L', 'rojo', 20, ...]


# ─────────────────────────────────────────────
# ACCEDER CON UNA VARIABLE COMO CLAVE
# ─────────────────────────────────────────────

# La clave puede estar guardada en una variable, no tiene que ser literal
miclave = "stock"
print(camisa[miclave])      # → 20

miclave = "almacen"
print(camisa[miclave])      # → Central

# Esto es muy útil cuando la clave viene de una entrada del usuario
# o se calcula en tiempo de ejecución


# ─────────────────────────────────────────────
# RECORRER UN DICCIONARIO CON FOR
# ─────────────────────────────────────────────

# OPCIÓN 1: iterar solo por las claves y acceder al valor manualmente
for clave in camisa.keys():
    print(clave, camisa[clave])

# OPCIÓN 2 (más elegante): iterar por clave y valor a la vez con items()
for clave, valor in camisa.items():
    print(clave, valor)

# 💡 ALTERNATIVA: iterar directamente (sin .keys()) hace lo mismo que la opción 1
# for clave in camisa:
#     print(clave, camisa[clave])

# 💡 ALTERNATIVA: crear una lista de pares formateados con comprensión de lista
# pares = [f"{k} → {v}" for k, v in camisa.items()]


# ─────────────────────────────────────────────
# ELIMINAR ELEMENTOS: pop() y popitem()
# ─────────────────────────────────────────────

# pop(clave): elimina la clave indicada y DEVUELVE su valor
elemento = camisa.pop('almacen')
print(elemento)   # → Central          (el valor que tenía 'almacen')
print(camisa)     # → {...} sin 'almacen'

# 💡 pop() también acepta valor por defecto para evitar errores:
# camisa.pop('clave_inexistente', None)   → devuelve None sin error

# popitem(): elimina y devuelve el ÚLTIMO par (clave, valor) insertado
# Devuelve una tupla  →  ('clave', valor)
elemento = camisa.popitem()
print(elemento)   # → ('activo', True)   (el último que se añadió)
print(camisa)     # → {...} sin 'activo'

# 💡 ALTERNATIVA para eliminar sin recuperar el valor:
# del camisa['precio']   → borra la clave directamente

Listas y slicing

# ─────────────────────────────────────────────
# MODIFICAR LISTAS: ÍNDICES Y SLICES
# ─────────────────────────────────────────────

# Importación de un módulo propio (archivo iterar_cadenas.py)
# Por ahora no te preocupes por esto, lo veremos más adelante
from iterar_cadenas import posicion

lista = [1, 2, 3, 4, 5, 6, 7]

# Cambiar UN elemento por su índice (posición)
# El índice 3 corresponde al 4º elemento (se empieza a contar desde 0)
lista[3] = 11
print(lista)   # → [1, 2, 3, 11, 5, 6, 7]

# SLICE (rebanada): modificar VARIOS elementos a la vez
# lista[4:7] selecciona desde el índice 4 hasta el 6 (el 7 no se incluye)
# Los reemplazamos por tres nuevos valores
lista[4:7] = [91, 92, 93]
print(lista)   # → [1, 2, 3, 11, 91, 92, 93]

# Un slice puede reemplazarse por MENOS elementos de los que había
# Aquí sustituimos 2 elementos (índices 0 y 1) por solo 1
lista[0:2] = [1]
print(lista)   # → [1, 3, 11, 91, 92, 93]  ← la lista se encoge

# También puede reemplazarse por MÁS elementos de los que había
# Aquí sustituimos 2 elementos por 9 → la lista crece
lista[0:2] = [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(lista)   # → [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 91, 92, 93]

# ─────────────────────────────────────────────
# EXTEND: añadir elementos de otro iterable
# ─────────────────────────────────────────────

listilla = [1, 2, 3]

otralista = [7, 77, 777]
tupla     = (8, 88, 888)
cadena    = "abc"

# extend() añade cada elemento del iterable uno a uno al final de la lista
listilla.extend(otralista)   # Añade los elementos de una lista
print(listilla)              # → [1, 2, 3, 7, 77, 777]

listilla.extend(tupla)       # También funciona con tuplas
print(listilla)              # → [1, 2, 3, 7, 77, 777, 8, 88, 888]

listilla.extend(cadena)      # Con una cadena añade CADA LETRA por separado
print(listilla)              # → [1, 2, 3, 7, 77, 777, 8, 88, 888, 'a', 'b', 'c']


# ─────────────────────────────────────────────
# EXTEND vs APPEND: diferencia importante
# ─────────────────────────────────────────────

lista = [1, 2, 3]
lista.extend([4, 5])   # extend: desglosa la lista y añade cada elemento
print(lista)           # → [1, 2, 3, 4, 5]

lista = [1, 2, 3]
lista.append([4, 5])   # append: añade el objeto ENTERO como un único elemento
print(lista)           # → [1, 2, 3, [4, 5]]  ← ¡el último elemento es una lista!


# ─────────────────────────────────────────────
# LISTAS ANIDADAS (listas dentro de listas)
# ─────────────────────────────────────────────

# Cada alumno es una lista con su nombre y una tupla de notas
alumnos = [
    ["Ana", (1, 2, 3)],
    ["Pep", (4, 5, 6)],
    ["Iu",  (7, 8)]
]
# Para acceder: alumnos[0] → ["Ana", (1,2,3)]
#               alumnos[0][1] → (1, 2, 3)
#               alumnos[0][1][0] → 1


# ─────────────────────────────────────────────
# MÉTODOS PARA ELIMINAR ELEMENTOS
# ─────────────────────────────────────────────

frutas = ["pera", "manzana", "kiwi", "pera"]

# 'in' comprueba si un elemento existe en la lista
if "pera" in frutas:
    frutas.remove("pera")   # remove() elimina la PRIMERA ocurrencia del valor
print(frutas)               # → ['manzana', 'kiwi', 'pera']  ← queda una "pera"

# pop() elimina y DEVUELVE el último elemento (sin argumentos)
ultima_fruta = frutas.pop()
print(ultima_fruta)   # → 'pera'
print(frutas)         # → ['manzana', 'kiwi']

# pop(índice) elimina y devuelve el elemento en esa posición
primera_fruta = frutas.pop(0)
print(primera_fruta)  # → 'manzana'
print(frutas)         # → ['kiwi']


# ─────────────────────────────────────────────
# BUSCAR ELEMENTOS: index()
# ─────────────────────────────────────────────

lista = [2, 3, 4, 5, 4, 3, 2, 7, 8, 2]

# index(valor) devuelve la posición de la PRIMERA aparición del valor
posicion = lista.index(2)
print(posicion)   # → 0

# index(valor, inicio) busca a partir de una posición concreta
# Así podemos encontrar la 2ª, 3ª... aparición del mismo valor
posicion = lista.index(2, posicion + 1)   # Busca desde la posición 1 en adelante
print(posicion)   # → 6

posicion = lista.index(2, posicion + 1)   # Busca desde la posición 7 en adelante
print(posicion)   # → 9


# ─────────────────────────────────────────────
# ORDENAR LISTAS: sort()
# ─────────────────────────────────────────────

lista = [2, 3, 4, 5, 4, 3, 2, 7, 8, 2]

lista.sort()                  # Orden ascendente (de menor a mayor)
print(lista)                  # → [2, 2, 2, 3, 3, 4, 4, 5, 7, 8]

lista.sort(reverse=True)      # Orden descendente (de mayor a menor)
print(lista)                  # → [8, 7, 5, 4, 4, 3, 3, 2, 2, 2]

# sort(key=función): ordena según el valor que devuelve la función
# para cada elemento. Aquí los pares se quedan igual y los impares
# se "pesan" el doble, por lo que los pares van primero.
def mifuncion(num):
    if num % 2 == 0:
        return num        # Par: se usa su valor real
    else:
        return num * 2    # Impar: se usa el doble (ocupa más "peso" al ordenar)

lista.sort(key=mifuncion)
print(lista)   # → [2, 2, 2, 3, 4, 4, 3, 5, 7, 8]  (pares primero)

# Ordenar cadenas por su longitud usando len como función clave
lista = ["Ana", "Iu", "Eva", "Pep", "Rosa", "Juan", "Roc"]
lista.sort(key=len)    # len devuelve el número de caracteres de cada nombre
print(lista)           # → ['Iu', 'Ana', 'Eva', 'Pep', 'Roc', 'Rosa', 'Juan']


# ─────────────────────────────────────────────
# COPIA DE LISTAS: referencia vs copia real
# ─────────────────────────────────────────────

notas = [6, 7, 3, 5]

# ⚠️ ASIGNACIÓN DIRECTA: NO crea una copia, ambas variables apuntan
# al MISMO objeto en memoria. Cambiar una cambia la otra.
copia = notas

copia[1] = 9
print(copia)    # → [6, 9, 3, 5]
print(notas)    # → [6, 9, 3, 5]  ← ¡notas también cambió! Cuidado con esto.


notas = [6, 7, 3, 5]

# ✅ COPIA REAL con .copy(): crea un objeto nuevo e independiente
# Modificar 'copia' NO afecta a 'notas'
copia = notas.copy()

copia[1] = 9
print(copia)    # → [6, 9, 3, 5]
print(notas)    # → [6, 7, 3, 5]  ← notas no ha cambiado ✓

# ─────────────────────────────────────────────
# SLICES (REBANADAS): acceder a partes de una cadena o lista
# ─────────────────────────────────────────────
# Sintaxis general: coleccion[inicio:fin:paso]
#   · inicio: índice donde empieza la rebanada (se incluye)
#   · fin:    índice donde termina (NO se incluye)
#   · paso:   de cuánto en cuánto avanza (por defecto 1)

cadena = "Mi mamá me mima"
#índices:  0123456789...

# [inicio:fin] → caracteres desde el índice 2 hasta el 5 (el 6 no se incluye)
print(cadena[2:6])    # → ' mam'

# [:fin] → desde el principio hasta el índice 1 (el 2 no se incluye)
print(cadena[:2])     # → 'Mi'

# [inicio:] → desde el índice 5 hasta el final
print(cadena[5:])     # → 'má me mima'

# Índices NEGATIVOS: cuentan desde el final hacia atrás
# -1 es el último carácter, -2 el penúltimo, etc.
# [-3:] → los últimos 3 caracteres
print(cadena[-3:])    # → 'ima'


# ─────────────────────────────────────────────
# LOS MISMOS SLICES FUNCIONAN IGUAL CON LISTAS
# ─────────────────────────────────────────────

lista = ["Ana", "Iu", "Eva", "Pep", "Rosa", "Juan", "Roc"]
#índices:   0      1     2      3      4       5      6

print(lista[2:6])   # → ['Eva', 'Pep', 'Rosa', 'Juan']  (del índice 2 al 5)
print(lista[:2])    # → ['Ana', 'Iu']                   (los 2 primeros)
print(lista[5:])    # → ['Juan', 'Roc']                  (desde el índice 5 hasta el final)
print(lista[-3:])   # → ['Juan', 'Roc', ... ]            (los 3 últimos)


# ─────────────────────────────────────────────
# EL TERCER PARÁMETRO: el PASO
# ─────────────────────────────────────────────

# [::] → sin inicio, sin fin, paso por defecto (1) → copia entera
print(lista[::])    # → ['Ana', 'Iu', 'Eva', 'Pep', 'Rosa', 'Juan', 'Roc']

# [::2] → coge un elemento sí, uno no (paso 2)
#          índices 0, 2, 4, 6 → "Ana", "Eva", "Rosa", "Roc"
print(lista[::2])   # → ['Ana', 'Eva', 'Rosa', 'Roc']

# [::-1] → paso NEGATIVO: recorre la lista al revés
#           sin inicio ni fin → la lista completa invertida
print(lista[::-1])  # → ['Roc', 'Juan', 'Rosa', 'Pep', 'Eva', 'Iu', 'Ana']

# [::-2] → al revés de dos en dos
#           índices 6, 4, 2, 0 → "Roc", "Rosa", "Eva", "Ana"
print(lista[::-2])  # → ['Roc', 'Rosa', 'Eva', 'Ana']

# [6:2:-1] → hacia atrás desde el índice 6 hasta el 3 (el 2 no se incluye)
#             índices 6, 5, 4, 3 → "Roc", "Juan", "Rosa", "Pep"
print(lista[6:2:-1])  # → ['Roc', 'Juan', 'Rosa', 'Pep']

Tuplas

# ─────────────────────────────────────────────
# TUPLAS Y LISTAS EN PYTHON
# ─────────────────────────────────────────────

# TUPLA: colección ordenada que NO se puede modificar (inmutable)
# Se define con paréntesis ( )
numeros = (1, 2, 3, 4)

# LISTA: colección ordenada que SÍ se puede modificar (mutable)
# Se define con corchetes [ ]
numeros_lista = [1, 2, 3, 4]

# También puedes crear una tupla SIN paréntesis, solo con comas
# Python la reconoce igualmente como tupla
masnumeros = 6, 7, 8, 9

print(numeros)       # → (1, 2, 3, 4)
print(masnumeros)    # → (6, 7, 8, 9)

# Recorrer una tupla con un bucle for (igual que con una lista)
for numero in numeros:
    print(numero)    # Imprime cada número en una línea distinta

# Las listas SÍ permiten cambiar sus elementos por índice
# El índice 0 es el primer elemento
numeros_lista[0] = 7  # Cambia el 1 por el 7 → [7, 2, 3, 4]
# numeros[0] = 7  ← Esto daría ERROR porque las tuplas son inmutables


# ─────────────────────────────────────────────
# FUNCIONES QUE DEVUELVEN MÚLTIPLES VALORES
# ─────────────────────────────────────────────

# Una función puede devolver varios valores separados por comas.
# En realidad, Python los empaqueta automáticamente en una tupla.
def estadistica(lista):
    mayor = max(lista)          # Valor más alto de la lista
    menor = min(lista)          # Valor más bajo de la lista
    media = sum(lista) / len(lista)  # Suma total dividida entre cantidad de elementos
    return mayor, menor, media  # Devuelve los tres valores como una tupla


# Llamamos a la función y guardamos el resultado (una tupla) en 'info'
info = estadistica([1, 2, 3, 4, 5])

print(info)           # → (5, 1, 3.0)  — imprime la tupla completa

# Podemos acceder a cada valor por su posición (índice)
print("mayor", info[0])   # → mayor 5
print("menor", info[1])   # → menor 1
print("media", info[2])   # → media 3.0

# DESEMPAQUETADO: asignar cada valor de la tupla a una variable distinta
# en una sola línea. El número de variables debe coincidir con los valores.
a, b, c = estadistica([1, 2, 3, 4, 5])
print(a)   # → 5   (mayor)
print(b)   # → 1   (menor)
print(c)   # → 3.0 (media)


# ─────────────────────────────────────────────
# LISTAS DE TUPLAS
# ─────────────────────────────────────────────

# Una lista puede contener tuplas como elementos.
# Aquí cada tupla representa un punto con coordenadas (x, y).
puntos = [(1, 2), (3, 4), (5, 6)]

# OPCIÓN 1: recibir cada tupla completa y desempaquetarla dentro del bucle
for punto in puntos:
    print(punto)        # Imprime la tupla: (1, 2), (3, 4)...
    x, y = punto        # Desempaquetamos la tupla en dos variables
    print(f"x vale {x} y vale {y}")

# OPCIÓN 2: desempaquetar directamente en la cabecera del for
# Es más compacto y hace exactamente lo mismo que la opción 1
for x, y in puntos:
    print(f"x vale {x} y vale {y}")

Ejercicio cadena

# Nos piden una función que nos diga si una cadena
# tiene letras repetidas consecutivas.
#
# Ejemplos:
# letras_repetidas("hola") -> False
# letras_repetidas("sevilla") -> True
#
# En "sevilla" hay dos letras "l" seguidas.


cadena = "hola"

# Así NO funciona:
#
# for letra in cadena:
#
# Porque con este tipo de recorrido solo tenemos
# la letra actual, pero no sabemos fácilmente
# cuál es la siguiente letra para compararla.


# ---------------------------------------------------
# PRIMERA SOLUCIÓN
# ---------------------------------------------------
# Recorremos la cadena usando posiciones (índices)
# para poder comparar una letra con la siguiente.


def letras_repetidas(cadena):

    # len(cadena) nos dice cuántas letras tiene la cadena.
    #
    # range(len(cadena)-1) genera números desde 0
    # hasta la penúltima posición.
    #
    # Restamos 1 porque vamos a usar i+1 y no queremos
    # salirnos del tamaño de la cadena.

    for i in range(len(cadena) - 1):

        # cadena[i]      -> letra actual
        # cadena[i + 1]  -> letra siguiente

        if cadena[i] == cadena[i + 1]:

            # Si encontramos dos letras iguales seguidas,
            # devolvemos True inmediatamente.
            return True

    # Si termina el bucle y no encontramos letras repetidas,
    # devolvemos False.
    return False


print(letras_repetidas("hola"))      # False
print(letras_repetidas("sevilla"))   # True
print(letras_repetidas("bliss"))     # True


# ---------------------------------------------------
# SEGUNDA SOLUCIÓN
# ---------------------------------------------------
# En vez de usar posiciones, guardamos la letra anterior
# y la comparamos con la actual.


def letras_repetidas2(cadena):

    # Variable donde guardaremos la letra anterior.
    # Al principio está vacía porque todavía no hemos
    # recorrido ninguna letra.
    anterior = ""

    # Recorremos la cadena letra por letra.
    for letra in cadena:

        # Comparamos la letra actual con la anterior.
        if letra == anterior:

            # Si son iguales, hay letras repetidas consecutivas.
            return True

        # Guardamos la letra actual como "anterior"
        # para la siguiente vuelta del bucle.
        anterior = letra

    # Si terminamos el recorrido sin encontrar repeticiones,
    # devolvemos False.
    return False


print(letras_repetidas2("hola"))      # False
print(letras_repetidas2("sevilla"))   # True
print(letras_repetidas2("bliss"))     # True