CP-UPV Bootcamp - Capítulo 5

Capítulo 5: Estructuras de Datos

PROBLEMAS DEL CAPÍTULO
Para acceder a los problemas de este capítulo, accede con tu usuario y contraseña a cpupv.contest.codeforces.com. Si no tienes usuario y contraseña, inscríbete aquí.

Índice de contenidos

1. Estructuras Lineales (LIFO / FIFO)

Las estructuras de datos lineales organizan los elementos en una secuencia continua. La diferencia clave entre ellas radica en por dónde se insertan y por dónde se eliminan los datos, lo que condiciona el orden en el que se procesan.

std::stack (Pila - LIFO)

Una Pila es una estructura basada en el principio Last-In, First-Out (LIFO: el último en entrar es el primero en salir). Imagínala como una pila de platos en un restaurante: solo puedes colocar un plato nuevo en la parte superior y, si quieres coger uno, tienes que retirar obligatoriamente el que está arriba del todo.

No se puede acceder directamente a los elementos del fondo o del medio sin antes desapilar los superiores. Permite acceso, inserción y borrado en el tope con una complejidad constante de $O(1)$.

std::stack<int> st;
st.push(10);     // Inserción en el tope O(1)
st.top();        // Consulta del elemento superior O(1)
st.pop();        // Elimina el elemento superior O(1)
bool emp = st.empty(); // Verifica si está vacía O(1)
st = []
st.append(10)    # Inserción en el tope O(1)
top_val = st[-1] # Consulta del elemento superior O(1)
st.pop()         # Elimina el elemento superior O(1)
emp = len(st) == 0 # Verifica si está vacía O(1)
ArrayDeque<Integer> st = new ArrayDeque<>();
st.push(10);     // Inserción en el tope O(1)
st.peek();       // Consulta del elemento superior O(1)
st.pop();        // Elimina el elemento superior O(1)
boolean emp = st.isEmpty(); // Verifica si está vacía O(1)

Técnica Estrella: Monotonic Stack (Pila Monótona)

Una Pila Monótona es una pila convencional que se gestiona bajo una regla estricta: sus elementos siempre deben mantenerse ordenados (ya sea de forma estrictamente creciente o decreciente) desde la base hasta el tope.

¿Cómo funciona la inserción?

Para insertar un nuevo valor $X$, no basta con hacer un push directo. Primero debemos hacer un "limpieza": desapilamos (hacemos pop) de todos los elementos superiores que rompan la propiedad de orden deseada frente a $X$. Una vez eliminados, recién insertamos $X$.

¿Por qué es $O(N)$ si hay un bucle interno?

Aunque procesemos el array con un bucle y dentro usemos un while para desapilar, cada elemento del array entra a la pila exactamente una vez (vía push) y sale como máximo una vez (vía pop). Por lo tanto, el número total de operaciones de pila a lo largo de todo el algoritmo está acotado por $2N$, lo que nos da un tiempo de ejecución amortizado de $O(N)$.

Ejemplo clásico: Siguiente Elemento Mayor (Next Greater Element)

Dado un array, queremos encontrar para cada posición cuál es el primer elemento estrictamente mayor que aparece a su derecha. Si no existe, guardamos $-1$.

// Devuelve un vector con el siguiente elemento mayor a la derecha para cada posición
std::vector<int> nextGreaterElement(const std::vector<int>& arr) {
    int n = arr.size();
    std::vector<int> ans(n, -1);
    std::stack<int> st; // Guardamos ÍNDICES para poder asignar las respuestas

    for (int i = 0; i < n; ++i) {
        // Mientras la pila no esté vacía y el elemento actual sea MAYOR
        // que el elemento apuntado por el índice del tope:
        while (!st.empty() && arr[i] > arr[st.top()]) {
            ans[st.top()] = arr[i]; // El elemento actual es la respuesta para st.top()
            st.pop();
        }
        st.push(i); // Guardamos el índice actual
    }
    return ans;
}
def next_greater_element(arr):
    n = len(arr)
    ans = [-1] * n
    st = []  # Pila que guardará ÍNDICES

    for i in range(n):
        # Mantenemos la propiedad monótona decreciente desapilando los menores
        while st and arr[i] > arr[st[-1]]:
            idx = st.pop()
            ans[idx] = arr[i]
        st.append(i)
        
    return ans
import java.util.ArrayDeque;
import java.util.Arrays;

public static int[] nextGreaterElement(int[] arr) {
    int n = arr.length;
    int[] ans = new int[n];
    Arrays.fill(ans, -1);
    ArrayDeque<Integer> st = new ArrayDeque<>(); // Guardamos ÍNDICES

    for (int i = 0; i < n; i++) {
        while (!st.isEmpty() && arr[i] > arr[st.peek()]) {
            ans[st.pop()] = arr[i];
        }
        st.push(i);
    }
    return ans;
}

std::queue (Cola - FIFO)

Una Cola sigue el principio First-In, First-Out (FIFO: el primero en entrar es el primero en salir). La mejor forma de visualizarla es la cola de la caja en un supermercado: la primera persona que llega a la fila es la primera en ser atendida, y los nuevos clientes se colocan siempre al final.

Los elementos entran por un extremo (el final o back) y salen por el otro (el frente o front). Todas estas operaciones se realizan en un tiempo constante de $O(1)$. Es la estructura fundamental para algoritmos de exploración por niveles, como la Búsqueda en Anchura (BFS) en grafos.

std::deque (Cola de Doble Extremo)

Un Deque (del inglés Double-Ended Queue) combina la potencia de las pilas y las colas. Es una estructura flexible que permite insertar, consultar y eliminar elementos de forma eficiente tanto por el frente como por el final en complejidad $O(1)$.

Piensa en un vagón de tren donde se pueden acoplar o desacoplar máquinas o vagones por ambos extremos con la misma facilidad. Es indispensable cuando necesitas mantener ventanas deslizantes (sliding window) o calcular mínimos y máximos en rangos dinámicos.

