суббота, 1 августа 2015 г.

Haskell, производительность, criterion

Стали с приятелем меряться скоростями на больших данных в задаче о монетах из предыдущей статьи. Он на C++, я на haskell. Само собой, даже самое быстрое решение из всех в ней представленных — с Mapочень, значительно уступает простому оптимизированному итеративному алгоритму на C. Из серьезных проблем решения с Map я вижу как минимум две. Во-первых, minNumCoinsMap генерирует огромный список от 0 до m, а затем берет его последний элемент. Список — классная структура данных в теории функционального программирования, вот только не очень быстрая, а для получения последнего элемента нужно пробежаться по всем элементам, начиная с первого. Все это очень нездо́рово, когда число m равно нескольким миллионам. Во-вторых, я использовал хотя и быструю, но неизменяемую (immutable) реализацию Map. Это значит, что на каждом шаге внутри функции step функция insert возвращает новый ассоциативный массив с уже накопленными данными и одним новым — очень расточительный подход! Чтобы хотя бы на порядок приблизиться к скорости алгоритма на C++, о списке и неизменяемом Map нужно забыть. Хотя на самом деле у меня было четыре итерации постепенного улучшения производительности за счет изменения деталей алгоритма и примитивов, его реализующих. На первой итерации я оставил immutable Map, но в функции step перестал копировать его полностью. В самом деле, алгоритм на шаге m проверяет уже полученные решения, записанные в массив, начиная со значения m - max c, где max c — максимальный номинал монеты в списке c. Соответственно, строки с определением функции step приняли следующий вид.
            where step a x = let cur = min (s ! (m - x) + 1) $ snd a
                             in (insert m cur (snd $ split (m - mc) s), cur)
                    where mc = maximum c
Функция split определена в модуле Data.IntMap.Strict и ее нужно импортировать. Это немного улучшило производительность. На второй итерации я заменил неизменяемый Map на изменяемый (mutable) Array из модуля Data.Array.IO. Новая функция minNumCoinsArray представлена ниже.
import           Data.Array.IO
import           Data.Array.Base             (unsafeRead, unsafeWrite)

-- ----------

minNumCoinsArray :: [Int] -> Int -> IO Int
minNumCoinsArray c m = do
    a <- newArray (0, m) 0 :: IO (IOUArray Int Int)
    mapM_ (mnc cs a) [1 .. m]
    unsafeRead a m
    where cs = sortBy (flip compare) c
          mnc :: [Int] -> IOUArray Int Int -> Int -> IO ()
          mnc c a m = foldM (step a) m (dropWhile (> m) c) >>= unsafeWrite a m
            where step a b x = liftM (min b . succ) $ unsafeRead a $ m - x
Этот массив работает с unboxed данными, расположенными непрерывно на участке памяти в куче от 0 до m — это очень эффективная структура данных (и уже не чистая). Кроме того, здесь оптимизирована свертка по списку монет c: сначала монеты сортируются в обратном порядке, а внутри свертки линейная функция filter заменена на dropWhile: в нормальной ситуации, когда самый старший номинал значительно меньше полной стоимости m, функция dropWhile будет почти всегда останавливаться на первом же элементе. Эта реализация при больших значениях m отставала от референсной программы на C++ на порядок. На третьей итерации я всего лишь заменил mutable unboxed Array на mutable unboxed Vector — чисто посмотреть, что из этого выйдет. Вот реализация функции minNumCoinsVector.
import qualified Data.Vector.Unboxed.Mutable as V

-- ----------

minNumCoinsVector :: [Int] -> Int -> IO Int
minNumCoinsVector c m = do
    v <- V.new $ m + 1
    V.unsafeWrite v 0 0
    mapM_ (mnc cs v) [1 .. m]
    V.unsafeRead v m
    where cs = sortBy (flip compare) c
          mnc c v m = foldM (step v) m (dropWhile (> m) c) >>= V.unsafeWrite v m
            where step v a x = liftM (min a . succ) $ V.unsafeRead v $ m - x
