二分查找算法Ⅰ

256 阅读5分钟

写在前面

  • 文章是在前人的基础上进行总结整理再加上自己的一点理解,仅作为自己学习的记录,不作任何商业用途!
  • 如果在文章中发现错误或者侵权问题,欢迎指出,谢谢!

基本框架

public int binarySearch(int[] array, int target) {
    int left = 0;
    int right = ...;

    while(...) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) ...;
        else if (nums[mid] < target) ...;
        else if (nums[mid] > target) ...;
    }
    
    return ...;
}
  • 说明
    • 二分查找的时候,不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节
    • 上面代码中 ... 标记的部分,就是可能出现细节问题的地方,即不同的二分类型的题这部分内容可能不同

寻找一个数

  • 在数组中搜索一个数,如果该数存在则返回下标,不存在则返回 -1
public int binarySearch(int[] array, int target) {
    int left = 0;
    int right = array.length - 1;
    
    while (left <= right) {
        int mid = left + (right - left) / 2;
        
        if (array[mid] == target) {
            return mid;
        } else if (array[mid] < target) {
            left = mid + 1;
        } else if (array[mid] > target) {
            right = mid - 1;
        }
    }
    
    return - 1;
}
  • Q1:为什么 while 循环的条件中是 <=,而不是 <

    • 首先第一点:可以看到 right = array.length - 1,那么就表明取值是可以取到 right 这个下标值的,也即二分搜索的区间为 [left, right],因此 left 可以等于 right
    • 再者第二点:明确何时会退出循环
      • 找到 target 值,也即通过 array[mid] == target 条件直接 return mid
      • 没有找到 target 值,那么只能是通过 left > right 来退出循环,即 left = right + 1,此时肯定不存在一个 [right + 1, right] 这样的区间的,所以可以直接 return -1
    • 最后第三点:在 right = array.length - 1 不变的情况下,修改为 while (left < right),怎么才能得到正确的答案?
      • 此时 while 退出循环的条件是:一个是找到 target,直接 return mid,另一个是没有找到 target,通过 left = right 退出循环
      • 注意此时 left = right 退出循环的时候的「搜索区间」为 [left, left],是没有去判断 array[left] 是否等于 target 的,所以在返回的时候打一个补丁就行:return array[left] == target ? left : -1
      • 完整代码在变形代码 1
  • Q2:为什么 left = mid + 1right = mid - 1

    • 可以看出在 while 循环里面有三个 if 判断,明显的将数组分为三个区间:小于、等于、大于,那么当我们发现索引 mid 不是要找的 target 时,下一步应该去搜索哪里呢?
    • 当然是去搜索 [left, mid - 1] 或者 [mid + 1, right],因为 mid 已经搜索过了,需要排除掉
    • 像那种 right = mid 或者 left = mid 的是将数组分为两个区间的解题方式
  • Q3:如果是 right = array.length 和 while (left < right) 这种写法该如何修改代码?

    • 在修改了 right 和 while 循环的条件的情况下,如何能够得到正确的结果,其重点在于退出循环的分析
    • 在该条件下,推出循环的情况分为两种:
      • 找到 target 值,也即通过 array[mid] == target 条件直接 return mid
      • 没有找到 target 值,那么只能是通过 left = right 来退出循环,注意这个情况退出循环的分析:
        • 此时 left = right 退出循环的时候的「搜索区间」为 [left, left),是没有去判断 array[left] 是否等于 target 的,所以需要增加一个判断
        • 此时 left = right 是有可能等于 array.length 的,因此还需要判断数组越界的情况
    • 完整代码在变形代码 2
  • Q3:此算法有什么缺陷?

    • 比如说给你有序数组 nums = [1,2,2,2,3],target 为 2,此算法返回的索引是 2,没错
    • 但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的
    • 这种在实际开发中还是很常见的,那么怎么修改上面的算法得到左侧或者右侧的边界呢?见下文
  • 变形代码 1

