Статьи

Динамическое программирование с Ruby

Изучение алгоритмов является краеугольным камнем компьютерных исследований и образования. Однако в нашем сообществе, ориентированном на веб-сайты, Rubyists часто не знают общих алгоритмических методов.

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

Предупреждение: если вы впервые знакомитесь с алгоритмами с некоторым математическим уклоном, процесс может показаться немного сложным. Но я настоятельно рекомендую вам придерживаться этого! Ощущение, что вы получите, как только он «щелкнет», потрясающе. Если у вас есть какие-либо вопросы, пожалуйста, оставьте их в комментариях ниже.

Давайте прыгать в!

Что это?

Прежде всего, название «динамическое программирование» не имеет абсолютно никакого отношения к практике написания кода. Это фактически началось как метод решения сложных математических задач. В частности, динамическое программирование (называемое DP) часто определяется как метод, который решает большую проблему, разбивая ее на последовательно меньшие задачи. Это немного расплывчато и на то есть веская причина: поскольку DP можно применять разными способами, базовые концепции являются довольно общими. Лучший способ действительно понять что-то — это использовать его, поэтому давайте посмотрим на проблему, которую мы можем решить с помощью DP.

Самые длинные возрастающие подпоследовательности

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

Вот проблема: Найти длину самой длинной увеличивающейся подпоследовательности последовательности A.

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

seq = [2, 9, 8, 4]

Сама последовательность не увеличивается. Давайте посмотрим на подпоследовательности длины три, чтобы увидеть, увеличивается ли какая-либо из них:

 [2, 9, 8]
[2, 8, 4]
[9, 8, 4]

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

 [2, 9]

Таким образом, наш ответ (который является максимальной длиной возрастающих подпоследовательностей) должен быть два. Теперь вопрос в том, как мы скажем компьютеру сделать это? Наивным подходом было бы проверить, увеличивается ли вся последовательность, проверить, увеличиваются ли все подпоследовательности длины n-1 (где n — длина последовательности), и так далее, пока мы не достигнем длины, где мы можем найти возрастающую подпоследовательность , Но это то, что называется on2 алгоритм, который означает, что он масштабируется в квадрате по отношению к размеру входной последовательности.

Можем ли мы сделать лучше?

Оказывается, мы можем, и мы сделаем это, используя магию DP. Прежде всего, сколько подпоследовательностей у нас будет в этом конце в seq[0] Только один: подпоследовательность длины один, которая содержит только seq[0] Как насчет тех, которые заканчиваются на seq[1] Хммм… поскольку seq[0] , we can simply add on seq[1]seq[0] Таким образом, максимальная длина подпоследовательности, заканчивающейся в seq[1]seq[0]seq[1]

Давайте попробуем обобщить это. Предположим, мы хотим узнать максимальную длину подпоследовательностей, заканчивающихся на seq[j] Скажем, у нас есть массив с именем qseq[j]и меньше, чем seq[j] Возьмем пример: для seq = [2, 4, 10, 9, 4]j = 3jseqq = [2, 4]seq[j]и меньше, чем seq[j]

Тогда можно сказать, что максимальная длина возрастающих подпоследовательностей заканчивается в seq[j]
равно 1 плюс максимальная длина возрастающих подпоследовательностей по всем показателям
в q По сути, мы используем вещи, которые мы уже рассчитали, чтобы выяснить,
как придумать окончательное решение.

Код всегда помогает прояснить ситуацию:

 def longest_incr_subseq(seq)
  l = {}

  seq.length.times do |j|
    l_conn_keys = l.keys.select do |i|
      i < j and seq[i] < seq[j]
    end

    l_conn_values = l_conn_keys.map do |k|
      l[k]
    end

    l[j] = 1 + (l_conn_values.max || 0)
  end

  return l.values.max
end

Давайте разберемся с этим.

 l = {}

Здесь мы определяем хеш, который отображает индексы на максимальную длину увеличивающихся подпоследовательностей, заканчивающихся на этом индексе. Возвращаясь к примеру, если seq=[2, 4, 3]l = {0=>1, 1=>2, 2=>2} Есть только одна возможная подпоследовательность, заканчивающаяся на 0: [2] так что l[0] = 1 Затем мы используем алгоритм, чтобы выяснить остальное
из l

 seq.length.times do |j|
  ...
end

Мы перебираем все индексы в последовательности, потому что мы должны заполнить значение для всех них в структуре l

 seq.length.times do |j|
  ...
  l_conn_keys = l.keys.select do |i|
    i < j and seq[i] < seq[j]
  end
  ...
end

l_conn_keysq Он содержит индексы, которые предшествуют jj Это подразумевает, что увеличивающиеся подпоследовательности, которые заканчиваются элементом l_conn_keysseq[j]

 seq.length.times do |j|
  ...
  l_conn_values = l_conn_keys.map do |k|
    l[k]
  end
  ...
end

Мы l_conn_keysseql Это дает нам максимальную длину возрастающих подпоследовательностей для всех соединительных индексов.

 l[j] = 1 + (l_conn_values.max || 0)

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

