711 wyrazów o optymalizacji – część 2

Tym, co nie czytali polecam lekturę części pierwszej. Tymczasem pojawił się wpis z rozwiązaniem i pojawiło się tam znacznie lepsze podejście do tematu. Optymalizacja polega na tym, że korzysta ono z właściwości, że wszystkie składowe muszą być dzielnikami 7,11 i wielokrotnością 0,01. W wersji całkowitej – muszą być liczbami całkowitymi będącymi dzielnikami. Następnie generuje kombinacje tych liczb i sprawdza właściwe warunki. Lekko dostosowany kod to:

import itertools
number = 711
iterations = 0
divs = list()
for i in range(1, number + 1):
    if 711000000 % i == 0:
        divs.append(i)

for i in itertools.combinations_with_replacement(divs, 4):
    iterations += 1
    if sum(i) == 711 and i[0] * i[1] * i[2] * i[3] == 711000000:
        print(i, iterations)

print(len(divs))

Dodałem wyświetlanie ilości dzielników – jest ich raptem 62, więc przeszukiwana przestrzeń to 62^4 czyli… niecałe 15 mln. Rozwiązanie jest znajdowane po nieco ponad 600 tys. iteracji w czasie… pomijalnym, bowiem ok. 0,2 sekundy, niezależnie od interpretera. Przy pomiarze tak niskich czasów wykonania wypadałoby się pobawić już w uśrednianie, ale chodzi o wartości orientacyjne.

W zasadzie dalsza optymalizacja nie ma sensu, ale pobawić się można. Przede wszystkim, można wyeliminować samo 711. Jeśli którakolwiek wartość byłaby taka, to pozostałe musiałyby być zerami, co jest sprzeczne z warunkami zadania. Kolejny całkowity dzielnik to połowa 711. Po uwzględnieniu tego, skrypt przyjmie postać:

import itertools
number = 711
iterations = 0
divs = list()
for i in range(1, round(number/2) + 1):
    iterations += 1
    if 711000000 % i == 0:
        divs.append(i)

for i in itertools.combinations_with_replacement(divs, 4):
    iterations += 1
    if sum(i) == 711 and i[0] * i[1] * i[2] * i[3] == 711000000:
        print(i, iterations)

print(len(divs))

Ogranicza nam to liczbę sprawdzanych dzielników do 49, przestrzeń do niecałych 6 mln, a liczbę iteracji potrzebnych do znalezienia rozwiązania do 266 tys. Dla przypomnienia, w pierwszym rozwiązaniu, które było czystym brute force zaczynaliśmy od przestrzeni 225 miliardów, czyli 44 tys. razy większej.

A gdyby tak nadal korzystać z dzielników, ale zapomnieć o itertools i wrócić do starych, dobrych pętli, tym razem nie na wartościach, tylko na indeksach w liście divs? Wersja naiwna to:

number = 711
iterations = 0
divs = list()
for i in range(1, round(number/2) + 1):
    iterations += 1
    if 711000000 % i == 0:
        divs.append(i)

for a in range(0, len(divs)-1):
    for b in range(0, len(divs)-1):
        for c in range (0, len(divs)-1):
            for d in range (0, len(divs)-1):
                iterations += 1
                if divs[a] + divs[b] + divs[c] + divs[d] == 711:
                    if divs[a] * divs[b] * divs[c] * divs[d] == 711000000:
                        print("Solved: ", divs[a], divs[b], divs[c], divs[d], iterations)
            exit()

Wyraźny krok wstecz – 3,6 mln iteracji i prawie sekunda (PyPy nadal ~0,2s). Ale otwiera nam to drogę do znanych już optymalizacji:

number = 711
iterations = 0
divs = list()
for i in range(1, round(number/2) + 1):
    iterations += 1
    if 711000000 % i == 0:
        divs.append(i)

