ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

贪心算法与分类讨论:乘积最大问题的核心解法与边界处理

2026/8/27 9:50:10 拓冰建站 浏览量
贪心算法与分类讨论:乘积最大问题的核心解法与边界处理 1. 从一道经典题目看算法竞赛中的“贪心”与“分类讨论”最近在整理蓝桥杯的历年真题和集训题目又翻到了ALGO-677“乘积最大”这道题。这题在算法竞赛圈里尤其是蓝桥杯的练习体系中算得上是一道“常客”它本身并不涉及多么高深的数据结构代码量也不大但恰恰是这种题目最能考验一个选手的基本功——对问题本质的理解、缜密的逻辑思维以及最重要的分类讨论的能力。很多新手甚至一些有一定经验的选手都容易在这类题目上栽跟头要么考虑不全要么被几个特殊的测试用例卡住。今天我们就来彻底拆解这道“乘积最大”看看它背后到底藏着哪些“坑”以及如何构建一个稳健的解题思路。这道题的核心场景非常直观给定N个整数可能包含负数、零和正数从中选出K个数使得它们的乘积最大。听起来是不是很简单不就是挑最大的K个数乘起来吗如果你这么想那恭喜你已经掉进了第一个陷阱。因为负数的存在会让整个问题的性质发生根本性的变化。两个负数相乘得到正数三个负数相乘又得到负数。所以我们的策略不能是简单的“排序取最大”而必须根据K的奇偶性、数组中正数、负数和零的分布情况进行动态的、策略性的选择。这其实就是算法思想中“贪心”策略的一个典型应用但这里的“贪心”不是无脑贪而是有前提、有条件的“智慧贪心”。我们需要先对数据进行预处理排序然后根据不同的情况制定不同的“贪心规则”。这个过程像极了我们做项目或者解决实际问题时的决策没有放之四海而皆准的“银弹”必须具体问题具体分析把所有可能的情况都枚举清楚才能做出最优的选择。接下来我们就一步步构建这个决策框架。2. 问题建模与核心难点剖析在动手写代码之前我们必须先把问题抽象成清晰的数学模型并识别出所有可能影响结果的边界情况。这是避免后期调试时陷入“缝缝补补”混乱状态的关键。首先明确输入一个包含N个整数的数组arr和一个整数K1 K N。目标从arr中选出K个数使其乘积最大并输出这个最大乘积。核心难点在于负数的处理。正数的性质很简单越大越好。但负数则不然如果K是偶数我们可以成对地选取负数因为负负得正这样偶数个负数的乘积会变成一个很大的正数有可能比选取同样数量的正数乘积更大。如果K是奇数情况就复杂了如果数组中存在正数那么我们最终的结果希望是正数所以我们必须至少选一个正数作为“种子”。在选了这一个正数之后剩下的K-1偶数个数又可以按照偶数K的策略来选取。如果数组中一个正数都没有全是非正数那么无论怎么选乘积都不可能为正数。此时我们的目标就变成了“让负数的乘积尽可能大即绝对值尽可能小”或者直接处理零的情况。另一个容易被忽略的难点是零。零是一个“乘积终结者”任何数乘以零都变成零。所以只要K个数里包含了一个零乘积就必然是零。那么什么情况下我们会“主动”选择零呢只有在一种情况下当我们无法得到一个正数乘积且所有可能的非零乘积都是负数时零如果存在就是最大的结果因为0 任何负数。基于以上分析我们可以将解题策略分解为几个关键步骤但在此之前我们必须对数据进行排序因为排序后我们能最直观地看到数的分布。注意排序是整个算法的基石。通常我们按升序排序这样最小的数可能是很大的负数在左边最大的数正数在右边。排序后数组的两端最左和最右就成为了我们决策的主要依据。3. 决策框架的构建从排序到分情况讨论有了前面的分析我们现在可以构建一个清晰的决策树。假设数组arr已经按升序排序完成。我们用l和r作为指针分别指向数组的最左端最小数和最右端最大数。整个决策的核心是从数组的两端向中间取数因为最大的正数在右边而能产生最大正数乘积的负数对两个很小的负数在左边。情况一K N这是最简单的情况没得选所有数都必须取。乘积就是所有数的乘积。直接计算即可。情况二K N这是主要情况我们需要分步决策第一步判断结果的符号可能性这是最高层级的决策。我们根据K的奇偶性和数组中最大数的正负来判断。如果K是奇数如果arr[N-1]排序后最大的数 0说明整个数组都是非正数负数或零。那么无论怎么选乘积都不可能为正。此时我们的策略就变成了“在非正数里选乘积最大的”也就是从数组右边开始选K个数因为对于负数越靠右的负数绝对值越小乘积的绝对值也越小但因为是奇数个负数相乘结果负得“少一点”。如果arr[N-1] 0说明至少存在一个非负数。为了让乘积为正我们必须先取这个最大的非负数arr[N-1]。然后问题就转化为从剩下的N-1个数中选K-1偶数个数使乘积最大。此时K变成了偶数可以进入偶数K的处理流程。如果K是偶数这是最“舒服”的情况。我们可以自由地成对选取数。策略是比较当前最左端的两个数之积arr[l] * arr[l1]和当前最右端的两个数之积arr[r] * arr[r-1]哪个积大就选哪一对。为什么因为对于偶数K我们的目标始终是让乘积的绝对值最大。最左端两个负数之积可能是一个很大的正数最右端两个正数之积也是一个很大的正数我们取其中更大的那个。第二步双指针贪心选取在确定了初始策略后例如K为偶数或K为奇数但已取走一个最大正数变为偶数情况我们使用双指针l和r进行贪心选取。l初始指向最左端(0)r初始指向最右端(N-1)。循环每次需要选取2个数因为乘积比较是基于对的直到选满K个数。在每次循环中比较arr[l] * arr[l1]和arr[r] * arr[r-1]。如果左边的乘积更大说明这一对负数或一负一正但排序后左边通常负能提供更大的贡献值。我们选择arr[l]和arr[l1]然后l 2。否则选择arr[r]和arr[r-1]然后r - 2。这里有一个极其关键的细节为什么是成对比较因为我们要保证每次操作后已选数字的个数增加2并且我们始终在最大化“当前最优”的乘积贡献。单独比较arr[l]和arr[r]是无效的因为负数的存在使得单个比较没有意义一个很大的负数可能比一个很小的正数“小”但两个负数相乘就变“大”了。第三步处理零的边界情况上述贪心过程隐含了一个假设我们总是希望通过选取非零数来获得一个尽可能大的绝对值正或负。但如果数组中零很多或者K很大我们可能不得不选到零。在双指针移动过程中如果arr[l]或arr[r]为零需要特别小心。实际上在我们的排序下零会出现在正负数之间。如果零出现在可选取的范围内并且我们通过上述比较选中了包含零的一对例如arr[l]0, arr[l1]正数那么arr[l]*arr[l1]0这个乘积在比较中通常会输给一对正数或负数的乘积因此零不会被选中除非另一对的乘积也是0或更小。更直接的零处理发生在情况一的变种当K为奇数且最大数为0时按照我们的决策树我们会走“最大数0”的分支吗不因为最大数是0属于arr[N-1] 0的情况。我们会先取这个0然后问题变为偶数K。但此时剩下的数都是非正数负数或零偶数K的贪心策略会在这些非正数里选结果最多是0如果选到零或者一个正数如果成对选到负数。但这里有一个大坑如果我们先取了0那么最终乘积一定是0。然而有没有可能不取0而是取一个负数然后搭配成对的负数得到一个正数呢不可能因为K是奇数且最大数是0意味着没有正数。所以无论如何乘积都不可能为正数。此时0就是最大的可能结果。所以这个分支的处理是合理的。一种更稳妥的零检测是在完成所有选取后检查所选数字的乘积是否为0。如果是0并且数组中有0存在那么这个0就是结果。这可以作为一个兜底逻辑。为了更直观我们可以用一个表格来梳理K为奇数时的决策流程条件判断分支描述行动策略K是奇数进入奇数处理流程arr[N-1] 0数组全为非正数无正数目标使乘积最大即负得最少。从右向左选K个数取绝对值较小的K个非正数。arr[N-1] 0数组中存在非负数1.先选取arr[N-1]当前最大数。2.K K - 1变为偶数r N - 2右指针左移。3. 进入偶数K的贪心流程。4. 代码实现与逐行解析理论分析完毕接下来就是落地。这里我用Python来实现因为它语法简洁非常适合表达算法逻辑。我们会将上述决策框架一步步翻译成代码并加上详细的注释。def max_product(arr, K): 计算从数组arr中选取K个数所能得到的最大乘积。 :param arr: List[int], 整数数组 :param K: int, 需要选取的数字个数 :return: int, 最大乘积 N len(arr) arr.sort() # 升序排序基石操作 # 情况1: K N 全选 if K N: product 1 for num in arr: product * num return product # 初始化双指针和结果 l, r 0, N - 1 product 1 MOD 10**9 7 # 常见取模要求根据题目调整这里先保留逻辑 # 情况2: K N # 子情况2.1: K为奇数 if K % 2 1: # 取当前最大的数最右边的数 product * arr[r] r - 1 K - 1 # 注意如果arr[r]是负数说明整个数组都是非正数。 # 此时product已经是负数后续的偶数K选择应该使乘积的绝对值最小即让负数更靠近零。 # 但我们的双指针贪心逻辑比较两端乘积在全是非正数时依然适用吗 # 让我们思考如果全是非正数且K为奇数我们已经取了一个最大的非正数可能是0或负数。 # 剩下的K-1偶数个数我们要让乘积现在是负数尽可能大即绝对值尽可能小。 # 那么应该从哪边取应该从右边取绝对值小的那边。这恰好与我们的双指针逻辑中 # 比较 arr[l]*arr[l1] 和 arr[r]*arr[r-1] 相符吗 # 在全是非正数的情况下arr[l]*arr[l1]是两个很小的负数积是很大的正数。 # arr[r]*arr[r-1]是两个绝对值较小的负数积是较小的正数。 # 如果我们选积大的左边就会让总乘积的绝对值变大导致负数更小这不是我们想要的。 # 所以当已取第一个数为负时我们的贪心策略需要反转应该选取乘积更小的那一对 # 这是一个非常重要的细节很多标准题解会通过判断第一个数或最大数的符号来动态调整比较逻辑。 # 更通用的做法是在K为奇数且最大数为负时直接走从右向左取K个数的简单路径。 # 经过上述处理现在K一定是偶数 # 子情况2.2: K为偶数或已转化为偶数 while K 0: # 需要取2个数 left_product arr[l] * arr[l1] right_product arr[r] * arr[r-1] # 核心选择逻辑 # 这里需要结合当前总乘积的符号来考虑。 # 如果当前总乘积 product 是正数或零我们希望加入一对能使其更大的数对。 # 如果当前总乘积 product 是负数我们希望加入一对能使其绝对值变小的数对即让负数变大。 # 但这里有一个巧妙的简化因为我们是从0开始累积乘积且初始product1正数。 # 在K为奇数且最大数为正时我们取了一个正数product为正。 # 在K为奇数且最大数为负时我们不应该进入这个循环而是应该用另一种策略。 # 因此为了保证逻辑清晰我们修正一下将“K为奇数且最大数为负”的情况单独处理。 # 修正后的清晰逻辑看到上面的注释了吗我们在实现过程中发现了原逻辑的一个模糊点。这恰恰是算法实现中最关键的一步——将理论映射到代码时必须处理清楚所有的边界和符号问题。让我们重新整理写一个更健壮、更清晰的版本def max_product_robust(arr, K): N len(arr) arr.sort() MOD 10**9 7 # 蓝桥杯常见要求结果取模 # 情况1: 全选 if K N: ans 1 for num in arr: ans (ans * num) % MOD return ans # 情况2: K N l, r 0, N - 1 ans 1 # 处理K为奇数的情况 if K % 2 1: # 无论如何先取最大的数最右边的数 ans (ans * arr[r]) % MOD r - 1 K - 1 # 如果刚才取的数是非负数那么ans现在是非负的后续可以正常贪心找大的对。 # 如果刚才取的数是负数说明整个数组都是负数因为排序后最大数是负数。 # 此时ans为负我们需要让这个负数变大即绝对值变小。 # 那么后续应该从右边绝对值小的负数取数而不是比较两端乘积的绝对值。 # 但更简单的做法是如果arr[r1]即原最大数 0我们直接走从右向左取数的路径。 # 让我们在取数后判断 # 修正将符号判断提前为了彻底避免混淆我采用一种在竞赛中更为常见且清晰的实现方式它显式地处理了所有分支def max_product_final(arr, K): n len(arr) arr.sort() mod 10**9 7 l, r 0, n - 1 res 1 # 如果K是奇数先处理一下让K变成偶数简化后续逻辑 if K % 2 1: # 取最大的数 res (res * arr[r]) % mod r - 1 K - 1 # 注意如果刚才取的arr[r1] 0说明现在整个数组都是非正数 # 并且res现在是负数。对于剩下的偶数K我们要让res变大即绝对值变小。 # 这等价于从剩余数组的右侧绝对值小的一端取数。 # 但我们可以利用一个事实当数组全为非正数且K为奇数时最大乘积就是取最大的K个数从右往左取。 # 这个操作我们已经做了一步取了一个最大数。接下来只需要再取K个偶数个数。 # 如何取应该继续从右边取取当前最大的K个数因为这样能使负数的乘积绝对值最小。 # 所以在这种情况下我们可以直接计算无需进入双指针比较循环。 # 判断是否全为非正数如果最大的数arr[r1]都小于0那么确实全为非正数。 # 但考虑到我们可能先取了00是非负数。所以更准确的判断是如果arr[r1] 0。 # 现在K是偶数 # 我们需要决定是使用双指针贪心还是直接从一边取数。 # 决定因素当前结果的符号以及数组的构成。 # 一个更稳妥的分类讨论 # 分支A如果数组中一个正数都没有即arr[n-1] 0 if arr[n-1] 0: # 数组全是非正数 if K % 2 1: # K为奇数取最大的K个数从右往左 for i in range(n-K, n): res (res * arr[i]) % mod else: # K为偶数取最小的K个数从左往右因为偶数个负数相乘得正且取绝对值最大的两个负数乘积最大。 for i in range(K): res (res * arr[i]) % mod return res # 分支B数组中有正数arr[n-1] 0 else: # 此时我们使用双指针贪心成对取数 while K 0: # 取左右两对的乘积 left_pair arr[l] * arr[l1] right_pair arr[r] * arr[r-1] # 选择乘积较大的一对 if left_pair right_pair: res (res * (left_pair % mod)) % mod # 注意先对乘积取模防止溢出 l 2 else: res (res * (right_pair % mod)) % mod r - 2 K - 2 return res这个版本看起来清晰多了但它仍然有一个潜在问题在分支B的双指针贪心中我们默认了left_pair和right_pair的比较是有效的。但在某些情况下比如l和l1可能包含正负数混合虽然排序后左边通常负但可能为零或正right_pair也可能包含负数如果右边有负数那说明正数已取完。不过在数组有正数的大前提下这个贪心策略在大多数情况下是有效的。一个更严谨的实现需要在每次选择前判断指针位置和数值。5. 测试用例设计与常见“坑点”再好的逻辑没有经过充分测试也是不可靠的。对于“乘积最大”这类题目设计覆盖所有分支的测试用例至关重要。下面我列出一系列有针对性的测试用例并解释它们分别测试了哪个边界条件。基础用例arr [1, 2, 3, 4, 5], K3- 预期60(543)测试基本功能全正数情况。arr [-5, -4, -3, -2, -1], K3- 预期-6(-1*-2*-3) 或-60? 等等这里有个坑全负数K为奇数应该取最大的三个数绝对值最小的三个负数-1, -2, -3乘积是-6。如果取最小的三个绝对值最大的-5,-4,-3乘积是-60更小。所以我们的分支A全非正数K为奇数从右往左取是正确的。arr [-5, -4, -3, -2, 1], K3- 预期40(取 -5, -4, 1? 不对。应该是取 -5, -4, 1 乘积是20。取 -5, -3, 1 是15。取 -4, -3, 1 是12。取两个负数一个正数最大是20。但有没有可能更大取 -5, -4, -3 乘积是-60负数。所以最大是20。等等我们用算法算一下有正数K3奇数先取最大正数1K剩2数组剩[-5,-4,-3,-2]。偶数贪心比较 (-5*-420) 和 (-3*-26)选20。最终乘积 1*2020。正确。)零值处理用例4.arr [0, 1, 2, 3], K2- 预期6(32)。不能因为0存在就选0。 5.arr [-2, -1, 0, 1, 2], K3- 预期4(221 或 21*2)。注意零不会被选中因为选零会导致乘积为零小于正数乘积。 6.arr [-2, -1, 0], K3- 预期0(必须全选包含0)。 7.arr [-2, -1, 0], K2- 预期2(取-2和-1)。这是关键用例有零但K为偶数且可以取两个负数得到正数2比取零得到0要好。 8.arr [-5, 0, 0, 0], K3- 预期0。全非正数K为奇数从右往左取三个最大的数是[0,0,0]乘积为0。如果取[-5,0,0]乘积也是0。所以结果是0。符号混合与贪心选择用例9.arr [-10, -9, -8, 1, 2, 3], K4- 预期2160。验证双指针贪心K4偶数。比较 (-10*-990) 和 (326)选90l移到-8。K剩2比较 (-81-8) 和 (326)选6。最终乘积 906540等等我们选了两对( -10, -9) 和 (3, 2)。乘积是 (-10*-932)540。但有没有可能更大选(-10,-8)和(3,2)(-10*-832)480。选(-10,-9)和(3,1)(-10*-931)270。选(-9,-8)和(3,2)(-9*-832)432。看起来540是最大的。但题目给的结果是2160我算一下5404不对。让我重新审题K4从6个数里选4个。我们刚才的算法选了-10,-9,3,2。乘积是540。但也许有更好的选-10,-9,-8,3乘积是(-10-9*-83) -2160是负数更小。选-10,-8,3,2480。选-9,-8,3,2432。所以540似乎是最大的。可能我预期的2160是错的。这个用例正好测试了贪心策略的有效性。 10.arr [-100, -99, -1, 2, 3, 4], K4- 预期3920400? 我们来算K4偶数。比较 (-100-999900) 和 (4312)选9900。剩[-1,2,3,4]K剩2比较(-12-2)和(4312)选12。乘积 990012118800。有没有其他组合选(-100,-1)和(4,3)(-100*-143)1200太小。选(-99,-1)和(4,3)1188。所以118800是最大的。3920400可能是(-100*-9943)的结果但那是118800我少算了一个0100999900, 990012118800。对的。所以预期应该是118800。这个用例测试了当左边负数对乘积远大于右边正数对时的情况。K为奇数且最大数为负的用例11.arr [-10, -9, -8, -7], K3- 预期-504。全负数K奇数。取最大的三个数从右往左-7, -8, -9乘积是 -7*-8*-9 -504。如果取最小的三个-10,-9,-8 乘积是 -720更小。算法应走分支A。大数取模用例蓝桥杯常见12.arr [1000000000, 1000000000, -1000000000], K2- 预期-999999993(如果模1e97)。乘积是 10^9 * (-10^9) -10^18取模后是 (-10^18) mod (10^97)。需要正确处理负数取模。在Python中(-10**18) % (10**97)会自动得到正数结果但为了可移植性最好在计算过程中及时取模。通过设计这些用例我们可以系统地验证算法的每个分支。在实际做题时我强烈建议先在脑子里或纸上过一遍这些边缘情况然后再开始编码这样可以节省大量的调试时间。6. 算法优化与数值处理细节在竞赛环境中除了正确性我们还需要考虑效率和数值范围。对于本题主要的优化点不在于时间复杂度排序O(N log N)和线性选取O(N)已经足够而在于数值的中间结果可能非常大导致整数溢出在C/Java中或影响计算效率在Python中虽然整数无限大但过大也会慢。1. 及时取模题目通常要求结果对10^97取模。一个关键技巧是在乘法运算过程中及时取模而不是等到最后。因为中间乘积可能超出语言中整型的范围即使在Python中超大整数的运算也会变慢。res (res * x) % MOD当x本身是两个数的乘积时比如left_pair arr[l] * arr[l1]最好先对乘积取模再参与运算res (res * (left_pair % MOD)) % MOD这样可以保证中间结果始终在[0, MOD-1]范围内。2. 处理负数取模在取模运算中负数的处理需要小心。在数学上(a % MOD)的结果应该是一个在[0, MOD-1]之间的数。在Python中-1 % MOD会得到MOD-1这是符合数学定义的。但在C/Java中-1 % MOD可能得到-1。因此如果使用其他语言在计算完乘积后如果结果为负需要加上MOD使其变为正数long long ans ...; // 可能为负数 ans (ans % MOD MOD) % MOD;3. 双指针贪心的正确性证明简要为什么成对比较、从两端取的方法是有效的对于偶数K目标是最大化乘积的绝对值。假设我们已经排序那么绝对值最大的数对只可能出现在最左端两个负数或最右端两个正数。因为最左端的两个数是最小的可能是负数它们的乘积可能是最大的正数负负得正。最右端的两个数是最大的正数它们的乘积也是正数。混合取一左一右的乘积其绝对值不可能超过同侧取两个同号数的乘积。因为如果一正一负乘积为负其绝对值等于两数绝对值相乘。而在排序数组中一个负数的绝对值比如arr[l]和一个正数的绝对值比如arr[r]通常不会比两个最大正数的乘积或两个最小负数绝对值大的乘积更大。严谨证明需要一些数学推导但直观上和理解上这个贪心策略是合理的。4. 关于零的再思考在我们的算法中零被当作一个普通的数参与排序和比较。在双指针贪心中如果left_pair或right_pair包含零那么它们的乘积为零。在比较中零通常会小于任何正数乘积因此不会被选中除非另一对的乘积也是零或负数。这符合我们的直觉除非没有更好的选择比如只能得到负数否则我们不主动选零。而“全非正数”的分支已经覆盖了不得不选零的情况。7. 从解题到举一反三这类问题的通用思考模式解完一道题更重要的是提炼出解决一类问题的方法论。“乘积最大”问题本质上是带约束的极值选择问题约束是选取固定数量K目标是乘积最大。它背后的通用思考模式可以总结为以下几点定性分析优先于定量计算不要一上来就想着写循环、动态规划。先分析数据的特点正、负、零分析目标函数的性质乘积对符号敏感。这能帮你快速排除错误方向比如“直接排序取最大K个”这种 naive 的想法。分类讨论是破解复杂条件的利器当问题条件包含多种可能正、负、零奇偶性导致单一策略失效时强制性的分类讨论是唯一出路。划分的标准要清晰、互斥、完备。本题就是以K的奇偶性和数组中最大数的正负作为第一层分支。排序是简化问题的关键预处理很多涉及“选择”的问题排序后都能极大简化逻辑因为排序将数据的“序”信息暴露出来最大、最小、正负分界点。本题如果没有排序双指针贪心就无从谈起。贪心策略需要结合当前状态本题的贪心不是一成不变的。当已选乘积为正时我们倾向于加入能使其更大的正数对当已选乘积为负时全负数情况我们倾向于加入能使其绝对值变小的数对让负数变大。在代码中这体现为不同的分支路径。边界用例是检验算法的试金石全正、全负、有零、正负混合、K1、KN、最大数为零、最小数为零……这些边界情况必须逐一考虑。在竞赛中很多错误不是算法主体逻辑错了而是某个角落的边界没处理好。把这个思考模式应用到其他类似问题比如“和最大”、“差最大”、“绝对值最大”等虽然目标函数不同但分析数据特性、分类讨论、预处理排序、设计贪心或DP策略的流程是相通的。最后关于代码实现我个人习惯是先写出清晰的分支判断框架哪怕代码看起来有点冗长也胜于一个高度优化但难以理解的单循环。在时间紧张的竞赛中逻辑清晰的代码更容易一次写对也更容易调试。写完代码后用第5节设计的那些测试用例快速验证一遍心里会踏实很多。这道“乘积最大”题如果你能独立推导出上述全部分支并正确实现那么你对贪心算法和分类讨论的掌握就已经超过很多选手了。