nineloong'sblog
首页归档照片墙音乐杂谈友链关于
封面
点击查看全图

二分查找:从经典模板到应用题

2026/5/23
# 二分搜索
# 专题探讨

前言

二分查找的核心思想很简单:每次把搜索范围砍掉一半。但刷题时会发现,不同题目的写法差别很大——while(left < right) 还是 while(left <= right)?left = mid + 1 还是 left = mid?mid 什么时候要向上取整?

这篇文章按难度递进,从最经典的 704 开始,经过边界搜索、峰值查找、旋转数组、二维矩阵,最后到三道应用题,覆盖二分查找的主流题型。每个题在讲清楚解法的同时,会说明循环条件和指针移动的缘由。


704. 二分查找 — 一切从这开始

LeetCode 704 — 二分查找 (Easy)

在一个升序数组中找 target,找到返回下标,否则返回 -1。

这是二分查找的「标准形态」:数组全局有序、无重复元素、查找精确值。

int search(vector<int>& nums, int target) {
    int left = 0, right = nums.size() - 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 right = mid - 1;                         
    }
    return -1;
}

区间 [left, right] 中的每一个元素都可能是答案,当 left == right 时区间里还有一个元素没检查,必须再进一次循环,所以用 while(left <= right)。写成 left < right 的话单元素数组会直接跳过,永远返回 -1。nums[mid] 已经在循环开头跟 target 比较过,确定不等于 target,可以把它从搜索区间里移除,所以边界收紧到 mid ± 1。

这就是二分查找的基础模板,后面的所有变体都建立在此之上,只是根据需求不同调整了循环条件和边界移动方式。


34. 在排序数组中查找元素的第一个和最后一个位置

LeetCode 34 — 在排序数组中查找元素的第一个和最后一个位置 (Medium)

给定一个升序整数数组 nums 和一个目标值 target,找出 target 在数组中的起始位置和结束位置。如果数组中不存在 target,返回 [-1, -1]。要求时间复杂度 O(log n)。

求解思路

直接的想法是先用基础二分找到任意一个 target,然后向两边线性扫描找边界。但这样在最坏情况下——比如整个数组全是同一个 target——会退化成 O(n),失去了二分的优势。

核心问题是如何用二分直接找到边界。找到 target 本身不难,难的是找到第一个出现位置和最后一个出现位置。换个问法:

  • 找左边界 = 找第一个 ≥ target 的位置
  • 找右边界 = 找第一个 > target 的位置,再减 1

也就是说,把「找开始和结束」转化为两次边界查询。而边界查询可以统一为:在一个有序数组中,找第一个满足某个条件的位置。

在经典二分中,找不到 target 时 left 和 right 自然交错,循环结束即可判断。但在边界查询中,满足条件和不满足条件的结果混在一起——比如找第一个 ≥ target 的位置,当 nums[mid] >= target 时往左走,但 mid 本身就是一个候选答案,必须先记录再收缩。用 ans 变量保存最近一次满足条件的 mid,同时 right = mid - 1 继续往左试探;不满足条件时 left = mid + 1 往右走。这个「记录 + 收缩」的模式是二分查找处理边界问题的核心技巧。

