左哥算法 - 二分法相关内容

329 阅读9分钟

二分法的基本思路流程

用流程图总结二分查找的主要思路和类型:

graph TD
    A[二分查找] --> B[确定查找类型]
    
    B --> C[标准二分查找]
    B --> D[变种二分查找]
    B --> E[二分答案]
    
    C --> C1[有序数组中找特定值]
    C --> C2[判断是否存在]
    
    D --> D1[找最左位置]
    D --> D2[找最右位置]
    D --> D3[找局部最小]
    
    E --> E1[确定答案范围]
    E --> E2[二分猜测答案]
    E --> E3[检验答案是否可行]
    
    C1 --> F[套用二分模板]
    D1 --> F
    D2 --> F
    
    F --> G[初始化左右边界]
    G --> H[计算中点 mid]
    H --> I{判断条件}
    
    I -->|目标值在左边| J[右边界左移]
    I -->|目标值在右边| K[左边界右移]
    I -->|找到目标值| L[返回结果]
    
    J --> H
    K --> H

二分查找的核心步骤说明:

  1. 确定查找类型

    • 标准二分:在有序数组中查找值
    • 变种二分:查找最左/最右位置
    • 二分答案:在答案范围内二分
  2. 二分模板四要素

    • 初始化:left = 0, right = n-1
    • 循环条件:while(left <= right)
    • 中点计算:mid = left + ((right-left) >> 1)
    • 边界更新:left = mid + 1right = mid - 1
  3. 常见变种特点

    • 找最左位置:当找到目标时继续往左找
    • 找最右位置:当找到目标时继续往右找
    • 二分答案:通过check函数验证答案可行性
  4. 注意事项

    • 边界条件的处理
    • 防止整数溢出
    • 循环退出条件
    • 无解情况的处理

具体题目逐个分析

我们从最基础的二分搜索开始讲起。

1. 在有序数组中找数

这是最基础的二分查找,目标是在一个排序好的数组中找到指定的数。

public static boolean exist(int[] sortedArr, int num) {
    if (sortedArr == null || sortedArr.length == 0) {
        return false;
    }
    
    int L = 0;
    int R = sortedArr.length - 1;
    while (L <= R) {
        int mid = L + ((R - L) >> 1); // 等同于 (L + R) / 2,但更安全,避免溢出
        if (sortedArr[mid] == num) {
            return true;
        } else if (sortedArr[mid] > num) {
            R = mid - 1;
        } else {
            L = mid + 1;
        }
    }
    return false;
}

核心要点解释:

  1. L + ((R - L) >> 1)(L + R) / 2 更安全,避免了 L + R 可能的溢出
  2. 循环条件是 L <= R,因为当 L == R 时还需要检查这个位置
  3. 时间复杂度:O(logN),每次排除一半的区域

2. 在有序数组中找>=num最左的位置。

问题描述

给定一个有序数组 arr,和一个数 num,找到 arr 中大于等于 num 的最左位置。

示例

输入:arr = [1,2,2,2,3,4,5,6,7], num = 2
输出:1 (因为从左往右第一个大于等于2的位置是index=1)

输入:arr = [1,2,2,2,3,4,5,6,7], num = 8
输出:-1 (因为数组中没有大于等于8的数)

解题思路

graph TD
    A[开始] --> B[初始化: L=0, R=N-1, ans=-1]
    B --> C[mid = L + R >> 1]
    C --> D{arr[mid] >= num ?}
    D -->|是| E[记录答案: ans = mid]
    E --> F[继续往左找: R = mid - 1]
    D -->|否| G[往右找: L = mid + 1]
    F --> H{L <= R ?}
    G --> H
    H -->|是| C
    H -->|否| I[返回ans]
步骤 操作 说明 
初始化 L=0, R=N-1, ans=-1 
 2 二分查找 当 L<=R 时循环 
计算中点 mid = L+(R-L)/2 
判断大小arr[mid] >= num ?
 5a是 记录ans=mid,R=mid-1
5b否 L=mid+16结束返回ans

代码实现

public static int findLeftMostIndex(int[] arr, int num) {
    if (arr == null || arr.length == 0) {
        return -1;
    }
    
    int L = 0;
    int R = arr.length - 1;
    int ans = -1; // 记录最终答案
    
    while (L <= R) {
        int mid = L + ((R - L) >> 1);
        if (arr[mid] >= num) {
            // 找到一个大于等于num的数
            ans = mid; // 记录当前位置
            R = mid - 1; // 继续往左找,看有没有更左的
        } else {
            // 当前数小于num,需要往右找
            L = mid + 1;
        }
    }
    return ans;
}

