Project Euler 008 – Grootste product in een reeks

Probleemstelling

In het gegeven getal van 1000 cijfers hebben de vier opeenvolgende cijfers met het grootste product het product 9\cdot9\cdot8\cdot9=5832.

Bepaal het grootste product van dertien opeenvolgende cijfers in dat getal.


Eerste analyse

Een string van 1000 cijfers bevat 1000-13+1=988 delen van dertien opeenvolgende cijfers. We bekijken elk deel en bewaren het grootste product. Er is voor deze opgave geen aparte wiskundige formule nodig.

We vergelijken een rechtstreekse aanpak met een variant die een deel overslaat zodra het een nul bevat. Een product met een factor nul kan immers nooit groter worden dan een reeds gevonden positief product.


Een stringdeel omzetten

Met slicing halen we dertien tekens uit de string:

deel = getal[begin : begin + 13]

int(deel) zou van bijvoorbeeld "7316" het getal 7316 maken. Wij willen echter 7\cdot3\cdot1\cdot6 berekenen. Daarom doorlopen we het deel teken per teken:

product = 1

for teken in deel:
    product *= int(teken)

Een afzonderlijk teken zoals "7" is aanvankelijk tekst. int(teken) zet het om in het gehele getal 7, waarna het kan worden vermenigvuldigd. Het product begint bij 1, het neutrale element van de vermenigvuldiging.


Vergelijking van de methoden

De eerste methode zet voor ieder deel alle dertien tekens om en vermenigvuldigt ze. De tweede methode gebruikt eerst:

if "0" in deel:
    continue

Een deel met een nul wordt dan onmiddellijk overgeslagen. Van de 988 delen bevatten er 731 een nul; in deze invoer kan de tweede methode dus veel werk vermijden. Daar staat tegenover dat de nultest zelf ook tijd kost en dat delen zonder nul tweemaal worden doorlopen.

Om toevallige schommelingen bij zo’n kort programma te beperken, voert het programma elke methode 1.000 keer uit en rapporteert het de gemiddelde uitvoeringstijd. De precieze verhouding is afhankelijk van de computer en Python-versie.


Gemeenschappelijke gegevens

Beide programma’s gebruiken dezelfde variabele getal, die de 1000 cijfers als één aaneengesloten string bevat. In Python worden opeenvolgende strings tussen haakjes automatisch samengevoegd. De volledige invoer hoeft daardoor niet op één onleesbaar lange regel te staan.

getal = (
    "73167176531330624919225119674426574742355349194934"
    "96983520312774506326239578318016984801869478851843"
    "85861560789112949495459501737958331952853208805511"
    "12540698747158523863050715693290963295227443043557"
    "66896648950445244523161731856403098711121722383113"
    "62229893423380308135336276614282806444486645238749"
    "30358907296290491560440772390713810515859307960866"
    "70172427121883998797908792274921901699720888093776"
    "65727333001053367881220235421809751254540594752243"
    "52584907711670556013604839586446706324415722155397"
    "53697817977846174064955149290862569321978468622482"
    "83972241375657056057490261407972968652414535100474"
    "82166370484403199890008895243450658541227588666881"
    "16427171479924442928230863465674813919123162824586"
    "17866458359124566529476545682848912883142607690042"
    "24219022671055626321111109370544217506941658960408"
    "07198403850962455444362981230987879927244284909188"
    "84580156166097919133875499200524063689912560717606"
    "05886116467109405077541002256983155200055935729725"
    "71636269561882670428252483600823257530420752963450"
)

Programma 1 – Altijd dertien cijfers vermenigvuldigen

Dit eerste programma berekent voor elk van de 988 delen het volledige product, ook wanneer een van de cijfers nul is.

import time


def grootste_product(reeks, lengte):
    grootste = 0

    for begin in range(len(reeks) - lengte + 1):
        deel = reeks[begin : begin + lengte]
        product = 1

        for teken in deel:
            product *= int(teken)

        if product > grootste:
            grootste = product

    return grootste


# Meet 1.000 uitvoeringen en bereken het gemiddelde
starttijd = time.perf_counter()

for _ in range(1_000):
    antwoord = grootste_product(getal, 13)

gemiddelde_tijd = (time.perf_counter() - starttijd) / 1_000

