2021-12-28 / Bartłomiej Kurek
Project Euler #2: Fibonacci. Iteracja, rekurencja.

Przemierzając kiedyś ziemskie połacie w zachwycie i zadumie minąłem rzeźbę, której fotografię znajdziemy na Wikipedii. Posiadam całą kolekcję fotografii wszelkich posągów zasłużonych myślicieli, lecz fotografii tej konkretnej, niepozornej statui mi brakuje. Stałem tak chwilę w zadumie i nie mogłem skojarzyć... Leonardo da Pisa, posąg inny niż wszystkie. Cóż, dopiero później myśl wyparła bezmyśl, jednak w tym mieście pełnym uliczek i historii nie mogłem odnaleźć już miejsca, w którym ta rzeźba się znajduje. Szukałem długo, a lokalna społeczność usilnie kierowała mnie do innego posągu.

Analizowany w artykule kod dostępny jest w repozytorium git.

Problem 2: Even Fibonacci numbers

http://projecteuler.net/problem=2

Each new term in the Fibonacci sequence is generated by adding the previous two terms.
By starting with 1 and 2, the first 10 terms will be:

1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ...

By considering the terms in the Fibonacci sequence whose values do not exceed four million,
find the sum of the even-valued terms.

Naszym zadaniem jest znalezienie sumy tych elementów ciągu, które są parzyste, a same elementy ciągu mają nie przekraczać zakresu 4 * 10 ** 6 (4 miliony). "Nie przekraczać" czyli: mniejsze lub równe.

Strategia

Problem można rozwiązać iteracyjnie lub rekurencyjnie. Zrobimy tak i tak. Ogólna zasada jednak jest taka: jeśli problem ma rozwiązanie iteracyjne, rozwiązujemy iteracyjnie. W przypadku rekurencji rozwijamy stos, zatem nie każdy problem da się rozwiązać rekurencyjnie z uwagi na ograniczenia maszyn (oraz naszego czasu).

Iteracja:
Dla przypomnienia: iteracja to przeskakiwanie po kolejnych elementach.

>>> L = [1, 2, 3]
>>> for i in L: print(i)
...
1
2
3

Rekurencja:
Rekurencja to samopowtarzalność/samodefinicja. Rekurencję łatwo zobaczyć. Bierzemy telefon, włączamy przedni aparat (ten do selfie) i kierujemy telefon ekranem w stronę lustra. W lustrze oglądamy co wyświetla. Pojawi się wtedy coś równie hipnotycznego jak Ummagumma.

Funkcja jest rekurencyjna kiedy wywołuje samą siebie.

>>> def f(): f()
...
>>> f()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 1, in f
  File "<stdin>", line 1, in f
  File "<stdin>", line 1, in f
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

Jeśli ktoś woli...:

>>> import sys
>>> sys.setrecursionlimit(16)
>>> def f(level=0):
...     print("#" * level)
...     f(level + 1)  # recursive!
...
>>> f()

#
##
###
####
#####
######
#######
########
#########
##########
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in f
  File "<stdin>", line 3, in f
  File "<stdin>", line 3, in f
  [Previous line repeated 8 more times]
  File "<stdin>", line 2, in f
RecursionError: maximum recursion depth exceeded while calling a Python object

Leonardo myślał o królikach. Let's follow the white rabbit.

Python

Na Pythonie się najłatwiej uczy i ćwiczy.

Python: iteracyjnie

Iteracyjnie - czyli z pętlą. Użyjemy jednak generatora (słowo kluczowe: yield). Generator fibonacci_gen() produkuje kolejne elementy ciągu. Moglibyśmy umieścić w nim klauzulę warunkową if sprawdzającą parzystość, jednak możemy to również odfiltrować w późniejszym etapie. Dzięki temu można tego generatora użyć w innych programach bez zmiany kodu.

#!/usr/bin/env python
import sys


def fibonacci_gen(limit):
    prev = 0
    curr = 1
    item = 0
    while item <= limit:
        item = prev + curr
        yield item
        prev = curr
        curr = item


limit = int((sys.argv[1] if len(sys.argv) > 1 else (4 * 10 ** 6)))
print(sum([_ for _ in fibonacci_gen(limit) if _ % 2 == 0]))

W ciągu Leonarda mamy liczby:

[0, 1, 1, 2, 3, 5, 8, 13, ...]

