蓝桥杯国赛真题解析:和与乘积问题的O(n)算法与双指针技巧

📅 2026/8/22 17:16:14
蓝桥杯国赛真题解析:和与乘积问题的O(n)算法与双指针技巧
1. 项目概述从一道国赛真题看算法思维的深度最近在复盘蓝桥杯历届国赛的经典题目2021年第十二届国赛的“和与乘积”这道题让我印象尤为深刻。它不像一些纯考数据结构的题目那样直接也不像某些动态规划题那样有明确的套路。这道题更像是一个精巧的数学谜题披着简单题目的外衣实则对选手的逻辑分析、数学归纳和边界处理能力提出了相当高的要求。很多朋友初次接触时可能会觉得题目描述清晰数据范围也不大但一上手编码就发现处处是坑要么超时要么答案不对。今天我就结合自己多次解题和教学的经验把这题从里到外彻底拆解一遍不仅告诉你“怎么做”更要讲清楚“为什么这么做”以及那些在标准题解里不会写的调试心得和思维误区。简单来说题目给定一个长度为n的整数数组数组中的元素均为正整数。我们需要计算这个数组的所有非空连续子数组中满足“子数组元素之和等于子数组元素之积”的个数。初看这个条件“和等于积”在正整数范围内感觉非常苛刻似乎只有全1序列或者包含1的特定序列才能满足。这恰恰是题目的第一个思维陷阱引导我们过早地陷入对数学性质的猜测而忽略了题目给定的数据范围n≤ 2×10^5所暗示的算法复杂度要求——我们必须找到一个低于 O(n²) 的解法。直接暴力枚举所有子数组是 O(n²) 的在 n200000 时必然超时。因此核心挑战在于如何利用“和等于积”这个强约束条件设计出高效的查找或计数方法。2. 核心思路解析化乘为加与双指针滑动面对“和等于积”这个条件最直接的数学洞察是对于一组大于1的正整数它们的乘积会以极快的速度超过它们的和。例如[2, 3]和为5积为6已经不相等[2, 2, 2]和为6积为8。只有当序列中包含足够多的“1”时乘积的爆炸性增长才会被抑制因为乘以1不会改变积但会增加和。这提示我们满足条件的子数组很可能大量集中在包含1的区段。2.1 关键数学性质与问题转化基于上述观察我们可以进行一个关键的问题转化对于一个不含1的子数组如果它的长度超过一个很小的阈值比如2或3其积几乎必然大于和。我们来严格论证一下 假设一个子数组不含1最小元素为2。设其长度为 k。其和 sum ≥ 2k。其积 product ≥ 2^k。当 k2 时2^24, 2*24边界情况[2,2]满足和等于积。当 k3 时2^38, 2*36积已大于和。随着k增大积将以指数级速度远超和。因此任何不含1且长度超过2的子数组都不可能满足“和等于积”。唯一的例外是长度恰好为1的子数组单个元素此时和与积显然相等只要该元素是正整数就成立。所以所有长度为1的子数组都天然是答案的一部分这部分的数量就是数组长度 n。现在问题简化为了如何高效地找出所有长度大于等于2且满足条件的子数组根据以上性质这样的子数组必然包含至少一个“1”。我们可以把原数组按照非1的元素进行“切割”得到一个由连续1组成的段简称“1段”和非1的“枢纽数”交替出现的结构。例如数组[2, 1, 1, 3, 1, 2, 1]可以被视为枢纽2接着一个长度为2的1段枢纽3长度为1的1段枢纽2长度为1的1段。 我们的搜索范围就可以限定在以某个非1的“枢纽数”为核心向左右两侧扩展其相邻的连续1段所构成的所有子数组。因为一旦子数组包含了两个非1的枢纽数且中间没有1间隔或者1的个数不够多其乘积会迅速膨胀导致条件无法满足。2.2 算法框架设计以非1元素为锚点基于这个认知我们可以设计出算法的主框架初始化答案ans n。因为所有单个元素都满足条件。遍历数组中每一个大于1的元素记为a[i]将其作为潜在子数组的“核心”或“起点”。对于每个核心a[i]我们分别向左右两个方向扩展吸收连续的1构造出以a[i]为唯一非1元素的候选子数组。同时我们也需要考虑以两个相邻的非1元素为核心的情况这是满足条件的最大可能长度。在扩展过程中动态计算当前子数组的和与积。由于积的增长极快我们需要在积超过一个合理上限比如所有元素之和这是一个明确上界时停止扩展避免数值溢出和无效计算。检查当前子数组是否满足“和等于积”若满足则计数。这个算法的复杂度如何因为我们在遍历每个非1元素时向其左右扩展。由于乘积的快速增长扩展的长度是有限的通常很短。更精确地说在正整数且大部分数不太大的情况下扩展的深度是 O(log(Sum)) 级别的其中Sum是数组总和。因此整体复杂度近似于 O(n log M)其中M与数据范围相关完全可以应对 n2e5 的规模。3. 实现细节与双指针技巧理论清晰后我们进入实现环节。这里最大的难点是如何优雅地处理“向左右扩展连续1”的过程并高效计算子数组的和与积。3.1 预处理连续1段一个非常实用的技巧是预处理两个数组leftOnes[i]和rightOnes[i]leftOnes[i]表示位置 i 左边连续1的个数不包括 i 本身。rightOnes[i]表示位置 i 右边连续1的个数不包括 i 本身。这样当我们以a[i](a[i] 1) 为核心时可以立即知道它左边有L leftOnes[i]个连续的1右边有R rightOnes[i]个连续的1。那么所有以a[i]为唯一非1元素的子数组就可以描述为从左边选择l个1 (0 ≤ l ≤ L)从右边选择r个1 (0 ≤ r ≤ R)与a[i]共同组成的子数组[1,...,1, a[i], 1,...,1]。对于这样的子数组长度len l 1 r。和sum l * 1 a[i] r * 1 l a[i] r。积product 1^l * a[i] * 1^r a[i]。满足“和等于积”的条件简化为l a[i] r a[i]即l r 0。 这意味着对于一个非1元素为核心左右只扩展1的子数组要想和等于积必须左右都不扩展1。也就是子数组就是[a[i]]本身。而这我们已经计入答案了长度为1的情况。所以对于单一非1核心的情况长度大于1的子数组不可能成立。这个结论非常重要它告诉我们满足条件的、长度大于1的子数组必须至少包含两个非1元素。3.2 双核心情形与滑动窗口因此我们需要将搜索目标调整为考察每一对相邻的非1元素(a[i], a[j])以及它们中间可能存在的连续1和它们各自左右两边的连续1。假设我们有两个相邻的非1元素下标分别为i和j(i j)且它们之间没有其他非1元素即a[i] 1,a[j] 1且对于所有 i k j有a[k] 1。 设它们之间有midOnes j - i - 1个1。 设a[i]左边有L个连续1a[j]右边有R个连续1。现在我们考虑一个子数组它包含a[i]和a[j]以及它们之间所有的1并且可以向左右两侧再扩展若干连续的1。设从左边扩展了l个1 (0 ≤ l ≤ L)从右边扩展了r个1 (0 ≤ r ≤ R)。那么这个子数组的结构是[1...1(共l个), a[i], 1...1(共midOnes个), a[j], 1...1(共r个)]。对于这个子数组和sum l a[i] midOnes a[j] r。积product a[i] * a[j]。因为所有1相乘仍为1条件sum product转化为l r (a[i] midOnes a[j]) a[i] * a[j]。我们可以将a[i] midOnes a[j]视为一个固定值fixed_sum。那么条件变为l r a[i] * a[j] - fixed_sum。 令target a[i] * a[j] - fixed_sum。 我们需要找到所有满足0 ≤ l ≤ L,0 ≤ r ≤ R且l r target的整数对(l, r)。每一对这样的(l, r)就对应一个满足条件的子数组。3.3 高效计算满足条件的 (l, r) 对数如何计算这个对数呢这变成了一个简单的组合问题如果target 0显然无解。如果target 0只有一种情况l0且r0。如果target 0那么l可以从max(0, target - R)取到min(L, target)。因为r target - l必须满足0 ≤ r ≤ R。 所以有效的l的取值范围是[max(0, target - R), min(L, target)]。 如果这个区间存在那么满足条件的(l, r)对数就是这个区间的长度count min(L, target) - max(0, target - R) 1当然这个值需要和0取最大值避免负值。这样对于每一对相邻的非1元素(i, j)我们都可以在 O(1) 时间内计算出以其为核心并能向左右扩展1的、满足条件的子数组数量。3.4 算法步骤总结输入与初始化读入数组a长度n。初始化答案ans n所有单元素子数组。预处理连续1段计算leftOnes和rightOnes数组。提取非1元素索引遍历数组将所有值大于1的元素的下标记录到一个列表pos中。遍历相邻非1元素对对于pos列表中每一对相邻的下标(p[k], p[k1])令i p[k],j p[k1]。计算midOnes j - i - 1。计算fixed_sum a[i] midOnes a[j]。计算product a[i] * a[j]。这里必须使用long long类型防止溢出。计算target product - fixed_sum。如果target 0跳过。获取L leftOnes[i],R rightOnes[j]。计算l_min max(0LL, target - R)l_max min((long long)L, target)。如果l_min l_max则增加答案ans (l_max - l_min 1)。输出答案。注意上述步骤只处理了包含恰好两个非1元素的子数组。根据之前的数学性质包含三个或以上非1元素的子数组其乘积会远大于和在数据范围有限且元素为正整数的情况下不可能满足条件无需考虑。但严谨起见可以在计算product时如果发现product已经大于数组所有元素之和一个绝对上界可以提前终止对该核心对的进一步扩展虽然我们这里只扩展到两个核心但如果是更一般的扩展算法这个剪枝很重要。4. 代码实现与关键点注释下面给出基于上述思路的C实现代码并附上关键注释。#include iostream #include vector #include algorithm using namespace std; int main() { int n; cin n; vectorint a(n); long long total_sum 0; // 数组总和用于潜在剪枝 for (int i 0; i n; i) { cin a[i]; total_sum a[i]; } // 1. 预处理每个位置左右连续1的个数 vectorint leftOnes(n, 0), rightOnes(n, 0); for (int i 1; i n; i) { if (a[i - 1] 1) { leftOnes[i] leftOnes[i - 1] 1; } else { leftOnes[i] 0; } } for (int i n - 2; i 0; --i) { if (a[i 1] 1) { rightOnes[i] rightOnes[i 1] 1; } else { rightOnes[i] 0; } } // 2. 记录所有大于1的元素的位置 vectorint pos; for (int i 0; i n; i) { if (a[i] 1) { pos.push_back(i); } } // 3. 初始答案所有长度为1的子数组 long long ans n; // 4. 遍历所有相邻的大于1的元素对 for (size_t k 0; k 1 pos.size(); k) { int i pos[k]; int j pos[k 1]; // 计算中间1的个数 int midOnes j - i - 1; // 计算固定部分的和 long long fixed_sum a[i] midOnes a[j]; // 计算乘积注意使用long long防止溢出 long long product 1LL * a[i] * a[j]; // 关键剪枝如果乘积已经超过总和那么即使左右扩展再多的1增加和和也追不上积。 // 实际上对于两个非1数如果product total_sum那么product - fixed_sum total_sum - fixed_sum // 而lr最大为 leftOnes[i] rightOnes[j]这个值通常远小于total_sum。 // 这里为了逻辑清晰先保留计算也可以直接判断 product total_sum leftOnes[i] rightOnes[j] 时跳过。 // 我们先计算target如果target太大自然会在后续判断中过滤。 long long target product - fixed_sum; if (target 0) { continue; // 和已经大于积即使不扩展1也不满足扩展1增加和更不满足 } int L leftOnes[i]; int R rightOnes[j]; // 计算l的可行范围 long long l_min max(0LL, target - R); long long l_max min((long long)L, target); if (l_min l_max) { ans (l_max - l_min 1); } } cout ans endl; return 0; }5. 边界情况、常见错误与调试心得即使思路正确实现时也极易掉入陷阱。下面分享几个我踩过的坑和调试经验。5.1 数值溢出问题这是本题最大的“坑点”。数组元素最大可达 10^9两个这样的数相乘会达到 10^18远超 32 位 int 的范围约 2×10^9。因此任何涉及乘法或可能累加出大数的地方都必须使用long long64位整数。product a[i] * a[j];这行代码如果a[i]和a[j]是int相乘的结果会先以int计算导致溢出然后再赋值给long long变量为时已晚。必须写成1LL * a[i] * a[j]或(long long)a[i] * a[j]确保计算在64位下进行。target,l_min,l_max这些由乘积推导出的变量也必须使用long long。答案ans本身也可能超过int范围因为子数组数量最多约为 n n*(log(n))? 量级对于 n2e5使用long long是安全的。5.2 边界条件处理没有非1元素或只有一个非1元素我们的算法主体是遍历相邻的非1元素对。如果pos.size() 2那么这个循环不会执行答案就是最初的ans n。这是正确的因为如果数组全是1那么任何子数组的和等于长度积等于1只有长度为1的子数组满足条件。如果只有一个非1元素根据3.1节的推导长度大于1的子数组也不满足条件。target的计算与范围target product - fixed_sum可能非常大导致l_min和l_max的计算出现负数或异常。我们通过max(0LL, target - R)和min((long long)L, target)来约束并最终判断l_min l_max来确保有效性。这是正确的。连续1段的边界leftOnes[0]和rightOnes[n-1]通过预处理循环被正确地初始化为0。5.3 算法正确性验证构造测试用例要验证代码需要构造多种类型的测试数据纯1数组[1,1,1,1]答案应为4。无1数组[2,3,4]答案应为3只有三个单元素。单个非1元素[5,1,1,1]答案应为4四个单元素。两个非1元素紧邻[2,3]fixed_sum5, product6, target1。L和R均为0。l_min max(0,1-0)1,l_max min(0,1)0l_minl_max无解。答案2。正确因为[2,3]的和是5积是6。两个非1元素间有1[2,1,3]fixed_sum2136, product6, target0。L和R取决于上下文假设左右无其他1则L0,R0。l_min max(0,0-0)0,l_max min(0,0)0有解对应l0,r0即子数组[2,1,3]本身。答案需要加上1。需要手动验证和6积6确实满足。可向左右扩展1的情况例如数组[1,1,2,1,3,1,1]以2和3为核心。i2, j4。a[2]2, a[4]3。midOnes1。fixed_sum2136。product6。target0。L leftOnes[2] 2左边两个1R rightOnes[4] 2右边两个1。l_min max(0, 0-2)0l_max min(2, 0)0。所以只有l0, r0一组解对应子数组[2,1,3]。但如果我们考虑[1,2,1,3]呢它的和是12137积是6不满足。[2,1,3,1]和也是7积是6。可见确实只有核心部分满足。这个例子说明我们的计算是准确的。更复杂的扩展案例需要构造target0的情况。例如[1, 1, 4, 1, 1, 5, 1]以4和5为核心。fixed_sum 4 2 5 11中间两个1。product20。target9。左边L2个1右边R1个1。我们需要lr9且0l2,0r1。显然r最大为1则l至少为8超出了L的范围无解。说明没有这样的子数组。可以尝试调整数值让target小一些。5.4 性能分析时间复杂度预处理连续1数组 O(n)。遍历非1元素索引 O(n)。遍历相邻非1元素对 O(m)其中m是非1元素的个数。每个元素对的计算是O(1)。整体复杂度 O(n)非常高效。空间复杂度使用了leftOnes,rightOnes,pos等额外数组均为 O(n)。这道“和与乘积”的题目从暴力枚举的 O(n²) 到最终 O(n) 的解法跨越的关键在于对问题性质的深度挖掘。它要求我们跳出“枚举所有子数组”的惯性思维通过数学分析将搜索空间缩小到只关注包含1的、且非1元素个数不超过2的特定子数组上并利用预处理和组合数学公式进行快速计数。在竞赛中能够迅速完成这种问题转化是区分普通选手和顶尖选手的重要标志。解决这类问题没有捷径唯有多思考、多总结理解每一个优化步骤背后的“为什么”才能举一反三。