二分查找(二):二分查找边界

317 阅读13分钟

二分查找边界

image.png 在算法领域,二分查找(Binary Search)以其高效性和简洁性,成为解决有序数组中查找问题的首选方法。然而,除了基本的查找功能,二分查找还可以扩展用于查找元素的边界位置,即最左边界和最右边界。这些边界的查找在处理包含重复元素的数组时尤为重要,能够帮助我们快速确定某个元素在数组中的范围或统计其出现次数。本文将深入探讨二分查找边界的概念,重点解析如何查找左边界和右边界,以及复用查找左边界的方法来高效定位右边界。

查找左边界

查找左边界的核心任务是找到数组中第一个等于目标值 target 的元素的位置。在有序数组中,这意味着我们需要定位到所有相同元素中最左侧的位置。传统的二分查找在遇到相等元素时,通常会返回任意一个匹配的索引。然而,查找左边界要求我们进一步缩小搜索范围,确保找到最左端的那个匹配项。

实现查找左边界的方法,关键在于调整二分查找的条件。当我们发现 nums[mid] 等于 target 时,不应立即返回,而是继续向左半部分搜索,试图找到更靠左的匹配项。具体来说,如果 nums[mid] 小于 target,则目标必然在右半部分,更新左指针 left = mid + 1;否则,无论 nums[mid] 是否等于 target,都将右指针 right = mid,继续向左搜索。这种方式确保了在最终退出循环时,left 指针正好指向第一个不小于 target 的位置。

然而,需要注意的是,最终找到的位置可能并不等于 target,尤其是在目标值不存在于数组中的情况下。因此,查找左边界后,必须进行一次验证,确认 nums[left] 确实等于 target。如果验证失败,则返回 -1,表示目标值不存在。

查找右边界

查找右边界的任务是找到数组中最后一个等于目标值 target 的元素的位置。与查找左边界类似,查找右边界在处理包含重复元素的数组时尤为重要。然而,直接应用类似于左边界的查找方法并不能直接得到右边界的位置。为了解决这一问题,我们需要采用一种巧妙的策略,即复用查找左边界的方法

1. 复用查找左边界

复用查找左边界的方法的关键在于将查找右边界的问题转化为查找另一个相关目标值的左边界。具体来说,我们可以通过查找 target + 1 的左边界,然后将其减一,得到 target 的右边界。这种方法的原理基于有序数组中相同元素的连续性:所有等于 target 的元素都是紧挨在一起的,因此,target + 1 的第一个出现位置前一个位置必然是 target 的最后一个出现位置。

具体步骤如下:
  1. 查找 target + 1 的左边界:使用之前定义的查找左边界的方法,找到数组中第一个大于等于 target + 1 的元素的位置,记为 i

  2. 计算右边界:将 i 减一,得到 j = i - 1。此时,j 应该指向 target 的最后一个出现位置。

  3. 验证:检查 nums[j] 是否等于 target。如果等于,则 jtarget 的右边界;否则,返回 -1,表示目标值不存在。

这种方法的优势在于,复用了已有的查找左边界的逻辑,避免了重复编码,同时保持了算法的高效性。通过这种转化,我们能够在不显著增加复杂度的情况下,实现对右边界的准确定位。

Java 实现示例
/**
 * 查找最右边的 target 的索引
 * 通过复用 binarySearchLeftEdge 来实现
 * @param nums 已排序的数组(升序排列)
 * @param target 目标值
 * @return 最右边的索引,如果不存在则返回 -1
 */
public static int binarySearchRightEdge(int[] nums, int target) {
    int left = 0, right = nums.length;
    int nextTarget = target + 1;
    
    // 特殊处理 target 为 Integer.MAX_VALUE 以避免溢出
    if (target == Integer.MAX_VALUE) {
        nextTarget = target;
    }
    
    // 查找 target + 1 的左边界
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < nextTarget) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    
    int j = left - 1;
    if (j < 0 || nums[j] != target) {
        return -1;
    }
    return j;
}

2. 转化为查找元素

除了复用查找左边界的方法之外,另一种方法是转化为查找元素。这种方法基于以下观察:

当数组中不包含 target 时,最终的 leftright 指针将分别指向第一个大于和第一个小于 target 的元素。因此,我们可以通过构造一个数组中不存在的元素,来辅助定位 target 的边界。

具体方法如下:
  1. 查找最左边界

    • 转化为查找 target - 0.5 的插入点。
    • 由于 target - 0.5 不存在于数组中,binarySearch 将返回第一个大于 target - 0.5 的元素的索引,这恰好是 target 的最左边界。
  2. 查找最右边界

    • 转化为查找 target + 0.5 的插入点。
    • binarySearch 将返回第一个大于 target + 0.5 的元素的索引,这个位置减一即为 target 的最右边界。
