☰
算法入门必练:二分、前缀和、双指针等简单模板的边界处理与实战技巧
2026/10/24 1:20:34 网站建设 项目流程

1. 为什么"简单模板"反而是算法入门最该先啃的硬骨头

很多人第一次接触算法学习,看到"简单算法模板"这几个字,第一反应是"简单嘛,随便看看就行"。我带过不少刚入门的朋友,十个里有八个栽在这个心态上。他们觉得二分、前缀和、双指针这些东西太基础,急着去啃动态规划、图论、线段树,结果刷题时连一个"在有序数组里找目标值"都能写出死循环,边界条件调半小时调不出来。

这里有个反直觉的结论:算法竞赛和笔试里,真正拉开差距的从来不是你会不会写高难度算法,而是这些"简单模板"你能不能一次写对、写稳、写快。一场两小时的比赛,简单题和中档题占了七成以上分数,而这些题目的核心骨架,几乎全是二分、前缀和、差分、双指针、离散化、位运算这些基础模板。你把它们练到肌肉记忆,等于把七成分数先揣进兜里。

所谓"模板",不是让你背代码,而是让你掌握一类问题的固定思考框架和固定代码骨架。比如看到"单调性"就条件反射想到二分,看到"区间求和"就想到前缀和,看到"区间修改单点查询"就想到差分。这种条件反射,才是模板真正的价值。

这篇文章我会把算法基础课里最核心的几类简单模板拆开讲透:每一类都告诉你它解决什么问题、代码骨架长什么样、边界怎么处理、我踩过哪些坑。适合刚入门算法、准备笔试面试、或者刷题总是卡在边界上的朋友。不需要你有很深的数学基础,但需要你愿意动手把每段代码自己敲一遍、跑一遍。

提示:模板一定要自己手敲,不要复制粘贴。复制来的代码你记不住边界,手敲三遍以上才会形成条件反射。

2. 二分查找:看似最简单,实则边界最容易翻车

2.1 二分的本质是"在一个具有单调性的答案空间里做排除"

很多人把二分理解成"在有序数组里找数",这个理解太窄了。二分的真正本质是:当答案空间具有单调性(或者说存在一个分界点,分界点一侧满足条件、另一侧不满足),我们就可以每次排除一半,把复杂度从 O(n) 降到 O(log n)。

这个理解一旦建立,你就能明白为什么二分能用在"求平方根""求最小的最大値""求第一个满足条件的下标"这些看起来跟数组无关的问题上。它们本质上都是在找一个单调分界点。

二分最常见的两种写法,一种是"找精确值",一种是"找边界"(第一个满足条件的位置 / 最后一个满足条件的位置)。前者简单,后者才是重灾区。我见过太多人写"找左边界"的时候,循环条件、mid 更新、返回值三处里必错一处。

2.2 整数二分的两套骨架,背下来能省你半小时调试

先看最经典的"查找目标值是否存在":

