Как алгоритмы динамического программирования реализованы в идиоматическом Haskell?

Haskell и другие функциональные языки программирования построены вокруг предпосылки не поддерживать состояние. Я все еще новичок в том, как работает функциональное программирование и концепции в нем, поэтому мне было интересно, возможно ли реализовать алгоритмы DP в FP.

Какие функциональные программные конструкции могут быть использованы для этого?

6 ответов

Решение

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

import qualified Data.MemoCombinators as Memo

fib = Memo.integral fib'
    where
    fib' 0 = 0
    fib' 1 = 1
    fib' n = fib (n-1) + fib (n-2)

fib это запомненная версия, и fib' просто "грубой силой" проблемы, но вычисляет ее подзадачи, используя запомнили fib, Другие алгоритмы DP написаны в том же стиле, с использованием различных структур мемо, но с той же идеей простого вычисления результата простым функциональным способом и запоминания.

Изменить: я наконец сдался и решил предоставить запоминающийся класс типов. Это означает, что запоминание стало проще:

import Data.MemoCombinators.Class (memoize)

fib = memoize fib'
    where
    fib' :: Integer -> Integer  -- but type sig now required 
    ...

Instaead о необходимости следовать типу, вы можете просто memoize что-нибудь. Вы все еще можете использовать старый способ, если вам это нравится.

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

Он упрощен тем, что работает только с функциями, которые принимают Int в качестве входных данных и выдают Int в качестве выходных. Поскольку мы используем Int двумя различными способами, я сделаю для них синонимы "Ключ" и "Значение". Но не забывайте, что, поскольку это синонимы, вполне возможно использовать ключи и значения и наоборот. Они используются только для удобства чтения.

type Key = Int
type Value = Int

dynamic :: (Table Value Key -> Key -> Value) -> Key -> Table Value Key
dynamic compute bnd = t
 where t = newTable (map (\coord -> (coord, compute t coord)) [0..bnd])

Давайте немного разберем эту функцию.

Во-первых, что делает эта функция? Из сигнатуры типа видно, что она как-то манипулирует таблицами. Действительно, первый аргумент "вычислить" - это функция (следовательно, динамическая - это функция "более высокого порядка"), которая генерирует какое-то значение из таблицы, а второй аргумент - это просто некая верхняя граница, указывающая нам, где остановиться. И как результат, "динамическая" функция дает нам своего рода таблицу. Если мы хотим получить ответ на какую-то проблему, дружественную к DP, мы запускаем "dynamic", а затем ищем ответ из нашей таблицы.

Чтобы использовать эту функцию для вычисления Фибоначчи, мы бы запустили ее примерно так

fib = findTable (dynamic helper n) n
 where
  helper t i =
    if i <= 1
       then i
       else findTable t (i-1) + findTable t (i-2)

Пока не беспокойтесь о понимании этой функции выдумки. Это станет немного понятнее, когда мы исследуем "динамику".

Во-вторых, какие предпосылки нам нужно знать, чтобы понять эту функцию? Я предполагаю, что вы более или менее знакомы с синтаксисом: [0..x] для обозначения списка от 0 до x, - - в сигнатурах типа, таких как Int -> Table -> ... по сравнению с - > в анонимных функциях, таких как \ordin ->... Если вам это неудобно, они могут помешать.

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

newTable :: [(k,v)] -> Table v k
findTable :: Table v k -> k -> v

Здесь нужно отметить три вещи:

  • Для простоты мы не используем эквивалент из стандартной библиотеки Haskell
  • findTable завершится сбоем, если вы попросите его найти несуществующее значение из таблицы. Мы можем использовать более причудливую версию, чтобы избежать этого, если это необходимо, но это тема для другого поста.
  • Как ни странно, я не упомянул какую-либо функцию "добавить значение в таблицу", хотя в книге и стандартных библиотеках Haskell она есть. Почему бы и нет?

Наконец, как эта функция на самом деле работает? Что тут происходит? Мы можем немного увеличить масштаб функции,

t = newTable (map (\coord -> (coord, compute t coord)) [0..bnd])

и методично разорвать его на части. Если смотреть снаружи, у нас есть t = newTable (...), который, кажется, говорит нам, что мы строим таблицу из какого-то списка. Скучный. Как насчет списка?

map (\coord -> (coord, compute t coord)) [0..bnd]

Здесь у нас есть функция карты более высокого порядка, которая перемещается по списку от 0 до bnd и в результате создает новый список. Чтобы вычислить новый список, он использует функцию \ координировать -> (координаты, вычислить t координаты). Помните о контексте: мы пытаемся построить таблицу из ключевых пар значений, поэтому, если вы изучаете кортеж, координата первой части должна быть ключом, а вычисление t координаты второй части должно быть значением. Это вторая часть, где вещи становятся захватывающими. Давайте увеличим немного дальше

compute t coord

Мы создаем таблицу из пар ключ-значение, и значение, которое мы подключаем к этим таблицам, получается из-за выполнения команды "вычислить t координаты". Что-то, что я не упомянул ранее, - это то, что compute принимает таблицу и ключ в качестве входных данных и сообщает нам, какое значение мы должны вставить в таблицу, другими словами, какое значение мы должны связать с этим ключом. Идея возврата к динамическому программированию заключается в том, что функция вычисления использует предыдущие значения из таблицы для вычисления того нового значения, которое мы должны добавить.

И это все! Чтобы выполнять динамическое программирование в Haskell, мы можем создать некую таблицу, последовательно вставляя значения в ячейки, используя функцию, которая ищет предыдущие значения из таблицы. Легко, правда?... или это?

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

compute t coord

