《编程之美》读书笔记(四):卖书折扣问题的贪心解法

简介: 《编程之美》读书笔记(四):卖书折扣问题的贪心解法        每次看完《编程之美》中的问题,想要亲自演算一下或深入思考的时候,都觉得时间过得很快,动辄一两个小时,如果再把代码敲一遍的话,需要的时间可能更长,真是搞不懂通过微软面试的那些家伙的脑袋到底什么构造,书的序言中提到他们每次面试45分钟,还要写出程序?!在我看来,如果是控制CPU曲线或是中国象棋问题或许还有可能,如果是买书折扣问题
《编程之美》读书笔记():卖书折扣问题的贪心解法
       每次看完《编程之美》中的问题,想要亲自演算一下或深入思考的时候,都觉得时间过得很快,动辄一两个小时,如果再把代码敲一遍的话,需要的时间可能更长,真是搞不懂通过微软面试的那些家伙的脑袋到底什么构造,书的序言中提到他们每次面试45分钟,还要写出程序?!在我看来,如果是控制CPU曲线或是中国象棋问题或许还有可能,如果是买书折扣问题,我觉得真的是不太容易,尤其是如果当面试者钻进本题的贪心解法而不是动态规划算法的思路之后,因为我写这篇文章前前后后大概用了5个小时 :-( 。不过我想只要是学习就不是浪费时间,今天上网看到微软的校园招聘网站又有更新,等我把这本书看完,就投简历过去试一试 :-) 。
1 问题描述及分析
       买书折扣问题的描述是,某出版社的《哈里波特》系列共有5卷,每本单卖都是8块钱,如果读者一次购买不同的k(k>=2)卷,就可以享受不同的折扣优惠,如下所示:

