二分查找模版、题型全面解析

1,268 阅读24分钟

导言:

二分查找算是最为基本的一个算法,也比较容易掌握。但是有些时候,我们可能因为一些细节的点没有考虑全而程序出错。虽然这是一个简单的算法,但是其也有比较高级的应用,比如按值二分,这篇文章将会从解题模版开始,来介绍一些二分查找的常见应用和题型。


什么是二分查找

比二分查找更简单的算法,我能想到的只有 遍历枚举,说的直白些,就是写 for 循环。我们通常需要在一个数组当中找一个数,这个时候我们可以写一个 for 循环去挨个查找,这么下来,时间复杂度就会是 O(n)。如果我告诉你,这个数组是排序好的,这时我们就可以使用二分查找去找这个数,我们可以选择数组中间的元素,这个中间元素会把数组分成前后两个相等的区间,如果我们要找的元素比中间元素要大,证明这个元素只可能在后半部分区间,我们就只需要去到后半部分区间用类似的方法再次查找,如果比中间元素要小,则需要去到前半部分区间用类似的方法再次查找,直到最后我们找到了,或者说整个数组给分完了(没找到),这样的话时间复杂度是 O(logn)。O(n) 和 O(logn) 可能直接看表达式不够形象,我举个例子你就懂了,假设数组长度是 4294967296,如果是 O(n) 时间的话,最差情况下你需要去找 4294967296 次,如果是 O(logn),最差情况下你只需要去找 32 次。

你可以形象地理解每次操作我们都把数组对折,形成一个新的数组,因此二分查找又叫做折半查找。相信有一定编程基础的人都能够理解这个算法,我们的重点将会放在具体的实现和题型上面。


二分查找模版

我之前也讲过,解题模版能够省去你很多的额外思维负担,当然,重要的还是理解,这里我首先放上一个我觉得好的模版,可能会跟其他的一些常见模版有所不同,但我后面会慢慢解释其中的原因

int start = 0, end = nums.length - 1;
while (start + 1 < end) {
    int mid = start + (end - start) / 2;
    
    if (...) {
        start = mid;
    } else {
        end = mid;
    }
}

首先解释一下,上面模版当中的 start + (end - start) / 2,这里不直接写成 (end + start) / 2 的目的是防止计算越界,举个例子,假如 end = 2^31 - 1, start = 100,如果是后一种写法的话就会在计算 end + start 的时候越界,导致结果不正确。

二分查找算法如果没有实现好,会有两种后果:

  • 死循环
  • 跳过了本该查找的位置

先来说说为什么程序会死循环,上面的代码中,每次我都会依据情况把 mid 赋给 start 或者是 end,考虑一个例子,在 [2,2] 数组中,我们要找到元素 3 并返回 index,上面模版中的 while 循环条件改为 start < end,相信不难看出,mid 计算后都会等于 start 的初始值,然后将 mid 赋值给 start,导致 start 和 end 一直紧挨在一起,这时就会导致死循环,当然很多人可能会说,那好办,我每次赋值的时候把 mid 往后移一格就好了,于是模版写成了下面的形式:

int start = 0, end = nums.length - 1;
while (start < end) {
    int mid = start + (end - start) / 2;
    
    if (...) {
        start = mid + 1;
    } else {
        end = mid - 1;
    }
}

如果写成上面的形式,之前的例子的确是可以过,但是还是会存在新的问题。现在来思考这么一个问题 “在一个允许重复元素的按升序排好序的数组中,返回元素的 index,如果有重复,就返回最大的那个 index”,比如说 [1,2,2,3] 这个例子,假如我们要找的元素的值是 2,那么正确的返回值应该是 2,也就是第二个 2 的 index,如果我们使用上面的模版,最后的程序就会是:

int start = 0, end = nums.length - 1;
while (start < end) {
    int mid = start + (end - start) / 2;
    if (nums[mid] <= target) {
        start = mid + 1;
    } else {
        end = mid - 1;
    }
}

根据条件返回 end 或者 start

这里解释下,针对这个问题,要找的元素可能会有很多个,我们需要找最后出现的那个,即使你找到这个元素,你也不知道是不是最后一个,但是我们知道答案肯定不是在之前,可能是在这个位置,也可能是在之后,因此我们还得在后半区间中继续查找,直到循环退出,你会发现最后返回的值是 3,来看看具体的过程,下面我们用 s 来表示 start 指针,e 来表示 end 指针,m 来表示 mid:

   m
