Задача о рюкзаке 0/1: реализация и оптимизация
Алгоритмическая задача о рюкзаке 0/1
Задача о рюкзаке 0/1 (Knapsack problem) формулируется так: имеется набор предметов, каждый с весом и ценностью. Необходимо выбрать подмножество таким образом, чтобы общий вес не превышал заданную вместимость рюкзака, а суммарная ценность была максимальной. Каждый предмет можно взять только один раз (отсюда 0/1). Это классическая задача дискретной оптимизации, решаемая методами динамического программирования.
Основное решение: двумерная таблица динамического программирования
Наиболее эффективный способ (для целых весов) - построить таблицу DP размером (n+1)×(W+1), где n - количество предметов, W - вместимость. Значение dp[i][j] - максимальная ценность для первых i предметов при весе не более j. Рекуррентное соотношение:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i-1]] + value[i-1]) если j >= weight[i-1]
иначе dp[i][j] = dp[i-1][j]
Python задачи на числа (задачи на числа в python)
Здесь weight и value - списки весов и ценностей. База: dp[0][j] = 0 для всех j.
Пример реализации:
def knapsack_2d(weights, values, capacity):
n = len(weights)
dp = [[0]*(capacity+1) for _ in range(n+1)]
for i in range(1, n+1):
w = weights[i-1]
v = values[i-1]
for j in range(1, capacity+1):
if j >= w:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - w] + v)
else:
dp[i][j] = dp[i-1][j]
return dp[n][capacity]
Python простые числа (простые числа в python)
Временная сложность O(n*W), память O(n*W). Подходит при не слишком больших W (до десятков тысяч).
Типичные ошибки:
- Неправильный порядок циклов - внутренний цикл должен быть по j от 1 до capacity.
- Забывание проверки j >= weight - это приводит к выходу за границы массива.
- Путаница индексов: weight[i-1] соответствует i-му предмету.
- Нецелые веса - метод не работает, требуется масштабирование или другой подход.
- Переполнение памяти при больших capacity (матрица (n+1)*(W+1) может быть огромной).
Как реализовать рекурсивное решение с кэшированием?
Рекурсия с мемоизацией (lru_cache) избегает вычисления одних и тех же подзадач. Это альтернатива итеративному DP, удобная при малом количестве предметов.
from functools import lru_cache
def knapsack_rec(weights, values, capacity):
@lru_cache(maxsize=None)
def helper(i, cap):
if i == len(weights) or cap == 0:
return 0
if weights[i] > cap:
return helper(i+1, cap)
else:
return max(helper(i+1, cap),
helper(i+1, cap - weights[i]) + values[i])
return helper(0, capacity)
Python задания на алгоритмы (задания на алгоритмы в python)
Сложность O(n*capacity) по времени, по памяти O(n*capacity) для кэша. Недостаток - глубина рекурсии может превысить лимит для большого n.
Проблемы: рекурсия до 1000 предметов вызовет RecursionError. Решение - увеличить sys.setrecursionlimit или использовать итеративный DP. Также кэш может потреблять много памяти.
Как уменьшить потребление памяти при решении?
Можно использовать одномерный массив размером W+1, обновляя его справа налево (для избежания переиспользования данных текущего шага).
def knapsack_1d(weights, values, capacity):
dp = [0]*(capacity+1)
for i in range(len(weights)):
w = weights[i]
v = values[i]
for j in range(capacity, w-1, -1):
dp[j] = max(dp[j], dp[j - w] + v)
return dp[capacity]
Память O(W), время O(n*W). Важно идти от capacity вниз, иначе будет использован один предмет несколько раз (как в задаче с неограниченным количеством).
Типичная ошибка: обход слева направо - приводит к неправильному результату (предмет учитывается многократно). Также при обработке нецелых весов требуется преобразование.
Почему жадный алгоритм не гарантирует оптимальность для 0/1 рюкзака?
Жадный подход (выбирать предметы с максимальным отношением ценность/вес) даёт оптимальное решение только для дробного рюкзака. Для 0/1 он не работает, так как неделимость предмета может нарушить жадную логику. Пример:
weights = [5, 4, 3]
values = [10, 8, 6]
capacity = 7
# Жадный по удельной ценности: предмет 0 (10/5=2), всё; оставшийся вес 2 - ничего не помещается, итого 10.
# Оптимальное: предметы 1 и 2 (8+6=14) вес 7.
Жадный алгоритм может быть использован как приближение, но не даёт точного ответа.
Ошибка: применение жадного алгоритма без проверки, подходит ли он для конкретной задачи.
Выбор варианта зависит от требований: двумерная таблица - для наглядности и малого capacity, одномерная - для экономии памяти, рекурсия - для ясности кода при небольшом n, жадный - только для нестрогой оценки.
Расширенные примеры решения задачи о рюкзаке 0/1
Восстановление набора выбранных предметов
После нахождения максимальной ценности часто требуется узнать, какие предметы были взяты. Для этого используется двумерная таблица dp. Алгоритм: начинаем с dp[n][capacity], если dp[i][j] != dp[i-1][j], значит i-й предмет взят, уменьшаем j на его вес и переходим к i-1.
def knapsack_with_items(weights, values, capacity):
n = len(weights)
dp = [[0]*(capacity+1) for _ in range(n+1)]
for i in range(1, n+1):
w = weights[i-1]
v = values[i-1]
for j in range(1, capacity+1):
if j >= w:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - w] + v)
else:
dp[i][j] = dp[i-1][j]
# Восстановление
items = []
j = capacity
for i in range(n, 0, -1):
if dp[i][j] != dp[i-1][j]:
items.append(i-1)
j -= weights[i-1]
return dp[n][capacity], items[::-1]
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 8
max_val, chosen = knapsack_with_items(weights, values, capacity)
print("Максимальная ценность:", max_val)
print("Номера предметов:", chosen)
Максимальная ценность: 9 Номера предметов: [0, 1, 3]
Сравнение производительности решений
Для случайных данных с n=100, capacity=1000 измерим время и память.
import random, time, sys
n, capacity = 100, 1000
weights = [random.randint(1, 100) for _ in range(n)]
values = [random.randint(1, 100) for _ in range(n)]
def time_it(func, *args):
start = time.perf_counter()
result = func(*args)
return result, time.perf_counter() - start
# двумерный
res2d, t2d = time_it(knapsack_2d, weights, values, capacity)
# одномерный
res1d, t1d = time_it(knapsack_1d, weights, values, capacity)
# рекурсивный с мемо (возможно долго - ограничим)
if n <= 30:
res_rec, t_rec = time_it(knapsack_rec, weights, values, capacity)
else:
res_rec, t_rec = None, None
print(f"2D DP: {t2d:.4f} сек, ответ {res2d}")
print(f"1D DP: {t1d:.4f} сек, ответ {res1d}")
if t_rec:
print(f"Recursive: {t_rec:.4f} сек, ответ {res_rec}")
2D DP: 0.0152 сек, ответ 985 1D DP: 0.0081 сек, ответ 985
Одномерный вариант быстрее из-за меньшего количества операций и лучшей локальности данных. Рекурсия медленнее и имеет накладные расходы на вызовы функций и кэш.
Использование functools.lru_cache для мемоизации
В рекурсивном решении lru_cache автоматически запоминает результаты для пар (i, cap). Это удобно, но стоит помнить о переполнении стека при больших n.
from functools import lru_cache
def knapsack_lru(weights, values, capacity):
@lru_cache(maxsize=None)
def dfs(idx, cap):
if idx == len(weights) or cap == 0:
return 0
if weights[idx] > cap:
return dfs(idx+1, cap)
return max(dfs(idx+1, cap), dfs(idx+1, cap - weights[idx]) + values[idx])
return dfs(0, capacity)
Пример вызова:
print(knapsack_lru([2,3,4],[3,4,5],5)) # Вывод: 7 (предметы 0 и 1)
7
Обработка нецелых весов с помощью масштабирования
Если веса вещественные, их можно умножить на коэффициент (например, 100) и округлить, но это может исказить точность. Альтернатива - использовать алгоритм для дробного рюкзака или метод ветвей и границ.
def knapsack_float_approx(weights, values, capacity, scale=100):
int_weights = [int(round(w * scale)) for w in weights]
int_capacity = int(round(capacity * scale))
return knapsack_1d(int_weights, values, int_capacity)
Погрешность зависит от scale. Увеличение scale приводит к росту памяти.
Проблема: при очень маленьких весах (0.0001) масштаб до 10000 может дать capacity = 10^6, и одномерный массив станет большим, но все равно выполнимым.