for a in range(0, len(divs)-1):
    for b in range(a, len(divs)-1):
        for c in range (b, len(divs)-1):
            for d in range (c, len(divs)-1):
                iterations += 1
                if divs[a] + divs[b] + divs[c] + divs[d] == 711:
                    if divs[a] * divs[b] * divs[c] * divs[d] == 711000000:
                        print("Solved: ", divs[a], divs[b], divs[c], divs[d], iterations)
            exit()

Jest tak dobrze, jak przy itertoolsach: ~0,2s oraz 246 tys. iteracji. Pamiętamy jednak, że najlepsze wyniki były dla sprawdzania od największych do najmniejszych, zatem:

number = 711
iterations = 0
divs = list()
for i in range(1, round(number/2) + 1):
    iterations += 1
    if 711000000 % i == 0:
        divs.append(i)

for a in range(len(divs)-1, 0, -1):
    for b in range(a, 0, -1):
        for c in range (b, 0, -1):
            for d in range (c, 0, -1):
                iterations += 1
                if divs[a] + divs[b] + divs[c] + divs[d] == 711:
                    if divs[a] * divs[b] * divs[c] * divs[d] == 711000000:
                        print("Solved: ", divs[a], divs[b], divs[c], divs[d], iterations)
            exit()

Wynik znajdowany jest już po 30 tys. iteracji, w czasie poniżej 0,1s na zwykłym interpreterze Pythona. Co ciekawe, w tym wariancie PyPy jest nieco wolniejsze, z czasem nieco ponad 0,1s, zapewne większy narzut na uruchomienie interpretera.

UPDATE Dostępna jest kolejna część traktująca o optymalizacji.

711 wyrazów o optymalizacji

Tytuł jest przekorny – raczej nie będzie to 711 wyrazów, ale liczba na początku tytułu dobrze wpływa na klikalność. Poza tym, liczba 711 jak najbardziej jest na miejscu, a sam wpis będzie o optymalizacji.

Mianowicie pojawił się pod koniec zeszłego roku Sekurak Book Simple CTF, gdzie było pewne zadanie. Jeśli chcesz pobawić się w zrobienie tego CTF samodzielnie, choć jest już zakończony[1], to dobry moment na przerwanie lektury wpisu. Zadanie jest na tyle proste i znane, że publikacja rozwiązania nie spowoduje krzywdy, a przy tym podczas dyskusji z kolegą z pracy pojawiły się ciekawe zagadnienia natury optymalizacyjnej, więc postanowiłem opisać.

Zmodyfikowana treść zadania, z zachowaniem pierwotnego sensu, pojawiła się jako wpis zagadka o siódmej jedenaście na zaprzyjaźnionym blogu. Z moją niewielką pomocą. Jeśli ktoś nie chce się bawić w całą CTFową otoczkę, a ma ochotę wytężyć mózg, zapraszam tamże. Tym bardziej, że jest więcej zagadek.

Całość daje się sprowadzić do układu dwóch równań z czterema niewiadomymi:
a + b + c + d = 7,11
a * b * c * d = 7,11
WolframAlpha – być może niewprawnie użyty – protestował Standard computation time exceeded, ale przecież to się da policzyć… W końcu chodzi o wartości nieciągłe, bo ceny muszą być wielokrotnością jednego grosza. Wiemy zatem, że każda z wartości jest większa od zera, mniejsza od 711 i jest wielokrotnością 0,01. Tu pauza – komputery znacznie lepiej radzą sobie z wartościami całkowitymi[2], więc przemnóżmy wartości przez 100 i przejdźmy w tym momencie na resztę czasu równoważną formę:
a + b + c + d = 711
a * b * c * d = 711000000

Przy naiwnym brute force, korzystając jedynie z faktu, że wszystkie zmienne muszą być liczbami całkowitymi, mamy do sprawdzenia maksymalnie 711^4 kombinacji. Czyli 225 miliardów. Jest to wersja wyjściowa, bez żadnych optymalizacji. Można to zapisać w Pythonie w postaci:

limit = 712
iterations = 0
for a in range(1, limit):
    print(a)
    for b in range(1, limit):
        for c in range(1, limit):
            for d in range(1, limit):
                iterations += 1
                if a + b + c + d == 711:
                    if a * b * c * d == 711000000:
                        print("Solved: ", a, b, c, d, iterations)
                        exit()

