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

3341b8d2abf6b623ddb0ba05544cd630.png

Введение

Продолжаю свой крестовый поход по 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.

Самым сложным является первый шаг. Как он осуществляется.

  1. Принимаем массив элементов за M.

  2. Исключаем из наших M элементы, вес которых больше вместимости, чтобы не спотыкаться о мусор.

  3. Сортируем элементы M по весу в порядке возрастания.

  4. Создаем массив R из результатов подбора наполнений, в котором каждый элемент будет соответствовать некоему item-у.

  5. Берем i-тый элемент массива M. Наша задача — получить все варианты наполнения с ним, соотнося с другими элементами. Для этого:

    1. Создаем такую сущность, как «ветка». Ветка и является неким возможным наполнением с i-тым элементом, которое должно укладываться в ограничение по весу.

    2. Первая ветка должна просто содержать i-тый элемент.

    3. Переходим на следующий элемент массива M, j. Получаем сумму по весу последней созданной ветки (на самой первой итерации это будет первая ветка с одиноким i-тым элементом) и j-того элемента. Сумма укладывается в ограничение? Ложим в конец последней созданной ветки j-тый элемент.

    4. Сумма не укладывается в ограничение? Пытаемся сформировать новую ветку. Делается это за счет того, что из копии предыдущей ветки исключаются элементы (начиная с последнего добавленного) ровно до тех пор, пока сумма не начнет укладываться в ограничение. Когда она уложилась в ограничение — на базе копии ветки с удаленными «мешавшими» элементами создаем новую ветку, уже с добавленным в конец j-тым элементом.

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

    6. Повторяем процесс с 3-го пункта ровно до тех пор, пока не пройдем через все элементы массива M по j.

  6. Запоминаем все ветки для i, как единый результат C.

  7. C мы начинаем соотносить с предыдущими результатами и чистить мусор.

    1. Берем ветку k из C.

    2. Берем предыдущий результат P из массива R.

    3. Если в P содержится хотя бы одна ветка, которая включает все элементы ветки k — то k для нас уже бессмысленна. Запоминаем ветку k, как подлежащую удалению, переходим к следующей ветке текущего результата. Если же P не содержит таких веток — идем к следующему P до тех пор, пока не пройдем весь массив R.

  8. Ложим C в R.

  9. Повторяем процесс до тех пор, пока не переберем все элементы в 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 раза