GSoC 2019: Sprawdzanie grafów pod względem dwudolności i monadyczne transformatory

W zeszłym lecie brałem udział w Google Summer of Code — programie dla studentów od firmy Google. Co roku organizatorzy wybierają kilka projektów Open Source, w tym od takich znanych organizacji jak Boost.org i The Linux Foundation. Aby pracować nad tymi projektami, Google zaprasza studentów z całego świata. 

Jako uczestnik Google Summer of Code 2019 realizowałem projekt w ramach biblioteki Alga z organizacją Haskell.org, zajmującą się rozwojem języka Haskell — jednego z najbardziej znanych języków programowania funkcyjnego. Alga to biblioteka, która przedstawia typowo bezpieczne reprezentacje grafów w Haskellu. Jest używana, na przykład, w semantic — bibliotece firmy Github, która buduje przy pomocy kodu drzewa semantyczne, grafy wywołań i zależności oraz potrafi je porównywać. Mój projekt polegał na dodaniu tam typowo bezpiecznej reprezentacji dla grafów dwudzielnych oraz algorytmów dla tej reprezentacji. 

W poście opowiem o mojej implementacji algorytmu sprawdzania grafu na dwudzielność w Haskellu. Mimo że algorytm jest jednym z najprostszych, jego piękna implementacja w stylu funkcyjnym zajęła mi kilka iteracji i wymagała dość dużo pracy. W rezultacie zdecydowałem się na implementację z transformatorami monad. 

GSoC 2019: Sprawdzanie grafów pod względem dwudolności i monadyczne transformatory

O mnie

Nazywam się Wasilij Alfiorow, jestem studentem czwartego roku w Petersburskiej Wyższej Szkole Ekonomicznej. Wcześniej na blogu pisałem o moim projekcie o algorytmach parametryzowanych i o podróży na ZuriHac. W tym momencie odbywam staż na Uniwersytecie w Bergen w Norwegii, gdzie zajmuję się podejściami do zadania List Coloring. Moje zainteresowania obejmują algorytmy parametryzowane oraz programowanie funkcyjne.

O implementacji algorytmu

Wstęp

Studentom biorącym udział w programie zdecydowanie zaleca się prowadzenie bloga. Dostałem platformę do bloga Summer of Haskell. Ten artykuł to tłumaczenie artykułu, które napisałem tam w lipcu po angielsku, z krótkim wprowadzeniem. 

Pull Request z kodem, o którym mowa, można znaleźć tutaj.

O wynikach mojej pracy można przeczytać (w języku angielskim) tutaj.

Post zakłada zaznajomienie czytelnika z podstawowymi pojęciami programowania funkcyjnego, choć postaram się przypomnieć wszystkie używane terminy, gdy przyjdzie na nie czas.

Sprawdzanie grafów na dwudzielność 

Algorytm sprawdzania, czy graf jest dwudzielny, zwykle jest przedstawiany w kursach algorytmów jako jeden z najprostszych algorytmów grafowych. Jego idea jest prosta: najpierw w jakiś sposób umieszczamy wierzchołki w lewej lub prawej części, a przy wykryciu konfliktowej krawędzi stwierdzamy, że graf nie jest dwudzielny.

Nieco dokładniej: najpierw umieszczamy jakiś wierzchołek w lewej części. Oczywiście wszyscy sąsiedzi tego wierzchołka muszą znajdować się w prawej części. Następnie wszyscy sąsiedzi sąsiadów tego wierzchołka muszą znajdować się w lewej części itd. Kontynuujemy przypisywanie wierzchołków do części, dopóki w spójnym składniku wierzchołka, od którego zaczęliśmy, są jeszcze wierzchołki, którym nie przypisano sąsiadów. Następnie powtarzamy tę czynność dla wszystkich składników spójności.

Jeśli istnieje krawędź między wierzchołkami, które znajdują się w tej samej części, nietrudno znaleźć w grafie nieparzysty cykl, co jest powszechnie wiadomo (i dość oczywiste), że nie może mieć miejsca w grafie dwudzielnym. W przeciwnym razie mamy poprawny podział na części, co oznacza, że graf jest dwudzielny.

Zwykle ten algorytm realizuje się za pomocą przeszukiwania wszerz lub przeszukiwania w głąb. W językach imperatywnych zazwyczaj stosuje się przeszukiwanie w głąb, jako nieco prostsze i nieskrzyniające dodatkowych struktur danych. Wybrałem też przeszukiwanie w głąb jako bardziej tradycyjne.

W ten sposób doszliśmy do następującego schematu. Przechodzimy przez wierzchołki grafu za pomocą przeszukiwania w głąb i przypisujemy im części, zmieniając numer części podczas przechodzenia po krawędzi. Jeśli próbujemy przypisać część wierzchołkowi, któremu już przypisano część, można śmiało stwierdzić, że graf nie jest dwudzielny. W momencie, gdy wszystkim wierzchołkom przypisano część i zbadaliśmy wszystkie krawędzie, mamy dobry podział.

Czystość obliczeń

