Изучение алгоритмов является краеугольным камнем компьютерных исследований и образования. Однако в нашем сообществе, ориентированном на веб-сайты, 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 — длина последовательности), и так далее, пока мы не достигнем длины, где мы можем найти возрастающую подпоследовательность , Но это то, что называется алгоритм, который означает, что он масштабируется в квадрате по отношению к размеру входной последовательности.
Можем ли мы сделать лучше?
Оказывается, мы можем, и мы сделаем это, используя магию 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]
Скажем, у нас есть массив с именем q
seq[j]
и меньше, чем seq[j]
Возьмем пример: для seq = [2, 4, 10, 9, 4]
j = 3
j
seq
q = [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_keys
q
Он содержит индексы, которые предшествуют j
j
Это подразумевает, что увеличивающиеся подпоследовательности, которые заканчиваются элементом l_conn_keys
seq[j]
seq.length.times do |j|
...
l_conn_values = l_conn_keys.map do |k|
l[k]
end
...
end
Мы l_conn_keys
seq
l
Это дает нам максимальную длину возрастающих подпоследовательностей для всех соединительных индексов.
l[j] = 1 + (l_conn_values.max || 0)
Вот сердце алгоритма. Мы говорим, что мы можем добавить наш элемент (увеличивая длину на 1) к длине, связанной с индексом, который имеет самую высокую подпоследовательность максимальной длины.
Давайте работать через пример. Скажите seq = [2, 4, 9, 5]
Для j=0
l_conn_keys = []
Таким образом, l_conn_values = []
Тогда с выражением l[j] = 1 = (l_conn_values.max || 0)
l[0] = 1
Установите j=1
Помните, что l_conn_keys
j=1
l_conn_keys = [0]
l_conn_values = [1]
Мы получаем l[1] = 1 + 1 = 2
Аналогично, мы можем рассмотреть j=2
l[2] = 3
Теперь j=3
Обратите внимание, что l_conn_keys = [0, 1]
l_conn_values = [1, 2]
l[3] = 2 + 1 = 3
Это дает нам правильный алгоритм (само доказательство на самом деле довольно простое и использует математическую индукцию — бонусные баллы, если вы можете это записать!). Давайте подумаем немного о том, как это работает. Мы перебираем всю последовательность только один раз (или постоянное число, кратное «один раз»), поэтому мы можем сказать, что алгоритм или что он масштабируется линейно. Подумайте о сравнении с нашим наивным алгоритмом, который был квадратичным. Эта разница может быть огромной в масштабе!
Из всей глубины проблемы, основные моменты:
- Мы разбили большие проблемы на более мелкие, а затем создали большое решение из небольших решений.
- Мы использовали максимальную длину подпоследовательностей, оканчивающихся на определенный индекс. Этот трюк с «окончанием по определенному индексу» на самом деле очень полезен во всех видах проблем.
- Наш алгоритм был немного быстрее, чем алгоритм, которого мы достигли сначала
Универмаг
На этот раз, вместо того, чтобы сразу перейти к Ruby-говорящему, мы попытаемся использовать некоторую математику в описании нашего алгоритма. Во многих случаях это может сделать аргументы более ясными.
Скажем, вы управляете сетью универмагов. У вас есть определенное местоположение (называемое Местоположение 0), из которого вы измеряете расстояния по прямой дороге. Вам предоставляется список возможных мест для магазинов в виде расстояний от Местоположения 0 в виде вектора / набора: Для каждого местоположения в м есть соответствующая прибыль, которые перечислены в п с в соответствии с местоположением
И вам также дают номер
Вам не разрешено создавать два магазина, которые находятся на расстоянии менее чем в k милях друг от друга. Проблема проста: как максимизировать свою прибыль?
Святая корова, это кажется действительно сложным. Вы должны как-то манипулировать между требованием расстояния и магазинами с самой высокой прибылью. Оказывается, мы можем использовать динамическое программирование, чтобы получить решение этой проблемы!
Давайте определим функцию (в математическом смысле) T где представляет максимальную прибыль, если вы должны были просто закончить список магазинов на расстоянии х . Таким образом, ценность, которую мы ищем в конце дня где n — количество магазинов.
Прежде всего, обратите внимание, что максимальная прибыль неизменна для расстояний между магазинами. Например, если у нас есть магазин в 10, а другой в 20:
Теперь давайте попробуем разобраться с большей проблемой вычислений в некоторые меньшие проблемы. Скажем, мы хотим максимизировать прибыль на расстоянии для некоторых я . Тогда мы можем включить все магазины, которые находятся 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]]
Но только расположение магазинов — это ключи в 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.