关键点解析

  1. 初始化答案为-1

    • 为什么:防止数组中没有找到>=num的数
  2. 找到>=num的数后不要立即返回

    • 为什么:可能左边还有相等的数
    • 做法:记录当前位置,继续往左找
  3. 向左找的操作

    • 当 arr[mid] >= num 时
    • 记录当前mid到ans
    • 让R = mid - 1继续往左找
  4. 向右找的操作

    • 当 arr[mid] < num 时
    • L = mid + 1

复杂度分析

  • 时间复杂度:O(logN)
  • 空间复杂度:O(1)

易错点

  1. 循环条件是 L <= R,不是 L < R
  2. 更新边界时是 R = mid - 1L = mid + 1,不是 mid
  3. 别忘了处理边界情况(空数组等)

3. 在有序数组中找<=num最右的位置

与找最左位置类似,但是方向相反。

public static int findRightMostIndex(int[] arr, int num) {
    if (arr == null || arr.length == 0) {
        return -1;
    }
    
    int L = 0;
    int R = arr.length - 1;
    int ans = -1;
    
    while (L <= R) {
        int mid = L + ((R - L) >> 1);
        if (arr[mid] <= num) {
            ans = mid; // 记录当前找到的位置
            L = mid + 1; // 继续往右找
        } else {
            R = mid - 1;
        }
    }
    return ans;
}

4. 局部最小值问题

1. 首位置(0位置)局部最小:
[1, 3, 4, 5]
 ↑
比右边小就是局部最小

2. 中间位置局部最小:
[4, 3, 2, 5, 7]
       ↑
比左右都小就是局部最小

3. 末尾位置(n-1位置)局部最小:
[5, 4, 3, 1]
          ↑
比左边小就是局部最小

记住这三种情况的判断条件:

  1. 0位置:只需要 arr[0] < arr[1]
  2. 中间位置i:需要 arr[i] < arr[i-1] && arr[i] < arr[i+1]
  3. 最后位置:只需要 arr[n-1] < arr[n-2]

这就是为什么在代码中要先特判首尾位置,然后才进行中间位置的二分查找。

1. 问题描述

给定一个数组arr,要找到一个局部最小值的位置。局部最小值定义如下:

  • 对于0位置:如果arr[0] < arr[1],则0位置是局部最小
  • 对于n-1位置:如果arr[n-1] < arr[n-2],则n-1位置是局部最小
  • 对于其他位置i:如果arr[i] < arr[i-1] && arr[i] < arr[i+1],则i位置是局部最小

2. 示例

输入:arr = [9,6,3,4,5]
输出:2 (因为arr[2]=3小于左边的6和右边的4)

输入:arr = [4,3,2,1]
输出:3 (因为arr[3]=1小于arr[2]=2,是最后一个位置)

3. 解题思路

步骤操作说明
1检查边界检查0位置和N-1位置是否是局部最小
2二分查找L=1, R=N-2
3计算中点mid = L+(R-L)/2
4判断局部最小比较mid位置与左右相邻位置的大小
5a找到局部最小返回mid
5b未找到根据趋势继续查找

4. 代码实现

public static int findLocalMinimum(int[] arr) {
    if (arr == null || arr.length == 0) {
        return -1;
    }
    
    int N = arr.length;
    // 特殊情况处理
    if (N == 1) {
        return 0;
    }
    
    // 检查0位置
    if (arr[0] < arr[1]) {
        return 0;
    }
    
    // 检查N-1位置
    if (arr[N-1] < arr[N-2]) {
        return N-1;
    }
    
    // 二分查找
    int L = 1;
    int R = N-2;
    while (L <= R) {
        int mid = L + ((R-L) >> 1);
        // 找到局部最小
        if (arr[mid] < arr[mid-1] && arr[mid] < arr[mid+1]) {
            return mid;
        }
        // 根据趋势继续查找
        if (arr[mid] > arr[mid-1]) {
            R = mid - 1;
        } else {
            L = mid + 1;
        }
    }
    return -1;
}

5. 关键点解析

  1. 为什么可以用二分?

    • 如果mid不是局部最小,那么一定至少有一侧存在局部最小
    • 通过比较mid与相邻位置的大小关系可以确定搜索方向
  2. 边界情况处理

    • 数组长度为1时,0位置就是局部最小
    • 检查0位置和N-1位置是否是局部最小
  3. 二分查找的方向选择

    • 如果arr[mid] > arr[mid-1],说明左边一定有局部最小
    • 否则右边一定有局部最小

测试用例

public static void main(String[] args) {
    // 测试用例1:中间位置局部最小
    int[] arr1 = {9,6,3,4,5};
    System.out.println(findLocalMinimum(arr1)); // 输出2
    
    // 测试用例2:最后位置局部最小
    int[] arr2 = {4,3,2,1};
    System.out.println(findLocalMinimum(arr2)); // 输出3
    
    // 测试用例3:第一个位置局部最小
    int[] arr3 = {1,2,3,4,5};
    System.out.println(findLocalMinimum(arr3)); // 输出0
}

