用于组合/背包的动态 T-SQL 方法

Posted

技术标签:

【中文标题】用于组合/背包的动态 T-SQL 方法【英文标题】:Dynamic T-SQL approach for combinatorics/knapsack 【发布时间】:2015-01-28 21:09:25 【问题描述】:

我想我的问题与背包问题的变体有关,但我真的想不出一个解决方案:

假设您在一家五金店,需要购买 21 个螺丝。 他们只提供袋装:

袋子 X - 16 颗螺丝 - 每颗螺丝 1.56 美元 - 总计 25 美元 Y 袋 - 8 颗螺丝 - 每颗螺丝 2.25 美元 - 总计 18 美元 Z 袋 - 4 颗螺丝 - 每颗螺丝 1.75 美元 - 总计 7 美元

现在您必须弄清楚应该购买哪些包,才能以尽可能低的价格获得 21 个螺丝(或更多!)。

所以我得到的是一个包含所有袋子的表格和一个用于定义所需数量的变量。因此,我需要的应该是一个包含袋名和所需金额的表格。

不幸的是 sqlfiddle 宕机了。但至少这里是示例数据:

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

非常感谢您的帮助!希望我们能解决这个问题,因为我需要为我们公司的 ERP 系统定制这个重要的功能。

提前谢谢你!

编辑: 我阅读了关于背包的整篇***文章,上面写着: 溢出近似算法 有可能生成一个近似算法,我们可以稍微超出允许的重量限制。您希望获得至少与给定边界 B 一样高的总值,但您可以超过重量限制...... 目前该近似算法的解决方案未知。

所以看来我最好使用贪心算法而不是发明***? ;)

【问题讨论】:

你确定 4 个螺丝要 5 美元(Z 袋)吗?这是每个螺丝的最佳价格(我希望一个袋子里的螺丝越多,每个螺丝的价格就越高)。所以......在你的情况下,似乎最好只使用贪心算法并只购买 Z 类型的袋子(你需要多少就买多少)。在您的情况下,您需要购买: cast ( ( 21.0 / 4.0 ) as int ) + 1 ,即 6 个袋子。我会和营销/定价部门谈谈,目前没有人愿意购买 X 或 Y 包。 你是对的,通常你会期望包里的螺丝越多,每个螺丝的价格就越低。但我不能真的依赖这个。如果您为我的示例计算单价,您会发现 Bag Y 的单价高于小包。不是您在五金店所期望的,但在我的情况下是可能的。 我已经编辑了示例数据,以使问题更加清晰。如果我是正确的,现在以最低价格获得 21 个螺钉(或更多)的最佳解决方案应该是:1x Bag X w。 16 个螺丝 25 美元 + 2x Bag Z w。 4 个螺丝 14 美元 总计:39 美元 如果袋子类型的数量是固定的并且已知的,可能会有一个快速的解决方案。 @CrimsonKing 不幸的是,它们不是固定的。取决于我们目前有哪些包袋:( 【参考方案1】:

这是一个可能的解决方案。我会看看我明天能不能完成它,因为现在已经快凌晨 3 点了。主要逻辑在那里。剩下要做的就是使用prev_w 值进行追溯。只需向后跳(从best_price 行开始)直到到达w=0 行。当前行和上一行的ws 之间的差异为您提供了每一步必须购买的包的尺寸。

在您的示例中,解决方案显然是: “w=24, w=8, w=4, w=0”,意思是“买包:16、4、4”。 这 3 个袋子售价 39 美元。

此解决方案假定此人不打算购买 超过 1000 个螺丝(这就是 @limit 的用途)。

脚本草稿:

-- 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;

脚本最终版本:

-- 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;

【讨论】:

这实际上看起来非常准确!现在我只需要从最优惠价格到我必须购买的包的链接才能获得该价格。希望你能帮助我。到目前为止谢谢,我也将自己尝试。 @naera 是的,应该是准确的。它是背包动态规划算法的直接实现。是的,现在剩下要编码的东西是微不足道的。我可以轻松完成脚本实现,但我还有一些工作要做。 @naera 好的,我已在答案中添加了脚本的最终版本。您可能希望使用各种输入数据对其进行更彻底的测试。 有点低效,因为它总是假设购买的是 1000 颗螺丝,运行算法然后从那里过滤。但它有效! @KubaWyrostek 确实有一个错误。感谢您找到它。我插入了一些启发式方法,现在已修复。此外,如果出现平局,算法现在将生成答案/解决方案,以相同的价格为您提供更多螺丝。因此,在您的示例中,我会告诉您购买 16+4+4(​​与 16+7 解决方案相比,它具有相同的价格但给您多 1 个螺丝)。【参考方案2】:

我决定提出一种稍微不同的方法。这个是基于集合的,一般的想法是找到符合所需条件的所有可能的包数量组合,然后选择最便宜的一个。

步骤:

给定@ReqQty,对于每一种Bag,找出多少个这样的Bag 包含在表达式中是有意义的(也就是说,如果一个bag 包含5 个,我们要购买12 个,考虑1 个是有意义的, 2 或 3 袋,但 4 袋显然太多了) 找到所有袋子及其数量的所有可能组合(即对于袋子种类A,数量为1、2和3,袋子种类B,数量为1和2,可以尝试:A * 1 + B * 1A * 2 + B * 1, A * 3 + B * 1, A * 1 + B * 2, A * 2 + B * 2, A * 3 + B * 2) 计算所有组合(这实际上是即时完成的),即求总数​​量和总价格 获取高于或等于所需数量的最低价格的行

这是提供示例数据 OP 的完整解决方案:

(解决方案已修改,下面有新版本!)

-- 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

对于给定的样本数据,结果如下:

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

解决方案肯定不完美:

我不喜欢用charindex来消除同类型的包包 应消除所有重复的组合 我不确定效率

但我只是缺乏时间或技能来提出更聪明的想法。我认为很好的是,它是纯粹基于集合的声明式解决方案。

编辑

我对解决方案进行了一些修改,以摆脱charindex(从而摆脱了对基于文本的包标识符的依赖)。不幸的是,我不得不为每种袋子添加 0 数量,这使得组合更多,但它似乎对性能没有明显影响。同样以相同的价格显示与更多作品的组合。 :-)

