3.15-3.16-二分

35. 搜索插入位置 - 力扣(LeetCode)

模板题,令l = -1 , r = nums.size()可避免讨论,最后返回的是r

class Solution {
  public:
      int searchInsert(vector<int>& nums, int target) {
        int l = -1 , r = nums.size();        
        while(l != r - 1){
          int mid = l + ((r - l) >> 1);
          if(nums[mid] < target) l = mid;
          else if(nums[mid] > target) r = mid;
          else return mid;
        }
        return r;
      }
  };

74. 搜索二维矩阵 - 力扣(LeetCode)

对行、列两次二分即可。

第一次二分查找行,得到行号d,此时进行讨论:

  • d==m 返回false
  • d<m 再二分列号,得到坐标matrix[d][r]与target比较即可
class Solution {
  public:
      bool searchMatrix(vector<vector<int>>& matrix, int target) {
          int m = matrix.size();
          int n = matrix[0].size();

          int u = -1 , d = m;
          while(u + 1 != d){
            int mid = u + ((d - u) >> 1);
            if(matrix[mid][n - 1] < target) u = mid;
            else if(matrix[mid][n - 1] > target) d = mid;
            else return true;
          }
          if(d == m)  return false;
          else{
            int l = -1 , r = n;
            while(l + 1 != r){
              int mid = l + ((r - l) >> 1);
              if(matrix[d][mid] < target) l = mid;
              else if(matrix[d][mid] > target) r = mid;
              else return true;
            }
            return matrix[d][r] == target;
          }
      }
  };

灵神方法:

方法一:二分查找

由于矩阵的每一行是递增的,且每行的第一个数大于前一行的最后一个数,如果把矩阵每一行拼在一起,我们可以得到一个递增数组。

例如示例 1,三行拼在一起得lc74.jpg

a=[1,3,5,7,10,11,16,20,23,30,34,60]
由于这是一个有序数组,我们可以用二分查找判断 target 是否在 matrix 中。

代码实现时,并不需要真的拼成一个长为 mn 的数组 a,而是将 a[i] 转换成矩阵中的行号和列号。例如示例 1,i=9 对应的 a[i]=30,由于矩阵有 n=4 列,所以 a[i] 在 ⌊i/n⌋=2 行,在 i mod n=1 列。

一般地,有a[i]=matrix[⌊i/n⌋][imodn]

class Solution {
public:
    bool searchMatrix(vector<vector<int>>& matrix, int target) {
        int m = matrix.size(), n = matrix[0].size();
        int left = -1, right = m * n;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            int x = matrix[mid / n][mid % n];
            if (x == target) {
                return true;
            }
            (x < target ? left : right) = mid;
        }
        return false;
    }
};

复杂度分析

  • 时间复杂度:O(log(mn)),其中 mn 分别为 matrix 的行数和列数。
  • 空间复杂度:O(1)。

方法二:排除法

lc74-1.png

该方法也适用于 240. 搜索二维矩阵 II

class Solution {
public:
    bool searchMatrix(vector<vector<int>>& matrix, int target) {
        int m = matrix.size(), n = matrix[0].size();
        int i = 0, j = n - 1;
        while (i < m && j >= 0) { // 还有剩余元素
            if (matrix[i][j] == target) {
                return true; // 找到 target
            }
            if (matrix[i][j] < target) {
                i++; // 这一行剩余元素全部小于 target,排除
            } else {
                j--; // 这一列剩余元素全部大于 target,排除
            }
        }
        return false;
    }
};

复杂度分析

  • 时间复杂度:O(m+n),其中 mn 分别为 matrix 的行数和列数。
  • 空间复杂度:O(1)。

34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)

代码细节说明:

为什么L的初始值为-1,R的初始值为N?