print(f"Het grootste product is: {antwoord}")
print(f"Gemiddelde uitvoeringstijd: {gemiddelde_tijd:.8f} seconden")

Programma 2 – Delen met een nul overslaan

Het tweede programma controleert elk deel eerst op een nul. Alleen wanneer er geen nul aanwezig is, worden de dertien cijfers naar gehele getallen omgezet en vermenigvuldigd.

import time


def grootste_product_zonder_nul(reeks, lengte):
    grootste = 0

    for begin in range(len(reeks) - lengte + 1):
        deel = reeks[begin : begin + lengte]

        if "0" in deel:
            continue

        product = 1
        for teken in deel:
            product *= int(teken)

        if product > grootste:
            grootste = product

    return grootste


# Meet 1.000 uitvoeringen en bereken het gemiddelde
starttijd = time.perf_counter()

for _ in range(1_000):
    antwoord = grootste_product_zonder_nul(getal, 13)

gemiddelde_tijd = (time.perf_counter() - starttijd) / 1_000

print(f"Het grootste product is: {antwoord}")
print(f"Gemiddelde uitvoeringstijd: {gemiddelde_tijd:.8f} seconden")

Beide methoden hebben tijdscomplexiteit O(nk) voor een reeks van n cijfers en delen van lengte k. Voor de vaste waarde k=13 is dit O(n). De nultest verbetert niet de theoretische grootteorde, maar kan wel de werkelijke uitvoeringstijd verkorten.


Concrete tijdsvergelijking

Op dezelfde computer en met telkens 1.000 uitvoeringen werden de volgende gemiddelde tijden gemeten:

Methode Gemiddelde tijd per uitvoering
Altijd dertien cijfers vermenigvuldigen 0,00116562 seconden
Eerst controleren op een nul 0,00039123 seconden

De verhouding is

    \[\frac{0{,}00116562}{0{,}00039123}\approx 2{,}98.\]

De methode met nultest was bij deze meting dus ongeveer 2,98 keer zo snel. Anders uitgedrukt: de gemiddelde uitvoeringstijd daalde met ongeveer

    \[\left(1-\frac{0{,}00039123}{0{,}00116562}\right)\cdot100\%\approx66{,}4\%.\]

De precieze tijden kunnen bij een volgende uitvoering verschillen door de computer, de Python-versie en andere actieve processen. De vergelijking blijft hier inhoudelijk zinvol omdat beide methoden onder dezelfde omstandigheden en even vaak werden uitgevoerd.


Resultaat

Beide programma’s geven als grootste product

    \[\boxed{23514624000}.\]


Besluit

De rechtstreekse methode is het eenvoudigst, maar voert ook veel overbodige omzettingen en vermenigvuldigingen uit. De nultest is een kleine, duidelijke optimalisatie die gebruikmaakt van de eigenschap dat een product met nul altijd nul is. Omdat 731 van de 988 delen een nul bevatten, leverde deze aanpak in de concrete meting een snelheidswinst van ongeveer factor 2,98 op.

Project Euler 007 – Het 10.001ste priemgetal

Probleemstelling

De eerste zes priemgetallen zijn 2, 3, 5, 7, 11 en 13. Het zesde priemgetal is dus 13.

Bepaal het 10.001ste priemgetal.


Eerste analyse

We onderzoeken de getallen in stijgende volgorde, testen welke getallen priem zijn en houden bij hoeveel priemgetallen we hebben gevonden. Zodra de teller 10.001 bereikt, kennen we het antwoord.

Een getal n is priem wanneer het groter is dan 1 en geen positieve delers behalve 1 en zichzelf heeft. Bij de controle hoeven we mogelijke delers slechts tot en met \sqrt n te testen.


Gekozen programmeeraanpak

De oplossing gebruikt de twee functies priem(n) en nde_priem(n).

  • priem(n) gaat na of een gegeven getal priem is. De functie behandelt 2 afzonderlijk, verwerpt andere even getallen en test daarna alleen oneven delers.
  • nde_priem(n) doorloopt de oneven kandidaten in stijgende volgorde, roept priem aan en stopt zodra het gevraagde aantal priemgetallen gevonden is.

