Algoritmos¶
Algoritmos sobre arreglos¶
Máximo y mínimo¶
El problema más elemental sobre arreglos es encontrar el valor máximo o mínimo de un conjunto de datos. Se recorre el arreglo una sola vez, manteniendo una variable que guarda el mejor valor visto hasta el momento.
Requisito
El arreglo debe tener al menos un elemento. Inicializar con numeros[0] lanza un IndexError si el arreglo está vacío.
La complejidad es \(O(n)\): cada elemento se visita exactamente una vez.
Índice del máximo
Si se necesita la posición del elemento máximo en lugar de su valor, se itera con range(len(...)) y se guarda el índice:
Funciones de Python
En competencias se puede usar max(lista) y min(lista) directamente.
Conocer la implementación manual es útil cuando el criterio de comparación es más complejo, por ejemplo: encontrar el estudiante con la nota más alta en una lista de tuplas (nombre, nota).
Sumas de prefijos¶
Suponga que tiene un arreglo de n números y debe responder muchas consultas de la forma: ¿cuánto suman los elementos entre el índice i y el índice j?
Si se recorre el arreglo desde i hasta j para cada consulta, la complejidad total es \(O(n \cdot q)\), donde q es el número de consultas.
Las sumas de prefijos permiten responder cada consulta en \(O(1)\), pagando solo un preprocesamiento de \(O(n)\) al inicio.
Construcción¶
Se construye un arreglo auxiliar prefijos donde prefijos[k] almacena la suma de todos los elementos desde el índice 0 hasta el índice k.
| Índice | Valor | Prefijo acumulado |
|---|---|---|
| 0 | 23 | 23 |
| 1 | 14 | 37 |
| 2 | 12 | 49 |
| 3 | 21 | 70 |
| 4 | 45 | 115 |
| 5 | 32 | 147 |
Consulta de rango¶
Para obtener la suma entre los índices i y j se usa la fórmula:
\(\text{suma}(i, j) = \text{prefijos}[j] - \text{prefijos}[i - 1]\)
Ejemplo
Suma del rango [2, 4] sobre [23, 14, 12, 21, 45, 32]:
Caso borde: i = 0
Cuando i = 0, no existe prefijos[-1] (en Python daría el último elemento), así que siempre tratar ese caso por separado, retornando prefijos[j] directamente.
Búsqueda binaria¶
- La búsqueda lineal revisa cada elemento del arreglo uno por uno, con complejidad \(O(n)\).
- La búsqueda binaria aprovecha el hecho de que el arreglo está ordenado para descartar la mitad del espacio en cada comparación, logrando \(O(\log n)\).
Requisito
El arreglo debe estar ordenado de menor a mayor antes de aplicar este algoritmo. Aplicarlo sobre un arreglo desordenado produce resultados incorrectos sin ningún mensaje de error.
Se mantienen dos punteros, izquierdo y derecho, que delimitan la zona donde puede estar el objetivo.
En cada paso se inspecciona el elemento del medio:
- Si es el objetivo, se retorna su índice.
- Si es menor que el objetivo, este solo puede estar a la derecha: se avanza
izquierdo. - Si es mayor, este solo puede estar a la izquierda: se retrocede
derecho.
flowchart TD
A{"izquierdo ≤ derecho"} -- Sí --> B["medio = (izq + der) // 2"]
B --> C{"arreglo[medio] == objetivo"}
C -- Sí --> D["Retornar medio"]
C -- "arreglo[medio] < objetivo" --> E["izquierdo = medio + 1"]
C -- "arreglo[medio] > objetivo" --> F["derecho = medio - 1"]
E --> A
F --> A
A -- No --> G["Retornar -1"]
int busqueda_binaria(vector<int>& arreglo, int objetivo) {
int izquierdo = 0, derecho = (int)arreglo.size() - 1;
while (izquierdo <= derecho) {
int medio = (izquierdo + derecho) / 2;
if (arreglo[medio] == objetivo) {
return medio;
} else if (arreglo[medio] < objetivo) {
izquierdo = medio + 1;
} else {
derecho = medio - 1;
}
}
return -1;
}
Ejemplo de ejecución
Arreglo: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], objetivo: 7
| Paso | izq | der | medio | arreglo[medio] | Acción |
|---|---|---|---|---|---|
| 1 | 0 | 9 | 4 | 5 | izq = 5 |
| 2 | 5 | 9 | 7 | 8 | der = 6 |
| 3 | 5 | 6 | 5 | 6 | izq = 6 |
| 4 | 6 | 6 | 6 | 7 | retorna 6 |
Arreglo: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], objetivo: 11
| Paso | izq | der | medio | arreglo[medio] | Acción |
|---|---|---|---|---|---|
| 1 | 0 | 9 | 4 | 5 | izq = 5 |
| 2 | 5 | 9 | 7 | 8 | izq = 8 |
| 3 | 8 | 9 | 8 | 9 | izq = 9 |
| 4 | 9 | 9 | 9 | 10 | izq = 10 |
| 5 | 10 | 9 | — | — | retorna -1 |
Comparación de complejidad
| Algoritmo | Mejor caso | Peor caso |
|---|---|---|
| Búsqueda lineal | \(O(1)\) | \(O(n)\) |
| Búsqueda binaria | \(O(1)\) | \(O(\log n)\) |
En un arreglo de 1 000 000 elementos, la búsqueda binaria necesita como máximo 20 comparaciones: \(\log_2(1 000 000) \approx 20\).
Algoritmos sobre números¶
Algoritmo de Euclides¶
El máximo común divisor (MCD) de dos números a y b es el número entero más grande que divide a ambos sin dejar residuo.
Requisito
Ambos valores deben ser enteros no negativos. Si ambos son 0, el resultado está indefinido matemáticamente.
El algoritmo de Euclides se define de forma recursiva:
Operación módulo
La operación \(a \bmod b\) (a % b en Python) devuelve el residuo de dividir a entre b.
Por ejemplo, \(17 \bmod 5 = 2\) porque: \(17 = 3 \cdot 5 + 2\).
Esto se repite reduciendo los valores en cada paso hasta que el segundo número sea cero. En este punto, el primero es el MCD.
Ejemplo de ejecución
mcd(48, 18):
| Paso | a | b | a % b |
|---|---|---|---|
| 1 | 48 | 18 | 12 |
| 2 | 18 | 12 | 6 |
| 3 | 12 | 6 | 0 |
| 4 | 6 | 0 | Retorna 6 |
Mínimo común múltiplo
El mínimo común múltiplo (MCM) se obtiene a partir del MCD con la fórmula:
\(\text{mcm}(a, b) = \frac{a \cdot b}{\text{mcd}(a, b)}\)
Complejidad
El algoritmo de Euclides tiene complejidad \(O(\log(\min(a, b)))\). Para números de hasta \(10^{18}\), termina en menos de 90 pasos.
Exponenciación binaria¶
La exponenciación binaria calcula \(a^n\) de forma eficiente en \(O(\log n)\), en lugar de \(O(n)\) que tomaría multiplicar a por sí mismo n veces.
Requisito
El exponente n debe ser un entero no negativo (\(n \geq 0\)). La implementación no soporta exponentes negativos.
La reducción de complejidad se basa en la siguiente observación:
- Si
nes par: \(a^n = \left(a^{\lfloor n/2 \rfloor}\right)^2\) - Si
nes impar: \(a^n = \left(a^{\lfloor n/2 \rfloor}\right)^2 \cdot a\)
En lugar de hacer n multiplicaciones, se hace la mitad del trabajo en cada paso recursivo.
Ejemplo de ejecución
potencia(2, 10):
potencia(2, 10)
1. mitad = potencia(2, 5)
2. mitad = potencia(2, 2)
3. mitad = potencia(2, 1)
4. mitad = potencia(2, 0) = 1
5. 1 * 1 * 2 = 2
6. 2 * 2 = 4
7. 4 * 4 * 2 = 32
8. 32 * 32 = 1024
Solo 4 multiplicaciones en lugar de 10.
Test de primalidad en O(√n)¶
Un número n es primo si sus únicos divisores son 1 y él mismo.
La forma directa de verificarlo sería revisar todos los números desde 2 hasta n - 1, pero eso es \(O(n)\).
Si n tiene un divisor \(d > \sqrt{n}\), entonces \(\frac{n}{d} < \sqrt{n}\), lo que significa que ya hay un divisor más pequeño que \(\sqrt{n}\).
Por lo tanto, basta revisar divisores hasta \(\sqrt{n}\).
Tiene una complejidad temporal de \(O(\sqrt{n})\).
No usar para muchas consultas
Si se necesita saber si miles de números son primos, este test resulta lento, por lo que conviene usar la Criba de Eratóstenes.
Factorización¶
La factorización de un número n consiste en encontrar todos los valores que lo dividen exactamente.
La observación clave es que si d divide a n y \(d \leq \sqrt{n}\), entonces \(n / d\) también es divisor, lo que permite encontrarlos de a pares revisando solo hasta \(\sqrt{n}\), con complejidad \(O(\sqrt{n})\).
Ejemplo para n = 36
\(\sqrt{36} = 6\), por lo tanto se revisan i = 1, 2, 3, 4, 5, 6:
| i | 36 % i | Par (36 // i) |
|---|---|---|
| 1 | 0 | 36 |
| 2 | 0 | 18 |
| 3 | 0 | 12 |
| 4 | 0 | 9 |
| 5 | 1 | — |
| 6 | 0 | 6 (igual a i) |
Resultado: [1, 2, 3, 4, 6, 9, 12, 18, 36]
Factorización prima
Para encontrar los factores primos de n, se divide n mientras sea divisible:
Criba de Eratóstenes¶
La Criba de Eratóstenes genera todos los números primos en el rango \([2, n]\) de forma eficiente. Aplicar el test de primalidad individual a cada número tomaría \(O(n\sqrt{n})\) en total. La Criba de Eratóstenes hace lo mismo en \(O(n \cdot \log(\log(n)))\), que en la práctica es casi lineal.
La idea es comenzar con todos los números marcados como primos y luego, para cada primo encontrado, eliminar todos sus múltiplos.
vector<int> criba(int n) {
vector<bool> es_primo(n + 1, true);
es_primo[0] = es_primo[1] = false;
for (int i = 2; (long long)i * i <= n; i++) {
if (es_primo[i]) {
for (int j = i * i; j <= n; j += i) { // (1)!
es_primo[j] = false;
}
}
}
vector<int> primos;
for (int i = 2; i <= n; i++) {
if (es_primo[i]) primos.push_back(i);
}
return primos;
}
- Se empieza en
i * iporque los múltiplos menores (2i,3i, ...,(i-1)i) ya fueron eliminados por primos anteriores.
def criba(n):
es_primo = [True] * (n + 1)
es_primo[0] = es_primo[1] = False
i = 2
while i * i <= n:
if es_primo[i]:
for j in range(i * i, n + 1, i): # (1)!
es_primo[j] = False
i += 1
return [i for i in range(2, n + 1) if es_primo[i]]
print(criba(30)) # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
- Se empieza en
i * iporque los múltiplos menores (2i,3i, ...,(i-1)i) ya fueron eliminados por primos anteriores.
Visualización para n = 15
Estado inicial: todos marcados como primos.
Complejidad
| Algoritmo | Para encontrar todos los primos ≤ n |
|---|---|
| Test individual \(O(\sqrt{n})\) | \(O(n\sqrt{n})\) |
| Criba de Eratóstenes | \(O(n \cdot \log(\log(n)))\) |
Para \(n = 10^6\), la criba es aproximadamente 300 veces más rápida.