Solve Multiple Choice Knapsack (MCKP) With Dynamic Programming?

Viewed 2144

Example Data

For this question, let's assume the following items:

  • Items: Apple, Banana, Carrot, Steak, Onion
  • Values: 2, 2, 4, 5, 3
  • Weights: 3, 1, 3, 4, 2
  • Max Weight: 7

Objective:

The MCKP is a type of Knapsack Problem with the additional constraint that "[T]he items are subdivided into k classes... and exactly one item must be taken from each class"

I have written the code to solve the 0/1 KS problem with dynamic programming using recursive calls and memoization. My question is whether it is possible to add this constraint to my current solution? Say my classes are Fruit, Vegetables, Meat (from the example), I would need to include 1 of each type. The classes could just as well be type 1, 2, 3.

Also, I think this can be solved with linear programming and a solver, but if possible, I'd like to understand the answer here.

Current Code:

<?php
$value = array(2, 2, 4, 5, 3);
$weight = array(3, 1, 3, 4, 2);

$maxWeight = 7;
$maxItems = 5;

$seen = array(array()); //2D array for memoization
$picked = array();

//Put a dummy zero at the front to make things easier later.
array_unshift($value, 0);
array_unshift($weight, 0);

//Call our Knapsack Solver and return the sum value of optimal set
$KSResult = KSTest($maxItems, $maxWeight, $value, $weight);
$maxValue = $KSResult; //copy the result so we can recreate the table

//Recreate the decision table from our memo array to determine what items were picked
//Here I am building the table backwards because I know the optimal value will be at the end
for($i=$maxItems; $i > 0; $i--) {
        for($j=$maxWeight; $j > 0; $j--) {
            if($seen[$i][$j] != $seen[$i-1][$j]
                && $maxValue == $seen[$i][$j]) {
                array_push($picked, $i);
                $maxValue -= $value[$i];
                break;
            }
        }
}

//Print out picked items and max value
print("<pre>".print_r($picked,true)."</pre>");
echo $KSResult;


//  Recursive formula to solve the KS Problem
//  $n = number of items to check
//  $c = total capacity of bag
function KSTest($n, $c, &$value, &$weight) {
    global $seen;

    if(isset($seen[$n][$c])) {
        //We've seen this subproblem before
        return $seen[$n][$c];
    }
    if($n === 0 || $c === 0){
        //No more items to check or no more capacity
        $result = 0;
    }
    elseif($weight[$n] > $c) {
        //This item is too heavy, check next item without this one
        $result = KSTest($n-1, $c, $value, $weight);
    }
    else {
        //Take the higher result of keeping or not keeping the item
        $tempVal1 = KSTest($n-1, $c, $value, $weight);
        $tempVal2 = $value[$n] + KSTest($n-1, $c-$weight[$n], $value, $weight);

        if($tempVal2 >= $tempVal1) {
            $result = $tempVal2;
            //some conditions could go here? otherwise use max()
        }
        else {
            $result = $tempVal1;
        }
    }
    //memo the results and return
    $seen[$n][$c] = $result;
    return $result;
}
?>

What I've Tried:

  1. My first thought was to add a class (k) array, sort the items via class (k), and when we choose to select an item that is the same as the next item, check if it's better to keep the current item or the item without the next item. Seemed promising, but fell apart after a couple of items being checked. Something like this: $tempVal3 = $value[$n] + KSTest($n-2, $c-$weight[$n]); max( $tempVal2, $tempVal3);
  2. Another thought is that at the function call, I could call a loop for each class type and solve the KS with only 1 item at a time of that type + the rest of the values. This will definitely be making some assumptions thought because the results of set 1 might still be assuming multiples of set 2, for example.

This looks to be the equation (If you are good at reading all those symbols?) :) and a C++ implementation? but I can't really see where the class constraint is happening?

3 Answers

The c++ implementation looks ok.

Your values and weights which are 1 dimensional array in your current PHP implementation will become 2 dimensional.

So for example,