public int binarySearch(int[] array, int target) {
    int left = 0;
    int right = nums.length - 1;

    while(left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            return mid;
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid;  // 注意这里也发生了变化
        }
    }
    
    return nums[left] == target ? left : -1;
}
  • 变形代码 2
public int search(int[] nums, int target) {
    int left = 0;
    int right = nums.length;

    while(left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            return mid;
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid;  // 后面会讲为什么是 mid 而不是 mid - 1
        }
    }
    if (left == nums.length || nums[left] != target) return -1;
    return left;
}

寻找左边界

  • 在一个存在重复数字的数组中搜索一个数,如果该数存在则返回最左边的下标,不存在则返回 -1
public int leftBound(int[] nums, int target) {
    int left = 0;
    int right = nums.length;  // 注意和下一版本对比

    while(left < right) {  // 注意和下一版本对比
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            right = mid;  // 注意和下一版本对比
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid;  // 注意和下一版本对比
        }
    }

    if (left == nums.length || nums[left] != target) return -1;
    return left; 
}
  • Q1:为啥这里要写成 right = nums.length 使得「搜索区间」变成左闭右开呢?

    • 因为对于搜索左右侧边界的二分查找,这种写法比较普遍
    • 后面也提供了 right = nums.length - 1 的版本,并且使用该方式,需要注意改动的地方
  • Q2:为什么 while 中是 < 而不是 <=

    • 因为 right = nums.length 而不是 nums.length - 1。因此每次循环的「搜索区间」是 [left, right) 左闭右开的形式
    • 在该条件下,是通过 left = right 来退出循环,注意这个情况退出循环的分析:
      • 此时 left = right 退出循环的时候 是没有判断 array[left] 是否等于 target 的,所以需要去判断
      • 此时 left = right 是有可能等于 array.length 的,因此还需要判断数组越界的情况
  • Q4:为什么 left = mid + 1right = mid ?和之前的算法不一样?

    • 因为我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步的搜索区间应该去掉 mid 分割成两个区间,即 [left, mid)[mid + 1, right)
  • Q3:为什么该算法能够搜索左侧边界?

    • 关键在于对于 nums[mid] == target 这种情况的处理:if (nums[mid] == target) right = mid;
    • 找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的
  • Q5:能不能令 right = nums.length - 1,也即继续使用两边都闭合的「搜索区间」?

    • 如果要令 right = nums.length - 1,那么就需要修改 while 语句为:while (left <= right)
    • 那么更新 left 和 right 的逻辑需要修改
      if (nums[mid] == target) {
          right = mid - 1;  // 收缩右侧边界
      }
      else if (nums[mid] < target) {
          left = mid + 1;  // 搜索区间变为 [mid+1, right]
      }
      else if (nums[mid] > target) {
          right = mid - 1;  // 搜索区间变为 [left, mid - 1]
      }
      
    • 在该条件下,是通过 left = right + 1 来退出循环,注意这个情况退出循环的分析:
      • 此时 left = right + 1 退出循环的时候是没有去判断 array[left] 是否等于 target 的,所以需要去判断
      • targetnums 中所有元素都大时,会存在 left = right + 1 = nums.length 的情况使得索引越界,因此在最后需要添加判断
    • 完整代码在变形代码 1
  • 变形代码 1

public int leftBound(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;  // 注意和上一版本对比

    while(left <= right) {  // 注意和上一版本对比
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            right = mid - 1;  // 注意和上一版本对比
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid - 1;  // 注意和上一版本对比
        }
    }

    if (left == nums.length || nums[left] != target) return -1;
    return left;
}

寻找右边界

  • 在一个存在重复数字的数组中搜索一个数,如果该数存在则返回最右边的下标,不存在则返回 -1
