2022-06-19 / Bartłomiej Kurek
Project Euler #3: Largest prime factor.

W tym artykule wracamy do rozwiązywania problemów algorytmicznych i podejmujemy problem #3 z zasobów ProjectEuler.

Problem 3: Largest prime factor

The prime factors of 13195 are 5, 7, 13 and 29.
What is the largest prime factor of the number 600851475143?

Repozytorium git.


Zakres

W celu rozwiązania zadania musimy umieć określić czy dana liczba N jest liczbą pierwszą.
Wbrew pozorom nie jest to problem trywialny, a same liczby pierwsze to bardzo ciekawy i złożony temat.
Wykorzystamy ten problem do zgłębienia kilku podstawowych aspektów liczb pierwszych, choćby dlatego, że wiedza ta będzie przydatna do rozwiązania przyszłych problemów.

Przydatna praca dotycząca ewaluacji algorytmów sprawdzających czy dana liczba jest liczbą pierwszą:
deepai.org.

Liczby pierwsze

Wyznaczanie liczb pierwszych może być zrealizowane w różnoraki sposób, nie istnieje jednak żaden wzór. Obecne osiągnięcia matematyki są coraz, coraz bliżej, jednak wciąż za daleko.

Sito Eratostenesa

Sito Eratostenesa (~ -300 BCE): iteracyjny algorytm, które eliminuje liczby poprzez oznaczanie wielokrotności kolejnych liczb - począwszy od liczby 2 - jako liczby posiadające inne dzielniki. Po przeprowadzeniu tej eliminacji, pozostałe liczby będą liczbami pierwszymi.
Krótki kod algorytmu sita w Python:

def sieve(n: int):
    """Seive of Erathostenes"""
    assert n > 1
    limit = n ** (1/2)  # sqrt
    limit = math.ceil(limit)

    A = [1] * n
    for i in range(2, limit):
        if A[i]:
            for j in range(i * 2, n, i):
                A[j] = 0

    return [idx for idx, val in enumerate(A) if val and idx >= 2]

Liczby sprawdzamy w przedziale [2, sqrt(n)].
W liście A oznaczamy na False indeksy, które
otrzymujemy z wyliczenia j jako kolejno: 2i, 3i, 4i, 5i... aż do n. Skrypt tutaj.

Uruchamiamy:

$ time python eratho.py 50
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]

real    0m0.023s
user    0m0.023s
sys 0m0.000s

Mierzymy czas wykonania dla liczby wynoszącej 1 million.

$ time python eratho.py 1000000

real    0m0.223s
user    0m0.215s
sys 0m0.008s

Dla liczby 10 millionów algorytm zabiera jednak sporo czasu.

$ time python eratho.py 10000000

real    0m2.238s
user    0m2.209s
sys 0m0.024s

Naiwnie proste i sprytne, do małych przedziałów jak znalazł.

Sito Sundarama

Sito Sundarama (1934): wariant sita Eratostenesa. Skrypt tutaj.

def sieve(n):
    """Sieve of Sundaram"""
    k = (n - 2) // 2
    A = [1] * (k + 1)
    for i in range(1, k + 1):
        j = i
        while i + j + 2 * i * j <= k:
            A[i + j + 2 * i * j] = 0
            j += 1

    primes = [2 * i + 1 for i in range(1, k + 1) if A[i]]
    return ([2] if n > 2 else []) + primes
$ time python sundaram.py 10000000

real    0m7.850s
user    0m7.812s
sys 0m0.020s

Benchmark (Erathostenes vs Sundaram)

Wyniki uzyskujemy poprawne, ale sito Sundarama jest znacznie wolniejsze od oryginalnego wariantu.
Już przy zakresie 10 milionów wykonuje się 3 razy wolniej.

Krótki benchmark:

→ eratho
   →             eratho-10000        1 0.00133794
   →            eratho-100000        1 0.01448907
   →           eratho-1000000        1 0.18316913
   →          eratho-10000000        1 2.24281044
--------------------------------------------------------------------------------
→ sundaram
   →           sundaram-10000        1 0.00336341
   →          sundaram-100000        1 0.04481575
   →         sundaram-1000000        1 0.5550501
   →        sundaram-10000000        1 6.88032754
================================================================================
RESULTS
================================================================================
GROUP               | CASE                | N_TIMES | TOTAL TIME  | N/SEC
--------------------------------------------------------------------------------
eratho              | eratho-10000        | 1       | 0.00133794  | 747.42
eratho              | eratho-100000       | 1       | 0.01448907  | 69.02
eratho              | eratho-1000000      | 1       | 0.18316913  | 5.46
eratho              | eratho-10000000     | 1       | 2.24281044  | 0.45
--------------------------------------------------------------------------------
sundaram            | sundaram-10000      | 1       | 0.00336341  | 297.32
sundaram            | sundaram-100000     | 1       | 0.04481575  | 22.31
sundaram            | sundaram-1000000    | 1       | 0.5550501   | 1.8
sundaram            | sundaram-10000000   | 1       | 6.88032754  | 0.15
--------------------------------------------------------------------------------