Чтобы вычислить значение в данной ячейке и таким образом заполнить таблицу, мы передаем t, ту самую таблицу, которую мы пытаемся создать в первую очередь. Как вы указываете, если функциональное программирование связано с неизменностью, то как может работать этот метод использования значений, которые мы еще не вычислили? Если у вас есть немного FP за поясом, вы можете спросить себя, как и я, "это ошибка?", Не должно ли это быть "сгиб" вместо "карта"?

Ключ здесь - ленивая оценка. Немного магии, которая позволяет создавать неизменную ценность из кусочков самого себя, сводится к лени. Будучи своего рода Хаскеллером с долгим желтым поясом, я все еще нахожу понятие лени немного странным. Так что я должен позволить кому-то еще занять здесь.

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

o
|
|--0--> 1
|
|--1--> 1
|
|--2--> 2
|
|--3--> 2
.
.
.

Куски таблицы, которую мы еще не видели, являются неизведанной территорией. Когда мы впервые идем вниз по списку, все это не обнаружено

o
.
.
.

Когда мы хотим вычислить первое значение, нам не нужно больше ничего знать о таблице, потому что я <= 1.

  helper t i =
    if i <= 1
       then i
       else findTable t (i-1) + findTable t (i-2)


o
|
|--0--> 1
.
.
.

Когда мы хотим вычислить последовательные значения, мы всегда оглядываемся только на уже открытые части таблицы (динамическое программирование, эй-эй!). Главное, что нужно помнить, это то, что мы на 100% работаем с неизменяемыми ценностями, без излишеств, кроме лени. "t" действительно означает таблицу, а не "таблицу в ее текущем состоянии на итерации 42". Просто мы обнаруживаем только те фрагменты таблицы, которые говорят нам, каково значение, соответствующее 42, когда мы фактически запрашиваем его.

Надеюсь, с другими на Stackru вы пойдете дальше, чем я, и вас не оставят невнятно бормотать "хм, да, лень что-то или другое" Это действительно не имеет большого значения:-)

Если вы хотите использовать DP с 2 или 3 параметрами (например, при обработке строк), вы можете использовать неизменяемый массив:

import Data.Array.IArray

answer :: String -> Int
answer s = table ! (1, l)
  where
    l = length s

    --signatyres are needed, because GHC doesn't know what kind of Array we need
    --string is stored in Array because we need quick access to individual chars
    a :: Array Int Char
    a = listArray (1, l) s

    table :: Array (Int, Int) Int
    table = listArray ((1, 1), (l, l)) [f i j | i <- [1..l], j <- [1..l]]

    f i j |    i    >     j    = 0
          |    i    ==    j    = 1
          | (a ! i) == (a ! j) = 2 + table ! (i+1, j-1)
          | otherwise          = maximum [table ! (i+1, j), table ! (i, j-1)]

Этот код решает следующую задачу: по заданной строке S найти подпоследовательность S максимальной длины, которая будет являться палиндромом (подпоследовательность не должна быть непрерывной).

По сути, 'f' - это функция восстановления, а массив 'table' - это матрица всех ее возможных значений. Поскольку Haskell ленив, вычисляются только необходимые для ответа значения 'f'. Другими словами, это рекурсия с запоминанием. Так что используйте Data.Memocombinators, который точно такой же, но уже написан кем-то другим:)

Динамическое программирование в haskell можно выразить элегантно благодаря лени, см. Первый пример на этой странице

Алгоритмы динамического программирования обычно используют идею сведения проблемы к более простой задаче. Его проблемы могут быть сформулированы как некий базовый факт (скажем, кратчайший путь от квадратной ячейки к себе имеет длину 0) плюс набор повторяющихся правил, которые точно показывают, как уменьшить проблему "найти кратчайший путь из ячейки" (i,j) в (0,0) " к проблеме " найти кратчайшие пути из клеток (i-1,j) , (i,j-1) в (0,0) ; выбрать лучшее ". AFAIK это может быть легко выражено в программе функционального стиля; без участия государства.

Просматривая ответы, я почувствовал себя немного странно, если мы говорим о рекурсии + кэшировании или просто о динамическом программировании (DP).

Потому что, если это просто DP, следующий код делает именно это: https://jelv.is/blog/Lazy-Dynamic-Programming/

      basic a b = d m n
  where (m, n) = (length a, length b)
        d i 0 = i
        d 0 j = j
        d i j
          | a !! (i - 1) ==  b !! (j - 1) = ds ! (i - 1, j - 1)
          | otherwise = minimum [ ds ! (i - 1, j)     + 1
                                , ds ! (i, j - 1)     + 1
                                , ds ! (i - 1, j - 1) + 1
                                ]

        ds = Array.listArray bounds
               [d i j | (i, j) <- Array.range bounds]
        bounds = ((0, 0), (m, n))

И эта версия DP не слишком отличается от других языков, потому что, если бы я попробовал ее на Javascript, она была бы немного многословной, но писала бы похожим образом.

      function levenshtein(str1, str2) {
    const m = str1.length + 1
    const n = str2.length + 1
    const mat = new Array(m).fill(0).map(() => 
        new Array(n).fill(0)
    )
    
    for (let i = 0; i < m; i++) {
        mat[i][0] = i
    }
    for (let j = 0; j < n; j++) {
        mat[0][j] = j
    }
    
    for (let i = 1; i < m; i++) {
        const ic = str1[i-1]
        for (let j = 1; j < n; j++) {
            const jc = str2[j-1]
            if (ic == jc) {
                mat[i][j] = mat[i-1][j-1]
            } else {
                mat[i][j] = Math.min(
                    mat[i-1][j],
                    mat[i][j-1],
                    mat[i-1][j-1]
                ) + 1
            }
        }
    }

    return mat[m-1][n-1]
}

Поэтому мне интересно, вопрос об использовании рекурсии + кэширования?

Другие вопросы по тегам