int l = 0, r = n - 1; while (l <= r) { int mid = l + (r - l) / 2; // 防止 (l+r) 溢出 if (a[mid] == target) return mid; else if (a[mid] < target) l = mid + 1; else r = mid - 1; } return -1;

这段代码的关键点有三个。第一,mid = l + (r - l) / 2而不是(l + r) / 2,因为当 l 和 r 都接近 int 上限时,相加会溢出成负数,导致死循环或者数组越界。这个坑我在一次笔试里真实踩过,数组长度不大但下标计算用了 long 的边界值,直接 WA。第二,循环条件是l <= r,因为区间是闭区间[l, r],当 l == r 时还有一个元素没检查。第三,更新时是mid + 1和mid - 1,因为 mid 已经检查过了,可以排除。

再看"找左边界"(第一个 >= target 的位置),这是最容易写错的:

int l = 0, r = n; // 注意 r 取 n,区间是 [l, r) while (l < r) { int mid = l + (r - l) / 2; if (a[mid] >= target) r = mid; // 答案在左半边(含 mid) else l = mid + 1; } return l; // l == r,就是第一个 >= target 的位置

这套写法的精髓在于区间定义成左闭右开[l, r),循环条件是l < r,当a[mid]满足条件时,答案可能是 mid 也可能是更左边,所以r = mid(注意不是 mid - 1,因为 mid 本身可能是答案);不满足时答案一定在 mid 右边,所以l = mid + 1。最后 l 和 r 相遇,返回 l 即可。

我个人的经验是:统一用左闭右开区间写所有二分,包括找右边界。找右边界(最后一个 <= target 的位置)可以转化成"找第一个 > target 的位置再减一",这样你只需要记住一套骨架,出错概率大幅下降。

2.3 浮点二分:循环次数比精度判断更靠谱

浮点二分很多人喜欢写while (r - l > 1e-8),但浮点数比较本身就有精度问题,有时候会死循环。更稳的做法是直接固定循环 100 次:

double l = 0, r = 1e9; for (int i = 0; i < 100; i++) { double mid = (l + r) / 2; if (check(mid)) l = mid; else r = mid; } // 此时 l 和 r 已经非常接近,输出 l 即可

为什么是 100 次?因为每次区间缩小一半,100 次后区间长度是初始的 2 的负 100 次方,远远超过 double 的精度极限(约 15 到 16 位有效数字)。固定次数既避免了精度判断的坑,又保证了结果足够精确。这个技巧我在做"求方程根""求最优比率"这类题时一直在用,非常稳。

注意:二分的前提是单调性。写二分之前先问自己一句"这个问题的答案空间是不是单调的",如果不是,二分直接不成立,别硬套。

3. 前缀和与差分:一对互为逆运算的区间处理利器

3.1 前缀和解决"静态区间求和",一次预处理换 O(1) 查询

前缀和的思想特别朴素:用一个数组s,其中s[i]表示原数组前 i 个元素的和。这样任意区间[l, r]的和就是s[r] - s[l-1],一次减法搞定。

// 预处理,下标从 1 开始更方便 s[0] = 0; for (int i = 1; i <= n; i++) s[i] = s[i-1] + a[i]; // 查询 [l, r] 的和 int sum = s[r] - s[l-1];

下标从 1 开始是个小技巧,能避免l-1越界到 -1 的情况,代码更干净。如果题目强制从 0 开始,那就老老实实特判 l == 0。

前缀和的适用场景是数组不变、查询很多。如果数组会频繁修改,前缀和每次修改都要重新预处理 O(n),就不划算了,这时候要考虑树状数组或线段树。但笔试里大部分区间求和题都是静态的,前缀和足够。

二维前缀和也是常考点,公式是s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j],查询子矩阵(x1,y1)到(x2,y2)的和是s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1]。这个容斥原理的公式我建议你画个图推一遍,比死记硬背强得多。

3.2 差分是前缀和的逆运算,专治"区间修改"

差分解决的问题是:给区间[l, r]的每个数都加上 c,操作很多次,最后问每个位置的值。如果暴力做,每次操作 O(n),m 次操作就是 O(nm),很容易超时。差分把每次区间修改降到 O(1)。

差分数组d的定义是d[i] = a[i] - a[i-1]。对区间[l, r]加 c,只需要:

d[l] += c; d[r+1] -= c;

最后对 d 数组求一次前缀和,就还原出了修改后的 a 数组。为什么这样对?因为d[l] += c会让从 l 开始的所有前缀和都增加 c,而d[r+1] -= c又把 r+1 之后的增量抵消掉,净效果就是只有[l, r]区间增加了 c。这个"加一个再减一个"的思路,是差分最核心的直觉。

我踩过的一个坑是:差分数组要开到 n+2,因为r+1可能等于 n+1,如果只开到 n 就会越界。这个细节在题目数据卡边界的时候特别致命,我建议你养成习惯,差分数组一律多开两个位置。

3.3 前缀和与差分的组合拳:二维差分

二维差分处理的是"给一个子矩阵整体加 c"。它的更新公式是四个点:

d[x1][y1] += c; d[x1][y2+1] -= c; d[x2+1][y1] -= c; d[x2+1][y2+1] += c;

最后对 d 做二维前缀和还原。这四个点的符号是"两加两减",跟二维前缀和的容斥公式是对应的。我记这个的方法是:画一个矩形,四个角分别标上 +、-、-、+,位置对应左上、右上、左下、右下。这样不用背也能推出来。

模板解决的问题单次操作复杂度适用前提
一维前缀和静态区间求和O(1) 查询数组不变
一维差分区间加、最后求值O(1) 修改离线,最后统一求值
二维前缀和静态子矩阵求和O(1) 查询矩阵不变
二维差分子矩阵加、最后求值O(1) 修改离线

4. 双指针:把 O(n²) 暴力优化成 O(n) 的通用套路