[1,2,2,3]
 s     e
     m 
[1,2,2,3]
     s e
     
[1,2,2,3]
       e
       s

这就是我们之前提到的 跳过本该查找的位置 的情况。网上还有一种很常见的模版,就是把 while 循环中的条件设为 start <= end,如果用来实现上面这个问题,就会是:

int start = 0, end = nums.length - 1;
while (start <= end) {
    int mid = start + (end - start) / 2;
    if (nums[mid] <= target) {
        start = mid + 1;
    } else {
        end = mid - 1;
    }
}

根据条件返回 end 或者 start

代码区别就是 while 循环中的条件,这个模版是可以得到正确解的,依照之前的 case ,来看看具体过程:

   m
[1,2,2,3]
 s     e

     m 
[1,2,2,3]
     s e

       m     
[1,2,2,3]
       e
       s
       
       m
[1,2,2,3]
     e
       s

你会发现最后的 end 指针就是我们所要找的位置,但是你要知道的是,这个模版中,while 循环结束后,end + 1 == start,而且只要是数组的长度不为 0,这个等式一直都是成立的。但是这个模版也有不好的地方,如果输入数组是 [1],那么 while 循环结束后,要么是 start 超出数组范围,要么是 end 变成 -1,也就是说最后你不仅需要判断 start 和 end 对应的元素是不是要找的元素,还需要判断 start 和 end 是否是在合法的范围内,如果你这样做了,程序不会出错,你习惯了上面的模版,你可以继续使用,但是要知道会存在这么一个情况。

回到我在一开始给出的模版:

int start = 0, end = nums.length - 1;
while (start + 1 < end) {
    int mid = start + (end - start) / 2;
    
    if (...) {
        start = mid;
    } else {
        end = mid;
    }
}

现在你应该不难发现这个模版和别的模版相比,其优势所在,我们不需去关注是否需要 mid +/- 1,也不需要去判断 while 循环后的 start、end 是否合法,具体问题我们只需要套模版即可。当然,有些人可能会说,根据具体问题具体调整,但是我想说的是,随机应变是好的,但这也失去了模版的意义,一个好的模版就相当于一个好的框架一样,我们不再需要去关注代码的实现细节,大大降低了我们解决问题的思维复杂度。

我会用上面的模版去解决 LeetCode 上面的一些题目,如果你的模版和我给的不一样也没关系,看看对于不同的问题,你的模版是不是都适用,而且并不需要改变特别多的实现细节。


二分基本问题

LC 278. First Bad Version

题目分析:

其实题目就是要找最先出现的元素,在这种情况下,如果我们找到了元素,依旧不知道它是不是最先(小)的,但是我们知道答案肯定不在后面,肯定在这或者是之前,因此这种情况需要将尾指针往前移。

代码实现:

public int firstBadVersion(int n) {
    int start = 0, end = n;
    
    while (start + 1 < end) {
        int mid = start + (end - start) / 2;
        
        if (isBadVersion(mid)) {
            end = mid;
        } else {
            start = mid;
        }
    }
    
    if (isBadVersion(start)) {
        return start;
    }
    
    return end;
}

LC 34. Find First and Last Position of Element in Sorted Array

题目分析:

给定一个元素,要找其最先出现的 index,还要找其最后出现的 index。这道题把之前的两个问题合在了一起。我们只需要用两次二分查找,一次找前,一次找后,仔细看的话,你会发现,这道题其实就是在找一个元素在数组中出现的范围,因为数组有序,所以这个范围是连续的。

代码实现:

public int[] searchRange(int[] nums, int target) {
    int[] result = new int[]{-1, -1};
    if (nums == null || nums.length == 0) {
        return result;
    }
    
    // find front
    int start = 0, end = nums.length - 1;
    while (start + 1 < end) {
        int mid = start + (end - start) / 2;
        if (nums[mid] >= target) {
            end = mid;
        } else {
            start = mid;
        }
    }
    
    if (nums[start] == target) {
        result[0] = start;
    } else if (nums[end] == target) {
        result[0] = end;
    }
    
    // find back
    start = 0; end = nums.length - 1;
    while (start + 1 < end) {
        int mid = start + (end - start) / 2;
        if (nums[mid] <= target) {
            start = mid;
        } else {
            end = mid;
        }
    }
    
    if (nums[end] == target) {
        result[1] = end;
    } else if (nums[start] == target) {
        result[1] = start;
    }
    
    return result;
}

