Ciurul lui Eratostene

Ce este ciurul lui Eratostene?

Ciurul lui Eratostene este o metodă eficientă de afla toate numerele prime între 1 și nn, folosindu-ne de proprietățile corespunzătoare numerelor naturale, precum și de relațiile dintre divizori și multipli. Pe lângă aplicația sa principală, se va dovedi a fi un algoritm foarte util pentru foarte multe tipuri de precalculări care sunt centrate în jurul aflării divizorilor sau a altor valori ce se calculează în funcție de divizorii numerelor de la 1 la nn.

Pentru a afla numerele prime de la 1 la nn, vom avea un algoritm simplu, care va lua la rând numerele de la 2 la nn și pentru fiecare număr nemarcat de la 2 la nn, se vor marca toți multiplii acestuia. Astfel, numerele nemarcate sunt numerele prime, acestea nefiind marcate anterior de momentul accesării lor, iar numerele marcate sunt numerele compuse. Se remarcă faptul că 1, nefiind număr prim, nu este luat în considerare.

Să exemplificăm algoritmul pentru numerele de la 2 la 30. Voi descrie doar pașii ce exemplifică ce se întâmplă când avem de-a face cu numere prime.

  • La pasul i=2i = 2, 2 este marcat drept număr prim, iar acesta va marca numerele pare de la 4 la 30 drept compuse (4,6,8,10,12,14,16,18,20(4, 6, 8, 10, 12, 14, 16, 18, 20, 22,24,26,28,30)22, 24, 26, 28, 30).
  • La pasul i=3i = 3, 3 este marcat drept număr prim, iar acesta va marca numerele multiplu de 3 de la 6 la 30 drept compuse (6,9,12,15,18,21,24,27,30)(6, 9, 12, 15, 18, 21, 24, 27, 30).
  • La pasul i=5i = 5, 5 este marcat drept număr prim, iar acesta va marca numerele multiplu de 5 de la 10 la 30 drept compuse (10,15,20,25,30)(10, 15, 20, 25, 30).
  • La pasul i=7i = 7, 7 este marcat drept număr prim, iar acesta va marca numerele multiplu de 7 de la 14 la 30 drept compuse (14,21,28)(14, 21, 28).
  • La pasul i=11i = 11, 11 este marcat drept număr prim, iar acesta va marca numerele multiplu de 11 de la 22 la 30 drept compuse (22)(22).
  • La pasul i=13i = 13, 13 este marcat drept număr prim, iar acesta va marca numerele multiplu de 13 de la 26 la 30 drept compuse (26)(26).
  • La pașii i=17i = 17, i=23i = 23, i=29i = 29, 17, 23 și 29 sunt marcați drept numere prime, dar multiplii lor mai mari ca ei sunt mai mari ca 30, nemaifiind marcați drept numere compuse, chiar dacă sunt, deoarece nu fac parte din scopul rulării algoritmului nostru pentru numerele până la 30.

Un exemplu de vizualizare pentru numerele de la 1 la 16

Algoritmul se dovedește a fi o optimizare față de metoda standard de aflare pentru toate numerele de la 1 la nn a primalității, complexitatea devenind O(nloglogn)\mathcal{O}(n \log \log n) pentru aflarea primalității pentru toate numerele, respectiv O(nlogn)\mathcal{O}(n \log n) pentru majoritatea celorlalte tipuri de prelucrări. De asemenea, și implementarea se dovedește a fi una foarte scurtă, ciurul putând fi scris în câteva rânduri.

int prim[100001];
for (int i = 2; i <= n; i++) {
    if (prim[i] == 0) {
        for (int j = i + i; j <= n; j += i) {
            prim[j] = 1;
        }
    }
}

Observație

E de remarcat că numerele prime vor fi cele nemarcate, iar numerele compuse vor fi cele marcate cu 1.

Optimizări ale implementării

Deși algoritmul în sine este deja foarte rapid, în practică concurenții tind să aplice diverse optimizări de constantă inspirate din optimizările ce se pot aplica algoritmului de aflare a divizorilor unui număr nn. Printre altele, putem vorbi de începerea celui de-al doilea for de la i2i^2 (primul număr compus care nu a fost marcat anterior va fi mereu i2i^2) sau de procesarea lui 2, urmată de procesarea numerelor impare din 2 în 2.

int prim[100001];
for (int i = 4; i <= n; i += 2) {
    prim[i] = 1;
}
for (int i = 3; i <= n; i += 2) {
    if (prim[i] == 0) {
        for (int j = i * i; j <= n; j += i * 2) {
            prim[j] = 1;
        }
    }
}

Observație

Trebuie avut grijă la cel de-al doilea for deoarece expresia int j = i * i; poate cauza overflow dacă implementarea nu este una corespunzătoare. De aceea, concurenții pot folosi o optimizare similară și pentru primul for.