int binarySearch(const vector<int>& nums, int target, bool lower) {
    int left = 0, right = nums.size() - 1, ans = nums.size();
    while (left <= right) {
        int mid = left + (right - left) / 2;
        // lower=true: 找第一个 >= target → 满足条件往左搜
        // lower=false: 找第一个 > target  → 满足条件往左搜
        if (nums[mid] > target || (lower && nums[mid] >= target)) {
            ans = mid;          // 记录候选,继续缩小范围
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return ans;
}

vector<int> searchRange(vector<int>& nums, int target) {
    int leftIdx = binarySearch(nums, target, true);       // 第一个 >= target
    int rightIdx = binarySearch(nums, target, false) - 1; // 第一个 > target 再 -1
    if (leftIdx <= rightIdx && nums[leftIdx] == target && nums[rightIdx] == target) {
        return {leftIdx, rightIdx};
    }
    return {-1, -1};
}

复杂度: 时间 O(log n),两次二分;空间 O(1)。

LCR 172 — 统计 target 出现次数

LeetCode LCR 172 和 34 的代码完全一致,区别只在于最后返回的是 rightIdx - leftIdx + 1(即 target 的个数)。理解了 34,这道题自然就过了,不再展开。


162. 寻找峰值

LeetCode 162 — 寻找峰值

给定一个整数数组 nums,保证相邻元素不相等。峰值元素是指值严格大于左右相邻元素的元素。数组可能存在多个峰值,返回任意一个峰值的下标即可。数组边界外的元素视为负无穷(即 nums[-1] = nums[n] = -∞),这保证了数组至少存在一个峰值。

求解思路

最直接的想法当然是线性扫描:只要碰到一个 nums[i] > nums[i+1],那 i 就是峰值——因为在它之前一直是上升的,突然下降了,这个拐点就是峰值。这个 O(n) 的解法已经能通过,但我们可以更快。

关键观察:取数组中任意位置 mid,看它和右边邻居 nums[mid+1] 的关系。如果 nums[mid] > nums[mid+1],说明从 mid 开始已经在走下坡路了——那么 mid 及其左侧一定存在一个峰值。反之如果 nums[mid] < nums[mid+1],还在上坡,峰值一定在 mid 右侧。数组两端视为负无穷保证了任何数组都至少有一个峰值。

这就把问题转化成了二分:每次比较 mid 和 mid+1,根据上坡/下坡方向决定往哪边走。因为每次都能保证目标半区一定包含峰值,二分一定收敛到正确答案。

这个二分不靠 nums[mid] == target 终止——没有 target 给你比较。我们是在不断缩小区间,直到区间只剩一个元素,那个元素就是峰值。因此用 while(left < right),当 left == right 时区间只有一个元素,就是答案,不需要再进入循环。

当 nums[mid] > nums[mid+1] 时,mid 本身比右边大,可能就是峰值,所以 right = mid 保留这个候选。nums[mid] < nums[mid+1] 时,mid 比右边小,绝对不可能是峰值,因此 left = mid + 1 丢弃它。

剩两个元素时,mid 等于 left(向下取整):nums[mid] > nums[mid+1] 时 right = mid = left,区间缩小为 1;nums[mid] < nums[mid+1] 时 left = mid + 1 = right,区间也缩小为 1。两种情况都不会死循环。

int findPeakElement(vector<int>& nums) {
    int left = 0, right = nums.size() - 1;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] > nums[mid + 1]) {
            right = mid;       // mid 可能是峰值,保留
        } else {
            left = mid + 1;    // mid 不可能是峰值,丢弃
        }
    }
    return left; // left == right,即峰值下标
}

复杂度: 时间 O(log n);空间 O(1)。


852. 山脉数组的峰顶索引

LeetCode 852 — 山脉数组的峰顶索引 (Medium)

给定一个长度至少为 3 的山脉数组 arr,满足存在某个下标 i(0 < i < arr.length - 1)使得 arr[0] < arr[1] < ... < arr[i] 且 arr[i] > arr[i+1] > ...。找出峰顶下标 i。

这道题是 162 的特例——数组被限定为单峰形态,两端负无穷的条件天然满足。162 的代码可以直接复用,逻辑完全不变。和 162 唯一的不同是题目保证了峰值的唯一性,所以分析时连「任意一个峰值」的说明都不需要了,代码照搬即可:

int peakIndexInMountainArray(vector<int>& arr) {
    int left = 0, right = arr.size() - 1;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] > arr[mid + 1]) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }
    return left;
}

复杂度: 时间 O(log n);空间 O(1)。


658. 找到 K 个最接近的元素

LeetCode 658 — 找到 K 个最接近的元素

