统计和小于目标的下标对数目 图解题解
这道题到底在问什么
- 输入
- nums=[-1,1,2,3,1], target=2
- 输出
- 3
- 输入
- nums=[-6,2,5,-2,-7,-1,3], target=-2
- 输出
- 10
先想最直接的笨办法
把三次整批计数加起来,5 对、4 对、再加 1 对,一共 10 对,这就是答案。回头看这套双指针:靠排序把数组理顺,再让两端夹逼,小了整批收、大了缩右边,时间只花在排序和这一趟扫描上,比两两枚举快得多。(动画第 23 步)
最优解:为什么这么做
一句话答案:LeetCode 2824 统计和小于目标的下标对:排序后固定右端 j,二分数左边有几个 nums[i] 小于 target 减 nums[j],逐个右端累加,时间 O(n log n)、空间 O(1)。
数出多少对下标,和严格小于 target
给一个下标从 0 开始的数组 nums 和整数 target,数出多少对下标 (i, j) 满足 i 在 j 前面、且 nums[i] 加 nums[j] 严格小于 target。是严格小于,取等不算。题面 nums=[-1,1,2,3,1]、target=2 时答案 3。
两重循环把每对都试一遍,代价在哪
双重循环能数:外层挑 i、内层挑 j,逐对把两数之和和 target 比,严格小于就计数。n 个数约 n 方 ÷ 2 对,要比这么多次,时间 O(n²)。这题 n 不超过 50 硬枚举也能过,但一对对试没占便宜,排好序就能把「逐对试」改成「一整批一次数完」。
排序打乱了原下标,为什么不影响对数
题目只问「有多少对」,不追问是原数组哪两个位置——(i, j) 和 (j, i) 是同一对,跟原位置无关。把 nums 排好序,值不变,能凑成的对一个不多不少,答案完全一样。排序换来有序:任取一个数当右端,它左边的数都不比它大,能配成小于 target 的对的,都在左段前一截。
固定右端 nums[j],左边有几个能配上
从左到右让每个 nums[j] 当右端。要和它配对的左伙伴 nums[i](i 在 j 左边)得满足 nums[i] 小于 target 减 nums[j]。左段 nums[0..j-1] 本身升序,比这阈值小的数都挤在最前一截,数有几个不必逐个看,二分(在有序段里对半缩着定位)找出这截右边界即可。
代码写成 bisect_left(nums, target − nums[j], hi=j):在 0 到 j(不含 j,即 hi=j 的作用)里找第一个不小于阈值的位置,这下标值恰好是它左边严格小于阈值的个数,即能配 nums[j] 的左伙伴数。累加各右端即得答案。
排完序的 [-1,1,1,2,3],各右端配到几个
先排序,[-1,1,2,3,1] 变成 [-1,1,1,2,3],逐个取右端,阈值是 target 减右端值。-1 当右端左边空着,配 0;第一个 1 当右端阈值 1,左边只有 -1 小于 1,配 1;第二个 1 阈值仍 1,也只有 -1 合格,配 1;2 阈值 0,仍只有 -1 小于 0,配 1;3 阈值 -1,左边没有更小的,配 0。
各右端配到的数加起来 0+1+1+1+0,正是题面给的 3。三对左伙伴都是 -1,加 1、1、2 得 0、0、1,全小于 2。
严格小于这道坎,bisect 该用左不用右
阈值那步最容易走偏。要的是 nums[i] 严格小于 target − nums[j],对应 bisect_left(第一个不小于阈值的位置,返回值正是严格小于阈值的个数);换成 bisect_right,等于阈值的数也算进,即和恰好等于 target 的对,取等本该排除。hi 也要钉在 j 上:hi=j+1 会把 nums[j] 自己算进左段、多配一个;漏写 hi 则整段都搜、越界到 j 右边。
复杂度上,排序 O(n log n) 是主要开销,n 个右端各做一次 O(log n) 二分,合起来仍是 O(n log n)。空间除下标计数器外 O(1)。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3记牢这一句:排序之后两端夹逼。两端和小于 target,就把左端配右边一整串一次数进来、左指针前进;两端和不小于 target,就让右指针后退。下面把数组排好序,一步步夹过去。
- 4第一步:排序这是原始的 nums,顺序是乱的。双指针夹逼有个前提:数组必须有序,这样两端才分别是最小和最大,才能靠一次比较就判断出一整批。所以第一件事永远是排序。你先记住这 7 个数,下一帧它们就会排好队。
- 5有序:可以夹逼了排好序后是 -7、-6、-2、-1、2、3、5,从左到右越来越大。现在最左边是最小的 -7,最右边是最大的 5。有了这个顺序,接下来两端一比就能判断一大片,而不用每两个数都单独相加去数。
- 6窗口 [0, 6]把左指针 l 放在下标 0,右指针 r 放在下标 6,底色框住的就是当前还要考察的窗口。计数器 ans 从 0 起步。核心的判断只看两端这两个数的和:小了就整批收,大了就缩右边。开始夹。
- 7和偏大:缩右边当前窗口两端是 nums[0]=-7 和 nums[6]=5,它们的和是 -2。拿它和 target=-2 比:这个和不小于 target。既然最左的 nums[0] 配上最大的 nums[6] 都到不了严格小于,别的左端更大更不行,这个右端可以整个排除。
- 8排除,r 左移两端和到不了严格小于,右端 nums[6]=5 是当前最大的数,它配上最小的左端都超标,配别的更超标,所以它不可能出现在任何合格的数对里,把它灰掉排除。右指针后退一格到 5,窗口缩小,继续看新的两端。
- 9和偏小:可整批数当前窗口两端是 nums[0]=-7 和 nums[5]=3,它们的和是 -4。拿它和 target=-2 比:这个和严格小于 target。有序数组里 l 和它右边每个数搭配,和都不超过这个两端和,所以也全都小于 target。
- 10ans = 5既然两端和都小于 target,那 nums[0] 和绿色这一串,从下标 1 一直到 5,每一个搭配都成立,一共 5 对,直接加进 ans,现在 ans 等于 5。下标 0 这个左端已经把它能配的都数完了,该让 l 前进一步了。
- 11一次锁定 5 对不放心可以逐对核一下:nums[0]=-7 分别加上绿色这些数,得到的和是 -13、-9、-8、-5、-4,每一个都严格小于 -2。这正是排序的好处,一次比较就锁定一整批,不用一对一对去算。
- 12和偏小:可整批数当前窗口两端是 nums[1]=-6 和 nums[5]=3,它们的和是 -3。拿它和 target=-2 比:这个和严格小于 target。有序数组里 l 和它右边每个数搭配,和都不超过这个两端和,所以也全都小于 target。
- 13ans = 9既然两端和都小于 target,那 nums[1] 和绿色这一串,从下标 2 一直到 5,每一个搭配都成立,一共 4 对,直接加进 ans,现在 ans 等于 9。下标 1 这个左端已经把它能配的都数完了,该让 l 前进一步了。
- 14一次锁定 4 对不放心可以逐对核一下:nums[1]=-6 分别加上绿色这些数,得到的和是 -8、-7、-4、-3,每一个都严格小于 -2。这正是排序的好处,一次比较就锁定一整批,不用一对一对去算。
- 15和偏大:缩右边当前窗口两端是 nums[2]=-2 和 nums[5]=3,它们的和是 1。拿它和 target=-2 比:这个和不小于 target。既然最左的 nums[2] 配上最大的 nums[5] 都到不了严格小于,别的左端更大更不行,这个右端可以整个排除。
- 16排除,r 左移两端和到不了严格小于,右端 nums[5]=3 是当前最大的数,它配上最小的左端都超标,配别的更超标,所以它不可能出现在任何合格的数对里,把它灰掉排除。右指针后退一格到 4,窗口缩小,继续看新的两端。
- 17和偏大:缩右边当前窗口两端是 nums[2]=-2 和 nums[4]=2,它们的和是 0。拿它和 target=-2 比:这个和不小于 target。既然最左的 nums[2] 配上最大的 nums[4] 都到不了严格小于,别的左端更大更不行,这个右端可以整个排除。
- 18排除,r 左移两端和到不了严格小于,右端 nums[4]=2 是当前最大的数,它配上最小的左端都超标,配别的更超标,所以它不可能出现在任何合格的数对里,把它灰掉排除。右指针后退一格到 3,窗口缩小,继续看新的两端。
- 19和偏小:可整批数当前窗口两端是 nums[2]=-2 和 nums[3]=-1,它们的和是 -3。拿它和 target=-2 比:这个和严格小于 target。有序数组里 l 和它右边每个数搭配,和都不超过这个两端和,所以也全都小于 target。
- 20ans = 10既然两端和都小于 target,那 nums[2] 和绿色这一串,从下标 3 一直到 3,每一个搭配都成立,一共 1 对,直接加进 ans,现在 ans 等于 10。下标 2 这个左端已经把它能配的都数完了,该让 l 前进一步了。
- 21一次锁定 1 对不放心可以逐对核一下:nums[2]=-2 分别加上绿色这些数,得到的和是 -3,每一个都严格小于 -2。这正是排序的好处,一次比较就锁定一整批,不用一对一对去算。
- 22停止 · ans = 10现在 l 和 r 撞到一起了,窗口里凑不出两个不同的数,再没有新的数对可数,循环结束。整个过程里每一步要么整批计数、l 前进,要么排除右端、r 后退,两个指针一共只走了一趟,又快又不重不漏。
- 23答案 = 10把三次整批计数加起来,5 对、4 对、再加 1 对,一共 10 对,这就是答案。回头看这套双指针:靠排序把数组理顺,再让两端夹逼,小了整批收、大了缩右边,时间只花在排序和这一趟扫描上,比两两枚举快得多。
⚠️ 容易写错的地方
✗ 错:不排序直接双指针
✓ 对:必须先排序再夹逼
双指针靠有序才能由两端和一次判定一整批,乱序时两端不是最小最大,整批计数的推理就不成立
✗ 错:把严格小于写成小于等于
✓ 对:和正好等于 target 的对不计入
题目要求严格小于,取等的那些下标对不算,条件写成小于等于会把和等于 target 的对错误计入,排除条件必须是 sum ≥ target
✗ 错:计数写成 r 减 l 加 1
✓ 对:一次记入的是 r 减 l 对
合格的是下标 l 与 l+1 到 r 这 r-l 个伙伴的搭配,不含 l 自己配自己,多加一会重复
✗ 错:两端和小了却去缩右边
✓ 对:和小于 target 时整批计数并让 l 右移
和已经够小,要保留右端去数更多对,该前进的是左指针;缩右边会漏掉大量合格对
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def countPairs(self, nums: List[int], target: int) -> int:
nums.sort()
ans = 0
for j, x in enumerate(nums):
i = bisect_left(nums, target - x, hi=j)
ans += i
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int countPairs(vector<int>& nums, int target) {
sort(nums.begin(), nums.end());
int ans = 0;
for (int j = 0; j < nums.size(); ++j) {
int i = lower_bound(nums.begin(), nums.begin() + j, target - nums[j]) - nums.begin();
ans += i;
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int countPairs(List<Integer> nums, int target) {
Collections.sort(nums);
int ans = 0;
for (int j = 0; j < nums.size(); ++j) {
int x = nums.get(j);
int i = search(nums, target - x, j);
ans += i;
}
return ans;
}
private int search(List<Integer> nums, int x, int r) {
int l = 0;
while (l < r) {
int mid = (l + r) >> 1;
if (nums.get(mid) >= x) {
r = mid;
} else {
l = mid + 1;
}
}
return l;
}
}复杂度
时间
O(n log n)
排序占 O(n log n),是主要开销。之后双指针从两端往里夹,l 只增、r 只减,两个指针合起来最多走 n 步,这一趟是 O(n)。若用排序加二分的写法,后半段是 n 次二分共 O(n log n),两种写法总量都由排序主导,同为 O(n log n)
空间
O(1) 到 O(n)
按峰值算。除了几个指针和计数器,双指针本身只用 O(1) 辅助空间;排序的内部开销分语言不同,不计排序则 O(1),要计则 C 加加 和 Java 递归栈约 O(log n)、Python 的 Timsort 最坏 O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 统计和小于目标的下标对数目 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
排序把 nums 的顺序全打乱了,数出来的对数还对得上原数组吗?+
对得上。题目问的是「有多少对下标满足 nums[i]+nums[j]
bisect_left 和 bisect_right 在这里为什么必须选左边那个?+
因为条件是严格小于。我们要数的是左段里 nums[i] 严格小于 target − nums[j] 的个数。bisect_left 返回第一个『不小于』阈值的位置,它前面的全是严格小于阈值的数,位置值正好等于这个个数。换成 bisect_right,返回的是第一个『大于』阈值的位置,会把恰好等于阈值的数也划进去——那对应 nums[i]+nums[j] 恰好等于 target,题目要求严格小于、取等不算,这样就多数了。差一个字,答案就偏大。
除了排序加二分,这题还有别的解法吗?+
有,排序后用双指针更省。左指针 l 放最左、右指针 r 放最右,看两端和:小于 target 时,因为数组有序,l 配 l+1 到 r 这一整串的和都更小,一次就能记入 r−l 对,然后 l 右移;不小于时,最大的右端连最小左端都超标,r 左移排除它。两个指针合起来只走一趟 O(n),加排序总复杂度同为 O(n log n)。数据规模小时(本题 n≤50)直接两重循环暴力枚举 O(n²) 也能过,但二分和双指针更能说清思路。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 统计和小于目标的下标对数目 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。