Jak widać zliczam też ilość iteracji, i dzięki temu wiem, że komputer musi – w różnych pętlach – policzyć w sumie prawie do 43 miliardów, zanim znajdzie rozwiązanie. Czas znalezienia rozwiązania litościwe pominę – spokojnie można wybrać się na spacer czy zakupy.

Mamy jednak dwie dodatkowe własności: iloczyn czterech liczb oraz ich sumę. Jeśli ktoś bawił się w sprawdzanie, czy dana liczba jest pierwsza, to pamięta zapewne, jeśli liczba jest złożona, to mniejszy dzielnik będzie co najwyżej równy pierwiastkowi danej liczby. W naszym przypadku iloczyn dwóch niewiadomych będzie co najwyżej równy pierwiastkowi z 711000000, czyli 26665. Z kolei któraś z niewiadomych będzie mniejsza od pierwiastka z 26665, czyli nieco ponad 163. Można więc ograniczyć jedną ze zmiennych – oczywiście tę najczęściej używaną – do 163.
Dodatkowo, niewiele myśląc, można skorzystać z własności sumowania i ograniczyć pozostałe zmienne do 711-163=548. Czyli zmniejszyć przeszukiwaną przestrzeń do niecałych 27 miliardów. Oblekając to w skrypt, pierwsza optymalizacja wygląda następująco:

limit = 549
iterations = 0
for a in range(1, limit):
    print(a)
    for b in range(1, limit):
        for c in range(1, limit):
            for d in range(1, 164):
                iterations += 1
                if a + b + c + d == 711:
                    if a * b * c * d == 711000000:
                        print("Solved: ", a, b, c, d, iterations)
                        exit()

Czas potrzebny na rozwiązanie nadal pomijam, ale jest to raczej tyle, ile potrzeba na zrobienie kawy czy herbaty, niż zakupów. Ilość iteracji potrzebnych do znalezienia rozwiązania to niecałe 6 mld.

Jeśli nie pamiętamy o ciekawej własności związanej z dzielnikami danej liczby, to nadal możemy zoptymalizować pętle tak, żeby automatycznie uwzględniać w nich warunek związany z sumą . Daną zmienną zwiększamy do wartości zależnej od wartości pozostałych zmiennych. Dzięki temu można zaobserwować, że w miarę wzrostu wartości zmiennej a sprawdzenia pozostałych są coraz szybsze.

limit = 712
iterations = 0
for a in range(1, limit):
    print(a)
    for b in range(1, limit - a):
        for c in range(1, limit - a - b):
            for d in range(1, limit - a - b - c):
                iterations += 1
                if a + b + c + d == 711:
                    if a * b * c * d == 711000000:
                        print("Solved: ", a, b, c, d, iterations)
                        exit()

Rozwiązanie jest znajdowane nawet nieco szybciej, niż w poprzednim przypadku – ok. 5,5 mld iteracji.

Tu pojawił się pomysł: co gdyby zapisać to w taki sposób, by program działał na początku bardzo szybko i zwalniał? Czyli nie zaczynamy od niskich wartości zmiennych i zwiększamy, tylko zaczynamy od wysokich i zmniejszamy? Można to zapisać następująco:

limit = 711
iterations = 0
for a in range(limit, 0, -1):
    print(a)
    for b in range(limit - a, 0, -1):
        for c in range(limit - a - b, 0, -1):
            for d in range(limit - a - b - c, 0, -1):
                iterations += 1
                if a + b + c + d == 711:
                    if a * b * c * d == 711000000:
                        print("Solved: ", a, b, c, d, iterations)
                        exit()

Przyznaję, że efekty tego podejścia mnie zaskoczyły. Tylko 1 mld operacji potrzebnych do znalezienia rozwiązania. Na moim sprzęcie skrypt wykonał się poniżej 3 minut na Pythonie 3.8.3. Co ciekawe dla Pythona 2.7.18 był to czas poniżej 2 minut, więc znacznie szybciej. Oba z paczek z repo Debiana unstable. Ale to raczej tylko ciekawostka, Python 2 jest martwy.