问题是如果给定一个订单,如何计算出最大的折扣数?
书中给出的动态规划解法这里就不再赘述了。不过,里面有两个问题需要单独关注一下:(1)如果订单描述为(X 1,X 2,X 3,X 4,X 5),其中X 1-X 5为所订数的数量,其所在位置为卷的编号,即第一卷X 1本,第二卷X 2本,…,第5卷X 5本;如果订单为:(X 3,X 2,X 4,X 5,X 1),则表示第一卷X 3本,第二卷X 2本,…,第五卷X 1本。我们可以很容易的看到,由于每本书的价格相同,所以折扣的多少仅仅在于如何选取而不在于究竟取那一卷书。因此,上面两个订单的最大折扣数是相同的,这也使得我们可以使用统一的方法(Y1,Y2,Y3,Y4,Y5),其中Y1≥Y1≥Y1≥Y1≥ Y5,来表示一个订单。作者将每本书的定价设为相同的是为了简化问题,因为如果每本书的定价也不同,则问题就会变得更加复杂,这时我们就不能仅仅考虑选几本书,还要考虑选哪几本。(2)书中说对于一次选择4至2本书的情况,(以3本书为例)只需考虑F(Y 1-1,Y 2-1,Y 3-1,Y 4,Y 5)的情况就可以了(注意,F为订单总价计算函数),并说“ 这样选择能够保证在选择当前折扣的情况下,剩下的书的种类最多, 它比其它组合都好”。我觉得这个结论并不显然,下一步可选的书的种类多就能证明这个子问题比别的子问题更好?这点我不敢苟同,须知该问题的解决是一个多步选择的过程,所以要得到这个结论就需要严格证明。如果这个条件不成立,那么书中给出的递归式也就不成立,即不能证明优化子结构性质成立。所以,对于如此重要的细节,书中应该给出严格证明而不是一句话带过。
刚开始拿到问题的时候就条件反射地想能不能用贪心算法,即每次都尽量按最大折扣来取书。书中给出一个反例:所给的订单是(2,2,2,1,1),按照贪心算法,我们的选择方式是5+3,其折扣为5*0.25+3*0.10=1.55;而如果采用4+4的模式,则折扣数为2*4*0.20=1.6。这显然违背了贪心规则。所以书中在解法二中采用了另外一种分析,作者计算了订单中书的数量在[1-10]区间内,各种不同的选取方法所能获得的最大折扣数:
本数
可能的分解本数
对应的折扣
对于2-5本,
直接按折扣
购买
2
3
4
5
5%
10%
20%
25%
6
=4+2
=3+3
=2+2+2
0.9
0.6
0.3
7
=5+2
=4+3
1.35
1.1
8
=5+3
=4+4
=3+3+2
=2+2+2+2
5*25%+3*10%=1.55
4*20%+4*20%=1.6
0.7
0.4
9
=5+4
=5+2+2
=4+3+2
=3+3+3
2.05
1.45
1.2
0.9
10
=5+5
=4+4+2
=4+3+3
=2+2+2+2+2
2.5
1.7
1.4
0.5
表1-2 折扣计算表
看上面给出的折扣计算表,我们可以简单分析一下。实际上这个反例产生的原因是3本书的折扣数量与4本书的折扣数量差距过大造成的。实际上只要我们将题目稍微改动一下,将三本书的折扣改成15%,得到表1-2所示的折扣,我们就会发现贪心算法奇迹般地生效了!(为清楚起见,我把修改前和修改后的折扣计算结果放到了一张表中)那么如果一开始作者给出的就是三本书15%的折扣的话,从表中就可以看出使用原始的贪心算法所得到的结果是对的,就可能会连带得出贪心算法有效的结论?! 我有点后怕!
很显然,如果可以应用贪心算法的话,贪心选择不应该局限于某一种折扣数量的设定。而如果《哈利波特》不是5卷而是M卷,折扣表扩大的话,就很可能会产生更多的反例情况。
本数
可能的分解本数
原始的折扣
新的折扣
对于 2-5 本,
直接按折扣
购买
2
3
4
5
5%
10%
20%
25%
5%
15%
20%
25%
6
=4+2
=3+3
=2+2+2
0.9
0.6
0.3
0.9
0.9
0.3
7
=5+2
=4+3
1.35
1.1
1.35
1.25
8
=5+3
=4+4
=3+3+2
=2+2+2+2
5*25%+3*10%=1.55
4*20%+4*20%=1.6
0.7
0.4
5*25%+3*15%=1.7
1.6
3*15%*2+2*10%=1.0
0.4
9
=5+4
=5+2+2
=4+3+2
=3+3+3
2.05
1.45
1.2
0.9
2.05
1.45
0.8+0.45+0.1=1.35
0.45*3=1.35
10
=5+5
=4+4+2
=4+3+3
=2+2+2+2+2
2.5
1.7
1.4
0.5
2.5
1.7
1.7
0.5
表1-3与贪心策略相符合的折扣表
于是作者想把多余10本的订单分成若干个小于10的订单组,并把每组的最大折扣相加,以得到全局最优解,关于这一点我将在下面进行说明。作者在最后讲述了修改贪心策略的方法,不过我不是太理解,而且其中还有一些错误,最重要的是作者最终也没能证明其正确性,我就不再深究了。
2 贪心算法是否适用的分析
贪心算法的适用有两个必要条件,即优化子结构和贪心选择性。第一个性质由于已经证明可以适用动态规划算法,所以优化子结构性质显然成立(假如书中的动态规划递归式成立的话)。现需要证明其贪心选择性,即如何“贪心”的进行选择。显然每次都查找最大的折扣数进行处理的贪心方法是行不通的,那么是贪心方法真的不行还是我们“贪”的不正确呢?我们下面就来分析。
贪心选择性的含义是,一个全局最优解可以通过局部最优选择来达到,换句话说,当考虑作何选择时,我们只考虑对当前问题最佳的选择而不考虑子问题的结果。贪心算法所做的当前选择可能要依赖于已经做出的所有选择,但不依赖于有待于做出的选择或子问题的解。所以,贪心策略通常是自顶向下地,一个一个地做出贪心选择,不断地将给定的问题归约为更小的问题。当然,在此之前我们必须证明在每一步所做的贪心选择最终能产生一个全局最优解,这也正是本题的关键所在。
书中解法二给出的分组的思想是可以借鉴的,但是显然犯了方向性的错误。因为贪心算法的关键在于“ 选择”,即从当前的状态来“贪心地”从多种子状态中选择一个“当前的”最优解进行下去,并由其贪心选择性而使最终的解刚好是最优解。既然书中最后采用的还是(经修改后的)贪心算法,就不应该把整体的问题分成若干组来执行。这是因为贪心算法的上一次选择与下一次选择之间是带有连续性的,并不能够将它们拆开处理;而如果要拆开处理之后再将结果相加,就还需要证明拆分是使得结果最优的拆分。所以我们的目标最终还是应该定为寻找如何“贪”!其实作者的意图是对的,但实际目标应该定为 限制本次选择对于此后选择的影响,或使本次选择的影响仅限于下一次选择,这也是表格1-1只需要计算到10的原因(两次选择最多选择10本书),但由于下一次选择的影响可能会影响下下次的选择,所以我们不能硬性将这些选择拆开然后再相加,而只能一次一次地完成选择。
2.1特殊情况
首先,让我们分析一下 图2-1左图所示的订单,很显然,蓝框所框住的15本书都应该按照5本书的折扣处理。我们之所以“敢”这么做的原因是这次的处理不会影响到下一次的“选择”。即如果蓝框的范围再向上扩大一层的话,一次选择之后就会使得最后两列书的数量为0,并导致下次选择时最多只有3本书的折扣可以选择,相当于间接地选择了“5+3”模式。而如果蓝框的范围如图2-2(左)所示的话,这两次处理我们还可以有“4+4”模式可供选择,而这才是最佳选择。所以,当我们的选择不会使列数减少的时候,我们可以放心大胆地选择利润最大的那选择法;同样在 图2-1右图(注意:左右两图并无关联,是两个不同的订单),由于第五列已经没有了,在蓝框范围内的书籍我们仍然应该按照左图的做法处理,只是这次我们的折扣是4本书的。
再细想一下,如果我们不选择折扣最大的(即书的数量最多的)那个选择,按照 书中给出的动态规划子问题的定义中的取法:如果取4本书是从前四列取,取3本书从前三列取,依次类推,我们权且称其为“最小取法”(与书中的“最小表示”相呼应),我们可以知道,这一次不选择折扣最大只是将折扣最大的选择“推后”了。因为贪心算法每一步的选择都应该是当前状况下的最优选择,即我们在每一步都应该尽可能多获得折扣,而不应该将折扣“推后”而导致我们本次的选择并非最佳选择。换句话说, 我们应该保证下一次选择的书的数量不应该比本次选择的数量多,即我们可以允许两次的选择是5+3或4+4,但绝不能允许3+5出现。作者给出的表1-1也印证了这一点,我们可以看到, 表中所有的拆分都是降序排列的,即下一次可选择的最大数量不会超过本次选择的书的数量
 