std::deque<int> dq;
dq.push_back(5);   // Inserta al final O(1)
dq.push_front(2);  // Inserta al inicio O(1)
dq.pop_back();     // Elimina del final O(1)
dq.pop_front();    // Elimina del inicio O(1)
int f = dq.front(); // Consulta el frente O(1)
int b = dq.back();  // Consulta el final O(1)
from collections import deque
dq = deque()
dq.append(5)      # Inserta al final O(1)
dq.appendleft(2)  # Inserta al inicio O(1)
dq.pop()          # Elimina del final O(1)
dq.popleft()      # Elimina del inicio O(1)
f = dq[0]         # Consulta el frente O(1)
b = dq[-1]        # Consulta el final O(1)
ArrayDeque<Integer> dq = new ArrayDeque<>();
dq.addLast(5);    // Inserta al final O(1)
dq.addFirst(2);   // Inserta al inicio O(1)
dq.removeLast();  // Elimina del final O(1)
dq.removeFirst(); // Elimina del inicio O(1)
int f = dq.peekFirst(); // Consulta el frente O(1)
int b = dq.peekLast();  // Consulta el final O(1)

Ejemplo 1: El Frigorífico de Diego Provencio

El arduo trabajador Diego Provencio almacena una fila de $N$ piezas de carne en el frigorífico donde es explotado, ordenadas de izquierda a derecha. Cada pieza $i$ tiene un peso de $W_i$ kilos. Para planificar los cortes de la semana, a Diego se le encarga saber, para cada pieza de carne, cuál es el índice de la primera pieza situada a su derecha que sea estrictamente más pesada que ella. Si no existe ninguna pieza más pesada a la derecha, se debe responder -1.

Pista (Haz clic para desplegar)

Una aproximación con dos bucles anidados requiere $O(N^2)$, lo cual dará TLE. Si recorremos la fila de carne de derecha a izquierda manteniendo una Monotonic Stack decreciente con los índices de los chuletones más pesados vistos hasta el momento, ¿cómo podemos resolver cada consulta en $O(1)$ amortizado?

ENLACE AL PROBLEMA
Ponte a prueba antes de desplegar el código: Resolver El Frigorífico de Diego Provencio en Codeforces
Ver Solución (C++, Python, Java)
#include <bits/stdc++.h>
#include <iostream>
#include <vector>
#include <stack>
using namespace std;

int main() {
    // Optimización de la entrada y salida para evitar TLE
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int cantidad_piezas;
    if (!(cin >> cantidad_piezas)) return 0;

    vector<long long> peso_carne(cantidad_piezas);
    for (int i = 0; i < cantidad_piezas; i++) {
        cin >> peso_carne[i];
    }

    // Array para almacenar la respuesta (índice 1-based del siguiente más pesado)
    vector<int> respuesta(cantidad_piezas, -1);
    
    // Pila monótona que almacenará los ÍNDICES de las piezas
    stack<int> pila_indices;

    // Recorremos la fila de carnes de derecha a izquierda
    for (int i = cantidad_piezas - 1; i >= 0; i--) {
        // Eliminamos de la pila las piezas a la derecha que sean
        // de peso menor o igual al de la pieza actual
        while (!pila_indices.empty() && peso_carne[pila_indices.top()] <= peso_carne[i]) {
            pila_indices.pop();
        }

        // Si la pila no está vacía, el tope es la primera pieza más pesada a la derecha
        if (!pila_indices.empty()) {
            respuesta[i] = pila_indices.top() + 1; // Convertimos a indexación 1-based
        }

        // Apilamos el índice de la pieza actual
        pila_indices.push(i);
    }

    // Imprimimos el resultado final separado por espacios
    for (int i = 0; i < cantidad_piezas; i++) {
        cout << respuesta[i] << (i == cantidad_piezas - 1 ? "" : " ");
    }
    cout << "\n";

    return 0;
}
import sys

def resolver():
    # Lectura rápida de todos los datos desde sys.stdin
    entrada = sys.stdin.read().split()
    if not entrada:
        return

    cantidad_piezas = int(entrada[0])
    pesos_carne = [int(x) for x in entrada[1:cantidad_piezas + 1]]

    # Inicializamos las respuestas con -1 por defecto
    respuesta = [-1] * cantidad_piezas
    
    # Pila monótona para guardar los índices
    pila_indices = []

    # Recorremos la lista de derecha a izquierda
    for i in range(cantidad_piezas - 1, -1, -1):
        peso_actual = pesos_carne[i]

        # Limpiamos los elementos de la derecha que no sean estrictamente mayores
        while pila_indices and pesos_carne[pila_indices[-1]] <= peso_actual:
            pila_indices.pop()

        # Si queda algún elemento en la pila, ese es el más cercano y más pesado
        if pila_indices:
            respuesta[i] = pila_indices[-1] + 1  # 1-based index

        # Guardamos el índice actual en la pila
        pila_indices.append(i)

    # Imprimimos la lista de respuestas separada por espacios
    print(*(respuesta))

if __name__ == "__main__":
    resolver()
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.StringTokenizer;
import java.util.ArrayDeque;
import java.util.Arrays;

public class Main {
    public static void main(String[] args) throws IOException {
        // Entrada rápida usando BufferedReader
        BufferedReader lector = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer tokens = new StringTokenizer(lector.readLine());

        if (!tokens.hasMoreTokens()) return;
        int cantidadPiezas = Integer.parseInt(tokens.nextToken());

        long[] pesosCarne = new long[cantidadPiezas];
        tokens = new StringTokenizer(lector.readLine());
        for (int i = 0; i < cantidadPiezas; i++) {
            pesosCarne[i] = Long.parseLong(tokens.nextToken());
        }

        int[] respuesta = new int[cantidadPiezas];
        Arrays.fill(respuesta, -1);

        // Usamos ArrayDeque como pila (más rápido que java.util.Stack)
        ArrayDeque<Integer> pilaIndices = new ArrayDeque<>();

        // Procesa la fila de carnes de derecha a izquierda
        for (int i = cantidadPiezas - 1; i >= 0; i--) {
            while (!pilaIndices.isEmpty() && pesosCarne[pilaIndices.peek()] <= pesosCarne[i]) {
                pilaIndices.pop();
            }

            if (!pilaIndices.isEmpty()) {
                respuesta[i] = pilaIndices.peek() + 1; // Convertimos a 1-based index
            }

            pilaIndices.push(i);
        }

        // Salida rápida con PrintWriter
        PrintWriter escritor = new PrintWriter(System.out);
        for (int i = 0; i < cantidadPiezas; i++) {
            escritor.print(respuesta[i] + (i == cantidadPiezas - 1 ? "" : " "));
        }
        escritor.println();
        escritor.flush();
    }
}

