6

I guess my question has to do with a variant of the knapsack problem, but I can't really come up with a solution for this:

Let's say you are in a hardware store and need to buy 21 screws. They only offer them in bags:

  • Bag X - 16 Screws - 1.56$ per screw - 25$ Total
  • Bag Y - 8 Screws - 2.25$ per screw - 18$ Total
  • Bag Z - 4 Screws - 1.75$ per screw - 7$ Total

Now you have to figure out which Bags you should buy to get your 21 screws (or more!) for the lowest possible price.

So what I got is a table with all the bags and a variable to define the required amount. What I need as a result should be a table with the bagname and the required amount.

Unfortunately sqlfiddle is down.. But at least here's the example data:

declare @bags table (id int, qty int, price decimal(19,4))
insert into @bags values
 (10, 16, 25.00)
,(20, 8, 18.00)
,(30, 4, 7.00)

declare @ReqQty int = 21

I really appreciate your help! Hope we can get this solved, as I need to customize our companys ERP System with this important function.

Thank you in advance!

Edit: I read the whole wikipedia article about knapsack and there it says: Overfill approximation algorithm It may be possible to generate an approximation algorithm where we can slightly overflow the allowed weight limit. You would want to achieve at least as high a total value as the given bound B, but you're allowed to exceed the weight limit... Currently the solution is unknown for this approximation algorithm.

So it seems I better use a greedy algorithm instead of inventig the wheel? ;)

