Criba de Eratóstenes
La criba de Eratóstenes (Sieve of Eratosthenes) es un algoritmo para encontrar todos los números primos en un segmento usando operaciones.
El algoritmo es muy simple: al comienzo escribimos todos los números entre 2 y . Marcamos todos los múltiplos propios de 2 (ya que 2 es el menor número primo) como compuestos. Un múltiplo propio de un número es un número mayor que y divisible por . Luego encontramos el siguiente número que no haya sido marcado como compuesto; en este caso es 3. Eso significa que 3 es primo, y marcamos todos los múltiplos propios de 3 como compuestos. El siguiente número no marcado es 5, que es el siguiente número primo, y marcamos todos sus múltiplos propios. Y continuamos este procedimiento hasta haber procesado todos los números de la fila.
En la siguiente imagen se puede ver una visualización del algoritmo para calcular todos los números primos en el rango . Se observa que, con bastante frecuencia, marcamos números como compuestos varias veces.
La idea detrás es esta: Un número es primo si ninguno de los números primos más pequeños lo divide. Como iteramos sobre los números primos en orden, ya marcamos como divisibles todos los números que son divisibles por al menos uno de los números primos. Por lo tanto, si llegamos a una celda y no está marcada, entonces no es divisible por ningún número primo más pequeño y, en consecuencia, tiene que ser primo.
Implementación
int n;
vector<bool> is_prime(n+1, true);
is_prime[0] = is_prime[1] = false;
for (int i = 2; i <= n; i++) {
if (is_prime[i] && (long long)i * i <= n) {
for (int j = i * i; j <= n; j += i)
is_prime[j] = false;
}
}Este código primero marca todos los números excepto el cero y el uno como números primos potenciales, y luego comienza el proceso de cribado de los números compuestos.
Para ello itera sobre todos los números de a .
Si el número actual es un número primo, marca como compuestos todos los números que son múltiplos de , empezando desde .
Esto ya es una optimización respecto de la forma ingenua de implementarlo, y está justificado porque todos los números más pequeños que son múltiplos de necesariamente también tienen un factor primo menor que , así que todos ellos ya fueron cribados antes.
Como puede desbordar fácilmente el tipo int, se hace la verificación adicional usando el tipo long long antes del segundo bucle anidado.
Con esta implementación el algoritmo consume de memoria (obviamente) y realiza operaciones (véase la sección siguiente).
Análisis asintótico
Es simple demostrar un tiempo de ejecución de sin saber nada sobre la distribución de los primos: ignorando la comprobación de is_prime, el bucle interno se ejecuta (a lo sumo) veces para , lo que hace que el número total de operaciones en el bucle interno sea una suma armónica de la forma , que está acotada por .
Demostremos que el tiempo de ejecución del algoritmo es . El algoritmo realizará operaciones por cada primo en el bucle interno. Por lo tanto, necesitamos evaluar la siguiente expresión:
Recordemos dos hechos conocidos.
- La cantidad de números primos menores o iguales que es aproximadamente .
- El -ésimo número primo es aproximadamente igual a (esto se sigue del hecho anterior).
Así podemos escribir la suma de la siguiente forma:
Acá extraímos el primer número primo 2 de la suma, porque en la aproximación es y provoca una división por cero.
Ahora evaluemos esta suma usando la integral de la misma función sobre desde hasta (podemos hacer esa aproximación porque, de hecho, la suma se relaciona con la integral como su aproximación por el método de los rectángulos):
La antiderivada del integrando es . Usando una sustitución y eliminando términos de orden inferior, obtenemos el resultado:
Ahora, volviendo a la suma original, obtenemos su evaluación aproximada:
Se puede encontrar una demostración más rigurosa (que da una evaluación más precisa, exacta salvo factores constantes) en el libro de Hardy & Wright “An Introduction to the Theory of Numbers” (p. 349).
Distintas optimizaciones de la criba de Eratóstenes
La mayor debilidad del algoritmo es que “recorre” la memoria varias veces, manipulando solo elementos individuales. Esto no es muy amigable con la caché. Y por eso, la constante que queda oculta en es comparativamente grande.
Además, la memoria consumida es un cuello de botella para grandes.
Los métodos que se presentan abajo permiten reducir la cantidad de operaciones realizadas, y también acortar de forma notable la memoria consumida.
Cribado hasta la raíz
Obviamente, para encontrar todos los números primos hasta , basta con realizar el cribado solo por los números primos que no superan la raíz de .
int n;
vector<bool> is_prime(n+1, true);
is_prime[0] = is_prime[1] = false;
for (int i = 2; i * i <= n; i++) {
if (is_prime[i]) {
for (int j = i * i; j <= n; j += i)
is_prime[j] = false;
}
}Esa optimización no afecta la complejidad (en efecto, al repetir la demostración de arriba obtenemos la evaluación , que es asintóticamente la misma según las propiedades de los logaritmos), aunque la cantidad de operaciones se reducirá de forma notable.
Cribado solo por los números impares
Como todos los números pares (excepto ) son compuestos, podemos dejar de revisar los números pares por completo. En su lugar, basta con operar solo con números impares.
Primero, esto nos permite reducir a la mitad la memoria necesaria. Segundo, reduce aproximadamente a la mitad la cantidad de operaciones que realiza el algoritmo.
Consumo de memoria y velocidad de las operaciones
Hay que notar que estas dos implementaciones de la criba de Eratóstenes usan bits de memoria al utilizar la estructura de datos vector<bool>.
vector<bool> no es un contenedor regular que almacena una serie de bool (ya que en la mayoría de las arquitecturas de computadora un bool ocupa un byte de memoria).
Es una especialización de vector<T> optimizada en memoria, que solo consume bytes de memoria.
Las arquitecturas de los procesadores modernos trabajan de forma mucho más eficiente con bytes que con bits, ya que normalmente no pueden acceder a los bits de forma directa.
Así que por debajo vector<bool> almacena los bits en un bloque grande de memoria continua, accede a la memoria en bloques de unos pocos bytes, y extrae/asigna los bits con operaciones de bits como máscaras (bit masking) y desplazamientos (bit shifting).
Por eso hay cierto overhead al leer o escribir bits con un vector<bool>, y con bastante frecuencia usar un vector<char> (que usa 1 byte por cada entrada, o sea 8 veces la cantidad de memoria) es más rápido.
Sin embargo, para las implementaciones simples de la criba de Eratóstenes, usar un vector<bool> es más rápido.
Estamos limitados por la velocidad con la que se pueden cargar los datos en la caché, y por lo tanto usar menos memoria da una gran ventaja.
Un benchmark (enlace ) muestra que usar un vector<bool> es entre 1.4x y 1.7x más rápido que usar un vector<char>.
Las mismas consideraciones aplican también a bitset.
Es también una forma eficiente de almacenar bits, similar a vector<bool>, así que solo ocupa bytes de memoria, pero es un poco más lenta al acceder a los elementos.
En el benchmark de arriba bitset rinde un poco peor que vector<bool>.
Otra desventaja de bitset es que hay que conocer el tamaño en tiempo de compilación.
Criba segmentada
De la optimización “cribado hasta la raíz” se sigue que no hace falta mantener todo el arreglo is_prime[1...n] en todo momento.
Para cribar basta con conservar los números primos hasta la raíz de , es decir prime[1... sqrt(n)], partir el rango completo en bloques y cribar cada bloque por separado.
Sea una constante que determina el tamaño del bloque; entonces tenemos bloques en total, y el bloque () contiene los números del segmento . Podemos trabajar los bloques por turnos, es decir, para cada bloque recorremos todos los números primos (de a ) y realizamos el cribado con ellos. Vale la pena notar que hay que modificar un poco la estrategia al manejar los primeros números: primero, todos los números primos de no deberían eliminarse a sí mismos; y segundo, los números y deben marcarse como no primos. Al trabajar sobre el último bloque no hay que olvidar que el último número necesario no necesariamente está al final del bloque.
Como se discutió antes, la implementación típica de la criba de Eratóstenes está limitada por la velocidad con la que se pueden cargar los datos en las cachés de la CPU.
Al partir el rango de números primos potenciales en bloques más chicos, nunca hace falta mantener varios bloques en memoria al mismo tiempo, y todas las operaciones son mucho más amigables con la caché.
Como ya no estamos limitados por las velocidades de la caché, podemos reemplazar el vector<bool> por un vector<char> y ganar un poco más de rendimiento, ya que los procesadores pueden manejar lecturas y escrituras de bytes de forma directa y no necesitan apoyarse en operaciones de bits para extraer bits individuales.
El benchmark (enlace ) muestra que, en esta situación, usar un vector<char> es unas 3x más rápido que usar un vector<bool>.
Una advertencia: esos números pueden variar según la arquitectura, el compilador y los niveles de optimización.
Acá tenemos una implementación que cuenta la cantidad de primos menores o iguales que usando cribado por bloques.
int count_primes(int n) {
const int S = 10000;
vector<int> primes;
int nsqrt = sqrt(n);
vector<char> is_prime(nsqrt + 2, true);
for (int i = 2; i <= nsqrt; i++) {
if (is_prime[i]) {
primes.push_back(i);
for (int j = i * i; j <= nsqrt; j += i)
is_prime[j] = false;
}
}
int result = 0;
vector<char> block(S);
for (int k = 0; k * S <= n; k++) {
fill(block.begin(), block.end(), true);
int start = k * S;
for (int p : primes) {
int start_idx = (start + p - 1) / p;
int j = max(start_idx, p) * p - start;
for (; j < S; j += p)
block[j] = false;
}
if (k == 0)
block[0] = block[1] = false;
for (int i = 0; i < S && start + i <= n; i++) {
if (block[i])
result++;
}
}
return result;
}El tiempo de ejecución del cribado por bloques es el mismo que el de la criba de Eratóstenes regular (salvo que el tamaño de los bloques sea muy chico), pero la memoria necesaria se reduce a y obtenemos mejores resultados de caché. Por otro lado, habrá una división por cada par de un bloque y un número primo de , y eso será bastante peor para tamaños de bloque más chicos. Por lo tanto, es necesario mantener un equilibrio al elegir la constante . Obtuvimos los mejores resultados para tamaños de bloque entre y .
Encontrar primos en un rango
A veces necesitamos encontrar todos los números primos en un rango de tamaño chico (p. ej. ), donde puede ser muy grande (p. ej. ).
Para resolver un problema así, podemos usar la idea de la criba segmentada. Pregeneramos todos los números primos hasta , y usamos esos primos para marcar todos los números compuestos en el segmento .
vector<char> segmentedSieve(long long L, long long R) {
// generar todos los primos hasta sqrt(R)
long long lim = sqrt(R);
vector<char> mark(lim + 1, false);
vector<long long> primes;
for (long long i = 2; i <= lim; ++i) {
if (!mark[i]) {
primes.emplace_back(i);
for (long long j = i * i; j <= lim; j += i)
mark[j] = true;
}
}
vector<char> isPrime(R - L + 1, true);
for (long long i : primes)
for (long long j = max(i * i, (L + i - 1) / i * i); j <= R; j += i)
isPrime[j - L] = false;
if (L == 1)
isPrime[0] = false;
return isPrime;
}La complejidad temporal de este enfoque es .
También es posible no pregenerar todos los números primos:
vector<char> segmentedSieveNoPreGen(long long L, long long R) {
vector<char> isPrime(R - L + 1, true);
long long lim = sqrt(R);
for (long long i = 2; i <= lim; ++i)
for (long long j = max(i * i, (L + i - 1) / i * i); j <= R; j += i)
isPrime[j - L] = false;
if (L == 1)
isPrime[0] = false;
return isPrime;
}Obviamente, la complejidad es peor: . Sin embargo, en la práctica sigue ejecutándose muy rápido.
Modificación de tiempo lineal
Podemos modificar el algoritmo de forma tal que solo tenga complejidad temporal lineal. Este enfoque se describe en el artículo Criba lineal. Sin embargo, este algoritmo también tiene sus propias debilidades.
Problemas de práctica
- Leetcode - Four Divisors
- Leetcode - Count Primes
- SPOJ - Printing Some Primes
- SPOJ - A Conjecture of Paul Erdos
- SPOJ - Primal Fear
- SPOJ - Primes Triangle (I)
- Codeforces - Almost Prime
- Codeforces - Sherlock And His Girlfriend
- SPOJ - Namit in Trouble
- SPOJ - Bazinga!
- Project Euler - Prime pair connection
- SPOJ - N-Factorful
- SPOJ - Binary Sequence of Prime Numbers
- UVA 11353 - A Different Kind of Sorting
- SPOJ - Prime Generator
- SPOJ - Printing some primes (hard)
- Codeforces - Nodbach Problem
- Codeforces - Colliders