Wikipedia wspomina również o sicie Atkinsa, jednak nie będziemy go tutaj już implementować.

Metody

Wygenerowanie liczb pierwszych pozwoliłoby nam na szybsze wykonanie algorytmu. Co jednak, kiedy nie chcemy przechowywać wcześniej znanych liczb pierwszych?
Istnieją algorytmy sprawdzające czy dana liczba jest pierwszą, lecz każdy z nich ma właściwe sobie niuanse. Część jest niedeterministyczna (nie rozpoznaje wszystkich liczb pierwszych), część ma zastosowanie tylko dla liczb pierwszych charakteryzujących się szczególną właściwością, itd. Wrócimy do tego tematu w innej odsłonie tej serii.

W matematyce istnieje wciąż nierozwiązany problem Riemanna, który jest powiązany z liczbami pierwszymi: (Wikipedia).

W tym artykule zastosujemy po prostu podejście naiwne, czyli liczby sprawdzać będziemy po kolei, aż do zakresu pierwiastka sprawdzanej liczby.

Python

#!/usr/bin/env python

import sys
import math


def is_prime(n):
    assert n >= 2
    if n == 2:
        return True

    top = math.ceil(n ** (1/2))
    for i in range(2, top + 1):
        if n % i == 0:
            return False
    return True


def next_prime(n):
    for i in range(n, 2 * n):
        if is_prime(i):
            return i
    raise Exception("No prime.")


def max_prime_factor(n):
    prime = 2
    max_prime = 0

    while n > 1:
        if n % prime == 0:
            n = n / prime
        else:
            prime = next_prime(prime + 1)

        if prime > max_prime:
            max_prime = prime
    return max_prime


if __name__ == "__main__":
    n = int(sys.argv[1]) if len(sys.argv) > 1 else 600851475143
    print(max_prime_factor(n))

Podczas uruchomienia tego programu możemy podać inną liczbę, dla której chcemy znaleźć najwyższy czynnik pierwszy, a domyślnie program znajdzie czynnik dla liczby 600851475143 z przedmiotowego problemu.

$ time python prog.py
6857

real    0m0.029s
user    0m0.021s
sys 0m0.008s

Zapamiętujemy wynik, rozwiązania w innych językach mają dać wynik identyczny.

Sprawdźmy jeszcze wykonanie dla innej liczby (123123123):

$ time python code/prog.py 123123123
333667

real    0m0.989s
user    0m0.989s
sys 0m0.000s

C

Analogiczne rozwiązanie w języku C demonstruje poniższy listing.

#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#include <assert.h>


int is_prime(int n)
{   /* naive */

    if(n < 2) { return 0; }
    else if(n == 2) { return 1; }

    int top = ceil(sqrt(n)) + 1;
    for(int i = 2; i < top; i++) {
        if(n % i == 0)
            return 0;
    }

    return 1;
}


int next_prime(int n)
{   /* naive prime gen. */

    for(int i = n; i < (2 * n); i++) {
        if(is_prime(i)) {
            return i;
        }
    }

    printf("NO PRIME! %d\n", n);
    assert(0);
}


void test()
{
    assert(next_prime(2) == 2);
    assert(next_prime(3) == 3);
    assert(next_prime(5) == 5);
    assert(next_prime(7) == 7);
    assert(next_prime(9) == 11);
    assert(next_prime(17) == 17);
    assert(next_prime(18) == 19);
}


int main(int argc, char *argv[])
{
    test();

    unsigned long number = (argc > 1 ? strtoul(argv[1], 0, 10) :  600851475143);

    int prime = 2;
    int max_prime = 0;

    while(number > 1) {
        if(number % prime == 0) {
            number /= prime;
        }
        else {
            prime = next_prime(prime + 1);
        }

        if(prime > max_prime) {
            max_prime = prime;
        }
    }

    printf("Max prime: %d\n", max_prime);

    return 0;
}

Uruchamiamy go dla przedmiotowej liczby (bez argumentu):

$ time ./prog
Max prime: 6857

real    0m0.001s
user    0m0.000s
sys 0m0.001s

A sprawdźmy jeszcze dla liczby 123123123:

$ time ./prog 123123123
Max prime: 333667

real    0m0.050s
user    0m0.046s
sys 0m0.004s

Na koniec tej sekcji warto wspomnieć o tym ciekawym zasobie przybliżającym różne implementacje pierwiastkowania w C: codeproject.