W Haskellu zakładamy, że wszystkie obliczenia są czyste. Jednak gdyby tak było w rzeczywistości, nie mielibyśmy możliwości wyświetlenia czegokolwiek na ekranie. W ogóle, czyste obliczenia są tak leniwe, że nie ma żadnego czystego powodu, by coś obliczać. Wszystkie obliczenia, które odbywają się w programie, są w jakiś sposób wymuszone w «nieczystej» monadzie IO.

Monady to sposób reprezentacji obliczeń z efektami w Haskellu. Wyjaśnienie, jak działają, wykracza poza ramy tego posta. Dobre i klarowne opisy można przeczytać po angielsku tutaj.

Chcę zauważyć, że podczas gdy niektóre monady, takie jak IO, są zaimplementowane za pomocą magii kompilatora, prawie wszystkie inne są realizowane programowo, a wszystkie obliczenia w nich są czyste.

Efektów jest bardzo wiele i dla każdego stworzono swoją monadę. To bardzo silna i piękna teoria: wszystkie monady implementują ten sam interfejs. Będziemy mówić o następujących trzech monadach:

  • Either e a — obliczenie, które zwraca wartość typu a lub rzuca wyjątek typu e. Zachowanie tej monady jest bardzo podobne do pracy z wyjątkami w językach imperatywnych: błędy mogą być przechwytywane lub przenoszone dalej. Główna różnica polega na tym, że monada jest w pełni logicznie zaimplementowana w standardowej bibliotece tego samego Haskella, podczas gdy w językach imperatywnych zazwyczaj używa się mechanizmów systemu operacyjnego.
  • State s a — obliczenie, które zwraca wartość typu a i ma dostęp do zmiennego stanu typu s.
  • Maybe a. Monada Maybe wyraża obliczenie, które może zostać przerwane zwrotem Nothing w dowolnym momencie. Jednak będziemy mówić o implementacji klasy MonadPlus dla typu Maybe, wyrażającej przeciwny efekt: to obliczenie, które może być przerwane zwrotem konkretnej wartości w dowolnym momencie.

Implementacja algorytmu

Mamy dwa typy danych, Graph a i Bigraph a b, z których pierwszy reprezentuje grafy z węzłami oznaczonymi wartościami typu a, a drugi reprezentuje grafy dwudzielne z węzłami lewej części oznaczonymi wartościami typu a i węzłami prawej części oznaczonymi wartościami typu b.

To nie są typy z biblioteki Alga. W Alga nie ma przedstawienia dla nieskierowanych grafów dwudzielnych. Typy zrobiłem takie dla przejrzystości.

Potrzebujemy również funkcji pomocniczych o następujących sygnaturach:

-- Lista sąsiednich wierzchołków.
neighbours :: Ord a => a -> Graph a -> [a]

-- Zbudować graf dwudzielny na podstawie grafu i funkcji, która dla każdego wierzchołka
-- zwraca jego część i znacznik w nowej części, ignorując konfliktowe krawędzie.
toBipartiteWith :: (Ord a, Ord b, Ord c) => (a -> Either b c)
						-> Graph a
						-> Bigraph b c

-- Lista wierzchołków w grafie
vertexList :: Ord a => Graph a -> [a]
Sygnatura funkcji, którą będziemy pisać, wygląda następująco:

type OddCycle a = [a]
detectParts :: Ord a => Graph a -> Either (OddCycle a) (Bigraph a a)

Łatwo zauważyć, że jeśli podczas przeszukiwania w głąb znaleźliśmy konfliktową krawędź, nieparzysta pętla znajduje się na szczycie stosu rekurencji. W związku z tym, aby ją odtworzyć, musimy odciąć od stosu rekursji wszystko do pierwszego wystąpienia ostatniego wierzchołka.

Zaimplementujemy przeszukiwanie w głąb, utrzymując asocjacyjny zbiór numerów części dla każdego wierzchołka. Stos rekurencji będzie automatycznie utrzymywany poprzez implementację klasy Functor wybranej przez nas monady: wystarczy umieścić wszystkie wierzchołki z ścieżki w wyniku zwracanym z funkcji rekurencyjnej.

Moim pierwszym pomysłem było użycie monady Either, która zdaje się realizować efekty, których potrzebujemy. Moja pierwsza implementacja była bardzo bliska tej wersji. W rzeczywistości miałem pięć różnych implementacji w pewnym momencie, a ostatecznie zatrzymałem się na innej.

Po pierwsze, musimy utrzymać asocjacyjny zbiór identyfikatorów części — to coś związanego z State. Po drugie, musimy móc zatrzymać się w przypadku wykrycia konfliktu. Może to być albo Monad dla Either, albo MonadPlus dla Maybe. Główna różnica polega na tym, że Either może zwracać wartość w przypadku, gdy obliczenie nie zostało zatrzymane, podczas gdy Maybe w takim przypadku zwraca tylko informację o tym. Ponieważ nie potrzebujemy oddzielnej wartości w przypadku sukcesu (już jest przechowywana w State), wybieramy Maybe. A w momencie, gdy musimy łączyć efekty dwóch monad, wystają transformatory monad, które właśnie łączą te efekty.