Ejemplo 2 (Avanzado): El Cartel Publicitario de la ETSINF

La Delegación de Alumnos quiere colocar un cartel publicitario rectangular gigante en la fachada de la ETSINF. La pared está formada por una hilera contigua de $N$ columnas verticalmente alineadas de ancho $1$, donde la $i$-ésima columna tiene una altura de $H_i$.

El cartel debe ser completamente rectangular y encajar dentro del área delimitada por las columnas (sin sobresalir por arriba ni por los lados). Tu objetivo es calcular el área máxima posible que puede tener dicho cartel.

Pista y Estrategia (Haz clic para desplegar)

Para cada columna $i$ con altura $H_i$, si consideramos que $H_i$ sea la **altura máxima fija del cartel**, ¿hasta dónde puede extenderse el cartel hacia la izquierda y hacia la derecha antes de chocar con una columna estrictamente más baja que $H_i$?

Usa **dos pases con Monotonic Stack**:

  • Un array L[i] que guarde el primer índice a la izquierda con una altura $ < H_i$.
  • Un array R[i] que guarde el primer índice a la derecha con una altura $ < H_i$.
El ancho máximo usando la altura $H_i$ será $(R[i] - L[i] - 1)$, y el área correspondiente será $H_i \times (R[i] - L[i] - 1)$. ¡Encuentra el máximo de todos los $i$ en tiempo $O(N)$!

ENLACE AL PROBLEMA
Demuestra tu nivel resolviendolo aquí: Resolver El Cartel Publicitario de la ETSINF en Codeforces
Ver Solución (C++, Python, Java)
#include <bits/stdc++.h>
#include <iostream>
#include <vector>
#include <stack>
using namespace std;

int main() {
    // Optimización de la I/O para evitar TLE en competiciones
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int num_columnas;
    if (!(cin >> num_columnas)) return 0;

    vector<long long> altura_columna(num_columnas);
    for (int i = 0; i < num_columnas; i++) {
        cin >> altura_columna[i];
    }

    // L[i] guarda el índice de la primera columna a la IZQUIERDA estrictamente más baja que la i-ésima
    // R[i] guarda el índice de la primera columna a la DERECHA estrictamente más baja que la i-ésima
    vector<int> limite_izquierdo(num_columnas);
    vector<int> limite_derecho(num_columnas);
    
    stack<int> pila_indices;

    // 1. Calculamos el límite izquierdo para cada columna
    for (int i = 0; i < num_columnas; i++) {
        while (!pila_indices.empty() && altura_columna[pila_indices.top()] >= altura_columna[i]) {
            pila_indices.pop();
        }
        // Si la pila está vacía, el cartel se puede extender hasta el inicio (-1)
        limite_izquierdo[i] = pila_indices.empty() ? -1 : pila_indices.top();
        pila_indices.push(i);
    }

    // Vaciamos la pila para volver a utilizarla
    while (!pila_indices.empty()) {
        pila_indices.pop();
    }

    // 2. Calculamos el límite derecho para cada columna
    for (int i = num_columnas - 1; i >= 0; i--) {
        while (!pila_indices.empty() && altura_columna[pila_indices.top()] >= altura_columna[i]) {
            pila_indices.pop();
        }
        // Si la pila está vacía, el cartel se puede extender hasta el final (num_columnas)
        limite_derecho[i] = pila_indices.empty() ? num_columnas : pila_indices.top();
        pila_indices.push(i);
    }

    // 3. Evaluamos el área máxima considerando la altura de cada columna como la fija del cartel
    long long area_maxima = 0;
    for (int i = 0; i < num_columnas; i++) {
        long long ancho_cartel = limite_derecho[i] - limite_izquierdo[i] - 1;
        long long area_actual = altura_columna[i] * ancho_cartel;
        area_maxima = max(area_maxima, area_actual);
    }

    cout << area_maxima << "\n";

    return 0;
}
import sys

def resolver():
    entrada = sys.stdin.read().split()
    if not entrada:
        return

    num_columnas = int(entrada[0])
    altura_columna = [int(x) for x in entrada[1:num_columnas + 1]]

    limite_izquierdo = [0] * num_columnas
    limite_derecho = [0] * num_columnas
    pila_indices = []

    # 1. Calculamos el límite izquierdo para cada columna
    for i in range(num_columnas):
        while pila_indices and altura_columna[pila_indices[-1]] >= altura_columna[i]:
            pila_indices.pop()
        
        limite_izquierdo[i] = pila_indices[-1] if pila_indices else -1
        pila_indices.append(i)

    # Vaciamos la pila
    pila_indices.clear()

    # 2. Calculamos el límite derecho para cada columna
    for i in range(num_columnas - 1, -1, -1):
        while pila_indices and altura_columna[pila_indices[-1]] >= altura_columna[i]:
            pila_indices.pop()
        
        limite_derecho[i] = pila_indices[-1] if pila_indices else num_columnas
        pila_indices.append(i)

    # 3. Calculamos la máxima área posible
    area_maxima = 0
    for i in range(num_columnas):
        ancho_cartel = limite_derecho[i] - limite_izquierdo[i] - 1
        area_actual = altura_columna[i] * ancho_cartel
        if area_actual > area_maxima:
            area_maxima = area_actual

    print(area_maxima)