为什么选择 target - 0.5target + 0.5

选择 target - 0.5target + 0.5 的原因在于,这些值在整数数组中不会存在(假设数组元素为整数)。因此,它们可以作为一个介于 target - 1target 之间、以及 targettarget + 1 之间的虚构值,帮助我们精确定位 target 的边界位置。

图示说明

假设我们有一个有序数组:

数组: [1, 2, 2, 2, 3, 4, 5]
索引: 0  1  2  3  4  5  6

目标值: 2

  • 查找最左边界

    • 查找 2 - 0.5 = 1.5 的插入点。
    • 在数组中,1.5 应该插入在第一个 2 的位置,即索引 1
    • 因此,最左边界为 1
  • 查找最右边界

    • 查找 2 + 0.5 = 2.5 的插入点。
    • 在数组中,2.5 应该插入在最后一个 2 之后,即索引 4
    • 将插入点减一,得到 3,即最右边界为 3
Java 实现示例

由于在实际编程中,我们无法直接使用浮点数 target - 0.5target + 0.5,尤其是在处理整数数组时。因此,我们需要一种替代方法来模拟这一效果。一个常见的做法是:

  • 查找最左边界:直接使用查找左边界的方法。
  • 查找最右边界:使用 target + 1 的查找左边界,再减一。

以下是基于这一思路的 Java 实现:

/**
 * 查找最右边的 target 的索引
 * 通过转化为查找 target + 1 的左边界来实现
 * @param nums 已排序的数组(升序排列)
 * @param target 目标值
 * @return 最右边的索引,如果不存在则返回 -1
 */
public static int binarySearchRightEdgeAlternative(int[] nums, int target) {
    int left = 0, right = nums.length;
    int nextTarget = target + 1;
    
    // 特殊处理 target 为 Integer.MAX_VALUE 以避免溢出
    if (target == Integer.MAX_VALUE) {
        nextTarget = target;
    }
    
    // 查找 target + 1 的左边界
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < nextTarget) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    
    int j = left - 1;
    if (j < 0 || nums[j] != target) {
        return -1;
    }
    return j;
}

代码详解

上述两种方法虽然实现方式略有不同,但核心思想相似,都是通过查找一个与 target 相关的插入点,然后通过计算得到 target 的边界位置。下面详细解释这两种方法的关键步骤和逻辑:

  1. 复用查找左边界

    • 目的:通过查找 target + 1 的左边界,确定 target 的最右边界。
    • 步骤
      • 使用 binarySearchLeftEdge 方法查找 target + 1 的插入点 i
      • 计算 j = i - 1,即为 target 的最右边界。
      • 验证 nums[j] == target,确保 target 存在于数组中。
    • 优势:复用了已有的查找左边界的方法,代码结构清晰,易于维护。
  2. 转化为查找元素

    • 目的:通过查找一个不存在于数组中的元素(如 target - 0.5target + 0.5),确定 target 的边界位置。
    • 步骤
      • 虽然在实际编程中无法直接使用 target - 0.5target + 0.5,但通过调整查找目标值为 target + 1,同样可以实现定位右边界的效果。
      • 使用 binarySearchLeftEdge 方法查找 target + 1 的插入点 i
      • 计算 j = i - 1,即为 target 的最右边界。
      • 验证 nums[j] == target,确保 target 存在于数组中。
    • 优势:概念上与查找边界的数学理论相符,增强了算法的理解深度。

实际应用中的实现

为了使二分查找边界的方法更加通用,可以在函数中引入一个布尔参数 ascending,指示数组的排序顺序。根据这个参数,调整比较逻辑,以适应升序或降序数组的不同需求。这种设计使得同一个函数既能处理升序数组,也能处理降序数组,提升了代码的复用性和灵活性。

完整的 Java 实现
import java.util.Arrays;

public class BinarySearchEdge {

    /**
     * 查找插入点,即第一个符合条件的元素的索引
     * @param nums 已排序的数组(升序或降序)
     * @param target 目标值
     * @param ascending 是否为升序排列
     * @return 插入点的索引
     */
    public static int binarySearchInsertion(int[] nums, int target, boolean ascending) {
        int left = 0;
        int right = nums.length; // 使用 nums.length 作为右边界

        while (left < right) {
            int mid = left + (right - left) / 2;
            if (ascending) {
                if (nums[mid] < target) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            } else { // 降序
                if (nums[mid] > target) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            }
        }
        return left;
    }