给定一个升序数组 arr 和两个整数 k、x,从数组中找出 k 个最接近 x 的元素。两个数 a 和 b 谁更接近 x 由 |a - x| 和 |b - x| 决定,距离相同时取较小的数。结果按升序排列返回。

求解思路

直接做法是把每个元素与 x 的距离算出来再排序取前 k 个,O(n log n)。但数组本身是有序的——这引出一个关键性质:最接近 x 的 k 个数一定是数组中连续的一段。因为如果最终结果不连续,假设我们选了 a 和 c 但跳过了中间的 b,那 b 离 x 一定比 a 或 c 更近(数组有序保证了这一点),矛盾。这个「结果连续」的发现是本题能上二分的基础——我们只需要找到这段长度为 k 的窗口的起始位置,而不是在 n 个元素中逐个挑选。

找起始位置可以用二分。衡量方式是看搜索区间的两个端点哪个离 x 更远——如果左端点比右端点远,说明窗口应该向右移。区间是 [arr[mid], arr[mid+k-1]],下一个可能加入的是 arr[mid+k]。比较 x - arr[mid] 和 arr[mid+k] - x:

  • 如果左边距离更大,说明 x 更靠近右边,窗口右移,left = mid + 1
  • 否则窗口保持或左移,right = mid

我们要找的是长度为 k 的窗口的起始下标,起始下标最大不能超过 n - k,不然凑不够 k 个元素,所以 right 初始值是 arr.size() - k 而不是 arr.size() - 1。和 162 一样,我们在缩小找到最优起始位置,while(left < right) 就够用了。当左边距离不大于右边距离时,mid 作为窗口起点可能是最优的,不能丢弃,故 right = mid。

vector<int> findClosestElements(vector<int>& arr, int k, int x) {
    int left = 0, right = arr.size() - k;
    while (left < right) {
        int mid = left + (right - left) / 2;
        // 比较:丢掉 arr[mid],加入 arr[mid+k] 划不划算
        if (x - arr[mid] > arr[mid + k] - x) {
            left = mid + 1; // 右边更近,窗口右移
        } else {
            right = mid;    // 左边更近或相等,保留 mid(相等取小值)
        }
    }
    return vector<int>(arr.begin() + left, arr.begin() + left + k);
}

复杂度: 时间 O(log n + k),二分找起点 O(log n),构造结果 O(k);空间 O(1)(输出数组不计入)。


33. 搜索旋转排序数组

LeetCode 33 — 搜索旋转排序数组 (Medium)

一个原本严格升序的数组在某个未知下标处进行了旋转——将数组后若干元素整体移到前面。例如 [1,2,3,4,5,6,7] 在下标 3 处旋转得到 [4,5,6,7,1,2,3]。给定旋转后的数组和一个目标值 target,找出 target 的下标,不存在则返回 -1。数组元素互不相同,时间复杂度要求 O(log n)。

求解思路

数组被旋转后变成了两段各自有序的区间,比如 [4,5,6,7,1,2,3],前半段 [4,5,6,7] 升序,后半段 [1,2,3] 升序,但整体不再有序。这看起来不能用二分了——二分的前提是数组有序。

但是,取任意一个位置 mid,它一定落在其中一段有序区间里。怎么区分 mid 在左半段还是右半段?比较 nums[left] 和 nums[mid]:

  • 如果 nums[left] <= nums[mid],说明从 left 到 mid 这一段没有跨过切口,是严格有序的
  • 否则,mid 到 right 这一段是有序的

确定哪一半有序之后,就可以判断 target 是否落在有序的那一半的范围内。如果在,就对该有序半段做标准二分;如果不在,target 必然在另一半,继续在另一半上重复这个逻辑。

和 704 一致,需要检查 nums[mid] == target 且每个元素都可能是答案,所以用 while(left <= right)。nums[mid] 已确定不等于 target,从搜索区间丢弃,故 left = mid + 1、right = mid - 1。

