二分法
这题看似简单,但是我们要理解二分法的边界法则是如何确定的。其遵从循环不变量法则,区间的定义就是不变量。
一般我们的区间方式分为两种,一种是左闭右闭,另一种是左闭右开,左开右开会进入死循环,左开右闭一般不考虑(后面解释)
我们先来看左闭右闭的情况
这是代码:
class Solution {
public int search(int[] nums, int target) {
// 避免当 target 小于nums[0] nums[nums.length - 1]时多次循环运算
if (target < nums[0] || target > nums[nums.length - 1]) {
return -1;
}
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1;
else if (nums[mid] > target)
right = mid - 1;
}
return -1;
}
}
这里要提的一点是关于左闭右闭区间的注意点
首先,由于是左闭右闭,因此左右区间的开始界限都应该在数组的第一位和最后一位处(对应的数组下标必须要都有意义)
其次,左闭右闭时两指针相等是有意义的,因此循环的结束条件要定义为<=
最后因为是左闭右闭,当我们的mid不为目标值时,我们直到mid代表的值及其后面的边界也必然不为目标值,因此边界要提升到mid的加一个位置,这是由于左闭右闭边界推导出来的边界法则
接着我们来看左闭右开的情况
然后来看看代码
class Solution {
public int search(int[] nums, int target) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1;
else if (nums[mid] > target)
right = mid;
}
return -1;
}
}
这里同样有三点要注意的
首先是由于是左闭右开的原则,因此起始边界的左边界第一位必然为0(有意义),而右边界正好为边界值,不-1,正好是有意义的下一位,因为右开就意味着右边界代表的值不会被搜索,因此越界一位没有问题,而数组长度正好就是其有意义的越界一位的值,此时正好是我们所需要的
其次是左闭右开原则下,左右指针最后都指向同一个值时,右指针代表该值必然不为我们所需要的值,就没必要搜索,因此左右指针相等时没有任何意义,所以我们的循环判断条件是<
最后是我们的每次收缩边界的方式是左边界+1,右边界定位到中值,这是因为在左闭右开的原则下,我们也能够判定中值必然不为我们所需要的条件,但因为我们是左闭右开,左边界的指针定义的值是一定要有意义的,因此左边界+1,直接排除中值,而右边界定位到中值,这个离有意义差一位的值,实际判断时我们并不会判断到中值,我们这里这样做只是为了和我们最开始的边界值设定保持一致
学习完了上面这些之后,我们就可以解释为什么左开右闭一般不考虑了(而且试了下也不能过),因为左开,意味着你的左边界最开始得是-1,这太几把离谱了,一般我们不会这么做
二分法两个边界结束循环时两个指针的定位位置
同样的,我们这里先讲左闭右开的方法,请看代码
class Solution {
public int searchInsert(int[] nums, int target) {
int len = 0,right = nums.length;
while (len<right){
int mid = len + (right-len)/2;
if(nums[mid]==target){
return mid;
}else if(nums[mid]>target){
right=mid;
}else {
len=mid+1;
}
}
//返回len也可以
return right;
}
}
这里其实就说明了,左闭右开的二分法最终结束位置会将两个边界都定位到比目标值大一位的值上(前提是目标值不存在,目标值存在其会直接返回目标值所对应的下标)
接下来请看左闭右闭的代码
class Solution {
public int searchInsert(int[] nums, int target) {
int len = 0,right = nums.length-1;
while (len<right){
int mid = len + (right-len)/2;
if(nums[mid]==target){
return mid;
}else if(nums[mid]>target){
right=mid-1;
}else {
len=mid+1;
}
}
//right+1也可以
return len;
}
}
由上面代码我们可以知道当目标值不存在时,左闭右闭的二分法最终会让右指针定位到目标值小一位处,而左指针会定位到目标值大一位处(前提仍然是目标值不存在)
二分法的两种形式的进一步了解
解决这个问题的思路是先用二分法找出左边界,再用二分法找出右边界,最后我们将左右边界返回,但是因为题目中可能并不存在目标值,因此我们要判断左右边界是否合法的情况
我们先来说使用左闭右开的方法,先来看看代码
class Solution {
public int[] searchRange(int[] nums, int target) {
int left = search(nums,target,true);
int right = search(nums,target,false);
if(left==-2 || right==-2){
return new int[]{-1,-1};
}
if(right-left>=1){
return new int[]{left,right-1};
}
return new int[]{-1,-1};
}
private int search(int[] nums, int target,boolean judge) {
int ans = -2;
int len = 0,right = nums.length;
//寻找左边界
if(judge){
while (len<right){
int mid = len + (right - len)/2;
if(nums[mid]<target){
len=mid+1;
}else {
right=mid;
ans = right;
}
}
return ans;
}
//寻找右边界
while (len<right){
int mid = len + (right - len)/2;
if(nums[mid]>target){
right=mid;
}else {
len=mid+1;
ans=len;
}
}
return ans;
}
}
先来讲讲代码本身的逻辑,调用两次方法获得对应的左右边界,然后判断左右边界的合法性,若有任何一个边界为-2,则说明边界有一个边界不存在,此时说明数组中的目标值在树中的最左边或者最右边且不存在,此时返回默认值。
第二个判断是判断左右边界是否合法,合法的前提是左边界要小于或者是等于右边界(之所以可以等于,是因为可能出现开始位置和结束位置均在一个位置的数),但为什么我们这里使用的是大于等于1而不是简单的右指针大于等于左指针?别急,下面我们再解释。
上面两个判断都不进入,说明数组中根本不存在目标值,此时仍然返回目标值
首先我们要搞清楚,当数组中存在对应的目标值时,左闭右开的二分法会将左指针定位到目标值的第一位,右指针定位到该值的下一位。当数组中不存在该值且该不存在的值并不是位于两边时,左闭右开的二分法会将左右指针一起定位到目标值的下一位上(这里是假设目标值在的情况而言的)
我们在方法里用true和false的判断来作为选项简化代码,传入true说明求左边界,传入false说明找右边界。同时我们定义了一个ans变量用于定位我们所需要的值的下标。由于我们采用的方法是左闭右开,因此我们的左边界总是会定位到目标值上(目标值存在的话),所以我们的ans在求左边界中直接令ans等于right就可以了。而在右边界里,我们的右边界总是偏移一位的,因此我们的右边界要进行-1的操作
那为什么左闭右开的缩小边界的方式是在判断条件之后而不是之前呢?这是因为如果我们将缩小边界的值放在下面,那么当值不存在且同时是处于数组最左或者最右时,我们的边界值仍然有可能变化,此时会令第一个判断条件失效导致出错。但实际上测试我们会发现即使下面先更改,上面用右边界大于左边界也不会出错,这是为什么呢?这是因为左闭右开的方式中并不是每一次缩小边界都会更新对应的边界值,当数字根本不存在于数组且在最左边或者最右边时,前者会令左边界压根不更新,后者则会让右边界到达-3的情况,第一个过滤不了,你-3也无法进入第二个啊,因此还是会返回默认值。但这种方式其实是不够好的,因此我们需要用第一种方式,将边界值放到上面来修改
而当我们将边界值放到上面来修改时,如果我们采用右边界大于等于左边界的方式,则当我们的目标值在数组中不存在时,会出现左右边界都指向下一位的情况,然后此时再进行右边界的缩小,会出现左边界大于右边界的情况,这肯定不是我们所需要的,因此我们要更新我们的判断方式,这里我们将判断方式更改为右边界-左边界>=1,则当目标值不存在时,其结果必然为0,0不小于1,就能够正确过滤。而对于存在的值,其也能正确判断出其至少为1,因此我们采用这种判断方式
接着要提的内容是我们这类ans的更新条件,我们ans的更新条件就是我们的边界的更新与否,求左边界时,我们的ans的更新就与右边界相关(因为我们求出左边界的方式是不断压缩右边界,所以随着右边界而更新),而我们求右边界时则是与左边界的更新与否相关,也是同理的。这里的ans负责两个事情,一是用于定位目标指针,二是用于作为判断目标值是否不存在且位于数组最左或者最右的条件
在主方法里获得了两个边界之后,我们对边界本身进行判断,最后就可以获得正确答案了
接着我们来讲左闭右闭的方法,先看代码
class Solution {
public int[] searchRange(int[] nums, int target) {
int left = search(nums,target,true);
int right = search(nums,target,false);
if(left==-2 || right==-2){
return new int[]{-1,-1};
}
if(right-left>1){
return new int[]{left+1,right-1};
}
return new int[]{-1,-1};
}
private int search(int[] nums, int target,boolean judge) {
int ans = -2;
int len = 0,right = nums.length-1;
//寻找左边界
if(judge){
while (len<=right){
int mid = len + (right - len)/2;
if(nums[mid]<target){
len=mid+1;
}else {
right=mid-1;
ans = right;
}
}
return ans;
}
//寻找右边界
while (len<=right){
int mid = len + (right - len)/2;
if(nums[mid]>target){
right=mid-1;
}else {
len=mid+1;
ans=len;
}
}
return ans;
}
}
这里我们采用的左闭右闭的方式,因此要对代码做相应的改动,比如说循环判断条件要改成<=,其次是边界的缩减法则都应该要改成从中值的后一位开始
我们要明确一点,就是左闭右闭的方式,对于数组中已经存在的值,会准确定位到其前一位和直到其后一位,如果数组中不存在那个值,那么就左右指针就会定义到那个值添加之后的位置(假设其添加)的左右各一位下标上。还有就是最开始的边界设定是0开始,而结束边界是在num.lenth()-1
我们第一个判断条件是用于过滤目标数字在不存在且在最左边或者是最右边的情况,此时我们的必然有一个边界根本没有进行任何的边界更新,此时其边界值必为-2,则会对其进行排除。但如果我们在最下面就进行+1,-1的缩小边界值的操作,那么就会出现原本的ans因为缩小边界导致数字变化从而令第一个判断代码失效的情况,因此这里我们的缩小边界值的操作要放到上面
最后是为什么第一个判断条件是右边界减去左边界大于1,而不是右边界大于左边界,因为在左闭右闭的情况里,当数字不存在时,左右指针仍然会准确定位到其左右,此时仍然满足右大与左,但这显然不是我们想要的结果,因此我们要对我们的判断条件进行相应的修改,我们能够发现当其不存在时,左右数字是正好为1的,如果其存在,那么必然大于1,因此我们这里用这个判断条件就能够将其不存在的情况过滤掉。当然我们也可以使用直接判断其指针对应的值是否等于目标值的方式,但那样的话还要处理边界问题,就显得麻烦了
最后提示一点,一般我们使用左闭右闭的二分法比较多
双指针法
双指针法是做数组问题时效率较高同时又比较常用的方法,需要我们重点掌握。一般的双指针法有两种思路,第一种是左右指针逼近,另一种是两个指针从同一个方向出发。这里一般会结合数组的交换方法或者是每次比较两个指针的大小,然后存在第三个用于赋值的指针。对于双指针法,我们解题的步骤有三。分别是1.确定双指针分别是从哪里出发。2.确定双指针结束的情况。3.确定双指针的内部的比较逻辑。确定了这三点之后,我们再构造对应的代码,一般可以结合for代码,此时for本身就代表一个指针,这里一般使用于两个指针从同一方向出发的情况。或者是结合while代码,这里一般适用于两个代码从不同方向出发的情况。
滑动窗口
请看代码:
class Solution {
// 滑动窗口
public int minSubArrayLen(int s, int[] nums) {
int left = 0;
int sum = 0;
int result = Integer.MAX_VALUE;
for (int right = 0; right < nums.length; right++) {
sum += nums[right];
while (sum >= s) {
result = Math.min(result, right - left + 1);
sum -= nums[left++];
}
}
return result == Integer.MAX_VALUE ? 0 : result;
}
}
本题中我们本质上也使用了双指针法,有两个指针进行工作,但是这个工作方式更像是窗口的移动,因此我们称之为移动窗口法
移动窗口的解题步骤主要有三,一是窗口内究竟存储什么,其次是何时要扩大窗口,最后是何时要缩小窗口,这三点是移动窗口法的关键所在,其实最关键的是第二三点,使用移动窗口法必然要解决第二三点,否则压根没法用。而滑动窗口有时也是要借助一些集合来实现的,或者是要创造一些其他的变量用于提高效率,但那些都是锦上添花的工作,我们要实现滑动窗口还是得先解决最初的三点,只有先实现了最初的滑动窗口,我们后面才能够提高其效率,毕竟要是连最初的题解都没有,那提高个der。
比如在本题中,我们的滑动窗口的代码如下:
class Solution {
// 滑动窗口
public int minSubArrayLen(int s, int[] nums) {
int left = 0;
int sum = 0;
int result = Integer.MAX_VALUE;
for (int right = 0; right < nums.length; right++) {
sum += nums[right];
while (sum >= s) {
result = Math.min(result, right - left + 1);
sum -= nums[left++];
}
}
return result == Integer.MAX_VALUE ? 0 : result;
}
}
在本题中,我们先定义了左右指针,这是当然的,滑动窗口必须要定义左右指针作为窗口,我们这里的滑动窗口题目中,我们滑动窗口内存放的符合条件的数组数字之和小于s的数组片段,我们滑动窗口右移的条件是窗口内的数字之和还没有超越s,左移的条件是数组内的片段之和大于s
滑动窗口的进一步理解
虽然题目看起来又臭又长还不好理解,不过我们可以简单理解成求两个不同元素的最长子数组
本题滑动窗口的代码:
class Solution {
public int totalFruit(int[] fruits) {
int len = 0,cnt = 0,ans = 0;
Map<Integer,Integer> map = new HashMap<>();
for (int i = 0; i < fruits.length; i++) {
if(!map.containsKey(fruits[i]) && cnt<2){
map.put(fruits[i], map.getOrDefault(fruits[i],0)+1);
ans=Math.max(i-len+1,ans);
cnt++;
}else if(map.containsKey(fruits[i]) && map.get(fruits[i])!=0){
map.put(fruits[i], map.getOrDefault(fruits[i],0)+1);
ans=Math.max(i-len+1,ans);
}else if(!map.containsKey(fruits[i]) && cnt==2){
map.put(fruits[len], map.getOrDefault(fruits[len],0)-1);
if(map.get(fruits[len])==0){
cnt--;
map.remove(fruits[len]);
}
i--;
len++;
}
}
return ans;
}
}
本题滑动窗口的代码是利用了Map集合构造的,窗口内存放的内容是两个只有两个不同元素的子数组,窗口右移的条件是窗口内的元素不到两个或已经到了两个但下一个要加入的元素是这两种元素的其中一个,窗口左移的条件是窗口内的元素已经到达了两个且下一个要加入的元素不是窗口内的一种元素,此时要不断缩小窗口直到元素种类重新减少为1。这里为了模拟窗口中已经不存在先前元素的过程要进行其种类数量是否为0的判断,若为0则删除这个元素,这个删除的模拟是很重要的,因为如果没有这个过程,会出BUG,这个删除对应移动窗口里不存在这个元素了
模拟打印数组
这一类的题目不涉及什么算法,就是嗯模拟就完了,但是这个模拟的过程本身也具有挑战性,如果模拟好是关键,一个简单想法就是模拟上下左右的赋值,这里我们要注意的是为了让我们的程序变得统一,因此我们这里对于边界的处理应该要统一,这是非常重要的一点,如果一开始就定义好了要左闭右开,那么就一直都是左闭右开,不要 一会左闭右开,一会左闭右闭的,这样代码很乱,也不易于维护,还容易出BUG
接下来请看模拟的代码:
class Solution {
public int[][] generateMatrix(int n) {
int[][] res = new int[n][n];
// 确定每个圈循环几次,例如n为奇数3,那么loop = 1 只是循环一圈,矩阵中间的值需要单独处理
int loop = n / 2;
// 定义每循环一个圈的起始位置
int startX = 0;
int startY = 0;
// 定义偏移量,因为每一圈循环,就需要控制每一条边遍历的长度
int offset = 1;
// 定义填充数字
int count = 1;
// 定义中间位置,用于给最后奇数长度的矩阵的中心赋值
int mid = n / 2;
while (loop > 0) {
int i = startX;
int j = startY;
// 模拟上侧从左到右(左闭右开)
for (; j<n -offset; ++j) {
res[startX][j] = count++;
}
// 模拟右侧从上到下(左闭右开)
for (; i<n -offset; ++i) {
res[i][j] = count++;
}
// 模拟下侧从右到左(左闭右开)
for (; j > startY; j--) {
res[i][j] = count++;
}
// 模拟左侧从下到上(左闭右开)
for (; i > startX; i--) {
res[i][j] = count++;
}
loop--;
//第二圈开始的时候,起始位置要各自加1, 例如:第一圈起始位置是(0, 0),第二圈起始位置是(1, 1)
startX += 1;
startY += 1;
// offset 控制每一圈里每一条边遍历的长度
offset += 1;
}
// 如果n为奇数的话,需要单独给矩阵最中间的位置赋值
if (n % 2 == 1) {
res[mid][mid] = count;
}
return res;
}
}
这里我们就是利用了左闭右开对上下左右四个方向都进行了模拟,这里有一些变量需要着重解释。首先是startX和startY是最开始的起始量,我们先定义循环的次数,也就是每次循环填充四条边达成目标值所需要的循环次数
我们先看24行到27行的代码,这里的for循环的边界是j< n -offset,也就是n原来的边界值加上起始值再减去偏移量,因为我们的值是左闭右开的,因此我们这里总是<或者是>符号,我们从左到右(或从上到下)模拟时,边界值为n-offset,这是因为我们的边界值最右和最下的特性决定的边界值,而当我们从右到左(从下到上)模拟时,我们的边界值是大于其所对应的起始量,这是由于我们从哪里出发,最后绕环之后按照左闭右开原则我们总不会覆盖环中的一个边界值决定的,这里大于其起始位置而不是简单的大于0的设置缘由是为了让我们在不同的位置也能够准确地执行到对应位置的左闭右开,模拟绕环时环逐渐缩小的过程
最后每次循环我们将起始位置增加,同时将偏移量增加,最后结束之后我们判断形成的圈是否为奇数圈,若是则在其对应的中心位置赋值,进行特殊处理
这里还有一点很重要的是,每次循环时我们我们的模拟顺序是连续的,这样就让我们的变量在变化之后可以正好符合下一个条件