🔎 Buscar

📦 Estructuras de datos

Arrays, listas enlazadas, pilas, colas, tablas hash, árboles, heaps y grafos: qué son, cómo se implementan, su complejidad y cuándo usar cada una. Teoría, código y práctica.

Fundamentos CS📖 Contenido

Estructuras de datos

Una estructura de datos es una forma de organizar y almacenar datos para poder operarlos de forma eficiente. Elegir la estructura correcta es muchas veces la decisión que define si un sistema funciona o se cae: una búsqueda sobre un array de un millón de elementos puede tardar 10 ms… o 10 segundos, según la estructura.

El principio que ordena todo: cada estructura hace muy rápido algunas operaciones y muy lento otras. No hay una estructura perfecta; hay una correcta para cada necesidad.

Qué mirar en una estructura

Dos cosas determinan todo:

  1. Operaciones: ¿qué quieres hacer? Insertar, buscar, eliminar, recorrer, acceder al máximo…
  2. Complejidad (big-O): ¿cuánto cuesta cada operación cuando los datos crecen? Lo explico a fondo en Algoritmos.

💡 Regla mental: un array es lo mejor para acceder por índice; una lista enlazada para insertar/eliminar en el medio; un hash table para buscar por clave; un árbol para datos ordenados; un heap para el máximo/mínimo rápido; un grafo para relaciones.

Arrays

Un array es un bloque contiguo de memoria donde cada elemento ocupa el mismo tamaño y se accede por su índice (posición).

arr = [10, 20, 30, 40, 50]
print(arr[0])    # 10  → O(1): acceso por índice
arr[2] = 99      # actualización → O(1)
int arr[5] = {10, 20, 30, 40, 50};
printf("%d\n", arr[0]);   // 10
Operación Complejidad Explicación
Acceder por índice O(1) Calculas la dirección: base + índice × tamaño
Insertar al final O(1) Si hay espacio
Insertar al inicio/medio O(n) Hay que desplazar los elementos posteriores
Buscar por valor O(n) Sin ordenar, hay que mirar todos
Buscar en array ordenado O(log n) Búsqueda binaria

⚠️ Arrays dinámicos: en Python y la mayoría de lenguajes, los arrays crecen solos (cada vez que se llenan, se redimensionan duplicando tamaño → amortizado O(1) por inserción). En C el tamaño es fijo y tú gestionas todo.

La tabla mental del array

Índice:    0     1     2     3     4
          ┌─────┬─────┬─────┬─────┬─────┐
Valor:    │ 10  │ 20  │ 30  │ 40  │ 50  │
          └─────┴─────┴─────┴─────┴─────┘
Memoria:  0x100 0x104 0x108 0x10C 0x110   (si int = 4 bytes)

arr[2] = 0x100 + 2×4 = 0x108. Por eso el acceso es O(1): no hay que «buscar», se calcula.

Listas enlazadas (linked list)

Una lista enlazada guarda cada elemento con un puntero al siguiente. No hay bloques contiguos: los nodos están dispersos en memoria y se conectan por punteros.

class Nodo:
    def __init__(self, valor):
        self.valor = valor
        self.siguiente = None   # puntero al siguiente

# Construir: 10 → 20 → 30
a = Nodo(10)
b = Nodo(20)
c = Nodo(30)
a.siguiente = b
b.siguiente = c
typedef struct Nodo {
    int valor;
    struct Nodo *siguiente;
} Nodo;
Operación Lista enlazada Array
Acceder por índice O(n) (hay que caminar) O(1)
Insertar al inicio O(1) (solo cambias el puntero de cabeza) O(n)
Insertar en el medio O(1) si ya tienes el nodo O(n)
Buscar por valor O(n) O(n)

💡 La lista enlazada gana en inserción/eliminación, el array gana en acceso por índice. La memoria de una lista enlazada es peor (cada nodo lleva además el puntero), y los accesos son cache-unfriendly (los nodos no están juntos en RAM).

Tipos de listas enlazadas

  • Simplemente enlazada: cada nodo → siguiente. Solo puedes recorrer hacia adelante.
  • Doblemente enlazada: cada nodo → siguiente y anterior. Recorres en ambas direcciones (a costa de un puntero más por nodo).
  • Circular: el último apunta al primero.

Pilas (stacks) y colas (queues)

Son las estructuras de la vida real: una pila es una torre de platos (sacas el de arriba), una cola es la fila del supermercado (el primero en llegar sale primero).

Pila (stack) — LIFO (Last In, First Out)

Último en entrar, primero en salir.