public int rightBound(int[] nums, int target) {
    int left = 0;
    int right = nums.length;  // 注意和下一版本对比
    
    while(left < right) {  // 注意和下一版本对比
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            return left = mid + 1;  // 注意和下一版本对比
        }
        else if (nums[mid] < target) {
            left = mid + 1;  // 注意和下一版本对比
        }
        else if (nums[mid] > target) {
            right = mid  // 注意和下一版本对比
        }
    }
    
    if (left == 0 || nums[left - 1] != target ) return -1;
    return left - 1;  // 返回值需要 -1
}
  • Q1:为啥这里要写成 right = nums.length 使得「搜索区间」变成左闭右开呢?

    • 上面解答过
  • Q2:为什么 while 中是 < 而不是 <=

    • 上面解答过
  • Q3:为什么该算法能够搜索右侧边界?

    • 关键在于对于 nums[mid] == target 的处理:if (nums[mid] == target) left = mid + 1;
    • nums[mid] == target 时不要立即返回,而是增大「搜索区间」的下界 left,使得区间不断向右收缩,达到锁定右侧边界的目的
  • Q4:为什么最后返回 left - 1 而不像左侧边界的函数,返回 left?而且我觉得这里既然是搜索右侧边界,应该返回 right 才对

    • 首先,退出循环的条件之一就是 left == right,那么返回 right - 1 和 返回 left - 1 是一样的
    • 再者,为什么是返回 left - 1 而不是 left
      • 关键点还是在 if (nums[mid] == target) return left = mid + 1; 因为相等的时候 left 是先加了 1 的,那么当返回 mid 的下标的时候,left 当然需要减去 1 再返回:mid = left - 1
  • Q5:能不能令 right = nums.length - 1,也即继续使用两边都闭合的「搜索区间」?

    • 如果要令 right = nums.length - 1,那么就需要修改 while 语句为:while (left <= right)
    • 那么更新 left 和 right 的逻辑需要修改
      if (nums[mid] == target) {
          left = mid + 1;  // 收缩左侧边界
      }
      else if (nums[mid] < target) {
          left = mid + 1;  // 搜索区间变为 [mid + 1, right]
      }
      else if (nums[mid] > target) {
          right = mid - 1;  // 搜索区间变为 [left, mid - 1]
      }
      
    • 在该条件下,是通过 left = right + 1 来退出循环,注意这个情况退出循环的分析:
      • 此时 left = right + 1 退出循环的时候是没有去判断 array[right] 是否等于 target 的,所以需要去判断
      • targetnums 中所有元素都小时,会存在 right = left - 1 = -1 的情况使得索引越界,因此在最后需要添加判断
    • 完整代码在变形代码 1
  • 变形代码 1

public int rightBound2(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;  // 注意和上一版本对比

    while(left <= right) {  // 注意和上一版本对比
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            left = mid + 1;
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid - 1;  // 注意和上一版本对比
        }
    }

    if (right < 0 || nums[right] != target ) return -1;
    return right;  // 注意和上一版本对比
}

总结(逻辑统一)

寻找一个数Ⅰ
如果初始化:
    right = nums.length
那么 while 循环的条件为:
    while (left < right)
那么 left 和 right 的更新逻辑为:
    if (nums[mid] == target) return mid;
    else if (nums[mid] < target) left = mid + 1;
    else if (nums[mid] > target) right = mid;
最后需要先判断再返回:
    if (left == nums.length || nums[left] != target) return -1;
    return left;
public int search(int[] nums, int target) {
    int left = 0;
    int right = nums.length;

    while(left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            return mid;
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid;  // 后面会讲为什么是 mid 而不是 mid - 1
        }
    }
    if (left == nums.length || nums[left] != target) return -1;
    return left;
}
寻找左边界Ⅰ
如果初始化:
    right = nums.length
那么 while 循环的条件为:
    while (left < right)
那么 left 和 right 的更新逻辑为:
    if (nums[mid] == target) right = mid;
    else if (nums[mid] < target) left = mid + 1;
    else if (nums[mid] > target) right = mid;
