Задача о рюкзаке 0/1: реализация и оптимизация

Раздел: Продвинутый Python -> Алгоритмические задачи

Алгоритмическая задача о рюкзаке 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, и одномерный массив станет большим, но все равно выполнимым.

Задания на алгоритмы в Python - comments

En
Python задания на алгоритмы (python)