首先,如果二分本来就没有结果。如对于 nums=[1 2 2 3 3 4]target = 5,如果你要寻找第一个 >=5 的数,你会发现,整个过程都在执行L=mid,最后得到的结果中,R是等于下标6的,他明显这个时候是越界的,说明我们找不到要寻找的数字。而如果我们一开始将R赋值为n-1,也就是赋值为下标5的时候,他返回的R是5,是没有越界的,被我们当成了答案,但其实这时候我们的二分是没有答案的,就发生了错误
   其次,L最小值为-1,R最小值只能取到1,因为L+1!=R为循环结束条件,R最大值为N,同理则L的最大值为N-2,则(L+R)/2的取值范围是 [0,N) ,mid的值始终位于0到N的左闭右开区间里面,不会发生越界的错误;

为什么循环结束的条件是while(L+1!=R)?

这边给出的循环条件是while(L+1!=R) 其实,就是当L和R相邻的时候,循环就结束,因为区间反回的是不重合的两区间,只有L=mid和R=mid这两种情况,最后根据需要返回L或者R;例如:

  • 对于a[1],他的下标为0 此时L=-1,R=1
  • 对于b[2],他的下标为0,1 此时L=-1,R=2

无论何种情况,初始的L+1始终小于R,历经循环后最终L和R相邻,不会出现一开始L就和R重合等情况导致出现while(L+1!=R)循环不能结束的情况。我们就能够通过二分得到不重合的两区间,而且只需要L=mid和R=mid,不需要考虑L=mid+1,R=mid-1的情况。

如何找第一个等于target/最后一个等于target的数?

关键在于改变等于号的位置:

  • (nums[mid] < target ? l : r) = mid; return r = 第一个>=target(即lower_bound函数)
  • (nums[mid] <= target ? l : r) = mid;return l = 最后一个<=target
  int l = -1, r = n;
        while( l + 1 != r)
        {
            int mid = l + (r - l ) >> 1;
          //寻找第一个等于target的坐标 我这边让二分的边界定为左边为5 右边>= 则所求为r
          // (nums[mid] < target ? l : r) = mid; 
          //此时循环结束后r为第一个>=target的数
          
          //如果改为求最后一个等于的,改变等于号的位置即可
          //(nums[mid] <= target ? l : r) = mid;
          //此时循环结束后l<=target , 而r > target,l为最后一个等于target的数
        }
class Solution {
  public:
      vector<int> searchRange(vector<int>& nums, int target) {
          vector<int> res(2 , -1);
          if(nums.empty())  return res;
          int n = nums.size();
          int l = -1 , r = n;
          //寻找第一个等于K的坐标 我这边让二分的边界定为 左边为<5 右边>=5 则所求为r第一个>=target的数
          while(l + 1 != r){
            int mid = l + (r - l) / 2;
           ( nums[mid] < target ? l : r) = mid;            
          }
          //这里需要判断targrt>nums[n - 1] 和target不存在的情况
          if(r == n || nums[r] != target) return res;
          else{
            res[0] = r;
            int ll = -1 , rr = n;
            while(ll + 1 != rr){
              int mid = ll + (rr - ll) / 2;
              (nums[mid ]<= target ? ll : rr) = mid;
            }
            res[1] = ll;
          }
          return res;
      }
  };

用库函数的写法:

auto it1 = lower_bound(v.begin(), v.end(), val,cmp1); auto it2 = upper_bound(v.begin(), v.end(), val,cmp2);

lower_bound upper_bound
无自定义比较函数 返回第一个 >= val 的元素 返回第一个 > val 的元素
使用自定义比较函数 返回 第一个 false 的元素 返回第一个 true 的元素

lower_bound/upper_bound返回的是迭代器it,如果要取值就*it,返回下标就返回it-v.begin()distance(v.begin() , it)

class Solution {
  public:
      vector<int> searchRange(vector<int>& nums, int target) {
         int start = ranges::lower_bound(nums , target) - nums.begin();
         if(start == nums.size() || nums[start] != target)  return { -1 , -1};
         int end = ranges::upper_bound(nums,target) - nums.begin() - 1;
         return {start , end};
      }
  };

153. 寻找旋转排序数组中的最小值 - 力扣(LeetCode)

思路:

x=nums[mid] 是现在二分取到的数。

我们需要判断 x 和数组最小值的位置关系,谁在左边,谁在右边?

把 x 与最后一个数 nums[n−1] 比大小:

  • 如果 x>nums[n−1],那么可以推出以下结论:

    • nums 一定被分成左右两个递增段;
    • 第一段的所有元素均大于第二段的所有元素;
    • x 在第一段。
    • 最小值在第二段。
    • 所以 x 一定在最小值的左边。所以更新l
  • 如果 x≤nums[n−1],那么 x 一定在第二段。(或者 nums 就是递增数组,此时只有一段。)

    • x 要么是最小值,要么在最小值右边。所以更新r

所以,只需要比较 x 和 nums[n−1] 的大小关系,就间接地知道了 x 和数组最小值的位置关系,从而不断地缩小数组最小值所在位置的范围,二分找到数组最小值。

class Solution {
  public:
      int findMin(vector<int>& nums) {
          int l = -1 , r = nums.size();
          while(l + 1 < r){
            int mid = l + (r - l) / 2;
            (nums[mid] <= nums.back() ? r : l) = mid;
          }
          return nums[r];
      }
  };

33. 搜索旋转排序数组 - 力扣(LeetCode)

有了上一题的铺垫,可以找出最小值的位置,从而更新区间范围。

自己写的,方法同灵神:

方法一:两次二分

首先找到 nums 的最小值的下标 i。

然后分类讨论:

  • 如果 target>nums[n−1],那么 target 一定在第一段 [0,i−1] 中,在 [0,i−1] 中二分查找 target。

  • 如果 target≤nums[n−1],那么:

    • 如果 i=0,说明 nums 是递增的,直接在 [0,n−1] 中二分查找 target。
    • 如果 i>0,那么 target 一定在第二段 [i,n−1] 中,在 [i,n−1] 中二分查找 target。
  • 这两种情况可以合并成:在 [i,n−1] 中二分查找 target。

class Solution {
private:
  // 153. 寻找旋转排序数组中的最小值(返回的是下标)
      int findMin(vector<int>& nums) {
        int l = -1 , r = nums.size();
        while(l + 1 < r){
          int mid = l + (r - l) / 2;
          (nums[mid] <= nums.back() ? r : l) = mid;
        }
        return r;
}
 // 有序数组中找 target 的下标
      int lower_bound(vector<int>& nums , int ll , int rr , int target){
        int l = ll - 1 , r = rr + 1;
        while(l + 1 != r){
          int mid = l + (r - l)  / 2;
          (nums[mid] < target ?  l : r) = mid;
        }
        return nums[r] == target ? r : -1;
      }
  public:
      int search(vector<int>& nums, int target) {
          int min = findMin(nums);
         if(target > nums.back()){//在第一段
           return lower_bound(nums , 0 , min - 1 , target);
         }
        else{//在第二段
          return lower_bound(nums , i , nums.size() - 1 , target);
        }
      }
  };

复杂度分析

  • 时间复杂度:O(logn),其中 nnums 的长度。
  • 空间复杂度:O(1),仅用到若干额外变量。

方法二:一次二分

设 x=nums[mid] 是我们现在二分取到的数。

现在需要判断 x 和 target 的位置关系,谁在左边,谁在右边?

check()函数定义为 x 在 target 右边为true

    if(check(x)) r = x;
    else l = x;

核心思路

如果 x 和 target 在不同的递增段:

  • 如果 target 在第一段(左),x 在第二段(右),说明 x 在 target 右边;
  • 如果 target 在第二段(右),x 在第一段(左),说明 x 在 target 左边。

如果 x 和 target 在相同的递增段:

  • 比较 x 和 target 的大小即可。

分类讨论

下面只讨论 x 在 target 右边,或者等于 target 的情况(x >= target)。其余情况 x 一定在 target 左边。

    if(check(x)) r = x;
    else l = x;

