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

资讯详情

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

二分查找实战:从蓝桥杯国赛题看无限序列的数学建模与高效查询

二分查找实战:从蓝桥杯国赛题看无限序列的数学建模与高效查询 1. 项目概述从一道蓝桥杯国赛题看二分查找的实战艺术“123”这道题是2021年蓝桥杯软件类国赛C/C/Java组的一道经典题目。乍一看标题“123”你可能会觉得一头雾水这能是什么难题但当你真正读题后会发现它巧妙地绕了一个弯将看似简单的数字序列与高效的查找算法紧密结合核心考点直指二分查找。这道题的价值远不止于解出它本身。它像一块试金石能检验你是否真正理解了二分法的精髓——不仅仅是会写一个在有序数组里找数的模板而是懂得如何将其应用于更抽象、更复杂的“问题空间”转换。很多选手在平时练习时对二分法感觉良好但一遇到这种需要自己构造“有序性”和“判定条件”的题目就容易卡壳。今天我们就来彻底拆解这道题不仅告诉你答案怎么写更要讲清楚为什么要这么做以及如何将这种“二分思维”迁移到其他场景中。简单来说题目定义了一个无限长的数字序列1, 1,2, 1,2,3, 1,2,3,4, ...。它的构造规则是依次写下以1开头的正整数序列第i组就是1, 2, ..., i。然后题目会进行T次询问每次给一个数字l和r要求你计算这个序列中从第l个数字到第r个数字的和。l和r的范围可以非常大典型数据范围在10^12级别。暴力模拟序列生成再求和在如此巨大的数据范围面前连存储序列都不可能更别说计算了。因此我们必须找到这个序列的数学规律并利用二分查找来快速定位任意位置l和r所在的“组”以及组内的具体位置从而在常数或对数时间内完成求和。这整个过程就是一个完整的“问题建模 - 数学抽象 - 算法设计 - 边界处理”的实战演练。2. 核心思路拆解如何将无限序列问题转化为二分查找面对“123”这道题直接处理序列是不现实的。我们的首要任务是将原问题转化为一个可以通过计算和快速查找来解决的模型。这个转化过程分为几个关键步骤。2.1 序列的数学建模与前缀和思想首先我们需要用数学语言来描述这个序列。定义整个序列为S。第i组子序列为[1, 2, 3, ..., i]其长度为i其和为i * (i 1) / 2等差数列求和公式。那么整个序列S就是这些子序列的拼接[1], [1,2], [1,2,3], [1,2,3,4], ...为了快速得到序列中某个位置pos从1开始计数的数字或者计算一段区间的和我们引入前缀和的思想。不过这里需要两层前缀和第一层前缀和cnt[i]表示前i组子序列总共包含多少个数字即序列总长度。计算公式cnt[i] 1 2 3 ... i i * (i 1) / 2。cnt[i]是一个关于i的单调递增函数。第二层前缀和sum[i]表示前i组子序列所有数字的总和。计算公式sum[i] 1 (12) (123) ... (12...i)。我们知道第k组的和是k*(k1)/2所以sum[i] Σ_{k1}^{i} [k*(k1)/2] (1/2) * Σ_{k1}^{i} (k^2 k)。利用平方和公式Σk^2 n(n1)(2n1)/6和等差数列和公式Σk n(n1)/2可以推导出sum[i] i * (i1) * (i2) / 6。这个公式是解题的关键它允许我们在 O(1) 时间内计算出前i组的总和。有了cnt[i]和sum[i]我们的目标就清晰了对于给定的位置pos我们要找到最小的i使得cnt[i] pos。这个i就是pos这个位置所在的组号。找到组号i后我们就能确定pos在该组中的具体位置从而知道这个位置上的数字是多少进而计算区间和。2.2 二分查找的引入与“有序性”判定为什么这里必须用二分查找因为我们需要解决的子问题是给定一个位置pos快速找到它所在的组号i。回顾我们拥有的信息cnt[i] i*(i1)/2是单调递增的。这意味着随着组号i的增大序列的总长度cnt[i]也严格增大。这构成了一个有序序列[cnt[1], cnt[2], cnt[3], ...]。我们的目标是在这个有序的“虚拟数组”cnt中查找第一个大于等于pos的cnt[i]所对应的i。这正是二分查找算法最典型的应用场景之一在有序序列中寻找第一个满足某个条件的元素的位置。这里“条件”就是cnt[mid] pos。我们通过计算cnt[mid]的值O(1)时间与pos比较就能判断mid所在的组是否已经包含了位置pos。如果cnt[mid] pos说明答案组号i可能在mid或其左侧我们令查找范围的高位r mid否则说明mid组还没到pos答案一定在右侧令低位l mid 1。通过大约log2(10^12) ≈ 40次这样的计算和比较我们就能从可能高达10^12的组号范围中精准定位到目标组号。这正是二分查找将时间复杂度从线性O(n)降至对数O(log n)的威力所在。注意虽然组号i可能很大但我们并不需要预先生成cnt数组。因为cnt[i]有直接的数学公式我们可以在二分查找的过程中动态地计算任何一个mid对应的cnt[mid]值。这体现了“空间换时间”思维的另一面有时清晰的数学规律本身就能替代庞大的预存储数组。2.3 计算区间和的具体步骤假设我们已经有了一个函数get_pos_info(pos)它能返回位置pos所在的组号group_id以及它在该组中是第几个元素offsetoffset从1开始即该位置的数字值就是offset。那么对于查询区间[l, r]的和我们可以分三步计算计算l所在组之前的所有组的数字和sum[group_id_l - 1]。计算l到其所在组结尾的和这是一个等差数列的部分和。l在其组中是第offset_l个元素那么从它到该组结尾共group_id_l个元素的和是(offset_l group_id_l) * (group_id_l - offset_l 1) / 2。同理计算r所在组开头到r的和。如果l和r在同一组计算就更简单直接是(offset_l offset_r) * (offset_r - offset_l 1) / 2。如果不在同一组还需要加上中间完整的所有组的和sum[group_id_r - 1] - sum[group_id_l]。最终区间[l, r]的和 第2步的结果 第5步的结果如果存在 第3步的结果。而所有这些计算其核心依赖就是通过二分查找快速得到的group_id和offset。3. 关键实现细节与二分查找的编码要点思路清晰后实现就成了细节的战场。二分查找虽然思想简单但写出正确、高效、无bug的代码却需要格外小心。下面我们深入每个环节。3.1 二分查找边界的确定与数学估算我们首先要确定二分查找的上下界[l, r]。l显然是1。r应该设为多少我们需要找到一个足够大的组号i_max使得cnt[i_max]肯定大于题目可能给出的最大pos即r的最大值通常是10^12量级。由cnt[i] i*(i1)/2 pos我们可以近似认为i^2 ≈ 2 * pos。对于pos_max 10^12i ≈ sqrt(2 * 10^12) ≈ 1.414 * 10^6。为了保险起见我们通常会将上界r设置为2 * 10^6或者更大一些比如2e6。在竞赛中为了绝对安全有时会直接设为一个很大的数如2e9但由于我们的二分查找是O(log n)的即使上界很大查找次数也只是从40次增加到60次左右完全在可接受范围内。一个更稳健的做法是根据公式解一个宽松的上界i*(i1)/2 10^12解得i大约为1.5e6因此将上界设为2e6是一个合理且安全的选择。3.2 二分查找模板的选择与死循环规避二分查找的代码实现有几个经典模板区别主要在于循环条件while(l r)还是while(l r)和边界更新方式r mid还是r mid - 1。对于“寻找第一个大于等于目标值的位置”这类问题我推荐使用以下模板它不易出错且逻辑清晰long long find_group(long long pos) { long long l 1, r 2e6; // 一个足够大的上界 while (l r) { long long mid l (r - l) / 2; // 防止溢出 if (cnt(mid) pos) { // cnt(mid) 是计算 i*(i1)/2 的函数 r mid; // mid 满足条件答案可能是 mid 或更小 } else { l mid 1; // mid 不满足条件答案一定更大 } } return l; // 循环结束时 l r即为答案 }这个模板的关键点循环条件while (l r)当搜索区间[l, r]内不止一个元素时继续查找。结束时l r指向唯一可能的位置。中间值取法mid l (r - l) / 2这是标准的取左中位数写法能有效避免(l r) / 2可能导致的整数溢出。在l和r都是大整数时尤为重要。边界更新当cnt(mid) pos时说明mid可能就是我们找的答案或者答案在左边所以将右边界r更新为mid。否则答案一定在mid的右边所以左边界l更新为mid 1。最终返回值l或r均可因为它们相等。这个模板保证了每次循环搜索区间都会缩小并且不会跳过可能的解从而避免了死循环。实操心得很多初学者在这里容易混淆r mid和r mid - 1。记住一个原则如果你检查mid后确定mid不可能是答案那么更新时可以1或-1将其排除如果你不能确定mid不是答案它可能正是我们要找的“第一个”那么更新时就应该包含mid。在本例中当cnt(mid) pos时mid有可能是第一个满足条件的所以r要更新为mid而不是mid-1。3.3 位置解析函数get_pos_info的实现这个函数是核心工具输入位置pos输出组号group_id和组内偏移offset。// 辅助函数计算前i组的总长度 long long cnt(long long i) { return i * (i 1) / 2; } // 辅助函数计算前i组的总和 long long sum(long long i) { return i * (i 1) * (i 2) / 6; } pairlong long, long long get_pos_info(long long pos) { long long group_id find_group(pos); // 使用上述二分查找 long long prev_cnt cnt(group_id - 1); // 前 group_id-1 组的总长度 long long offset pos - prev_cnt; // 在当前组中的位置从1开始 return {group_id, offset}; }这里offset的值直接就是序列中该位置上的数字。因为第group_id组的内容是1, 2, ..., group_id组内第offset个元素的值就是offset。3.4 区间求和函数calc_sum的实现这是最后一步将前面的模块组装起来。我们需要处理l和r在同一组和不同组的情况。long long calc_sum(long long l, long long r) { auto [g_l, o_l] get_pos_info(l); auto [g_r, o_r] get_pos_info(r); if (g_l g_r) { // 同一组直接计算等差数列部分和 long long n o_r - o_l 1; return (o_l o_r) * n / 2; } else { // 不同组分为三部分 long long part1 0, part2 0, part3 0; // part1: l 到其所在组结尾 long long end_l g_l; // 第g_l组的最后一个数字是 g_l part1 (o_l end_l) * (end_l - o_l 1) / 2; // part2: 中间完整的组 (g_l1 到 g_r-1) part2 sum(g_r - 1) - sum(g_l); // 利用前缀和公式O(1)计算 // part3: r 所在组开头到 r part3 (1 o_r) * o_r / 2; // 第g_r组的前o_r个数字和 return part1 part2 part3; } }注意事项在计算part1和part3时务必注意等差数列求和的公式。首项、末项、项数这三个量一定要对应正确。例如part1的项数是(g_l - o_l 1)而不是(g_l - o_l)因为o_l本身也占一项。这是非常容易出错的地方建议在编码时用一个小例子如l4, r10手动演算验证。4. 完整代码实现与逐行解析将上述所有模块整合并处理好输入输出就得到了完整的解决方案。下面我们以C为例展示完整代码并加以解析。#include iostream #include cmath using namespace std; typedef long long LL; // 使用 long long 防止溢出 // 计算前i组的总长度 LL cnt(LL i) { return i * (i 1) / 2; } // 计算前i组的总和 LL sum(LL i) { return i * (i 1) * (i 2) / 6; } // 二分查找找到第一个 cnt(i) pos 的组号 i LL find_group(LL pos) { LL l 1, r 2e6; // 上界设定参考了 pos_max1e12 的估算 while (l r) { LL mid l (r - l) / 2; if (cnt(mid) pos) { r mid; } else { l mid 1; } } return l; } // 获取位置pos的信息组号group组内偏移offset即该位置的值 pairLL, LL get_pos_info(LL pos) { LL group find_group(pos); LL prev_cnt cnt(group - 1); LL offset pos - prev_cnt; // offset ∈ [1, group] return {group, offset}; } // 计算区间[l, r]的和 LL calc_sum(LL l, LL r) { auto [g_l, o_l] get_pos_info(l); auto [g_r, o_r] get_pos_info(r); if (g_l g_r) { // 同一组内 LL n o_r - o_l 1; return (o_l o_r) * n / 2; } else { // 不同组分三部分计算 LL part1 0, part2 0, part3 0; // 1. l到其所在组结尾 part1 (o_l g_l) * (g_l - o_l 1) / 2; // 2. 中间完整的组 part2 sum(g_r - 1) - sum(g_l); // 3. r所在组开头到r part3 (1 o_r) * o_r / 2; return part1 part2 part3; } } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin T; while (T--) { LL l, r; cin l r; cout calc_sum(l, r) \n; } return 0; }逐行解析与技巧说明typedef long long LL;这是一个好习惯。题目中l和r可达10^12计算过程中如i*(i1)*(i2)会超过int范围必须使用long long。用LL别名使代码更简洁。cnt和sum函数直接使用推导出的公式O(1)时间复杂度。注意sum公式中的除法/6在整数运算中可能会产生小数但由于i*(i1)*(i2)一定是6的倍数三个连续整数中必有一个是2的倍数、一个是3的倍数所以整数除法结果是精确的。find_group函数中的上界2e6这是基于pos最大为10^12的估算。cnt(2e6) 2e6 * 2e6 / 2 ≈ 2e12远大于1e12足够安全。你也可以设置为1.5e6或2e6附近的数。get_pos_info中的prev_cnt计算cnt(group-1)得到前group-1组的总长度。pos - prev_cnt就得到了在当前组内的位置。calc_sum中的auto [g_l, o_l] ...这是C17的结构化绑定可以方便地获取pair的两个元素。如果编译器不支持可以改用tie(g_l, o_l) get_pos_info(l);。主函数中的输入输出优化ios::sync_with_stdio(false);和cin.tie(nullptr);是关闭C流与C标准流的同步解绑cin和cout可以显著加快大量数据输入输出的速度是竞赛编程的常用技巧。整体时间复杂度每次查询calc_sum会进行两次二分查找O(log N)以及若干次O(1)计算。对于T次查询总复杂度为O(T log N)其中N是二分上界约2e6log N约等于21效率极高。5. 常见问题、调试技巧与思维拓展即使理解了算法在实现和调试时也可能遇到各种问题。下面记录一些典型的坑和解决思路。5.1 整数溢出问题这是此类题目中最常见、最隐蔽的错误。错误可能发生在中间计算结果溢出例如cnt(i)函数中i*(i1)/2当i接近2e6时i*(i1)约为4e12这在long long通常最大约9e18范围内是安全的。但如果你错误地使用了int或者在其他地方进行了更复杂的计算如i*i*i就很容易溢出。二分查找中的溢出mid (l r) / 2在l和r都很大时l r可能溢出。因此必须使用mid l (r - l) / 2。排查方法在关键计算步骤后添加调试输出打印中间变量的值看是否出现负数或异常巨大的正数溢出回绕。对于C可以使用cout “i” i “, cnt” cnt(i) endl;来监控。5.2 二分查找的死循环与边界错误死循环通常是由于while循环条件和l、r的更新方式不匹配造成的。坚持使用while (l r)和mid l (r - l) / 2的模板并仔细考虑更新逻辑可以避免绝大多数死循环。边界错误例如寻找第一个 pos的组结果却找到了最后一个 pos的组。这通常是因为判断条件if (cnt(mid) pos)写反了或者更新语句r mid/l mid 1用错了。调试技巧编写一个简单的测试函数用小数据例如pos1到20手动模拟二分查找过程单步跟踪l,r,mid和判断条件的变化与手工计算的结果对比。5.3 区间求和公式验证calc_sum函数中的公式相对复杂容易在项数、首项末项上出错。验证方法构造几个典型的测试用例用暴力方法模拟生成一小段序列计算出结果与你的calc_sum函数结果对比。用例1l1, r1。结果应为1。用例2l1, r3。序列1, 1,2。和为1124。用例3l2, r5。序列1, 2, 1,2,3。从第2个到第5个是1, 2, 1, 2。和为6。用例4跨组用例l4, r10。可以手工分段计算验证。5.4 思维拓展二分查找的本质与应用迁移解完这道题我们不应该只停留在AC。更要思考其背后的模式“123”题本质上提供了一种将无限序列上的位置查询转化为对单调函数求逆的范例。核心模式有一个无限或极大的序列其构造规则已知。序列的某种累积属性如长度前缀和cnt[i]可以表示为一个关于索引i的单调递增函数f(i)。我们需要解决与原序列位置相关的问题如求某个位置的元素或某段区间的和。通过二分查找在i的空间上求解f(i) pos从而将“位置pos”映射回“索引i”。利用索引i和函数f结合序列规则解决原问题。许多题目都符合这个模式例如求第N个数字LeetCode 400数字序列 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ...求第n位是什么数字。这里需要先确定n落在几位数的区间里这可以通过二分查找“位数区间总长度前缀和”来实现。完全平方数序列序列是 1, 4, 9, 16, ... 求第k个是什么或者前k个的和这里f(i) i^2是单调的。分拆数相关的序列只要你能找到描述序列某个宏观属性的单调函数二分查找就可能派上用场。最后的建议二分查找的难点从来不是代码模板而是**发现“有序性”和构造“判定条件”**的能力。在遇到复杂问题时多问自己有没有一个量是随着另一个量单调变化的我能否通过判断这个量来缩小解的范围这种思维的锻炼比刷十道模板题更有价值。
返回列表