peter.petrov
  • 38,363
  • 16
  • 94
  • 159
  • 1
    Are you sure 4 screws cost $5 (bag Z)? This is the best price per screw (I would expect the more screws in a bag, the better the price per screw). So ... in your case, seems it's best to just use a greedy algorithm and buy only bags of type Z (as many as you need). In your case you need to buy: cast ( ( 21.0 / 4.0 ) as int ) + 1 which is 6 bags. I would talk to the marketing/pricing department, the way prices stand currently nobody will want to buy bags X or Y. – peter.petrov Jan 28 '15 at 21:24
  • You are right, usually you would expect the more screws are in the bag, the lower is the price per screw. But I can't really rely on this. If you calculate the price per unit for my example, you will see that Bag Y has a higher price per unit than the small bag. Not what you would expect in a hardware store, but possible in my case. –  Jan 28 '15 at 21:29
  • I have edited the example data, to make the problem more clear. The best solution now to get 21 screws (or more) for the lowest price should be this if I am correct: 1x Bag X w. 16 screws for 25$ + 2x Bag Z w. 4 screws for 14$ Total: 39$ –  Jan 28 '15 at 21:40
  • If the number of types of bags are fixed and known, there might be a quick solution. – CrimsonKing Jan 28 '15 at 21:44
  • @CrimsonKing Unfortunately they are not fixed. Depends on which bags we have currently in stock :( –  Jan 28 '15 at 21:46
  • The number of bags in stock could be stored in the @bags table and used relevantly, but what I am trying to ask is if the types are always X Y and Z or there could be many more types of bags. – CrimsonKing Jan 28 '15 at 21:50
  • There could be more or less availible bags to choose from. I just made the example up with 3 bags, but this could differ from day to day. If we are out of stock, we can only choose from 2 etc. –  Jan 28 '15 at 21:55
  • Would a solution using C# and CLR functions be acceptable? – Code Different Jan 28 '15 at 23:00

2 Answers2

2

Here is a possible solution. I will see if I can finish it tomorrow as it's almost 3 AM here now. The main logic is there. All that's left to be done is to trace back using the prev_w values. Just jump back (starting from the best_price row) till you reach the w=0 row. The differences between the ws of current and the previous row give you the size of the bag you have to buy at each step.

In your example, the solution route obviously is:
"w=24, w=8, w=4, w=0" which translates "to buy bags: 16, 4, 4.".
These 3 bags cost $39.

This solution assumes the person is not going to buy
more than 1000 screws (this is what @limit is there for).

Script draft:

-- use TEST;

declare @limit decimal(19,4);
set @limit = 1000;

create table #bags
(
    id int primary key,
    qty int,
    price decimal(19,4),
    unit_price decimal(19,4),
    w int, -- weight
    v decimal(19,4) -- value
);

insert into #bags(id, qty, price) 
values
 (10, 16, 25.00)
,(20, 8, 18.00)
,(30, 4, 7.00);

declare @ReqQty int;
set @ReqQty = 21;

update #bags set unit_price = price / ( 1.0 * qty );

update #bags set w = qty;
update #bags set v = -price;

select * From #bags;

create table #m(w int primary key, m int, prev_w int);
declare @w int;
set @w = 0;
while (@w<=@limit)
begin
    insert into #m(w) values (@w);
    set @w = @w + 1;
end;

update #m
set m = 0;

set @w = 1;

declare @x decimal(19,4);
declare @y decimal(19,4);

    update m1
    set
    m1.m = 0 
    from #m m1
    where
    m1.w = 0;

while (@w<=@limit)
begin

    select 
        @x = max(b.v + m2.m) 
    from
    #m m1 
    join #bags b on m1.w >= b.w and m1.w = @w
    join #m m2 on m2.w = m1.w-b.w;

    select @y = min(m22.w) from
    #m m11 
    join #bags bb on m11.w >= bb.w and m11.w = @w
    join #m m22 on m22.w = m11.w-bb.w
    where
    (bb.v + m22.m) = ( @x );



    update m1
    set
    m1.m = @x,
    m1.prev_w = @y
    from #m m1
    where
    m1.w = @w;

    set @w = @w + 1;
end;

select * from #m;

select 
-m1.m as best_price
from
#m m1
where
m1.w = (select min(m2.w) from #m m2 where m2.w >= @ReqQty and (m2.m is not null));

drop table #bags;
drop table #m;

Script final version:

-- use TEST;

declare @limit decimal(19,4);
set @limit = 1000;

declare @ReqQty int;
set @ReqQty = 21;

create table #bags
(
    id int primary key,
    qty int,
    price decimal(19,4),
    unit_price decimal(19,4),
    w int, -- weight
    v decimal(19,4), -- value
    reqAmount int,
    CONSTRAINT UNQ_qty UNIQUE(qty) 
);

insert into #bags(id, qty, price) 
values
 (10, 16, 25.00)
,(20, 7, 14.00)
,(30, 4, 7.00);


update #bags set unit_price = price / ( 1.0 * qty );

update #bags set w = qty;
update #bags set v = -price;

update #bags set reqAmount = 0;

-- Uncomment the next line when debugging!
-- select * From #bags;

create table #m(w int primary key, m int, prev_w int);
declare @w int;
set @w = 0;
while (@w<=@limit)
begin
    insert into #m(w) values (@w);
    set @w = @w + 1;
end;

update #m
set m = 0;

set @w = 1;

declare @x decimal(19,4);
declare @y decimal(19,4);

    update m1
    set
    m1.m = 0 
    from #m m1
    where
    m1.w = 0;

while (@w<=@limit)
begin

    select 
        @x = max(b.v + m2.m) 
    from
    #m m1 
    join #bags b on m1.w >= b.w and m1.w = @w
    join #m m2 on m2.w = m1.w-b.w;

    select @y = min(m22.w) from
    #m m11 
    join #bags bb on m11.w >= bb.w and m11.w = @w
    join #m m22 on m22.w = m11.w-bb.w
    where
    (bb.v + m22.m) = ( @x );

    update m1
    set
    m1.m = @x,
    m1.prev_w = @y
    from #m m1
    where
    m1.w = @w;

    set @w = @w + 1;
end;

-- Uncomment the next line when debugging!
-- select * from #m;

declare @z int;
set @z = -1;

select 
@x = -m1.m, 
@y = m1.w ,
@z = m1.prev_w
from
#m m1
where
m1.w =  

-- The next line contained a bug. It's fixed now. 
-- (select min(m2.w) from #m m2 where m2.w >= @ReqQty and (m2.m is not null)); 

(
    select top 1 best.w from 
    (
        select m1.m, max(m1.w) as w
        from 
        #m m1
        where
        m1.m is not null
        group by m1.m
    ) best where best.w >= @ReqQty and best.w < 2 * @ReqQty
    order by best.m desc
)



-- Uncomment the next line when debugging!
-- select * From #m m1 where m1.w = @y;

while (@y > 0)
begin
    update #bags
    set reqAmount = reqAmount + 1
    where
    qty = @y-@z;

    select 
    @x = -m1.m, 
    @y = m1.w ,
    @z = m1.prev_w
    from
    #m m1
    where
    m1.w = @z;

end;

select * from #bags;

select sum(price * reqAmount) as best_price
from #bags;

drop table #bags;
drop table #m;
peter.petrov
  • 38,363
  • 16
  • 94
  • 159
  • This actually seems to work pretty accurate! Now I just need the link from the best price back to bags I have to buy to get that price. Hopefully you can help me on that. Thanks so far, I will also try it on my own. –  Jan 29 '15 at 10:00
  • @naera Yeah, it should be accurate. It's a direct implementation of the knapsack dynamic programming algorithm. And yes, now what's left to be coded is trivial. I can finish the script implementation easily but I have some work to do first. – peter.petrov Jan 29 '15 at 10:59
  • @naera OK, I've added the final version of the script in my answer. You may want to test it more thoroughly with various input data. – peter.petrov Jan 29 '15 at 12:12
  • A bit inefficient in that it always assumes the purchase is for 1000 screws, run the algorithm and then filter from there. But it works! – Code Different Jan 29 '15 at 14:19
  • @ZoffDino It does not assume the purchase is always for 1000 screws, it assumes it's for no more than 1000 screws. And this limit can be changed by changing the value of the `@limit` variable/constant. – peter.petrov Jan 29 '15 at 14:52
  • @peter.petrov I think my comment wasn't clear enough. What I meant was that the code calculate the purchase quantity out to 1000 screws, then scale it back to the quantity specified. – Code Different Jan 29 '15 at 14:55
  • Your script works brillant! Thank you! I will flag this approach as the correct answer. –  Jan 29 '15 at 18:29
  • I used final version of the script, and - given the following Bags: `16 pieces for $25`, `7 pieces for $14` and `4 pieces for $7` and requirement of `21` this algorithm tells me to buy `3 * 7` pieces, that is `$42`, whereas `16 + 7` pieces is 23 (greater than 21) and costs only `$39`. The same for `16 + 4 + 4` (which is 24 and also costs $39). So the algorithm is unfortunately wrong. :-( (Or I am really bad at math :-) – Kuba Wyrostek Jan 29 '15 at 20:35
  • @KubaWyrostek Hm, it shouldn't do so. This looks like an interesting test. I will try to find time to check it later today. – peter.petrov Jan 30 '15 at 08:16
  • 1
    @KubaWyrostek There was a bug indeed. Thanks for finding it. I plugged in some heuristics and It's fixed now. Also, in case of a tie, the algorithm will now produce that answer/solution which gives you more screws for the same price. So in your example, I will tell you to buy 16+4+4 (as it has the same price but gives you 1 screw more, when compared to the 16+7 solution). – peter.petrov Jan 30 '15 at 09:38
2

I've decided to come up with a slightly different approach. This one is set based and the general idea is to find all possible combinations of amounts of bags that match the required condition, then select the cheapest one.

Steps:

  • given @ReqQty, for each kind of Bag find how many of such Bag does it make sense to include in expression (thats is if bag contains 5 pieces, and we want to purchase 12 pieces, it makes sense to consider 1, 2 or 3 bags, but 4 bags would be obviously too much)
  • find all possible combination of all Bags and their amounts (that is i.e. for Bag kind A with amounts 1, 2 and 3, and Bag kind B with amounts 1 and 2 one can try: A * 1 + B * 1, A * 2 + B * 1, A * 3 + B * 1, A * 1 + B * 2, A * 2 + B * 2, A * 3 + B * 2)
  • calculate all combinations (this actually done on the fly), that is find total quantity and total price
  • get row with lowest price that is higher or equal to required quantity

This is whole solution with sample data OP provided:

(The solution was modified, new version is available below!)

-- sample data

declare @ReqQty int = 21

declare @Bags table (Code nvarchar(1), Quantity int, Price decimal(10,2))
insert into @Bags
select 'X', 16, 25.00
union
select 'Y', 8, 18.00
union
select 'Z', 4, 7

; with 
-- helper table: all possible integer numbers <= @ReqQty
Nums (I) as
(
    select 1
    union all
    select I + 1
    from Nums
    where I < @ReqQty
),
-- possible amounts of each kind bag that make sense
-- i.e. with 3-piece bag and 5-piece requirement it 
-- is worth checking 1 (x3 = 3) or 2 (x2 = 6) bags, but
-- 3, 4... would be definitely too much
Vars (Code, Amount) as
(
    select B.Code, Nums.I
    from @Bags as B
    inner join Nums on B.Quantity * I - @ReqQty < B.Quantity
),
Sums (Expr, Amount, TotalQuantity, TotalPrice) as
(
    -- take each kind of bag with every amount as recursion root
    select
        convert(nvarchar(100), V.Code + '(' + convert(nvarchar(100), Amount) + ')'),
        Amount,
        B.Quantity * Amount,
        convert(decimal(10, 2), B.Price * Amount)
    from Vars as V
        inner join @Bags as B on V.Code = B.Code

    union all

    -- add different kind of bag to the summary
    -- 'Sums.Amount >= V.Amount' is to eliminate at least some duplicates
    select
        convert(nvarchar(100), Expr + ' + ' + V.Code + '(' + convert(nvarchar(100), V.Amount) + ')'),
        V.Amount,
        Sums.TotalQuantity + B.Quantity * V.Amount,
        convert(decimal(10, 2), Sums.TotalPrice + B.Price * V.Amount)
    from Vars as V
        inner join @Bags as B on V.Code = B.Code
            inner join Sums on (charindex(V.Code, Expr) = 0) and Sums.Amount >= V.Amount
)
-- now find lowest price that matches required quantity
-- remove 'top 1' to see all combinations
select top 1 Expr, TotalQuantity, TotalPrice from Sums
where TotalQuantity >= @ReqQty
order by TotalPrice asc

For a given sample data this is the result:

Expr         TotalQuantity  TotalPrice
Z(2) + X(1)  24             39.00

The solution is definitely not perfect:

  • I do not like using charindex to eliminate same types of bags
  • all duplicate combinations should be eliminated
  • I am not sure about efficiency

but I simply lacked time or skills to come up with more clever ideas. What I think is nice is that it's purely set-based, declarative solution.

EDIT

I've modified the solution a bit to get rid of charindex (and thus get rid of dependency of text based bag identifiers). Unfortunately I had to add 0 amount for each kind of bag which made even more combinations but it seems to have no noticeable impact on performance. Also for the same price the combination with more pieces is shown. :-)

-- sample data

declare @ReqQty int = 21

declare @Bags table (Code nvarchar(1), Quantity int, Price decimal(10,2))
insert into @Bags
select 'X', 16, 25.00
union
select 'Y', 8, 18.00
union
select 'Z', 4, 7.00

; with 
-- helper table to apply order to bag types
Bags (Code, Quantity, Price, BI) as
(
    select Code, Quantity, Price, ROW_NUMBER() over (order by Code)
    from @Bags
),
-- helper table: all possible integer numbers <= @ReqQty
Nums (I) as
(
    select 0
    union all
    select I + 1
    from Nums
    where I < @ReqQty
),
-- possible amounts of each kind bag that make sense
-- i.e. with 3-piece bag and 5-piece requirement it 
-- is worth checking 1 (x3 = 3) or 2 (x2 = 6) bags, but
-- 3, 4... would be definitely too much
Vars (Code, Amount) as
(
    select B.Code, Nums.I
    from Bags as B
    inner join Nums on B.Quantity * I - @ReqQty < B.Quantity
),
Sums (Expr, Amount, BI, TotalQuantity, TotalPrice) as
(
    -- take first kind of bag with every amount as recursion root
    select
        convert(nvarchar(100), V.Code + '(' + convert(nvarchar(100), Amount) + ')'),
        Amount, B.BI,
        B.Quantity * Amount,
        convert(decimal(10, 2), B.Price * Amount)
    from Vars as V
        inner join Bags as B on V.Code = B.Code
    where B.BI = 1

    union all

    -- add different kind of bag to the summary
    select
        convert(nvarchar(100), Expr + ' + ' + V.Code + '(' + convert(nvarchar(100), V.Amount) + ')'),
        V.Amount, B.BI,
        Sums.TotalQuantity + B.Quantity * V.Amount,
        convert(decimal(10, 2), Sums.TotalPrice + B.Price * V.Amount)
    from Vars as V
        inner join Bags as B on V.Code = B.Code
            -- take next bag kind according to order
            inner join Sums on B.BI = Sums.BI + 1
            and Sums.TotalQuantity + B.Quantity * V.Amount - @ReqQty < B.Quantity
)
-- now find lowest price that matches required quantity
-- remove 'top 1' to see all combinations
select top 1 Expr, TotalQuantity, TotalPrice from Sums
where TotalQuantity >= @ReqQty
order by TotalPrice asc, TotalQuantity desc, Expr asc
Kuba Wyrostek
  • 6,163
  • 1
  • 22
  • 40
  • I don't mean to criticize but if you have more types of bags (say 10 or 12 or 15), this will be difficult to do (the combinations will be too many). – peter.petrov Jan 29 '15 at 16:33
  • Hmm… actually when I tried this vs. algorithmic approach in other answer, this set-based solution performs almost 10 times better (~20 ms vs ~200ms). (The difference drops to about 2x if I set the @limit to 30 but I am yet not sure if I can :-) But you are right, I wonder how good does it scale for >10 bags. Also I think the difference is negligible and OP should go with the solution that suits him better, so no problem at all. :-) – Kuba Wyrostek Jan 29 '15 at 20:11
  • 1
    Yep, you were totally right. If I increase the number of bags it takes forever to calculate. So this solution is worthless for daily use. :-( – Kuba Wyrostek Jan 30 '15 at 10:15
  • Well, it doesn't matter. If it's logically correct, it's still a solution and has certain value. It's just not efficient in terms of big O, I guess, so for practical goals it's not so useful. – peter.petrov Jan 30 '15 at 10:19
  • I've yet modified a little bit to exclude most unnecessary combinations. Yet it still works acceptable only when there is reasonable ratio between required items and minimal bag amount (I mean it i.e. fails if you wish to purchase 100 items and there is a bag containing 1 item). Guess I'll just leave it for "educational purposes" then. ;-) I hope to find time someday to come up with a better set-based solution. ;-) – Kuba Wyrostek Jan 30 '15 at 10:47