int prim[100001];
for (int i = 4; i <= n; i += 2) {
    prim[i] = 1;
}
for (int i = 3; i * i <= n; i += 2) {
    if (prim[i] == 0) {
        for (int j = i * i; j <= n; j += i * 2) {
            prim[j] = 1;
        }
    }
}

// afisarea numerelor prime de la 1 la n
for (int i = 2; i <= n; i++) {
    if (prim[i] == 0) {
        cout << i << " ";
    }
}

Chiar dacă aceste implementări nu îmbunătățesc semnificativ performanța ciurului lui Eratostene, ele pot fi utile în contextul optimizărilor ce ar putea fi folosite la rezolvarea problemelor.

Alte aplicații ale ciurului lui Eratostene

După cum am menționat la începutul articolului, ciurul lui Eratostene este un algoritm foarte versatil, putând fi folosit pentru aflarea multor proprietăți ale numerelor, precum divizorii (primi sau toți) ai unui număr, pentru calcule de tipul celor folosite la pinex sau Möbius și așa mai departe. Câteva din implementările acestor operații vor fi prezentate mai jos, în cadrul problemei educaționale descrisă în cele ce urmează.

Problema Ciurul lui Eratostene

Kilonova

View Problem

Kilonova

Could not fetch problem details

Deschide

Se dau qq operații de forma type valuetype \ value. În funcție de tipul operației, va trebui să faceți următoarele lucruri:

  • 1 value1\ value: Să se afișeze Prime sau Composite dacă numărul este prim, respectiv compus.
  • 2 value2\ value: Să se afișeze divizorii primi ai lui valuevalue, în ordine crescătoare. Dacă value=1value = 1, se va afișa o linie goală.
  • 3 value3\ value: Să se afișeze divizorii lui valuevalue, în ordine crescătoare.

Pentru a rezolva problema (și în general pentru a rezolva problemele care implică folosirea ciurului lui Eratostene), ne vom precalcula toate răspunsurile folosind variații ale ciurului lui Eratostene, una pentru numerele prime și cealaltă pentru divizori în general. Pentru a ține în memorie toți divizorii, vom folosi varianta din STL a vectorilor, deoarece face accesarea datelor mai ușoară.

#include <iostream>
#include <vector>

using namespace std;

const int NMAX = 1000000;
int q, prime[NMAX + 1];
vector<vector<int> > divisors, prime_divisors;

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

    divisors.resize(NMAX + 1);
    prime_divisors.resize(NMAX + 1);

    for (int i = 1; i <= NMAX; i++) {
        for (int j = i; j <= NMAX; j += i) {
            divisors[j].push_back(i);
        }
    }

    prime[1] = 1;
    for (int i = 2; i <= NMAX; i++) {
        if (prime[i] == 0) {
            for (int j = i; j <= NMAX; j += i) {
                prime_divisors[j].push_back(i);
                if (j != i) {
                    prime[j] = 1;
                }
            }
        }
    }

    cin >> q;

    for (int i = 1; i <= q; i++) {
        int type, value;
        cin >> type >> value;
        if (type == 1) {
            cout << (prime[value] == 0 ? "Prime" : "Composite") << '\n';
        }
        if (type == 2) {
            for (int j = 0; j < (int)prime_divisors[value].size(); j++) {
                cout << prime_divisors[value][j] << " ";
            }
            cout << '\n';
        }
        if (type == 3) {
            for (int j = 0; j < (int)divisors[value].size(); j++) {
                cout << divisors[value][j] << " ";
            }
            cout << '\n';
        }
    }
    return 0;
}

Problema Cufăr

Kilonova

View Problem

Kilonova

Could not fetch problem details

Deschide

Vrăjitoarea cea bună are un cufăr în care este închisă piatra magică de către piticii lăzii cu ajutorul unui cifru digital. Piticii i-au dat vrăjitoarei o cutie în care sunt nn cartonașe. Pe fiecare cartonaș este scris un număr natural pe care vrăjitoarea îl va folosi să deschidă lada. Valorile scrise pe cartonașe sunt distincte între ele.

Pentru a afla cifrul trebuie să procedeze astfel: extrage fiecare cartonaș din cutie și apoi determină valoarea magică asociată numărului natural scris pe cartonaș. Pentru fiecare cartonaș valoarea magică este dată de al kk-lea divizor prim al numărului înscris pe acesta. Vrăjitoarea trebuie să adune valorile magice obținute pentru cele nn cartonașe și apoi să introducă în ordine cifrele valorii obținute, pentru a descuia lada.

Pentru a rezolva problema vom afla folosind un ciur toți divizorii primi ai numerelor mai mici sau egale cu 10610^6, aceștia fiind folosiți pentru a calcula răspunsul la query-uri. Aflarea răspunsului final devine ușoară ulterior efectuării acestui pas.

#include <fstream>
using namespace std;

