Раскрашиваем карту при помощи языка Haskell

в 3:32, , рубрики: haskell, теория графов, я пиарюсь, метки: ,

Раскрашиваем карту при помощи языка HaskellСегодня я хотел бы рассказать про ноябрьский конкурс по функциональному программированию, несмотря на то, что с момента его проведения уже прошло практически два месяца. Соорганизатором конкурса выступил наш уважаемый коллега Алексей Кишкин, который предложил задачу, реализовал её решение на нескольких языках программирования, а также подготовил некоторую вспомогательную инфраструктуру для проверки решений.

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

Ну а мы, как обычно, далее в этой краткой заметке мы рассмотрим решение указанной задачи на языке программирования Haskell. Впрочем, я сразу хочу оговориться — программа написана соорганизатором конкурса, который обычно пишет свои программы на языке OCaml, так что сегодня, как я ни старался, в определениях функций мы будем постоянно видеть торчащие верблюжьи уши OCaml'я :).

Алгоритм

Не мудрствуя лукаво, воспользуемся уже известным алгоритмом раскраски графов, который основан на подходе с использованием метода неявного перебора. Работа этого алгоритма подразделяется на две последовательные фазы.

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

Во время второй фазы, если это возможно, достигается «лучшая» раскраска, использующая меньшее число цветов. На первом шаге второй фазы отыскивается первая по номеру вершина, окрашенная в цвет, от которого мы хотим избавиться. И из неё осуществляется так называемый шаг возвращения, а именно — во множестве вершин, смежных с данной, с меньшим чем у неё номером, находим максимальную (она была окрашена позже остальных, пусть это N-ая вершина) и пытаемся перекрасить её в другой допустимый цвет с номером большим её собственного, но меньшим номера «лишнего» цвета. Если это удаётся, то далее по правилу первой фазы перекрашиваются следующие вершины с (N + 1)-ой до конца. Если ни одна из них не потребует цвета, от которого мы избавляемся, значит мы добились оптимальной раскраски с меньшим количеством цветов и остановимся.

А если какая-то вершина потребует — мы осуществляем шаг возвращения из неё. В ситуации, когда та самая N-ая вершина не может быть перекрашена в другой допустимый цвет — сразу же делаем шаг возвращения из неё. Алгоритм завершает работу, если на шаге возвращения достигается первая вершина.

Как видно, во время такого перебора вершин осуществляется движение по дереву поиска в глубину. Несмотря на то, что алгоритм переборный, он нисколько не хуже других известных методов и вполне эффективен.

Реализация

Начнём с определения типов данных. Это всё будут только синонимы, и это более или менее просто:

type RegionNumber = Int

type Color = Int

type Regions = Map RegionNumber [RegionNumber]

type Picture = Map RegionNumber Color

Здесь у нас два раза переобозначается тип Int, которым мы кодируем номера цветов и номера регионов на карте. Что-то иное использовать в этом деле — себе дороже, поскольку цвета даже в алгоритме кодируются целыми числами, а для кодирования регионов вполне достаточно использовать номер региона, хотя можно было бы использовать его код в виде строки. Но это немного замедлило бы само решение, а для больших карт такое замедление было бы критичным. Так что оставим так.

Тип Regions представляет собой перечень соседних регионов для заданного. Вся эта информация хранится в словаре (Map), причём кодом является номер региона, а значением — список тех регионов, с которым граничит регион, чей номер является кодом. В свою очередь, вся карта тоже является словарём, но в нём в качестве кода также хранится номер региона, а в качестве значения — номер цвета.

Перейдём к определению функций и начнём с самого верха. Вот определение функции main:

main fn = do
  s <- readFile fn
  let fileData = map loadData $ lines s
      names    = zip [1..] $ map fst fileData
      codes    = Map.fromList $ map ((a, b) -> (b, a)) names
      nodes    = foldl (acc (x, nl) -> Map.insert (codes Map.! x)
                       [codes Map.! n | n <- nl] acc) Map.empty fileData
      solution = paint nodes 1 Map.empty
      nsl      = optimizeLoop nodes solution $ numberOfColors solution
  if validPicture nodes nsl
    then printSolution nsl $ Map.fromList names
    else putStrLn "ОШИБКА: Некорректный результат работы программы."