4.1 双指针的核心是"单调性带来的指针不回退"

双指针听起来玄乎,其实核心就一句话:当右指针向右移动时,左指针只会向右移动,不会向左回退。正因为两个指针都只朝一个方向走,总移动次数是 O(n),而不是 O(n²)。

这个性质成立的前提,是问题本身具有某种单调性。最典型的例子是"在一个有序数组里找两个数,使它们的和等于目标值"。左指针指向最小,右指针指向最大,如果和偏小就左指针右移,和偏大就右指针左移。因为数组有序,这个移动方向是确定的,不会来回横跳。

int l = 0, r = n - 1; while (l < r) { int sum = a[l] + a[r]; if (sum == target) { /* 找到 */ break; } else if (sum < target) l++; else r--; }

4.2 滑动窗口:双指针里最实用的一类

滑动窗口是双指针的一个特例,专门处理"连续子数组/子串"的问题,比如"最短的包含某字符集的子串""和不超过 k 的最长子数组"。它的骨架是:

int l = 0, ans = 0; for (int r = 0; r < n; r++) { // 把 a[r] 加入窗口 while (/* 窗口不满足条件 */) { // 把 a[l] 移出窗口 l++; } // 此时窗口 [l, r] 满足条件,更新答案 ans = max(ans, r - l + 1); }

这个骨架的关键在于:右指针负责扩张,左指针负责收缩,while 循环负责维护窗口的合法性。我见过很多人把 while 写成 if,结果窗口不合法时只收缩一次,导致答案错误。记住,只要窗口不合法,就要一直收缩,所以必须是 while。

滑动窗口最容易出错的地方是"窗口维护的是什么"。比如求"无重复字符的最长子串",窗口维护的是"窗口内没有重复字符",那么加入新字符后如果重复了,就要一直移动左指针直到不重复。这个"维护什么"想清楚了,代码自然就出来了。

4.3 双指针的常见变体和适用判断

双指针不只有"左右夹逼"和"滑动窗口"两种。还有一类是"快慢指针",比如判断链表是否有环、找链表中点。快指针每次走两步,慢指针每次走一步,如果有环它们必然相遇。这个技巧在链表题里出现频率极高。

判断一道题能不能用双指针,我的经验是看两点:一是问题是否涉及"两个位置"或"一段连续区间";二是移动指针时,答案是否具有单调性。如果两个条件都满足,双指针大概率能用,而且能把暴力 O(n²) 降到 O(n)。

提示:双指针的题目,先在纸上模拟一遍指针移动过程,确认指针不会回退,再动手写代码。这一步能帮你避免大量逻辑错误。

5. 离散化与位运算:两个容易被忽视但极其好用的模板

5.1 离散化:把稀疏的大范围映射到紧凑的小范围

离散化解决的问题是:数据范围很大(比如到 1e9),但实际用到的数据点很少(比如只有 1e5 个)。这时候如果开一个 1e9 的数组会爆内存,但我们可以把这些用到的点映射到 1 到 n 的连续下标上。

离散化的标准三步是:排序、去重、二分查找映射位置。

vector<int> xs; // 收集所有用到的坐标 sort(xs.begin(), xs.end()); xs.erase(unique(xs.begin(), xs.end()), xs.end()); // 查询某个值 x 映射后的下标(从 1 开始) int idx = lower_bound(xs.begin(), xs.end(), x) - xs.begin() + 1;

这里unique只是把重复元素移到末尾并返回新的逻辑末尾,真正的删除要靠erase,这个组合是固定写法,直接背下来。lower_bound返回第一个 >= x 的位置,正好对应离散化后的下标。

离散化最常见的应用场景是"值域很大但点数不多的区间操作",比如"给若干区间加值,最后求某点的值",配合差分使用。我一开始总觉得离散化麻烦,后来发现它其实就是"把稀疏坐标压缩",理解了这一点就没什么神秘的了。

5.2 位运算:几个必须刻进 DNA 的常用技巧

位运算的模板不多,但每一个都极其高频,值得单独拎出来记。

判断奇偶用x & 1,比x % 2快(虽然现代编译器会优化,但习惯上还是用位运算)。取最低位的 1 用x & (-x),这个在树状数组里是核心。判断是不是 2 的幂用x > 0 && (x & (x-1)) == 0,因为 2 的幂二进制只有一个 1,减一后所有低位变 1,与运算结果为 0。

枚举一个集合的所有子集,用for (int sub = mask; sub; sub = (sub - 1) & mask),这个技巧在状压 DP 里出现频率极高。它的原理是每次把最低位的 1 去掉,同时保证 sub 始终是 mask 的子集。

// 枚举 mask 的所有非空子集 for (int sub = mask; sub; sub = (sub - 1) & mask) { // 处理子集 sub }

还有一个常用的:x >> k & 1取 x 的第 k 位(从 0 开始数)。以及x | (1 << k)把第 k 位置 1,x & ~(1 << k)把第 k 位清 0。这几个操作组合起来,能解决大部分位运算题。

位运算技巧写法典型用途
判断奇偶x & 1替代取模
取最低位 1x & (-x)树状数组
判断 2 的幂x & (x-1)快速判断
枚举子集sub = (sub-1) & mask状压 DP
取第 k 位x >> k & 1状态压缩

5.3 这些"小模板"为什么值得单独花时间

有人会问,离散化和位运算看起来用得不多,值得专门练吗?我的答案是值得。原因有两个:一是它们在特定题型里几乎是唯一解,不会就是不会,没有替代方案;二是它们能显著提升你代码的"密度",让你在有限时间里处理更大规模的数据。

离散化让你能处理值域 1e9 的问题,位运算让你能用整数表示集合、用常数时间完成状态操作。这两样东西,是把"能过"变成"过得漂亮"的关键。我建议你在刷完基础的前缀和、二分之后,专门花两三天把这两类模板刷熟,收益远超你的预期。

6. 把模板练成条件反射:我的刷题节奏和避坑清单

6.1 模板不是背出来的,是"默写 + 变形"练出来的

我自己的方法是:每学一个模板,先理解原理,然后合上资料默写三遍,直到不看任何提示也能一次写对。默写通过之后,再去找三道这个模板的变形题,看看模板在稍微变化的条件下的表现。

比如二分,默写通过后,我会去找"找第一个大于等于""找最后一个小于等于""在旋转数组里找最小值"这几类变形。每做一道,就回头对照模板,看看哪里需要调整。这样练下来,模板就真正长在你脑子里了,而不是停留在"我好像见过"的层面。

刷题节奏上,我建议同一类模板集中刷,不要东一榔头西一棒子。集中刷能让你的大脑快速建立这类问题的模式识别,效率比分散刷高得多。一个模板集中刷 5 到 8 道,基本就稳了。

6.2 我踩过的那些边界坑,帮你提前避开

第一个坑是数组下标越界。前缀和、差分、双指针都涉及下标加减,稍不注意就访问到 -1 或 n。我的习惯是数组一律多开几个位置,下标从 1 开始,能省掉大量特判。

第二个坑是整数溢出。二分求 mid、前缀和累加、位运算左移,都可能溢出。累加用 long long,mid 用l + (r - l) / 2,左移注意位数不要超过类型宽度。

第三个坑是循环条件写反。二分的l <= r和l < r对应不同的区间定义,混用必错。我的建议是固定一套区间定义(我习惯左闭右开),所有二分都用同一套,减少心智负担。

第四个坑是滑动窗口的 while 写成 if。前面提过,窗口不合法时要一直收缩,必须是 while。这个错误非常隐蔽,因为小数据下可能碰巧过,大数据就 WA。

6.3 从"会写模板"到"会用模板"的最后一公里

会写模板只是第一步,真正的能力是看到题目能反应过来该用哪个模板。这个能力靠的是大量"读题—判断—套模板"的循环训练。

我的做法是准备一个"题型—模板"对照表,每做一道题就记录它属于哪类模板、关键判断点是什么。积累到几十道之后,你会发现大部分题目的套路是有限的,看到题目的第一句话就能大致猜到方向。比如看到"有序数组 + 查找"就想二分,看到"区间 + 多次操作"就想差分或前缀和,看到"连续子数组 + 最值"就想滑动窗口。

这个对照表不需要多正式,一个笔记本或者一个文档就行。关键是坚持记录,让每一次刷题都变成对模板库的一次加固。等你积累到一定程度,模板就不再是"要回忆的东西",而是"看到就会用"的本能。

最后分享一个我自己的小习惯:每次比赛或笔试前,我会花十分钟把最核心的几个模板在草稿纸上默写一遍,就当热身。这个动作能让我在正式做题时手更稳,边界处理更少出错。看起来简单,但实测下来对状态的帮助很大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询