Algoritme

  1. Tel 2 als het eerste priemgetal.
  2. Onderzoek achtereenvolgens de oneven getallen 3, 5, 7, enzovoort.
  3. Test voor elke kandidaat de oneven delers tot en met zijn vierkantswortel.
  4. Verhoog de teller wanneer de kandidaat priem is.
  5. Stop zodra de teller 10.001 bedraagt.

Python-programma

import time
from math import isqrt


def priem(n: int) -> bool:
    """Ga na of n een priemgetal is."""
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False

    # Een samengesteld getal heeft minstens een deler tot en met sqrt(n).
    for deler in range(3, isqrt(n) + 1, 2):
        if n % deler == 0:
            return False

    return True


def nde_priem(n: int) -> int:
    """Bereken het n-de priemgetal, waarbij 2 het eerste is."""
    if n < 1:
        raise ValueError("n moet minstens 1 zijn")
    if n == 1:
        return 2

    aantal_priemgetallen = 1
    kandidaat = 1

    # Na 2 hoeven alleen oneven kandidaten onderzocht te worden.
    while aantal_priemgetallen < n:
        kandidaat += 2
        if priem(kandidaat):
            aantal_priemgetallen += 1

    return kandidaat


# Start van de tijdsmeting
starttijd = time.perf_counter()


# Bereken het 10.001ste priemgetal
antwoord = nde_priem(10_001)


# Stop de tijdsmeting
eindtijd = time.perf_counter()
uitvoeringstijd = eindtijd - starttijd


# Toon het resultaat
print(f"Het 10.001ste priemgetal is: {antwoord}")
print(f"Uitvoeringstijd: {uitvoeringstijd:.8f} seconden")

De eenvoudige bovengrens voor de tijdscomplexiteit is O(p_n\sqrt{p_n}), waarbij p_n het n-de priemgetal is. Het geheugengebruik is O(1).


Wiskundige benadering

De priemgetal-telfunctie \pi(x) telt hoeveel priemgetallen kleiner dan of gelijk aan x zijn. Volgens de priemgetalstelling geldt

    \[\pi(x)\sim\frac{x}{\ln x}.\]

Voor de omgekeerde vraag kunnen we gebruiken dat

    \[p_n\approx n(\ln n+\ln\ln n-1).\]

Voor n=10\,001 geeft dit ongeveer 104.318. We verwachten het antwoord dus in de buurt van honderdduizend. Dit is een schatting; de exacte primaliteitstesten van het programma blijven nodig.


Resultaat

Het 10.001ste priemgetal is

    \[\boxed{104743}.\]

Uitvoeringstijd: 0,05393281 seconden.


Besluit

De opsplitsing in priem(n) en nde_priem(n) levert een duidelijke en herbruikbare oplossing. Proefdeling is voor deze opgave ruim snel genoeg, terwijl de priemgetalstelling vooraf een goede schatting van de grootteorde geeft.

Project Euler 006 – Verschil tussen kwadratensommen

Probleemstelling

De som van de kwadraten van de eerste tien natuurlijke getallen is

    \[1^2+2^2+\cdots+10^2=385.\]

Het kwadraat van de som van de eerste tien natuurlijke getallen is

    \[(1+2+\cdots+10)^2=55^2=3025.\]

Het verschil is dus 3025-385=2640.

Bepaal het verschil tussen het kwadraat van de som en de som van de kwadraten van de eerste honderd natuurlijke getallen.


Eerste analyse

We kunnen de twee gevraagde waarden rechtstreeks berekenen. Tijdens één lus houden we zowel de som van de getallen als de som van hun kwadraten bij. Omdat slechts honderd getallen worden verwerkt, is deze aanpak bijzonder snel.

Daarnaast bestaat een elegante wiskundige oplossing met de formules voor de som van de eerste n natuurlijke getallen en de som van de eerste n kwadraten.


Gekozen programmeeraanpak

Het Python-programma gebruikt één for-lus die de getallen van 1 tot en met 100 doorloopt. Voor elk getal werken we twee totalen bij:

  • som bevat de gewone som;
  • som_van_kwadraten bevat de som van de afzonderlijke kwadraten.

Na de lus berekenen we som**2, het kwadraat van de som. Daarvan trekken we de som van de kwadraten af.