Ну тут всё просто. Сначала мы загружаем весь файл в память и проводим его первичную обработку при помощи функции loadData. Она для каждой строки файла возвращает пару вида (регион, список граничащих с ним регионов). Затем список таких пар преобразуется в структуру данных, содержащую не идентификаторы регионов, а их номера (за это отвечают строки, в которых рассчитываются образцы codes и nodes). После этого проводится первая фаза описанного ранее алгоритма при помощи вызова функции paint. Результат её работы передаётся во вторую фазу алгоритма, в функцию optimizeLoop. После этого делается проверка того, что полученная раскраска карты валидна, и в случае успешного прохождения проверки на экран выводится решение.

Вот определение функции loadData, осуществляющей первичную обработку файла с данными:

loadData :: String -> (String, [String])
loadData l =
  case splitOn ":" l of
    [rgn, nbrs] -> (rgn, filter (n -> length n > 0) $ splitOn " " nbrs)
    _           -> error "ОШИБКА: Некорректный формат входного файла."

Как видно, здесь вовсю используется функция splitOn из модуля Data.List.Split, который необходимо подключить, предварительно установив пакет split. Этот модуль содержит функции, использующиеся для разделения списков по различным критериям. В частности, функция splitOn берёт список и возвращает список списков, полученных при помощи разделения исходного списка на подсписки при помощи какого-либо заданного элемента. Например, если функции splitOn задан символ (:) и, скажем, строка "moscow: moscowregion, kaluga". В результате функция вернёт список ["moscow", " moscowregion, kaluga"]. Именно на этом свойстве основана функция loadData — сначала строка разделяется на две подстроки по символу (:), а потом второй подсписок разделяется по символу пробела.

Теперь переходим к функции paint, которая раскрашивает карту (на первой фазе алгоритма). Вот её определение:

paint :: Regions -> Color -> Picture -> Picture
paint rgn clr pct = if null np
                      then pct
                      else paint rgn (clr + 1) $ paintWith clr rgn pct np
  where
    np = Set.toList $ Set.difference (Map.keysSet rgn)
                                     (Map.keysSet pct)

Эта функция берёт карту (аргумент pct) и раскрашивает её всеми возможными цветами. При вызове из функции main определяется, что номера цветов пока ничем не граничены, и можно использовать их столько, сколько получится по результатам работы первой фазы алгоритма. В замыкании np рассчитывается список тех регионов, которые ещё не окрашены ни в один цвет. А функция paintWith окрашивает все неокрашенные регионы карты в заданный цвет, если каждый соответствующий регион возможно покрасить в этот цвет (нет соседей этого же цвета). Вот её определение:

paintWith :: Color -> Regions -> Picture -> [RegionNumber] -> Picture
paintWith _ _ pct [] = pct
paintWith clr rgn pct np = case filter validRegions np of
                             []     -> pct
                             (r:rs) -> paintWith clr rgn (Map.insert r clr pct) rs
  where
    validRegions rgn'   = case Map.lookup rgn' rgn of
                            Just n  -> all (differentColors clr) n
                            Nothing -> False
    differentColors c r = case Map.lookup r pct of
                            Just c' -> c' /= c
                            Nothing -> True

Ну тут всё просто. Ищем среди заданных непокрашенных регионов те, которые можно покрасить в заданный цвет. Это делается при помощи локально определённого предиката validRegions. Если этот предикат находит регионы, то первый из них окрашивается в заданный цвет, а по остальным опять пробегается этой же функцией paintWith, поскольку среди оставшихся неокрашенных регионов могут появиться такие, которые уже нельзя красить в заданный цвет (они граничат с только что покрашенным).

Теперь мы плавно переходим ко второй фазе алгоритма. Она реализована в функции optimizeLoop. Вот её определение:

optimizeLoop :: Regions -> Picture -> Color -> Picture
optimizeLoop rgn pct maxClr =
  case optimize rgn pct maxClr of
    Just npl -> let newMaxClr = numberOfColors npl
                in  if newMaxClr >= maxClr
                      then optimizeLoop rgn npl newMaxClr
                      else npl
    Nothing  -> pct

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

Переходим к функции optimize:

optimize :: Regions -> Picture -> Color -> Maybe Picture
optimize rgn pct clrToRemove =
  case [x | (x, c) <- Map.toList pct, c == clrToRemove] of
    []      -> Just pct
    toCheck -> stepBack (minimum toCheck) rgn pct clrToRemove

