hello-algo/docs/chapter_binary_search/binary_search.md
2023-05-21 04:51:32 +08:00

198 lines
6.9 KiB
Markdown
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 二分查找
「二分查找 Binary Search」是一种基于分治思想的高效搜索算法。它利用数据的有序性每轮减少一半搜索范围实现定位目标元素。
我们先来求解一个简单的二分查找问题。
!!! question "给定一个长度为 $n$ 的有序数组 `nums` ,元素按从小到大的顺序排列。查找并返回元素 `target` 在该数组中的索引。若数组中不包含该元素,则返回 $-1$ 。数组中不包含重复元素。"
该数组的索引范围可以使用区间 $[0, n - 1]$ 来表示。其中,**中括号表示“闭区间”,即包含边界值本身**。在该表示下,区间 $[i, j]$ 在 $i = j$ 时仍包含一个元素,在 $i > j$ 时为空区间。
接下来,我们基于上述区间定义实现二分查找。先初始化指针 $i = 0$ 和 $j = n - 1$ ,分别指向数组首元素和尾元素。之后循环执行以下两个步骤:
1. 计算中点索引 $m = \lfloor {(i + j) / 2} \rfloor$ ,其中 $\lfloor \space \rfloor$ 表示向下取整操作。
2. 根据 `nums[m]``target` 缩小搜索区间,分为三种情况:
1.`nums[m] < target` 时,说明 `target` 在区间 $[m + 1, j]$ 中,因此执行 $i = m + 1$
2.`nums[m] > target` 时,说明 `target` 在区间 $[i, m - 1]$ 中,因此执行 $j = m - 1$
3.`nums[m] = target` 时,说明找到目标元素,直接返回索引 $m$ 即可;
**若数组不包含目标元素,搜索区间最终会缩小为空**,即达到 $i > j$ 。此时,终止循环并返回 $-1$ 即可。
为了更清晰地表示区间,我们在下图中以折线图的形式表示数组。
=== "<0>"
![二分查找步骤](binary_search.assets/binary_search_step0.png)
=== "<1>"
![binary_search_step1](binary_search.assets/binary_search_step1.png)
=== "<2>"
![binary_search_step2](binary_search.assets/binary_search_step2.png)
=== "<3>"
![binary_search_step3](binary_search.assets/binary_search_step3.png)
=== "<4>"
![binary_search_step4](binary_search.assets/binary_search_step4.png)
=== "<5>"
![binary_search_step5](binary_search.assets/binary_search_step5.png)
=== "<6>"
![binary_search_step6](binary_search.assets/binary_search_step6.png)
=== "<7>"
![binary_search_step7](binary_search.assets/binary_search_step7.png)
值得注意的是,**当数组长度 $n$ 很大时,加法 $i + j$ 的结果可能会超出 `int` 类型的取值范围**。为了避免大数越界,我们通常采用公式 $m = \lfloor {i + (j - i) / 2} \rfloor$ 来计算中点。
有趣的是,理论上 Python 的数字可以无限大(取决于内存大小),因此无需考虑大数越界问题。
=== "Java"
```java title="binary_search.java"
[class]{binary_search}-[func]{binarySearch}
```
=== "C++"
```cpp title="binary_search.cpp"
[class]{}-[func]{binarySearch}
```
=== "Python"
```python title="binary_search.py"
[class]{}-[func]{binary_search}
```
=== "Go"
```go title="binary_search.go"
[class]{}-[func]{binarySearch}
```
=== "JavaScript"
```javascript title="binary_search.js"
[class]{}-[func]{binarySearch}
```
=== "TypeScript"
```typescript title="binary_search.ts"
[class]{}-[func]{binarySearch}
```
=== "C"
```c title="binary_search.c"
[class]{}-[func]{binarySearch}
```
=== "C#"
```csharp title="binary_search.cs"
[class]{binary_search}-[func]{binarySearch}
```
=== "Swift"
```swift title="binary_search.swift"
[class]{}-[func]{binarySearch}
```
=== "Zig"
```zig title="binary_search.zig"
[class]{}-[func]{binarySearch}
```
时间复杂度为 $O(\log n)$ 。每轮缩小一半区间,因此二分循环次数为 $\log_2 n$ 。
空间复杂度为 $O(1)$ 。指针 `i` , `j` 使用常数大小空间。
## 区间表示方法
除了上述的双闭区间外,常见的区间表示还有“左闭右开”区间,定义为 $[0, n)$ ,即左边界包含自身,右边界不包含自身。在该表示下,区间 $[i, j]$ 在 $i = j$ 时为空。
我们可以基于该表示实现具有相同功能的二分查找算法。
=== "Java"
```java title="binary_search.java"
[class]{binary_search}-[func]{binarySearchLCRO}
```
=== "C++"
```cpp title="binary_search.cpp"
[class]{}-[func]{binarySearchLCRO}
```
=== "Python"
```python title="binary_search.py"
[class]{}-[func]{binary_search_lcro}
```
=== "Go"
```go title="binary_search.go"
[class]{}-[func]{binarySearchLCRO}
```
=== "JavaScript"
```javascript title="binary_search.js"
[class]{}-[func]{binarySearchLCRO}
```
=== "TypeScript"
```typescript title="binary_search.ts"
[class]{}-[func]{binarySearchLCRO}
```
=== "C"
```c title="binary_search.c"
[class]{}-[func]{binarySearchLCRO}
```
=== "C#"
```csharp title="binary_search.cs"
[class]{binary_search}-[func]{binarySearchLCRO}
```
=== "Swift"
```swift title="binary_search.swift"
[class]{}-[func]{binarySearchLCRO}
```
=== "Zig"
```zig title="binary_search.zig"
[class]{}-[func]{binarySearchLCRO}
```
如下图所示,在两种区间表示下,二分查找算法的初始化、循环条件和缩小区间操作皆有所不同。
在“双闭区间”表示法中,由于左右边界都被定义为闭区间,因此指针 $i$ 和 $j$ 缩小区间操作也是对称的。这样更不容易出错。因此,**我们通常采用“双闭区间”的写法**。
![两种区间定义](binary_search.assets/binary_search_ranges.png)
## 优点与局限性
二分查找效率很高,主要体现在:
- **二分查找的时间复杂度较低**。对数阶在大数据量情况下具有显著优势。例如,当数据大小 $n = 2^{20}$ 时,线性查找需要 $2^{20} = 1048576$ 轮循环,而二分查找仅需 $\log_2 2^{20} = 20$ 轮循环。
- **二分查找无需额外空间**。与哈希查找相比,二分查找更加节省空间。
然而,并非所有情况下都可使用二分查找,原因如下:
- **二分查找仅适用于有序数据**。若输入数据无序,为了使用二分查找而专门进行排序,得不偿失。因为排序算法的时间复杂度通常为 $O(n \log n)$ ,比线性查找和二分查找都更高。对于频繁插入元素的场景,为保持数组有序性,需要将元素插入到特定位置,时间复杂度为 $O(n)$ ,也是非常昂贵的。
- **二分查找仅适用于数组**。二分查找需要跳跃式(非连续地)访问元素,而在链表中执行跳跃式访问的效率较低,因此不适合应用在链表或基于链表实现的数据结构。
- **小数据量下,线性查找性能更佳**。在线性查找中,每轮只需要 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,当数据量 $n$ 较小时,线性查找反而比二分查找更快。