最后需要判断再返回:
    if (left == nums.length || nums[left] != target) return -1;
    return left;
public int leftBound(int[] nums, int target) {
    int left = 0;
    int right = nums.length;  // 注意和下一版本对比

    while(left < right) {  // 注意和下一版本对比
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            right = mid;  // 注意和下一版本对比
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid;  // 注意和下一版本对比
        }
    }

    if (left == nums.length || nums[left] != target) return -1;
    return left; 
}
寻找右边界Ⅰ
如果初始化:
    right = nums.length
那么 while 循环的条件为:
    while (left < right)
那么 left 和 right 的更新逻辑为:
    if (nums[mid] == target) left = mid + 1;
    else if (nums[mid] < target) left = mid + 1;
    else if (nums[mid] > target) right = mid;
最后需要判断再返回:
    if (left == 0 || nums[left - 1] != target) return -1;
    return left - 1;
public int rightBound(int[] nums, int target) {
    int left = 0;
    int right = nums.length;  // 注意和下一版本对比
    
    while(left < right) {  // 注意和下一版本对比
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            return left = mid + 1;  // 注意和下一版本对比
        }
        else if (nums[mid] < target) {
            left = mid + 1;  // 注意和下一版本对比
        }
        else if (nums[mid] > target) {
            right = mid  // 注意和下一版本对比
        }
    }
    
    if (left == 0 || nums[left - 1] != target ) return -1;
    return left - 1;  // 返回值需要 -1
}
寻找一个数Ⅱ
如果初始化:
    right = nums.length - 1
那么 while 循环的条件为:
    while (left <= right)
那么 left 和 right 的更新逻辑为:
    if (nums[mid] == target) return mid;
    else if (nums[mid] < target) left = mid + 1;
    else if (nums[mid] > target) right = mid - 1;
最后可以直接返回:
    return -1;
public int binarySearch(int[] array, int target) {
    int left = 0;
    int right = array.length - 1;
    
    while (left <= right) {
        int mid = left + (right - left) / 2;
        
        if (array[mid] == target) {
            return mid;
        } else if (array[mid] < target) {
            left = mid + 1;
        } else if (array[mid] > target) {
            right = mid - 1;
        }
    }
    
    return - 1;
}
寻找左边界Ⅱ
如果初始化:
    right = nums.length - 1
那么 while 循环的条件为:
    while (left <= right)
那么 left 和 right 的更新逻辑为:
    if (nums[mid] == target) right = mid - 1;
    else if (nums[mid] < target) left = mid + 1;
    else if (nums[mid] > target) right = mid - 1;
最后需要判断再返回:
    if (left == nums.length || nums[left] != target) return -1;
    return left;
public int leftBound(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;  // 注意和上一版本对比

    while(left <= right) {  // 注意和上一版本对比
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            right = mid - 1;  // 注意和上一版本对比
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid - 1;  // 注意和上一版本对比
        }
    }

    if (left == nums.length || nums[left] != target) return -1;
    return left;
}
寻找右边界Ⅱ
如果初始化:
    right = nums.length - 1
那么 while 循环的条件为:
    while (left <= right)
那么 left 和 right 的更新逻辑为:
    if (nums[mid] == target) left = mid + 1;
    else if (nums[mid] < target) left = mid + 1;
    else if (nums[mid] > target) right = mid - 1;
最后需要判断再返回:
    if (right < 0 || nums[right] != target) return -1;
    return right;
public int rightBound2(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;  // 注意和上一版本对比

    while(left <= right) {  // 注意和上一版本对比
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            left = mid + 1;
        }
        else if (nums[mid] < target) {
            left = mid + 1;
        }
        else if (nums[mid] > target) {
            right = mid - 1;  // 注意和上一版本对比
        }
    }

    if (right < 0 || nums[right] != target ) return -1;
    return right;  // 注意和上一版本对比
}

LeetCode 相关题型

参考与感谢