W algorytmie śledzimy więc poprzednią i obecną liczbę, dodajemy je i sumę tę za każdym razem wydajemy (yield). Limit dla ciągu możemy podać przy wywołaniu tego skryptu (int(sys.argv[1])). Sumę elementów parzystych załatwiamy w ostatniej linii kodu. Sumujemy iterowane elementy, ale pod warunkiem, że są parzyste.
Underscore (_) tutaj to najzwyklejsza nazwa zmiennej. Programiści Python stosują tę dziwną nazwę w celu zaznaczenia, iż nie jest ona istotą zagadnienia, ani też nie będziemy jej później używać. Moglibyśmy ją nazwać i, x, n, item, czy element_of_a_squence. Nie ma to jednak żadnego znaczenia, takoż _.

Sprawdzamy program:

$ python3 iterative.py
4613732

Mamy więc już wynik. Zapamiętujemy sobie wartość, pozostałe rozwiązania mają dać taki sam wynik.
Z ciekawości możemy też sprawdzić sumy dla większego zakresu - wszakże odbieramy w kodzie argument z linii poleceń.

$ python3 iterative.py 1000000000
1485607536
$ python3 iterative.py 100000000000000000000000000000
171679151392093647435137529168

Możemy też policzyć sumę parzystych elementów ciągu Fibonacciego w zakresie szacunkowej liczby wszystkich cząstek we Wszechświecie.

$ python iterative.py $(expr '3.28 * 10 ^ 80' | bc -l | cut -d. -f 1 | tr -d '[:punct:][:cntrl:]')
442259386782137518167658571404249416738352889129833053963717692207734295884996994

bc umie liczyć, ale formatuje wyniki.

$ expr '3.28 * 10 ^ 80' | bc -l
32800000000000000000000000000000000000000000000000000000000000000000\
0000000000000.00

W tym przypadku akurat tego nie chcemy. Nie interesuje nas też część ułamkowa (dlatego cut). tr usuwa później backslash i znaki kontrolne (tutaj newline).
Teraz "na czysto":

$ expr '3.28 * 10 ^ 80' | bc -l | cut -d. -f 1 | tr -d '[:punct:][:cntrl:]'
328000000000000000000000000000000000000000000000000000000000000000000000000000000

Całość to argument naszego programu. W tym zakresie policzyliśmy powyżej sumę parzystych elementów ciągu. Impressive.

Python: rekurencyjnie

Tutaj Project Euler szykuje już dla nas niespodzianki i zaprasza do eksploracji głębokiego i pięknego świata wiedzy i odkryć. Omówimy zatem rozwiązanie, a okazję wykorzystamy by odkryć odrobinę teorii.

Rekurencyjna postać funkcji ciągu wygląda tak:

def fib_rec(n):
    # F(n) = F(n-1) + F(n-2)
    if n <= 1:
        return n
    return fib_rec(n - 1) + fib_rec(n - 2)

"Idziemy" od maximum, a każdy element ciągu to suma wyniku tej funkcji dla poprzednika i jego poprzednika. Kiedy dojdziemy do 1, to przestajemy.

Używalibyśmy tej funkcji w ten sposób:

>>> for i in range(10):
...     print(fib_rec(i))
...
0
1
1
2
3
5
8
13
21
34

Liczbę 4 milionów przekroczymy przy n == 35.

>>> [fib_rec(i) for i in range(35)]
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, ..., 3524578, 5702887]

Parzyste elementy wyfiltrujemy w ten sposób:

>>> list(filter(lambda n: n % 2 == 0, [fib_rec(i) for i in range(35)]))
[0, 2, 8, 34, 144, 610, 2584, 10946, 46368, 196418, 832040, 3524578]

Wynik otrzymamy poprawny:

>>> sum(list(filter(lambda n: n % 2 == 0, [fib_rec(i) for i in range(35)])))
4613732

Ale...:
To rozwiązanie jest bardzo wolne.

>>> t = time.monotonic(); sum(list(filter(lambda n: n % 2 == 0, [fib_rec(i) for i in range(35)]))); time.monotonic() - t
4613732
4.805196590954438

U mnie zajęło 5 sekund. To sporo, gdyż rozwiązanie iteracyjne z generatorem dla limitu równego liczbie wszystkich cząstek we Wszechświecie zajęło 200 mikrosekund:

>>> t = time.monotonic(); sum([_ for _ in fibonacci_gen(3.28 * 10 ** 80) if _ % 2 == 0]); time.monotonic() - t
442259386782137518167658571404249416738352889129833053963717692207734295884996994
0.00020728004164993763