pila = []
pila.append(1)      # push
pila.append(2)
pila.append(3)
print(pila.pop())   # 3  → pop (sale el último)
print(pila.pop())   # 2
Operación Nombre Complejidad
Insertar push O(1)
Quitar pop O(1)
Mirar el tope peek O(1)

Usos: undo/redo de editores, evaluar expresiones, depth-first search, el stack de llamadas de la CPU (recursión), parsing de paréntesis.

# ¿Paréntesis balanceados? Clásico con una pila
def balanceado(s):
    pila = []
    pares = {')': '(', ']': '[', '}': '{'}
    for c in s:
        if c in '([{':
            pila.append(c)
        elif c in ')]}':
            if not pila or pila.pop() != pares[c]:
                return False
    return not pila

print(balanceado("([{}])"))   # True
print(balanceado("([)]"))     # False

Cola (queue) — FIFO (First In, First Out)

Primero en entrar, primero en salir.

from collections import deque
cola = deque()
cola.append("ana")     # enqueue (llegar)
cola.append("luis")
cola.append("carlos")
print(cola.popleft())  # "ana"  → dequeue (atender al primero)
Operación Complejidad
Insertar (enqueue) O(1)
Quitar (dequeue) O(1)
Mirar el frente O(1)

Usos: colas de mensajes, breadth-first search, colas de impresión, buffers, tareas pendientes.

⚠️ En Python, list.pop(0) es O(n): usar deque para colas reales.

Tablas hash (hash tables / diccionarios)

Una tabla hash guarda clave → valor. La clave se pasa por una función hash que la convierte en un índice de un array. Buscar precios["pera"] es O(1) promedio: no recorres nada, calculas el índice directo.

precios = {}
precios["manzana"] = 2.5
precios["pera"] = 3.0
print(precios["pera"])   # 3.0  → O(1)

print("mango" in precios)   # False → O(1)
precios.pop("manzana")      # eliminar → O(1)
Clave "pera" ──hash──▶ índice 3 ──▶ [ ][ ][ ]["pera":3.0][ ]

Colisiones

Si dos claves caen en el mismo índice, hay una colisión. Dos soluciones clásicas:

  • Chaining: cada posición del array es una lista de pares (varios elementos en el mismo índice).
  • Open addressing: buscas la siguiente posición libre.

Con colisiones, la búsqueda ya no es O(1) puro; por eso la tabla se redimensiona (duplica tamaño) cuando el factor de carga sube demasiado (típicamente > 0.75).

Operación Promedio Peor caso
Insertar O(1) O(n) (muchas colisiones)
Buscar O(1) O(n)
Eliminar O(1) O(n)

💡 En la práctica, Python (dict), Go (map), JS (Map/Object) y Java (HashMap) implementan tablas hash internamente. Cuando digas «diccionario», «mapa», «HashMap», «tabla hash»: es la misma estructura.

Buenas funciones hash

  • Determinista: la misma entrada siempre da el mismo índice.
  • Rápida de calcular.
  • Distribuye bien: entradas parecidas dan índices muy distintos (evita agrupaciones).

⚠️ Seguridad: un atacante que conoce tu función hash puede fabricar claves que colisionan todas → DoS (la tabla degrada a O(n)). Por eso lenguajes modernos aleatorizan la semilla del hash (salting).

Árboles binarios de búsqueda (BST)

Un árbol binario tiene un nodo raíz y cada nodo tiene hasta dos hijos (izquierdo y derecho). Un árbol binario de búsqueda (BST) mantiene el orden: a la izquierda van los valores menores, a la derecha los mayores.

           50
         /    \
       30      70
      /  \    /  \
     20  40  60  80
class Nodo:
    def __init__(self, valor):
        self.valor = valor
        self.izq = None
        self.der = None

def insertar(raiz, valor):
    if raiz is None:
        return Nodo(valor)
    if valor < raiz.valor:
        raiz.izq = insertar(raiz.izq, valor)
    else:
        raiz.der = insertar(raiz.der, valor)
    return raiz

def buscar(raiz, valor):
    if raiz is None or raiz.valor == valor:
        return raiz
    if valor < raiz.valor:
        return buscar(raiz.izq, valor)
    return buscar(raiz.der, valor)
Operación Complejidad (árbol balanceado) Árbol degenerado
Buscar O(log n) O(n)
Insertar O(log n) O(n)
Eliminar O(log n) O(n)
Recorrido ordenado O(n) O(n)

⚠️ El problema: si insertas valores ordenados (1,2,3,4…), el árbol se convierte en una lista enlazada (degenera) y todo pasa a O(n). La solución son los árboles balanceados: AVL y Red-Black (estos últimos usan PostgreSQL y los std::map de C++/Java). Se auto-equilibran con rotaciones.