例如 图2-2左图中,如果我们本次选择(最底层)只选择了3本书,那么上一段定义的 最小取法,我们得到的子问题应该是F(Y 1-1,Y 2-1,Y 3-1,Y 4,Y 5),于是下一次我们仍然可以选择5本书,大于本次所选的三本书,这是不应该发生的。而如果我们选择5本书,下一次只有3本书可以选择,这显然是可以的,但并不是最优的;如果我们选择4+4呢?显然不违反我们刚才定下的规则,而且折扣最大。下面我们的问题就是如何对原始的贪心定义稍作修改,使其能够帮我们挑出最大折扣的选法。
在处理掉一些特殊情况之后,我们的订单变为 图2-2左图所示的情况。而且前面我们定义: 本次选择的书的数量不应该小于下一次可选择的书的最大数量(其中,书的取法按照书中动态规划方法给出的选法来进行)。也就是说,我们不应该把最大的折扣选择“推后”执行,而使得本次选择不是当前看起来“最优的”。其次,这种选取方式也使得本次选择的影响仅限于下一次(即图中红色框中的范围),而不会“扩散”到下一次选择之后的选择,例如下下次选择。同时,下下次选择也只受下次选择的影响,由此我们成功地阻止了本次选择对整体带来的影响。
2.2修改的贪心算法
对于 图2-2左图所示的情况,在满足上述条件的前提下,我们目前有5和4两种选择(当然,如果书的卷数为M>5的话,可能的选择也许会更多),我们如何在不引发贪心反例的情况下获得最大折扣呢?我的答案是 “查表”。此时,我们应该查阅表 1-2,找到其中折扣数最大的那个最为本次的选择。我们可以看到,因为5+3<4+4,所以我们本次应该选择4本书。我们还需要验证,下一次选择我们还需要选择4本书。此时状况如图2-2右图所示,我们的选择只有4+3,因为如果本次我们选择3本书,下一次我们还可以选择4本书,这与我们的规则不符。所以我们的贪心选择是正确的。其余的书籍我们可以按照上面提到的两种情况加以处理即可。

