尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

蓝桥杯国赛真题解析:利用数学剪枝与滑动窗口高效求解“和与乘积”问题

蓝桥杯国赛真题解析:利用数学剪枝与滑动窗口高效求解“和与乘积”问题 1. 项目概述从“和与乘积”看蓝桥杯国赛的思维跃迁拿到“蓝桥杯2021年第十二届国赛真题-和与乘积”这个题目很多选手的第一反应可能是懵的。题目名字听起来很数学但蓝桥杯国赛从来不会只考纯数学公式推导。它更像是一个披着数学外衣的算法思维题考察的是选手如何将实际问题抽象、转化并用高效的算法去解决大规模数据下的计算问题。这恰恰是蓝桥杯尤其是国赛级别题目最迷人的地方——它不满足于你会写代码更要求你有清晰的建模能力和优化意识。简单来说这道题的核心是给定一个由正整数构成的数组我们需要找出数组中所有的连续子数组满足这个子数组所有元素的和等于这个子数组所有元素的乘积。听起来是不是有点像在数组里寻找“平衡点”但请注意数组元素是正整数且题目规模数组长度和元素值往往会设置得让暴力枚举所有子数组的算法O(n²)甚至O(n³)直接超时。因此这道题的挑战不在于理解题意而在于如何设计出能够应对大数据量的巧妙算法。它适合所有正在备战蓝桥杯国赛或希望提升自己算法优化能力的同学。无论你是Python选手还是C/Java选手这道题所蕴含的“利用数学性质剪枝”和“双指针滑动窗口优化”的思想都是算法竞赛中非常经典且实用的技巧。接下来我将彻底拆解这道题不仅告诉你“怎么做”更重点剖析“为什么这么做”以及我在反复调试中总结出的那些容易踩坑的细节。2. 核心思路解析为什么暴力枚举行不通我们先来最直观地理解一下题目。假设有一个数组[a1, a2, a3, ..., an]我们需要找到所有满足条件的整数对(l, r)1 ≤ l ≤ r ≤ n使得sum(arr[l:r1]) product(arr[l:r1])最笨的方法就是枚举所有可能的子数组起点l和终点r然后计算它们的和与积并进行比较。对于一个长度为n的数组子数组总数是n*(n1)/2即 O(n²) 量级。如果对每个子数组都从头计算和与积那计算量就是 O(n³)这显然是不可接受的。即使我们通过前缀和Prefix Sum技术将计算子数组和优化到 O(1)计算子数组的积仍然是个大问题。注意这里有一个关键陷阱。很多同学想到用“前缀积”来类比“前缀和”试图也将乘积计算优化到 O(1)。但这在整数范围内几乎不可行因为乘积增长极其迅速很快就会超出任何标准整数类型如long long甚至Python int的表示范围导致溢出。虽然Python的整数是任意精度但数值爆炸带来的计算开销和存储开销也会让算法变得低效。那么出路在哪里我们必须利用题目中隐藏的数学性质进行剪枝。性质一正整数条件。数组元素都是正整数。这意味着子数组的和与积都是严格递增的随着子数组变长。但积的增长速度远快于和。性质二元素“1”的特殊性。数字“1”是这道题的灵魂。因为1 * x x且1 x x(当x0)。所以在一个乘积式子中加入数字1乘积不变但和会增加。这导致了在包含1的子数组中和会大于积。反过来要使得和等于积子数组中就不能有太多1或者说1的出现会打破一种平衡。性质三非1元素的影响。只要子数组中包含一个大于1的元素比如2乘积至少会翻倍而和只增加这个元素的值。因此对于不含1的子数组其乘积会非常迅速地超过和。这意味着满足“和等于积”的子数组其非1元素的个数是极其有限的。基于这些性质我们可以推导出一个强有力的剪枝条件任何一个满足和等于积的子数组其包含的大于1的元素个数不会超过 log₂(数组最大和)。因为每个大于1的元素至少会使乘积乘以2而和的最大值是数组所有元素之和记为S_max。要使乘积不超过S_max大于1的元素个数k必须满足2^k S_max所以k log₂(S_max)。在实际情况中这个k通常非常小比如不超过30。这个结论是算法的基石。它告诉我们虽然数组可能很长几万甚至几十万但我们需要重点关注的、可能符合条件的“核心段”其实非常短。我们的算法可以围绕寻找这些短暂的“核心段”由少量大于1的元素构成然后向两端扩展数字1来构造可能的解。3. 算法设计与实现详解理解了核心思路后我们设计一个高效算法的路线图就清晰了。主流且高效的解法是“中心扩散法”或称为“基于非1元素段的滑动窗口法”。下面我分步骤拆解这个算法的实现。3.1 预处理定位所有非1元素第一步我们需要扫描一遍数组记录下所有值大于1的元素的索引位置。同时为了快速计算包含1的区间和我们需要计算前缀和数组prefix_sum其中prefix_sum[i]表示前i个元素的和通常令prefix_sum[0] 0。例如对于数组nums [1, 1, 2, 1, 1, 3, 1]非1元素的索引列表为pos [2, 5]对应值2和3索引从0开始。前缀和数组为[0, 1, 2, 4, 5, 6, 9, 10]。这个pos数组定义了所有可能的“核心段”的边界。任何一个不含1的连续子数组其起点和终点必然落在pos中或者是同一个索引。3.2 枚举核心段并计算接下来我们枚举所有可能的“核心段”。核心段由pos中连续的一段索引构成。设pos的长度为m。 我们使用两层循环外层循环i枚举核心段的起点在pos中的位置。内层循环j从i开始枚举核心段的终点在pos中的位置。对于每一对(i, j)我们得到了一个纯粹由大于1的元素构成的子数组nums[pos[i]: pos[j]1]。我们计算这个核心段的和core_sum与积core_product。核心段的和可以通过前缀和快速计算core_sum prefix_sum[pos[j]1] - prefix_sum[pos[i]]。核心段的积需要在遍历中累乘得到。这里必须注意乘积溢出的问题。虽然我们之前从数学上推断核心段很短但代码中仍需加入判断如果core_product在累乘过程中已经超过了整个数组的总和total_sum那么无论两边加多少1乘积都只会更大绝对不可能等于和了此时可以立即跳出内层循环进行剪枝。这是性能优化的关键一步。3.3 向两端扩展数字1现在我们有了一个核心段的core_sum和core_product。显然在只有大于1元素的情况下几乎总是core_product core_sum因为积增长太快。要让两者相等我们需要引入数字1。数字1的特性是增加一个1和加1积不变。所以如果我们希望在核心段的基础上形成一个合法的子数组我们可以在其左侧和右侧添加若干个1。设我们在左侧添加left_ones个1在右侧添加right_ones个1。那么新子数组的和与积分别为total_sum core_sum left_ones right_onestotal_product core_product因为乘以1不变要满足total_sum total_product即core_sum left_ones right_ones core_product。 这可以转化为left_ones right_ones core_product - core_sum。令need core_product - core_sum。need必须是一个非负整数。如果need 0说明即使不加1积也已经小于和而加1只会让和更大差距更大所以此核心段不可能扩展出解。如果need 0那么问题就变成了我们能否在核心段的左侧找到连续的left_ones个1在右侧找到连续的right_ones个1使得left_ones right_ones need这里left_ones和right_ones是可以独立选择的。设核心段实际在数组中的左边界索引为L pos[i]右边界索引为R pos[j]。左侧可用的连续1的个数available_left等于从L-1位置向左数连续为1的个数。右侧可用的连续1的个数available_right等于从R1位置向右数连续为1的个数。那么只要存在整数left_ones和right_ones满足0 left_ones available_left0 right_ones available_rightleft_ones right_ones need那么我们就得到了一个有效的子数组[L - left_ones, R right_ones]。如何统计解的数量对于一组固定的(L, R, need)满足条件的(left_ones, right_ones)对数就是解的数量。具体来说left_ones可以从max(0, need - available_right)取到min(need, available_left)。每一组(left_ones, right_ones)都唯一确定一个子数组。因此解的数量为min(need, available_left) - max(0, need - available_right) 1当然这个结果需要和0取最大值避免负数。3.4 处理全为1的特殊子数组我们的算法基于“核心段”展开但有一种特殊情况被遗漏了子数组全由1构成。 对于全1的子数组[1, 1, ..., 1]长度为k其和为k其积为1。要满足和等于积即k 1。所以只有长度为1的全1子数组即单个元素1是合法的。这部分解可以在预处理时轻松计算直接统计数组中值为1的个数即可。每个1本身就是一个合法的子数组。3.5 算法流程总结与复杂度分析输入与初始化读取数组nums计算长度n总和total_sum前缀和数组pre_sum。统计单元素1答案ans初始化为数组中1的个数。预处理非1索引遍历数组记录所有值大于1的元素的索引到列表pos。枚举核心段遍历pos中的每个起始索引i。初始化core_product 1。遍历j从i到len(pos)-1更新core_product * nums[pos[j]]。如果core_product total_sum剪枝跳出内层循环。计算核心段和core_sum pre_sum[pos[j]1] - pre_sum[pos[i]]。计算need core_product - core_sum。如果need 0继续下一个j因为随着j增大积增长快于和need会先负后正不实际上如果当前need0加更多大于1的数积增长更快need会更负或者可能变正但情况复杂通常直接continue即可。确定核心段左右边界L pos[i],R pos[j]。计算左侧可用1的个数avail_leftL左边连续的1和右侧可用1的个数avail_rightR右边连续的1。计算可行的(left_ones, right_ones)组合数并累加到答案ans。输出答案。复杂度分析预处理前缀和、统计1、记录非1索引O(n)。枚举核心段外层循环 O(m)内层循环由于乘积快速超过总和而剪枝实际执行次数非常少。m是非1元素的个数且内层循环长度受log₂(total_sum)限制。因此这部分复杂度接近 O(m * log S)在绝大多数情况下非常高效。总复杂度接近 O(n)足以处理 n 高达 10^5 甚至 10^6 的数据规模。4. 代码实现与关键技巧这里给出一个Python版本的实现并附上关键注释和技巧说明。Python的整数不会溢出但逻辑判断core_product total_sum的剪枝依然至关重要。def solve(): n int(input().strip()) nums list(map(int, input().strip().split())) total_sum sum(nums) # 前缀和 pre[i] 表示前i个元素的和 pre[0]0 pre_sum [0] * (n 1) for i in range(n): pre_sum[i1] pre_sum[i] nums[i] # 答案初始化所有单个元素1 ans nums.count(1) # 记录所有大于1的元素的索引 pos [] for i, val in enumerate(nums): if val 1: pos.append(i) m len(pos) # 枚举所有可能的“核心段”由大于1的元素组成的连续子数组 for i in range(m): core_product 1 # 为了快速计算核心段的和我们需要知道核心段的左右实际边界 L pos[i] # 枚举核心段的结束点 for j in range(i, m): R pos[j] core_product * nums[R] # 关键剪枝如果核心段的积已经超过整个数组的总和那么再向右扩展只会让积更大绝对不可能等于和了 if core_product total_sum: break core_sum pre_sum[R1] - pre_sum[L] need core_product - core_sum if need 0: # 此时积小于和但继续向右扩展j积的增长速度远快于和所以need可能会从负变正。 # 我们不能直接break因为未来可能有解。但可以continue继续尝试更长的核心段。 continue # 计算核心段左右两侧连续1的个数 # 左侧连续1的个数 left_ones 0 idx L - 1 while idx 0 and nums[idx] 1: left_ones 1 idx - 1 # 右侧连续1的个数 right_ones 0 idx R 1 while idx n and nums[idx] 1: right_ones 1 idx 1 # 我们需要 left_ones right_ones need并且能分配 # left_ones 可以取 [0, min(need, left_ones)]但必须保证 right_ones need - left_ones 也在其范围内 # 等价于存在 left_ones 满足 # max(0, need - right_ones) left_ones min(need, left_ones) low max(0, need - right_ones) high min(need, left_ones) if low high: ans (high - low 1) print(ans) if __name__ __main__: solve()关键技巧与注意事项剪枝的位置与条件if core_product total_sum: break这行代码是效率的生命线。它基于一个全局上界进行剪枝避免了无数无效计算。total_sum是数组所有元素之和任何合法子数组的和都不可能超过它那么其积也不可能超过它。need 0时的处理当need为负数时表示当前核心段本身的积已经小于和。此时能否直接break不能。因为随着j增大核心段向右扩展加入一个大于1的数积的增长量乘以一个大于1的数会远大于和的增长量加上一个数。所以need有可能从负数变为0或正数。因此这里只能continue继续尝试更长的核心段。计算连续1的个数代码中使用了while循环向左右扩展来计算连续1的个数。这里有一个小优化点可以预处理两个数组left_one_count和right_one_count分别记录每个位置向左/右连续的1的个数。这样可以将每次计算从 O(n) 降到 O(1)。但对于算法整体复杂度影响不大且使代码更清晰。解的数量计算low和high的推导是这段逻辑的精华。它确保了left_ones和right_ones的分配是有效的。ans (high - low 1)直接完成了组合数量的累加。单元素1的处理答案初始化为nums.count(1)这完美覆盖了全为1的子数组中唯一合法的情况长度为1。不需要也不应该再去枚举长度大于1的全1子数组。5. 常见问题与调试心得在实际实现和调试这道题时我遇到了几个典型问题相信也是很多同学会踩的坑。问题一答案重复计数或漏计现象对于某些测试用例得出的答案比标准答案大或小。排查重复计数检查在向两端扩展1时是否考虑了left_ones和right_ones同时为0的情况即不扩展只取核心段本身。当need0时low max(0, 0-right_ones)0high min(0, left_ones)0high-low11这意味着核心段本身就是一个解。这是正确的需要计入。但要确保你的算法不会通过其他途径再次计入这个解。漏计确保你的算法覆盖了“单元素1”这种情况。这是独立于核心段枚举逻辑的。边界处理计算left_ones和right_ones时数组索引不要越界idx 0和idx n。问题二算法超时现象对于长数组例如全1数组混合少量大数程序运行时间过长。排查检查剪枝确认if core_product total_sum: break这行代码存在且正确。这是避免算法退化为 O(m²) 的关键。乘积溢出针对C/Java在C或Java中即使使用long long乘积也可能溢出。剪枝条件core_product total_sum可能在溢出后判断失效。更安全的做法是在累乘前进行判断if (core_product total_sum / nums[pos[j]]) break;或者使用Python的大整数特性来验证逻辑。预处理优化如果还是超时可以考虑预处理left_one_count和right_one_count数组避免在双重循环内再用while循环扫描。问题三对全1数组的处理错误现象输入为大量1时结果错误。排查确认你的算法不会将长度大于1的全1子数组当作解。根据推导只有长度为1的全1子数组合法。你的答案初始化ans count_of_1是否正确在核心段枚举逻辑中对于全1数组pos为空列表不会进入循环因此ans保持为count_of_1这是正确的。调试心得从小样例开始不要一上来就跑大规模数据。先构造几个小例子手动计算答案然后用你的程序验证。示例1[1]- 答案应为1。示例2[2]- 答案应为1 (核心段本身need0)。示例3[1, 2]- 解有[1],[2],[1,2]? 我们来算一下[1,2]的和3积2不相等。所以答案应为2。示例4[1, 1, 2, 1, 3]。手动找出所有解来验证程序。打印中间变量在调试时打印出i, j, L, R, core_product, core_sum, need, left_ones, right_ones, low, high等关键变量观察逻辑是否符合预期。特别是看need为0时是否正确地计入了核心段本身。考虑极端情况全是1[1,1,1,...,1]答案就是n。没有1[2,3,4]只有各个元素本身以及可能的核心段组合。包含大数[1, 1, 1000000, 1, 1]测试剪枝是否有效。长数组混合构造一个长数组验证效率。这道“和与乘积”题从暴力枚举的O(n³)到优化后的近似O(n)其提升的关键在于深刻理解了题目中“正整数”和“1”带来的数学约束并将这种约束转化为强有力的剪枝条件。它考察的不仅仅是编码能力更是问题分析、数学建模和算法优化的综合能力。掌握这种“挖掘性质、设计剪枝”的思路对于解决蓝桥杯国赛乃至其他算法竞赛中的难题都至关重要。在平时练习中多问自己“为什么这个方法可行有没有更紧的条件”这种思维训练比单纯刷题更有价值。
返回列表