1.如果 x>nums[n−1],说明 x 在第一段中,那么 target 也必须在第一段中(target > nums[n - 1])(否则 x 一定在 target 左边)且 x 必须大于等于 target

  • 写成代码就是 target > nums[n - 1] && x >= target

2.如果 x≤nums[n−1],说明 x 在第二段中(或者 nums 只有一段),那么 target 可以在第一段,也可以在第二段。

  • 如果 target 在第一段,则target > nums[n - 1]
  • 如果 target 在第二段,那么 x 必须大于等于 target
  • 写成代码就是 target > nums[n - 1] || x >= target

根据这两种情况,去判断 x 和 target 的位置关系,从而不断地缩小 target 所在位置的范围,二分找到 target。

细节

下面代码用的开区间二分,用其他二分写法也是可以的。

二分的范围可以是 (−1,n−1),也就是闭区间 [0,n−2]。

这是因为,如果 target 在 nums 中的位置是 n−1(x 不可能在 target 右边),那么上面分类讨论中的代码,计算结果均为 false。这意味着每次二分更新的都是 left,那么循环结束后,答案自然就是 n−1 了。

class Solution {
public:
    int search(vector<int>& nums, int target) {
        int end = nums.back();
        auto check = [&](int i) -> bool {
            int x = nums[i];
            if (x > end) {
                return target > end && x >= target;
            }
            return target > end || x >= target;
        };

        int left = -1, right = nums.size() - 1; // 开区间 (-1, n-1)
        while (left + 1 < right) { // 开区间不为空
            int mid = left + (right - left) / 2;
            if (check(mid)) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return nums[right] == target ? right : -1;
    }
};

复杂度分析

  • 时间复杂度:O(logn),其中 nnums 的长度。
  • 空间复杂度:O(1),仅用到若干额外变量。

4. 寻找两个正序数组的中位数 - 力扣(LeetCode)

前言

本质上,我们需要在两个有序数组中,查找第 k 小的数,其中 k=⌈(m+n)/2⌉

  • 如果 m+n 是奇数,返回第 k 小的数。
  • 如果 m+n 是偶数,返回第 k 小的数和第 k+1 小的数的平均值。

本文先从最暴力的排序做法开始,然后讲解双指针做法,最后过渡到二分做法。

一、引入:均匀分组

lc4-1-c.png

这里的关键是「均匀分组」,每组 5 个数,只要第一组的最大值 ≤ 第二组的最小值,我们就找到了答案。

怎么想到要均匀分组的?请看百科中关于中位数的介绍:

中位数……可将数值集合划分为大小相等的两部分。

二、枚举:双指针做法

lc4-4-c2.png

下面来说具体做法。

设 a 的 b 的长度分别为 m 和 n,且 m≤n(如果不满足则交换两个数组)。

  • 为方便处理 i=−1,即 a 有 0 个数在第一组的情况,我们可以往 a 的最左边插入一个哨兵 −∞,这可以保证数组仍然是有序的。对于 j=−1 的情况也同理,往b的最左边插入一个−∞。
  • 为方便处理 i+1=m,即 a 有 m 个数在第一组的情况,我们可以往 a 的最右边插入一个哨兵 ∞,这可以保证数组仍然是有序的。对于 j+1=n 的情况也同理,往 b 的最右边插入一个 ∞。这可以避免 \(a_{i+1}\)\(b_ {j+1}\)下标越界。
  • 插入 −∞ 和 ∞ 后,便可保证无论 a 和 b 是什么样的,一定存在一个 i,满足 \(a_i≤b_ {j+1}\)\(a_{i+1}>=b_j\)
  • m 和 n 的值不变。

如此修改后,i 的含义变成了 a 有 i 个数在第一组,j 的含义变成了 b 有 j 个数在第一组。

初始化 i=0,那么 j 应该初始化成多少?

