Skip to content

Commit

Permalink
Update 01.Array-Binary-Search.md
Browse files Browse the repository at this point in the history
  • Loading branch information
itcharge committed Sep 5, 2023
1 parent 10116e7 commit d635c34
Showing 1 changed file with 56 additions and 46 deletions.
102 changes: 56 additions & 46 deletions Contents/01.Array/03.Array-Binary-Search/01.Array-Binary-Search.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
## 1. 二分查找算法介绍

> **「二分查找算法(Binary Search Algorithm)」**:也叫做 **「折半查找算法」****「对数查找算法」**。是一种在有序数组中查找某一特定元素的搜索算法。
>
> 基本算法思想:先确定待查找元素所在的区间范围,在逐步缩小范围,直到找到元素或找不到该元素为止。
> **二分查找算法(Binary Search Algorithm)**:也叫做折半查找算法、对数查找算法,是一种用于在有序数组中查找特定元素的高效搜索算法。
二分查找算法的过程如下所示:
二分查找的基本算法思想为:通过确定目标元素所在的区间范围,反复将查找范围减半,直到找到元素或找不到该元素为止。

1. 每次查找时从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;
2. 如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。
3. 如果在某一步骤数组为空,则代表找不到。
以下是二分查找算法的基本步骤:

举个例子来说,给定一个有序数组 `[0, 1, 2, 3, 4, 5, 6, 7, 8]`。如果我们希望查找 `5` 是否在这个数组中。
1. **初始化**:首先,确定要查找的有序数据集合。可以是一个数组或列表,确保其中的元素按照升序或者降序排列。
2. **确定查找范围**:将整个有序数组集合的查找范围确定为整个数组范围区间,即左边界和右边界。
3. **计算中间元素**:根据右边界和右边界计算出中间元素下标位置。
4. **比较中间元素**:将目标元素与中间元素进行比较:
1. 如果目标元素等于中间元素,那么查找成功,返回中间元素的下标位置。
2. 如果目标元素小于中间元素,说明目标元素在左半部分,更新右边界为中间元素的前一个位置。
3. 如果目标元素大于中间元素,说明目标元素在右半部分,更新左边界为中间元素的后一个位置。

1. 第一次区间为整个数组 `[0, 1, 2, 3, 4, 5, 6, 7, 8]`,中位数是 `4`,因为 `4` 小于 `5`,所以如果 `5` 存在在这个数组中,那么 `5` 一定在 `4` 右边的这一半区间中。于是我们的查找范围变成了 `[4, 5, 6, 7, 8]`
2. 第二次区间为 `[4, 5, 6, 7, 8]`,中位数是 `6`,因为 `5` 小于 `6`,所以如果 `5` 存在在这个数组中,那么 `5` 一定在 `6` 左边的这一半区间中。于是我们的查找范围变成了 `[4, 5, 6]`
3. 第三次区间为 `[4, 5, 6]`,中位数是 `5`,正好是我们需要查找的数字。
5. 重复步骤 $3 \sim 4$,直到找到目标元素或者查找范围缩小为空(左边界大于右边界),表示目标元素不存在。

于是我们发现,对于一个长度为 `9` 的有序数组,我们只进行了 `3` 次查找就找到了我们需要查找的数字。而如果是按顺序依次遍历数组,则最坏情况下,我们需要查找 `9` 次。
举个例子来说,以在有序数组 $[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]$ 中查找目标元素 $5$ 来说,使用二分查找算法的步骤如下:

1. **确定查找范围**:初始时左边界 $left$ 为 $0$(数组的起始位置),$right$ 为 $9$(数组的末尾位置)。此时查找范围为 $[0, 9]$。
2. **计算中间元素**:中间元素下标位置为 $4$,对应元素为 $nums[4] == 4$。
3. **比较中间元素**:因为 $5 > nums[4]$,所以目标元素可能在右半部分,更新左边界为中间元素的后一个位置,即 $left = 5$。此时查找范围为 $[5, 9]$。
4. **计算中间元素**:中间元素下标位置为 $7$,对应元素为 $nums[7] == 7$。
5. **比较中间元素**:因为 $5 < nums[7]$,所以目标元素可能在左半部分,更新右边界为中间元素的前一个位置,即 $right = 6$。此时查找范围为 $[5, 6]$。
6. **计算中间元素**:中间元素下标位置为 $5$,对应元素为 $nums[5] == 5$。
7. **比较中间元素**:因为 $5 == nums[5]$,正好是我们正在查找的目标元素,此时返回中间元素的下标位置,算法结束。

