Initial commit: C++ courseware
This commit is contained in:
commit
4a4ee7c287
1789 files changed
+373489
No files matched your search
@@ -0,0 +1,250 @@
|
||||
## **C++算法讲义: 二分查找**
|
||||
|
||||
#### **1. 引言 **
|
||||
|
||||
* **问题引入:猜数字游戏**
|
||||
从 1 到 1000 猜一个数字,你会怎么猜?
|
||||
* **线性查找 (老实人)**:从 1 开始逐个猜。最坏情况猜 1000 次,$O(N)$。
|
||||
* **二分查找 (聪明人)**:每次猜中间,根据反馈排除一半区间。最坏情况只需 10 次 ($2^{10} > 1000$),$O(\log N)$。
|
||||
当数据量达到 42 亿时,线性查找需 42 亿次,而二分查找仅需 32 次,效率差距天壤之别!
|
||||
|
||||
|
||||
* **核心前提**
|
||||
二分查找之所以高效,是因为它有一个**前提**:**数据必须具有单调性(即有序)**。如果数据是乱序的,折半策略将失效。就像在乱七八糟的纸堆里找东西,你不可能“折半”查找。
|
||||
|
||||
* **学习价值**
|
||||
掌握二分查找是 C++ 算法学习者的必备技能:
|
||||
* **高效搜索**:在大量有序数据中快速定位,提升程序性能。
|
||||
* **解决变体问题**:很多复杂问题通过转化,也能应用二分查找思想。
|
||||
* **面试高频考点**:它不仅考察代码实现,更考察对边界和变体问题的处理能力。
|
||||
|
||||
* **学习目标**
|
||||
学完此部分,你应该能:
|
||||
1. 理解二分查找的**分治原理**和**对数时间复杂度**。
|
||||
2. 掌握数据**有序性**这一核心前提。
|
||||
3. 熟练编写**经典二分查找模板**,并理解其闭区间 `l, r`、`l <= r` 循环条件和 `mid` 计算方式。
|
||||
4. 掌握修改模板,解决**查找第一个/最后一个出现的目标值**等变体问题。
|
||||
5. 识别**死循环陷阱**,并正确处理 `mid` 计算与边界更新关系。
|
||||
|
||||
---
|
||||
|
||||
#### **2. 核心思想**
|
||||
|
||||
##### **2.1 二分查找的定义**
|
||||
|
||||
二分查找(Binary Search)是一种基于**分治策略**的高效搜索算法。它利用数据的**有序性**,每轮将搜索范围减半,直到找到目标元素或搜索区间为空。
|
||||
|
||||
类比词典查单词:打开中间页,如果单词靠前就往前翻,靠后就往后翻,每次都排除一半无关的页码。
|
||||
|
||||
##### **2.2 基本原理:三指针与区间缩减**
|
||||
|
||||
在有序数组 `arr` 中查找 `target`,我们使用三个“指针”:
|
||||
* `l` (左边界):搜索区间的头部索引。
|
||||
* `r` (右边界):搜索区间的尾部索引。
|
||||
* `mid` (中间点):`mid = l + (r - l) / 2`。
|
||||
|
||||
**核心步骤(循环执行):**
|
||||
|
||||
1. **计算 `mid`**:`mid = l + (r - l) / 2` (向下取整)。这种写法能**有效防止 `l + r` 溢出**。
|
||||
2. **比较 `arr[mid]` 与 `target`**:
|
||||
* `arr[mid] < target`:目标在 `mid` 的右边,更新 `l = mid + 1`。
|
||||
* `arr[mid] > target`:目标在 `mid` 的左边,更新 `r = mid - 1`。
|
||||
* `arr[mid] == target`:找到目标,返回 `mid`。
|
||||
|
||||
##### **2.3 经典二分查找模板 (闭区间 `[l, r]`)**
|
||||
|
||||
我们推荐使用**闭区间 `[l, r]`** 的写法,因为它清晰直观,不易出错。
|
||||
|
||||
```cpp
|
||||
// 在有序数组 nums 中查找 target,找到返回下标,没找到返回 -1
|
||||
int bs(const vector<int>& nums, int target) {
|
||||
int l = 0;
|
||||
int r = nums.size() - 1; // r 是最后一个元素的下标,[l, r] 闭区间
|
||||
|
||||
// 循环条件:当 l <= r 时,表示搜索区间 [l, r] 依然有效
|
||||
// 即使 l == r,区间中仍有一个元素,需要检查
|
||||
while (l <= r) {
|
||||
int mid = l + (r - l) / 2; // 防溢出写法,向下取整
|
||||
|
||||
if (nums[mid] == target) {
|
||||
return mid; // 找到了,直接返回
|
||||
} else if (nums[mid] < target) {
|
||||
l = mid + 1; // 目标在右半边,新的左边界变为 mid + 1
|
||||
} else { // nums[mid] > target
|
||||
r = mid - 1; // 目标在左半边,新的右边界变为 mid - 1
|
||||
}
|
||||
}
|
||||
// 循环结束,说明搜索区间为空(l > r),target 不存在于数组中
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
**代码解读要点:**
|
||||
1. `r = nums.size() - 1;`:`r` 初始化为最后一个元素的下标,定义 `[l, r]` 为闭区间。
|
||||
2. `while (l <= r)`:关键条件。当 `l == r` 时,区间中仍有 1 个元素,需要检查。
|
||||
3. `l = mid + 1;` 和 `r = mid - 1;`:当 `nums[mid]` 不等于 `target` 时,`mid` 肯定不是目标,所以可以直接排除 `mid`。
|
||||
|
||||
---
|
||||
|
||||
#### **3. 实例**
|
||||
|
||||
##### **3.1 查找第一个出现的元素 (OJ:B0189 )**
|
||||
|
||||
**题目:** 有序数组 `nums` 可能包含重复元素。查找 `target` 第一次出现的索引,未找到返回 -1。
|
||||
|
||||
**解决思路:找到了别停,继续向左收缩!**
|
||||
当 `nums[mid] == target` 时,先记录 `mid` 可能是答案,然后向左 (`r = mid - 1`) 继续搜索,看是否存在更靠前的 `target`。
|
||||
|
||||
```cpp
|
||||
// 查找第一次出现 target 的索引
|
||||
int bsFirst(const vector<int>& nums, int target) {
|
||||
int l = 0; // 左边界索引
|
||||
int r = nums.size() - 1; // 右边界索引
|
||||
int ans = -1; // 记录可能的结果
|
||||
|
||||
while (l <= r) {
|
||||
int mid = l + (r - l) / 2;
|
||||
|
||||
if (nums[mid] == target) {
|
||||
ans = mid; // 记录当前索引
|
||||
r = mid - 1; // 继续向左收缩,找更靠前的
|
||||
} else if (nums[mid] < target) {
|
||||
l = mid + 1; // 目标在右侧
|
||||
} else { // nums[mid] > target
|
||||
r = mid - 1; // 目标在左侧
|
||||
}
|
||||
}
|
||||
return ans; // 返回记录的第一个 target 的索引
|
||||
}
|
||||
```
|
||||
|
||||
**流程 (查找 `2` 在 `[1, 2, 2, 2, 3, 5, 7, 9]`):**
|
||||
|
||||
1. `l=0, r=7, mid=3` (指向第二个 `2`)。`nums[3]==2`。`ans=3`,`r=2`。
|
||||
2. `l=0, r=2, mid=1` (指向第一个 `2`)。`nums[1]==2`。`ans=1`,`r=0`。
|
||||
3. `l=0, r=0, mid=0` (指向 `1`)。`nums[0]==1 < 2`。`l=1`。
|
||||
4. `l=1, r=0`。`l > r`,循环结束。返回 `ans = 1`。
|
||||
|
||||
##### **3.2 查找最后一个出现的元素**
|
||||
|
||||
**题目:** 寻找这个数字在序列中**最后一次出现**的下标。
|
||||
|
||||
**思路**:与查找第一个出现的元素类似,但策略相反——**找到了别停,继续向右收缩!**
|
||||
当 `nums[mid] == target` 时,先记录 `mid` 可能是答案,然后向右 (`l = mid + 1`) 继续搜索,看是否存在更靠后的 `target`。
|
||||
|
||||
```cpp
|
||||
// 查找最后一个出现 target 的索引
|
||||
int bsLast(const vector<int>& nums, int target) {
|
||||
int l = 0;
|
||||
int r = nums.size() - 1;
|
||||
int ans = -1;
|
||||
|
||||
while (l <= r) {
|
||||
int mid = l + (r - l) / 2;
|
||||
|
||||
if (nums[mid] == target) {
|
||||
ans = mid; // 记录当前索引
|
||||
l = mid + 1; // 继续向右收缩,找更靠后的
|
||||
} else if (nums[mid] < target) {
|
||||
l = mid + 1; // 目标在右侧
|
||||
} else { // nums[mid] > target
|
||||
r = mid - 1; // 目标在左侧
|
||||
}
|
||||
}
|
||||
return ans;
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.3 区间收缩排除法 (左闭右开 `[l, r)`)**
|
||||
|
||||
这是一种不同风格的二分查找模板,它不是在循环中“找到就返回”,而是不断缩小区间,直到区间只剩下一个元素,最后再检查这个元素是不是我们要找的。
|
||||
|
||||
```cpp
|
||||
// 查找第一个等于或大于 target 的元素
|
||||
int bsExclude(const vector<int>& nums, int target) {
|
||||
int l = 0;
|
||||
int r = nums.size(); // [l, r) 左闭右开,r 初始化为 size
|
||||
|
||||
// 循环条件:当 l < r 时,表示搜索区间 [l, r) 依然有效
|
||||
// 最终 l == r,指向第一个满足条件的元素或 size
|
||||
while (l < r) {
|
||||
int mid = l + (r - l) / 2; // 向下取整
|
||||
if (nums[mid] >= target) {
|
||||
r = mid; // mid 可能是答案,或者答案在 mid 左边,所以把 r 收缩到 mid (不排除 mid)
|
||||
} else { // nums[mid] < target
|
||||
l = mid + 1; // mid 太小,排除 mid,把 l 移到 mid + 1
|
||||
}
|
||||
}
|
||||
// 循环结束时,l == r。这个 l 就是第一个 >= target 的位置。
|
||||
// 需要后处理:检查 l 是否越界且 nums[l] 是否等于 target。
|
||||
if (l < nums.size() && nums[l] == target) {
|
||||
return l;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**代码解读要点:**
|
||||
1. **区间定义:`[l, r)` (左闭右开)**。`r` 初始化为 `nums.size()`。
|
||||
2. **`while (l < r)`**:当 `l == r` 时循环终止。
|
||||
3. **`if (nums[mid] >= target) r = mid;`**:`mid` 可能含有答案,因此 `mid` 不被排除。
|
||||
4. **`else l = mid + 1;`**:`mid` 肯定不是答案,因此 `mid` 被排除。
|
||||
5. **后处理**:循环结束时 `l == r`,这个 `l` 是第一个 `>= target` 的位置。需要额外检查。
|
||||
|
||||
##### **3.4 陷阱:二分查找的死循环**
|
||||
|
||||
死循环常发生在**区间只剩 2 个元素**时 (`l`, `l+1`)。
|
||||
**原因**:`mid` 的取整方向与边界更新方向不一致。
|
||||
* 如果策略是 `l = mid` (保留 `mid` 作为左边界),且 `mid` 是**向下取整**(偏向 `l`),那么当 `r = l+1` 时 `mid` 永远等于 `l`,导致 `l = mid` 陷入 `l=l` 的死循环。
|
||||
* **解决方案**:
|
||||
* **当 `l = mid` 更新时,`mid` 必须**向上取整:`mid = l + (r - l + 1) / 2`。
|
||||
* **当 `r = mid` 更新时,`mid` 必须**向下取整:`mid = l + (r - l) / 2`。
|
||||
|
||||
**逻辑本质:** 二分查找的死循环,永远发生在区间还剩 2 个元素的时候。`mid` 必须跳过那个被保留的边界,去测试另一个元素,这样区间才能缩小。
|
||||
|
||||
**示例:寻找最后一个 `target`,使用 `[l, r)` 和 `l = mid` 策略时,正确的 `mid` 计算**
|
||||
|
||||
```cpp
|
||||
// 查找最后一个等于 target 的元素
|
||||
int bsLastOpt(const vector<int>& nums, int target) {
|
||||
int l = 0;
|
||||
int r = nums.size(); // [l, r) 左闭右开
|
||||
|
||||
int lastIdx = -1; // 记录可能的最后一个 target 索引
|
||||
|
||||
while (l < r) {
|
||||
// mid 向上取整,以避免 l = mid 时死循环
|
||||
int mid = l + (r - l + 1) / 2; // 注意这里 +1,让 mid 偏向右边
|
||||
|
||||
if (nums[mid] <= target) { // mid 可能是答案,或答案在 mid 右边,包含 mid
|
||||
lastIdx = mid; // 记录当前可能的答案
|
||||
l = mid; // 保留 mid,向右搜索 [mid, r)
|
||||
} else { // nums[mid] > target // mid 太大了,答案在 mid 左边,不包含 mid
|
||||
r = mid - 1; // 排除 mid,向左搜索 [l, mid-1)
|
||||
}
|
||||
}
|
||||
// 循环结束,检查 lastIdx 是否有效且等于 target
|
||||
if (lastIdx != -1 && nums[lastIdx] == target) {
|
||||
return lastIdx;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **4. 总结**
|
||||
|
||||
* **要点回顾**
|
||||
1. **核心**:利用有序性,每次排除一半搜索空间,$O(\log N)$。
|
||||
2. **前提**:数据必须有序。
|
||||
3. **经典模板 (`[l, r]` 闭区间)**:`r = size - 1; while (l <= r); l = mid + 1; r = mid - 1;` 找到即可返回。
|
||||
4. **变体**:
|
||||
* **查找第一个**:`nums[mid] == target` 时,`ans = mid; r = mid - 1;` (向左收缩)。
|
||||
* **查找最后一个**:`nums[mid] == target` 时,`ans = mid; l = mid + 1;` (向右收缩)。
|
||||
5. **区间收缩排除法 (`[l, r)` 左闭右开)**:`r = size; while (l < r);` 最终 `l == r` 需后处理。
|
||||
6. **死循环**:`mid` 取整方向需与边界更新方向一致。
|
||||
* `l = mid` 时,`mid` 向上取整 (`mid = l + (r - l + 1) / 2`)。
|
||||
* `r = mid` 时,`mid` 向下取整 (`mid = l + (r - l) / 2`)。
|
||||
Reference in new issue
Block a user