复杂度分析

  • 时间复杂度:O(logN)
  • 空间复杂度:O(1)

易错点

  1. 忘记处理边界情况(数组为空、长度为1)
  2. 忘记检查0位置和N-1位置
  3. 二分方向的选择错误

补充说明

这个问题的关键在于理解:

  1. 为什么可以用二分
  2. 如何确定二分的方向
  3. 如何处理边界情况

5.附加问题

   int mid = L + ((R - L) >> 1); // 等同于 (L + R) / 2,但更安全,避免溢出

为什么不直接写 (L + R) / 2 ?

假设有一个非常大的数组,L 和 R 都很大:

L = 2000000000   // 20亿
R = 2000000000   // 20亿

// 如果直接计算 (L + R) / 2
2000000000 + 2000000000 = 4000000000  // 这个数超过了int的最大值(约21亿)

这时会发生整数溢出,导致计算错误。

改进方式一:L + (R - L) / 2

// 方式一:避免溢出的数学变换
(L + R) / 2 
= L/2 + R/2
= L + (R-L)/2

例如:L = 1, R = 5
(1 + 5) / 2 = 3
1 + (5-1)/2 = 1 + 2 = 3

改进方式二:L + ((R - L) >> 1)

// 方式二:用位运算代替除以2
x / 2 等价于 x >> 1(右移一位)

例如:数字6的二进制
6      = 110
6 >> 1 = 011  (等于3)

所以:
L + (R-L)/2 等价于 L + ((R-L) >> 1)

具体例子

L = 1, R = 5

方式一:L + (R-L)/2
1 + (5-1)/2
= 1 + 4/2
= 1 + 2
= 3

方式二:L + ((R-L) >> 1)
1 + ((5-1) >> 1)
= 1 + (4 >> 1)
= 1 + 2
= 3

为什么用位运算?

  1. 更安全:避免了大数相加的溢出风险
  2. 更高效:计算机进行位运算比除法运算更快

记忆方式

  • 除以2 等价于 右移1位(>>1)
  • 乘以2 等价于 左移1位(<<1)

比如:

8 >> 1 = 4  (除以2)
8 << 1 = 16 (乘以2)

6. 加餐- 二进制-十进制互相转化

让我用简单的方式来讲解二进制和十进制的转换。

1. 十进制转二进制

想象一个不断除以2的过程,记录余数,从下往上读就是二进制数。

以十进制 13 转二进制为例:

13 ÷ 2 = 61  ↓
6  ÷ 2 = 30  ↓
3  ÷ 2 = 11  ↓
1  ÷ 2 = 01  ↓

从下往上读余数:1101
所以 13 的二进制是 1101

更多例子:

十进制 8 的转换过程:
8 ÷ 2 = 40
4 ÷ 2 = 20
2 ÷ 2 = 10
1 ÷ 2 = 01
结果:1000

十进制 5 的转换过程:
5 ÷ 2 = 21
2 ÷ 2 = 10
1 ÷ 2 = 01
结果:101

2. 二进制转十进制

使用"权重法",每一位乘以对应的2的幂,然后相加。

以二进制 1101 转十进制为例:

1101 = 1×2³ + 1×2² + 0×2¹ + 1×2⁰
     = 1×8  + 1×4  + 0×2  + 1×1
     = 8    + 4    + 0    + 1
     = 13

更多例子:

二进制 1000:
1×2³ + 0×2² + 0×2¹ + 0×2⁰
= 8 + 0 + 0 + 0 
= 8

二进制 101:
1×2² + 0×2¹ + 1×2⁰
= 4 + 0 + 1 
= 5

3. 快速记忆2的幂

2⁰ = 1
2¹ = 2
2² = 4
2³ = 8
2⁴ = 16
2⁵ = 32
2⁶ = 64
2⁷ = 128
2⁸ = 256

4. 实用小技巧

  1. 判断奇偶数:
// 看二进制的最后一位
1101 (13) 最后一位是1 -> 奇数
1000 (8)  最后一位是0 -> 偶数
  1. 除以2和乘以2:
左移一位(<<1) = 乘以2
右移一位(>>1) = 除以2

例如:
5 (101) << 1 = 1010 (10)
8 (1000) >> 1 = 100 (4)

5. 形象记忆法

想象二进制就像开关:

0 = 关
1 = 开

1101 可以想象成四个开关:
开-开-关-开

6. Java中的进制转换

// 十进制转二进制字符串
String binary = Integer.toBinaryString(13);  // "1101"

// 二进制字符串转十进制
int decimal = Integer.parseInt("1101", 2);   // 13