Dochodzimy jednak do sedna. Widać, że interpreter Pythona ma znaczenie. Jeśli zależy nam na szybkości, to warto zainteresować się alternatywną implementacją Pythona, czyli projektem PyPy. PyPy w wersji 7.3.1 (kompatybilna z Pythonem 3.6.9) wykonuje powyższy program w… nieco ponad 3 sekundy. Czyli jakieś pięćdziesiąt razy szybciej. Jeśli wrócimy do rozwiązania drugiego, to okaże się, że z użyciem PyPy można je znaleźć w 17 sekund. Natomiast wersja naiwna to… raptem 2 minuty.

Jak widać, szybkie narzędzia mogą prowadzić do lenistwa umysłowego – skoro działa szybko, to po co optymalizować? Optymalizacja algorytmu w powyższych przykładach nie jest skończona, da się lepiej. Znacznie lepiej. Na razie zostawię pole do popisu czytelnikom w komentarzach, za jakiś tydzień zaktualizuję wpis.

[1] Jest to naprawdę prosty CTF, jeśli ktoś nie miał okazji się bawić – polecam przymierzyć.
[2] Jak mawiają: real programmers use integers.

UPDATE Dostępne są kolejne wpisy na ten temat.

Ergonomia maców – system i software

W poprzednim wpisie o podobnym tytule było o hardware maców, pora zająć się softem i systemem. Będzie z ponad półrocznej perspektywy, ale muszę przyznać, że niewiele się od pierwszego wrażenia zmieniło. A nie ukrywam, że jestem rozczarowany. Mocno rozczarowany.

Pierwsze na co się naciąłem korzystając z macowego filesystemu to fakt, że jest prawie case sensitive. Prawie. Otóż lubię robić symlink download -> Download, bo wygodniej się wpisuje bez shift w konsoli. I co? I nie da się. Nawet nie ma sensownego komunikatu o błędzie.

Odnoszę wrażenie, że ergonomia maców generalnie jest chwalona. Spotkałem się z opiniami, że maci są wygodne, dopracowane, a system jest przyjazny. Tymczasem konfiguracja wyglądu kuleje i nie daje miejsca na dostosowanie do indywidualnych preferencji, nawet w podstawowym zakresie.

Pasek z zegarkiem, statusem Wi-Fi, stanem baterii itp. musi być u góry. Nie da się przesunąć go na dół i sprawdzić, by zawsze był widoczny. Nie i już – musi być u góry, a o tym, że ktoś chciałby cały czas mieć dostęp do zegarka itp. również jakby nie pomyślano – o tym niżej.

Na dole jest z kolei dock, który IMO jest totalnie nieprzydatny i – jak pisała Yzoja – zachowuje się dziwnie i nielogicznie. Na szczęście da się skonfigurować jego ukrywanie, więc kontakt z nim ogranicza się w zasadzie do „skaczących” powiadomień. Może są one ładne, ale na dłuższą metę irytujące. Na szczęście nie wyskakują często.

Przyciski do maksymalizacji, minimalizacji okien są po lewej stronie belki okna i nie da się tego zmienić. Dodatkowo brakuje przycisku do maksymalizacji okna takiej, aby pasek statusu był widoczny. Da się to osiągnąć klikając na belce okna, ale przycisku nie ma, a domyślna maksymalizacja ukrywa pasek z zegarkiem i nie da się tego zmienić konfiguracją. Jak dla mnie poświęcenie kikunastu pikseli ekranu to niewielka cena za stały dostęp do skrótu uruchamiania programów, statusu programu i kilku innych informacji.

Systemowy zegarek. Gdy zobaczyłem, że kliknięcie w zegarek nie pokazuje kalendarza, zupełnie zwątpiłem w zdrowy rozsądek twórców systemu. Funkcja bardzo przydatna, obecna i w Linuksie, i w Windows, nawet nie sądziłem, że tak często z niej korzystam. Nie znalazłem fajnego darmowego rozwiązania, które naprawia ten problem, ale przyznaję, że szukałem pobieżnie – i tak mam cały czas w pracy otwarty Google Calendar.