Результат не сильно отличался от предыдущего. И тогда я решил выжать из этого решения все что можно до последней капли (ну, наверное, все-таки не до последней). Никаких функциональных штучек вроде списков и сверток, никаких min, succ, dropWhile и тому подобного. Берем тупой итеративный алгоритм как в C и применяем его к тупой C-подобной структуре данных mutable unboxed Vector. Список монет реализуем как immutable unboxed Vector — ведь он не изменяется.
import qualified Data.Vector.Unboxed         as VI

-- ----------

minNumCoinsVectorBasic :: VI.Vector Int -> Int -> IO Int
minNumCoinsVectorBasic c m = do
    v <- V.new $ m + 1
    V.unsafeWrite v 0 0
    mapM_ (mnc c v) [1 .. m]
    V.unsafeRead v m
    where mnc c v m = VI.foldM_ (step v) m c
            where step v a x =
                    if x > m
                        then return a
                        else do y <- V.unsafeRead v $ m - x
                                let cur = y + 1
                                if cur < a
                                    then do V.unsafeWrite v m cur
                                            return cur
                                    else return a
Список монет теперь имеет тип VI.Vector Int и должен формироваться перед вызовом minNumCoinsVectorBasic, например так.
          coins    = [24, 18, 14, 11, 10, 8, 5, 3, 1]
          vcoins   = VI.fromList $ sortBy (flip compare) coins
Предельно просто и уныло, бейсик какой-то, но … Вы будете смеяться, но это заработало на порядок быстрее и догнало программу на C++. Репутация haskell была восстановлена! Для измерения производительности я использовал библиотеку criterion. Функция main приняла вид
import           Criterion.Main
import           System.Environment

-- ----------

main = do
    args <- getArgs
    if benchArg `elem` args
        then withArgs (filter (/= benchArg) args) $ defaultMain
             [
                 bench ("map " ++ show m2)          $ whnf
                     (minNumCoinsMap        coins) m2,
                 bench ("array " ++ show m2)        $ whnfIO $
                     minNumCoinsArray       coins  m2,
                 bench ("vector " ++ show m2)       $ whnfIO $
                     minNumCoinsVector      coins  m2,
                 bench ("vector basic " ++ show m2) $ whnfIO $
                     minNumCoinsVectorBasic vcoins m2
             ]
        else {-print $ minNumCoinsPlain [1, 2, 5] 20   -- m = 50 will hang it!-}
             {-print $ minNumCoins_     [1, 2, 5] 20   -- m = 50 will hang it!-}
             {-print $ minNumCoins      [1, 2, 5] 10002-}
             {-print $ minNumCoinsMemo  [1, 2, 5] 10001-}
             {-print $ minNumCoinsMap   [1, 2, 5] 10001-}
             {-print $ minNumCoins      coins m1-}
             {-print $ minNumCoinsMap   coins m1-}
             {-print $ minNumCoinsMap   coins m2-}
             {-minNumCoinsArray         coins m2 >>= print-}
             minNumCoinsVectorBasic   vcoins m2 >>= print
    where benchArg = "-b"
          coins    = [24, 18, 14, 11, 10, 8, 5, 3, 1]
          vcoins   = VI.fromList $ sortBy (flip compare) coins
          m1       = 16659
          m2       = 1665900
То есть, для запуска criterion в эту программу нужно предать опцию -b. Скомпилировав программу с оптимизацией под LLVM,
ghc --make -O2 -fllvm -optlo-O3 minNumCoins.hs
и запустив ее с опцией -b,
./minNumCoins -b -o minNumCoins.html
benchmarking map 1665900
time                 3.304 s    (3.158 s .. 3.478 s)
                     1.000 R²   (0.999 R² .. 1.000 R²)