-- 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

【讨论】:

我不是要批评,但如果你有更多类型的包(比如 10 或 12 或 15),这将很难做到(组合会太多)。 嗯……实际上,当我在其他答案中尝试这种与算法方法相比时,这种基于集合的解决方案的性能几乎提高了 10 倍(~20 ms vs ~200ms)。 (如果我将@limit 设置为 30,则差异会下降到大约 2 倍,但我不确定是否可以 :-) 但你是对的,我想知道它对于 >10 个袋子的扩展性有多好。另外我认为差异可以忽略不计,OP应该采用更适合他的解决方案,所以完全没有问题。 :-) 是的,你完全正确。如果我增加袋子的数量,则需要永远计算。所以这个解决方案对于日常使用毫无价值。 :-( 好吧,没关系。如果它在逻辑上是正确的,它仍然是一个解决方案,并且具有一定的价值。我猜它在大 O 方面效率不高,所以对于实际目标来说它不是那么有用。 我还做了一些修改以排除大多数不必要的组合。然而,只有在所需物品和最小袋子数量之间存在合理比例时,它仍然可以接受(我的意思是,如果你想购买 100 件物品并且袋子里有 1 件物品,它就会失败)。猜猜我会把它留作“教育目的”。 ;-) 我希望有一天能找到时间提出一个更好的基于集合的解决方案。 ;-)

以上是关于用于组合/背包的动态 T-SQL 方法的主要内容,如果未能解决你的问题,请参考以下文章

算法-背包问题 VI(动态规划)

leetcode 377. 组合总和 Ⅳ----动态规划之双重for循环变式----求排列数

动态规划(0-1背包)--- 组合总和

背包问题-动态规划

当使用 sp_executesql 作为过滤器时,保护 t-sql 动态代码的最佳方法是啥

动态规划的背包问题《数字组合》