Эта функция как раз реализует первый шаг второй фазы алгоритма — она пытается удалить заданный цвет. Делает она это чётко по алгоритму путём возвращения на шаг назад из региона. Это осуществляет функция stepBack. Вот её определение:

stepBack :: RegionNumber -> Regions -> Picture -> Color -> Maybe Picture
stepBack 1     _  pct      _      = Nothing
stepBack node rgn pct clrToRemove = repaint rgn pct clrToRemove $
                                      reverse $
                                      sort $
                                      filter (< node) (rgn Map.! node)

Тут так. Берём список регионов, граничащих с заданным. Фильтруем их так, чтобы их цвет был меньше, чем у заданного региона. Сортируем, обращаем и перекрашиваем. Перекраска осуществляется при помощи функции repain, которая определена следующим образом:

repaint :: Regions -> Picture -> Color -> [RegionNumber] -> Maybe Picture
repaint _ pct _ [] =  Nothing
repaint rgn pct clrToRemove (c:cs) =
  case repaintWith rgn pct ((pct Map.! c) + 1) clrToRemove c of
    Nothing -> repaint rgn pct clrToRemove cs
    ok      -> ok

Эта функция последовательно пробует перекрасить все заданные регионы при помощи функции repaintWith, которая перекрашивает регионы в заданный цвет (в то время как описываемая функция пытается использовать все возможные цвета, которые доступны — по номеру меньшие, чем номер цвета текущего региона). Вот определение функции repaintWith:

repaintWith :: Regions -> Picture -> Color -> Color -> RegionNumber -> Maybe Picture
repaintWith rgn pct clrFrom clrTo cnd
  | clrFrom >= clrTo = Nothing
  | otherwise = if validPicture rgn np
                  then if numberOfColors np < clrTo
                         then Just np
                         else optimize rgn np clrTo
                  else repaintWith rgn pct (clrFrom + 1) clrTo cnd
  where
    np = paint rgn 1 $
           Map.filterWithKey (n _ -> n <= cnd) $
           Map.insert cnd clrFrom pct

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

Осталось рассмотреть две служебные функции. Первая — numberOfColors. Вот её определение:

numberOfColors :: Picture -> Int
numberOfColors = maximum . map snd . Map.toList

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

А вот функция validPicture, которая представляет собой предикат, проверяющий валидность раскраски карты:

validPicture :: Regions -> Picture -> Bool
validPicture rgn pct = all (uncurry validRegion) $ Map.toList pct
  where
    validRegion rgn' clr = all (differentColors clr) (rgn Map.! rgn')
    differentColors c r  = case Map.lookup r pct of
                             Just c' -> c' /= c
                             Nothing -> True

Этот предикат просто проверяет условие того, что ни один регион не имеет соседа, окрашенного в тот же самый цвет. Он пробегается по всем регионам карты и для каждого из них смотрит, есть ли у него соседи, окрашенные в тот же цвет. Если хоть одна такая пара находится, то предикат возвращает значение False.

Всё.

Результаты

Посмотрим, что будет в результате выполнения этой программы. В этом нам поможет сайт, подготовленный соорганизатором как раз для этих целей: Хроматическое число карты. Это крайне специализированный сайт — он расскрашивает только карту России и ничего больше. Однако для проверки решений, которые присылались на конкурс, этого хватит.

Исходные данные для работы надо взять по этой ссылке. Там лежит файл, в котором как раз записано описание административного состава России в том формате, который ранее описан в этой статье. И если этот файл скормить функции main, то мы получим что-то типа следующего:

magadan 3 
volgograd 3 
altaikrai 3 
tuva 2 
saratov 2 
kaliningrad 1 
altai 1 
ryazan 1 
novgorod 4 
penza 4 
arkhangelsk 4 
kabardinobalkaria 3 
moscow 2 
tula 4 
...

Если затем полученный результат внести в поле вводе на указанном выше сайте, то результатом будет следующая карта:

Раскрашиваем карту при помощи языка Haskell

Таким образом были собраны результаты со всех участников конкурса, и сводку по этим результатам можно посмотреть здесь. Кто участвовал, тот — молодец.

Заключение

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

Описанный модуль на языке Haskell, как обычно, можно получить по следующей сылке: MapPainting.

Мои предыдущие статьи о конкурсах по ФП на Хаброхабре:

Автор: Darkus

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js