Całkiem nieźle, nie? Kiedyś pisałem w C++ wielowątkowy filtr SMS z blacklistą/whitelistą i łańcuchem warunków - na jeden SMS przypadało średnio 3.5 mikrosekundy. Systemy real time są rozważane w milisekundach i mikrosekundach.

Wracamy do rekurencji. Poniżej nieco dodatkowej teorii.

Stos

Jeśli nasza funkcja rekurencyjna fib_rec() otrzyma dużą wartość liczbową, to tych wywołań wewnątrz będzie mnóstwo. Dla każdego n - 1 oraz każdego n - 2 będziemy wywoływać tę funkcję, a tym samym tworzyć kolejne ramki na stosie. Sprawdźmy ile tych wywołań jest:

def fib_rec(n):
    fib_rec.counter += 1
    if n <= 1:
        return n
    return fib_rec(n - 1) + fib_rec(n - 2)


fib_rec.counter = 0  # yes, it's just an object.
fib_rec(32)
print(fib_rec.counter)

Wykonujemy (dla liczby 32, jak wyżej w kodzie):

$ python count_calls.py
7049155

7+ milionów wywołań. Im większa liczba, tym wolniej. Poniżej przykładowa różnica 4 (czyli limit 32 vs 36) i przyrost czasu:

$ time python recursive.py 32
2178309

real    0m0.764s
user    0m0.760s
sys     0m0.004s

$ time python recursive.py 36
14930352

real    0m5.141s
user    0m5.122s
sys     0m0.008s

Ograniczenie stosu:
Jeśli będziemy wykonywać tę funkcję dla dużej wartości n, to python zgłosi wyjątek RecursionError. W przeciwnym wypadku system zabiłby sam proces Python.

Traceback (most recent call last):
  File "/tmp/count_calls.py", line 10, in <module>
    fib_rec(1024)
  File "/tmp/count_calls.py", line 6, in fib_rec
    return fib_rec(n - 1) + fib_rec(n - 2)
  [Previous line repeated 995 more times]
  File "/tmp/count_calls.py", line 4, in fib_rec
    if n <= 1:
RecursionError: maximum recursion depth exceeded in comparison

Stos w Python

W przykładzie powyżej Python przerwał naszą funkcję. Co się stanie, jeśli zmienimy ograniczenie rekursji na wyższe?
Najpierw zobaczmy, że to C:

Traceback (most recent call last):
  File "/tmp/count_calls.py", line 10, in <module>
    sys.setrecursionlimit(102400000000000)
OverflowError: Python int too large to convert to C int

Tutaj inty to inne inty. Ale o tym było wczoraj. Teraz o rekurencji: zmieniamy limit rekursji, liczymy dla n=100k, a w systemie zmieniamy rozmiar stosu na 4096k.
Kod:

import sys


def fib_rec(n):
    if n <= 1:
        return n
    return fib_rec(n - 1) + fib_rec(n - 2)


sys.setrecursionlimit(1234567890)
fib_rec(100000)

Wykonanie:

$ ulimit -s 4096
$ python3 count_calls.py
Segmentation fault (core dumped)

Ups... nadepnęliśmy na paszczę węża. Pozostaje nam pochylić się nad nim i zapytać co się stało:

$ gdb -q $(command -v python3) core.dev.lan.python3.1640658530
[...]
Core was generated by 'python3 count_calls.py'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x000000000050f61e in _PyEval_EvalFrameDefault ()
(gdb) bt
#0  0x000000000050f61e in _PyEval_EvalFrameDefault ()
Backtrace stopped: Cannot access memory at address 0x7ffc7fcd40f8
(gdb) frame view 0x000000000050f61e
#0  0x0000000000000000 in ?? (warning: Unable to restore previously selected frame.
)

Nie każdy problem da się rozwiązać rekurencyjnie. Jeśli problem ma rozwiązanie iteracyjne, to stosujemy rozwiązanie iteracyjne.

Tail call: TRE/TCO

Pisząc funkcje rekurencyjne możemy stosować TRE (Tail Recursion Elimination), a ogólnie to TCO - Tail Call Optimization. Chodzi o to, aby przypadek rekurencyjny (return func()) był na samym końcu.

def func(x):
    if condition:
        return ...
    return func(x + 1)  # here! recursive! tail.

To pozwala na eliminację nadmiarowych ramek stosu. W Python tego nie ma, co więcej - zmierzymy to teraz:

import time