if __name__ == "__main__":
    resolver()
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.StringTokenizer;
import java.util.ArrayDeque;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader lector = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer tokens = new StringTokenizer(lector.readLine());

        if (!tokens.hasMoreTokens()) return;
        int numColumnas = Integer.parseInt(tokens.nextToken());

        long[] alturaColumna = new long[numColumnas];
        tokens = new StringTokenizer(lector.readLine());
        for (int i = 0; i < numColumnas; i++) {
            alturaColumna[i] = Long.parseLong(tokens.nextToken());
        }

        int[] limiteIzquierdo = new int[numColumnas];
        int[] limiteDerecho = new int[numColumnas];
        ArrayDeque<Integer> pilaIndices = new ArrayDeque<>();

        // 1. Calculamos el límite izquierdo para cada columna
        for (int i = 0; i < numColumnas; i++) {
            while (!pilaIndices.isEmpty() && alturaColumna[pilaIndices.peek()] >= alturaColumna[i]) {
                pilaIndices.pop();
            }
            limiteIzquierdo[i] = pilaIndices.isEmpty() ? -1 : pilaIndices.peek();
            pilaIndices.push(i);
        }

        pilaIndices.clear();

        // 2. Calculamos el límite derecho para cada columna
        for (int i = numColumnas - 1; i >= 0; i--) {
            while (!pilaIndices.isEmpty() && alturaColumna[pilaIndices.peek()] >= alturaColumna[i]) {
                pilaIndices.pop();
            }
            limiteDerecho[i] = pilaIndices.isEmpty() ? numColumnas : pilaIndices.peek();
            pilaIndices.push(i);
        }

        // 3. Buscamos el área máxima
        long areaMaxima = 0;
        for (int i = 0; i < numColumnas; i++) {
            long anchoCartel = limiteDerecho[i] - limiteIzquierdo[i] - 1;
            long areaActual = alturaColumna[i] * anchoCartel;
            areaMaxima = Math.max(areaMaxima, areaActual);
        }

        PrintWriter escritor = new PrintWriter(System.out);
        escritor.println(areaMaxima);
        escritor.flush();
    }
}

2. Contenedores Asociativos (Búsqueda y Mapeo)

A diferencia de los vectores o arrays (donde accedemos por un índice numérico $0, 1, 2...$), los contenedores asociativos nos permiten almacenar elementos y comprobar si un valor existe en tiempo récord sin tener que recorrer toda la lista de principio a fin.

Conjuntos: std::set vs std::unordered_set

Ambas estructuras sirven para lo mismo: guardar elementos únicos (sin duplicados) y responder al instante a la pregunta "¿está este elemento guardado aquí?". Sin embargo, por dentro funcionan de manera totalmente distinta:

1. std::set (Conjunto Ordenado)

Imagina una agenda telefónica perfectamente ordenada alfabéticamente. Cada vez que añades un nombre nuevo, lo colocas en el sitio exacto que le corresponde para no perder el orden.

2. std::unordered_set (Conjunto no ordenado / Hash)

Imagina un guardarropa con percheros numerados con una regla matemática. Cuando llegas con un objeto, aplicas una fórmula mágica (una función Hash) a partir de su valor que te dice directamente en qué perchero exacto debes colgarlo, sin importar el resto de objetos.

Estructura Estructura interna Complejidad ¿Mantiene Orden?
std::set Árbol balanceado $O(\log N)$ ✅ Sí (creciente)
std::unordered_set Tabla Hash $O(1)$ promedio ❌ No

El peligro de los Anti-Hash Tests en Codeforces

En plataformas como Codeforces, las pruebas son públicas durante la fase de hacks. Un rival puede generar un test que fuerce colisiones masivas en la función de hash por defecto, degradando la complejidad de $O(1)$ a $O(N)$ y causando TLE. Para evitar esto, se recomienda usar semillas aleatorias o estructuras de Hash seguras:

struct custom_hash {
    static uint64_t splitmix64(uint64_t x) {
        x += 0x9e3779b97f4a7c15;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9;
        x = (x ^ (x >> 27)) * 0x94d049bb133111eb;
        return x ^ (x >> 31);
    }

    size_t operator()(uint64_t x) const {
        static const uint64_t FIXED_RANDOM = chrono::steady_clock::now().time_since_epoch().count();
        return splitmix64(x + FIXED_RANDOM);
    }
};

// Uso anti-hack seguro:
unordered_map<long long, int, custom_hash> safe_map;
import random

# En Python dict/set son vulnerables a hacks de colisión si se conocen los valores.
# Solución anti-hack: aplicar un XOR con un entero aleatorio único al insertar/consultar.
RANDOM_KEY = random.randint(1, 1 << 60)

class AntiHackDict:
    def __init__(self):
        self.d = {}

    def insert(self, key, value):
        self.d[key ^ RANDOM_KEY] = value

    def get(self, key, default=None):
        return self.d.get(key ^ RANDOM_KEY, default)
import java.util.*;

// En Java HashMap usa Árboles Rojo-Negro en cubetas con muchas colisiones (O(log N) peor caso),
// pero mezclar la clave con un entero aleatorio garantiza inmunidad total:
public class SafeHash {
    private static final long RANDOM_SEED = new Random().nextLong();
    private Map safeMap = new HashMap<>();

    public void put(long key, int val) {
        safeMap.put(key ^ RANDOM_SEED, val);
    }

    public Integer get(long key) {
        return safeMap.get(key ^ RANDOM_SEED);
    }
}

3. Conjuntos Ordenados: set y multiset

Un std::set mantiene elementos únicos estrictamente ordenados mediante un árbol auto-balanceado (Rojo-Negro). Su variante std::multiset admite elementos duplicados manteniendo la misma propiedad de ordenamiento automático.

std::set<int> st;
st.insert(5);              // Inserción en O(log N)
st.insert(2);
st.erase(5);               // Eliminación en O(log N)

// Consultas de Mínimo y Máximo en O(1) vía iteradores:
int min_val = *st.begin(); 
int max_val = *st.rbegin();

// Búsquedas binarias integradas en O(log N):
auto it1 = st.lower_bound(3); // Primer elemento >= 3
auto it2 = st.upper_bound(3); // Primer elemento > 3
import bisect

# En Python la librería estándar no incluye un set/multiset ordenado.
# Para CP se suele mantener una lista ordenada combinada con 'bisect':
st = [2, 5]
bisect.insort(st, 10)       # Inserción manteniendo el orden O(N)
st.remove(5)                # Eliminación O(N)

min_val = st[0]             # Mínimo O(1)
max_val = st[-1]            # Máximo O(1)

# Búsqueda binaria integrada en O(log N) con la librería estándar:
idx = bisect.bisect_left(st, 3)  # Índice del primer elemento >= 3
TreeSet<Integer> st = new TreeSet<>();
st.add(5);                  // Inserción en O(log N)
st.add(2);
st.remove(5);               // Eliminación en O(log N)

int minVal = st.first();    // Mínimo en O(log N) / O(1)
int maxVal = st.last();     // Máximo en O(log N) / O(1)