  • 如果 m+n 是偶数,那么每组的大小为 \(\frac{m+n}{2}\),j 应当初始化成\(\frac{m+n}{2}\)
  • 如果 m+n 是奇数,我们规定第一组比第二组多一个数,第一组的大小为 \(\frac{m+n + 1}{2}\) ,j 应当初始化成 \(\frac{m+n + 1}{2}\)

两种情况可以合并为:j 初始化成\(⌊\frac{m+n + 1}{2}⌋\)

为了保证组的大小不变,i 每增加 1,j 就要减少 1。

根据图片中的结论,只要发现 \(a_i≤b_{ j+1}\)\(a_{i+1}>b_j\) ,那么:

  • 如果 m+n 是偶数,中位数为 max(ai ,bj) 和 min(a_i+1 ,b_j+1) 的平均值。
  • 如果 m+n 是奇数,中位数为 max(ai,bj)。

答疑

问:为什么图中说存在一个位置,满足 \(a_i ≤b_{j+1}\)且$ a_{i+1}>b_j$?

答:根据 i 和 j 的关系,i 变大,j 会随着变小。把 b 反转,变成一个递减数组,这样 j 会随着 i 的变大而变大,我们可以更容易地观察出性质。把这两个数组画成折线图,一个递增另一个递减,并且由于我们插入了 −∞ 和 ∞,所以二者必然相交。这说明存在一个位置,满足 \(a_i ≤b_{j+1}\)且$ a_{i+1}>b_j$。

问:保证 m≤n 有什么好处?

答:如果 m>n,我们没法从 i=0 开始枚举。以 m=5,n=3 为例,i=0 时,b 数组需要有 4 个数在第一组,但 n=3<4,无法做到。保证 m≤n 可以让我们从 i=0 开始枚举,写起来更方便。

问:如果数组中存在重复元素,上述做法是否正确?

答:仍然是正确的,因为只用到了「a 和 b 是有序数组」的条件。

    class Solution {
public:
    double findMedianSortedArrays(vector<int>& a, vector<int>& b) {
        if (a.size() > b.size()) {
            swap(a, b); // 保证下面的 i 可以从 0 开始枚举
        }
    int m = a.size(), n = b.size();
    a.insert(a.begin(), INT_MIN); // 最左边插入 -∞
    b.insert(b.begin(), INT_MIN);
    a.push_back(INT_MAX); // 最右边插入 ∞
    b.push_back(INT_MAX);

    // 枚举 nums1 有 i 个数在第一组
    // 那么 nums2 有 (m + n + 1) / 2 - i 个数在第一组
    int i = 0, j = (m + n + 1) / 2;
    while (true) {
        if (a[i] <= b[j + 1] && a[i + 1] > b[j]) { // 写 >= 也可以
            int max1 = max(a[i], b[j]); // 第一组的最大值
            int min2 = min(a[i + 1], b[j + 1]); // 第二组的最小值
            return (m + n) % 2 ? max1 : (max1 + min2) / 2.0;
        }
        i++; // 继续枚举
        j--;
    }
}
   };

复杂度分析

  • 时间复杂度:O(m+n),其中 m 是 a 的长度,n 是 b 的长度。往 a 前面插入一个元素的时间复杂度是 O(m),往 b 前面插入一个元素的时间复杂度是 O(n),加起来是 O(m+n)。
  • 空间复杂度:O(m+n)。

三、优化:二分做法

由于 a 和 b 是有序数组,i 越小,\(a_i ≤b_{j+1}\) 越能成立;i 越大,\(a_i ≤b_{j+1}\)
越不能成立。所以可以二分最大的满足 \(a_i ≤b_{j+1}\) 的 i。二分结束后,我们有 a
\(a_i ≤b_{j+1}\)\(a _{i+1}>b_j\)

最后,讨论二分的上下界。本文用开区间二分,其他二分写法也是可以的。

  • 开区间二分左边界:0。在插入 −∞ 后, \(a_i ≤b_{j+1}\)在 i=0 时一定成立。
  • 开区间二分右边界:m+1。在插入 ∞ 后, \(a_i ≤b_{j+1}\) 在 i=m+1 时一定不成立。

答疑

问:能否二分红色折线图的最小值?

答:这种做法会在有重复元素时失效。试想一下,如果我们在折线图上二分,碰巧遇到了相邻且相同的元素,你要更新 left 还是更新 right 呢?

写法一

注意在数组前面插入元素的时间复杂度是线性的,所以和上面的复杂度分析一样,都是 O(n+m)。

真正满足题目时间复杂度要求的是后面的写法二。