Recorridos de un árbol

Recorrido Orden Uso
In-order (izq, nodo, der) Ordenado ascendente Listar elementos
Pre-order (nodo, izq, der) Copiar / serializar Serialización
Post-order (izq, der, nodo) Hijos antes que padres Borrar el árbol, expresiones
BFS (por niveles) De arriba abajo Buscar lo más cercano

Heaps (montículos)

Un heap es un árbol binario casi completo con una propiedad especial: cada nodo es mayor (max-heap) o menor (min-heap) que sus hijos. El máximo (o mínimo) está siempre en la raíz.

Max-heap:
           90
         /    \
       72      60
      /  \    /  \
     50  40  30  20
Operación Complejidad
Ver el máximo/mínimo O(1)
Insertar O(log n)
Quitar el máximo/mínimo O(log n)

Se implementan sobre un array (el padre del índice i está en (i-1)//2, los hijos en 2i+1 y 2i+2), sin punteros. Son la base del heap sort y de las colas de prioridad (tareas urgentes, Dijkstra, el planificador de tareas del SO).

import heapq

cola = []
heapq.heappush(cola, 5)   # insertar → O(log n)
heapq.heappush(cola, 1)
heapq.heappush(cola, 3)
print(heapq.heappop(cola))   # 1 → el mínimo sale primero

# En Python, heapq = min-heap. Para max-heap, guarda negativos.

Grafos

Un grafo es un conjunto de nodos (vértices) conectados por aristas (edges). Es la estructura de las relaciones: redes sociales (personas → amistad), rutas (ciudades → carreteras), dependencias (paquetes → requisitos), la web (páginas → enlaces).

       A ── B
       |    |
       C ── D ── E

Representaciones comunes:

# 1. Lista de adyacencia (la más usada)
grafo = {
    "A": ["B", "C"],
    "B": ["A", "D"],
    "C": ["A", "D"],
    "D": ["B", "C", "E"],
    "E": ["D"],
}

# 2. Matriz de adyacencia (n×n, 1 si hay arista)
#    A B C D E
# A [0,1,1,0,0]
# B [1,0,0,1,0]
# ...
Representación Espacio ¿Arista existe? Vecinos
Lista de adyacencia O(V + E) O(grado) rápido
Matriz de adyacencia O(V²) O(1) O(V)

Grafos dirigidos y ponderados

  • Dirigido: las aristas tienen dirección (A→B no es lo mismo que B→A). Ej: seguir a alguien en Twitter, enlaces de la web.
  • Ponderado: las aristas tienen peso. Ej: distancia entre ciudades, costo de una tarea.
  • Cíclico / acíclico: un ciclo es un camino que vuelve al inicio. Los DAG (Directed Acyclic Graph) ordenan tareas con dependencias (builds, pipelines).

Los algoritmos sobre grafos (DFS, BFS, Dijkstra, caminos mínimos, topológico) están en la página de Algoritmos.

Cuándo usar cada estructura

Necesidad Estructura
Acceso rápido por posición Array
Insertar/eliminar mucho en los extremos Lista enlazada / deque
Último en entrar, primero en salir Pila (stack)
Primero en entrar, primero en salir Cola (queue)
Buscar por clave exacta rápido Tabla hash
Mantener datos ordenados + buscar Árbol balanceado
El máximo/mínimo siempre a mano Heap / cola de prioridad
Relaciones entre elementos Grafo

Cheatsheet de complejidades

Estructura Acceso Búsqueda Insertar Eliminar
Array O(1) O(n) O(n)* O(n)*
Lista enlazada O(n) O(n) O(1)† O(1)†
Pila / Cola O(n) O(1) O(1)
Tabla hash O(1) O(1) O(1) O(1)
BST balanceado O(log n) O(log n) O(log n) O(log n)
Heap O(1)‡ O(n) O(log n) O(log n)§

* O(1) al final sin desplazamientos. † Si ya tienes el nodo. ‡ Solo el máximo/mínimo. § Solo el máximo/mínimo.

Práctica propuesta

  1. Implementa una pila y una cola sobre un array y verifica su comportamiento.
  2. Implementa una lista enlazada en C con insertar al inicio y al final.
  3. Cuenta la frecuencia de palabras de un texto usando un diccionario.
  4. Implementa un BST con insertar, buscar y recorrido in-order.
  5. Implementa una cola de prioridad con un heap (o usa heapq).
  6. Representa una red social como grafo y responde: ¿hay camino entre A y B? (DFS/BFS)

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.