int search(vector<int>& nums, int target) {
    int left = 0, right = nums.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) return mid;

        if (nums[left] <= nums[mid]) {        // 左半段有序
            if (nums[left] <= target && target < nums[mid]) {
                right = mid - 1;              // target 在左半段内
            } else {
                left = mid + 1;               // target 在右半段
            }
        } else {                              // 右半段有序
            if (nums[mid] < target && target <= nums[right]) {
                left = mid + 1;               // target 在右半段内
            } else {
                right = mid - 1;              // target 在左半段
            }
        }
    }
    return -1;
}

复杂度: 时间 O(log n);空间 O(1)。

注意 nums[left] <= nums[mid] 的条件用的是 <= 而不是 <:当 left == mid 时,搜索区间仅剩两个元素,nums[left]已经检查过不是target,所以应该进入右半段,用 <= 将其归为左半段有序,可以正常进入右半段,否则会进入左半段返回-1。


81. 搜索旋转排序数组 II

LeetCode 81 — 搜索旋转排序数组 II (Medium)

和 33 完全相同的设定——旋转后的升序数组中搜索 target——但允许数组中出现重复元素。返回 true 或 false。

求解思路

33 的解法依赖一个前提:比较 nums[left] 和 nums[mid] 就能确定哪一半有序。但重复元素打破了这个前提——当 nums[left] == nums[mid] 时,无法判断左半段是否有序。比如 [1,0,1,1,1] 中 left=0, mid=2,nums[left] == nums[mid] == 1,但左半段并不有序;而 [1,1,1,0,1] 中同样的 left=0, mid=2,nums[left] == nums[mid] == 1,左半段是有序的。两个数组在 mid 处的信息完全一样,但结构不同。

处理方式:遇到 nums[left] == nums[mid] 时,无法获得有用信息,只能将 left 右移一位,丢掉这个重复元素。这是安全的——因为 nums[left] 的值在 mid 处还有一个副本,不会丢失 target。其余逻辑和 33 完全一致。

由于最坏情况下每次只能移动一步(全相等数组),时间复杂度退化到 O(n),但平均仍是 O(log n)。注意返回值从 33 的下标变为 bool。

bool search(vector<int>& nums, int target) {
    int left = 0, right = nums.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) return true;

        // 无法判断左半段是否有序,安全收缩左边界
        if (nums[left] == nums[mid]) {
            left++;
            continue;
        }

        if (nums[left] < nums[mid]) {        // 左半段有序
            if (nums[left] <= target && target < nums[mid]) {
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        } else {                              // 右半段有序
            if (nums[mid] < target && target <= nums[right]) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
    }
    return false;
}

复杂度: 平均 O(log n),最坏 O(n)(全相等时每次 left++ 只移动一步);空间 O(1)。


153. 寻找旋转排序数组中的最小值

LeetCode 153 — 寻找旋转排序数组中的最小值

一个原本严格升序的数组在某个未知下标处旋转过一次(定义和 33 相同),数组元素互不相同。找出旋转后数组中的最小元素。要求时间复杂度 O(log n)。例如 [4,5,6,7,1,2,3] 的最小值为 1。

求解思路

旋转前数组是 [0,1,2,3,4,5,6],旋转后变成 [4,5,6,0,1,2,3]。观察发现:左半段的所有元素都大于右半段的所有元素。最小值就是那个切口位置,也是整个数组唯一一个比左边元素小的元素。虽然数组整体不再有序,但两段各自有序这个性质,仍然给了二分搜索的可能——关键在于找到一个能判断最小值在 mid 左边还是右边的依据。

用 nums[mid] 和 nums[right] 比较即可:

  • nums[mid] > nums[right]:mid 落在了左半段(较大的那半),最小值一定在 mid 右边。因为右半段全部小于左半段,mid 连最大的右半段元素都比不过,说明 mid 还在高处,left = mid + 1。
  • nums[mid] < nums[right]:mid 落在了右半段(较小的那半)。最小值可能在 mid 位置,也可能在 mid 左边。所以 right = mid,保留 mid 这个候选。