LC 74. Search a 2D Matrix

题目分析:

我们需要去判断一个元素在不在矩阵中,这个矩阵是排序好的,前一行的元素都比后一行的元素小,在同一行中,元素也是从小到大排列的。这个问题也很简单,我们先找行,再找列,但是需要注意的是,在找行的时候,你必须确保这一行开头的元素是小于等于你要找的元素的,不然接下来的操作将会没有意义。

代码实现:

public boolean searchMatrix(int[][] matrix, int target) {
    if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
        return false;
    }
    
    int startRow = 0, endRow = matrix.length - 1;
    while (startRow + 1 < endRow) {
        int mid = startRow + (endRow - startRow) / 2;
        if (matrix[mid][0] == target) {
            return true;
        } else if (matrix[mid][0] < target) {
            startRow = mid;
        } else {
            endRow = mid;
        }
    }
    
    int selectRow = 0;
    if (matrix[endRow][0] <= target) {          // *
        selectRow = endRow;
    } else {
        selectRow = startRow;
    }
    
    int startCol = 0, endCol = matrix[0].length - 1;
    while (startCol + 1 < endCol) {
        int mid = startCol + (endCol - startCol) / 2;
        if (matrix[selectRow][mid] == target) {
            return true;
        } else if (matrix[selectRow][mid] < target) {
            startCol = mid;
        } else {
            endCol = mid;
        }
    }
    
    if (matrix[selectRow][startCol] == target 
          || matrix[selectRow][endCol] == target) {
        return true;
    }
    
    return false;
}

LC 240. Search a 2D Matrix II

题目分析:

这道题是上面那道题的变种,问题没变,主要是变在输入矩阵上,题目只保证矩阵中元素行和列是排好序的,也就是说前一行的元素不一定都比后一行的元素小,这个时候,我们之前的先找行,再找列的策略就失效了。于是可以考虑从对角线入手,对角线上的点往右和往下分别做二分,这样可以把时间复杂度控制在 O(2 * (1 + log2 + ... + log(n)) = O(log(n!)) = O(nlog(n))。除了这种做法,还有一种 O(n) 的解法比较 tricky,看答案才知道,由于这里只讲二分,所以另外一种解法有兴趣的自己可以去查看。

代码实现:

public boolean searchMatrix(int[][] matrix, int target) {
    if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
        return false;
    }
    
    int numOfFind = Math.min(matrix.length, matrix[0].length);
    
    for (int i = 0; i < numOfFind; ++i) {
        boolean upDown = find(matrix, i, target, true);
        boolean leftRight = find(matrix, i, target, false);
        
        if (upDown || leftRight) {
            return true;
        }
    }
    
    return false;
}

private boolean find(int[][] matrix, int i, int target, boolean isFindRow) {
    
    int start = i;
    int end = isFindRow ? matrix.length - 1 : matrix[0].length - 1;
    
    while (start + 1 < end) {
        int mid = start + (end - start) / 2;
        
        if (isFindRow) {
            if (matrix[mid][i] < target) {
                start = mid;
            } else if (matrix[mid][i] > target) {
                end = mid;
            } else {
                return true;
            }
        } else {
            if (matrix[i][mid] < target) {
                start = mid;
            } else if (matrix[i][mid] > target) {
                end = mid;
            } else {
                return true;
            }
        }
    }
    
    if (isFindRow) {
        if (matrix[start][i] == target || matrix[end][i] == target) {
            return true;
        }
    } else {
        if (matrix[i][start] == target || matrix[i][end] == target) {
            return true;
        }
    }
    
    return false;
}

Rotated Array 系列

有一类题型是把一个数组向右或者向左移动了一下,比如数组 [1,2,3,4,5,6] 向右移动两格就会变成 [5,6,1,2,3,4],这样这个数组就变成了一个非排序状态。但是仔细看的话你会发现,移动后的数组变成了两截,这两截内的元素是按序排列的,比如在上面的例子中,移动后的数组就会有 [5,6] 和 [1,2,3,4] 这两个区间,这两个区间内的元素都是按序排列的,仔细观察这两个区间,你会发现,其中一个区间内的所有元素都比另一个区间的任意元素小, 这个点就给我们二分查找创造了条件,我们可以根据尾元素作为区分值,但要清楚一点的是,没有移动过的数组也需要被你的程序考虑在内。我们结合实际的题目来看看。