// Búsqueda binaria integrada en O(log N):
Integer lower = st.ceiling(3); // Primer elemento >= 3
Integer upper = st.higher(3);  // Primer elemento > 3

⚠️ Trampa mortal con multiset::erase() en C++

En C++, llamar a ms.erase(val) pasando directamente un valor eliminará TODAS las instancias de ese valor presentes en el multiset. Si tu intención es eliminar únicamente una sola instancia del elemento, debes pasarle un iterador directo obtenido previamente con find():

std::multiset<int> ms = {3, 3, 3, 5};

// PELIGRO: Borra TODOS los '3' del multiset
// ms.erase(3); -> ¡El multiset queda solo con {5}!

// FORMA CORRECTA: Borra SOLO UNA instancia del '3'
auto it = ms.find(3);
if (it != ms.end()) {
    ms.erase(it); // Se borra solo un '3', quedan {3, 3, 5}
}
import bisect

# Simulamos el comportamiento de multiset en Python con una lista ordenada y bisect
ms = [3, 3, 3, 5]

# Para borrar únicamente UNA instancia del valor:
val = 3
pos = bisect.bisect_left(ms, val)  # Busca la primera aparición de 3 en O(log N)

if pos < len(ms) and ms[pos] == val:
    del ms[pos]  # Borra solo un 3. Quedan [3, 3, 5]
import java.util.*;

// Java no posee un MultiSet integrado en la librería estándar.
// La técnica estándar en CP es combinar TreeMap:
TreeMap<Integer, Integer> multiset = new TreeMap<>();

void add(int x) {
    multiset.put(x, multiset.getOrDefault(x, 0) + 1);
}

void removeOne(int x) {
    if (!multiset.containsKey(x)) return;
    int count = multiset.get(x);
    if (count == 1) multiset.remove(x);
    else multiset.put(x, count - 1);
}

Ejemplo Práctico: El Reparto de Merchandising de CP-UPV

En el último evento de CP-UPV se han preparado $N$ camisetas de regalo. Cada camiseta $i$ tiene un nivel de exclusividad o talla $P_i$. Hay $M$ participantes en la fila para recoger su obsequio. El $j$-ésimo participante tiene un nivel de prioridad máximo de $T_j$ y desea llevarse la camiseta con la mayor exclusividad posible que no supere su límite $T_j$.

Cada camiseta solo se puede entregar a un único participante. Si un participante no encuentra ninguna camiseta cuyo valor sea menor o igual a su límite $T_j$, se va tristemente con las manos vacías y debemos imprimir -1. Para cada participante, indica el valor de la camiseta que consigue llevarse.

💡 Pista y Estrategia (Haz clic para desplegar)

Como varias camisetas pueden tener el mismo valor y necesitamos mantener el inventario ordenado a medida que regalamos prendas, la estructura ideal es un std::multiset.

Para un límite $T$, la función upper_bound(T) nos da el primer elemento estrictamente mayor que $T$. Por lo tanto, el elemento inmediatamente anterior a este iterador (decrementándolo con --it) será **el valor máximo $\le T$**.

Una vez entregada la camiseta, recuerda eliminar **solo esa prenda concreta** usando el iterador ms.erase(it) para no borrar las demás camisetas del mismo valor del inventario.

ENLACE AL PROBLEMA
Ponte a prueba antes de desplegar el código: Resolver El Reparto de Merchandising en Codeforces
Ver Solución (C++, Python, Java)
#include <bits/stdc++.h>
using namespace std;

int main() {
    // Optimización de I/O para CP
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int num_camisetas, num_participantes;
    if (!(cin >> num_camisetas >> num_participantes)) return 0;

    multiset<int> inventario_camisetas;
    for (int i = 0; i < num_camisetas; i++) {
        int valor;
        cin >> valor;
        inventario_camisetas.insert(valor);
    }

    for (int i = 0; i < num_participantes; i++) {
        int limite_participante;
        cin >> limite_participante;

        // upper_bound nos da el primer elemento > limite_participante
        auto it = inventario_camisetas.upper_bound(limite_participante);

        if (it == inventario_camisetas.begin()) {
            // Si el iterador está al inicio, no hay camisetas <= limite
            cout << -1 << "\n";
        } else {
            // Retrocedemos un paso para obtener el valor máximo <= limite
            --it;
            cout << *it << "\n";
            
            // ¡IMPORTANTE! Eliminamos por ITERADOR para borrar solo UNA camiseta
            inventario_camisetas.erase(it);
        }
    }

    return 0;
}
import sys
import bisect

def resolver():
    entrada = sys.stdin.read().split()
    if not entrada:
        return

    num_camisetas = int(entrada[0])
    num_participantes = int(entrada[1])

    idx = 2
    # Leemos e introducimos las camisetas en una lista estándar ordenada
    inventario = [int(x) for x in entrada[idx : idx + num_camisetas]]
    inventario.sort()
    idx += num_camisetas

    salida = []
    for _ in range(num_participantes):
        limite = int(entrada[idx])
        idx += 1

        # bisect_right nos da la posición del primer elemento > limite
        i = bisect.bisect_right(inventario, limite)

        if i == 0:
            salida.append("-1")
        else:
            # El elemento en i - 1 es el valor máximo <= limite
            valor_elegido = inventario[i - 1]
            salida.append(str(valor_elegido))
            # Eliminamos esa camiseta concreta de la lista
            del inventario[i - 1]

    print("\n".join(salida))