ifstream f("cufar.in");
ofstream g("cufar.out");

int c, n, nr, k, prime[9][1000002];
int cate[1000002];
long long sol;
bool prim[1000002];

void ciur() {
    for (int i = 2; i <= 1000000; i++) {
        if (!prim[i]) {
            for (int j = i; j <= 1000000; j += i) {
                prim[j] = 1;
                ++cate[j];
                prime[cate[j]][j] = i;
            }
        }
    }
}

int main() {
    ciur();
    f >> c >> n;
    if (c == 1) {
        f >> nr >> k;
        g << prime[k][nr] << '\n';
    } else {
        long long sol = 0;
        for (int i = 1; i <= n; ++i) {
            f >> nr >> k;
            sol += 1LL * prime[k][nr];
        }
        g << sol << '\n';
    }
    return 0;
}

Problema primprim

Kilonova

View Problem

Kilonova

Could not fetch problem details

Deschide

Pentru un număr natural a definim costul ca fiind valoarea absolută (modulul) diferenței dintre a și numărul prim cel mai apropiat de a. Asupra unui șir de nn numere naturale, situate pe poziții numerotate de la 1 la nn, se aplică, în ordine, o succesiune de qq operații. O operație constă dintr-o înlocuire și o afișare și este descrisă sub forma i x pi \ x \ p, cu semnificația: mai întâi înlocuim cu xx elementul din șir de pe poziția ii; apoi afișăm suma minimă totală a costurilor unor elemente convenabil selectate de pe pp poziții distincte din șir.

Cunoscând nn și cele nn elemente ale șirului, scrieți un program care să determine:

  • suma costurilor tuturor elementelor din șirul dat;
  • rezultatele afișate în urma aplicării fiecăreia dintre cele qq operații, date în forma precizată.

Pentru a rezolva problema, vom precalcula pentru fiecare valoare răspunsul optim pentru fiecare număr de la 1 la aa folosind ciurul lui Eratostene. Apoi, parcurgem valorile de la 1 la aa pentru a afla răspunsul optim după ce am aflat numerele prime din șir.

Pentru a rezolva query-urile, voi folosi un vector de frecventa pentru a tine aceste diferențe, care de altfel sunt destul de mici. Apoi, pentru fiecare query, voi parcurge vectorul de frecvență pentru a afla suma celor mai mici pp diferențe.

#include <cmath>
#include <fstream>

using namespace std;

ifstream fin("primprim.in");
ofstream fout("primprim.out");

int vals[1000002], primes[200002], cnt, ans[1100002];
bool pr[1100002];

int fr[202];

int main() {
    int c;
    fin >> c;

    int n, a = 1100000, i, j;
    fin >> n;

    for (i = 1; i <= n; i++) {
        fin >> vals[i];
    }

    // precalculam raspunsul optim pentru fiecare numar de la 1 la a folosind
    // ciurul lui Eratostene

    for (i = 2; i <= a; i++) {
        if (pr[i] == 0) {
            primes[cnt++] = i;
            for (j = i + i; j <= a; j += i) {
                pr[j] = 1;
            }
        }
    }

    int poz = -1;

    // parcurg valorile de la 1 la a pentru a afla raspunsul optim dupa ce am
    // aflat numerele prime
    ans[1] = 1;
    for (i = 2; i <= a; i++) {
        if (pr[i] == 0) {
            poz++;
        }
        ans[i] = abs(i - primes[poz]);
        if (poz + 1 < cnt) {
            ans[i] = min(ans[i], abs(i - primes[poz + 1]));
        }
    }

    // voi folosi vector de frecventa pentru a tine aceste diferente, care de
    // altfel sunt destul de mici

    for (i = 1; i <= n; i++) {
        fr[ans[vals[i]]]++;
    }

    if (c == 1) {
        int total = 0;
        for (i = 1; i <= n; i++) {
            total += ans[vals[i]];
        }
        fout << total;
        return 0;
    }

    int q;
    fin >> q;

    for (i = 1; i <= q; i++) {
        int a, b, p;
        fin >> a >> b >> p;

        fr[ans[vals[a]]]--;
        vals[a] = b;
        fr[ans[vals[a]]]++;

        int dif = 0;
        int sol = 0;

        // la fiecare pas parcurg vectorul de frecventa pana cand dau de p
        // diferente

        while (p) {
            sol += min(p, fr[dif]) * dif;
            p -= min(p, fr[dif]);
            dif++;
        }

        fout << sol << '\n';
    }
    return 0;
}

Concluzie

Ciurul lui Eratostene este una dintre cele mai importante metode de a lucra cu numere prime, precalculările care se pot face pe stilul acestui algoritm ne pot ajuta în multe contexte, în special când calculăm divizori, sume sau chiar numărul de soluții în alte probleme.

Probleme suplimentare

Lectură suplimentară