Dlaczego wybrałem taki skomplikowany typ? Dwie przyczyny. Po pierwsze, realizacja jest bardzo zbliżona do imperatywnej. Po drugie, musimy manipulować wartością zwracaną w przypadku konfliktu, przy powrocie z rekurencji w celu odtworzenia nieparzystego cyklu, a to dużo łatwiej zrobić w monadzie Maybe.

W ten sposób uzyskujemy taką realizację.

{-# LANGUAGE ExplicitForAll #-}
{-# LANGUAGE ScopedTypeVariables #-}

data Part = LeftPart | RightPart

otherPart :: Part -> Part
otherPart LeftPart  = RightPart
otherPart RightPart = LeftPart

type PartMap a = Map.Map a Part
type OddCycle a = [a]

toEither :: Ord a => PartMap a -> a -> Either a a
toEither m v = case fromJust (v `Map.lookup` m) of
                    LeftPart  -> Left  v
                    RightPart -> Right v

type PartMonad a = MaybeT (State (PartMap a)) [a]

detectParts :: forall a. Ord a => Graph a -> Either (OddCycle a) (Bigraph a a)
detectParts g = case runState (runMaybeT dfs) Map.empty of
                    (Just c, _)  -> Left  $ oddCycle c
                    (Nothing, m) -> Right $ toBipartiteWith (toEither m) g
    where
        inVertex :: Part -> a -> PartMonad a
        inVertex p v = ((:) v) <$ do modify $ Map.insert v p
                                        let q = otherPart p
                                        msum [ onEdge q u | u  a -> PartMonad a
        onEdge p v = do m  inVertex p v
                            Just q  -> do guard (q /= p)
                                           return [v]

        processVertex :: a -> PartMonad a
        processVertex v = do m <- get
                            guard (v `Map.notMember` m)
                            inVertex LeftPart v

        dfs :: PartMonad a
        dfs = msum [ processVertex v | v  [a]
        oddCycle c = tail (dropWhile ((/=) last c) c)

Blok where jest rdzeniem algorytmu. Postaram się wyjaśnić, co wewnątrz niego się dzieje.

  • inVertex to część przeszukiwania w głąb, w której odwiedzamy wierzchołek po raz pierwszy. Tutaj przypisujemy wierzchołkowi numer części i uruchamiamy onEdge dla wszystkich sąsiadów. To również miejsce, w którym odtwarzamy stos wywołań: jeśli msum zwróciło wartość, dodajemy wierzchołek v.
  • onEdge — to część, w której odwiedzamy krawędź. Jest wywoływana dwa razy dla każdej krawędzi. Tutaj sprawdzamy, czy wierzchołek z drugiej strony został odwiedzony, i odwiedzamy go, jeśli nie. Jeśli został już odwiedzony, sprawdzamy, czy krawędź jest konfliktowa. Jeśli jest, zwracamy wartość — samą górę stosu rekurencji, na którą następnie podczas zwrotu nałożą się wszystkie pozostałe wierzchołki.
  • processVertex sprawdza dla każdego wierzchołka, czy został odwiedzony, i uruchamia na nim inVertex, jeśli nie.
  • dfs uruchamia processVertex dla wszystkich wierzchołków.

To wszystko.

Historia słowa INLINE

Słowa INLINE nie było w pierwszej realizacji algorytmu, pojawiło się później. Gdy próbowałem znaleźć lepszą implementację, odkryłem, że w przypadku niektórych grafów wersja bez INLINE działa zauważalnie wolniej. Mimo że semantycznie funkcje powinny działać identycznie, bardzo mnie to zdziwiło. Jeszcze bardziej dziwne było to, że na innym komputerze z inną wersją GHC nie zauważyłem żadnej różnicy.

Po tygodniu spędzonym na czytaniu wyjścia GHC Core udało mi się naprawić problem jedną linią z użyciem wyraźnego INLINE. W pewnym momencie między GHC 8.4.4 a GHC 8.6.5 optymalizator przestał robić to samodzielnie.

Nie spodziewałem się napotkać takiego bałaganu w programowaniu w Haskellu. Niemniej jednak optymalizatory nawet w dzisiejszych czasach czasami popełniają błędy, a naszą rolą jest dostarczanie im wskazówek. Na przykład tutaj wiemy, że funkcja powinna być zainlinowana, ponieważ jest zainlinowana w wersji imperatywnej, i to jest powód, by dać kompilatorowi wskazówkę.

Co było dalej?

Potem zaimplementowałem algorytm Hopcrofta-Karpa już z innymi monadami, i na tym program się zakończył.

Dzięki Google Summer of Code zdobyłem praktyczne doświadczenie w programowaniu funkcyjnym, które nie tylko pomogło mi przejść na staż w Jane Street latem, ale również wprowadziło mnie w niesamowity świat zastosowania tej paradygmatyki w praktyce, wyraźnie różniący się od mojego doświadczenia w tradycyjnych językach.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster