Ориентируемся по карте

Рассмотрим задачу поиска маршрута на карте. У нас есть карта метро и нам нужно проложить маршрут от одной станции к другой. Карта метро~– это граф, узлы обозначают станции, а рёбра соединяют соседние станции. Предположим, что мы знаем расстояния между всеми станциями и нам надо найти кратчайший путь от станции площадь Баха до станции Таинственный лес.

Схема метрополитена

Схема метрополитена

Давайте переведём этот рисунок на Haskell. Сначала опишем имена линий и станций:

module Metro where

data Station = St Way Name
    deriving (Show, Eq)

data Way = Blue | Black | Green | Red | Orange
    deriving (Show, Eq)

data Name = Kosmodrom | UlBylichova | Zvezda 
          | Zapad | Ineva | De | Krest | Rodnik | Vostok 
          | Yug | Sirius | Til | TrollevMost | Prizrak | TainstvenniyLes 
          | DnoBolota | PlBakha | Lao | Sever
          | PlShekspira
    deriving (Show, Eq)

Предположим, что нам известны координаты каждой из станций. По ним мы можем вычислять расстояние между станциями по прямой:

data Point = Point 
    { px :: Double
    , py :: Double
    } deriving (Show, Eq)

place :: Name -> Point
place x = uncurry Point $ case x of
    Kosmodrom           -> (-3,7)
    UlBylichova         -> (-2,4) 
    Zvezda              -> (0,1)
    Zapad               -> (1,7)
    Ineva               -> (0.5, 4)
    De                  -> (0,-1)
    Krest               -> (0,-3)
    Rodnik              -> (0,-5)
    Vostok              -> (-1,-7)
    Yug                 -> (-7,-1)
    Sirius              -> (-3,0)
    Til                 -> (3,2)
    TrollevMost         -> (5,4)
    Prizrak             -> (8,6)
    TainstvenniyLes     -> (11,7)
    DnoBolota           -> (-7,-4)
    PlBakha             -> (-3,-3)
    Lao                 -> (3.5,0)
    Sever               -> (6,1)
    PlShekspira         -> (3,-3)


dist :: Point -> Point -> Double
dist a b = sqrt $ (px a - px b)^2 + (py a - py b)^2

stationDist :: Station -> Station -> Double
stationDist (St n a) (St m b)
    | n /= m && a == b  = penalty
    | otherwise         = dist (place a) (place b)
    where penalty = 1

Расстояние между точками вычисляется по формуле Евклида (dist). Если у станций одинаковые имена, но они расположены на разных линиях мы будем считать, что расстояние между ними равно единице. Теперь нам необходимо описать связность станций. Мы опишем связность в виде функции, которая для данной станции возвращает список всех соседних с ней станций:

metroMap :: Station -> [Station]
metroMap x = case x of
    St Black Kosmodrom          -> [St Black UlBylichova]
    St Black UlBylichova        -> 
            [St Black Kosmodrom, St Black Zvezda, St Red UlBylichova]  
    St Black  Zvezda            -> 
            [St Black UlBylichova, St Blue  Zvezda, St Green Zvezda]
    ...

Приведён пример заполнения только для одной линии. Остальные линии заполняются аналогично. Обратите внимание на то, что некоторые станции имеют одинаковые имена, но находятся на разных линиях.

Всё готово для того чтобы написать функцию поиска маршрута. Для этого мы воспользуемся алгоритмом A*.

Алгоритм эвристического поиска А*

Наша задача относится к задачам поиска путей на графе. Путём на графе называют такую последовательность узлов, в которой для любых двух соседних узлов существует ребро, которое их соединяет. В нашем случае графом является карта метро, узлами~– станции, рёбрами~– линии между станциями, а путями~– маршруты.

Представим, что мы находимся в узле A и нам необходимо попасть в узел B и единственное, что нам известно~– это все соседние узлы с тем, в котором мы находимся. У нас есть возможность перейти в один из соседних узлов и посмотреть нет ли среди их соседей узла B. В этом случае нам ничего не остаётся кроме того как бродить по карте от станции к станции в случайном порядке, пока мы не натолкнёмся на узел B или все узлы не кончатся. Такой поиск называют слепым.

Вот если бы у нас был компас, который в каждой точке указывал в сторону цели нам было бы гораздо проще. Такой компас принято называть эвристикой. Это функция, которая принимает узел и возвращает число. Чем меньше число, тем ближе узел к цели. Обычно эвристика указывает не точное расстояние до цели, поскольку мы не знаем где цель, а приблизительную оценку. Мы не знаем расстояние до цели, но догадываемся, нам кажется, что она где-то там, ещё чуть-чуть и мы найдём её. Примером эвристики для поиска по карте может быть функция, которая вычисляет расстояние по прямой до цели. Предположим, что мы не знаем где находится цель (какая дорога к ней ведёт), но мы знаем её координаты. Также мы знаем координаты каждой вершины, в которой мы находимся. Тогда мы можем легко вычислить расстояние по прямой до цели и наш поиск станет гораздо более осмысленным.

Так находясь в точке A мы можем сразу пойти в тот соседний узел, который ближе всех к цели. Такой поиск называют поиском по первому лучшему приближению. В поиске A* учитывается не только расстояние до цели, но и то расстояние, которое мы уже прошли. Мы выбираем не ту вершину, которая ближе к цели, а ту для которой полный путь до цели будет минимальным. Ведь пока мы идём мы можем запоминать какое расстояние мы уже прошли. Прибавив к этому значению, то которое мы получим с помощью эвристики мы получим полный (предполагаемый) путь до цели.

Поиск А* гораздо лучше поиска по первому лучшему приближению. Его часто применяют в компьютерных играх для поиска пути или принятия решений.

Принято разделять поиск на графе и поиск на дереве. Если мы идём по графу, то вершины могут повторятся (они образуют циклы). В случае поиска на дереве мы считаем, что все вершины уникальны. При поиске на графе очень важно запоминать те вершины, в которых мы уже побывали. Иначе мы будем очень часто ходить кругами.

В Haskell очень удобно работать с данными, которые имеют иерархическую структуру. Их можно представить в виде дерева, обычно в таких типах у нас есть конструкторы-константы и конструкторы, которые собирают составные значения. Граф выходит за рамки этого класса данных, потому что рёбра графов могут образовывать циклы. Но мы схитрим и представим граф поиска в виде дерева. Корнем нашего дерева будет начальная точка поиска, а поддеревьями для данной вершины узла будут все вершины-соседи. В таком дереве будет очень много повторяющихся узлов, так например мы можем пойти в соседнюю вершину, потом вернуться обратно, опять пойти в туже соседнюю вершину, и так до бесконечности. Для того, чтобы избежать подобных ситуаций мы будем запоминать те вершины, в которых мы уже побывали и не рассматривать их, если они встретятся нам ещё раз.

Сформулируем задачу поиска в типах. У нас есть дерево поиска, которое содержит все возможные разветвления, также каждая вершина содержит значение эвристики, по нему мы знаем насколько близка данная вершина к цели. Также у нас есть специальный предикат, который определён на вершинах, по нему мы можем узнать является ли данная вершина целью. Нам нужно получить путь, или цепочку вершин, которая будет начинаться в корне дерева поиска и заканчиваться в целевой вершине.

search :: Ord h => (a -> Bool) -> Tree (a, h) -> Maybe [a]

Здесь a – это значение вершины и h – значение эвристики. Обратите внимание на зависимость Ord h в контексте, ведь мы собираемся сравнивать эти значения по близости к цели. При обходе дерева мы будем запоминать повторяющиеся вершины, для этого мы воспользуемся типом множество из стандартного модуля Data.Set. Внутри Set могут хранится только значения, для которых определены операции сравнения, поэтому нам придётся добавить в контекст ещё одну зависимость:

import Data.Tree
import qualified Data.Set as S

search :: (Ord h, Ord a) => (a -> Bool) -> Tree (a, h) -> Maybe [a]

Поиск будет заключаться в том, что мы будем обходить дерево от корня к узлам. При этом среди всех узлов-альтернатив мы будем просматривать узлы с наименьшим значением эвристики. В этом нам поможет специальная структура данных, которая называется очередью с приоритетом (priority queue). Эта очередь хранит элементы с учётом их старшинства (приоритета). Мы можем добавлять в неё элементы и извлекать элементы. При этом мы всегда будем извлекать элемент с наименьшим приоритетом. Мы воспользуемся очередями из библиотеки fingertree. Для начала установим библиотеку:

cabal install fingertree

Теперь посмотрим в документацию и узнаем какие функции нам доступны. Документацию к пакету можно найти на сайте http://hackage.haskell.org/package/fingertree. Пока отложим детальное изучение интерфейса, отметим лишь то, что мы можем добавлять элементы к очереди и извлекать элементы с учётом приоритета:

insert  :: Ord k => k -> v -> PQueue k v -> PQueue k v
minView :: Ord k => PQueue k v -> Maybe (v, PQueue k v)

Вернёмся к функции search. Я бы хотел обратить ваше внимание на то, как мы будем разрабатывать эту функцию. Вспомним, что Haskell – ленивый язык. Это означает, что при обработке рекурсивных типов данных, функция “углубляется” в значение лишь тогда, когда функция, которая вызвала эту функцию попросит её об этом. Это даёт нам возможность работать с потенциально бесконечными структурами данных и, что более важно, разделять сложный алгоритм на независимые составляющие.

В функции search нам необходимо обойти все элементы в порядке значения эвристики и остановиться в вершине, на которой целевой предикат вернёт True. Но для начала мы добавим к вершинам их пути из корня, для того чтобы в конце мы смогли узнать как мы попали в текущую вершину. Итак наша функция разбивается на три составляющие:

search :: (Ord h, Ord a) => (a -> Bool) -> Tree (a, h) -> Maybe [a]
search isGoal =  findPath isGoal . flattenTree . addPath 

выпишем типы составляющих функций и проверим код в интерпретаторе.

un = undefined

findPath :: (a -> Bool) -> [Path a] -> Maybe [a]
findPath = un

flattenTree :: (Ord h, Ord a) => Tree (Path a, h) -> [Path a]
flattenTree = un

addPath :: Tree (a, h) -> Tree (Path a, h)
addPath = un

data Path a = Path 
	{ pathEnd   :: a
	, path      :: [a]
	}

Обратите внимание на то как поступающие на вход данные разделились между функциями. Информация о приоритете вершин не идёт дальше функции flattenTree, а предикат isGoal используется только в функции findPath. Модуль прошёл проверку типов и мы можем детализировать функции дальше:

addPath :: Tree (a, h) -> Tree (Path a, h)
addPath = iter []
    where iter ps t = Node (Path val (reverse ps'), h) $ 
            iter ps' <$> subForest t
            where (val, h)  = rootLabel t
                  ps'       = val : ps

В этой функции мы просто присоединяем к данной вершине все родительские вершины, так мы составляем маршрут от данной вершины до начальной, поскольку мы всё время добавляем новые вершины в начало списка, в итоге у нас получаются перевёрнутые маршруты, поэтому перед тем как обернуть значение в конструктор Path мы переворачиваем список. На самом деле нам нужно перевернуть только один путь. Путь, который ведёт к цели, но за счёт того, что язык у нас ленивый, функция reverse будет применена не сразу, а лишь тогда, когда нам действительно понадобится значение пути. Это как раз и произойдёт лишь один раз, в самом конце программы, лишь для одного значения!

Давайте пока пропустим функцию flattenTree и сначала определим функцию findPath. Эта функция принимает все вершины, которые мы обошли если бы шли без цели (функции isGoal) и ищет среди них первую, которая удовлетворяет предикату. Для этого мы воспользуемся стандартной функцией find из модуля Data.List:

findPath :: (a -> Bool) -> [Path a] -> Maybe [a]
findPath isGoal =  fmap path . find (isGoal . pathEnd)

Напомню тип функции find, она принимает предикат и список, а возвращает первое значение списка, на котором предикат вернёт True:

find :: (a -> Bool) -> [a] -> Maybe a

Функция fmap применяется из-за того, что результат функции find завёрнут в Maybe, это частично определённая функция. В самом деле ведь в списке может и не оказаться подходящего значения.

Осталось определить функцию flattenTree. Было бы хорошо определить её так, чтобы она была развёрткой для списка. Поскольку функция find является свёрткой (может быть определена через fold), вместе эти функции работали бы очень эффективно. Мы определим функцию flattenTree через взаимную рекурсию. Две функции будут по очереди вызывать друг друга. Одна из них будет извлекать следующее значение из очереди, а другая – проверять не встречалось ли нам уже такое значение, и добавлять новые элементы в очередь.

flattenTree :: (Ord h, Ord a) => Tree (Path a, h) -> [Path a]
flattenTree a = ping none (singleton a) 

ping :: (Ord h, Ord a) => Visited a -> ToVisit a h -> [Path a]
ping visited toVisit 
    | isEmpty toVisit = []
    | otherwise       = pong visited toVisit' a
    where (a, toVisit') = next toVisit


pong :: (Ord h, Ord a) 
    => Visited a -> ToVisit a h -> Tree (Path a, h) -> [Path a]
pong visited toVisit a 
    | inside a visited  = ping visited toVisit
    | otherwise         = getPath a : 
        ping (insert a visited) (schedule (subForest a) toVisit)

Типы Visited и ToVisit обозначают наборы вершин, которые мы уже посетили и которые только собираемся посетить. Не вдаваясь в подробности интерфейса этих типов, давайте присмотримся к функциям ping и pong с точки зрения функции, которая их будет вызывать, а именно функции findPath. Эта функция ожидает на входе список. Внутри она обходит список в поисках нужного элемента, поэтому она будет применять сопоставление с образцом, разбирая список на части. Сначала она запросит сопоставление с пустым списком, запустится функция ping с пустым множеством посещённых вершин (none) и одним элементом в очереди вершин (singleton a), которые предстоит посетить. Функция ping проверит не является ли очередь пустой, очередь содержит один элемент, поэтому она перейдёт к следующему случаю и извлечёт из очереди один элемент (next), который будет передан в функцию pong. Функция pong проверит нет ли в списке уже посещённых элементов того, который был только что извлечён (inside a visited). Если это окажется так, то она запросит следующий элемент у функции ping. Если же исходный элемент окажется новым, она добавит его в список (getPath a : ...) и запланирует обход всех дочерних деревьев данного элемента (schedule (subForest a) toVisit). При первом заходе исходный элемент окажется новым и функция findPath поймёт, что список не пустой и остановит вычисление. Она немного передохнёт и примется за следующий случай. Там она будет извлекать первый элемент списка и сопоставлять его с предикатом. При этом первый элемент уже вычислен. Мы воспользуемся этим, убедимся в том, что он не является целью и рекурсивно вызовем функцию find на хвосте списка. Функция findPath запросит следующее значение и так далее.

Наша функция flattenPath не является развёрткой, но очень похожа на неё тем, что позволяет вычислять результирующий список частично. Например функция length требует полного обхода списка. Мы не можем использовать её с бесконечными списками. Теперь давайте разберёмся с подчинёнными функциями:

getPath :: Tree (Path a, h) -> Path a
getPath = fst . rootLabel

Функции для множества вершин, которые мы уже посетили:

import qualified Data.Set as S
...

type Visited a   = S.Set a

none :: Ord a => Visited a
none = S.empty

insert :: Ord a => Tree (Path a, h) -> Visited a -> Visited a
insert = S.insert . pathEnd . getPath

inside :: Ord a => Tree (Path a, h) -> Visited a -> Bool
inside = S.member . pathEnd . getPath

Функции для очереди тех вершин, что мы только собираемся посетить:

import Data.Maybe
import qualified Data.PriorityQueue.FingerTree as Q
...

type ToVisit a h = Q.PQueue h (Tree (Path a, h))

priority t = (snd $ rootLabel t, t)

singleton :: Ord h => Tree (Path a, h) -> ToVisit a h
singleton = uncurry Q.singleton . priority 

next :: Ord h => ToVisit a h -> (Tree (Path a, h), ToVisit a h)
next = fromJust . Q.minView

isEmpty :: Ord h => ToVisit a h -> Bool
isEmpty = Q.null

schedule :: Ord h => [Tree (Path a, h)] -> ToVisit a h -> ToVisit a h
schedule = Q.union . Q.fromList . fmap priority

Эти функции очень простые, они специализируют более общие функции для типов Set и PQueue, вы наверняка легко разберётесь с ними, заглянув в документацию к модулям Data.Set и Data.PriorityQueue.FingerTree.

Осталось только написать функцию, которая будет составлять дерево поиска для алгоритма A*. Она принимает функцию ветвления, а также функцию расстояния до цели и строит по ним дерево поиска:

astarTree :: (Num h, Ord h) 
    => (a -> [(a, h)]) -> (a -> h) -> a -> Tree (a, h)
astarTree alts distToGoal s0 = unfoldTree f (s0, 0)
    where f (s, h) = ((s, heur h s), next h <$> alts s)
          heur h s = h + distToGoal s  
          next h (a, d) = (a, d + h)

Поиск маршрутов в метро

Теперь давайте посмотрим как наша функция справится с задачей поиска маршрутов в метро:

metroTree :: Station -> Station -> Tree (Station, Double)
metroTree init goal = astarTree distMetroMap (stationDist goal) init

connect :: Station -> Station -> Maybe [Station]
connect a b = search (== b) $ metroTree a b

main = print $ connect (St Red Sirius) (St Green Prizrak)

К примеру найдём маршрут от станции “Дно Болота” до станции “Призрак”:

*Metro> connect (St Orange DnoBolota) (St Green Prizrak)
Just [St Orange DnoBolota,St Orange PlBakha,
    St Red PlBakha,St Red Sirius,St Green Sirius,
    St Green Zvezda,St Green Til,
    St Green TrollevMost,St Green Prizrak]
*Metro> connect (St Red PlShekspira) (St Blue De)
Just [St Red PlShekspira,St Red Rodnik,St Blue Rodnik,
    St Blue Krest,St Blue De]
*Metro> connect (St Red PlShekspira) (St Orange De)
Nothing

В третьем случае маршрут не был найден, поскольку у нас нет станции De на оранжевой ветке.

Тестирование с помощью QuickCheck

Мы проверили три случая, ещё три случая, ещё три случая, ожидаемый результат сходится с тем, что возвращает нам интерпретатор, но можем ли мы быть уверены в том, что алгоритм действительно работает? Для Haskell была разработана специальная библиотека тестирования QuickCheck, которая упрощает процесс проверки программ. Мы можем сформулировать свойства, которые обязательно должны выполняться, а QuickCheck сгенерирует случайный набор данных и проверит наши свойства на них.

Например в нашей задаче путь из A в B должен совпадать с перевёрнутым путём из B в A. Также все станции в маршруте должны быть соседними. Давайте проверим эти свойства. Для этого нам нужно сформулировать их в виде предикатов:

module Test where

import Control.Applicative 

import Metro

prop1 :: Station -> Station -> Bool
prop1 a b = connect a b == (fmap reverse $ connect b a)

prop2 :: Station -> Station -> Bool
prop2 a b = maybe True (all (uncurry near) . pairs) $ connect a b

pairs :: [a] -> [(a, a)]
pairs xs = zip xs (drop 1 xs)

near :: Station -> Station -> Bool
near a b = a `elem` (fst <$> distMetroMap b)

Установим QuickCheck:

cabal install QuickCheck

Теперь нам нужно подсказать QuickCheck как генерировать случайные значения типа Station. QuickCheck тестирует функции, которые принимают значения из класса Arbitrary и возвращают Bool. Класс Arbitrary отвечает за генерацию случайных значений.

Основной метод arbitrary возвращает генератор случайных значений:

class Arbitrary a where
    arbitrary :: Gen a

Мы воспользуемся тем, что этот класс уже определён для многих стандартных типов. Кроме того класс Gen является монадой. Мы сгенерируем случайное целое число и отобразим его в одну из станций. Сделать это можно разными способами, мы начнём из одной станции и будем случайно блуждать по карте:

import Test.QuickCheck
...

instance Arbitrary Station where
    arbitrary = ($ s0) . foldr (.) id . fmap select <$> ints
        where ints = vector =<< choose (0, 100)
              s0 = St Blue De

select :: Int -> Station -> Station
select i s = as !! mod i (length as)
    where as = fst <$> distMetroMap s

Мы воспользовались двумя функциями из библиотеки QuickCheck. Это vector и choose. Первая строит список случайных чисел заданной длины, а вторая выбирает случайное число из заданного диапазона. Теперь мы можем протестировать наши предикаты с помощью функции quickCheck:

*Test Prelude> quickCheck prop1
+++ OK, passed 100 tests.
*Test Prelude> quickCheck prop2
+++ OK, passed 100 tests.
*Test Prelude> 

Свойства прошли тестирование на выборке из 100 комбинаций аргументов. Если нам интересно, мы можем с помощью функции verboseCheck посмотреть на каких именно значениях проводилось тестирование:

*Test Prelude> verboseCheck prop2
Passed:  
St Black Kosmodrom
St Red UlBylichova
Passed: 
St Black UlBylichova
St Orange Sever
Passed:  
St Red Sirius
St Blue Krest
...

Если бы свойство не выполнилось, QuickCheck сообщил бы нам об этом и показал бы те элементы, для которых свойство не выполнилось. Давайте составим такое свойство искусственно. Например, проверим, находятся ли все станции на одной линии:

fakeProp :: Station -> Station -> Bool
fakeProp (St a _) (St b _) = a == b

Посмотрим, что на это скажет QuickCheck:

*Test Prelude> quickCheck fakeProp
*** Failed! Falsifiable (after 1 test):  
St Green Sirius
St Blue Rodnik

По умолчанию QuickCheck проверит свойство сто раз. Для изменения этих настроек, мы можем воспользоваться функцией quickCheckWith, дополнительным параметром она принимает значение типа Arg, которое содержит параметры тестирования. Например протестируем первое свойство 500 раз:

*Test> quickCheckWith (stdArgs{ maxSuccess = 500 }) prop1
+++ OK, passed 500 tests.

Мы воспользовались стандартными настройками (stdArgs) и изменили один параметр.

Формирование тестовой выборки

Предположим, что мы уверены в правильной работе алгоритма для голубой и чёрной ветки метро, но сомневаемся в остальных. Как раз для этого случая в QuickCheck предусмотрена функция a==>b. Это функция обозначает условную проверку, свойство b будет протестировано только в том случае, если свойство a окажется верным. Иначе тестовые данные будут отброшены.

notBlueAndBlack a b = cond a && cond b ==> prop1 a b 
    where cond (St a _) = a /= Blue && a /= Black

Далее тестируем как обычно:

*Test> quickCheck notBlueAndBlack 
+++ OK, passed 100 tests.

Также с помощью функции forAll мы можем подсказать QuickCheck на каких данных тестировать свойство.

forAll :: (Show a, Testable prop) => Gen a -> (a -> prop) -> Property

Эта функция принимает генератор случайных значений и свойство, которое зависит от тех значений, которые создаются этим генератором. К примеру, пусть нас интересуют только все возможные пути между четырьмя станциями: (St Blue De), (St Red Lao), (St Green Til) и (St Orange Sever). Воспользуемся функцией elements :: [a] -> Gen a, она как раз принимает список значений, и возвращает генератор, который случайным образом выбирает любое значение из этого списка.

testFor = forAll (liftA2 (,) gen gen) $ uncurry prop1
    where gen = elements [St Blue De, St Red Lao, 
                    St Green Til, St Orange Sever]

Проверим, те ли значения попали в выборку:

*Test> verboseCheckWith (stdArgs{ maxSuccess = 3 }) testFor
Passed:  
(St Blue De,St Orange Sever)
Passed: 
(St Orange Sever,St Red Lao)
Passed:  
(St Red Lao,St Red Lao)
+++ OK, passed 3 tests.

Мы можем настроить формирование выборки ещё одним способом. Для этого мы сделаем специальный тип обёртку над Station и определим для него свой экземпляр класса Arbitrary:

newtype OnlyOrange = OnlyOrange Station
newtype Only4      = Only4       Station

instance Arbitrary OnlyOrange where
    arbitrary = OnlyOrange . St Orange <$> 
        elements [DnoBolota, PlBakha, Krest, Lao, Sever]

instance Arbitrary Only4 where
    arbitrary = Only4 <$> elements [St Blue De, St Red Lao, 
                    St Green Til, St Orange Sever]

После этого мы можем очень легко комбинировать различные выборки при тестировании.

*Test> quickCheck $ \(Only4 a) (Only4 b) -> prop1 a b
+++ OK, passed 100 tests.
*Test> quickCheck $ \(Only4 a) (OnlyOrange b) -> prop1 a b
+++ OK, passed 100 tests.
*Test> quickCheck $ \a (OnlyOrange b) -> prop2 a b
+++ OK, passed 100 tests.

Классификация тестовых случаев

Мы можем попросить у QuickCheck, чтобы он разбил тестовую выборку на классы и в конце тестирования сообщил бы нам сколько элементов в какой класс попали. Это делается с помощью функции classify:

classify :: Testable prop => Bool -> String -> prop -> Property

Она принимает условие классификации, метку класса и свойство. Например так мы можем разбить выборку по типам линий:

prop3 :: Station -> Station -> Property
prop3 a@(St wa _) b@(St wb _) = 
    classify (wa == Orange || wb == Orange) "Orange" $
    classify (wa == Black  || wb == Black)  "Black"  $
    classify (wa == Red    || wb == Red)    "Red"    $ prop1 a b

Протестируем:

*Test> quickCheck prop3
+++ OK, passed 100 tests:
34% Red
15% Orange
 9% Black
 8% Orange, Red
 6% Black, Red
 5% Orange, Black

Оценка быстродействия с помощью criterion

Недавно появилась библиотека unordered-containers. Она предлагает более эффективную реализацию нескольких структур из стандартной библиотеки containers. Например там мы можем найти тип HashSet. Почему бы нам не заменить на него стандартный тип Set?

cabal install unordered-containers

Изменения отразятся лишь на контекстах объявлений типов. Элементы, принадлежащие множеству HashSet, должны быть экземплярами классов Eq и Hashable. Новый класс Hashable нужен для ускорения работы с данными. Давайте посмотрим на этот класс:

Prelude> :m Data.Hashable
Prelude Data.Hashable> :i Hashable
class Hashable a where
  hash :: a -> Int
  hashWithSalt :: Int -> a -> Int
  	-- Defined in `Data.Hashable'
...
... много экземпляров

Обязательный метод класса hash даёт нам возможность преобразовать элемент в целое число. Это число называют хеш-ключом. Хеш-ключи используются для хранения элементов в хеш-таблицах. Мы не будем подробно на них останавливаться, отметим лишь то, что они позволяют очень быстро извлекать данные из контейнеров и обновлять данные.

Теперь просто скопируйте модуль Astar.hs измените одну строчку, и добавьте ещё одну (в шапке модуля):

import qualified Data.HashSet as S
import Data.Hashable

Попробуйте загрузить модуль в интерпретатор. ghci выдаст длинный список ошибок, это – хорошо. По ним вы сможете легко догадаться в каких местах необходимо заменить Ord a на (Hashable a, Eq a).

Теперь для поиска маршрутов нам необходимо определить экземпляр класса Hashable для типа Station. В модуле Data.Hashable уже определены экземпляры для многих стандартных типов. Мы воспользуемся экземпляром для целых чисел.

Добавим в driving подчинённых типов класс Enum и воспользуемся им в экземпляре для Hashable:

instance Hashable Station where
    hash (St a b) = hash (fromEnum a, fromEnum b)

Теперь определим две функции определения маршрута:

import qualified AstarSet       as S
import qualified AstarHashSet   as H
...

connectSet :: Station -> Station -> Maybe [Station]
connectSet a b = S.search (== b) $ metroTree a b

connectHashSet :: Station -> Station -> Maybe [Station]
connectHashSet a b = H.search (== b) $ metroTree a b

Как нам сравнить быстродействие двух алгоритмов? Оценка быстродействия программ, написанных на Haskell, может таить в себе подвохи. Например если мы запустим оба алгоритма в одной программе, возможно случится такая ситуация, что часть данных, одинаковая для каждого из методов будет вычислена один раз, а во втором алгоритме переиспользована, и нам может показаться, что второй алгоритм гораздо быстрее первого. Также необходимо учитывать внешние факторы. Тестовая программа вычисляется на одном компьютере, и если алгоритмы тестируются в разное время, может статься так, что мы сидели-сидели и ждали пока тест завершится, в это время работал первый алгоритм, потом нам надоело ждать, мы решили включить музыку, проверить почту, и второму алгоритму досталось меньше вычислительных ресурсов. Все эти факторы необходимо учитывать при тестировании. Как раз для этого и существует замечательная библиотека criterion.

Она проводит серию тестов и по ним оценивает показатели быстродействия. При этом учитывается достоверность тестов. По результатам тестирования показатели сверяются между собой, и если разброс оказывается слишком большим, программа сообщает нам: что-то тут не чисто, данным не стоит доверять. Более того результаты оформляются в наглядные графики, мы можем на глаз оценить распределения и разброс показателей.

Основные типы criterion

Центральным элементом библиотеки является класс Benchmarkable. Он объединяет данные, которые можно тестировать. Среди них чистые функции (тип Pure) и значения с побочными эффектами (тип IO a).

Мы можем превращать данные в тесты (тип Benchmark) с помощью функции bench:

benchSource :: Benchmarkable b => String -> b -> Benchmark

Она добавляет к данным комментарий и превращает их в тесты. Как было отмечено, существует одна тонкость при тестировании чистых функций: чистые функции в Haskell могут разделять данные между собой, поэтому для независимого тестирования мы оборачиваем функции в специальный тип Pure. У нас есть два варианта тестирования:

Мы можем протестировать приведение результата к заголовочной нормальной форме (вспомните главу о ленивых вычислениях):

nf :: NFData b => (a -> b) -> a -> Pure

или к слабой заголовочной нормальной форме:

whnf :: (a -> b) -> a -> Pure

Аналогичные функции (nfIO, whnfIO) есть и для данных с побочными эффектами. Класс NFData обозначает все значения, для которых заголовочная нормальная форма определена. Этот класс пришёл в библиотеку criterion из библиотеки deepseq. Стоит отметить эту библиотеку. В ней определён аналог функции seq. Функция seq приводит значения к слабой заголовочной нормальной форме (мы заглядываем вглубь значения лишь на один конструктор), а функция deepseq проводит полное вычисление значения. Значение приводится к заголовочной нормальной форме.

Также нам пригодится функция группировки тестов:

bgroup :: String -> [Benchmark] -> Benchmark

С её помощью мы объединяем список тестов в один, под некоторым именем. Тестирование проводится с помощью функции defaultMain:

defaultMain :: [Benchmark] -> IO ()

Она принимает список тестов и выполняет их. Выполнение тестов заключается в компиляции программы. После компиляции мы получим исполняемый файл который проводит тестирование в зависимости от параметров, указываемых флагами. До них мы ещё доберёмся, а пока опишем наши тесты:

-- | Module: Speed.hs
module Main where

import Criterion.Main
import Control.DeepSeq

import Metro

instance NFData Station where
    rnf (St a b) = rnf (rnf a, rnf b)

instance NFData Way  where
instance NFData Name where

pair1 = (St Orange DnoBolota, St Green Prizrak)
pair2 = (St Red Lao, St Blue De)

test name search = bgroup name $ [
            bench "1" $ nf (uncurry search) pair1,
            bench "2" $ nf (uncurry search) pair2]

main = defaultMain [        
        test "Set"  connectSet,
        test "Hash" connectHashSet]

Экземпляр для класса NFData похож на экземпляр для Hashable. Мы также определили метод значения через методы для типов, из которых он состоит. Класс NFData устроен так, что для типов из класса Enum мы можем воспользоваться определением по умолчанию (как в случае для Way и Name).

Теперь перейдём в командную строку, переключимся на директорию с нашим модулем и скомпилируем его:

$ ghc -O --make Speed.hs

Флаг -O говорит ghc, что необходимо провести оптимизацию кода. Появится исполняемый файл Speed. Что мы можем делать с этим файлом? Узнать это можно, запустив его с флагом --help:

Мы можем узнать какие функции нам доступны, набрав:

$ ./Speed --help
I don't know what version I am.
Usage: Speed [OPTIONS] [BENCHMARKS]
  -h, -?       --help               print help, then exit
  -G           --no-gc              do not collect garbage between iterations
  -g           --gc                 collect garbage between iterations
  -I CI        --ci=CI              bootstrap confidence interval
  -l           --list               print only a list of benchmark names
  -o FILENAME  --output=FILENAME    report file to write to
  -q           --quiet              print less output
               --resamples=N        number of bootstrap resamples to perform
  -s N         --samples=N          number of samples to collect
  -t FILENAME  --template=FILENAME  template file to use
  -u FILENAME  --summary=FILENAME   produce a summary CSV file of all results
  -V           --version            display version, then exit
  -v           --verbose            print more output
If no benchmark names are given, all are run
Otherwise, benchmarks are run by prefix match

Из этих настроек самые интересные, это -s и -o. -s указывает число сэмплов выборке (столько раз будет запущен каждый тест). а -o говорит, о том в какой файл поместить результаты. Результаты представлены в виде графиков, формируется файл, который можно открыть в любом браузере. Записать данные в таблицу (например для отчёта) можно с помощью флага -u.

Проверим результаты:

./Speed -o res.html -s 100 

Откроем файл res.html и посмотрим на графики. Оказалось, что для данных двух случаев первый алгоритм работал немного лучше. Но выборку из двух вариантов вряд ли можно считать убедительной. Давайте расширим выборку с помощью QuickCheck. Мы запустим проверку какого-нибудь свойства тем и другим методом. В итоге QuickCheck сам сгенерирует достаточное число случайных данных, а criterion оценит быстродействие. Мы проверим самое первое свойство (о перевёрнутых маршрутах) на том и другом алгоритме.

module Main where

import Control.Applicative

import Test.QuickCheck
import Metro

instance Arbitrary Station where
    arbitrary = ($ s0) . foldr (.) id . fmap select <$> ints
        where ints = vector =<< choose (0, 100)
              s0 = St Blue De

select :: Int -> Station -> Station
select i s = as !! mod i (length as)
    where as = fst <$> distMetroMap s

prop :: (Station -> Station -> Maybe [Station]) 
	-> Station -> Station -> Bool
prop search a b = search a b == (reverse <$> search b a)

main = defaultMain [
	bench "Set"  $ quickCheck (prop connectSet),
	bench "Hash" $ quickCheck (prop connectHashSet)]

В этом тесте метод Set также оказался совсем немного быстрее.

Как интерпретировать результаты? С левой стороны мы видим оценку плотности вероятности распределения быстродействия. Под графиком мы видим среднее (mean) и дисперсию значения (std dev). Показаны три числа. Это нижняя грань доверительного интервала, оценка величины и верхняя грань доверительного интервала (ci, confidence interval). Среднее значение показывает оценку величины, мы говорим, что алгоритм работает примерно 100 миллисекунд. Дисперсия – это разброс результатов вокруг среднего значения. С правой стороны мы видим графики с точками. Каждая точка обозначает отдельный запуск алгоритма. Количество запусков соответствует флагу -s. В последней строке под графиком criterion сообщает степень недоверия к результатам. В последнем опыте этот показатель достаточно высок. Возможно это связано с тем, что наш алгоритм выбора случайных станций имеет сильный разброс по времени. Ведь сначала мы генерируем случайное число n от 0 до 100, и затем начинаем блуждать по карте от начальной точке n раз. Также может влиять то, что время работы алгоритма зависит от положения станций.

Краткое содержание

В этой главе мы реализовали алгоритм эвристического поиска А*. Также мы узнали несколько стандартных структур данных. Это множества и очереди с приоритетом и освежили в памяти ленивые вычисления.

Мы научились проверять свойства программ (QuickCheck), а также оценивать быстродействие программ (criterion).

Упражнения

Зарегистрировано под лицензией Creative commons Attribution-NonCommercial-NoDerivs 3.0 Generic (CC BY-NC-ND 3.0)