用 right 做参照是因为 nums[right] 一定在右半段中(或本身就是最小值),能一致地反映 mid 在高区还是低区。用 left 做参照的话,left 在旋转 0 次时就在低区、旋转非 0 次时在高区,判断逻辑不一致。题目保证元素互不相同,所以不会出现 nums[mid] == nums[right] 的情况。

和 162 一样在缩小区间找位置,用 while(left < right) 即可。剩两个元素时 mid = left(向下取整):nums[mid] > nums[right] 则 left = mid + 1 = right,nums[mid] < nums[right] 则 right = mid = left,都能收敛到单元素。

int findMin(vector<int>& nums) {
    int left = 0, right = nums.size() - 1;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] > nums[right]) {
            left = mid + 1;    // mid 在左半段,最小值在右侧
        } else {
            right = mid;       // mid 在右半段,最小值在左侧(含 mid)
        }
    }
    return nums[right]; // left == right
}

复杂度: 时间 O(log n);空间 O(1)。

延伸:找最大值

对称推导——如果把标准从「找最小值」换成「找最大值」,只需要做两处调整:

  1. 参照物从 right 换成 left(因为最大值一定在左半段)
  2. 与 left 比较后可能出现 left = mid,此时 mid 必须向上取整,即 mid = left + (right - left + 1) / 2,否则只剩两个元素时会死循环
int findMax(vector<int>& nums) {
    int left = 0, right = nums.size() - 1;
    while (left < right) {
        int mid = left + (right - left + 1) / 2; // 向上取整!
        if (nums[mid] < nums[left]) {
            right = mid - 1;    // mid 在右半段,最大值在左侧
        } else {
            left = mid;         // mid 在左半段,最大值在右侧(含 mid)
        }
    }
    return nums[right];
}

找最大值时必须向上取整。因为剩两个元素时如果用向下取整,mid = left,若 nums[mid] >= nums[left],left = mid 会让 left 原地不动,死循环。向上取整后 mid = right,left = mid 直接收敛。二分中出现 left = mid 时,mid 必须向上取整,这是一个硬规则。


154. 寻找旋转排序数组中的最小值 II

LeetCode 154 — 寻找旋转排序数组中的最小值 II

和 153 相同——在旋转过的升序数组中找最小值——但数组允许重复元素。例如 [2,2,2,0,1] 返回 0。重复元素打破了 153 中 nums[mid] 与 nums[right] 严格不等的前提,需要额外处理。

求解思路

在 153 中,nums[mid] 与 nums[right] 的比较只有大于和小于两种情况,方向明确。重复元素引入了第三种情况:nums[mid] == nums[right],此时无法判断 mid 在左半段还是右半段。比如 [1,3,3,3,3](旋转 0 次)中,mid 在左半段但和 right 相等;[3,3,3,1,3](旋转 3 次)中,mid 在左半段且同样和 right 相等。两个数组的 mid-right 关系完全一样,但最小值位置不同——一个在左侧,一个在右侧。153 的逻辑在这里失效了。

解法与34思路相同,相等时只安全丢弃一个元素,遇到相等时,right--。既然 nums[mid] == nums[right],right 这个元素的值在 mid 处也有一个副本,丢掉 right 不会丢失最小值。每次只安全地移除一个元素,直到 nums[mid] 和 nums[right] 重新分出大小,就可以继续走 153 的二分逻辑。

int findMin(vector<int>& nums) {
    int left = 0, right = nums.size() - 1;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] > nums[right]) {
            left = mid + 1;
        } else if (nums[mid] == nums[right]) {
            right--;          // 安全收缩,不丢失最小值
        } else {
            right = mid;
        }
    }
    return nums[right];
}

复杂度: 平均 O(log n),最坏 O(n)(全相等时 right-- 每次只移动一步);空间 O(1)。

延伸:找最大值(允许重复)

对称地,相等时 left++:

int findMax(vector<int>& nums) {
    int left = 0, right = nums.size() - 1;
    while (left < right) {
        int mid = left + (right - left + 1) / 2; // 向上取整
        if (nums[mid] < nums[left]) {
            right = mid - 1;
        } else if (nums[mid] == nums[left]) {
            left++;           // 安全收缩
        } else {
            left = mid;
        }
    }
    return nums[right];
}

74. 搜索二维矩阵

LeetCode 74 — 搜索二维矩阵 (Medium)

给定一个 m x n 的矩阵,每行按升序排列,且每行的第一个元素大于上一行的最后一个元素。判断一个目标值 target 是否存在于矩阵中。例如:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]],target = 3 返回 true。

求解思路

题目给的条件很特别——不仅每行有序,而且行与行之间也有严格的大小关系。这等价于把整个矩阵按行展开,得到一个全局有序的一维数组。所以最简单的做法是把二维坐标映射成一维,做一次全局二分,O(log(m*n))。

另一种做法是两次二分:先对第一列二分确定 target 落在哪一行(找最后一个 matrix[row][0] <= target 的行),再对该行做标准二分。两种做法的效率相同,但两次二分能更清晰地展示二分查找在二维场景下的分解思路。

第一次二分 — 找行: 和 34 的边界搜索类似,需要记录「最后一个满足条件的行」。matrix[mid][0] == target 时直接返回 true;matrix[mid][0] < target 时,mid 这一行是候选,存到 rowans 然后继续往右(下面)找更大的行首;matrix[mid][0] > target 时往左找。

bool searchMatrix(vector<vector<int>>& matrix, int target) {
    int m = matrix.size(), n = matrix[0].size();

    // 第一次二分:找行 — 最后一个 matrix[row][0] <= target 的行
    int left = 0, right = m - 1, row = -1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (matrix[mid][0] == target) return true;
        else if (matrix[mid][0] < target) {
            row = mid;          // 候选行
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    if (row == -1) return false;

    // 第二次二分:在该行内找 target
    int l = 0, r = n - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2;
        if (matrix[row][mid] == target) return true;
        else if (matrix[row][mid] < target) l = mid + 1;
        else r = mid - 1;
    }
    return false;
}

复杂度: 时间 O(log m + log n);空间 O(1)。


875. 爱吃香蕉的珂珂

LeetCode 875 — 爱吃香蕉的珂珂 (Medium)

有 n 堆香蕉,第 i 堆有 piles[i] 根香蕉。珂珂吃香蕉的速度是每小时 k 根。每小时她只选一堆香蕉吃:如果这堆香蕉少于 k 根,她吃完这堆后这一小时不再吃其他堆;如果多于 k 根,她吃完 k 根后这一小时也结束。警卫将在 h 小时后回来,求珂珂能在 h 小时内吃完所有香蕉的最小速度 k。

求解思路

这道题和前面所有题目的根本区别在于:搜索的不是数组中的某个元素,而是一个未知的速度值。如何想到二分?关键有两点:第一,直接求最小速度 k 不好下手,但验证一个给定速度 k 能否在 h 小时内吃完非常容易——遍历每堆香蕉,ceil((double)piles[i] / k) 累加即可,O(n) 完成。第二,速度越快越容易在 h 小时内吃完——这说明问题具有单调性:如果速度 v 可行,那么所有大于 v 的速度也可行;如果速度 v 不可行,那么所有小于 v 的速度也不可行。单调性意味着可以在答案的取值范围内二分搜索。

速度的范围:最慢是 1(每小时至少吃 1 根),最快是 max(piles)(对最⼤那堆也能一小时吃完,再快没有意义)。在这 [1, max(piles)] 上二分找最小可行速度。和峰值、最小值题目一样,在缩小区间找最小值,用 while(left < right) 即可。判定返回 true 时 mid 可行,right = mid 保留候选;返回 false 时 mid 太慢,left = mid + 1 丢弃。

