Day1
二分小技巧
这段代码是二分答案中查找最大可行值(即“最大的最小值”)的经典模版。
很多初学者会被 (l + r + 1) >> 1 里的这个 +1 搞晕。我为你深度剖析一下为什么要这么写,以及如何避坑。
1. 核心矛盾:死循环
在二分搜索中,当搜索区间只剩下两个相邻的数(比如 l = 2, r = 3)时,最容易发生死循环。
-
如果不加 1:
mid = (2 + 3) >> 1 = 2。 -
执行程序:如果
check(2)为真,执行l = mid,结果l还是 2。 -
下一轮循环:
l还是 2,r还是 3,mid还是 2…… 死循环了! -
如果加了 1:
mid = (2 + 3 + 1) >> 1 = 3。 -
执行程序:如果
check(3)为真,执行l = mid,结果l变成 3,循环结束(因为l < r不再成立)。 -
执行程序:如果
check(3)为假,执行r = mid - 1,结果r变成 2,循环结束。
总结:只要你的分支里出现了 l = mid,为了让区间能收缩,mid 就必须向上取整,也就是 (l + r + 1) / 2。
2. 二分答案的两套“标准模版”
为了不搞混,你可以直接记住这两套对应的组合,像配对一样:
模版 A:找最大的可行值(本题所用)
用于:最大的最小值、尽可能大、最后一次出现的位置。
- 计算:
mid = (l + r + 1) >> 1;(向上取整) - 判断:
if (check(mid)) l = mid; // mid 行,答案可能是 mid 或更大 else r = mid - 1; // mid 不行,答案一定比 mid 小
模版 B:找最小的可行值
用于:最小的最大值、尽可能小、第一次出现的位置。
- 计算:
mid = (l + r) >> 1;(向下取整) - 判断:
if (check(mid)) r = mid; // mid 行,答案可能是 mid 或更小 else l = mid + 1; // mid 不行,答案一定比 mid 大
3. 如何在考试时快速判断?
你不需要死记硬背,只需要在写完 if 分支后,看一眼:
- 如果你写的是
l = mid,为了防止l停在原地,mid必须往r的方向推一把,所以要+1。 - 如果你写的是
r = mid,为了防止r停在原地,mid必须往l的方向靠,所以 不用加 1。
4. 关于 inf 的小建议
在你的原代码中:int r = inf;
虽然这题坐标范围大,但其实 r 的初始值设为坐标的最大差值即可,例如:
int r = a[n] - a[1];
这样可以稍微减少几次循环次数,虽然对 AC 影响不大,但逻辑上更严密。
你现在掌握了这个 +1 的原理,就避开了二分搜索里最大的坑!
const &小技巧
在 C++ 中,这种写法 check(int x, const vector<int>& a) 是非常专业且标准的方法。
我为你拆解一下这个参数写法背后的三个关键知识点:
1. & (引用传递):性能优化的关键
如果你写成 check(int x, vector<int> a)(没有 &),程序在每次调用 check 函数时,都会把整个 vector 完整地复制一遍。
- 后果:如果 \(N=10^5\),二分查找大约调用 30 次
check,总共会产生 300 万次整数复制。这会导致程序运行变慢,甚至超时(TLE)。 - 作用:加上
&后,函数直接操作原数组的“别名”,零复制成本,速度极快。
2. const:安全保障
由于我们使用了引用传递,函数内部理论上是可以修改原数组 a 的。
- 作用:加上
const后,编译器会强制保证:这个函数只能读取a的内容,绝对不能修改它。 - 好处:这在大型项目中非常重要,能防止在逻辑复杂的函数里不小心改动了原始数据(比如误写了
a[i] = x)。
3. vector<int> 作为参数的完整逻辑
由于你在 solve 里读取了 \(N\),所以 check 函数现在需要知道两个东西:
- 正在测试的距离 \(x\)。
- 存储坐标的数组 \(a\)。
完整的函数实现如下:
// 这里的 const vector<int>& a 既快又安全
bool check(int x, const vector<int>& a) {
int num = 1; // 第一头牛放在第一个位置
int last_pos = a[1]; // 记录上一头牛的位置
// 注意:这里需要知道 n。如果 n 是全局变量直接用,
// 如果不是,建议参数写成 (int x, const vector<int>& a, int n)
for (int i = 2; i < a.size(); i++) { // a.size() 可以自动获取长度
if (a[i] - last_pos >= x) {
num++;
last_pos = a[i];
}
}
return num >= m; // m 一般建议也定义为全局或作为参数传入
}
总结
当你看到 const Type& name 这种写法时,脑子里可以翻译为:
“我要用这个数据,但我为了省时间不想复制它,而且我保证不会把它弄乱。”
这是 C++ 高手最常用的参数传递方式,推荐你以后处理 vector、string 等大数据类型时都这样写。
这是一个非常深入本质的问题。简单直接的回答是:修改“别名”本质上就是直接修改那个内存地址上的数据。
在计算机底层,“变量名”和“内存地址”其实是绑定的。为了让你彻底理解,我们可以从以下三个层面剖析:
1. 变量名到底是什么?
在编译器看来,变量名(比如 int x)只是一个助记符。
- 当你定义
int x = 10;时,编译器在内存里找了一个地址(假设是0x1001),并把x这个名字和0x1001关联起来。 - 修改变量
x,其实就是往0x1001这个地址写数据。
2. 别名(引用)的本质
当你写 int &y = x; 时,你并没有开辟新的内存空间。
- 你只是告诉编译器:“
y也是地址0x1001的名字。” - 现在,
x和y指向的是同一个物理内存空间。
所以,回答你的疑问:
- 修改变量和修改内存地址在底层是同一件事。
- 当你通过别名
y修改数据时,指令会直接指向0x1001这个地址,把里面的值改掉。
3. 用“便利贴”类比(直观理解)
想象你有一个盒子(这就是内存地址),盒子里面装着一个苹果。
- 你在盒子上贴了一张标签写着“我的午餐”(这是变量名
x)。 - 你又在同一个盒子上贴了第二张标签写着“圆形的食物”(这是别名
y)。 - 操作: 你通过“圆形的食物”这个标签,把盒子里的苹果换成了梨。
- 结果: 当你去看“我的午餐”时,里面也变成了梨。
结论: 你并没有修改标签(变量名)本身,你通过标签直接伸进盒子(内存地址)里修改了内容。
4. 别名 vs 指针(底层区别)
虽然别名修改的是内存,但它和指针在操作上有一点点细微的区别:
- 指针 (
int *p): 指针是一个独立的变量,它自己占用 8 字节内存,里面存的是别人的地址。修改指针指向的数据,需要先读取指针自己的值,再顺着地址去找目标。 - 别名 (
int &y): 别名在编译后,通常直接被替换为它所代表的那个地址。它不是一个独立的变量,它就是原变量的“化身”。
总结
当你写 a[i] - a[now] 或者修改 a[i] 时:
- 如果
a是引用(vector<int> &a),CPU 发出的指令是:“去数组a所在的那个原始内存位置读/写数据。” - 由于没有中间的复制过程,指令直接作用于原始内存。
这就是为什么引用传递(&)能实现“零拷贝”且效率极高的根本原因。
DFS剪纸小技巧
这是一个非常经典的问题!这两种写法在功能上是完全等价的,但在代码逻辑思维和坑点上有着显著的区别。
简单来说:
- 写在外面(开头):叫“进门安检”。不管谁来了,先放进函数里,然后第一件事就是检查合不合格,不合格就踢出去。
- 写在里面(循环内):叫“三思后行”。在准备把这一步迈出去之前,先看一眼前面能不能走,能走才迈步(才调用函数)。
这道题当然可以写在 for 循环里面。
下面我用生动的例子和代码对比来给你讲讲它们的区别。
方式一:进门安检(推荐写法,写在外面)
这是我刚才帮你改的写法。它的逻辑是:“我不管三七二十一,先递归进去,进去之后发现不对劲(越界、撞墙)我再 return。”
- 特点:函数开头全是
if判断。 - 代码样子:
void DFS(int x, int y) { // 【进门安检】 // 1. 检查是不是越界了?是就滚 if (x < 1 || x > n || y < 1 || y > m) return; // 2. 检查是不是墙或者走过了?是就滚 if (mp[x][y] == -1 || vis[x][y]) return; // --- 安检通过,正式办事 --- vis[x][y] = 1; // 标记 if (x == n && y == m) { flg = 1; return; } // 到终点了 for (int i = 0; i < 4; i++) { // 【无脑递归】不管下一个点是啥,先丢进函数里再说 DFS(x + dx[i], y + dy[i]); } } - 优点:
- 逻辑统一:无论是起点
(1,1)还是中间的点,都通过这一套逻辑检查。 - 代码整洁:
for循环里很干净。
- 逻辑统一:无论是起点
- 缺点:会有一次多余的函数调用(比如撞墙了,还是调用了函数,只是进去马上出来了),但对现代计算机来说这不算啥。
方式二:三思后行(写在循环里面)
这是你想要问的写法。它的逻辑是:“我在迈出下一步之前,先检查那一步是不是坑。如果是坑,我就不跳;如果是路,我再跳(递归)。”
- 特点:
for循环里逻辑很长,DFS函数开头不需要判断越界。 - 代码样子:
void DFS(int x, int y) { // 进来的时候,说明 (x,y) 肯定是合法的(因为父节点检查过了) vis[x][y] = 1; // 直接标记 if (x == n && y == m) { flg = 1; return; } for (int i = 0; i < 4; i++) { int xx = x + dx[i]; int yy = y + dy[i]; // 【三思后行】 // 在调用 DFS 之前,把所有坏情况都排除掉 if (xx >= 1 && xx <= n && yy >= 1 && yy <= m && // 检查越界 mp[xx][yy] != -1 && // 检查墙 vis[xx][yy] == 0) // 检查是否走过 { DFS(xx, yy); // 确认安全了,才敢调用 } } } - 优点:
- 效率稍微高极其微小的一丢丢(少了一次无效函数调用)。
- 缺点(大坑!!!):
- 谁来检查起点? 因为所有检查都在“下一步”之前,那么第一个点
(1,1)谁来检查? - 如果你用这种写法,你必须在
main函数里保证起点是安全的,或者手动检查一次起点。如果题目给你一个起点就是墙的数据,第一种写法能直接 return,第二种写法可能一上来就标记了墙,或者需要额外代码处理。
- 谁来检查起点? 因为所有检查都在“下一步”之前,那么第一个点
演示:如果把这道题改成“判断写在循环里”
如果你坚持要把判断写在循环里,代码是完全可行的,如下:
void DFS(int x, int y) {
if (flg) return; // 剪枝
// 注意:这里不需要判断 x,y 是否越界/撞墙
// 因为能进到这个函数,说明上一层已经检查过它是安全的了
vis[x][y] = 1; // 直接标记
if (x == n && y == m) {
flg = 1;
return;
}
for (int i = 0; i < 4; i++) {
int xx = x + dx[i];
int yy = y + dy[i];
// 【核心区别】把判断全部移到这里
// 只有满足所有条件,才进行递归
if (xx >= 1 && xx <= n && yy >= 1 && yy <= m && // 不越界
mp[xx][yy] != -1 && // 不是墙
vis[xx][yy] == 0) // 没走过
{
DFS(xx, yy);
}
}
}
int main() {
// ... 输入省略 ...
// 【大坑注意】
// 使用这种写法时,必须确保起点本身是合法的!
// 如果起点是墙,或者起点越界,这里直接调用 DFS(1,1) 会导致逻辑错误(比如把墙标记为走过)
if (mp[1][1] != -1) {
DFS(1, 1);
}
// ... 输出 ...
}
总结
| 特性 | 方式一:写在外面 (DFS开头) | 方式二:写在里面 (For循环内) |
|---|---|---|
| 形象比喻 | 保安:先进门,不合格赶出去 | 侦查员:先看路,路通才走 |
| 代码位置 | DFS 函数第一行 |
DFS 的 for 循环内部 |
| 对起点的处理 | 自动处理(起点不合格直接return) | 需要手动处理(Main函数要额外判断起点) |
| 代码复杂度 | 简单清晰 (推荐) | 循环内条件太长,容易写错 |
| 适用场景 | 绝大多数 DFS/BFS 题目 | 极度追求性能优化或特殊回溯逻辑 |
建议:作为初学者或者在打比赛时,永远优先选择“写在外面”。因为这样你就不用担心“起点是不是墙”、“起点是不是已经越界”这种边缘情况,函数会帮你自动处理好,不容易出错。

浙公网安备 33010602011771号