Статьи

Алгоритм недели: Python против Ruby в задаче о ранце

Последним алгоритмом, который нам пришлось кодировать в  Алгоритмах 2,  была  проблема ранца,  которая заключается в следующем:

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

Мы внесли небольшие изменения в это в том, что вы можете выбрать каждый предмет только один раз, что называется проблемой ранца 0-1.

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

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

  • Пусть  A  = 2-D массив размером  n  (количество предметов) *  W  (размер рюкзака)
  • Инициализация  A [0, X] = 0  для  X = 0,1, .., W
  • для i = 1,2,3,… n

    • для х = 0,1,2,…, ш

      • A [i, x] = max {A [i-1, x], A [x-1, xw i ] + V i  }
      • где V i  — это значение i- го  элемента, а W i  — вес i- го  элемента.
  • вернуть A [n, W]

Эта версия работает в O (nW) времени и O (nW) пространстве. Это основная часть  моего решения  для Ruby :

number_of_items,knapsack_size = # calculated from file
 
cache = [].tap { |m| (number_of_items+1).times { m << Array.new(knapsack_size+1) } }
cache[0].each_with_index { |value, weight| cache[0][weight] = 0  }
 
(1..number_of_items).each do |i|
  value, weight = rows[i-1]
  (0..knapsack_size).each do |x|
    if weight > x
      cache[i][x] = cache[i-1][x] 
    else
      cache[i][x] = [cache[i-1][x], cache[i-1][x-weight] + value].max
    end
  end
end
 
p cache[number_of_items][knapsack_size]

Этот подход работает достаточно хорошо, когда  n  и  W  малы, но во второй части проблемы  n  было 500, а  W  было 2 000 000, что означает, что двумерный массив будет содержать 1 миллиард записей.

Если в этой структуре данных мы храним целые числа по 4 байта каждый, то  требуемый объем памяти составляет 3,72 ГБ — это слишком много для моей машины!

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

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

@new_cache = [].tap { |m| (@number_of_items+1).times { m << {} } }
 
def knapsack_cached(rows, knapsack_size, index)
  return 0 if knapsack_size == 0 || index == 0
  value, weight = rows[index]
  if weight > knapsack_size
    stored_value = @new_cache[index-1][knapsack_size]
 
    return stored_value unless stored_value.nil?  
    return @new_cache[index-1][knapsack_size] = knapsack_cached(rows, knapsack_size, index-1)
  else
    stored_value = @new_cache[index-1][knapsack_size]
    return stored_value unless stored_value.nil?
 
    option_1  = knapsack_cached(rows, knapsack_size, index-1)
    option_2  = value + knapsack_cached(rows, knapsack_size - weight, index-1)
    return @new_cache[index-1][knapsack_size] = [option_1, option_2].max
  end
end
 
p knapsack_cached(rows, @knapsack_size, @number_of_items-1)

Код очень похож на предыдущую версию, за исключением того, что мы начинаем с последнего пункта и продолжаем наш путь внутрь. Мы закончили тем, что сохранили 2 549 110 элементов в  @new_array,  которые мы можем обработать , выполнив это:

p @new_cache.inject(0) { |acc,x| acc + x.length}

Если бы мы использовали двумерный массив, это означало бы, что мы заполнили бы только 0,25% структуры данных, действительно расточительно!

Я хотел немного рассказать о том, насколько быстро этот алгоритм работает в Ruby по сравнению с JRuby, и недавно я наткнулся на  nailgun  — который позволяет вам  запускать постоянную JVM, а затем запускать свой код через него вместо запуска нового. каждый раз, так что я думал, что смогу поиграть с этим!

# Ruby
$ time ruby knapsack/knapsack_rec.rb
real	0m18.889s user	0m18.613s sys	0m0.138s
 
# JRuby
$ time ruby knapsack/knapsack_rec.rb
real	0m6.380s user	0m10.862s sys	0m0.428s
 
# JRuby with nailgun
$ ruby --ng-server & # start up the nailgun server
 
$ time ruby --ng knapsack/knapsack_rec.rb
real	0m6.734s user	0m0.023s sys	0m0.021s
 
$ time ruby --ng knapsack/knapsack_rec.rb
real	0m5.213s user	0m0.022s sys	0m0.021s

Первый запуск немного медленный, поскольку JVM запускается, но после этого мы получаем незначительное улучшение. Я думал, что время запуска JVM будет большей частью времени работы, но я думаю, что нет!

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

Это было  решение Python :

def knapsack_cached(rows, knapsack_size, index):
    global cache
    if(index is 0 or knapsack_size is 0):
        return 0
    else:
        value, weight = rows[index]
        if(weight > knapsack_size and knapsack_size not in cache[index-1]):
            cache[index-1][knapsack_size] = knapsack_cached(rows, knapsack_size, index-1)                
        else:
            if(knapsack_size not in cache[index-1]):
                option_1  = knapsack_cached(rows, knapsack_size, index-1)
                option_2  = value + knapsack_cached(rows, knapsack_size - weight, index-1)
                cache[index-1][knapsack_size] = max(option_1, option_2)                
 
        return cache[index-1][knapsack_size]
 
knapsack_size, number_of_items, rows = # worked out from the file
 
result = knapsack_cached(rows, knapsack_size, number_of_items-1)    
print(result)

Код в значительной степени совпадает с версией Ruby, но интересно, что он работает быстрее:

$ time python knapsack/knapsack.py 
real	0m4.568s user	0m4.480s sys	0m0.078s

Я понятия не имею, почему это было бы так, но это было для всех алгоритмов, которые мы написали до сих пор. Если у кого-то есть какие-либо идеи, я заинтригован, чтобы услышать их!