def fib_rec(n):
    if n <= 1:
        return n
    return fib_rec(n - 1) + fib_rec(n - 2)  # tail call


def fib_rec_no_tail(n):
    if n > 1:
        return fib_rec(n - 1) + fib_rec(n - 2)
    return n


def timeit(func, *args, **kwargs):
    t0 = time.monotonic()
    func(*args, **kwargs)
    dt = time.monotonic() - t0
    print(dt, func.__qualname__)
    return dt


timeit(fib_rec, 42)
timeit(fib_rec_no_tail, 42)

Wykonuję:

$ python3 calls.py
8.308601832017303 fib_rec (37,) {}
8.29210988804698 fib_rec_no_tail (37,) {}

Jeśli ktoś jest zainteresowany szczegółami, to:

Podsumowanie

$ time ./iterative.py
4613732

real    0m0.031s
user    0m0.024s
sys     0m0.008s
$ time ./recursive.py
4613732

real    0m5.093s
user    0m5.075s
sys     0m0.008s

Uuuuuuuu. Pierwsze jest szybkie jak kobra, a to drugie jest jak... Ouroboros. Taki rekurenycjny wąż, który zjada swój własny ogon.

C

O C i C++ mówiliśmy w ramach integralności danych. Tutaj nie będziemy się męczyć. Robimy "na piechotę", iteracyjnie, z ifem w środku. Można też doczytać o niecodziennych dodatkach do formatowania (PRIu64).

Kod:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

#define __STDC_FORMAT_MACROS
#include <inttypes.h>


int main(int argc, char** argv)
{
    uint64_t maximum = argc > 1 ? strtoull(argv[1], 0, 10) : 4000000;

    if(errno) {
        fprintf(stderr, strerror(errno));
        return 1;
    }

    uint64_t last = 0;
    uint64_t next = 1;
    uint64_t result = 0;

    uint64_t sum_even = 0;
    uint64_t sum_all = 0;

    /* printf("# Maximum fib number: %ld\n", maximum); */

    while((result = last + next) <= maximum) {
        last = next;
        next = result;

        if(result % 2 == 0) {
            sum_even += result;
        }
        sum_all += result;

        /* printf(". %20" PRIu64 "\n", result); */
    }

    printf("SUM of even:  %" PRIu64 "\n", sum_even);
    /* printf("TOTAL      :  %" PRIu64 "\n", sum_all); */

    return 0;
}

Kompiluję, uruchamiam, patrzymy:

$ cc -o fib main.c -Wall -Wextra -Werror
$ time ./fib
SUM of even:  4613732

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

No... ultra szybka rzecz. Tak jak i w Python - tutaj również dorobiłem możliwość przekazania zakresu (argv[1]). Domyślnie jest 4 miliony, ale jeśli przekażemy argument, to możemy sprawdzić wyższe wartości:

$ time ./fib 12345678901234567890
SUM of even:  15970217317495049952

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

Nadal ultra szybki. Sprawdzamy więc dla liczby cząstek we Wszechświecie:

$ ./fib $(expr '3.28 * 10 ^ 80' | bc -l | cut -d. -f 1 | tr -d '[:punct:][:cntrl:]')
Numerical result out of range

Boom. Nie ma. Big Bang. Już z tym walczyliśmy. Tym razem wiemy już, że się da, ale wracamy do Project Euler. Euler to też Leonhard. "Leonard z Bazylei".

Lisp

(defun fib-gen (&optional (max 4000000) (prev 0) (next 1) (acc ()))
  (if (> prev max)
      (nreverse acc)
    (fib-gen max next (+ prev next) (cons prev acc) )))


(prin1 (apply '+ (remove-if-not #'evenp (fib-gen))))

defun to oryginał Pythonowego def. &optional to oryginał Pythonowego *args. cons to construct (tworzy listę, tu wszystko jest listą). apply to to samo co dzisiaj w Pythonie map(). Tutaj funkcją jest +, a remove-if-not to to samo co filter() w Python. Funkcją dla tego filtra jest evenp czyli predykat "czy parzyste?". W środku funkcji fib-gen jest if/else. Tak, tutaj funkcje w nazwach mogą mieć kreski, slashe, itd. Jemu wszystko jedno, pierwsze to funkcja, reszta to argumenty. Troszkę trzeba pogłówkować, ale otwiera oczy i umysły.

Odpalamy:

$ time clisp -q solution.lisp
4613732

real    0m0.008s
user    0m0.004s
sys     0m0.004s

