前言
这几天看灵神的二分查找 红蓝染色法的视频收获颇多,借这个机会和大家分享一波自己的感悟,希望能给各位带来一些启发。原视频链接:灵茶山艾府讲解二分查找 红蓝染色法。其实灵神已经把这类问题讲得非常清楚了,我站在我一个菜鸡的视角和大家谈谈我的理解,希望可以对大家有所帮助。(文章写完了来补一句,文章很长,希望大家耐心看完,我写爽了,觉得同学们认真看完一定能有所收获^ _ ^)
例题引入 leetcode34. 在排序数组中查找元素的第一个和最后一个位置
灵神讲的例题就是这一道:
leetcode 34. 在排序数组中查找元素的第一个和最后一个位置 如果各位过去接触过二分,可以先去尝试一下这道题,没有做过建议直接看视频或者直接听我接下来的讲解。解这道题,我们要着重解决的就是下面的四个问题(以下四个问题通了,不仅这道题懂了,这类二分问题都懂了)
- 边界到底取啥?看答案有些时候是left = 0, right = nums.size() - 1,有些时候怎么又是left = 0, right = nums.size() 了…
- 循环结束的条件到底是啥?看答案有些时候是left <= right,有些时候怎么又是left < right了…
- 这一次查找完nums[mid]以后,怎么更新left和right呢?看答案有些时候是left = mid + 1,有些时候怎么又是left = mid了…
- 返回的到底是什么?看答案怎么有些时候是left,有些时候又是left + 1了…
解答
各位看到的五花八门的答案的原因其实就是上述问题1的存在------------边界怎么取。
如果你选择左闭右闭,即初始化left = 0, right = nums.size() - 1,那么所见即所得,即left和right所指向的位置就是你这个区间的开始和结束。
那么针对这种情况,循环结束的条件自然而然就是left <= right,因为left == right时,仍证明这个区间中有元素!
int binarySearchBothClosedIntervalFindStart(vector<int>& nums, int target)
{
// 左闭右闭
int left = 0, right = nums.size() - 1;
// 为什么是小于等于,因为两边都是闭区间,小于等于保证区间内有元素
while ( left <= right )
{
// ...
}
// ...
}
现在请大家想一想左闭右开的情况,以及左开右开的情况。
这是左闭右开的情况,left指向的就是第一个元素,right的前一个位置才是区间中最后一个元素。
循环结束的条件是不是应该变成了while ( left < right ),应该很好想,因为如果left == right了,那是不是说明实际的最后一个位置(right指向的前一个位置)已经在第一个位置以前了,此时说明了区间中已经没有元素了。
int binarySearchHalfOpenHalfClosedIntervalFindStart(vector<int>& nums, int target)
{
// 左闭右闭
int left = 0, right = nums.size();
while ( left < right )
{
// ...
}
// ...
}
我觉得上面的两种方案讨论完,左开右开大家理解起来应该就很简单了。
int binarySearchHalfOpenHalfClosedIntervalFindStart(vector<int>& nums, int target)
{
// 左闭右闭
int left = -1, right = nums.size();
// left指向的后一个元素才是实际元素,right指向的前一个元素才是实际元素
// 一旦left + 1 == right,说明了第一个元素在最后一个元素的后面
while ( left + 1 < right )
{
// ...
}
// ...
}
好,现在上方的提出的四个问题已经被我们解决了两个,非常的简单。但是还是要给大家浇一盆凉水,因为3、4才是我们真正需要解决的硬骨头。
进一步解答
接下来就是红蓝染色法隆重登场的时候了,本题需要我找到target第一次出现和最后一次出现的位置(这个题有一个隐藏特点,就是如果你能找到第一次出现的位置,那么他就一定不止出现一次,也就是答案不可能是[index, -1],知道了这一点读懂灵神给的代码就很轻松了,这个我们后面再说)
现在我们暂时跳出一下这道题,来看一看一段很多同学非常熟悉的代码:
class Solution {
public:
int search(vector<int>& nums, int target)
{
int low = 0;
int high = nums.size() - 1;
while ( low <= high )
{
int mid = (low + high) / 2;
if ( nums[mid] == target )
return mid;
else if ( nums[mid] > target )
high = mid - 1;
else
low = mid + 1;
}
return -1;
}
};
这代码实在太经典了,这就是很多同学写过的二分搜索的代码。现在我想带大家从另外一个视角来理解一下这段代码:
现在我们想在nums = [-1,0,3,5,9,12]中找到target = 2,显然,2并不存在于nums中,最终会返回-1。我们跟着上面的代码跑一跑,模拟一下运行过程,在运行的过程中,我们定一个规矩,小于target = 2的元素我们标红,大于target = 2的元素我们标蓝。
这里的left和right的更新方式是我们上面所说的左闭右闭,循环结束条件自然而然也就是left <= right。不知道大家觉不觉得这里更新left = mid + 1,right = mid - 1是顺理成章的?比如我第一次nums[mid] = 3 > 2,我自然而然就知道3及之后的所有的元素都不可能再包含2这个元素了,也就是蓝区所有的元素都被否决了,因此我需要去还没有被染色的区域继续去寻找这个2,而我采用的策略是左闭右闭,自然要将right更新为mid - 1,这是未被染色区域的最后一个元素且因为右闭我必须将right指向实际的元素。left = mid + 1的理解同理。
最后的结果就如最后一张图上所呈现的,界限非常分明。(PS:我上面只举了target并不存在于nums的例子,因为如果target存在于nums中,那么这个色没染完整个过程就结束了,我想讲的东西就没有表达出来)
这里我还想着重提一个点,就是right < left时循环结束了,此时在左闭右闭这个大条件下,似乎right < left就是right == left - 1。二者在整个过程结束后,一定就是1的差值。我们不妨思考一下为什么,想结束这个循环,最最最靠近结束这个循环的时机是不是就是 right == left - 1时 。而在target不存在与nums中条件下,所有元素是不是一定都要被染色?那么是不是循环结束前,一定会有一个left == right == mid,即最后一定一定会有一个元素未被染色。那么无论此时这个元素被染成什么颜色,只有两条路可走,要么left = mid + 1,要么right = mid - 1,此时循环都一定会结束了。因此整个nums被染完色之后,一定是left = right + 1的情况。
我这么大费周章的跟各位模拟这个过程都是在为我接下来要说的话做铺垫:针对这里的34题,要找到target第一次出现的位置,我是不是可以给nums去染色。把小于target的染成红色,大于和等于target的染成蓝色。最后蓝色区域的第一个元素的下标就是我要求的答案!!!
我们带着上面的知识储备来模拟一下在nums = [5,7,7,8,8,10]中target = 8第一次出现的位置
不难发现,left / right + 1都可以作为最后可以返回的结果。
下面就是代码实现:
int binarySearchBothClosedIntervalFindStart(vector<int>& nums, int target)
{
// 左闭右闭
int left = 0, right = nums.size() - 1;
// 为什么是小于等于,因为两边都是闭区间,小于等于保证区间内有元素
while ( left <= right )
{
int mid = (left + right) / 2;
// nums[mid]包括其左边所有元素均小于target
// 由于都是闭区间,因此left更新为mid + 1
if ( nums[mid] < target )
left = mid + 1;
// nums[mid]包括其右边所有元素均大于target
// 由于都是闭区间,因此right更新为mid - 1
else if ( nums[mid] >= target ) // >=!!!
right = mid - 1;
}
return left;
}
而我们去找target所存在的最后一个位置就可以采用相同的思路:就是给小于等于target的元素染红,把大于target的元素染蓝。
不难看出,最终要返回的结果就是right。
下面是代码实现:
int binarySearchBothClosedIntervalFindEnd(vector<int>& nums, int target)
{
// 左闭右闭
int left = 0, right = nums.size() - 1;
// 为什么是小于等于,因为两边都是闭区间,小于等于保证区间内有元素
while ( left <= right )
{
int mid = (left + right) / 2;
if ( nums[mid] <= target ) // <=!!!
left = mid + 1;
else if ( nums[mid] > target )
right = mid - 1;
}
return right;
}
以下是完整的代码实现:
class Solution {
public:
int binarySearchBothClosedIntervalFindStart(vector<int>& nums, int target)
{
// 左闭右闭
int left = 0, right = nums.size() - 1;
// 为什么是小于等于,因为两边都是闭区间,小于等于保证区间内有元素
while ( left <= right )
{
int mid = (left + right) / 2;
// nums[mid]包括其左边所有元素均小于target
// 由于都是闭区间,因此left更新为mid + 1
if ( nums[mid] < target )
left = mid + 1;
// nums[mid]包括其右边所有元素均大于target
// 由于都是闭区间,因此right更新为mid - 1
else if ( nums[mid] >= target )
right = mid - 1;
}
// 循环结束时,一定是left == right + 1的情况!!!
// 此时数组的楚河汉界已经非常分明了!
// left一定是>=的起点,而right一定是<的终点
// 因此想找target一定要从>=那边去找
return left;
}
int binarySearchBothClosedIntervalFindEnd(vector<int>& nums, int target)
{
// 左闭右闭
int left = 0, right = nums.size() - 1;
// 为什么是小于等于,因为两边都是闭区间,小于等于保证区间内有元素
while ( left <= right )
{
int mid = (left + right) / 2;
if ( nums[mid] <= target )
left = mid + 1;
else if ( nums[mid] > target )
right = mid - 1;
}
return right;
}
vector<int> searchRange(vector<int>& nums, int target) {
int start = binarySearchBothClosedIntervalFindStart(nums, target);
// 如果蓝色区域的第一个元素不为target,那么就说明nums中不存在target
if ( start == nums.size() || nums[start] != target )
return vector<int>{-1, -1};
int end = binarySearchBothClosedIntervalFindEnd(nums, target);
return vector<int>{start, end};
}
};
现在大家回看一下前面提到的几个问题,只要能够充分理解自己的区间选择策略以及染色策略,都可以写出条理非常清晰的代码。
- 边界到底取啥?
- 循环结束的条件到底是啥?
- 怎么更新left和right?
- 返回的到底是什么?
左闭右开和左开右开的代码留给大家当作业!代码我在下方贴出来:
左闭右开
class Solution {
public:
int binarySearchHalfOpenHalfClosedIntervalFindStart(vector<int>& nums, int target)
{
// 左闭右开
int left = 0, right = nums.size();
while ( left < right )
{
int mid = (left + right) / 2;
if ( nums[mid] < target )
left = mid + 1;
else if ( nums[mid] >= target )
right = mid;
}
// 最后的结果一定是l == r
// 因此此时返回l和r都可以,都是蓝色区域的第一个元素下标
return left;
}
int binarySearchHalfOpenHalfClosedIntervalFindEnd(vector<int>& nums, int target)
{
// 左闭右开
int left = 0, right = nums.size();
while ( left < right )
{
int mid = (left + right) / 2;
if ( nums[mid] <= target )
left = mid + 1;
else if ( nums[mid] > target )
right = mid;
}
// 最后的结果一定是left == right
// 红色区域的最后一个下标是left - 1或者right - 1
return left - 1;
}
vector<int> searchRange(vector<int>& nums, int target) {
int start = binarySearchHalfOpenHalfClosedIntervalFindStart(nums, target);
if ( start == nums.size() || nums[start] != target )
return vector<int>{-1, -1};
int end = binarySearchHalfOpenHalfClosedIntervalFindEnd(nums, target);
return vector<int>{start, end};
}
};
左开右开
class Solution {
public:
int binarySearchBothOpenIntervalFindStart(vector<int>& nums, int target)
{
// 左开右开
int left = -1, right = nums.size();
while ( left + 1 < right )
{
int mid = (left + right) / 2;
if ( nums[mid] < target )
left = mid;
else if ( nums[mid] >= target )
right = mid;
}
// 最后一定是left + 1 == right的情况,right是蓝色区域的第一个下标
return right;
}
int binarySearchBothOpenIntervalFindEnd(vector<int>& nums, int target)
{
// 左开右开
int left = -1, right = nums.size();
while ( left + 1 < right )
{
int mid = (left + right) / 2;
if ( nums[mid] <= target )
left = mid;
else if ( nums[mid] > target )
right = mid;
}
// 最后一定是left + 1 == right的情况,right是红色区域的最后一个下标
return left;
}
vector<int> searchRange(vector<int>& nums, int target) {
int start = binarySearchBothOpenIntervalFindStart(nums, target);
if ( start == nums.size() || nums[start] != target )
return vector<int>{-1, -1};
int end = binarySearchBothOpenIntervalFindEnd(nums, target);
return vector<int>{start, end};
}
};
灵神给出的代码
真的牛逼,这脑子是一点学不来。我们在写出了找到target第一次出现的位置的函数后,其实根本不必额外再去写一个实现找到target最后一次出现位置的函数。我们只需要转变一下染色的思路。把小于target + 1的元素染成红色,大于等于target + 1的元素染成蓝色。这样最后left - 1这个下标就是我们要求的答案。
class Solution {
public:
int binarySearchBothClosedInterval(vector<int>& nums, int target)
{
// 左闭右闭
int left = 0, right = nums.size() - 1;
// 为什么是小于等于,因为两边都是闭区间,小于等于保证区间内有元素
while ( left <= right )
{
int mid = (left + right) / 2;
// nums[mid]包括其左边所有元素均小于target
// 由于都是闭区间,因此left更新为mid + 1
if ( nums[mid] < target )
left = mid + 1;
// nums[mid]包括其右边所有元素均大于target
// 由于都是闭区间,因此right更新为mid - 1
else if ( nums[mid] >= target )
right = mid - 1;
}
// 循环结束时,一定是left == right + 1的情况!!!
// 此时数组的楚河汉界已经非常分明了!
// left一定是>=的起点,而right一定是<的终点
// 因此想找target一定要从>=那边去找
return left;
}
vector<int> searchRange(vector<int>& nums, int target) {
int start = binarySearchBothClosedInterval(nums, target);
if ( start == nums.size() || nums[start] != target )
return vector<int>{-1, -1};
// 按照题目的设定,如果start一定存在,就说明一定能找到end
// 以nums = [5,7,7,8,8,10]找到target = 8最后一次出现的位置为例
// 我把小于9的染成红色,大于等于9的染成蓝色
// 因此我上方的代码返回的蓝色区域的第一个元素的下标
// 这个结果值-1就是我要的结果
int end = binarySearchBothClosedInterval(nums, target + 1) - 1;
return vector<int>{start, end};
}
};
以上就是本题的全部讲解,希望红蓝染色法能对大家理解二分法提供一些帮助!