Задача о рюкзаке. Простое решение, но где-то должен быть подвох

Введение
Продолжаю свой крестовый поход по NP-полным задачам. От судоку и латинских квадратов немного устал, потому давеча решил переключиться на что-то другое. Выбор пал на задачу о рюкзаке, которая звучит следующим образом, цитата из Вики.
Задача о рюкзаке (также задача о ранце) — NP-полная задача комбинаторной оптимизации. Своё название получила от конечной цели: уложить как можно большее число ценных вещей в рюкзак при условии, что вместимость рюкзака ограничена. С различными вариациями задачи о рюкзаке можно столкнуться в экономике, прикладной математике, криптографии и логистике.
В общем виде задачу можно сформулировать так: из заданного множества предметов со свойствами «стоимость» и «вес» требуется отобрать подмножество с максимальной полной стоимостью, соблюдая при этом ограничение на суммарный вес.
Решение «навскидку» было найдено очень быстро и, честно говоря, после истории с судоку и латинским квадратом процесс показался детским утренником. Не уверен в том, рабочий ли алгоритм на 100 процентов, но, по крайней мере, все тест-кейсы, что были мной придуманы решаются на раз. Потому буду рад, если кто-нибудь поможет разобраться в этом вопросе подкидыванием каких-нибудь новых и более хитрых кейсов, дабы проверить решение на «вшивость».
Сразу прикладываю реп. Как всегда Java, я ж джавист все-таки)
Теперь к делу.
Еще раз формулировка задачи и суть алгоритма
Дан некий контейнер размерностью W. Дано множество элементов, каждый из которых имеет свойства «цена» и «вес». Необходимо во множестве элементов найти такое подмножество, которое имеет максимальную суммарную цену с соблюдаемым ограничением «суммарный вес <= W".
Суть придуманного мной алгоритма проста, как пять копеек: мы находим все возможные и как можно более длинные варианты наполнения (поясню ниже), которые укладываются в ограничение на размер контейнера. Затем мы находим то наполнение, которое имеет максимальную суммарную цену среди полученных. На этом все.
Иллюстрация на примере. Вместимость контейнера (W) = 9.
ВЕС | ЦЕНА |
1 | 4 |
2 | 3 |
7 | 2 |
3 | 1 |
8 | 6 |
В данном случае все возможные и как можно более длинные варианты наполнения выглядят так:
1 + 2 + 3 = 6
1 + 7 = 8
1 + 8 = 9
2 + 7 = 9
Понятно, что нам нет смысла учитывать отдельно 1, 1 + 2, 1 + 3 и так далее — это подмножества как минимум одного из приведенных вариантов наполнения. Это и заставляет приведенное множество наполнений гордо именоваться всеми возможными и как можно более длинными вариантами наполнения.
Теперь среди этих наполнений достаточно найти то наполнение, что имеет максимальную суммарную цену. В нашем случае нетрудно убедиться, что это будет 1 + 8, и общий профит у нас — 10. Предметы с такими весами мы с чистой совестью упаковываем.
В общем-то это и есть вся суть алгоритма. Давайте рассмотрим мою реализацию на Java.
Реализация
Начинаем, разумеется, с интерфейса нашего «рюкзака».
package com.smolka.backpack;
import java.util.Set;
public interface Backpack {
void fillBackpack(Set- items);
Set
- getItemsInBackpack();
int getCapacity();
int getCostOfContent();
int getWeightOfContent();
}
И класса предметов.
package com.smolka.backpack;
import java.util.Objects;
public class Item {
private final String id;
private final int weight;
private final int cost;
public Item(String id, int weight, int cost) {
assert weight > 0;
assert cost > 0;
this.id = id;
this.weight = weight;
this.cost = cost;
}
public String getId() {
return id;
}
public int getWeight() {
return weight;
}
public int getCost() {
return cost;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Item item = (Item) o;
return Objects.equals(id, item.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
}
Ключевой метод в Backpack — fillBackpack, который и попытается как можно выгодней запаковать наш рюкзак переданными элементами. Остальные — по большей части вспомогательный сахар, позволяющий посмотреть на то, что у нас вышло по цене, весу и самому наполнению рюкзака.
В предметы, как видно, я решил добавить поле id — уникальный идентификатор предмета. Как по мне, так будет более наглядно и во входном множестве будут содержаться элементы с одинаковым сочетанием цены и веса. Но можно было бы обойтись просто составным ключом weight + cost, если условия задачи не будут подразумевать таких повторов. В моем случае пусть будет первое.
Реализация такова.
Реализация
package com.smolka.backpack.impl;
import com.smolka.backpack.Backpack;
import com.smolka.backpack.Item;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
public class BackpackImpl implements Backpack {
private final Set- itemsInBackpack;
private final int capacity;
public BackpackImpl(int capacity) {
assert capacity > 0;
this.itemsInBackpack = new HashSet<>();
this.capacity = capacity;
}
@Override
public void fillBackpack(Set
- items) {
itemsInBackpack.clear();
Set
- itemsWithoutOverWeightedElements = items.stream().filter(item -> item.getWeight() <= capacity).collect(Collectors.toSet());
List
- sortedItems = new ArrayList<>(itemsWithoutOverWeightedElements.stream().toList());
sortedItems.sort(Comparator.comparingInt(Item::getWeight));
List
selectionResults = new ArrayList<>();
for (int i = 0; i < sortedItems.size(); i++) {
addSelectionResultForIndex(i, sortedItems, selectionResults);
}
int maxCost = -1;
List- branchWithMaxCost = new ArrayList<>();
for (ItemSelectionResult selectionResult : selectionResults) {
if (selectionResult.isEmpty()) {
continue;
}
ItemsCostInfo itemsCostInfo = selectionResult.getBranchWithMaxCost();
int currentCost = itemsCostInfo.cost();
if (currentCost > maxCost) {
maxCost = currentCost;
branchWithMaxCost = itemsCostInfo.items();
}
}
itemsInBackpack.addAll(branchWithMaxCost);
}
@Override
public int getCostOfContent() {
return itemsInBackpack.stream().map(Item::getCost).reduce(Integer::sum).orElse(0);
}
@Override
public int getWeightOfContent() {
return itemsInBackpack.stream().map(Item::getWeight).reduce(Integer::sum).orElse(0);
}
@Override
public Set
- getItemsInBackpack() {
return itemsInBackpack;
}
@Override
public int getCapacity() {
return capacity;
}
private void addSelectionResultForIndex(int index, List
- items, List
otherResults) {
ItemSelectionResult result = new ItemSelectionResult(items.get(index));
for (int i = index + 1; i < items.size(); i++) {
Item nextItem = items.get(i);
int newWeight = result.getWeightOfLastBranch() + nextItem.getWeight();
if (newWeight > capacity) {
Branch newBranch = result.createCapableBranchFromLast(nextItem, capacity);
if (newBranch.isEmpty()) {
otherResults.add(postProcessResultAndReturn(result, otherResults));
return;
}
result.addNewBranch(newBranch);
} else {
result.addInLastBranch(nextItem);
}
}
otherResults.add(postProcessResultAndReturn(result, otherResults));
}
private ItemSelectionResult postProcessResultAndReturn(ItemSelectionResult result, List otherResults) {
Set branchesToRemove = new HashSet<>();
for (Branch branch : result.getBranches()) {
for (ItemSelectionResult otherResult : otherResults) {
if (otherResult.containsAsSubCombination(branch.getItemsCombination())) {
branchesToRemove.add(branch.getUuid());
break;
}
}
}
result.removeAllBranchesByUuids(branchesToRemove);
return result;
}
private record ItemsCostInfo(
List- items,
int cost
) {
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
ItemsCostInfo that = (ItemsCostInfo) o;
return cost == that.cost && Objects.equals(items, that.items);
}
@Override
public int hashCode() {
return Objects.hash(items, cost);
}
}
private static class ItemSelectionResult {
private final List
branches;
public ItemSelectionResult(Item rootItem) {
this.branches = new ArrayList<>();
this.branches.add(new Branch());
this.getLastBranch().addInBranch(rootItem);
}
public boolean isEmpty() {
return branches.isEmpty();
}
public Branch getLastBranch() {
return branches.getLast();
}
public List getBranches() {
return branches;
}
public boolean containsAsSubCombination(Set- items) {
for (Branch branch : branches) {
if (branch.combinationContainsAll(items)) {
return true;
}
}
return false;
}
public void removeAllBranchesByUuids(Set
toRemove) {
branches.removeIf(b -> toRemove.contains(b.getUuid()));
}
public ItemsCostInfo getBranchWithMaxCost() {
int maxCost = -1;
List- branchElementsWithMaxCost = new ArrayList<>();
for (Branch branch : branches) {
List
- branchElements = branch.getItemsInOrder();
int costOfBranch = getCostOfBranchElements(branchElements);
if (maxCost < costOfBranch) {
maxCost = costOfBranch;
branchElementsWithMaxCost = branchElements;
}
}
return new ItemsCostInfo(branchElementsWithMaxCost, maxCost);
}
public Branch createCapableBranchFromLast(Item newItem, int capacity) {
int newWeight = newItem.getWeight();
List
- lastBranchElementsCopy = new ArrayList<>(getLastBranch().getItemsInOrder());
if (getWeightOfLastBranch() + newWeight <= capacity) {
lastBranchElementsCopy.add(newItem);
return new Branch(lastBranchElementsCopy);
}
while (!lastBranchElementsCopy.isEmpty() && getWeightOfBranchElements(lastBranchElementsCopy) + newWeight > capacity) {
lastBranchElementsCopy.removeLast();
if (lastBranchElementsCopy.isEmpty()) {
return new Branch(lastBranchElementsCopy);
}
}
lastBranchElementsCopy.add(newItem);
return new Branch(lastBranchElementsCopy);
}
public int getWeightOfLastBranch() {
return getWeightOfBranchElements(getLastBranch().getItemsInOrder());
}
public void addInLastBranch(Item item) {
getLastBranch().addInBranch(item);
}
public void addNewBranch(Branch branch) {
branches.add(branch);
}
private int getWeightOfBranchElements(List
- branch) {
return branch.stream().map(Item::getWeight).reduce(Integer::sum).orElse(0);
}
private int getCostOfBranchElements(List
- branch) {
return branch.stream().map(Item::getCost).reduce(Integer::sum).orElse(0);
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
ItemSelectionResult that = (ItemSelectionResult) o;
return Objects.equals(branches, that.branches);
}
@Override
public int hashCode() {
return Objects.hashCode(branches);
}
}
private static class Branch {
private final UUID uuid;
private final List
- itemsInOrder;
private final Set
- itemsCombination;
public Branch() {
this.uuid = UUID.randomUUID();
this.itemsInOrder = new ArrayList<>();
this.itemsCombination = new HashSet<>();
}
public Branch(List
- items) {
this.uuid = UUID.randomUUID();
this.itemsInOrder = items;
this.itemsCombination = new HashSet<>(items);
}
public UUID getUuid() {
return uuid;
}
public List
- getItemsInOrder() {
return itemsInOrder;
}
public Set
- getItemsCombination() {
return itemsCombination;
}
public boolean combinationContainsAll(Set
- otherItems) {
return itemsCombination.containsAll(otherItems);
}
public void addInBranch(Item item) {
this.itemsInOrder.add(item);
this.itemsCombination.add(item);
}
public boolean isEmpty() {
return this.itemsInOrder.isEmpty();
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Branch branch = (Branch) o;
return Objects.equals(uuid, branch.uuid);
}
@Override
public int hashCode() {
return Objects.hashCode(uuid);
}
}
}
Теперь комментирую.
Внутреннее состояние нашего рюкзака — itemsInBackpack (наиболее выгодное наполнение Item-ами, которое подбирается при каждом вызове fillBackpack) и capacity (вместимость рюкзака). Инициализация в конструкторе всего этого дела, думаю, в комментариях не нуждается.
Переходим к самому интересному — fillBackpack.
Наша задача, как и было оговорено, делится на два логических шага. Первое — выявить все возможные и как можно более длинные варианты наполнения. Второе — найти среди них то, что имеет наивысшую суммарную цену, и сложить это дело в itemsInBackpack.
Самым сложным является первый шаг. Как он осуществляется.
Принимаем массив элементов за M.
Исключаем из наших M элементы, вес которых больше вместимости, чтобы не спотыкаться о мусор.
Сортируем элементы M по весу в порядке возрастания.
Создаем массив R из результатов подбора наполнений, в котором каждый элемент будет соответствовать некоему item-у.
Берем i-тый элемент массива M. Наша задача — получить все варианты наполнения с ним, соотнося с другими элементами. Для этого:
Создаем такую сущность, как «ветка». Ветка и является неким возможным наполнением с i-тым элементом, которое должно укладываться в ограничение по весу.
Первая ветка должна просто содержать i-тый элемент.
Переходим на следующий элемент массива M, j. Получаем сумму по весу последней созданной ветки (на самой первой итерации это будет первая ветка с одиноким i-тым элементом) и j-того элемента. Сумма укладывается в ограничение? Ложим в конец последней созданной ветки j-тый элемент.
Сумма не укладывается в ограничение? Пытаемся сформировать новую ветку. Делается это за счет того, что из копии предыдущей ветки исключаются элементы (начиная с последнего добавленного) ровно до тех пор, пока сумма не начнет укладываться в ограничение. Когда она уложилась в ограничение — на базе копии ветки с удаленными «мешавшими» элементами создаем новую ветку, уже с добавленным в конец j-тым элементом.
Если получилось так, что в процессе подбора новой ветки мы удалили из копии все, включая, разумеется, i-тый элемент — то в принципе весь процесс подбора веток для i-того элемента можно сворачивать, т.к. с учетом изначальной сортировки M по весу дальше будет только хуже.
Повторяем процесс с 3-го пункта ровно до тех пор, пока не пройдем через все элементы массива M по j.
Запоминаем все ветки для i, как единый результат C.
C мы начинаем соотносить с предыдущими результатами и чистить мусор.
Берем ветку k из C.
Берем предыдущий результат P из массива R.
Если в P содержится хотя бы одна ветка, которая включает все элементы ветки k — то k для нас уже бессмысленна. Запоминаем ветку k, как подлежащую удалению, переходим к следующей ветке текущего результата. Если же P не содержит таких веток — идем к следующему P до тех пор, пока не пройдем весь массив R.
Ложим C в R.
Повторяем процесс до тех пор, пока не переберем все элементы в M по i.
Таким образом мы получаем те самые варианты наполнения. В принципе, можно было бы обойтись без шага 7 — он у меня появился чуть позже, и без него все тесты все равно проходили. Но опять-таки, при возросшей сложности формирования возможных наполнений мы снижаем сложность последующего поиска максимума, да и от мусора просто избавляемся, укладываясь в изначальную формулировку плана про все возможные и как можно более длинные варианты наполнения.
Всю описанную логику fillBackpack и проделывает до того, как приступит к выявлению максимума на полученных наполнениях. Комментировать сам код, думаю, особого смысла не имеет — он делает ровно то, что уже описано.
Теперь работа довольно скучная. Из каждого i-того результата с вариантами наполнений мы берем тот вариант, что имеет максимальную суммарную цену — и ищем максимум уже среди всех извлеченных таким образом вариантов. Эта часть
int maxCost = -1;
List- branchWithMaxCost = new ArrayList<>();
for (ItemSelectionResult selectionResult : selectionResults) {
if (selectionResult.isEmpty()) {
continue;
}
ItemsCostInfo itemsCostInfo = selectionResult.getBranchWithMaxCost();
int currentCost = itemsCostInfo.cost();
if (currentCost > maxCost) {
maxCost = currentCost;
branchWithMaxCost = itemsCostInfo.items();
}
}
и проделывает озвученную логику.
Довершаем картину тем, что branchWithMaxCost ложим в itemsInBackpack. На этом вроде как и все.
Заключение
Тест-кейсы, с которыми все отрабатывает, выглядят так.
Тест-кейсы
package com.smolka;
import com.smolka.backpack.Backpack;
import com.smolka.backpack.Item;
import com.smolka.backpack.impl.BackpackImpl;
import org.junit.Test;
import java.util.Set;
public class BackpackTest {
@Test
public void test() {
Set- items = Set.of(
new Item("Часы", 1, 4),
new Item("Пакет сока", 2, 3),
new Item("Банка помидоров", 7, 2),
new Item("Ржавый портсигар", 3, 1),
new Item("Планшет", 8, 6)
);
int check = 10;
Backpack backpack = new BackpackImpl(9);
backpack.fillBackpack(items);
assert backpack.getCostOfContent() == check;
assert backpack.getWeightOfContent() <= backpack.getCapacity();
}
@Test
public void test2() {
Set
- items = Set.of(
new Item("Консервы", 5, 3),
new Item("Гиря", 10, 5),
new Item("Банка гвоздей", 6, 4),
new Item("Кулон", 5, 2)
);
int check = 7;
Backpack backpack = new BackpackImpl(14);
backpack.fillBackpack(items);
assert backpack.getCostOfContent() == check;
assert backpack.getWeightOfContent() <= backpack.getCapacity();
}
@Test
public void test3() {
Set
- items = Set.of(
new Item("Коробок кубинских сигар", 15, 60),
new Item("Платиновое ожерелье", 30, 90),
new Item("Золотой слиток", 50, 100)
);
int check = 190;
Backpack backpack = new BackpackImpl(80);
backpack.fillBackpack(items);
assert backpack.getCostOfContent() == check;
assert backpack.getWeightOfContent() <= backpack.getCapacity();
}
@Test
public void test4() {
Set
- items = Set.of(
new Item("Брат", 10, 100),
new Item("Сестра", 20, 80),
new Item("Первая научная история войны 1812 года, 3 издание", 50, 1000)
);
int check = 1000;
Backpack backpack = new BackpackImpl(50);
backpack.fillBackpack(items);
assert backpack.getCostOfContent() == check;
assert backpack.getWeightOfContent() <= backpack.getCapacity();
}
@Test
public void test5() {
Set
- items = Set.of(
new Item("Статуэтка", 4, 1),
new Item("Столовые принадлежности", 5, 2),
new Item("Пачка сигарет", 1, 3)
);
int check = 3;
Backpack backpack = new BackpackImpl(4);
backpack.fillBackpack(items);
assert backpack.getCostOfContent() == check;
assert backpack.getWeightOfContent() <= backpack.getCapacity();
}
@Test
public void test6() {
Set
- items = Set.of(
new Item("Бокс 1", 4, 2),
new Item("Бокс 2", 4, 2),
new Item("Бокс 3", 4, 2)
);
int check = 4;
Backpack backpack = new BackpackImpl(8);
backpack.fillBackpack(items);
assert backpack.getCostOfContent() == check;
assert backpack.getWeightOfContent() <= backpack.getCapacity();
}
@Test
public void test7() {
Set
- items = Set.of(
new Item("Бокс 1", 4, 2),
new Item("Бокс 2", 4, 2),
new Item("Бокс 3", 4, 2)
);
int check = 0;
Backpack backpack = new BackpackImpl(3);
backpack.fillBackpack(items);
assert backpack.getCostOfContent() == check;
assert backpack.getWeightOfContent() <= backpack.getCapacity();
}
}
Среди них есть, например, test3. Если верить вики, то на этом кейсе не отрабатывает тот же жадный алгоритм, но мой вроде бы справляется.
К оценке сложности пока не приступал, но на поверхностный взгляд сложность не такая уж и высокая.
Если это действительно так — то тогда я пока отказываюсь верить, что алгоритм рабочий на 100%. Как-то все слишком просто. В любом случае буду рад, если кто-нибудь сведущий накинет каких-нибудь более интересных и хитрых кейсов.
Пока что как-то так. Делитесь впечатлениями, критикуйте и так далее)
Всем добра и всем дзякую!
Habrahabr.ru прочитано 3764 раза