于是我们发现,对于一个长度为 $10$ 的有序数组,我们只进行了 $3$ 次查找就找到了目标元素。而如果是按顺序依次遍历数组,则在最坏情况下,我们可能需要查找 $10$ 次才能找到目标元素。

二分查找过程的示意图如下所示:

Expand All @@ -38,15 +48,15 @@

### 3.1 题目大意

**描述**:给定一个升序的数组 `nums`,和一个目标值 `target`
**描述**:给定一个升序的数组 $nums$,和一个目标值 $target$

**要求**:返回 `target` 在数组中的位置,如果找不到,则返回 `-1`
**要求**:返回 $target$ 在数组中的位置,如果找不到,则返回 $-1$

**说明**

- 你可以假设 `nums` 中的所有元素是不重复的。
- `n` 将在 `[1, 10000]`之间。
- `nums` 的每个元素都将在 `[-9999, 9999]`之间。
- 你可以假设 $nums$ 中的所有元素是不重复的。
- $n$ 将在 $[1, 10000]$ 之间。
- $nums$ 的每个元素都将在 $[-9999, 9999]$之间。

**示例**

Expand All @@ -65,13 +75,13 @@

#### 思路 1:二分查找

设定左右节点为数组两端,即 `left = 0``right = len(nums) - 1`,代表待查找区间为 `[left, right]`(左闭右闭)。
设定左右节点为数组两端,即 `left = 0``right = len(nums) - 1`,代表待查找区间为 $[left, right]$(左闭右闭)。

取两个节点中心位置 `mid`,先比较中心位置值 `nums[mid]` 与目标值 `target` 的大小。
取两个节点中心位置 $mid$,先比较中心位置值 $nums[mid]$ 与目标值 $target$ 的大小。

- 如果中心位置值 `nums[mid]` 与目标值 `target` 相等,则返回中心位置。
- 如果中心位置值 `nums[mid]` 小于目标值 `target`,则将左节点设置为 `mid + 1`,然后继续在右区间 `[mid + 1, right]` 搜索。
- 如果中心位置值 `nums[mid]` 大于目标值 `target`,则将右节点设置为 `mid - 1`,然后继续在左区间 `[left, mid - 1]` 搜索。
- 如果中心位置值 $nums[mid]$ 与目标值 $target$ 相等,则返回中心位置。
- 如果中心位置值 $nums[mid]$ 小于目标值 $target$,则将左节点设置为 $mid + 1$,然后继续在右区间 $[mid + 1, right]$ 搜索。
- 如果中心位置值 $nums[mid]$ 大于目标值 $target$,则将右节点设置为 $mid - 1$,然后继续在左区间 $[left, mid - 1]$ 搜索。

#### 思路 1:代码

Expand Down Expand Up @@ -107,7 +117,7 @@ class Solution:
从上面的例子中我们了解了二分查找的思路和具体代码。但是真正在解决二分查找题目的时候还是需要考虑很多细节的。比如说以下几个问题:

1. **区间的开闭问题**:区间应该是左闭右闭,还是左闭右开?
2. **`mid` 的取值问题**`mid = (left + right) // 2`,还是 `mid = (left + right + 1) // 2`
2. **$mid$ 的取值问题**`mid = (left + right) // 2`,还是 `mid = (left + right + 1) // 2`
3. **出界条件的判断**`left <= right`,还是 `left < right`
4. **搜索区间范围的选择**`left = mid + 1``right = mid - 1``left = mid ``right = mid` 应该怎么写?

Expand All @@ -117,44 +127,44 @@ class Solution:

区间的左闭右闭、左闭右开指的是初始待查找区间的范围。