if __name__ == "__main__":
    resolver()
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.StringTokenizer;
import java.util.TreeMap;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader lector = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer tokens = new StringTokenizer(lector.readLine());

        if (!tokens.hasMoreTokens()) return;
        int numCamisetas = Integer.parseInt(tokens.nextToken());
        int numParticipantes = Integer.parseInt(tokens.nextToken());

        // Simulamos un multiset en Java usando TreeMap
        TreeMap<Integer, Integer> inventarioCamisetas = new TreeMap<>();

        tokens = new StringTokenizer(lector.readLine());
        for (int i = 0; i < numCamisetas; i++) {
            int valor = Integer.parseInt(tokens.nextToken());
            inventarioCamisetas.put(valor, inventarioCamisetas.getOrDefault(valor, 0) + 1);
        }

        PrintWriter escritor = new PrintWriter(System.out);
        tokens = new StringTokenizer(lector.readLine());

        for (int i = 0; i < numParticipantes; i++) {
            int limite = Integer.parseInt(tokens.nextToken());

            // floorKey(limite) devuelve la clave más grande que sea <= limite
            Integer valorElegido = inventarioCamisetas.floorKey(limite);

            if (valorElegido == null) {
                escritor.println(-1);
            } else {
                escritor.println(valorElegido);
                
                // Reducimos el stock de esa camiseta en 1
                int cantidad = inventarioCamisetas.get(valorElegido);
                if (cantidad == 1) {
                    inventarioCamisetas.remove(valorElegido);
                } else {
                    inventarioCamisetas.put(valorElegido, cantidad - 1);
                }
            }
        }

        escritor.flush();
    }
}

4. Colas de Prioridad (Heaps)

Una Cola de Prioridad (o Heap) es una estructura de datos que procesa los elementos no en el orden en que llegan, sino en función de su prioridad. A diferencia de una cola tradicional (FIFO) donde el primero en entrar es el primero en salir, aquí el elemento con mayor (o menor) prioridad siempre está arriba del todo.

La analogía del Hospital de Urgencias:

Piensa en la sala de espera de un hospital. No se atiende a los pacientes por estricto orden de llegada: si alguien entra con una dolencia leve y minutos después llega un paciente grave, el sistema de triaje reorganiza inmediatamente la prioridad para que el paciente crítico pase primero. Un Heap hace exactamente esto de forma eficiente en segundo plano.

Estructura interna y Tipos de Heap:

Internamente se implementa como un Árbol Binario Completo alojado sobre un array contiguo, lo que garantiza un acceso al elemento prioritario en tiempo constante $O(1)$ y ajustes logarítmicos $O(\log N)$ al insertar o extraer elementos.

¡Atención al comportamiento por defecto según el lenguaje!

Un error muy común en programación competitiva es asumir que todos los lenguajes usan la misma prioridad por defecto:

  • En C++ (std::priority_queue), la prioridad por defecto es un Max-Heap (saca primero los valores más grandes).
  • En Python (heapq) y Java (PriorityQueue), la prioridad por defecto es un Min-Heap (saca primero los valores más pequeños).

#include <bits/stdc++.h;>

// 1. Max-Heap por defecto (mayor valor arriba)
std::priority_queue<int> max_heap;
max_heap.push(10);               // Inserción en O(log N)
max_heap.push(5);
int top_val = max_heap.top();    // Consulta del máximo en O(1) -> 10
max_heap.pop();                  // Elimina el tope en O(log N)

// 2. Min-Heap (menor valor arriba)
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;
min_heap.push(10);
min_heap.push(5);
int min_val = min_heap.top();    // Consulta del mínimo en O(1) -> 5
import heapq

# 1. Min-Heap por defecto en Python
min_heap = []
heapq.heappush(min_heap, 10)     # Inserción en O(log N)
heapq.heappush(min_heap, 5)
min_val = min_heap[0]            # Consulta del mínimo en O(1) -> 5
val = heapq.heappop(min_heap)    # Extrae el mínimo en O(log N)

# 2. Max-Heap (Multiplicando valores por -1)
max_heap = []
heapq.heappush(max_heap, -10)    # Al guardar -10, -10 < -5 (queda arriba)
heapq.heappush(max_heap, -5)
max_val = -heapq.heappop(max_heap) # Recuperamos invirtiendo el signo -> 10
import java.util.PriorityQueue;
import java.util.Collections;

// 1. Min-Heap por defecto
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.add(10);                 // Inserción en O(log N)
minHeap.add(5);
int minVal = minHeap.peek();     // Consulta del mínimo en O(1) -> 5
minHeap.poll();                  // Extrae el mínimo en O(log N)

// 2. Max-Heap
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
maxHeap.add(10);
maxHeap.add(5);
int maxVal = maxHeap.peek();     // Consulta del máximo en O(1) -> 10
Operación Descripción Complejidad
Inserción (Push/Add) Inserta un elemento y reajusta la propiedad del árbol. $O(\log N)$
Consulta Tope (Top/Peek) Accede al elemento prioritario (raíz) sin eliminarlo. $O(1)$
Eliminación Tope (Pop/Poll) Elimina la raíz y reestructura el árbol. $O(\log N)$

Ejemplo Práctico: Las Paellas de CP-UPV

Para la gran fiesta de la competición de Paellas de CP-UPV, los organizadores han preparado $N$ recipientes pequeños con distintas cantidades de arroz $A_1, A_2, \dots, A_N$ en gramos. Como las paellas deben cocinarse en paelleras grandes, es necesario ir combinando los recipientes de dos en dos.

Fusionar dos recipientes que contienen $X$ y $Y$ gramos requiere una paellera de tamaño $X + Y$ y conlleva un coste energético de exactamente $X + Y$ julios, generando un nuevo recipiente de dicho tamaño. Este proceso se repite sucesivamente hasta unificar todo el arroz en un único recipiente. Tu objetivo es calcular el coste energético total mínimo para lograrlo.

Pista y Estrategia: (Haz clic para desplegar)

Para minimizar la suma de costes acumulados, la estrategia voraz (greedy) óptima consiste en fusionar siempre los dos recipientes más pequeños disponibles en cada paso.

Utiliza un Min-Heap (o priority_queue de mínimos):

  1. Inserta las $N$ cantidades iniciales en la cola de prioridad.
  2. Mientras la cola tenga más de un elemento, extrae los dos menores ($A$ y $B$), acumula su suma $(A + B)$ al coste total y vuelve a insertar la suma en el Min-Heap.
Al procesar cada paso en $O(\log N)$, la complejidad total será de $O(N \log N)$.

ENLACE AL PROBLEMA
Ponte a prueba antes de desplegar el código: Resolver Las Paellas de CP-UPV en Codeforces
Ver Solución (C++, Python, Java)
#include <bits/stdc++.h>
using namespace std;

