Day1

二分小技巧

这段代码是二分答案中查找最大可行值(即“最大的最小值”)的经典模版。

很多初学者会被 (l + r + 1) >> 1 里的这个 +1 搞晕。我为你深度剖析一下为什么要这么写,以及如何避坑。

1. 核心矛盾:死循环

在二分搜索中,当搜索区间只剩下两个相邻的数(比如 l = 2, r = 3)时,最容易发生死循环。

  • 如果不加 1mid = (2 + 3) >> 1 = 2

  • 执行程序:如果 check(2) 为真,执行 l = mid,结果 l 还是 2。

  • 下一轮循环:l 还是 2,r 还是 3,mid 还是 2…… 死循环了!

  • 如果加了 1mid = (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 分支后,看一眼:

  1. 如果你写的是 l = mid,为了防止 l 停在原地,mid 必须往 r 的方向推一把,所以要 +1
  2. 如果你写的是 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 函数现在需要知道两个东西:

  1. 正在测试的距离 \(x\)
  2. 存储坐标的数组 \(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++ 高手最常用的参数传递方式,推荐你以后处理 vectorstring 等大数据类型时都这样写。

这是一个非常深入本质的问题。简单直接的回答是:修改“别名”本质上就是直接修改那个内存地址上的数据。

在计算机底层,“变量名”和“内存地址”其实是绑定的。为了让你彻底理解,我们可以从以下三个层面剖析:

1. 变量名到底是什么?

在编译器看来,变量名(比如 int x)只是一个助记符

  • 当你定义 int x = 10; 时,编译器在内存里找了一个地址(假设是 0x1001),并把 x 这个名字和 0x1001 关联起来。
  • 修改变量 x,其实就是往 0x1001 这个地址写数据。

2. 别名(引用)的本质

当你写 int &y = x; 时,你并没有开辟新的内存空间。

  • 你只是告诉编译器:“y 也是地址 0x1001 的名字。”
  • 现在,xy 指向的是同一个物理内存空间

所以,回答你的疑问:

  • 修改变量修改内存地址在底层是同一件事
  • 当你通过别名 y 修改数据时,指令会直接指向 0x1001 这个地址,把里面的值改掉。

3. 用“便利贴”类比(直观理解)

想象你有一个盒子(这就是内存地址),盒子里面装着一个苹果。

  1. 你在盒子上贴了一张标签写着“我的午餐”(这是变量名 x)。
  2. 你又在同一个盒子上贴了第二张标签写着“圆形的食物”(这是别名 y)。
  3. 操作: 你通过“圆形的食物”这个标签,把盒子里的苹果换成了梨。
  4. 结果: 当你去看“我的午餐”时,里面也变成了梨。

结论: 你并没有修改标签(变量名)本身,你通过标签直接伸进盒子(内存地址)里修改了内容。


4. 别名 vs 指针(底层区别)

虽然别名修改的是内存,但它和指针在操作上有一点点细微的区别:

  • 指针 (int *p): 指针是一个独立的变量,它自己占用 8 字节内存,里面存的是别人的地址。修改指针指向的数据,需要先读取指针自己的值,再顺着地址去找目标。
  • 别名 (int &y): 别名在编译后,通常直接被替换为它所代表的那个地址。它不是一个独立的变量,它就是原变量的“化身”。

总结

当你写 a[i] - a[now] 或者修改 a[i] 时:

  • 如果 a引用vector<int> &a),CPU 发出的指令是:“去数组 a 所在的那个原始内存位置读/写数据。”
  • 由于没有中间的复制过程,指令直接作用于原始内存

这就是为什么引用传递(&)能实现“零拷贝”且效率极高的根本原因。

DFS剪纸小技巧

这是一个非常经典的问题!这两种写法在功能上是完全等价的,但在代码逻辑思维坑点上有着显著的区别。

简单来说:

  1. 写在外面(开头):叫“进门安检”。不管谁来了,先放进函数里,然后第一件事就是检查合不合格,不合格就踢出去。
  2. 写在里面(循环内):叫“三思后行”。在准备把这一步迈出去之前,先看一眼前面能不能走,能走才迈步(才调用函数)。

这道题当然可以写在 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 函数第一行 DFSfor 循环内部
对起点的处理 自动处理(起点不合格直接return) 需要手动处理(Main函数要额外判断起点)
代码复杂度 简单清晰 (推荐) 循环内条件太长,容易写错
适用场景 绝大多数 DFS/BFS 题目 极度追求性能优化或特殊回溯逻辑

建议:作为初学者或者在打比赛时,永远优先选择“写在外面”。因为这样你就不用担心“起点是不是墙”、“起点是不是已经越界”这种边缘情况,函数会帮你自动处理好,不容易出错。

posted @ 2026-01-22 14:44  EcSilvia  阅读(1)  评论(0)    收藏  举报