十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

蓝桥杯“123”数列题解:前缀和与二分查找的算法实践

蓝桥杯“123”数列题解:前缀和与二分查找的算法实践 1. 从“123”数列到前缀和一道蓝桥杯真题的深度拆解最近在整理历年蓝桥杯的真题翻到了2021年C/C B组的F题题目就叫“123”。乍一看这名字简单得有点让人摸不着头脑但真正上手去解才发现里面藏着不少关于数列构造、数学归纳和算法优化的门道。这道题的核心是要求我们快速计算一个特殊无限数列中某一段区间内所有数字的和。对于算法竞赛的初学者来说它是一道绝佳的综合练习题既能考察基础的编程实现又能深入考验对时间复杂度、空间复杂度的把控以及对前缀和、二分查找等核心思想的灵活运用。今天我就结合自己的解题思路和踩过的坑来详细拆解一下这道题希望能给正在备赛或者对算法感兴趣的朋友一些实实在在的参考。2. “123”数列的构造规则与问题定义首先我们必须彻底理解题目给出的这个“123”数列到底是什么。题目描述是这样的有一个无限长的数列其前几项为1, 1, 2, 1, 2, 3, 1, 2, 3, 4, 1, 2, 3, 4, 5, ...。简单来说这个数列是由无数个从1开始的连续正整数段拼接而成的。2.1 数列的生成逻辑我们可以这样形式化地描述它第1段包含1个数字即[1]第2段包含2个数字即[1, 2]第3段包含3个数字即[1, 2, 3]第4段包含4个数字即[1, 2, 3, 4]...第k段包含k个数字即[1, 2, 3, ..., k]然后把这些段按顺序拼接起来就得到了我们的“123”数列。所以数列的第i项具体是多少取决于i落在了第几段以及在这段中的位置。2.2 问题的核心区间求和题目会给出T组查询每组查询包含两个整数l和r代表一个闭区间[l, r]。我们的任务是计算出原数列中第l项到第r项包含两端所有数字的和。即Sum(l, r) a[l] a[l1] ... a[r]其中a[i]表示数列的第i项。这里最直接的挑战是l和r的取值范围可以非常大在蓝桥杯的评测环境下通常可以大到10^12甚至更大。我们不可能真的去生成这个直到10^12项的数列然后累加那样无论是时间还是空间都是不可接受的。因此问题的核心转化为了如何在不显式生成整个数列的前提下快速定位任意位置i对应的数字并高效计算任意区间的和。3. 解题的核心思路分层前缀和面对这种“在特殊构造的无限序列上快速区间求和”的问题一个非常经典且有效的思路是使用前缀和思想并且是两层的前缀和。这听起来可能有点抽象我们一步步来拆解。3.1 第一层段内前缀和与定位首先我们需要解决单个位置i的定位问题。给定一个下标i从1开始我们需要知道i落在第几段我们设这个段号为block_id。i在该段中是第几个元素我们设这个段内位置为pos。一旦知道了block_id和pos那么a[i]的值就很简单了a[i] pos。因为第block_id段的内容就是1, 2, ...,block_id段内第pos个元素的值就是pos。那么如何由i求block_id和pos呢这需要用到数列的构造规律。前block_id个段总共包含的数字个数是一个三角形数total_numbers_up_to_block(block_id) 1 2 3 ... block_id block_id * (block_id 1) / 2我们可以通过二分查找来快速确定block_id。寻找满足block_id * (block_id 1) / 2 i的最小block_id。找到block_id后前block_id-1个段的总数字数为prev_total (block_id - 1) * block_id / 2。那么位置i在当前段block_id中的位置pos就是pos i - prev_total。3.2 第二层数列全局前缀和仅仅能求单个a[i]还不够我们需要快速求区间和。定义S(i)为数列前i项的和即S(i) a[1] a[2] ... a[i]。那么区间[l, r]的和就可以通过前缀和差分得到Sum(l, r) S(r) - S(l-1)。所以问题进一步转化为如何快速计算任意S(i)计算S(i)可以分两部分完整段的和前block_id - 1个完整的段它们的和是多少当前段的部分和第block_id段中前pos个数字的和是多少完整段的和第k段包含数字1,2,...,k的和是sum_of_block(k) 1 2 ... k k * (k 1) / 2。 那么前m个完整段的总和就是对这些sum_of_block(k)从k1累加到km。这个和有一个公式total_sum_of_blocks(m) Σ_{k1}^{m} [k*(k1)/2] m * (m1) * (m2) / 6这个公式可以通过数学归纳法或者平方和、立方和公式推导出来是解题的关键之一。记不住也没关系在代码里可以用循环累加预处理当m不太大时或者直接记住这个结论。当前段的部分和在第block_id段中前pos个数字的和就是1 2 ... pos pos * (pos 1) / 2。因此S(i)的计算公式为block_id find_block(i) // 通过二分找到i所在的段号 pos i - (block_id-1)*block_id/2 // i在当前段中的位置 S(i) (block_id-1)*block_id*(block_id1)/6 pos*(pos1)/2这里(block_id-1)*block_id*(block_id1)/6就是total_sum_of_blocks(block_id-1)。3.3 算法流程总结基于以上分析解决单次查询的流程如下实现一个函数get_block_and_pos(i)通过二分法返回block_id和pos。实现一个函数S(i)利用上述公式计算前缀和。对于每次查询(l, r)输出S(r) - S(l-1)。这个算法的时间复杂度是每次计算S(i)需要一次二分查找O(log N)其中N与block_id同阶大约为sqrt(2*i)因此单次查询复杂度为O(log N)。对于T次查询T通常≤10^5总复杂度为O(T log N)完全在可接受范围内。4. 实现细节与关键技巧思路清晰了但实现起来仍有几个坑点需要注意。下面我结合C代码分享一些关键的实现技巧和调试经验。4.1 二分查找的边界与溢出处理二分查找block_id是算法的核心。我们需要找到最小的block_id使得block_id * (block_id 1) / 2 i。这里有两个细节细节一二分上下界下界low显然是1。上界high需要设得足够大。因为block_id约等于sqrt(2*i)当i最大为10^12时block_id最大约为1.5e6。为了保险可以将上界设为2e6或更大。更稳健的做法是通过解不等式n*(n1)/2 max_i来估算上界。细节二防止中间运算溢出在二分判断条件mid * (mid 1) / 2 i中mid * (mid 1)在mid很大时例如1e9可能会超过64位整数(long long)的范围导致溢出进而使二分判断出错。这是一个非常隐蔽的坑。注意在C中即使使用long long通常是64位其最大值大约是9e18。当mid接近1e9时mid * (mid 1)就接近1e18仍在安全范围内。但为了代码的通用性和鲁棒性最好将判断条件写成if (i * 2 / mid / (mid1) 1)或者使用__int128如果编译器支持或者使用double进行近似计算但需小心精度。更简单安全的方法是在计算mid * (mid 1) / 2之前先判断mid * (mid 1) / 2 i等价于判断mid * (mid 1) 2 * i。由于i可能很大我们可以移项来避免溢出mid * (mid 1) - 2 * i 0但这样还是可能溢出。最稳妥的方法是使用除法进行判断bool check(long long mid, long long i) { // 判断 mid*(mid1)/2 i // 等价于判断 mid*(mid1) 2*i // 为了防止溢出用除法判断 if (mid (2LL*i)/mid / (mid1)) { // 一种近似写法需仔细处理 return true; } // 或者直接使用__int128 // return (__int128)mid * (mid 1) / 2 i; }在实际竞赛中如果明确知道数据范围例如i≤10^12那么mid最大约1.5e6mid*(mid1)约2.25e12用long long是安全的。但养成防止溢出的习惯很重要。4.2 前缀和公式的推导与验证公式total_sum_of_blocks(m) m * (m1) * (m2) / 6需要理解并正确应用。我们来验证一下 当m1时前1个段的和 第1段的和 1。公式计算1*2*3/61正确。 当m2时前2个段的和 第1段和第2段和 1 (12)4。公式计算2*3*4/64正确。 当m3时前3个段的和 1 (12) (123) 13610。公式计算3*4*5/610正确。在代码中直接使用这个公式计算完整段的和效率是O(1)。但要注意三个连续的整数相乘再除以6同样需要考虑溢出问题。对于m约1e6的情况m*(m1)*(m2)约1e18在long long范围内。如果数据更大可能需要使用__int128或高精度。4.3 代码实现示例下面是一个考虑了上述细节的C实现框架#include iostream #include cmath using namespace std; using ll long long; // 函数1二分查找返回i所在的段号block_id ll find_block_id(ll i) { ll l 1, r 2e6; // 上界根据最大数据范围设定例如i最大1e12时sqrt(2e12)约1.414e6 while (l r) { ll mid (l r) / 2; // 使用__int128防止中间结果溢出如果编译器不支持需用其他方法避免溢出 if ((__int128)mid * (mid 1) / 2 i) { r mid; } else { l mid 1; } } return l; // l即为满足条件的最小block_id } // 函数2计算数列前i项的和 S(i) ll prefix_sum(ll i) { if (i 0) return 0; ll block_id find_block_id(i); // 前block_id-1个完整段的总数字个数 ll prev_cnt (block_id - 1) * block_id / 2; // i在当前段中的位置 ll pos i - prev_cnt; // pos 1 // 计算前block_id-1个完整段的总和 // sum Σ_{k1}^{block_id-1} (k*(k1)/2) (block_id-1)*block_id*(block_id1)/6 ll full_blocks_sum (block_id - 1) * block_id * (block_id 1) / 6; // 计算当前段内前pos个数字的和 ll partial_sum pos * (pos 1) / 2; return full_blocks_sum partial_sum; } int main() { int T; cin T; while (T--) { ll l, r; cin l r; ll ans prefix_sum(r) - prefix_sum(l - 1); cout ans endl; } return 0; }4.4 常见错误与调试心得下标从1开始题目通常默认数列下标从1开始。在计算pos i - prev_cnt时prev_cnt是前block_id-1段的总数所以pos自然是从1开始计数的这正好对应段内元素值。二分查找的等号在二分条件中我们寻找的是 i的最小block_id所以条件为mid*(mid1)/2 i时调整右边界r mid否则调整左边界l mid 1。这是二分查找寻找下界的标准写法。前缀和差分计算区间和时是S(r) - S(l-1)而不是S(r) - S(l)。这是前缀和的基础但紧张时容易写错。数据范围与类型始终使用long long。在计算诸如block_id * (block_id 1) / 2时即使block_id是long long乘法结果也可能溢出int。确保所有参与运算的变量和常量都是long long类型例如写2LL而不是2。测试用例自己构造一些小的测试用例验证。例如数列前几项1, 1,2, 1,2,3, ...计算S(1)1, S(2)2, S(3)4, S(4)5, S(5)7, S(6)10。计算Sum(2,4) a[2]a[3]a[4] 121 4。用公式S(4)-S(1)5-14。5. 算法优化与思维延伸虽然上述解法已经足够通过本题但我们还可以从更深的层次思考并探讨相关的变种问题。5.1 避免二分查找直接解方程我们二分查找是为了解方程block_id * (block_id 1) / 2 i。这实际上是一个关于block_id的二次不等式。我们可以直接求解 令x block_id解x*(x1)/2 i即x^2 x - 2i 0。 其正根为x (-1 sqrt(1 8i)) / 2。由于我们要找的是最小的整数x使得x*(x1)/2 i我们可以先计算tmp (ll)floor((-1 sqrt(1 8*i)) / 2)。但这样计算得到的tmp可能刚好满足等式也可能略小。我们需要检查一下如果tmp * (tmp 1) / 2 i那么block_id tmp 1否则block_id tmp。这种方法将二分查找的O(log N)优化成了O(1)但需要注意浮点数开方和类型转换的精度问题。当i非常大时例如接近10^18sqrt(18*i)的计算可能会有精度误差导致tmp偏差1。一个稳妥的做法是计算得到tmp后用整数运算进行微调。ll find_block_id_direct(ll i) { ll tmp (ll)((sqrt(1.0 8.0 * i) - 1) / 2); // 微调 while (tmp * (tmp 1) / 2 i) tmp; while ((tmp - 1) * tmp / 2 i) tmp--; // 实际上后一个while通常不需要 return tmp; }在大多数情况下直接解法更快且更简洁但需要处理好边界和精度。5.2 问题变种如果数列规则变化怎么办“123”数列的本质是一个“分段等差数列”。我们遇到的更一般化问题可能是给定一个分段函数定义的数列如何快速求前缀和或区间和通用思路定位找到下标i所在的“段”。这通常需要该段长度满足某种可求和的规律如等差数列、等比数列、固定长度等。分段求和前缀和 所有完整段的和 最后不完整段的部分和。加速如果“段”的规律是数学上可求和的如等差数列的和、平方和、立方和公式那么完整段的和可以用公式O(1)计算。否则可能需要预处理前缀和数组然后用二分查找定位。例如如果数列变为[1], [2,3], [4,5,6], [7,8,9,10], ...即每段是连续的整数。那么第k段的首项是start_k (k-1)*k/2 1末项是end_k k*(k1)/2段内和可以用等差数列求和。定位和求和的整体思路与“123”数列完全一致。5.3 对初学者的建议从暴力到优化这道题对初学者而言最佳的练习路径是暴力生成小数据先写一个程序生成数列的前N项比如N1000并计算前缀和。这能帮助你直观理解数列规律并用于验证优化算法的正确性。实现二分查找定位先实现get_block_and_pos(i)函数并用暴力生成的数据验证其正确性。推导前缀和公式在纸上推导total_sum_of_blocks(m)的公式。如果不记得公式可以在预处理时用循环计算前一部分的完整段和并存到数组里用空间换时间。对于m很大的情况再想办法用公式。整合并测试将定位、前缀和计算、区间查询整合起来用多组数据测试包括边界数据如l1, r1; lr最大值等。思考优化尝试用直接解方程法替代二分查找并比较两者的精度和效率。这道“123”题看似简单实则融合了数学观察、公式推导、二分查找、前缀和、防溢出处理等多个基础且重要的知识点。它在蓝桥杯中出现很好地考察了选手将实际问题抽象化、数学化并运用基础算法高效解决的能力。希望通过这篇详细的拆解能让你不仅会做这道题更能掌握解决这一类“特殊序列区间求和”问题的通用思维框架。在实际编码中多考虑边界和溢出多构造测试数据验证这些习惯比单纯解出一道题更重要。
返回列表