LC 153. Find Minimum in Rotated Sorted Array

题目分析:

找出转动数组中最小的值,之前讲过的转动数组的性质,这个数组其实可以看成两个排序好的数组,一前一后,一大一小, 我们做二分的时候,二分取到的中点有可能在前区间,也有可能在后区间,怎么判断?可以使用尾元素作为区分值,二分中点对应的值比尾元素小的话那就说明二分中点是在后面的区间,大的话就会是在前面的区间。如果中点在后面的区间,那我们就要移动尾指针,如果是在前面的区间的话,我们就要移动首指针,其实就是逐步逼近后区间首元素的一个过程。可能很多人会有一个疑问就是,这里能不能使用首元素作为区分值,其实是不行的,考虑一个例子 [1,2,3,4,5],如果还是使用尾元素,那么整个过程就是一直移动尾指针,直到逼近答案,但是使用首元素的话,如果你还是考虑这个数组有两个区间,比首元素大证明在第一个区间,要移动首指针,这里就会出问题,我们跳过了答案。多试几个例子,相信不难理解。

代码实现:

public int findMin(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }
    
    int start = 0, end = nums.length - 1;
    while (start + 1 < end) {
        int mid = start + (end - start) / 2;
        
        if (nums[mid] <= nums[end]) {
            end = mid;
        } else {
            start = mid;
        }
    }
    
    if (nums[start] > nums[end]) {
        return nums[end];
    }
    
    return nums[start];
}

LC 33. Search in Rotated Sorted Array

题目分析:

这道题和前面那道题相比更复杂了些,我们不是要找值最小的那个元素,而是要找给定元素在数组中出现的 index。前一道题,我们只需要判断二分中点在哪个位置即可,这里会比之前多了一个判断,就是我们要找的元素是在前后两个区间中的哪一个? 我们知道了二分中点所在的区间,以及要找的元素所在的区间后,就可以按情况移动前后指针,其实一共有下面几种情况,我用 t 表示 target,也就是我们要找的元素,用 m 表示二分中点:

[...][...]         -> 二分中点在前区间,要找的元素在后区间
 m     t
 
[...][...]         -> 二分钟点在后区间,要找的元素在前区间
 t     m

[...][...]         -> 二分中点和要找的元素都在前区间,要找的元素在二分中点之后
 m t

[...][...]         -> 二分中点和要找的元素都在前区间,要找的元素在二分中点之前
 t m

[...][...]         -> 二分中点和要找的元素都在后区间,要找的元素在二分中点之后
      m t

[...][...]         -> 二分中点和要找的元素都在后区间,要找的元素在二分中点之前
      t m

分析归分析,我们最后还是要转化成可执行的代码,写代码的时候你会发现有些情况是可以合并的:

[...][...] -> 要找的元素在前区间,二分中点在后区间,或者是在前区间但比要找的元素大,这时我们需要移动尾指针
  t m m m

[...][...] -> 要找的元素和二分中点都在前区间,但是要找的元素比二分中点要大,这时移动首指针
 m t
 
[...][...] -> 要找的元素在后区间,二分中点在前区间,或者是在后区间但比要找的元素小,这时我们需要移动首指针
 m m m t

[...][...] -> 要找的元素和二分中点都在后区间,但是要找的元素比二分中点要小,这时移动尾指针
      t m

注意思考分析的时候也需要想想,如果数组没有被转动,那么我们的思路是否正确。

代码实现:

public int search(int[] nums, int target) {
    if (nums == null || nums.length == 0) {
        return -1;
    }
    
    int start = 0, end = nums.length - 1;
    while (start + 1 < end) {
        int mid = start + (end - start) / 2;
        
        if (target > nums[end]) {
            if (nums[mid] > target || nums[mid] <= nums[end]) {
                end = mid;
            } else if (nums[mid] == target) {
                return mid;
            } else {
                start = mid;
            }
        } else {
            if (nums[mid] > nums[end] || nums[mid] < target) {
                start = mid;
            } else if (nums[mid] == target) {
                return mid;
            } else {
                end = mid;
            }
        }
    }
    
    if (nums[start] == target) {
        return start;
    }
    
    if (nums[end] == target) {
        return end;
    }
    
    return -1;
}

