Przeskocz do treści

Delta mi!

Informatyczny kącik olimpijski

Ploter

Tomasz Idziaszek

o artykule ...

  • Publikacja w Delcie: lipiec 2011
  • Publikacja elektroniczna: 01-07-2011

Napisanie programu, który generuje rysunek fraktala, idealnie nadaje się na zadanie dla początkującego programisty. Proste reguły prowadzące do powstania skomplikowanych wzorów powodują, że przy stosunkowo niewielkim wysiłku programistycznym można osiągnąć całkiem ambitne efekty wizualne. Ponadto samopodobieństwo fraktali pozwala ćwiczyć jedną z podstawowych koncepcji programistycznych – rekurencję.

obrazek

Rys. 1 Krzywa Peano rzędu math powstaje z połączenia dziewięciu krzywych rzędu  math
Od redakcji: czasami (np. na mathworld.wolfram.com) krzywa ta nazywana jest drugą krzywą Hilberta (ang. Hilbert II curve).

Rys. 1 Krzywa Peano rzędu math powstaje z połączenia dziewięciu krzywych rzędu  math

Nic więc dziwnego, że autorzy zadań na konkursach programistycznych chętnie sięgają po inspirację do świata fraktali. Jednym z popularnych typów zadań jest generowanie krzywych wypełniających płaszczyznę, np. krzywej Peano (zob. rysunek), Hilberta lub ich wariacji. Rozmiar takich krzywych rośnie wykładniczo względem rzędu krzywej, zatem zadanie zwykle jest formułowane następująco: dla krzywej rzędu math i liczby math stwierdzić, jakie współrzędne ma punkt płaszczyzny, leżący w odległości math od początku krzywej. Oczekuje się, że program będzie działać w czasie math

Najczęściej takie zadania nie są koncepcyjnie trudne. Przykładowo, gdybyśmy chcieli rozwiązać taki problem dla smoczej krzywej math należy rozpatrzyć dwa przypadki:

(a)
math i wtedy rekurencyjnie znajdujemy rozwiązanie dla krzywej  math albo
(b)
math i wtedy znajdujemy punkt na krzywej  math w odległości math od początku krzywej i odpowiednio przesuwamy go na płaszczyźnie.

Dla krzywej Peano rozwiązanie jest analogiczne, z tym że trzeba będzie rozpatrzyć nie dwa, ale 9 przypadków.

Można jednak zadanie sformułować na odwrót: mając dany punkt płaszczyzny math leżący na krzywej, pytamy się, w jakiej odległości od początku krzywej się on znajduje. Takie zadanie dla krzywej Peano pojawiło się m.in. na światowych finałach konkursu ACM ICPC w roku 2003 (zadanie pt. Riding the Bus). Jego rozwiązanie także jest łatwe: krzywa Peano math rzędu math mieści się na kwadratowej siatce rozmiaru math a jej długość to  math Patrząc na wartości math i math jesteśmy w stanie stwierdzić, do której z dziewięciu mniejszych krzywych math należy punkt math Jeśli należy do math-tej krzywej (w kolejności ich występowania w math), to wynikiem będzie suma math oraz rozwiązania podzadania dla krzywej math i punktu math odpowiednio obróconego. Taki algorytm działa w czasie math

Odwrotny problem dla smoczej krzywej został postawiony przed uczestnikami tegorocznych Potyczek Algorytmicznych w zadaniu Ploter. Na pierwszy rzut oka wydaje się, że pomysł, którego użyliśmy dla krzywej Peano, tu się zupełnie nie sprawdzi. Brakuje nam bowiem istotnego składnika: jak dla punktu na krzywej math sprawdzić, do której z dwóch mniejszych krzywych math on należy. Spróbujmy więc trochę inaczej: wykonajmy procedurę rekurencyjnie dla obu mniejszych krzywych. Takie rozwiązanie działać będzie, oczywiście, w czasie math  Ale zauważmy, że w wielu przypadkach, jeśli szukamy punktu na niewłaściwej krzywej mniejszego rzędu, to po kilku zejściach rekurencyjnych nasz punkt na tyle istotnie oddali się od aktualnie rozważanego kawałka krzywej, że będziemy mogli to łatwo wykryć i odciąć przeszukiwanie.

Zobaczmy, jak ta idea sprawdzi się w praktyce. Niech math oznacza ostatni punkt na krzywej rzędu math Łatwo pokazać obliczającą go rekurencję math:

display-math

Niech math będzie funkcją, która zwraca zbiór odległości, w jakich punkt math leży od początku krzywej math (dany punkt może leżeć w co najwyżej dwóch odległościach od początku krzywej). Funkcja ta wykona dwa wywołania rekurencyjne. Jej wynikiem będzie suma dwóch zbiorów:

display-math

Dla math mamy

display-math

A kiedy możemy odciąć przeszukiwanie? Nie jesteśmy w stanie łatwo sprawdzić, czy dany punkt występuje na krzywej math ale dla wielu punktów możemy powiedzieć, że na pewno na niej nie występują. Rozważmy bowiem najmniejszy prostokąt math o bokach równoległych do osi układu współrzędnych, który zawiera wszystkie punkty krzywej math Ilekroć funkcja math zostanie wywołana dla punktu math który leży poza prostokątem math możemy natychmiast zwrócić math Niech math oznaczają odległości punktu math od krawędzi prostokąta math (odpowiednio prawej, górnej, lewej i dolnej). Możemy je obliczyć rekurencyjnie math:

pict

Pozostało pytanie, na ile powyższa optymalizacja wpływa na czas działania algorYtmu. Narysujmy krzywą math i zaznaczmy wszystkie prostokąty otaczające math  krzywych math  które składają się na krzywą math Oszacujmy, ile maksymalnie narysowanych prostokątów może zawierać dany punkt math; ta liczba będzie oznaczać, ile razy wywołanie math  nie zostanie odcięte. Wynika z tego, że jeśli liczba prostokątów będzie rzędu math  to na każdym poziomie rekurencji będziemy mieli stałą liczbę wywołań, a zatem cały algorytm będzie działał w czasie math

Można udowodnić przez indukcję, że math  i math  są podzielne przez math zatem każda z krzywych math  zaczyna się w jednym z punktów zbioru math  Można też pokazać, że math  tak więc każdy z narysowanych prostokątów jest zawarty w kwadracie o boku math  Zatem prostokąty zawierające punkt math pokrywają w sumie co najwyżej 16 punktów ze zbioru math W każdym z tych punktów mogą zaczynać się co najwyżej cztery krzywe math  zatem math  należy do nie więcej niż math prostokątów. Zatem nasz algorytm istotnie działa w czasie liniowym!