Algoritme

  1. Stel beide sommen gelijk aan 0.
  2. Doorloop de getallen van 1 tot en met 100.
  3. Tel ieder getal op bij de gewone som.
  4. Tel ieder kwadraat op bij de som van de kwadraten.
  5. Kwadrateer de gewone som.
  6. Bereken het verschil en druk het resultaat af.

Python-programma

import time


# Start van de tijdsmeting
starttijd = time.perf_counter()


# Bereken de som en de som van de kwadraten
som = 0
som_van_kwadraten = 0

for getal in range(1, 101):
    som += getal
    som_van_kwadraten += getal**2


# Bereken het gevraagde verschil
kwadraat_van_som = som**2
verschil = kwadraat_van_som - som_van_kwadraten


# Stop de tijdsmeting
eindtijd = time.perf_counter()
uitvoeringstijd = eindtijd - starttijd


# Toon het resultaat
print(f"Het gevraagde verschil is: {verschil}")
print(f"Uitvoeringstijd: {uitvoeringstijd:.8f} seconden")

Voor een algemene bovengrens n heeft deze lus tijdscomplexiteit O(n). Het programma gebruikt slechts een vast aantal variabelen en heeft daarom geheugencomplexiteit O(1).


Wiskundige oplossing

De som van de eerste n natuurlijke getallen wordt gegeven door

    \[1+2+\cdots+n=\frac{n(n+1)}{2}.\]

Voor n=100 krijgen we

    \[1+2+\cdots+100 =\frac{100\cdot101}{2} =5050.\]

Het kwadraat van de som is bijgevolg

    \[5050^2=25\,502\,500.\]

Voor de som van de eerste n kwadraten gebruiken we

    \[1^2+2^2+\cdots+n^2 =\frac{n(n+1)(2n+1)}{6}.\]

Invullen van n=100 geeft

    \[1^2+2^2+\cdots+100^2 =\frac{100\cdot101\cdot201}{6} =338\,350.\]

Het gevraagde verschil is dus

    \[25\,502\,500-338\,350 =25\,164\,150.\]

Daarom is het antwoord

    \[\boxed{25164150}.\]


Resultaat

Het verschil tussen het kwadraat van de som en de som van de kwadraten is 25164150.

Uitvoeringstijd: 0,00006433 seconden.


Besluit

De programmeeroplossing met één lus is duidelijk en snel. De twee somformules bieden daarnaast een elegante constante-tijdoplossing en bevestigen hetzelfde resultaat: 25164150.

Project Euler 005 – Kleinste veelvoud

Probleemstelling

Het getal 2520 is het kleinste positieve getal dat zonder rest deelbaar is door elk getal van 1 tot en met 10.

Bepaal het kleinste positieve getal dat zonder rest deelbaar is door elk getal van 1 tot en met 20.


Eerste analyse

Alle veelvouden van 20 uitproberen is mogelijk, maar vereist zeer veel deelbaarheidstests. Het gevraagde getal is precies het kleinste gemene veelvoud van de getallen 1 tot en met 20. We kunnen dit kgv snel opbouwen met behulp van de grootste gemene deler.


Gekozen programmeeraanpak

Voor twee positieve gehele getallen a en b geldt

    \[\operatorname{kgv}(a,b)=\frac{a\cdot b}{\operatorname{ggd}(a,b)}.\]

De grootste gemene deler berekenen we met het algoritme van Euclides. Daarna verwerken we achtereenvolgens alle getallen van 2 tot en met 20. Het lopende resultaat is na iedere stap het kleinste gemene veelvoud van alle getallen die tot dan toe zijn verwerkt.

Waarom het algoritme van Euclides werkt

Voor gehele getallen a en b, met b\ne0, geldt

    \[\operatorname{ggd}(a,b)=\operatorname{ggd}(b,a\bmod b).\]

Volgens de deling met rest kunnen we namelijk schrijven

    \[a=qb+r,\]

waarbij r=a\bmod b. Een getal d deelt precies dan zowel a als b, als d zowel b als r deelt:

  • als d\mid a en d\mid b, dan deelt d ook r=a-qb;
  • omgekeerd, als d\mid b en d\mid r, dan deelt d ook a=qb+r.

De paren (a,b) en (b,r) hebben bijgevolg exact dezelfde gemeenschappelijke delers en dus dezelfde grootste gemene deler.

De Python-functie

def ggd(a, b):
    while b != 0:
        a, b = b, a % b
    return a

