2

In a database, I have a price list of different packages each consisting of at least one of the following products: Photo, Floor Plan, EPC. Example (ID | Title | Price):

12 Photo    10.00   
13 EPC  20.00   
14 Floor Plan   20.00   
15 4 Photos 40.00   
16 4 Photos + EPC   55.00   
17 4 Photos + Floor Plan    55.00   
18 4 Photos + Floor Plan + EPC  75.00   
etc...

Now I can't get my head around how I can always determine the cheapest package combination. For example if I wanted 5 photos and a floor plan, the combination of items 17 + 12 would be cheaper (65.00) than combination 5x12 and 14 (70.00). I've translated the price list into the following array and passed it to a my algorithm attempt, but it fails... Can anyone nudge me in the right direction?

Array
(
    [12] => Array
       (
           [price] => 10.00
           [items] => Array
              (
                 [0] => 1 // photo
                 [1] => 0 // floor plan
                 [2] => 0 // epc
              )
       )
    [13] => Array
       (
           [price] => 20.00
           [items] => Array
              (
                 [0] => 0 // photo
                 [1] => 0 // floor plan
                 [2] => 1 // epc
              )
       )
    [14] => Array
       (
           [price] => 20.00
           [items] => Array
              (
                 [0] => 0 // photo
                 [1] => 1 // floor plan
                 [2] => 0 // epc
              )
       )
    [15] => Array
       (
           [price] => 40.00
           [items] => Array
              (
                 [0] => 4 // photo
                 [1] => 0 // floor plan
                 [2] => 0 // epc
              )
       )
    [16] => Array
       (
           [price] => 60.00
           [items] => Array
              (
                 [0] => 4 // photo
                 [1] => 0 // floor plan
                 [2] => 1 // epc
              )
       )
       etc...
)

The Finder:

class CombinationFinder {

    private $products = array();

    public function __construct($products) {
        $this->products = $products;
        // sort by overall amount of items
        uasort($this->products, function ($a, $b) {
            $sum_a = array_sum($a['items']);
            $sum_b = array_sum($b['items']);
            if ($sum_a == $sum_b) {
                return 0;
            }
            return $sum_a < $sum_b ? -1 : 1;
        });
    }

    private function intersect($combo, $purchased) {
        return array_map(function ($a, $b) {
            $result = $b-$a;
            return $result < 0 ? 0 : $result;
        }, $combo, $purchased);
    }

    private function possibility($purchased, $limit) {
        $price = 0;
        $combination = array();
        foreach($this->products as $pid => $combo) {
            // if adding this combo exceeds limit, try next combo
            if($price + $combo['price'] >= $limit) {
                continue;
            }
            // see if combo helps
            $test = $this->intersect($combo['items'], $purchased);
            if(array_sum($test) === array_sum($purchased)) {
                continue;
            }
            // add combo and deduct combo items
            $combination[] = $pid;
            $price += $combo['price'];
            $purchased = $test;
            // if purchased items are covered, break
            if(array_sum($purchased) === 0) {
                return array('price' => $price, 'combination' => $combination);
            }
        }
        return false;
    }

    public function getCheapest($photos, $floorplans, $epc) {
        $purchased = array((int)$photos, (int)$floorplans, (int)$epc);
        $limit = 9999;
        while($test = $this->possibility($purchased, $limit)) {
            $limit = $test['price'];
            $possibility = $test;
        }
        sort($possibility['combination'], SORT_NUMERIC);
        echo 'Cheapest Combination: '.implode(', ', $possibility['combination']);exit;
    }
}
Jonny
  • 2,223
  • 23
  • 30
  • Typical case for dynamic programming and comes down to trial and elimination. – Ja͢ck Nov 25 '14 at 04:09
  • Thanks, been reading on that. Sounds like it might be the theory I need. I just need to apply it somehow. – Jonny Nov 25 '14 at 11:30

2 Answers2

1