Давайте работать через пример. Скажите seq = [2, 4, 9, 5] Для j=0l_conn_keys = [] Таким образом, l_conn_values = [] Тогда с выражением l[j] = 1 = (l_conn_values.max || 0)l[0] = 1 Установите j=1 Помните, что l_conn_keysj=1l_conn_keys = [0]l_conn_values = [1] Мы получаем l[1] = 1 + 1 = 2 Аналогично, мы можем рассмотреть j=2l[2] = 3 Теперь j=3 Обратите внимание, что l_conn_keys = [0, 1]l_conn_values = [1, 2]l[3] = 2 + 1 = 3

Это дает нам правильный алгоритм (само доказательство на самом деле довольно простое и использует математическую индукцию — бонусные баллы, если вы можете это записать!). Давайте подумаем немного о том, как это работает. Мы перебираем всю последовательность только один раз (или постоянное число, кратное «один раз»), поэтому мы можем сказать, что алгоритм на или что он масштабируется линейно. Подумайте о сравнении с нашим наивным алгоритмом, который был квадратичным. Эта разница может быть огромной в масштабе!

Из всей глубины проблемы, основные моменты:

  • Мы разбили большие проблемы на более мелкие, а затем создали большое решение из небольших решений.
  • Мы использовали максимальную длину подпоследовательностей, оканчивающихся на определенный индекс. Этот трюк с «окончанием по определенному индексу» на самом деле очень полезен во всех видах проблем.
  • Наш алгоритм был немного быстрее, чем алгоритм, которого мы достигли сначала

Универмаг

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

Скажем, вы управляете сетью универмагов. У вас есть определенное местоположение (называемое Местоположение 0), из которого вы измеряете расстояния по прямой дороге. Вам предоставляется список возможных мест для магазинов в виде расстояний от Местоположения 0 в виде вектора / набора: м Для каждого местоположения в м есть соответствующая прибыль, которые перечислены в п с число Пи в соответствии с местоположением ми
И вам также дают номер К

Вам не разрешено создавать два магазина, которые находятся на расстоянии менее чем в k милях друг от друга. Проблема проста: как максимизировать свою прибыль?

Святая корова, это кажется действительно сложным. Вы должны как-то манипулировать между требованием расстояния и магазинами с самой высокой прибылью. Оказывается, мы можем использовать динамическое программирование, чтобы получить решение этой проблемы!

Давайте определим функцию (в математическом смысле) T где Техас представляет максимальную прибыль, если вы должны были просто закончить список магазинов на расстоянии х . Таким образом, ценность, которую мы ищем в конце дня TMN-1 где n — количество магазинов.

Прежде всего, обратите внимание, что максимальная прибыль неизменна для расстояний между магазинами. Например, если у нас есть магазин в 10, а другой в 20: t_eqals_10_19

Теперь давайте попробуем разобраться с большей проблемой вычислений TMN-1 в некоторые меньшие проблемы. Скажем, мы хотим максимизировать прибыль на расстоянии ми для некоторых я . Тогда мы можем включить все магазины, которые находятся k единиц «позади»
ми

Итак, мы можем написать:

трет-рекуррентное

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

базовый вариант

Теперь, когда у нас есть математическая идея алгоритма DP, давайте закодируем его в Ruby:

 def T(x, m, t_hash)
  return 0 if x < m[0]

  keys = t_hash.keys.sort
  relative_distances = keys.map do |d|
    x - d
  end

  #filter out the stores to leave only the
  #ones that come atleast "k" units before "x"
  filtered_distances = relative_distances.select do |r|
    r >= 0
  end

  key = keys[filtered_distances.index filtered_distances.min]
  return t_hash[key]
end

def dept_store(m, p, k)
  t_hash = {m[0] => p[0]}

  (1..m.length-1).to_a.each do |j|
    b = m[j] - k
    t_hash[m[j]] = p[j] + T(b, m, t_hash)
  end

  p t_hash
  t_hash.values.max
end

Давайте посмотрим на реализацию Техас первый:

 def T(x, m, t_hash)
  return 0 if x < m[0]

  keys = t_hash.keys.sort
  relative_distances = keys.map do |d|
    x - d
  end

  #filter out the stores to leave only the
  #ones that come atleast "k" units before "x"
  filtered_distances = relative_distances.select do |r|
    r >= 0
  end

  key = keys[filtered_distances.index filtered_distances.min]
  return t_hash[key]
end

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

Обратите внимание, что t_hash например, t_hash[m[i]] TMI

Но только расположение магазинов — это ключи в t_hash

Теперь, когда у нас есть Tдействительно проста:

 def dept_store(m, p, k)
  t_hash = {m[0] => p[0]}

  (1..m.length-1).to_a.each do |j|
    b = m[j] - k
    t_hash[m[j]] = p[j] + T(b, m, t_hash)
  end

  t_hash.values.max
end

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

Взять домой очки:

  • Мы разбили сложную проблему на более простые, более мелкие проблемы
  • Мы использовали функцию для отслеживания состояния (фактически, мы неявно сделали то же самое для более раннего примера; бонусные баллы, если вы можете определить функцию!)
  • Мы создали отношения, которые позволили нам «выстроить» решение нашей большой проблемы
  • Наше решение работает в линейном времени
  • Тот же трюк с делением проблемы «на основании того, где они заканчиваются» был использован довольно эффективно

Это все люди!

Надеемся, что эта статья дала вам представление о концепциях динамического программирования.
а также некоторые идеи о том, как реализовать их в Ruby.