mean                 3.394 s    (3.370 s .. 3.411 s)
std dev              25.30 ms   (0.0 s .. 29.13 ms)
variance introduced by outliers: 19% (moderately inflated)

benchmarking array 1665900
time                 145.4 ms   (143.3 ms .. 147.7 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 144.1 ms   (143.6 ms .. 145.0 ms)
std dev              890.1 μs   (399.6 μs .. 1.244 ms)
variance introduced by outliers: 12% (moderately inflated)

benchmarking vector 1665900
time                 136.2 ms   (135.4 ms .. 137.5 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 137.7 ms   (137.0 ms .. 139.8 ms)
std dev              1.518 ms   (389.3 μs .. 2.314 ms)
variance introduced by outliers: 11% (moderately inflated)

benchmarking vector basic 1665900
time                 20.87 ms   (20.48 ms .. 21.20 ms)
                     0.996 R²   (0.988 R² .. 0.999 R²)
mean                 21.22 ms   (20.88 ms .. 21.96 ms)
std dev              1.090 ms   (452.6 μs .. 1.646 ms)
variance introduced by outliers: 18% (moderately inflated)
я получил вот такой замечательный графический отчет. Все картинки внутри отчета интерактивные — для их просмотра нужно разрешить javascript. Я не хочу делать никаких выводов, хотя они напрашиваются сами собой. Исходный код программы по-прежнему доступен отсюда.

пятница, 24 июля 2015 г.

Мемоизация функций в haskell на конкретном примере

Возьмем пресловутую задачу вычисления n-ого числа Фибоначчи. Наивное решение на haskell
fib 0 = 0
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)
не будет работать для больших n: попробуйте посчитать fib 50! Проблема заключается в экспоненциальном росте числа рекурсивных вызовов fib при увеличении n и невозможности сведения этих вызовов при таком определении функции в простой цикл. И это при том, что интуитивно задача является простейшей, а для получения, скажем, 50-ого числа, нужно всего-то вычислить 48 последовательных значений fib, начиная с n = 2. То есть практически нам нужно сделать всего 48 вычислений fib и каким-то образом запомнить их результаты, чтобы не вызывать функцию fib вновь и вновь. Механизм запоминания результатов вызовов функции для разных ее аргументов с дальнейшей подстановкой уже вычисленных значений вместо повторных вызовов этой же функции с теми же аргументами называется мемоизацией (memoization). Удивительно, но мемоизация функций поддерживается компилятором ghc из коробки. Почему же тогда в приведенной реализации функции fib она не работает? Оказывается, компилятор не может мемоизировать вызовы функций со связанными аргументами, поскольку связанный аргумент может изменить семантику вызова функции и не гарантирует одинаковый результат в случае ее повторных вызовов. Зато функции, объявленные без указания аргументов (то есть бесточечные определения) будут успешно мемоизироваться! Очень подробно мемоизация функций рассматривается в этом руководстве, там же приводится решение задачи вычисления n-ого числа Фибоначчи с определением функции в бесточечном стиле. Я не стану больше говорить о числах Фибоначчи, однако замечу, что очевидный алгоритм решения этой задачи, следующий непосредственно из определения fib и заключающийся в последовательном нахождении всех чисел fib i для i от 2 до n, представляет собой классический пример так называемого динамического программирования. В этой статье я покажу решение похожей задачи о поиске минимального числа монет с номиналами из заданного списка c, которые в сумме составляют заданную стоимость m. Алгоритм решения так же основан на последовательном вычислении частных решений для стоимостей от 1 до m в предположении, что количество монет для стоимости 0 равно 0. Подробное описание алгоритма и псевдокод можно легко найти в интернете (например, здесь). Поэтому я не стану на этом останавливаться, а сразу же приведу наивное решение.
minNumCoinsPlain :: [Int] -> Int -> Int
minNumCoinsPlain c m = map (mnc c) [0 .. m] !! m
    where mnc c m = foldl (\a x -> min (minNumCoinsPlain c (m - x) + 1) a) m $
                    filter (<= m) c
Наивное оно по той же причине, что и приведенное решение задачи о числах Фибоначчи — слишком много рекурсивных вызовов minNumCoinsPlain при больших значениях m. Вы можете скачать исходный код примера отсюда, загрузить его в ghci и протестировать при не очень больших m (начните с 25, при 50 вычисления должны плотно висеть, при необходимости их можно прервать стандартной комбинацией клавиш Ctrl-C).
:l minNumCoins
[1 of 1] Compiling Main             ( minNumCoins.hs, interpreted )
Ok, modules loaded: Main.
:set +s
minNumCoinsPlain [1, 2, 5] 25
5
(4.22 secs, 1,137,893,584 bytes)
Больше четырех секунд вычислений для задачи, которую решит любой школьник не задумываясь! Давайте уберем аргумент m из определения функции: останется аргумент c — функция не стала бесточечной, но ведь c вообще не меняется между вызовами, может компилятор сможет мемоизировать minNumCoinsPlain в таком случае?
minNumCoins_ :: [Int] -> Int -> Int
minNumCoins_ c = (map (mnc c) [0 ..] !!)
    where mnc _ 0 = 0
          mnc c m = foldl (\a x -> min (minNumCoins_ c (m - x) + 1) a) m $
                    filter (<= m) c
Проверяем в ghci.
minNumCoins_ [1, 2, 5] 25
5
(3.66 secs, 1,084,731,832 bytes)
Чуда не произошло. Наличие в определении функции связанного аргумента c способно повлиять на семантику вызовов функции, поэтому компилятор не может выполнить ее мемоизацию. Кстати, метод избавления от связанного аргумента m с помощью применения сечения функции (!!) я взял из упомянутого руководства на haskell.org. Давайте избавимся и от аргумента c! Это сделать не так уж сложно, учитывая, что он не изменяется между вызовами функции.
minNumCoins :: [Int] -> Int -> Int
minNumCoins c =
    let minNumCoinsMemo = (map (mnc c) [0 ..] !!)
        mnc _ 0 = 0
        mnc c m = foldl (\a x -> min (minNumCoinsMemo (m - x) + 1) a) m $
                  filter (<= m) c
    in minNumCoinsMemo
Видите, я зафиксировал c в функции-обертке minNumCoins, в то время как основная рабочая лошадка — функция minNumCoinsMemo — объявлена в бесточечном стиле и вызывается рекурсивно из вспомогательной функции mnc c. Это значит, что ghc должен ее наконец-то мемоизировать!
minNumCoins [1, 2, 5] 25
5
(0.01 secs, 3,910,424 bytes)
Ура! Мгновенное выполнение. Проверим на большой стоимости m.
minNumCoins [1, 2, 5] 10001
2001
(16.53 secs, 0 bytes)
Не быстро, но и не бесконечно долго! Собственно, фиксация аргументов, которую мы здесь проделали вручную, может быть реализована с помощью функции fix из модуля Data.Function: читайте упомянутое выше руководство. Однако, при количестве аргументов от двух и более, прямое использование fix становится трудоемким и подверженным ошибкам. Поэтому лучше всего воспользоваться готовыми функциями из модуля Data.Function.Memoize. Для нашей задачи, в частности, нужна функция memoFix2, поскольку наша рекурсивная функция ожидает на входе два аргумента c и m. Давайте протестируем решение с memoFix2. Прежде всего следует импортировать эту функцию из модуля Data.Function.Memoize.
import Data.Function.Memoize (memoFix2)
Определение новой функции minNumCoinsMemo.
minNumCoinsMemo :: [Int] -> Int -> Int
minNumCoinsMemo = memoFix2 mnc
    where mnc _ _ 0 = 0
          mnc f c m = foldl (\a x -> min (f c (m - x) + 1) a) m $
                      filter (<= m) c
Обратите внимание, функция minNumCoinsMemo объявлена в бесточечном стиле, а у вспомогательной функции mnc появился новый аргумент f, который вышел на первое место — это и есть наша фиксированная функция, которая рекурсивно вызывается внутри mnc. Проверим ее скорость.
minNumCoinsMemo [1, 2, 5] 10001
2001
(1.00 secs, 527,741,984 bytes)
Вот это уже очень хорошо! Новая функция оказалась быстрее нашей самодельной функции minNumCoins на порядок. Но это еще не всё. Хотя с мемоизацией мы закончили. На самом деле, природа задачи о минимальном количестве монет такова, что мы можем попробовать вручную собирать уже подсчитанные результаты в список или в ассоциативный массив (Map): это легко сделать с помощью функций mapAccumL или mapAccumR из модуля Data.List. Очевидно, что поиск уже найденных значений внутри списка потребует линейной скорости, а внутри Map — логарифмической. Я не знаю как организован поиск мемоизированных значений изнутри, но даже если он происходит за константное время, логарифм при таких небольших значениях как 10001 может составить ему хорошую конкуренцию. Поэтому давайте протестируем этакую народную самописную реализацию мемоизации на основе хорошо оптимизированного, строгого типа IntMap. Импортируем нужные функции.
import Data.IntMap.Strict (singleton, insert, (!))
import Data.List (mapAccumL)
Определяем новую функцию
minNumCoinsMap :: [Int] -> Int -> Int
minNumCoinsMap c m = snd (mapAccumL (mnc c) (singleton 0 0) [0 .. m]) !! m
    where mnc c s m = foldl step (s, m) $ filter (<= m) c
            where step a x = let cur = min (s ! (m - x) + 1) $ snd a
                             in (insert m cur s, cur)
Почти все то же самое, но на этот раз вместо рекурсивных вызовов функций типа f c (m - x), внутри функции свертки step мы обращаемся к соответствующему элементу массива s в предположении, что он уже был вычислен в предыдущих итерациях. Проверяем функцию minNumCoinsMap.
minNumCoinsMap [1, 2, 5] 10001
2001
(0.15 secs, 20,208,520 bytes)
Весьма красноречивый результат. Народная мемоизация в этой задаче — чемпион. Напоследок приведу ссылку на замечательную статью из журнала Практика функционального программирования, в которой исследуется взаимосвязь рекурсии, мемоизации и динамического программирования.

пятница, 5 июня 2015 г.

Алгоритм Крускала для невзвешенного графа на haskell

Очередная задачка от 1HaskellADay. В условии задан список ребер графа. Многие пути вдоль ребер предположительно зациклены, то есть если есть два пути от a к b и от b к c, то, возможно, в списке присутствует также путь от a к c; сюда же относятся вырожденные циклы, когда имеется несколько путей от a к b, либо имеются обратные пути от b к a. Целью задачи является удаление лишних (redundant) ребер графа, которые приводят к появлению циклов. Очевидно, разрешение цикла всегда связано с удалением произвольного ребра: скажем, в случае цикла (a, b, c) его разрешением может стать удаление любого из трех ребер (a, b), (b, c) и (a, c). Алгоритм Крускала находит минимальное остовное дерево (minimum spanning tree) для взвешенного неориентированного графа. Давайте предположим, что нам неважны направления ребер. В этом случае, принимая во внимание, что невзвешенный граф — это частный случай взвешенного, а дерево — это связный ациклический граф, алгоритм Крускала идеально подходит для решения нашей задачи. Напомню его суть. В случае взвешенного графа все ребра предварительно сортируются по весу (этот шаг гарантирует построение минимального дерева). Затем проходом по всем ребрам строится лес, постепенно объединяющийся в единое дерево, либо в список крупных деревьев, если исходный граф несвязан. В процессе прохода может оказаться, что очередное ребро сформирует цикл — в этом случае оно просто отбрасывается. Как определить, что ребро может сформировать цикл? Перед началом прохода все вершины (vertices) графа заносятся в специальную структуру, которая называется disjoint set. Эта структура представляет собой один или более наборов чисел, каждый из которых имеет своего представителя (representative) — это число, которое является меткой набора. На старте алгоритма все вершины разъединены и, составляя отдельные наборы, выставляют в качестве представителя самих себя. В процессе прохода по всем ребрам отдельные наборы будут объединяться, выбирая в качестве представителя одну из вершин в объединенном наборе. Соответственно, если в процессе прохода обеим вершинам очередного ребра соответствует один и тот же представитель, то это ребро отбрасывается, иначе обе вершины объединяются. Структура disjoint set гарантирует, что если одна из вершин уже была объединена в некоторый набор, то в случае ее объединения с другой вершиной, эта другая вершина вместе с набором, к которому она относится, объединится с первым набором, возможно сформировав нового представителя. Так постепенно формируется единый набор вершин (или несколько крупных). Алгоритм для работы с disjoint set часто называют union-find. Он гарантирует константное время объединения и поиска представителя. Я не собираюсь его здесь реализовывать, а лучше возьму одну из многочисленных существующих реализаций для языка haskell: Data.UnionFind.IO. Представленная в этом модуле реализация disjoint set не поддерживает поиск представителя для произвольного числа: только числа, уже добавленные в набор, корректно возвращают своего представителя. Поскольку мы добавляем все вершины в набор на старте, нам достаточно создать Map, ключами которого будут числа-вершины, а значениями — представления этих чисел в наборе disjoint set. Таким образом, мы сможем находить представителей вершин в процессе прохода по ребрам графа за логарифмическое время. Учитывая линейность прохода по ребрам, общий класс алгоритма должен соответствовать O(nlogm)O(n\log{}m), где n — число ребер исходного графа, а m — число вершин. Вот исходный код функции deredundancify, которая из исходного списка ребер строит список всех вершин графа и остовное дерево.
deredundancify :: [Edge] -> IO ([Vertex], [Edge])
deredundancify es = do
    let vs = map head . group . sort $ foldr (\(a, b) c -> a : b : c) [] es
    ps <- mapM fresh vs
    m  <- liftM M.fromList $ mapM (\a -> do b <- descriptor a; return (b, a)) ps
    let testNotCycle (a, b) = do
            let pa = fromJust $ M.lookup a m
                pb = fromJust $ M.lookup b m
            da <- descriptor pa
            db <- descriptor pb
            if da == db
                then return False
                else do
                    pa `union` pb
                    return True
    rs <- filterM testNotCycle es
    return (vs, rs)
Для того, чтобы все скомпилировалось, перед определением функции нужно добавить импорт некоторых модулей и определить синонимы типов Vertex и Edge.
import            Control.Monad               (liftM, filterM)
import            Data.List                   (sort, group)
import qualified  Data.Map                    as M
import            Data.UnionFind.IO
import            Data.Maybe                  (fromJust)

type Vertex = Int
type Edge   = (Int, Int)
Внутри функции deredundancify сначала строится список вершин vs, затем все вершины заносятся в структуру ps с помощью функции fresh из модуля Data.UnionFind.IO. Затем строится Map m, ключами которого являются представители (descriptor) исходного набора ps (которые вначале должны просто соответствовать вершинам vs), а значениями — исходные наборы из ps. Основная работа функции deredundancify заключается в формировании списка ребер rs, который будет соответствовать искомому остовному дереву. Список rs строится за счет применения фильтра testNotCycle к исходному списку ребер es. Фильтр testNotCycle ищет наборы pa и pb внутри m, получает их дескрипторы da и db и сравнивает их. Если дескрипторы равны, то данное ребро отбрасывается (функция возвращает False), иначе — pa и pb объединяются (union) и данное ребро заносится в список для остовного дерева (функция возвращает True). Собственно, это и есть решение задачи. Но давайте подумаем, как мы его проверим. Из большого списка ребер мы получим список чуть поменьше. И что нам с ним делать? Нужна какая-то визуализация. Очевидное решение — использовать GraphViz. В haskell есть байндинг для GraphViz. Давайте напишем функцию, которая будет выводить текст в формате dot, который GraphViz сможет использовать для изображения графов. Прежде всего, подключим модули GraphViz.
import            Data.Graph.Inductive        hiding (Edge)
import            Data.GraphViz
import            Data.GraphViz.Attributes
import            Data.GraphViz.Printing      (renderDot)
import            Data.Text.Lazy              (Text, unpack, pack)
import            System.Environment          (getArgs)
Последний импорт понадобится в функции main для парсинга аргументов командной строки. Вот функция mkDot.
mkDot :: [Vertex] -> [Edge] -> [GlobalAttributes] -> DotGraph Node
mkDot vs es attrs =
    let lvs = map (\a -> (a, pack $ show a)) vs
        les = map (\(a, b) -> (a, b, ())) es
        gr  = mkGraph lvs les :: Gr Text ()
    in graphToDot nonClusteredParams {globalAttributes = attrs} gr
Она принимает список вершин, список ребер и атрибуты GraphViz и возвращает промежуточное представление для данных dot. Давайте напишем функцию main, которая сможет выводить данные dot на экран.
main = do
    args     <- getArgs
    (vs, rs) <- deredundancify theGraph
    let attrs = [EdgeAttrs
                    [edgeEnds $ if "-l" `elem` args then NoDir else Forward]]
        dot   = mkDot vs (if "-o" `elem` args then theGraph else rs) attrs
    putStrLn $ unpack $ renderDot $ toDot dot
Теперь, после компиляции исходного кода, предварительно включив в него список ребер исходного графа theGraph, можно вывести исходный граф и искомое остовное дерево на картинку. Получение картинки исходного графа.
./deredundancify -o | dot -Tpng > original.png
Получение картинки остовного дерева исходного графа.
./deredundancify | dot -Tpng > deredundancified.png
Картинки для исходного дерева, представленного в оригинальном примере, получаются очень большими, поэтому я их положил на Google Drive: исходный граф, остовное дерево. Посмотрите и сравните: по картинкам становится понятно, что такое большое остовное дерево. Кстати, на выложенных картинках все ребра направлены, но по условиям нашей задачи граф неориентирован, поэтому по-хорошему стрелки на ребрах должны отсутствовать. Не проблема, если хотите картинки без стрелок — используйте опцию -l при вызове deredundancify. Исходный код решения задачи можно загрузить отсюда или отсюда. Update. Как обычно, ряд улучшений в исходном коде уже после публикации статьи. Все они касаются функции deredundancify. Во-первых, создание структуры ps для uninon-find и ассоциативного массива m можно объединить. Это потому, что вначале все наборы внутри ps состоят из отдельных вершин и их представителями являются собственно сами эти вершины. Во-вторых, я предпочитаю избегать конструкцию if-then-else в haskell. В данном случае в функции testNotCycle вместо нее можно определить логическую константу, применив оператор liftM2 (==) или liftM2 (/=) к значениям представителей вершин ребра, а необходимость объединения вершин определять с помощью функции when из модуля Control.Monad. Функция liftM2 определена в этом же модуле. Чтобы не плодить список импортированных из него функций, лучше переписать его импорт как
import            Control.Monad
В-третьих, поиск внутри m можно переписать в стрелочном стиле. Для этого нужно включить еще один импорт.
import            Control.Arrow
Я писал про стрелки в haskell в предыдущей статье. Теперь функция deredundancify выглядит так.
deredundancify :: [Edge] -> IO ([Vertex], [Edge])
deredundancify es = do
    let vs = map head . group . sort $ foldr (\(a, b) c -> a : b : c) [] es
    m  <- liftM M.fromList $ mapM (\v -> do p <- fresh v; return (v, p)) vs
    let testNotCycle e = do
            let ve = both (fromJust . flip M.lookup m) e
            notCycle <- uncurry (liftM2 (/=)) $ both descriptor ve
            when notCycle $ uncurry union ve
            return notCycle
            where both = join (***)
    rs <- filterM testNotCycle es
    return (vs, rs)
Я заменил запись аргумента функции testNotCycle (a, b) на e, поскольку стрелочная нотация прекрасно работает с кортежами и в ней не требуется знать его (кортежа) отдельные элементы. Функция both заворачивает стрелочный комбинатор (***) в функцию join из модуля Control.Monad, таким образом применяя свой аргумент-функцию к обоим элементам кортежа. И еще одна техника, которая позволит переписать монадные вычисления внутри отображения mapM при вычислении m в бесточечном стиле. Это стрелки Клейсли. Они импортируются из модуля Control.Arrow по умолчанию. Основная идея заключается в оборачивании всех функции, возвращающих монадные значения в тип Kleisli, а само монадное вычисление — в конструктор runKleisli, который возвращает монадный тип из комбинации стрелок.
m  <- liftM M.fromList $ mapM (runKleisli $ arr id &&& Kleisli fresh) vs
Здесь из функции id создается обычная стрелка с помощью явного конструктора arr. Несмотря на то, что функции являются стрелками, и мы ни разу до этого не использовали явные конструкторы стрелок в отношении функций, в данном случае без них не обойтись — ghc откажется компилировать код. Другая стрелка — стрелка Клейсли — создается из функции fresh, возвращающей монадное значение IO (Point a). Обе стрелки комбинируются стрелочным комбинатором (&&&). На выходе runKleisli получаем монадное вычисление пары (входное значение (то есть вершина), элемент структуры union-find). Не думаю, что новое тело функции deredundancify понятней, чем предыдущее (скорее наоборот), зато оно явно лаконичней. Ну и напоследок упражнение: записать тело функции правой свертки (\(a, b) c -> a : b : c), которую мы использовали для получения списка всех вершин графа, в бесточечном стиле с помощью стрелок. Причем с сохранением семантики и эффективности: то есть числа a и b должны стыковаться со списком c в заданном порядке, а конструкторы списка (:) нельзя заменять на операции конкатенации (++). Я напишу ответ, но не стану его объяснять.
curry $ fst . fst &&& uncurry ((:) . snd) >>> uncurry (:)
А это бесточечное решение без использования стрелок, тоже без объяснения.
let (<:) = flip (:) in flip $ uncurry . flip . ((<:) .) . (<:)
Update 2. Я все-таки решил привести здесь уменьшенные варианты изображений оригинального графа и его осто́вного дерева в дополнение к приведенным выше ссылкам на большие изображения, которые оказались настолько большими, что вставлять их в статью было бы просто безумием. Новые изображения обязаны своей компактностью выбору движка для рендеринга sfdp вместо стандартного dot, отказу от прорисовки форм вершин графа (вернее, теперь это форма point) и их идентификаторов, а также выбору полупрозрачных цветов для ребер и вершин. Чтобы достичь этого, мне пришлось внести небольшие дополнения в исходный код программы. Итак, изображение исходного графа без идентификаторов вершин, полученное как
./deredundancify -o -t | sfdp -Tpng > original_tiny.png
А это осто́вное дерево, полученное как
./deredundancify -t | sfdp -Tpng > deredundancified_tiny.png
На первой картинке много синих линий-ребер, на второй их практически не видно, зато красные кружки́-вершины связаны в более длинные цепочки. Все это выглядит здо́рово и весьма достоверно.