past deze identiteit telkens opnieuw toe door (a,b) te vervangen door (b,a\bmod b). De ggd verandert daarbij niet. De resten zijn steeds niet-negatief en strikt kleiner dan de vorige positieve deler. Daarom wordt uiteindelijk rest 0 bereikt. Dan geldt \operatorname{ggd}(a,0)=a, zodat de overblijvende waarde van a de grootste gemene deler is.

Een kort voorbeeld is

    \[\operatorname{ggd}(48,18) =\operatorname{ggd}(18,12) =\operatorname{ggd}(12,6) =\operatorname{ggd}(6,0) =6.\]


Algoritme

  1. Maak een functie die met het algoritme van Euclides de grootste gemene deler berekent.
  2. Stel het lopende kleinste gemene veelvoud gelijk aan 1.
  3. Doorloop de getallen van 2 tot en met 20.
  4. Bereken telkens het nieuwe kgv met de ggd-formule.
  5. Druk na de laatste stap het antwoord en de uitvoeringstijd af.

Python-programma

import time


# Bereken de grootste gemene deler met het algoritme van Euclides
def grootste_gemene_deler(eerste_getal, tweede_getal):
    while tweede_getal != 0:
        eerste_getal, tweede_getal = tweede_getal, eerste_getal % tweede_getal

    return eerste_getal


# Start van de tijdsmeting
starttijd = time.perf_counter()


# Bouw het kleinste gemene veelvoud van de getallen 1 tot en met 20 op
kleinste_gemene_veelvoud = 1

for getal in range(2, 21):
    kleinste_gemene_veelvoud = (
        kleinste_gemene_veelvoud
        * getal
        // grootste_gemene_deler(kleinste_gemene_veelvoud, getal)
    )


# Stop de tijdsmeting
eindtijd = time.perf_counter()
uitvoeringstijd = eindtijd - starttijd


# Toon het resultaat
print(f"Het kleinste positieve deelbare getal is: {kleinste_gemene_veelvoud}")
print(f"Uitvoeringstijd: {uitvoeringstijd:.8f} seconden")

Wiskundige oplossing

Het kgv bevat van elke priemfactor de hoogste macht die nodig is voor de getallen tot en met 20. Dit geeft

    \[2^4\cdot3^2\cdot5\cdot7\cdot11\cdot13\cdot17\cdot19 =232792560.\]


Resultaat

Het kleinste positieve getal dat deelbaar is door alle getallen van 1 tot en met 20 is

    \[\boxed{232792560}.\]

Uitvoeringstijd: minder dan 0,001 seconden.


Besluit

De combinatie van de kgv-formule en het algoritme van Euclides vermijdt een lange brute-force zoekactie. Zo wordt het correcte antwoord met een korte, algemene en leesbare methode berekend.

Project Euler 004 – Grootste palindroomproduct

Probleemstelling

Een palindroomgetal leest van links naar rechts hetzelfde als van rechts naar links. Het grootste palindroom dat het product is van twee tweecijferige getallen is

    \[9009=91\cdot99.\]

Bepaal het grootste palindroom dat geschreven kan worden als het product van twee driecijferige getallen.


Eerste analyse

Er zijn 900 driecijferige getallen. Zelfs wanneer we alle unieke paren onderzoeken, zijn er hoogstens 405450 producten. Deze zoekruimte is klein genoeg voor brute force.

We doorlopen de factoren in dalende volgorde, zodat grote producten eerst worden onderzocht. Zodra verdere producten niet meer groter kunnen zijn dan het beste gevonden palindroom, stoppen we de betreffende lus.


Gekozen programmeeraanpak

We maken eerst een afzonderlijke, algemene functie is_palindroom(uitdrukking). Ze zet haar argument indien nodig om naar tekst en vergelijkt die tekst met haar omgekeerde. De functie is dus niet speciaal geschreven voor producten van driecijferige getallen.

Door de palindroomtest los te koppelen van de zoeklussen krijgt elk programmadeel één duidelijke taak. De functie kan afzonderlijk worden getest en hergebruikt, terwijl het hoofdalgoritme overzichtelijk blijft.

Daarna daalt de eerste factor van 999 tot 100. De tweede factor daalt telkens van 999 tot de eerste factor, zodat elk ongeordend factorpaar hoogstens eenmaal wordt onderzocht.