int main() {
    // Optimización de I/O
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    if (!(cin >> n)) return 0;

    // Min-Heap para obtener siempre los dos recipientes más pequeños
    priority_queue<long long, vector<long long>, greater<long long>> min_heap;

    for (int i = 0; i < n; i++) {
        long long arroz;
        cin >> arroz;
        min_heap.push(arroz);
    }

    long long coste_total = 0;

    // Mientras quede más de un recipiente por fusionar
    while (min_heap.size() > 1) {
        long long primero = min_heap.top();
        min_heap.pop();

        long long segundo = min_heap.top();
        min_heap.pop();

        long long suma = primero + segundo;
        coste_total += suma;

        // Devolvemos la mezcla al Min-Heap
        min_heap.push(suma);
    }

    cout << coste_total << "\n";

    return 0;
}
import sys
import heapq

def resolver():
    entrada = sys.stdin.read().split()
    if not entrada:
        return

    n = int(entrada[0])
    
    # heapq en Python es nativamente un Min-Heap
    arroz = [int(x) for x in entrada[1:n + 1]]
    heapq.heapify(arroz)

    coste_total = 0

    # Mientras quede más de un recipiente
    while len(arroz) > 1:
        primero = heapq.heappop(arroz)
        segundo = heapq.heappop(arroz)

        suma = primero + segundo
        coste_total += suma

        # Insertamos de nuevo la mezcla en el Heap
        heapq.heappush(arroz, suma)

    print(coste_total)

if __name__ == "__main__":
    resolver()
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.StringTokenizer;
import java.util.PriorityQueue;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader lector = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer tokens = new StringTokenizer(lector.readLine());

        if (!tokens.hasMoreTokens()) return;
        int n = Integer.parseInt(tokens.nextToken());

        // PriorityQueue en Java es por defecto un Min-Heap
        PriorityQueue<Long> minHeap = new PriorityQueue<>();

        tokens = new StringTokenizer(lector.readLine());
        for (int i = 0; i < n; i++) {
            minHeap.add(Long.parseLong(tokens.nextToken()));
        }

        long costeTotal = 0;

        while (minHeap.size() > 1) {
            long primero = minHeap.poll();
            long segundo = minHeap.poll();

            long suma = primero + segundo;
            costeTotal += suma;

            minHeap.add(suma);
        }

        PrintWriter escritor = new PrintWriter(System.out);
        escritor.println(costeTotal);
        escritor.flush();
    }
}

5. Uniones de Conjuntos Disjuntos (DSU / Union-Find)

El Disjoint Set Union (DSU) o Union-Find es una de las estructuras más elegantes y eficientes de la informática. Nos permite gestionar una colección de elementos divididos en varios conjuntos disjuntos (sin solapamiento) y responder a preguntas de conectividad en tiempo prácticamente instantáneo.

La analogía de las Redes Sociales y Grupos de Amigos:

Imagina un grupo de personas donde inicialmente nadie se conoce ($N$ conjuntos de $1$ persona). Cada persona elige a un "representante" o líder de su grupo (al principio, cada uno es su propio líder).

Las dos operaciones fundamentales

DSU mantiene un array de padres parent[i] que representa una estructura de bosque de árboles, soportando dos métodos básicos:

Las dos optimizaciones mágicas: $\mathcal{O}(\alpha(N))$

Sin optimizar, la cadena de padres en find(i) podría degenerar en una lista enlazada de altura $N$, haciendo las consultas en $\mathcal{O}(N)$. Para evitarlo aplicamos dos técnicas:

  1. Compresión de Caminos (Path Compression): Cada vez que llamamos a find(i), hacemos que todos los nodos visitados en el camino apunten directamente a la raíz. Las futuras búsquedas serán $\mathcal{O}(1)$.
  2. Unión por Tamaño/Rango (Union by Size/Rank): Al unir dos conjuntos, siempre enganchamos el árbol más pequeño debajo de la raíz del árbol más grande, evitando que el árbol crezca en profundidad de forma innecesaria.

Al combinar ambas, la complejidad amortizada por operación pasa a ser $\mathcal{O}(\alpha(N))$, donde $\alpha$ es la Función Inversa de Ackermann. En la práctica, $\alpha(N) \le 4$ para cualquier $N$ en el universo, ¡haciendo que el DSU funcione en tiempo prácticamente constante $\mathcal{O}(1)$!

#include <bits/stdc++.h>

struct DSU {
    std::vector<int> parent;
    std::vector<int> sz;

    DSU(int n) {
        parent.resize(n + 1);
        std::iota(parent.begin(), parent.end(), 0); // parent[i] = i
        sz.assign(n + 1, 1);                       // tamaño inicial de cada conjunto = 1
    }

    // Compresión de caminos (Path Compression)
    int find(int i) {
        if (parent[i] == i) return i;
        return parent[i] = find(parent[i]);
    }

    // Unión por tamaño (Union by Size)
    bool unite(int i, int j) {
        int root_i = find(i);
        int root_j = find(j);
        
        if (root_i == root_j) return false; // Ya están en el mismo conjunto

        // Colocamos el árbol más pequeño bajo el más grande
        if (sz[root_i] < sz[root_j]) std::swap(root_i, root_j);
        parent[root_j] = root_i;
        sz[root_i] += sz[root_j];
        return true;
    }

    bool same(int i, int j) {
        return find(i) == find(j);
    }
};
class DSU:
    def __init__(self, n):
        self.parent = list(range(n + 1))
        self.sz = [1] * (n + 1)

    # Compresión de caminos (Path Compression)
    def find(self, i):
        if self.parent[i] == i:
            return i
        self.parent[i] = self.find(self.parent[i])
        return self.parent[i]

    # Unión por tamaño (Union by Size)
    def unite(self, i, j):
        root_i = self.find(i)
        root_j = self.find(j)

        if root_i == root_j:
            return False

        if self.sz[root_i] < self.sz[root_j]:
            root_i, root_j = root_j, root_i

        self.parent[root_j] = root_i
        self.sz[root_i] += self.sz[root_j]
        return True

    def same(self, i, j):
        return self.find(i) == self.find(j)
class DSU {
    int[] parent;
    int[] sz;

    public DSU(int n) {
        parent = new int[n + 1];
        sz = new int[n + 1];
        for (int i = 0; i <= n; i++) {
            parent[i] = i;
            sz[i] = 1;
        }
    }

    // Compresión de caminos (Path Compression)
    public int find(int i) {
        if (parent[i] == i) return i;
        return parent[i] = find(parent[i]);
    }