int minEatingSpeed(vector<int>& piles, int h) {
    int maxPile = 0;
    for (int p : piles) maxPile = max(maxPile, p);

    int left = 1, right = maxPile;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (canEat(piles, h, mid)) {
            right = mid;       // mid 可行,尝试更小的
        } else {
            left = mid + 1;    // mid 不可行,必须加快
        }
    }
    return right;
}

bool canEat(vector<int>& piles, int h, int speed) {
    int time = 0;
    for (int p : piles) {
        time += (p + speed - 1) / speed; // 向上取整
    }
    return time <= h;
}

复杂度: 时间 O(n · log M),M = max(piles);空间 O(1)。


1011. 在 D 天内送达包裹的能力

LeetCode 1011 — 在 D 天内送达包裹的能力 (Medium)

传送带上的包裹按给定顺序排列,第 i 个包裹的重量为 weights[i]。每天按包裹顺序往船上装货,当累计重量超过船的运力时,超出的包裹留到下一天。必须在 days 天内运完所有包裹,求船的最低运力。

求解思路

和 875 是同一套思路:直接求最低运力不好下手,但验证一个给定运力能否在 days 天内运完很简单——按顺序装,当天重量超标就留到第二天,最后比较所需天数是否 ≤ days。运力越大所需天数越少,同样是单调性问题。

运力的下界是 max(weights)(至少得运走最重的单个包裹),上界是 sum(weights)(一天全部运走)。在这个范围内二分搜索最小可行运力。

int shipWithinDays(vector<int>& weights, int days) {
    int maxW = 0, sumW = 0;
    for (int w : weights) {
        maxW = max(maxW, w);
        sumW += w;
    }

    int left = maxW, right = sumW;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (canShip(weights, days, mid)) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }
    return right;
}

bool canShip(vector<int>& weights, int days, int capacity) {
    int dayCount = 1, curLoad = 0;
    for (int w : weights) {
        if (curLoad + w > capacity) {
            dayCount++;
            curLoad = 0;
        }
        curLoad += w;
    }
    return dayCount <= days;
}

复杂度: 时间 O(n · log S),S = sum(weights);空间 O(1)。


410. 分割数组的最大值

LeetCode 410 — 分割数组的最大值

给定一个非负整数数组 nums 和一个整数 k,将 nums 分成 k 个非空连续子数组。一种分割方式会产生 k 个子数组各自的和,取其中最大值作为这种分割方式的代价。求所有分割方式中这个代价的最小值。例如 nums = [7,2,5,10,8], k = 2,最佳分割为 [7,2,5] 和 [10,8],代价为 18。

求解思路

这道题一眼看过去不像二分——题目要的是分割数组,感觉像动态规划。但换个角度:我们要找的是子数组和的上限,使得在这个限制下能用不超过 k 段切完整个数组。如果给定的上限越大,所需段数就越少——单调性出现了。验证一个上限 mid 是否可行也很简单:贪心地往每段塞数,超过 mid 就另起一段,最后统计段数是否 ≤ k。

这和 1011 本质是同一道题:1011 是「给定天数限制求最小运力」,410 是「给定分段数限制求最小的最大段和」,判定逻辑完全一样。下界是 max(nums)(任何一段的和至少得大于等于最大元素),上界是 sum(nums)(只分一段)。

int splitArray(vector<int>& nums, int k) {
    int left = 0, right = 0;
    for (int x : nums) {
        left = max(left, x);
        right += x;
    }

    while (left < right) {
        int mid = left + (right - left) / 2;
        if (canSplit(nums, k, mid)) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }
    return right;
}

bool canSplit(vector<int>& nums, int k, int limit) {
    int segments = 1, curSum = 0;
    for (int x : nums) {
        if (curSum + x > limit) {
            segments++;
            curSum = 0;
        }
        curSum += x;
    }
    return segments <= k;
}

复杂度: 时间 O(n · log S),S = sum(nums);空间 O(1)。

875、1011、410 这三道题的代码结构几乎完全一样——区别只在搜索范围和判定函数的具体逻辑。确定答案的单调范围 → 写判定函数 → 在 [left, right] 上二分搜索满足判定条件的最小/最大值。