values[i][j] will be value of j th item in class i. Similarly in case of weights[i][j]. You will be taking only one item for each class i and move forward while maximizing the condition.

The c++ implementation also does an optimization in memo. It only keeps 2 arrays of size respecting the max_weight condition, which are current and previous states. This is because you only need these 2 states at a time to compute present state.

Answers to your doubts:

1)

My first thought was to add a class (k) array, sort the items via class (k), and when we choose to select an item that is the same as the next item, check if it's better to keep the current item or the item without the next item. Seemed promising, but fell apart after a couple of items being checked. Something like this: $tempVal3 = $value[$n] + KSTest($n-2, $c-$weight[$n]); max( $tempVal2, $tempVal3);

This won't work because there could be some item in class k+1 where you take a optimal value and to respect constraint you need to take a suboptimal value for class k. So sorting and picking the best won't work when the constraint is hit. If the constraint is not hit you can always pick the best value with best weight.

2)

Another thought is that at the function call, I could call a loop for each class type and solve the KS with only 1 item at a time of that type + the rest of the values.

Yes you are on the right track here. You will assume that you had already solved for first k classes. Now you will try extending using the values of k+1 class respecting the weight constraint.

3)

... but I can't really see where the class constraint is happening?

for (int i = 1; i < weight.size(); ++i) {
    fill(current.begin(), current.end(), -1);
    for (int j = 0; j < weight[i].size(); ++j) {
        for (int k = weight[i][j]; k <= max_weight; ++k) {
            if (last[k - weight[i][j]] > 0)
                current[k] = max(current[k],
                                 last[k - weight[i][j]] + value[i][j]);
        }
    }
    swap(current, last);
}

In the above c++ snippet, the first loop iterates on class, the second loop iterates on values of class and the third loop extends the current state current using the previous state last and only 1 item j with class i at a time. Since you are only using previous state last and 1 item of the current class to extend and maximize, you are following the constraint.

Time complexity:

O( total_items x max_weight) which is equivalent to O( class x max_number_of_items_in_a_class x max_weight)

So I am not a php programmer but I will try to write a pseudocode with good explanation.

In the original problem each cell i, j meaning was: "Value of filling the knapsack with items 1 to i until it reach capacity j", the solution in the link you have provided defines each cell as "Value of filling the knapsack with items from buckets 1 to i until it reach capacity j". Notice that in this variation there is not such this as not taking an element from a class.

So on each step (each call for KSTest with $n, $c), we need to find which element to pick from the n'th class such that the weight of this element is less than c and it's value + KSTest(n - 1, c - w) is the greatest.

So I think you should only change the else if and else statements to something like:

else {
    $result = 0
    for($i=0; $i < $number_of_items_in_nth_class; $i++) {
        if ($weight[$n][$i] > $c) {
            //This item is too heavy, check next item
            continue;
        }
        $result = max($result, KSTest($n-1, $c - $weight[$n][$i], $value, $weight));
    }
}

Now two disclaimers:

  1. I do not code in php so this code will not run :)

  2. This is not the implementation given in the link you provided, TBH I didn't understood why the time complexity of their algorithm is so small (and what is C) but this implementation should work since it is following the definition of the recursive formula given.

The time complexity of this should be O(max_weight * number_of_classes * size_of_largerst_class).

This is my PHP solution. I've tried to comment the code in a way that it's easy to follow.

Update: I updated the code because the old script was giving unreliable results. This is cleaner and has been thoroughly tested. Key takeaways are that I use two memo arrays, one at the group level to speed up execution and one at the item level to reconstruct the results. I found any attempts to track which items are being chosen as you go are unreliable and much less efficient. Also, isset() instead of if($var) is essential for checking the memo array because the previous results might have been 0 ;)

<?php
/**
* Multiple Choice Knapsack Solver
*
* @author Michael Cruz
* @version 1.0 - 03/27/2020
**/
class KS_Solve {
    public $KS_Items;
    public $maxValue;
    public $maxWeight;
    public $maxItems;
    public $finalValue;
    public $finalWeight;
    public $finalItems;
    public $finalGroups;
    public $memo1 = array(); //Group memo
    public $memo2 = array(); //Item memo for results rebuild