LC 81. Search in Rotated Sorted Array II

题目分析:

这道题和前面那道题相比,只是增加了一个条件,就是数组中允许出现重复的元素。可能很多人纠结的地方会是在首尾两个指针上,允许重复说明,这两个指针上的元素也会是重复的,就比如我们当前的二分中点的元素值是 x,然后你对比发现首尾两个元素的值也都是 x,那么你怎么确定这个数是在前区间还是后区间?我的做法是用循环去做判断,如果二分中点元素的值和尾指针元素的值相同,那么我就会向后移动这个二分中点,如果发现移到某一点,这一点并不是尾指针,那么说明这个二分中点在前区间,如果移到了尾指针处,说明这个点在后区间,其他照旧。这里说明一点,这道题的最坏情况的时间复杂度会退化到 O(n) 的,也就是说数组里面的元素全部相同,但是我们要找的元素不在数组内,比如 [2,2,2,2,2,2,2],我们要找的数是 1。

代码实现:

public boolean search(int[] nums, int target) {
    if (nums == null || nums.length == 0) {
        return false;
    }
    
    int start = 0, end = nums.length - 1;
    while (start + 1 < end) {
        int mid = start + (end - start) / 2;
        
        if (nums[mid] == nums[end]) {
            int tmp = mid;
            while (tmp < end && nums[tmp] == nums[end]) {
                tmp++;
            }
            
            if (nums[tmp] == nums[end]) {
                end = mid;
            } else {
                start = mid;
            }
            
            continue;
        }
        
        if (target < nums[end]) {
            if (nums[mid] > nums[end] || nums[mid] <= target) {
                start = mid;
            } else {
                end = mid;
            }
        } else if (target == nums[end]) {
            return true;
        } else {
            if (nums[mid] < nums[end] || nums[mid] >= target) {
                end = mid;
            } else {
                start = mid;
            }
        }
    }
    
    if (nums[start] == target || nums[end] == target) {
        return true;
    }
    
    return false;
}

按值二分

这一类题目在二分里面算是难题了。题目通常不会要你在数组中去做查找,往往是让你去使用二分去逼近一个答案,这个答案往往也是近似的。当然在这个时候,模版的起的作用并不大。


LC 69. Sqrt(x)

拓展:输入输出参数为 double

题目分析:

如果输入参数是任意的浮点数,可以想想怎么做。那么这个时候我们就需要一个精确度,然后我们逐渐去逼近答案。但是这里有一个 tricky 的地方就是,如果输入的这个数是小于 1 的话,你得把 start 和 end 颠倒一下,因为小数是越乘越小的。

代码实现:

public double mySqrtComp(double x) {
    if (x == 0.0 || x == 1.0) {
        return x;
    }
    
    double left = 0.0, right = x, eps = 1e-7;
    
    if (x < 1.0) {
        left = x;
        right = 1.0;
    }
    
    while (Math.abs(right - left) > eps) {
        double mid = left + (right - left) / 2.0;
        
        if (Math.abs(mid * mid - x) < eps) {
            return mid;
        } else if (mid * mid < x) {
            left = mid;
        } else {
            right = mid;
        }
    }
    
    return left;
}

LC 644. Maximum Average Subarray II

题目分析:

给定一个数组,要求出这个数组的一个子数组,这个子数组的长度必须大于等于 K,而且子数组中所有元素的平均值在所有符合条件(长度大于等于 K)的子数组中是最大的。按常规思路,我们可能会首先从数组本身入手,把所有的子数组都算一遍,时间复杂度是 O(n^2),会超时。这道题应该对一个线索产生警觉,就是精确值,给这个精确值的意义何在?真的是像我们中学时代做数学题一样,是为了最后表示方便吗?按值二分的题目一般都会给出一个精确值,目的是让你二分到一定程度的时候可以退出循环,一般在用二分解这类问题的时候,我们的思路一定要从最后的答案去倒推整个过程。思路如下:

  • 我们要求长度大于 K 的子数组的最大平均值
  • 子数组平均值可能的范围是多少?
  • 给定一个平均值,我们是否可以在线性时间内判断有没有符合条件子数组的平均值是超过给定的这个平均值的