Przełączanie między programami – pisałem, że mi się podoba, bo przełącza nie kolejno, tylko na ostatnio używane, co jest nawet fajne. Niestety, jeśli mamy uruchomione dwie instancje tego samego programu, np. Firefox z dwoma różnymi profilami, to przełączanie cmd-tab nie działa między nimi i trzeba korzystać z innego skrótu: cmd-`. Czyli najpierw cmd-tab, by wybrać Firefoksa, potem cmd-`, by wybrać instancję. Korzystam na szczęście rzadko, bo jest to wyjątkowo niewygodne. Oczywiście możliwości zmiany zachowania brak. Jak cmd-c i cmd-v działające między wszystkimi aplikacjami są plusem, tak ww. jest analogicznym minusem. IMO bardziej upierdliwym.

Aplikacje i ich instalacja. Yzoja nazwała instalator aplikacji żartem dla kilkulatków. Faktycznie jest fatalnie. Oczywistą wadą w stosunku do Linuksa jest brak centralnego zarządzania pakietami. Nie da się jednym poleceniem/kliknięciem zaktualizować wszystkich aplikacji. Wymagana jest instalacja każdego programu z osobna, a aktualizacje żyją własnym życiem. Miało być łatwo (przeciągnięcie w celu instalacji), wyszło koślawo, bo każda aplikacja realizuje to trochę inaczej (np. pozwalając na różne wersje tej samej aplikacji lub nie), są też aplikacje instalowane zupełnie inaczej (brew, macports). Czyli nie jest to jeden sposób instalacji. Dodatkowo kwestię instalacji nowych wersji komplikuje wymuszanie zamknięcia działających instancji. A czasem po prostu wygodniej jest zainstalować nowszą wersję, dokończyć pracę w starej i dopiero wtedy zrobić restart.

Stabilność aplikacji pozostawia wiele do życzenia. LibreOffice Calc z arkuszem kalkulacyjnym, nawet pustym, wiesza mi się po kilkunastu sekundach. Na szczęście nie potrzebuję arkusza, tj. mam online. Może reinstalacja pomoże, tylko zastanawiam się, co poszło nie tak, bo wersję mam najnowszą…

Virtualbox nie jest szybki (w porównaniu z natywnym linuksowym KVM), ale to jakby niekrytyczne i jestem w stanie wybaczyć. Za to po wybudzeniu z uśpienia zużywał 100% CPU i powodował radosne wycie wiatraków, a tego wybaczyć nie mogę. Drążyłem temat, dobrzy ludzie z supportu desktopów w firmie podpowiedzieli polecenia diagnostyczne (konsola, dużo konsoli), w logach padały często odniesienia do dźwięku i faktycznie – po wyłączeniu dźwięku w wirtualkach jest spokój. Ponieważ moje VMki to raczej serwery, to brak dźwięku w nich mnie nie boli, ale wada pozostaje wadą.

Skoro przy dźwięku jesteśmy to kolejna sprawa. Korzystam ze stacji dokującej (Belkin), do której podłączone mam słuchawki. Po odłączeniu od stacji dźwięk przełącza się na wbudowane głośniki, po podpięciu z powrotem do stacji wraca na słuchawki. Prawie zawsze, bo czasem nie wróci i trzeba klikać w ustawieniach systemowych. Urządzenie jest widoczne, więc nie wiem o co mu chodzi. W każdym razie i w zakresie współpracy ze stacją dokującą ergonomia maców pozostawia sporo do życzenia.

Ja rozumiem, że I don’t like the bugs, but the bugs like me zobowiązuje, ale jak dla mnie macOS łączy najgorsze cechy Windowsa i Linuksa i zdecydowanie daleko mu do wygodnego, bezproblemowego systemu, działającego OOTB, jak niektórzy próbują go przedstawać. YMMV