经过修改后,贪心算法的空间复杂度变为计算如1-2所示的表格的代价,而修改后的贪心算法的时间复杂度就很直观,应该为我们做出选择的次数(包括一次查表操作),也就是O(Y1)。
3总结
本问题的修改后的贪心算法的规则是:
1 本次选择的书的数量不应该小于下一次可选择的书的最大数量(其中,书的取法按照书中动态规划方法给出的选法来进行)
2 每一次选择之前都应该查表,选择其中使得近两次折扣数最大的那个作为本次选择(因为我们使得本次选择的影响被控制在两次选择的范围内)。
图2-1左图中蓝框内的情况最终也可以归结为后面所分析的状况中的一种。即因为下一次只有 5可以选择(少于五本将违反规则1),所以本次就选择5本书。此外,当书的卷数变为M(M>5)的时候,表1-2需要的计算的数量也会相应变大。但实际上表1-2可以简化,例如我们看8本书的情况, 3+3+2和2+2+2+2其实都不用考虑,因为按照图2-2左图所示,因为规则1的存在使我们不可能选择3本书。即便是换一个例子,如果我们本次选择3本书、下一次最多也只能选3本书的话,我们就回到了处理图2-2左图蓝框中的情况当中。最后,如果书的卷数为M,则我们的表格的行数只需要为2M即可, 因为我们一次选择的影响仅限于本次和下次,所涉及的书的数量不会超过2M个
4《编程之美》本问题勘误
[1] P33,本数为6的4+2的折扣应该是4*20%+2*5%=0.9而不是1.1
[2] P34,最后一段,第2-3行说原始的“ 贪心策略建议我们买 Y5 本卷五,Y4-Y5 本卷四,Y3-Y4 本卷三,Y2-Y3 本卷二和Y1-Y2 本卷一”,对于订单(2,2,2,1,1),其中前三卷每卷两本,后两卷每卷一本,贪心的规则应该是5+3,即第一次选择应该每卷拿一本才对。 而文中说第四卷拿 Y4-Y5=0 本?所以这段文字我认为不妥。而且对于这一段后面的内容我也不是太明白,我个人觉得有修改一下的必要。
[3] 同样是P34最后一段,最后三行。虽然我对这一段的意思不太了解,但是仍然可以看出一个明显的错误,那就是最后三行所举的例子提到“要买3本第一卷, 2 本第二卷 ….”,而在最后却说“经调整后的贪心策略告诉我们应该买三本四卷, 三本两卷….”,前面说第二卷一共就两本,后面却说要买3本第二卷,真不知道这个贪心策略是怎么“告诉的”。我建议,反正由于书的价格导致订单上的书到底第几卷不重要,就不如干脆不用第几卷,改用第几列更为合适。
相关文章
|
4月前
|
C++ 存储 Serverless
力扣C++|一题多解之数学题专场(2)
力扣C++|一题多解之数学题专场(2)
28 0
力扣C++|一题多解之数学题专场(2)
|
4月前
|
C++ 存储
力扣C++|一题多解之数学题专场(1)
力扣C++|一题多解之数学题专场(1)
23 0
力扣C++|一题多解之数学题专场(1)
|
9月前
|
算法
第五天_双指针【算法入门】
第五天_双指针【算法入门】
33 0
|
9月前
|
算法
第四天_双指针【算法入门】
第四天_双指针【算法入门】
33 0
|
6月前
|
算法
代码随想录算法训练营第四十天 | LeetCode 343. 整数拆分、96. 不同的二叉搜索树
代码随想录算法训练营第四十天 | LeetCode 343. 整数拆分、96. 不同的二叉搜索树
44 1
|
6月前
|
算法
代码随想录算法训练营第二十九天 | 回溯算法总结
代码随想录算法训练营第二十九天 | 回溯算法总结
29 0
|
8月前
|
算法 Java 测试技术
LeetCode 周赛上分之旅 #46 经典二分答案与质因数分解
学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在这个专栏里,小彭与你分享每场 LeetCode 周赛的解题报告,一起体会上分之旅。
39 0
LeetCode 周赛上分之旅 #46 经典二分答案与质因数分解
|
9月前
|
算法
第三天_双指针【算法入门】
第三天_双指针【算法入门】
30 0
|
9月前
|
算法
[算法刷题题解笔记] 洛谷 P1007 独木桥 [贪心]
[算法刷题题解笔记] 洛谷 P1007 独木桥 [贪心]
|
10月前
|
算法 Android开发
LeetCode 周赛上分之旅 #34 按部就班地解决动态规划问题
学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在这个专栏里,小彭与你分享每场 LeetCode 周赛的解题报告,一起体会上分之旅。
53 0