- **左闭右闭**:初始化赋值时,`left = 0``right = len(nums) - 1``left` 为数组第一个元素位置,`right` 为数组最后一个元素位置,从而区间 `[left, right]` 左右边界上的点都能取到。
- **左闭右开**:初始化赋值时,`left = 0``right = len(nums)``left` 为数组第一个元素位置,`right` 为数组最后一个元素的下一个位置,从而区间 `[left, right)` 左边界点能取到,而右边界上的点不能取到。
- **左闭右闭**:初始化赋值时,`left = 0``right = len(nums) - 1`$left$ 为数组第一个元素位置,$right$ 为数组最后一个元素位置,从而区间 $[left, right]$ 左右边界上的点都能取到。
- **左闭右开**:初始化赋值时,`left = 0``right = len(nums)`$left$ 为数组第一个元素位置,$right$ 为数组最后一个元素的下一个位置,从而区间 $[left, right)$ 左边界点能取到,而右边界上的点不能取到。

关于区间的左闭右闭、左闭右开,其实在网上都有对应的代码和解法。但是相对来说,左闭右开这种写法在解决问题的过程中,需要考虑的情况更加复杂,所以建议 **全部使用「左闭右闭」区间**

### 4.2 `mid` 的取值问题
### 4.2 $mid$ 的取值问题

在二分查找的实际问题中,最常见的 `mid` 取值就是 `mid = (left + right) // 2` 或者 `mid = left + (right - left) // 2 `。前者是最常见写法,后者是为了防止整型溢出。式子中 `// 2` 就代表的含义是中间数「向下取整」。当待查找区间中有偶数个元素个数时,则位于最中间的数为 `2` 个,这时候使用上面式子只能取到中间靠左边那个数,而取不到中间靠右边的那个数。那么,右边的那个数到底能取吗?
在二分查找的实际问题中,最常见的 $mid$ 取值就是 `mid = (left + right) // 2` 或者 `mid = left + (right - left) // 2 `。前者是最常见写法,后者是为了防止整型溢出。式子中 `// 2` 就代表的含义是中间数「向下取整」。当待查找区间中有偶数个元素个数时,则位于最中间的数为 $2$ 个,这时候使用上面式子只能取到中间靠左边那个数,而取不到中间靠右边的那个数。那么,右边的那个数到底能取吗?

