二分查找主要就是搜索区间的正确更新,不能落下或者重复计算某些区间
为什么while(left<=right) 如果 right = middle会发生什么
版本一的正确写法(right = middle - 1
)的步长推导
假设初始区间是 [left, right]
,middle
的计算公式为:
middle = left + (right - left) / 2
每次根据 nums[middle]
和 target
的比较,我们有三种情况:
- 如果
nums[middle] == target
,返回middle
。 - 如果
nums[middle] > target
,说明目标值在middle
的左边,因此搜索范围缩小为[left, middle - 1]
,即right = middle - 1
。 - 如果
nums[middle] < target
,目标值在middle
的右边,搜索范围缩小为[middle + 1, right]
,即left = middle + 1
。
通过使用 right = middle - 1
和 left = middle + 1
,每次迭代都在缩小搜索区间,并保证不会重复检查 middle
位置,从而能够保证算法收敛。
修改为 right = middle
后的情况
如果将 right = middle - 1
改为 right = middle
,则在 nums[middle] > target
的情况下,你会将右边界调整为 middle
,即搜索区间变成 [left, middle]
。问题在于,在下一次迭代时,如果 middle
仍然是相同的值(因为 left
和 right
都没有明显缩小),则会导致无限循环,因为搜索区间没有明显缩小。
为了更具体地说明这一点,假设 left = 0
,right = 2
,计算得到 middle = 1
:
- 如果
nums[middle] > target
,使用right = middle
,右边界变成1
,区间变为[0, 1]
,再计算middle
,还是1
。 - 这样即使在下一次迭代,
middle
仍然会是1
,从而导致死循环,区间[0, 1]
永远不会收敛到目标值,算法无法结束。
步长分析
- 正确的步长:当
nums[middle] > target
时,步长为right = middle - 1
,保证每次区间缩小,从而能最终收敛。 - 错误的步长:当
right = middle
时,可能会导致步长为 0 的情况,即right
不会真正缩小,导致死循环。
总结
right = middle - 1
是为了确保中间点不再包含在接下来的搜索区间内,确保算法收敛。right = middle
可能导致搜索区间没有明显缩小,从而陷入死循环。
因此,将 right = middle - 1
改为 right = middle
是不正确的,会导致算法无法终止。
为什么不能是left = middle,right = middle-1
分析更新方式
我们先看看这两种更新方法的效果:
left = middle + 1
:当nums[middle] < target
时,目标值一定在middle
右边,因此我们排除掉middle
,从middle + 1
开始搜索。right = middle
:当nums[middle] >= target
时,目标值可能就是middle
或者在middle
的左边,因此我们保留middle
,将搜索范围缩小到[left, middle]
。
你提到的更新方式
你建议将 left = middle
和 right = middle - 1
,也就是:
left = middle
:如果nums[middle] < target
,将left
更新为middle
,表示我们把当前的middle
包含在接下来的搜索区间中。right = middle - 1
:如果nums[middle] >= target
,将right
更新为middle - 1
,排除middle
这个位置。
问题 1:区间无法正确缩小
如果你使用 left = middle
和 right = middle - 1
,在某些情况下,搜索区间可能无法有效缩小,从而导致问题。例如:
- 如果
left
和right
相差 1,即区间为[left, right] = [0, 1]
,并且middle = 0
,这时你设置left = middle
使得left = 0
,同时right = middle - 1
使得right = -1
。 - 这会导致区间变成
[0, -1]
,这种区间不合理,程序可能提前结束并且未能正确找到目标值。
问题 2:遗漏目标值
使用 left = middle
和 right = middle - 1
可能会遗漏目标值,因为有时候我们并没有正确保留 middle
。举个例子:
假设数组为 nums = [1, 2, 3, 4, 5]
,目标值为 4
。
- 初始
left = 0
,right = 4
,middle = 2
(对应值3
)。 - 由于
nums[middle] < target
,你会更新left = middle
,即left = 2
。 - 再次迭代时,
right = middle - 1 = 1
,此时left = 2
,right = 1
,导致搜索区间无效,程序提前退出,目标值4
未被检查到。
这种情况下,middle
的值实际上是我们可能要查找的目标值之一,因此不应该被直接排除。
正确的做法
为了避免这些问题,我们在设计二分查找时,必须确保每次迭代能够有效缩小搜索区间,并且不会遗漏任何可能的目标值。这就是为什么我们使用以下更新方式:
left = middle + 1
:如果nums[middle] < target
,那么middle
肯定不是目标值,且目标值在middle
右边。因此,我们可以安全地排除middle
,从middle + 1
继续搜索。right = middle
:如果nums[middle] >= target
,那么middle
可能是目标值,因此不能排除它。我们需要将right
保持为middle
,继续在左侧区间搜索。
区间如何正确缩小
这两种更新方式的共同点在于,每次迭代后区间都能缩小:
- 当
left = middle + 1
时,左边界向右移动,排除掉middle
。 - 当
right = middle
时,右边界向左收缩,保留middle
位置。
因此,我们可以确保算法每次迭代都能缩小区间,并最终收敛到目标值。
总结
- 使用
left = middle
和right = middle - 1
不正确,因为可能导致区间无法有效缩小,或错过目标值,甚至提前终止循环。 - 正确的更新方式应该是
left = middle + 1
和right = middle
,这样可以保证区间每次迭代都能正确缩小,并确保不会遗漏目标值。
这就是为什么我们不能简单地使用 left = middle
和 right = middle - 1
,而必须仔细设计区间更新策略。
陷入死循环的情况
是的,陷入死循环的条件之一就是 middle
不再变化。具体来说,当 left
和 right
的更新方式不能有效缩小区间时,middle
在连续的迭代中保持不变,这会导致算法进入死循环。
让我们再详细看看这个现象的原因:
什么时候 middle
不再变化?
middle
的计算公式是:
middle = left + (right - left) / 2;
如果 left
和 right
的值在连续的迭代中没有发生足够的变化,middle
的值就会重复,这意味着算法无法缩小搜索区间,从而无法继续收敛。
例如:
- 如果
left = 0
,right = 1
,计算得到middle = 0
。 - 在这种情况下,如果
right
更新为middle
(即right = middle
),那么right
仍然等于0
。 - 接下来的迭代中,
left = 0
,right = 0
,再次计算middle
仍然是0
。
此时,middle
没有变化,搜索区间始终是 [0, 0]
,算法陷入死循环。
死循环的根本原因
死循环的根本原因是搜索区间没有有效缩小。在二分查找算法中,区间的缩小是依赖于 left
和 right
的更新。如果在迭代过程中 left
和 right
的更新没有让区间缩小,middle
的值就不会改变,算法将不断重复这一过程,永远无法退出。
具体条件
-
区间不缩小:如果每次迭代后,
left
和right
没有有效缩小(即没有明确排除当前的middle
),算法无法找到目标值。 -
middle
重复:如果计算出的middle
在连续迭代中不发生变化,意味着搜索区间没有得到进一步缩小,因此会导致无限循环。
举个例子
假设我们使用错误的写法 right = middle
,并且数组 nums = [1, 3, 5]
,目标值是 2
:
-
初始:
left = 0
,right = 2
,计算middle
:middle = 0 + (2 - 0) / 2 = 1;
-
比较:
nums[middle] = 3
,由于nums[middle] > target
,按照错误的逻辑right = middle
,即right = 1
。 -
下一步:
left = 0
,right = 1
,重新计算middle
:middle = 0 + (1 - 0) / 2 = 0;
-
比较:
nums[middle] = 1
,由于nums[middle] < target
,设置left = middle + 1
,即left = 1
。 -
再次迭代:
left = 1
,right = 1
,计算middle
:middle = 1 + (1 - 1) / 2 = 1;
现在
middle = 1
,left == right == 1
,并且middle
一直是 1。由于你在前面的迭代中设置了right = middle
,区间没有缩小,并且middle
将保持为 1,导致无限循环。
正确做法:避免死循环
为了避免死循环,你需要确保每次迭代时搜索区间都能有效缩小。也就是说,当排除掉某些不可能的范围时,你需要将边界正确地调整:
- 当
nums[middle] > target
时,应该使用right = middle - 1
,以排除掉middle
这个位置,保证区间缩小。 - 当
nums[middle] < target
时,应该使用left = middle + 1
,排除掉middle
这个位置。
通过这样修改,left
和 right
每次迭代都会向中间靠近,最终使得 left
超过 right
,算法能够正确终止。
结论
陷入死循环的条件主要是因为**middle
不再变化**,这是由区间没有正确缩小导致的。为了避免这种情况,必须在每次迭代时确保 left
和 right
被正确地更新,从而让 middle
随着区间的变化而不断调整,最终收敛到解或者终止搜索。
数学问题
二分查找背后的思想和逻辑确实可以与某些数学问题相对应,特别是在以下几个方面:
1. 二分法求解方程(Bisection Method)
二分查找与二分法求解方程有直接的数学对应。二分法(Bisection Method)是一种用于数值求解方程的基础方法,主要用于寻找连续函数在某个区间内的零点(即函数值为零的点)。
二分法求解方程的基本思想:
给定一个连续函数 ( f(x) ),在区间 ([a, b]) 上,如果 ( f(a) ) 和 ( f(b) ) 的符号相反(即 ( f(a) \cdot f(b) < 0 )),那么根据中间值定理,函数在 ([a, b]) 之间必定有一个零点(解)。
二分法通过不断地将区间对半分,检查中点处的函数值,来逐步缩小包含零点的区间,直到找到零点或使区间足够小。
步骤:
- 计算中点 ( c = \frac{a + b}{2} )。
- 如果 ( f© = 0 ),则 ( c ) 是零点,返回。
- 如果 ( f(a) \cdot f© < 0 ),则零点位于 ([a, c]),更新区间为 ([a, c])。
- 如果 ( f© \cdot f(b) < 0 ),则零点位于 ([c, b]),更新区间为 ([c, b])。
- 重复上述步骤,直到区间足够小。
这个过程与二分查找极其类似——我们通过检查区间的中点,逐步缩小区间的大小。
关联:
- 二分查找是在一个有序数组中通过对比中间元素与目标值,来缩小目标可能存在的范围。
- 二分法求根是在一个连续函数中通过检查中点处的函数值,来缩小零点所在的区间。
两者的核心思想都是通过“分而治之”,每次把问题规模减半。
2. 对数时间复杂度(Logarithmic Time Complexity)
二分查找的时间复杂度是 ( O(\log n) ),这在计算机科学中是非常典型的“对数级别”问题。数学上,对数函数的特性决定了通过对半划分的过程可以很快缩小问题的规模。
在二分查找中,每次我们将搜索空间缩小为原来的一半,问题的规模随着递归的深度呈对数递减。假设初始问题的规模为 ( n ),那么我们大约只需要 (\log_2(n)) 次比较就能找到目标。
这与数学中的对数性质紧密相关:
[
\log_2(n) = k \implies 2^k = n
]
也就是说,当我们将问题规模减少到 ( 1 ) 时,我们进行了大约 ( \log_2(n) ) 次步骤,这就是二分查找背后的数学原理之一。
3. 离散搜索 vs 连续搜索
二分查找可以看作是离散版本的搜索问题,而二分法求解方程则是连续版本的搜索问题。两者都是通过逐步缩小解所在的区间,来逼近目标值:
-
离散搜索(Discrete Search):在有序数组中搜索特定值,这是二分查找的应用。数组是离散的,元素是有限的,每次搜索都在有限个候选项中进行。
-
连续搜索(Continuous Search):在一个函数的连续区间中搜索零点,这对应于二分法的应用。区间是连续的,函数值可以是任意实数。
尽管二者的对象不同(离散 vs 连续),但二分查找和二分法的核心思想相同,都是通过每次对半分割搜索区间,逐渐逼近目标。
4. 对数函数的反解问题
二分查找还可以类比于解决对数方程的过程。例如,对于一个方程 ( 2^x = n ),我们可以通过二分查找的方式来逼近 ( x )。通过逐步尝试不同的 ( x ) 值,并根据结果调整 ( x ) 的区间,直到找到合适的 ( x )。
这个过程与使用二分查找在一个对数分布中搜索目标值相似。因为每次的比较都将搜索空间减半,所以这种问题的复杂度也是对数级别的。
模板
在二分查找中,区间的边界定义有两种常见的方式:左闭右闭和左闭右开。这两种定义决定了循环的边界条件以及 left
和 right
的更新方式。下面我们分别介绍这两种方式的实现。
1. 左闭右闭区间([left, right])
定义:区间是左闭右闭,即 left
和 right
都是闭区间的边界,表示搜索区间是 ([left, right]),包括 left
和 right
两端的元素。
实现:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 注意right初始化为nums.size() - 1
while (left <= right) { // 循环条件是left <= right,表示[left, right]是闭区间
int middle = left + (right - left) / 2; // 防止溢出,等价于(left + right) / 2
if (nums[middle] == target) {
return middle; // 找到目标值,直接返回索引
} else if (nums[middle] < target) {
left = middle + 1; // 排除middle,搜索区间变为[middle + 1, right]
} else {
right = middle - 1; // 排除middle,搜索区间变为[left, middle - 1]
}
}
return -1; // 如果循环结束仍未找到目标值,返回-1
}
关键点:
- 初始区间:
left = 0
,right = nums.size() - 1
,表示整个数组的索引范围。 - 循环条件:
left <= right
,因为是左闭右闭区间,当left == right
时,区间仍然有效,需要检查。 - 更新规则:
left = middle + 1
:排除middle
,因为nums[middle] < target
,目标值在右边。right = middle - 1
:排除middle
,因为nums[middle] > target
,目标值在左边。
2. 左闭右开区间([left, right))
定义:区间是左闭右开,即 left
是闭区间的边界,right
是开区间的边界,表示搜索区间是 ([left, right)),包含 left
,但不包含 right
。
实现:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size(); // 注意right初始化为nums.size()
while (left < right) { // 循环条件是left < right,表示[left, right)是左闭右开区间
int middle = left + (right - left) / 2; // 防止溢出,等价于(left + right) / 2
if (nums[middle] == target) {
return middle; // 找到目标值,直接返回索引
} else if (nums[middle] < target) {
left = middle + 1; // 排除middle,搜索区间变为[middle + 1, right)
} else {
right = middle; // 搜索区间变为[left, middle),right是开区间,所以不排除middle
}
}
return -1; // 如果循环结束仍未找到目标值,返回-1
}
关键点:
- 初始区间:
left = 0
,right = nums.size()
,表示区间 ([0, nums.size())`,整个数组范围。 - 循环条件:
left < right
,因为是左闭右开区间,当left == right
时,区间为空,循环终止。 - 更新规则:
left = middle + 1
:排除middle
,因为nums[middle] < target
,目标值在右边。right = middle
:不排除middle
,因为nums[middle] >= target
,目标值可能是middle
。
左闭右闭 vs 左闭右开
方式 | 区间形式 | 循环条件 | right 初始化 | 更新 left | 更新 right |
---|---|---|---|---|---|
左闭右闭 | ([left, right]) | left <= right | nums.size() - 1 | left = middle + 1 | right = middle - 1 |
左闭右开 | ([left, right)) | left < right | nums.size() | left = middle + 1 | right = middle |
总结
- 左闭右闭:包含
left
和right
,即 ([left, right])。循环条件是left <= right
,在最后一次循环中检查left == right
的情况。 - 左闭右开:包含
left
但不包含right
,即 ([left, right))。循环条件是left < right
,当left == right
时,区间为空,停止循环。