    class Solution {
public:
    double findMedianSortedArrays(vector<int>& a, vector<int>& b) {
        if (a.size() > b.size()) {
            swap(a, b); // 保证下面的 i 可以从 0 开始枚举
        }
    int m = a.size(), n = b.size();
    a.insert(a.begin(), INT_MIN);
    b.insert(b.begin(), INT_MIN);
    a.push_back(INT_MAX);
    b.push_back(INT_MAX);

    // 循环不变量:a[left] <= b[j+1]
    // 循环不变量:a[right] > b[j+1]
    int left = 0, right = m + 1;
    while (left + 1 < right) { // 开区间 (left, right) 不为空
        int i = (left + right) / 2;
        int j = (m + n + 1) / 2 - i;
        if (a[i] <= b[j + 1]) {
            left = i; // 缩小二分区间为 (i, right)
        } else {
            right = i; // 缩小二分区间为 (left, i)
        }
    }

    // 此时 left 等于 right-1
    // a[left] <= b[j+1] 且 a[right] > b[j'+1] = b[j],所以答案是 i=left
    int i = left;
    int j = (m + n + 1) / 2 - i;
    int max1 = max(a[i], b[j]);
    int min2 = min(a[i + 1], b[j + 1]);
    return (m + n) % 2 ? max1 : (max1 + min2) / 2.0;
}
};	

写法二

去掉插入的 −∞ 和 ∞,所有下标都减一。

开区间二分的左右边界改成 −1 和 m。

  • i=−1 时,j 的值为 ⌊(m+n+1)/2⌋−1。

  • i=0 时,j 的值为 ⌊(m+n+1)/2⌋−2。

一般地,j 和 i 的关系为 j = ⌊(m+n+1)/2⌋−2−i = ⌊(m+n-3)/2⌋−i。

答疑

问:当 m=0 时,是否会算出 i=0?

答:不会,m=0 不会进入二分循环,i=left=−1。

class Solution {
public:
    double findMedianSortedArrays(vector<int>& a, vector<int>& b) {
        if (a.size() > b.size()) {
            swap(a, b);
        }	   
		int m = a.size(), n = b.size();
    // 循环不变量:a[left] <= b[j+1]
    // 循环不变量:a[right] > b[j+1]
    int left = -1, right = m;
    while (left + 1 < right) { // 开区间 (left, right) 不为空
        int i = (left + right) / 2;
        int j = (m + n + 1) / 2 - 2 - i;
        if (a[i] <= b[j + 1]) {
            left = i; // 缩小二分区间为 (i, right)
        } else {
            right = i; // 缩小二分区间为 (left, i)
        }
    }

    // 此时 left 等于 right-1
    // a[left] <= b[j+1] 且 a[right] > b[j'+1] = b[j],所以答案是 i=left
    int i = left;
    int j = (m + n + 1) / 2 - 2 - i;
    int ai = i >= 0 ? a[i] : INT_MIN;
    int bj = j >= 0 ? b[j] : INT_MIN;
    int ai1 = i + 1 < m ? a[i + 1] : INT_MAX;
    int bj1 = j + 1 < n ? b[j + 1] : INT_MAX;
    int max1 = max(ai, bj);
    int min2 = min(ai1, bj1);
    return (m + n) % 2 ? max1 : (max1 + min2) / 2.0;
}
};

复杂度分析

  • 时间复杂度:O(logmin(m,n)),其中 m 是 a 的长度,n 是 b 的长度。注:这个复杂度比题目所要求的 O(log(m+n)) 更优。
  • 空间复杂度:O(1)。

posted @ 2025-03-16 22:58  七龙猪  阅读(1)  评论(0)    收藏  举报
-->