其实,右边的数也是可以取的,令 `mid = (left + right + 1) // 2`,或者 `mid = left + (right - left + 1) // 2`。这样如果待查找区间的元素为偶数个,就能取到中间靠右边的那个数了,把这个式子代入到 [704. 二分查找](https://leetcode.cn/problems/binary-search/) 中试一试,发现也是能通过题目评测的。

这是因为二分查找的思路是根据每次选择中间位置上的数值来决定下一次在哪个区间查找元素。每一次选择的元素位置可以是中间位置,但并不是一定非得是区间中间位置元素,靠左一些、靠右一些、甚至区间三分之一、五分之一处等等,都是可以的。比如说 `mid = left + (right - left + 1) * 1 // 5` 也是可以的。

但一般来说,取中间位置元素在平均意义下所达到的效果最好。同时这样写最简单。而对于 `mid` 值是向下取整还是向上取整,大多数时候是选择不加 `1`。但有些写法中,是需要考虑加 `1` 的,后面会讲解这种写法。
但一般来说,取中间位置元素在平均意义下所达到的效果最好。同时这样写最简单。而对于 $mid$ 值是向下取整还是向上取整,大多数时候是选择不加 $1$。但有些写法中,是需要考虑加 $1$ 的,后面会讲解这种写法。

### 4.3 出界条件的判断

我们经常看到二分查找算法的写法中,`while` 语句出界判断的语句有`left <= right``left < right` 两种写法。那我们究竟应该在什么情况用什么写法呢?

这就需要判断一下导致 `while` 语句出界的条件是什么。

- 如果判断语句为 `left <= right`,且查找的元素不存在,则 `while` 判断语句出界条件是 `left == right + 1`,写成区间形式就是 `[right + 1, right]`,此时待查找区间为空,待查找区间中没有元素存在,所以此时终止循环可以直接返回 `-1` 是正确的。
- 比如说区间 `[3, 2]`,不可能存在一个元素既大于等于 `3` 又小于等于 `2`,此时直接终止循环,返回 `-1` 即可。
- 如果判断语句为`left < right`,且查找的元素不存在,则 `while` 判断语句出界条件是 `left == right`,写成区间形式就是 `[right, right]`。此时区间不为空,待查找区间还有一个元素存在,并不能确定查找的元素不在这个区间中,此时终止循环返回 `-1` 是错误的。
- 比如说区间 `[2, 2]`,元素 `2` 就属于这个区间,此时终止循环,返回 `-1` 就漏掉了这个元素。
- 如果判断语句为 `left <= right`,且查找的元素不存在,则 `while` 判断语句出界条件是 `left == right + 1`,写成区间形式就是 $[right + 1, right]$,此时待查找区间为空,待查找区间中没有元素存在,所以此时终止循环可以直接返回 $-1$ 是正确的。
- 比如说区间 $[3, 2]$,不可能存在一个元素既大于等于 $3$ 又小于等于 $2$,此时直接终止循环,返回 $-1$ 即可。
- 如果判断语句为`left < right`,且查找的元素不存在,则 `while` 判断语句出界条件是 `left == right`,写成区间形式就是 $[right, right]$。此时区间不为空,待查找区间还有一个元素存在,并不能确定查找的元素不在这个区间中,此时终止循环返回 $-1$ 是错误的。
- 比如说区间 $[2, 2]$,元素 $2$ 就属于这个区间,此时终止循环,返回 $-1$ 就漏掉了这个元素。

但是如果我们还是想要使用 `left < right` 的话,怎么办?

可以在返回的时候需要增加一层判断,判断 `left` 所指向位置是否等于目标元素,如果是的话就返回 `left`,如果不是的话返回 `-1`。即:
可以在返回的时候需要增加一层判断,判断 $left$ 所指向位置是否等于目标元素,如果是的话就返回 $left$,如果不是的话返回 $-1$。即:

````python
```python
# ...
while left < right:
# ...
return left if nums[left] == target else -1
````
```

此外,循环语句用 `left < right` 还有一个好处,就是在退出循环的时候,一定有 `left == right`,我们就不用判断应该返回 `left` 还是 `right` 了。
此外,循环语句用 `left < right` 还有一个好处,就是在退出循环的时候,一定有 `left == right`,我们就不用判断应该返回 $left$ 还是 $right$ 了。

### 4.4 搜索区间范围的选择

Expand All @@ -179,11 +189,11 @@ class Solution:

#### 思路:

- 取两个节点中心位置 `mid`,先看中心位置值 `nums[mid]`
- 取两个节点中心位置 $mid$,先看中心位置值 $nums[mid]$

- 如果中心位置值 `nums[mid]` 与目标值 `target` 相等,则 **直接返回** 这个中心位置元素的下标。
- 如果中心位置值 `nums[mid]` 小于目标值 `target`,则将左节点设置为 `mid + 1`,然后继续在右区间 `[mid + 1, right]` 搜索。
- 如果中心位置值 `nums[mid]` 大于目标值 `target`,则将右节点设置为 `mid - 1`,然后继续在左区间 `[left, mid - 1]` 搜索。
- 如果中心位置值 $nums[mid]$ 与目标值 $target$ 相等,则 **直接返回** 这个中心位置元素的下标。
- 如果中心位置值 $nums[mid]$ 小于目标值 $target$,则将左节点设置为 $mid + 1$,然后继续在右区间 $[mid + 1, right]$ 搜索。
- 如果中心位置值 $nums[mid]$ 大于目标值 $target$,则将右节点设置为 $mid - 1$,然后继续在左区间 $[left, mid - 1]$ 搜索。

#### 代码:

Expand Down Expand Up @@ -221,7 +231,7 @@ class Solution:
#### 思路:

- 取两个节点中心位置 `mid`,根据判断条件先将目标元素一定不存在的区间排除。
- 取两个节点中心位置 $mid$,根据判断条件先将目标元素一定不存在的区间排除。
- 然后在剩余区间继续查找元素,继续根据条件排除不存在的区间。
- 直到区间中只剩下最后一个元素,然后再判断这个元素是否是目标元素。

Expand Down Expand Up @@ -271,17 +281,17 @@ class Solution:

#### 细节:

- 判断语句是 `left < right`。这样在退出循环时,一定有`left == right` 成立,就不用判断应该返回 `left` 还是 `right` 了。同时方便定位查找元素的下标。但是一定要注意最后要对区间剩余的元素进行一次判断。
- 在循环体中,优先考虑 `nums[mid]` 在什么情况下一定不是目标元素,排除掉不可能区间,然后再从剩余区间中确定下一次查找区间的范围。
- 在考虑 `nums[mid]` 在什么情况下一定不是目标元素之后,它的对立面(即 `else` 部分)一般就不需要再考虑区间范围了,直接取上一个区间的反面区间。如果上一个区间是 `[mid + 1, right]`,那么相反面就是 `[left, mid]`。如果上一个区间是 `[left, mid - 1]`,那么相反面就是 `[mid, right]`
- 当区分被分为 `[left, mid - 1]``[mid, right]` 两部分时,**`mid` 取值要向上取整**。即 `mid = left + (right - left + 1) // 2`。因为如果当区间中只剩下两个元素时(此时 `right = left + 1`),一旦进入 `left = mid` 分支,区间就不会再缩小了,下一次循环的查找区间还是 `[left, right]`,就陷入了死循环。
- 判断语句是 `left < right`。这样在退出循环时,一定有`left == right` 成立,就不用判断应该返回 $left$ 还是 $right$ 了。同时方便定位查找元素的下标。但是一定要注意最后要对区间剩余的元素进行一次判断。
- 在循环体中,优先考虑 $nums[mid]$ 在什么情况下一定不是目标元素,排除掉不可能区间,然后再从剩余区间中确定下一次查找区间的范围。
- 在考虑 $nums[mid]$ 在什么情况下一定不是目标元素之后,它的对立面(即 `else` 部分)一般就不需要再考虑区间范围了,直接取上一个区间的反面区间。如果上一个区间是 $[mid + 1, right]$,那么相反面就是 $[left, mid]$。如果上一个区间是 $[left, mid - 1]$,那么相反面就是 $[mid, right]$
- 当区分被分为 $[left, mid - 1]$$[mid, right]$ 两部分时,**$mid$ 取值要向上取整**。即 `mid = left + (right - left + 1) // 2`。因为如果当区间中只剩下两个元素时(此时 `right = left + 1`),一旦进入 `left = mid` 分支,区间就不会再缩小了,下一次循环的查找区间还是 $[left, right]$,就陷入了死循环。
- 关于边界设置可以记忆为:只要看到 `left = mid` 就向上取整。或者记为:
- `left = mid + 1``right = mid``mid = left + (right - left) // 2` 一定是配对出现的。
- `right = mid - 1``left = mid``mid = left + (right - left + 1) // 2` 一定是配对出现的。

### 5.3 两种思路适用范围

- **二分查找的思路 1**:因为判断语句是 `left <= right`,有时候要考虑返回是 `left` 还是 `right`。循环体内有 3 个分支,并且一定有一个分支用于退出循环或者直接返回。这种思路适合解决简单题目。即要查找的元素性质简单,数组中都是非重复元素,且 `==``>``<` 的情况非常好写的时候。
- **二分查找的思路 1**:因为判断语句是 `left <= right`,有时候要考虑返回是 $left$ 还是 $right$。循环体内有 3 个分支,并且一定有一个分支用于退出循环或者直接返回。这种思路适合解决简单题目。即要查找的元素性质简单,数组中都是非重复元素,且 `==``>``<` 的情况非常好写的时候。
- **二分查找的思路 2**:更加符合二分查找算法的减治思想。每次排除目标元素一定不存在的区间,达到减少问题规模的效果。然后在可能存在的区间内继续查找目标元素。这种思路适合解决复杂题目。比如查找一个数组里可能不存在的元素,找边界问题,可以使用这种思路。

## 参考资料
Expand Down

0 comments on commit d635c34

Please sign in to comment.