    /**
     * 查找最左边的 target 的索引
     * @param nums 已排序的数组(升序或降序)
     * @param target 目标值
     * @param ascending 是否为升序排列
     * @return 最左边的索引,如果不存在则返回 -1
     */
    public static int binarySearchLeftEdge(int[] nums, int target, boolean ascending) {
        if (nums.length == 0) return -1;
        int i = binarySearchInsertion(nums, target, ascending);
        if (i == nums.length || nums[i] != target) return -1;
        return i;
    }

    /**
     * 查找最右边的 target 的索引
     * 通过复用 binarySearchInsertion 来实现
     * @param nums 已排序的数组(升序或降序)
     * @param target 目标值
     * @param ascending 是否为升序排列
     * @return 最右边的索引,如果不存在则返回 -1
     */
    public static int binarySearchRightEdge(int[] nums, int target, boolean ascending) {
        if (nums.length == 0) return -1;
        int nextTarget;
        if (ascending) {
            nextTarget = target + 1;
        } else { // 降序
            nextTarget = target - 1;
        }

        // 特殊处理 target 的边界情况,避免溢出
        if (ascending && target == Integer.MAX_VALUE) {
            nextTarget = target;
        }
        if (!ascending && target == Integer.MIN_VALUE) {
            nextTarget = target;
        }

        int i = binarySearchInsertion(nums, nextTarget, ascending);
        int j = i - 1;

        if (j < 0 || j >= nums.length || nums[j] != target) return -1;
        return j;
    }

    /**
     * 主方法,包含多个测试用例
     */
    public static void main(String[] args) {
        // 测试数组(升序和降序)
        int[][] testArraysAscending = {
            {1, 2, 2, 2, 3, 4, 5},
            {1, 1, 1, 1, 1},
            {1, 3, 5, 7, 9},
            {},
            {2, 2, 2, 2, 2},
            {1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
            {Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE}
        };

        int[][] testArraysDescending = {
            {5, 4, 3, 2, 2, 2, 1},
            {1, 1, 1, 1, 1},
            {9, 7, 5, 3, 1},
            {},
            {2, 2, 2, 2, 2},
            {10, 9, 8, 7, 6, 5, 4, 3, 2, 1},
            {Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE}
        };

        // 测试目标值
        int[] targetsAscending = {2, 1, 4, 3, 2, 11, Integer.MAX_VALUE};
        int[] targetsDescending = {2, 1, 4, 3, 2, 0, Integer.MAX_VALUE};

        System.out.println("=== 升序数组测试 ===");
        for (int i = 0; i < testArraysAscending.length; i++) {
            int[] nums = testArraysAscending[i];
            int target = targetsAscending[i];
            int left = binarySearchLeftEdge(nums, target, true);
            int right = binarySearchRightEdge(nums, target, true);
            System.out.println("测试用例 " + (i + 1) + ":");
            System.out.println("数组: " + Arrays.toString(nums));
            System.out.println("目标值: " + target);
            System.out.println("最左边界索引: " + left);
            System.out.println("最右边界索引: " + right);
            System.out.println("-----------------------------");
        }

        System.out.println("=== 降序数组测试 ===");
        for (int i = 0; i < testArraysDescending.length; i++) {
            int[] nums = testArraysDescending[i];
            int target = targetsDescending[i];
            int left = binarySearchLeftEdge(nums, target, false);
            int right = binarySearchRightEdge(nums, target, false);
            System.out.println("测试用例 " + (i + 1) + ":");
            System.out.println("数组: " + Arrays.toString(nums));
            System.out.println("目标值: " + target);
            System.out.println("最左边界索引: " + left);
            System.out.println("最右边界索引: " + right);
            System.out.println("-----------------------------");
        }

        // 额外的自定义测试用例
        System.out.println("=== 额外的自定义测试用例 ===");
        // 升序
        int[] numsAsc = {1, 2, 2, 2, 3, 4, 5};
        int targetAsc = 2;
        System.out.println("升序数组: " + Arrays.toString(numsAsc));
        System.out.println("目标值: " + targetAsc);
        System.out.println("最左边界索引: " + binarySearchLeftEdge(numsAsc, targetAsc, true));   // Expected: 1
        System.out.println("最右边界索引: " + binarySearchRightEdge(numsAsc, targetAsc, true)); // Expected: 3
        System.out.println("-----------------------------");

        // 降序
        int[] numsDesc = {5, 4, 4, 4, 3, 2, 1};
        int targetDesc = 4;
        System.out.println("降序数组: " + Arrays.toString(numsDesc));
        System.out.println("目标值: " + targetDesc);
        System.out.println("最左边界索引: " + binarySearchLeftEdge(numsDesc, targetDesc, false));   // Expected: 1
        System.out.println("最右边界索引: " + binarySearchRightEdge(numsDesc, targetDesc, false)); // Expected: 3
        System.out.println("-----------------------------");
    }
}

测试用例解析

上述代码中,我们设计了多个测试用例,分别针对升序和降序数组,验证最左边界和最右边界的查找功能。以下是部分测试用例的解析:

升序数组测试
  1. 测试用例 1:

    • 数组: [1, 2, 2, 2, 3, 4, 5]
    • 目标值: 2
    • 最左边界: 1(第一个 2 出现在索引 1
    • 最右边界: 3(最后一个 2 出现在索引 3
  2. 测试用例 6:

    • 数组: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    • 目标值: 11
    • 最左边界: -1(目标值不存在)
    • 最右边界: -1
降序数组测试
  1. 测试用例 1:

    • 数组: [5, 4, 3, 2, 2, 2, 1]
    • 目标值: 2
    • 最左边界: 3(第一个 2 出现在索引 3
    • 最右边界: 5(最后一个 2 出现在索引 5
  2. 测试用例 6:

    • 数组: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
    • 目标值: 0
    • 最左边界: -1(目标值不存在)
    • 最右边界: -1

运行结果示例

PixPin_2024-10-29_16-52-38.png 输出如下

=== 升序数组测试 ===
测试用例 1:
数组: [1, 2, 2, 2, 3, 4, 5]
目标值: 2
最左边界索引: 1
最右边界索引: 3
-----------------------------
测试用例 2:
数组: [1, 1, 1, 1, 1]
目标值: 1
最左边界索引: 0
最右边界索引: 4
-----------------------------
测试用例 3:
数组: [1, 3, 5, 7, 9]
目标值: 4
最左边界索引: -1
最右边界索引: -1
-----------------------------
测试用例 4:
数组: []
目标值: 3
最左边界索引: -1
最右边界索引: -1
-----------------------------
测试用例 5:
数组: [2, 2, 2, 2, 2]
目标值: 2
最左边界索引: 0
最右边界索引: 4
-----------------------------
测试用例 6:
数组: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
目标值: 11
最左边界索引: -1
最右边界索引: -1
-----------------------------
测试用例 7:
数组: [2147483647, 2147483647, 2147483647]
目标值: 2147483647
最左边界索引: 0
最右边界索引: 2
-----------------------------
=== 降序数组测试 ===
测试用例 1:
数组: [5, 4, 3, 2, 2, 2, 1]
目标值: 2
最左边界索引: 3
最右边界索引: 5
-----------------------------
测试用例 2:
数组: [1, 1, 1, 1, 1]
目标值: 1
最左边界索引: 0
最右边界索引: 4
-----------------------------
测试用例 3:
数组: [9, 7, 5, 3, 1]
目标值: 4
最左边界索引: -1
最右边界索引: -1
-----------------------------
测试用例 4:
数组: []
目标值: 3
最左边界索引: -1
最右边界索引: -1
-----------------------------
测试用例 5:
数组: [2, 2, 2, 2, 2]
目标值: 2
最左边界索引: 0
最右边界索引: 4
-----------------------------
测试用例 6:
数组: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
目标值: 0
最左边界索引: -1
最右边界索引: -1
-----------------------------
测试用例 7:
数组: [2147483647, 2147483647, 2147483647]
目标值: 2147483647
最左边界索引: 0
最右边界索引: 2
-----------------------------
=== 额外的自定义测试用例 ===
升序数组: [1, 2, 2, 2, 3, 4, 5]
目标值: 2
最左边界索引: 1
最右边界索引: 3
-----------------------------
降序数组: [5, 4, 4, 4, 3, 2, 1]
目标值: 4
最左边界索引: 1
最右边界索引: 3
-----------------------------

总结

二分查找作为一种高效的搜索算法,不仅能够在有序数组中快速定位目标元素,还能通过巧妙的方法扩展用于查找元素的最左边界和最右边界。通过复用查找左边界的逻辑,我们能够高效地确定目标元素在数组中的范围,尤其在处理包含重复元素的数组时,这种方法显得尤为重要。

在实现过程中,了解数组的排序顺序(升序或降序)对比较逻辑的影响,是确保算法正确性的关键。通过引入 ascending 参数,我们可以在同一个函数中适配不同的排序顺序,提升代码的复用性和灵活性。此外,处理特殊情况,如空数组、目标值不存在或目标值为极值(如 Integer.MAX_VALUE),也是实现鲁棒性算法的重要环节。