由第二点可知,子数组的平均值肯定是在数组中最小和最大元素的值之间,第三点是重点,我们可以用最小和最大元素的值作为 start 和 end,然后每次用二分中点值去到数组中找,看一下这个值是小了还是大了,如果数组中存在符合条件的子数组的平均值比这个值要大,那么说明这个值小了,我们要找的最大平均值比现在的二分中点要大,因此,我们移动 start 指针去缩小范围,反之,二分中点大了,我们需要移动 end 指针缩小范围。另外提及一点的是,在数组中求平均值这个过程也非常巧妙,我们只需要将子数组中的所有元素和我们当前取到的二分中点做差,然后加起来看是否大于 0。

(a1 + a2 + a3 + a4 + ... + an) / n = avg
(a1 + a2 + a3 + a4 + ... + an) = avg * n
(a1 - avg) + (a2 - avg) + ... + (an - avg) = 0

如果 (a1 - avg) + (a2 - avg) + ... + (an - avg) > 0,说明当前选的 avg 是小于实际的平均值的
如果 (a1 - avg) + (a2 - avg) + ... + (an - avg) < 0,说明当前选的 avg 是大于实际的平均值的

代码实现:

public double findMaxAverage(int[] nums, int k) {
    if (nums == null || nums.length < k) {
        return 0;
    }
    
    int minValue = Integer.MAX_VALUE, maxValue = Integer.MIN_VALUE;
    for (int i : nums) {
        minValue = Math.min(i, minValue);
        maxValue = Math.max(i, maxValue);
    }
    
    double error = Integer.MAX_VALUE;
    double l = minValue, r = maxValue;
    double result = minValue;
    while (error >= 1e-5) {
        double mid = (l + r) / 2.0;
        if (haveSolutionOrNot(nums, mid, k)) {
            l = mid;
        } else {
            r = mid;
        }
        
        error = Math.abs(result - mid);
        result = mid;
    }
    
    return result;
}

private boolean haveSolutionOrNot(int[] nums, double mid, int k) {
    double sum = 0.0;
    for (int i = 0; i < k; ++i) {
        sum += nums[i] - mid;
    }

    if (sum >= 0.0) {
        return true;
    }

    double prevSum = 0.0, minSum = 0.0;
    for (int i = k; i < nums.length; ++i) {
        sum += nums[i] - mid;

        prevSum += nums[i - k] - mid;

        minSum = Math.min(minSum, prevSum);
        if (sum >= minSum) {
            return true;
        }
    }

    return false;
}

LC 774. Minimize Max Distance to Gas Station

题目分析:

题目给定一个升序排列的数组,里面表示的是已经建好的加油站的位置,现在要新建 K 个加油站,怎么建,使得所有相邻加油站的间距的最大值最小,求最后所有相邻加油站的间距的最大值。按值二分的题目做多了,你会觉得其实都是一个套路,那就是选定答案出现的范围,然后去确认每次的二分中点大了,或是小了,再相应的移动前后指针。这里,最小间距肯定不可能小于 0,最大间距不会超过题目给的最大位置,我们把这个当作二分的初始值,判断条件是,满足二分中点这样的最大间距总共需要新建多少个加油站,看一下这个数目是否比 K 大。

代码实现:

public double minmaxGasDist(int[] stations, int K) {
    double left = 0.0, right = stations[stations.length - 1];
    
    while (right - left > 1e-6) {
        double mid = (right + left) * 0.5;
        if (countStationNeeded(stations, mid) <= K) {
            right = mid;
        } else {
            left = mid;
        }
    }
    
    return left;
}

private int countStationNeeded(int[] stations, double D) {
    int numOfStationNeeded = 0;
    for (int i = 1; i < stations.length; ++i) {
        // 两个加油站间距除以期望的间距(二分中点),表示说当前这个间距是期望的间距的多少倍
        // 这个倍数减 1 就是我们要新建的加油站的数目(取整已经帮我们做了这一步)
        numOfStationNeeded += (int) ((stations[i] - stations[i - 1]) / D);
    }
    
    return numOfStationNeeded;
}

二分其他题型

LC 302. Smallest Rectangle Enclosing Black Pixels

题目分析:

给定一个矩阵,矩阵中每一格的值不是 1 就是 0,其中所有的 1 都是连接在一起的,要你求出一个面积最小的矩形,矩形可以把值为 1 的区域给框住。一开始真的不知道是可以用二分解这道题,感觉常规的做法应该是搜索,但是搜索的时间复杂度是 O(mn),最后看答案想了一下,这道题的输入参数给了一个值为 1 的点的坐标,我们可以根据这个来做二分,关键点在于,我们需要把列或者行当作一个整体来二分,比如用行做二分的时候,我们会去遍历一行里面所有元素,这一行有无值为 1 的空格将作为移动左右指针的条件,这样可以把时间降至 O(m * logn + n * logm)

代码实现:

public int minArea(char[][] image, int x, int y) {
    int minRow = searchFirst(image, 0, y, true);
    int maxRow = searchLast(image, y, image[0].length - 1, true);
    int minCol = searchFirst(image, 0, x, false);
    int maxCol = searchLast(image, x, image.length - 1, false);
    
    return (maxRow - minRow + 1) * (maxCol - minCol + 1);
}

private int searchFirst(char[][] image, int start, int end, boolean isVertical) {
    int l = start, r = end;
    while (l + 1 < r) {
        int mid = l + (r - l) / 2;
        if (hasBlack(image, mid, isVertical)) {
            r = mid;
        } else {
            l = mid;
        }
    }
    
    if (hasBlack(image, l, isVertical)) {
        return l;
    }
    
    return r;
}

private int searchLast(char[][] image, int start, int end, boolean isVertical) {
    int l = start, r = end;
    while (l + 1 < r) {
        int mid = l + (r - l) / 2;
        if (hasBlack(image, mid, isVertical)) {
            l = mid;
        } else {
            r = mid;
        }
    }
    
    if (hasBlack(image, r, isVertical)) {
        return r;
    }
    
    return l;
}

private boolean hasBlack(char[][] image, int rowOrCol, boolean isVertical) {
    if (isVertical) {
        for (int i = 0; i < image.length; ++i) {
            if (image[i][rowOrCol] == '1') {
                return true;
            }
        }
    } else {
        for (int i = 0; i < image[0].length; ++i) {
            if (image[rowOrCol][i] == '1') {
                return true;
            }
        }
    }
    
    return false;
}

LC 668. Kth Smallest Number in Multiplication Table

题目分析:

在一个乘法表里面寻找第 K 小的元素。这道题自我感觉是非常 tricky 的一道题,一开始想很难想到可以用二分,其二分的思路类似按值二分,但是还是不一样,至少说这道题最后是可以得到一个确定的解的。思路大概是,给定乘法表中,最后一个元素是最大的,其值是 mn,第一个元素是最小的,其值是 1,由此我们就可以确认前后指针,然后我们每次都用二分中点去判断,看看矩阵中有多少个数是小于这个值的,如果说矩阵中有超过 K 个数小于这个值,那说明这个二分中点大了,要移动后指针,反之,缩短前指针。 我个人认为很难理解的原因是,我们可能会认为二分最后得到的答案有可能不在乘法矩阵中,但这个疑点其实是可以利用二分的性质来解释的,二分就是不断趋近于最后的答案的过程,而且这里我们是二分整数,你可以把这道题想象成找元素在数组中最先出现的位置,举个例子,假如说乘法表中第 K 个出现的元素是 10,你会发现 11 也可以,但是 11 并不在乘法表中,在二分的过程中,我们找到 11 了,但是我们并不会直接返回结果,我们会把后指针移到 11 的位置,然后继续在 11 之前的区间查找。直到二分把区间缩小到只剩下 1-2个数,我们才会去判断并返回最后的结果。

代码实现:

public int findKthNumber(int m, int n, int k) {
    int start = 1, end = m * n;
    while (start + 1 < end) {
        int mid = start + (end - start) / 2;
        
        if (count(mid, m, n, k)) {
            end = mid;
        } else {
            start = mid;
        }
    }
    
    if (count(start, m, n, k)) {
        return start;
    }
    
    return end;
}

private boolean count(int mid, int m, int n, int k) {
    int res = 0;
    for (int i = 1; i <= m; ++i) {
        if (mid / i == 0) {
            break;
        }
        
        res += Math.min(mid / i, n);
    }
    
    return res >= k;
}

总结

二分查找的内容就说的这里,总体说来,算法本身不难,但是细节相对较多,因此用好模版就显得尤为重要。另外就是其应用非常的广,题目种类也很多,这个只能靠平时的积累了。希望这次的内容对你有帮助。