Informatyk gra na giełdzie
Nasz znajomy informatyk zdecydował się zainwestować część swoich oszczędności na giełdzie papierów wartościowych. Jak na informatyka przystało, do grania na giełdzie postanowił zaprząc komputer. W tym celu, korzystając z najnowszych trendów sztucznej inteligencji, napisał program, który na podstawie przeszłych notowań giełdowych przewiduje, jak kurs akcji będzie się zmieniał w przyszłości, i podejmuje decyzje o kupnie bądź sprzedaży. Nasz znajomy przetestował program, uruchamiając go na dużym zbiorze archiwalnych notowań. Zastanawia się teraz, jak dobrze jego program sobie poradził – stanął zatem przed problemem wyznaczenia najlepszej możliwej gry na giełdzie, jeśli znamy wszystkie notowania.
Model gry na giełdzie będzie następujący. Mamy daną tablicę z notowaniami giełdowymi w kolejnych dniach: oznacza cenę jednej akcji w -tym dniu (dla uproszczenia przyjmiemy, że mamy tylko jeden rodzaj akcji). Na początku dysponujemy kwotą i możemy wykonać nie więcej niż operacji kupna-sprzedaży akcji (zakładamy, że możemy kupować ułamkową liczbę akcji). Zauważmy, że nie potrzebujemy wykonywać operacji równolegle (tzn. przed każdym kupnem opłaca się nam najpierw sprzedać wszystkie posiadane akcje). Ponadto warto też kupować akcje za całą dostępną kwotę. Z tego wynika, że jeśli pierwszą operację kupna przeprowadzimy w dniu a odpowiadającą jej sprzedaż w dniu to po tej operacji będziemy mieli kwotę Naszym celem jest zmaksymalizowanie kwoty po wszystkich operacjach, czyli iloczynu
Możemy pozbyć się mnożeń i dzieleń, logarytmując powyższy wzór. Innymi słowy, równoważnie możemy zmaksymalizować sumę
W końcu jeśli wprowadzimy pomocniczą tablicę która zawierać będzie zmiany zlogarytmowanych notowań, tzn. to nasze zadanie sprowadzi się do wybrania co najwyżej rozłącznych fragmentów które maksymalizują sumę liczb do nich należących (Rys. 1):
Dla jest to klasyczne zadanie znajdowania fragmentu tablicy o największej sumie, które może być znane Czytelnikom. Z kolei jego uogólnienie dla było treścią zadania pt. Tanie linie, które pojawiło się podczas weekendowej rundy Potyczek Algorytmicznych 2012. Oba problemy mają liczne ciekawe rozwiązania. W dalszej części artykułu przedstawimy, z pożytkiem dla znajomego informatyka, aż osiem z nich.
***
Na początek przypomnijmy, jak rozwiązać zadanie dla Algorytm A1 jest bardzo prosty: w czasie możemy przebadać wszystkie możliwe fragmenty, ustalając lewy koniec fragmentu i iterując po kolejnych możliwych prawych końcach.
Algorytm A2 wykona to samo w optymalnym czasie Przeglądamy elementy tablicy od lewej do prawej i trzymamy – maksymalny fragment w przedziale oraz – maksymalny fragment, który dotyka prawego końca tego przedziału (tzn. kończy się elementem ). Na początek i inicjujemy zerami, a następnie wykonujemy pętlę:
Zadanie dla można rozwiązać, korzystając z metody programowania dynamicznego i uogólniając algorytm A2. Przez oznaczmy największą sumę liczb z przedziału zawartych w co najwyżej rozłącznych fragmentach, a przez to samo, ale z zastrzeżeniem, że ostatni fragment zawiera element Rekurencja (bez uwzględniania warunków brzegowych) jest następująca:
Rozwiązaniem jest ; algorytm A3 działa w czasie
Poszukując szybszego algorytmu dla spróbujmy odpowiedzieć na pytanie: Czy, mając optymalne rozwiązanie dla fragmentów, da się je łatwo rozszerzyć do fragmentów? Zatrzymajmy się nad przypadkiem Powiedzmy, że fragment ma największą sumę. Jak może wyglądać optymalne rozwiązanie dla dwóch fragmentów Rozważmy dwa przypadki (Rys. 2).
- (1) Jeden z fragmentów (powiedzmy ) jest rozłączny z Wtedy jest poprawnym rozwiązaniem, zatem z optymalności dostajemy, że Ale z optymalności mamy zatem fragmenty i mają tę samą sumę. To pokazuje, że jest również optymalne.
- (2) Oba fragmenty przecinają Jeśli to fragment musi mieć sumę zero (inaczej moglibyśmy poprawić dodając do niego ten fragment, lub poprawić usuwając ten fragment). Zatem usunięcie tego fragmentu z nie zmieni wyniku. Analogicznie dla fragment musi mieć sumę zero i można go dodać do Stosując takie samo rozumowanie do prawego końca fragmentu dostajemy, że istnieje optymalne rozwiązanie w którym i
Udowodniliśmy zatem, że możemy znaleźć optymalne rozwiązanie dla rozszerzając fragment o największej sumie. W tym celu: albo (1) dodajemy nowy fragment o największej sumie, który jest rozłączny z albo (2) znajdujemy fragment o najmniejszej sumie zawarty całkowicie w i usuwamy go, dzieląc na dwa fragmenty. Wybieramy ten wariant, który lepiej poprawia wynik.
Zachęceni tym sukcesem moglibyśmy wykonać trochę eksperymentów praktycznych i przekonać się, że pomysł ten działa dla dowolnego Niech to zbiór fragmentów o maksymalnej łącznej sumie, a to pozostałe części tablicy (Rys. 3). Aby uzyskać optymalne fragmentów, albo dołączamy maksymalny fragment, który jest zawarty w pewnym albo usuwamy minimalny fragment z pewnego Zachęcamy do próby dowodu poprawności tego rozwiązania. To może nie być proste, ale dla Czytelników Wytrwałych na końcu artykułu podamy wskazówkę, jak można się do tego zabrać.
Pozostaje kwestia efektywnej implementacji tego pomysłu. Jedną fazę możemy wykonać w czasie uruchamiając algorytm A2 na każdym przedziale osobno. Tak zapisany algorytm A4 będzie działał w czasie co nie daje nam jeszcze zysku w porównaniu z algorytmem A3. Widać, że kluczową operacją jest odpowiadanie na pytania „jaki jest maksymalny fragment w danym przedziale?” dla różnych przedziałów. Pokażemy teraz, jak to robić efektywnie.
W tym celu może nam pomóc jeszcze jeden algorytm dla Algorytm A5 będzie oparty o metodę „dziel i zwyciężaj”. Mając dany przedział o długości możemy go podzielić na dwie części o długości Maksymalny fragment w tym przedziale może znajdować się w całości w lewej części, w całości w prawej części lub może składać się z maksymalnego fragmentu, który dotyka prawej krawędzi lewej części, oraz maksymalnego fragmentu, który dotyka lewej krawędzi prawej części (por. też Rys. 4).
Załóżmy, że i zbudujmy drzewo przedziałowe (Rys. 5). Drzewo będzie miało węzły o numerach od 1 do W węźle dla tego drzewa będą znajdować się informacje o przedziale a konkretnie: – suma liczb w przedziale, – maksymalny fragment w przedziale oraz i – maksymalne fragmenty dotykające odpowiednio lewego i prawego końca przedziału. Wartości w węzłach najniższego poziomu (tzn. dla ) inicjujemy, przyjmując oraz Wartości w pozostałych węzłach wyznaczamy na podstawie wartości w węzłach i które odpowiadają lewej i prawej połowie przedziału:
Wyznaczenie wartości we wszystkich węzłach zabiera czas i w takim czasie działa algorytm A5. Odpowiedzią jest, oczywiście, wartość
Skonstruowane drzewo przedziałowe wyróżnia się tym, że umożliwia ono znalezienie największego fragmentu dla dowolnego przedziału tablicy w czasie W tym celu przypomnijmy, że każdy przedział można podzielić na przedziałów bazowych, tzn. przedziałów, które odpowiadają węzłom drzewa przedziałowego (Rys. 5). Niech będą kolejnymi węzłami odpowiadającymi takiemu podziałowi. Wtedy maksymalny fragment to będzie albo dla pewnego albo
dla pewnych
Poniższa pętla wyznacza maksymalny fragment w czasie :
Algorytm A6 jest następujący: najpierw budujemy drzewo przedziałowe (jak w algorytmie A5) oraz drugie drzewo przedziałowe, które będzie liczyło minimalne fragmenty. Oprócz wartości będziemy potrzebować również końców fragmentów – odpowiednie wzbogacenie drzewa przedziałowego zostawiamy jako ćwiczenie dla Czytelników. Wszystkie przedziały trzymamy w kolejce priorytetowej: przedziały z priorytetami równymi minimalnym fragmentom w tych przedziałach, zaś przedziały z priorytetami równymi maksymalnym fragmentom.
Każdy krok algorytmu to wyciągnięcie z kolejki przedziału o priorytecie o największej wartości bezwzględnej, uaktualnienie wyniku o wartość bezwzględną tego priorytetu, a następnie dodanie trzech nowych przedziałów do kolejki. Cały algorytm działa w czasie
***
Algorytm A6 jest efektywny, ale dość skomplikowany w implementacji. Przedstawimy teraz prostszy (choć nieco zaskakujący) algorytm, na który autor artykułu wpadł, próbując udowodnić poprawność algorytmów A4 i A6. Algorytm ten korzysta z metody „spróbujmy to zrobić od końca”.
Wypiszmy liczby z tablicy w ciągu i podzielmy go na maksymalne bloki liczb o tym samym znaku (na potrzeby definicji bloku traktujemy 0 jako liczbę dodatnią). Zauważmy, że w optymalnym rozwiązaniu każdy fragment musi zaczynać się i kończyć pełnym blokiem, który zawiera liczby dodatnie (jeśli kończyłby się niepełnym blokiem dodatnim, to moglibyśmy ten fragment rozszerzyć, uzyskując nie gorsze rozwiązanie, a gdyby kończył się blokiem ujemnym – moglibyśmy go skrócić). Możemy więc zastąpić każdy blok przez jedną liczbę będącą sumą elementów tego bloku. Dodatkowo dodajmy na obu końcach ciągu liczbę-strażnika (Rys. 6).
Jeśli w nowym ciągu mamy co najwyżej liczb dodatnich, to ich suma jest rozwiązaniem zadania. W przeciwnym przypadku będziemy iteracyjnie skracać ciąg, nie zmieniając optymalnego rozwiązania.
Faza skracania jest następująca: wybieramy liczbę w ciągu o najmniejszej wartości bezwzględnej, a następnie zastępujemy ją i dwie liczby z nią sąsiadujące – ich sumą. Zauważmy, że nowa liczba będzie miała przeciwny znak do więc krótszy ciąg nadal będzie zawierał naprzemiennie liczby dodatnie i ujemne. Fazę skracania można wykonać w czasie – wystarczy trzymać elementy ciągu na liście, a poza tym mieć kolejkę priorytetową z liczbami uporządkowanymi względem ich wartości bezwzględnych. Zatem algorytm A8 będzie działał w czasie
Pozostaje udowodnić poprawność operacji skracania. Powiedzmy, że ciąg składa się z 9 liczb oraz że liczbą o najmniejszej wartości bezwzględnej jest (dodatnia), więc chcemy skrócić ciąg, zastępując przez ich sumę (Rys. 7). Chcemy wykazać, że istnieje optymalne rozwiązanie, w którym każdy fragment albo zawiera wszystkie liczby albo nie zawiera żadnej z nich. W tym celu pokażemy, jak z rozwiązania optymalnego niespełniającego tego warunku skonstruować rozwiązanie optymalne, które go spełnia. Rozważymy dwa przypadki:
- (1) Rozwiązanie zawiera fragment, do którego należą dwie spośród liczb Wyrzucając z tego fragmentu obie te liczby, dostaniemy nie gorsze rozwiązanie (bo ).
- (2) Rozwiązanie zawiera jedną liczbę (czyli fragment ). Fragmentów jest mniej niż liczb dodatnich. Jeśli zatem jakaś z liczb dodatnich (powiedzmy ) nie należy do żadnego fragmentu, to zamieniając ją z dostaniemy nie gorsze rozwiązanie (bo ). W przeciwnym przypadku co najmniej jeden fragment zawiera więcej niż jedną liczbę (powiedzmy ). Możemy zatem wyrzucić z niego jedną liczbę ujemną rozbijając go na dwa fragmenty. Wyrzucając również fragment znowu dostaniemy nie gorsze rozwiązanie (bo ).
Dowód, gdy liczba o najmniejszej wartości bezwzględnej jest ujemna, jest symetryczny i zostawiamy go jako ćwiczenie dla Czytelników.
I wreszcie nadszedł czas na obiecaną wskazówkę do dowodu poprawności algorytmów A4 i A6. Wykonujmy kolejne fazy tych algorytmów, do momentu, aż wszystkie fragmenty będą zawierać liczby o tych samych znakach. Następnie wykonajmy na tak uzyskanym ciągu algorytm A8. Porównując podział tablicy po -tej fazie algorytmu A4 i przed -tą od końca fazą algorytmu A8, możemy dojść do wniosku, że w zasadzie te algorytmy działają tak samo, tylko w odwrotnej kolejności. Szkoda, że na giełdzie nie można najpierw sprzedać, a potem kupić...