    // Unión por tamaño (Union by Size)
    public boolean unite(int i, int j) {
        int rootI = find(i);
        int rootJ = find(j);

        if (rootI == rootJ) return false;

        if (sz[rootI] < sz[rootJ]) {
            int temp = rootI; rootI = rootJ; rootJ = temp;
        }

        parent[rootJ] = rootI;
        sz[rootI] += sz[rootJ];
        return true;
    }

    public boolean same(int i, int j) {
        return find(i) == find(j);
    }
}

Ejemplo Práctico: La Red de PCs de CP-UPV

Para la próxima competición de CP-UPV, los organizadores están configurando $N$ ordenadores en el laboratorio. Inicialmente, ningún ordenador está conectado con otro.

Se van a procesar $Q$ eventos en orden:

Pista y Estrategia: Disjoint Set Union (Haz clic para desplegar)

Este problema requiere conectar componentes dinámicamente y realizar consultas de pertenencia al mismo grupo en tiempo prácticamente instantáneo.

Utilizamos la estructura DSU con Path Compression y Union by Size:

  1. Para eventos del tipo 1 u v, ejecutamos dsu.unite(u, v).
  2. Para eventos del tipo 2 u v, comprobamos si dsu.same(u, v) (es decir, find(u) == find(v)).
Al aplicar ambas optimizaciones, cada consulta se resuelve en tiempo amortizado $\mathcal{O}(\alpha(N))$, permitiendo procesar las $Q$ consultas de forma ultraeficiente.

ENLACE AL PROBLEMA
Ponte a prueba antes de desplegar el código: Resolver La Red de PCs de CP-UPV en Codeforces
Ver Solución (C++, Python, Java)
#include <bits/stdc++-h>
using namespace std;

struct DSU {
    vector<int> parent;
    vector<int> sz;

    DSU(int n) {
        parent.resize(n + 1);
        iota(parent.begin(), parent.end(), 0);
        sz.assign(n + 1, 1);
    }

    int find(int i) {
        if (parent[i] == i) return i;
        return parent[i] = find(parent[i]);
    }

    bool unite(int i, int j) {
        int root_i = find(i);
        int root_j = find(j);
        if (root_i == root_j) return false;

        if (sz[root_i] < sz[root_j]) swap(root_i, root_j);
        parent[root_j] = root_i;
        sz[root_i] += sz[root_j];
        return true;
    }

    bool same(int i, int j) {
        return find(i) == find(j);
    }
};

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    if (!(cin >> n >> q)) return 0;

    DSU dsu(n);

    while (q--) {
        int tipo, u, v;
        cin >> tipo >> u >> v;

        if (tipo == 1) {
            dsu.unite(u, v);
        } else {
            if (dsu.same(u, v)) {
                cout << "YES\n";
            } else {
                cout << "NO\n";
            }
        }
    }

    return 0;
}
import sys

sys.setrecursionlimit(300000)

class DSU:
    def __init__(self, n):
        self.parent = list(range(n + 1))
        self.sz = [1] * (n + 1)

    def find(self, i):
        if self.parent[i] == i:
            return i
        self.parent[i] = self.find(self.parent[i])
        return self.parent[i]

    def unite(self, i, j):
        root_i = self.find(i)
        root_j = self.find(j)
        if root_i == root_j:
            return False

        if self.sz[root_i] < self.sz[root_j]:
            root_i, root_j = root_j, root_i

        self.parent[root_j] = root_i
        self.sz[root_i] += self.sz[root_j]
        return True

    def same(self, i, j):
        return self.find(i) == self.find(j)

def resolver():
    entrada = sys.stdin.read().split()
    if not entrada:
        return

    n = int(entrada[0])
    q = int(entrada[1])

    dsu = DSU(n)
    salida = []

    idx = 2
    for _ in range(q):
        tipo = int(entrada[idx])
        u = int(entrada[idx + 1])
        v = int(entrada[idx + 2])
        idx += 3

        if tipo == 1:
            dsu.unite(u, v)
        else:
            if dsu.same(u, v):
                salida.append("YES")
            else:
                salida.append("NO")

    print("\n".join(salida))

if __name__ == "__main__":
    resolver()
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.StringTokenizer;

public class Main {
    static class DSU {
        int[] parent;
        int[] sz;

        public DSU(int n) {
            parent = new int[n + 1];
            sz = new int[n + 1];
            for (int i = 0; i <= n; i++) {
                parent[i] = i;
                sz[i] = 1;
            }
        }

        public int find(int i) {
            if (parent[i] == i) return i;
            return parent[i] = find(parent[i]);
        }

        public boolean unite(int i, int j) {
            int rootI = find(i);
            int rootJ = find(j);
            if (rootI == rootJ) return false;

            if (sz[rootI] < sz[rootJ]) {
                int temp = rootI; rootI = rootJ; rootJ = temp;
            }

            parent[rootJ] = rootI;
            sz[rootI] += sz[rootJ];
            return true;
        }

        public boolean same(int i, int j) {
            return find(i) == find(j);
        }
    }

    public static void main(String[] args) throws IOException {
        BufferedReader lector = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer tokens;
        
        String linea = lector.readLine();
        if (linea == null) return;
        tokens = new StringTokenizer(linea);

        int n = Integer.parseInt(tokens.nextToken());
        int q = Integer.parseInt(tokens.nextToken());

        DSU dsu = new DSU(n);
        PrintWriter escritor = new PrintWriter(System.out);

        for (int i = 0; i < q; i++) {
            while (tokens == null || !tokens.hasMoreTokens()) {
                tokens = new StringTokenizer(lector.readLine());
            }

            int tipo = Integer.parseInt(tokens.nextToken());
            int u = Integer.parseInt(tokens.nextToken());
            int v = Integer.parseInt(tokens.nextToken());

            if (tipo == 1) {
                dsu.unite(u, v);
            } else {
                if (dsu.same(u, v)) {
                    escritor.println("YES");
                } else {
                    escritor.println("NO");
                }
            }
        }

        escritor.flush();
    }
}

Problemas de práctica

Aplica las estructuras aprendidas completando la serie de ejercicios asignados en el concurso activo de nuestro bootcamp en Codeforces.