Alleen producten die groter zijn dan het tot dan toe gevonden maximum blijven interessant. Wanneer een product in de dalende binnenste lus niet meer groter is, mogen we die lus afbreken. Voor ieder nog interessant product gebruiken we is_palindroom(); bij een positief resultaat bewaren we het product en beide factoren.

De functie is_palindroom()

def is_palindroom(uitdrukking) -> bool:
    tekst = str(uitdrukking)
    return tekst == tekst[::-1]

str(uitdrukking) maakt een tekstvoorstelling van de ontvangen waarde. De uitdrukking tekst[::-1] levert dezelfde tekst in omgekeerde volgorde. Zijn beide teksten gelijk, dan geeft de functie True terug; anders geeft ze False terug. Zo werkt de functie bijvoorbeeld voor het getal 9009, maar ook voor een tekst zoals "lepel".


Algoritme

  1. Initialiseer het grootste palindroom met 0.
  2. Doorloop de eerste factor dalend van 999 tot en met 100.
  3. Doorloop de tweede factor dalend van 999 tot en met de eerste factor.
  4. Bereken het product.
  5. Breek de lus af wanneer geen groter product meer mogelijk is.
  6. Test het product met de algemene functie is_palindroom().
  7. Bewaar elk groter gevonden palindroom en zijn factoren.
  8. Druk het antwoord, de factoren en de uitvoeringstijd af.

Python-programma

import time


# Controleer of een uitdrukking een palindroom is
def is_palindroom(uitdrukking) -> bool:
    tekst = str(uitdrukking)
    return tekst == tekst[::-1]


# Start van de tijdsmeting
starttijd = time.perf_counter()


# Zoek dalend naar het grootste palindroomproduct
grootste_palindroom = 0
eerste_factor = 0
tweede_factor = 0

for factor_a in range(999, 99, -1):
    if factor_a * 999 <= grootste_palindroom:
        break

    for factor_b in range(999, factor_a - 1, -1):
        product = factor_a * factor_b

        if product <= grootste_palindroom:
            break

        if is_palindroom(product):
            grootste_palindroom = product
            eerste_factor = factor_a
            tweede_factor = factor_b


# Stop de tijdsmeting
eindtijd = time.perf_counter()
uitvoeringstijd = eindtijd - starttijd


# Toon het resultaat
print(f"Het grootste palindroom is: {grootste_palindroom}")
print(f"Factoren: {eerste_factor} en {tweede_factor}")
print(f"Uitvoeringstijd: {uitvoeringstijd:.8f} seconden")

Complexiteit en eenvoudige optimalisaties

Met n mogelijke driecijferige factoren onderzoeken de twee geneste lussen in het ongunstigste geval een kwadratisch aantal paren. De tijdscomplexiteit is daarom O(n^2) wanneer de cijferlengte vast is. De tekstvergelijking in is_palindroom() kost voor een getal met d cijfers O(d) tijd, zodat de algemene notatie O(n^2d) is. Het geheugengebruik blijft O(1).

De code vermijdt dubbele factorparen, doorloopt grote producten eerst en breekt een lus af zodra geen verbetering meer mogelijk is. Men zou ook de deelbaarheid van zescijferige palindromen door 11 in de lussen kunnen verwerken, maar die extra optimalisatie is voor deze kleine zoekruimte niet nodig.


Wiskundige achtergrond

Een zescijferig palindroom heeft de vorm abccba. De waarde ervan is

    \[100001a+10010b+1100c =11(9091a+910b+100c).\]

Ieder zescijferig palindroom is dus deelbaar door 11. Bij een product dat zo’n palindroom vormt, moet minstens een van beide factoren deelbaar zijn door 11. De Python-oplossing hoeft deze extra eigenschap niet te gebruiken, omdat de gewone afkappingen de zoekruimte al voldoende beperken.


Resultaat

Het grootste palindroomproduct is

    \[906609=913\cdot993.\]

Uitvoeringstijd: ongeveer 0,00121165 seconden.


Besluit

De dalende brute-force aanpak onderzoekt een beperkte zoekruimte en slaat dankzij de afkappingen veel onnodige producten over. Zo vinden we met duidelijke Python-code snel het correcte antwoord 906609.