总结

面对一道二分搜索题目,可以按以下顺序进行思考。

第一步:循环条件 — 能不能在循环里直接判定 mid 是不是答案?

这是选择 <= 还是 < 的根本依据。

能直接判定 → while(left <= right)。典型情况是「找某个值」:nums[mid] == target 命中就返回了(704);或者「找第一个满足某条件的位置」,nums[mid] >= target 时 mid 就是候选,拿 ans 记录下来继续往左搜(34)。总之每一轮都能明确回答「mid 是或不是答案」,答案在循环过程中已经确定,区间搜空就结束。

不能直接判定 → while(left < right)。典型情况是「mid 是否答案需要上下文才能知道」:峰值需要跟左右邻居比较才知道是不是峰(162),最小值需要跟 right 比较才知道在左半还是右半(153),给定速度需要跑一遍验证才知道行不行(875)。这些场景里 mid 只能告诉你该往哪边走,不能直接判对错。只能不断收缩区间,直到只剩一个元素——那就是答案。

第二步:指针移动 — mid 有没有可能是答案?

在 while(left <= right) 中,mid 已经被判定过了,不是答案就丢掉,所以一律 left = mid + 1 / right = mid - 1。

在 while(left < right) 中,每轮只判断了方向,mid 可能仍然是答案:

  • 当答案落在左侧且可能包含 mid 时 → right = mid(保留 mid)
  • 当答案落在右侧且可能包含 mid 时 → left = mid(保留 mid)
  • 当答案落在右侧且 mid 绝对不可能是答案时 → left = mid + 1(丢弃 mid)
  • 当答案落在左侧且 mid 绝对不可能是答案时 → right = mid - 1(丢弃 mid)

什么时候 mid 绝对不可能是答案?比如 162 中 nums[mid] < nums[mid+1],mid 比右边小,在上坡,峰值一定在右侧,mid 不可能是峰值。比如 153 中 nums[mid] > nums[right],mid 在高区,最小值一定在右边,mid 不可能是最小值。

第三步:mid 取整条件 — 出现 left = mid 时向上取整。

绝大多数情况向下取整:mid = left + (right - left) / 2。唯一需要向上取整的场景是代码中出现了 left = mid:此时 mid = left + (right - left + 1) / 2,否则剩两个元素时 left = mid 会让 left 原地不动,死循环。全文只有「找最大值」这类对称推导里用到它。

二分搜索的应用(875、1011、410):

这类题目初看不像二分——题目描述里没有排序数组,也没有搜索目标。识别它的线索是:直接求最优值不好下手,但验证一个候选值是否可行非常容易,且候选值存在单调性。碰到这三条,就可以在候选值范围上二分。代码结构统一:while(left < right) + right = mid / left = mid + 1。


相关题目

  • LeetCode 704 — 二分查找
  • LeetCode 34 — 在排序数组中查找元素的第一个和最后一个位置
  • LCR 172 — 统计目标成绩的出现次数
  • LeetCode 162 — 寻找峰值
  • LeetCode 852 — 山脉数组的峰顶索引
  • LeetCode 658 — 找到 K 个最接近的元素
  • LeetCode 33 — 搜索旋转排序数组
  • LeetCode 81 — 搜索旋转排序数组 II
  • LeetCode 153 — 寻找旋转排序数组中的最小值
  • LeetCode 154 — 寻找旋转排序数组中的最小值 II
  • LeetCode 74 — 搜索二维矩阵
  • LeetCode 875 — 爱吃香蕉的珂珂
  • LeetCode 1011 — 在 D 天内送达包裹的能力
  • LeetCode 410 — 分割数组的最大值
avatar

nineloong

一隅之地,深耕自我

RECOMMENDED

每日一题:检查数组是否经排序和轮转得到

2026/5/23

每日一题:统计特殊字母的数量 II

2026/5/27

每日一题:寻找旋转排序数组中的最小值

2026/5/15

Table of Contents