从这篇文章开始,我将开启算法专栏,记录在刀砍leetcode算法过程中的理论总结与实战代码,我认为二分算法是算法问题中细节最多的部分,所以我先拿它开刀!二分题目实战请看我的二分查找专栏:二分查找实战专栏
1.Overview-三种问题类型、两种算法形式
⼆分搜索的原型就是在「有序数组」中搜索⼀个元素 target,返回该元素对应的索引。 如果该元素不存在,那可以返回⼀个什么特殊值,这种细节问题只要微调算法实现就可实现。
还有⼀个重要的问题,如果「有序数组」中存在多个 target 元素,那么这些元素肯定挨在⼀起,这⾥就涉 及到算法应该返回最左侧的那个 target 元素的索引还是最右侧的那个 target 元素的索引,也就是所谓的 「搜索左侧边界」和「搜索右侧边界」,这个也可以通过微调算法的代码来实现。但是一般算法题没有这么无脑,但是都可以转化为「搜索左侧边界」和「搜索右侧边界」问题。
另外,众所周知,我们二分查找实在一个区间范围内进行的,所以这就引申出了两种区间表示法这也对应了两种不同形式的二分查找代码框架(本质上是一样的,是可以相互转化的),且这两种形式的算法框架都可应用于我们上述的三种问题:搜索⼀个元素、「搜索左侧边界」、「搜索右侧边界」
这两种形式分别是左闭右闭区间的搜索、左闭右开区间的搜索
下面我们针对每种问题类型分别进行两种形式的算法框架详述
2.搜索一个元素target
2.1左闭右闭区间搜索 - [ ]
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
return -1 ;
}
1.因为我们设定好是左闭右闭区间形式,所以一开始区间左端点应该是对应于待查找序列第一个元素,区间右端点应该是对应于待查找序列队最后一个元素故有:
int left = 0, right = nums.length - 1;
2.为什么是left <= right 不是left < right ,因为若是left < right的话,算法进行到left=right时,我们不会进入while循环了,即没有对left和right同时指向的元素进行判断,就直接pass返回-1了,那万一这个元素就是我们的target怎么办。所以问题本质就是我们while括号里的东西要使得搜索范围包含了区间里的所有元素,不能遗漏,所以我们左闭右闭区间搜索对应的写法就是:
left <= right
那我们稍加思考不难发现,在算法没有找到target而结束while循环时left和right分别指向相邻的元素,其中left指向相邻后一位置,right指向相邻前一位置,他们发生交错。但凡能找到target,left和right是不会交错的,最极端也是相等的情况。
3.二分查找是折半查找,所以每次在区间内先判断的是中间元素是否是target,固有:
int mid = left + (right - left) / 2;
为什么不写mid = (left + right)/ 2 的形式,当然可以,但是这样会在left和right都非常大的情况下发生内存泄漏,而我们的写法就会避免这个问题,这点大家应该都知道,不多说
4.如果区间中间元素等于target,直接返回,不等于,我们将区间缩小一半继续查找:
if (nums[mid] == target) { return mid; }
5.如果target大于区间中间元素,则我们在中间元素右边区间寻找,所以这时候新区间左端点等于mid+1:
else if (nums[mid] < target) { left = mid + 1; }
6.同理,如果target小于区间中间元素,则我们在中间元素左边区间寻找,所以这时候新区间右端点等于mid-1:
else if (nums[mid] > target) { right = mid - 1; }
这里直接写成else right = mid - 1;也是可以的
7.最后如果跳出while,就说明没找到,返回 -1
2.2左闭右开区间搜索 - [ )
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid ;
}
}
return -1 ;
}
1.因为我们设定好是左闭右开区间形式,所以一开始区间左端点应该是对应于待查找序列第一个元素,区间右端点应该是对应于待查找序列队最后一个元素的后一个位置故有:
int left = 0, right = nums.length ;
2.为什么是left < right 不是left <= right ,因为若是left <= right,算法进行到left=right时( 对应区间假设为[ k , k) ),还会再进入一次while,重复了,为什么重复了呢?我们给right赋值为k的那一刻起,就说明k位置对应元素不是target(因为右开!!!)所以我们while括号里的写法是left < right,并不会造成遗漏
left < right
3.因为左闭右闭区间和左闭右开区间的左闭合的,我们对区间左端点的操作都是相同的 :
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
}
4. 为什么右端点处理不同?为什么不是right = mid - 1 ,因为我们发现target小于区间mid位置元素所以这时候新区间应该是[ left,mid ),所以我们right赋值为mid:
else if (nums[mid] > target) { right = mid ; }
3.搜索左侧边界
3.1左闭右闭区间搜索 - [ ]
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
right = mid - 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
}
}
if (left == nums.size()) return -1;//left换成right+1也可以
return nums[left] == target ? left : -1;
}
1.左闭右闭同前面一样一开始left对应为待查找区间的第一个元素,right对应为待查找区间的最后一个元素:
int left = 0, right = nums.length - 1;
2.为什么时 left <= right ,不多做解释,和前面一样
left <= right
3.当我们判断区间中间元素是否等于target时,若等于,不再是直接返回其位置,因为我们要找到我们序列中第一个等于target的元素位置(即左边界的含义),故我们要在right=mid-1,left不变的新区间内继续寻找:
if (nums[mid] == target) { right = mid - 1;}
4.如果target小于区间中间元素,那我们target肯定在mid左边的区间里,target的左边界肯定也在这个区间里,故我们将right赋值为mid-1
else if (nums[mid] > target) {right = mid - 1;}
5.如果target大于区间中间元素,那我们target肯定在mid右边的区间里,target的左边界肯定也在这个区间里,故我们将left赋值为mid+1:
else if (nums[mid] < target) { left = mid + 1;}
6.上面都很好理解,不好理解的是while结束时我们找没找到左边界呢?以及找到的话它在哪?
首先,我们要明确一点,在我们左闭右闭区间搜索中,我们结束while循环时,left和right是交错的,right指向相邻前一位置,left指向相邻后一位置。
其次,若我们在序列中存在target(潜台词:存在target就一定有左边界),那么算法结束时,left指向的就是这个左边界,没有为什么,算法决定的,你可以手动验证,那么若有target,则最后left的取值范围一定为 [ 0 , nums.length -1 ],right始终指向left的前一位(注意left=0的时候,right就等于-1哦~),这是找到的情况
那如果不存在target,left,right是什么情况呢?没找到无非有三种情况,每种情况下left都指向大于target的第一个元素,没有为什么,算法决定的,同样你可以手动验证,三种情况如下:
· target小于序列所有元素,算法执行过程中会一直减小right,left保持不变,那么最后终止时left=0,right=-1(0对应于大于target的第一个元素的位置)
· target大于序列所有元素,算法执行过程中会一直增大left,right保持不变,那么最后终止时right=nums.length-1,left=nums.length(nums.length是大于target的第一个元素的位置 )
· target介于序列所有元素之间,算法执行过程中left,right都会变化,终止时left指向第一个大于target的元素,right指向他前一个元素
所以我们最后判断有没有找到就是以left的位置为和对应位置元素值来判断,具体如下:
当算法结束时left=nums.length对应于target大于序列所有元素,没找到,返回-1,
if (left == nums.size()) return -1;
left不等于nums.length时,没法仅根据其位置判断是否有target,则需要用到 nums[left] == target 判断:
return nums[left] == target ? left : -1
3.2左闭右开区间搜索 - [ )
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] > target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
}
}
if(left==nums.length) return -1; //left换成right也可以
return nums[left]==target ? left : -1;
}
1.为什么right=nums.length?前面应该讲懂了
2.为什么while括号里是left<right?前面应该讲懂了
3.当区间中间元素等于target时,这时候有两种情况:
· mid左边还有别的target,我们令right=mid,新区间为[ left,mid ),我们在新区间里继续寻找target左边界,这是没问题的,算法继续执行一定会进入下面一种情况
· 若mid本身就是最左边的target了,我们令right=mid,这时候新区间[ left,mid)不含我们的target,算法还在继续但找了寂寞,那为什么还要这么写?我们稍加思索会发现,算法继续执行,最后left会等于right,跳出while循环,最后left,right都指向我们的左边界
所以当中间元素等于target时:
if (nums[mid] == target) { right = mid;}
4.当target小于区间中间元素时,target在mid左边区间,所以:
else if (nums[mid] > target) { right = mid;}
5.当target大于区间中间元素时,target在mid右边区间,所以:
else if (nums[mid] < target) { left = mid + 1;}
7.不好理解的地方是,while结束时,找没找到左边界?找到的话它在哪?
首先,我们明确一点,我们while循环结束是left和right是相等的,
其次,若我们序列中存在target,我们算法结束时left和right一定是指向左边界的,算法决定的,没有为什么,那么最后我们left和right指向的范围为[ 0 , nums.length-1 ]
那如果不存在target,即没找到target左边界,无非对应三种情况,每种情况下left和right都同时指向大于target的第一个元素 ,三种情况如下:
· target小于序列所有元素,算法执行过程中会一直减小right,left保持不变,那么最后终止时left=right=0(0对应于大于target的第一个元素的位置)
· target大于序列所有元素,算法执行过程中会一直增大left,right保持不变,那么最后终止时left=right=nums.length(nums.length是大于target的第一个元素的位置 )
· target介于序列所有元素之间,算法执行过程中left,right都会变化,终止时left和right指向第一个大于target的元素
所以和上面一样方式返回:
if(left==nums.length) return -1;
return nums[left]==target ? left : -1;
4.搜索右侧边界
4.1左闭右闭区间搜索 - [ ]
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length-1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid -1;
}
}
if(left - 1 < 0) return -1; // left-1换成right也可以
return nums[left-1] == target ? (left-1) : -1;
}
直接拎出不同点分析:left-1
如果序列中存在target,则算法结束时,right指向该target的右边界(和搜索左边界中左闭右闭区间搜法不一样),left指向相邻下一个元素
如果序列中不存在target,则算法结束时,也是对应的三种情况,每种情况下left指向大于target的第一个元素(和搜索左边界中左闭右闭区间搜法一样),right指向相邻上一个元素
4.2左闭右开区间搜索 - [ )
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
if(left - 1 == nums.length) return -1; // left-1换成right-1也可以
return nums[left-1] == target ? (left-1) : -1;
}
如果序列中存在target,则算法结束时,left和right指向该target的右边界的下一位(和搜索左边界中左闭右开区间搜法不一样),left指向相邻下一个元素
如果序列中不存在target,则算法结束时,也是对应的三种情况,每种情况下left和right都指向大于target的第一个元素(和搜索左边界中左闭右开区间搜法一样),right指向相邻上一个元素