I have solved my problem using a slightly different approach than suggested above, using what I think is a variation of Dijkstra's algorithm. It takes the same array as shown in the question as input.

Thank you guys for guiding me through this maze!!

class CombinationFinder {

    private $products = array();
    private $paths = array();

    public function __construct($products) {
        $this->products = $products;
        // sort by price
        uasort($this->products, function ($a, $b) {
            if($a['price'] === $b['price']) {
                return 0;
            }
            return $a['price'] > $b['price'] ? 1 : -1;
        });
    }

    private function intersect($combo, $purchased) {
        return array_map(function ($a, $b) {
            $result = $b-$a;
            return $result < 0 ? 0 : $result;
        }, $combo, $purchased);
    }

    private function findPossibilities($purchased) {

        $possibilities = array();
        foreach($this->products as $pid => $combo) {
            // possible path?
            $remainder = $this->intersect($combo['items'], $purchased);
            if(array_sum($remainder) === array_sum($purchased)) {
                continue;
            }
            $possibility = new StdClass;
            $possibility->step = $pid;
            $possibility->cost = $combo['price'];
            $possibility->remainder = $remainder;
            $possibilities[] = $possibility;
        }
        return $possibilities;

    }

    private function determineCheapestPath() {
        $minval = null;
        $minkey = null;
        foreach($this->paths as $key => $path) {
            if(!is_null($minval) && $path->cost >= $minval) {
                continue;
            }
            $minval = $path->cost;
            $minkey = $key;
        }
        return $minkey;
    }

    private function walk() {

        // determine currently cheapest path
        $path = $this->determineCheapestPath();
        // find possibilties for next move
        if(array_sum($this->paths[$path]->remainder) === 0) {
            return $path;
        }
        $possibilties = $this->findPossibilities($this->paths[$path]->remainder);
        $move = array_shift($possibilties);
        // update currently cheapest path
        $this->paths[$path]->cost += $move->cost;
        $this->paths[$path]->steps[] = $move->step;
        $this->paths[$path]->remainder = $move->remainder;

        return $this->walk();
    }

    // CONCEPT: from an initial set of possible paths, keep exploring the currently cheapest
    // path until the destination is reached.
    public function getCheapest($photos, $floorplans, $epc, $sqfeet) {
        $purchased = array((int)$photos, (int)$floorplans, (int)$epc);

        if(array_sum($purchased) === 0) {
            return array();
        }

        // initial graph
        foreach($this->findPossibilities($purchased) as $possibility) {
            $path = new StdClass;
            $path->cost = $possibility->cost;
            $path->steps = array();
            $path->steps[] = $possibility->step;
            $path->remainder = $possibility->remainder;
            $this->paths[] = $path;
        }

        $cheapest = $this->paths[$this->walk()];
        return array_count_values($cheapest->steps);
    }
}
Jonny
  • 2,223
  • 23
  • 30
0

This is an NP-Complete problem. For example, you can encode subset-sum problems into it by including entries like N photos for N dollars for various N and then asking if you have to pay more than necessary.

Because it's NP-Complete, there are no known solutions that scale well. But if your problem is small you can just brute force the solution.

Basically, you want something like:

bestCombination = minBy(
    filter(
        allCombinations(list),
        meetsCriteriaFunction
    ),
    totalPriceOfListFunction
)

Of course, you'll have to implement those functions or use whatever their equivalents are in PHP. (Sorry, I don't know the syntax very well.)

Craig Gidney
  • 17,763
  • 5
  • 68
  • 136
  • Many thanks for the clues! With some 23 relevant packages, brute forcing doesn't seem like a very expensive deal. It just remains the problem how to get all possible combinations... – Jonny Nov 25 '14 at 11:13
  • @Jonathan There's existing questions about that. The magic words to search for are things like "combinations php powerset subsets". Or just http://stackoverflow.com/q/6092781/52239 – Craig Gidney Nov 25 '14 at 15:41