Umie liczyć i nie tylko. Może dzisiaj już troszkę stary (na polskie realia - emeryt), ale wciąż żywy i pewnie będzie z nami kiedy inne już zdążą zardzewieć.

C++

long double już męczyliśmy wcześniej, nie ma co pokazywać.
Można do rozwiązania podejść tak, ale przedmiotowy problem jest natury pętli for/while. To może - dla odmiany, wartości dydaktycznej oraz entuzjastów -funroll-loops - zrobimy "BEZ PĘTLI".

#include <iostream>


int main()
{
    int limit = 4000000;
    int prev = 0;
    int curr = 1;
    int temp = 0;
    int total = 0;

  fib:
    temp = prev + curr;
    if (temp > limit) goto end;
    total += (temp % 2 == 0) ? temp : 0;
    prev = curr;
    curr = temp;
    goto fib;

  end:
    std::cout << total << std::endl;
}

Kompiluję, uruchamiam, patrzymy:

$ c++ loopless.cxx
$ ./a.out
4613732

Pięknie. Zero myślenia. Zajrzyjmy mu pod maskę:

$ stat -c "%s" a.out
16592
$ echo "disass main" | gdb --quiet a.out > A.txt
$

Włączam optymalizacje. Kompiluję, uruchamiam, patrzymy:

$ c++ loopless.cxx -O3
$ ./a.out
4613732
$ stat -c "%s" a.out
16624

Oo! Większy!

$ echo "disass main" | gdb --quiet a.out > B.txt
$

Output gdb: A.txt, B.txt.

Zróbmy benchmark na koniec roku 2021.

$ c++ loopless.cxx
$ time for x in `seq 2021`; do ./a.out; done > /dev/null

real    0m2.867s
user    0m2.220s
sys     0m0.794s
$ c++ loopless.cxx -O3
$ time for x in `seq 2021`; do ./a.out; done > /dev/null

real    0m2.878s
user    0m2.182s
sys     0m0.845s

Oo! Większy i wolniejszy. "Faced with a loop optimizer that has some brains, I decided to stop messing around" ...

bc

Tutaj raczej nie ma czego tłumaczyć.

define fib(limit) {
    prev = 0;
    curr = 1;
    temp = 0;
    total = 0;
    while (temp <= limit) {
        temp = prev + curr;
        if (temp % 2 == 0) {
            total += temp;
        }
        prev = curr;
        curr = temp;
    }
    return total;
}

print "EULER: ", fib(4000000), "\n"
print "UNIVERSE:\n"
fib(3.28 * 10 ^ 80)

Wow, nawet markdown go lubi.

$ cat fib.bc | bc -q
EULER: 4613732
UNIVERSE:
44225938678213751816765857140424941673835288912983305396371769220773\
4295884996994

Teraz będzie benchmark, zatem bez Universe. Wyłączamy Wszechświat. W poniższej pętli trochę czasu zżera kotek, ale bez niego bc wchodzi w tryb interaktywny. Nie ma co kombinować, sprawdzamy:

$ time for x in `seq 2021`; do cat fib.bc | bc -q; done > /dev/null

real    0m3.849s
user    0m4.556s
sys     0m1.998s

Sam kotek zjada ~2 sekundy, a dochodzi jeszcze exec bc.

$ time for x in `seq 2021`; do cat fib.bc > /dev/null; done

real    0m1.959s
user    0m1.421s
sys     0m0.670s

Kiedy bc nic nie robi, to i tak samo uruchomienie zajmuje dużo czasu:

$ time for x in `seq 2021`; do echo | bc -q > /dev/null; done

real    0m3.892s
user    0m3.526s
sys     0m1.497s

Hmmm, impressive. Podoba mi się. Bierzemy go do następnych odcinków.

Podsumowanie

Zsumowaliśmy parzyste liczby ciągu Leonarda da Pisa. Mam nadzieję, że można było się czegoś ciekawego dowiedzieć. Ja się co nieco dowiedziałem.
W następnym odcinku mamy mnożenie i liczby pierwsze. Yay, can't wait!

Jeśli ktoś dysponuje własną fotografią całego posągu Leonardo da Pisa, będę wdzięczny za podesłanie jej na email wraz z licencją. Mam jednak nadzieję, że kiedyś sam tam wrócę i tym razem go odnajdę.
W tym artykule zostawię zatem jedynie foto dwóch heptadekagonów (tutaj jest oryginał [2.8M]).

Dzisiaj nie było słonika, ale wróci.