    public function __construct() {
        //some default variables as an example.

        //KS_Items = array(Value, Weight, Group, Item #)
        $this->KS_Items = array(
            array(2, 3, 1, 1),
            array(2, 1, 1, 2),
            array(4, 3, 2, 3),
            array(5, 4, 2, 4),
            array(3, 2, 3, 5)
        );

        $this->maxWeight = 7;
        $this->maxItems = 5;
        $this->KS_Wrapper();
    }

    public function KS_Wrapper() {
        $start_time = microtime(true); 

        //Put a dummy zero at the front to make things easier later.
        array_unshift($this->KS_Items, array(0, 0, 0, 0));

        //Call our Knapsack Solver
        $this->maxValue = $this->KS_Solver($this->maxItems, $this->maxWeight);

        //Recreate the decision table from our memo array to determine what items were picked
        //ksort($this->memo2); //for debug
        for($i=$this->maxItems; $i > 0; $i--) {
            //ksort($this->memo2[$i]); //for debug
            for($j=$this->maxWeight; $j > 0; $j--) {
                if($this->maxValue == 0) {
                    break 2;
                }
                if($this->memo2[$i][$j] == $this->maxValue
                    && $j == $this->maxWeight) {
                    $this->maxValue -= $this->KS_Items[$i][0];
                    $this->maxWeight -= $this->KS_Items[$i][1];
                    $this->finalValue += $this->KS_Items[$i][0];
                    $this->finalWeight += $this->KS_Items[$i][1];
                    $this->finalItems .= " " . $this->KS_Items[$i][3];
                    $this->finalGroups .= " " . $this->KS_Items[$i][2];
                    break;
                }
            }
        }

        //Print out the picked items and value. (IMPLEMENT Proper View or Return!)
        echo "<pre>";
        echo "RESULTS: <br>";
        echo "Value: " . $this->finalValue . "<br>";
        echo "Weight: " . $this->finalWeight . "<br>";
        echo "Item's in KS:" . $this->finalItems . "<br>";
        echo "Selected Groups:" . $this->finalGroups . "<br><br>";
        $end_time = microtime(true); 
        $execution_time = ($end_time - $start_time); 
        echo "Results took " . sprintf('%f', $execution_time) . " seconds to execute<br>";

    }

    /**
    *  Recursive function to solve the MCKS Problem
    *  $n = number of items to check
    *  $c = total capacity of KS   
    **/
    public function KS_Solver($n, $c) {
        $group = $this->KS_Items[$n][2];
        $groupItems = array();
        $count = 0;
        $result = 0;
        $bestVal = 0;

        if(isset($this->memo1[$group][$c])) {
            $result = $this->memo1[$group][$c];
        }
        else {
            //Sort out the items for this group
            foreach($this->KS_Items as $item) {
                if($item[2] == $group) {
                    $groupItems[] = $item;
                    $count++;
                }
            }
            //$k adjusts the index for item memoization
            $k = $count - 1;

            //Find the results of each item + items of other groups
            foreach($groupItems as $item) {
                if($item[1] > $c) {
                    //too heavy
                    $result = 0;
                }
                elseif($item[1] >= $c && $group != 1) {
                    //too heavy for next group
                    $result = 0;
                }
                elseif($group == 1) {
                    //Just take the highest value
                    $result = $item[0];
                }
                else {
                    //check this item with following groups
                    $result = $item[0] + $this->KS_Solver($n - $count, $c - $item[1]);
                }

                if($result == $item[0] && $group != 1) {
                    //No solution with the following sets, so don't use this item.
                    $result = 0;
                }

                if($result > $bestVal) {
                    //Best item so far
                    $bestVal = $result;
                }
                //memo the results
                $this->memo2[$n-$k][$c] = $result;
                $k--;
            }
            $result = $bestVal;
        }

        //memo and return
        $this->memo1[$group][$c] = $result;
        return $result;
    }
}
new KS_Solve();
?>
Related