找到 K 个最接近的元素 图解题解
这道题到底在问什么
- arr
- [1,2,3,4,5]
- k, x
- k=4, x=3
- 输出
- [1,2,3,4]
最优解:为什么这么做
一句话答案:LeetCode 658 找到 K 个最接近的元素:答案必是长度 k 的连续窗口,在左端点范围 [0,n−k] 上二分——比窗口该丢和该纳的两个数谁离 x 远来收缩,时间 O(log(n−k)+k)、空间 O(1)。
升序数组里挑离 x 最近的 k 个数,返回哪一段
给升序(从小到大排好)数组 arr、整数 k 和目标 x,挑出离 x 最近的 k 个数按升序返回;一样近取更小的。题面 arr=[1,2,3,4,5]、k 取 4、x 取 3,答案 [1,2,3,4]:只有 5 离 3 最远,丢掉它。
按距离排个序取前 k 个,慢在哪
给每个数标上到 x 的距离、按距离排序取最小的 k 个再排回升序,也能出答案,但排序要 O(n log n)(大 O 记号,衡量规模对操作量的放大倍数),几十万个数只为挑 k 个全排一遍不值。还有从两头往里夹的双指针:每次把更远的一端往里收一格,缩 n−k 次是 O(n−k),n 大同样拖。
最优那段的左端点,能二分着定位
答案一定是连续的一段(长度 k,即窗口)。数组有序,离 x 最近的 k 个数必然挨成一排,跳着选总能用更近的相邻数换掉一个更远的,只会更优。所以要定的只有一个量:窗口从哪个下标起。窗口不能越界,左端点最大只到 n−k,被锁在 [0, n−k] 里。
窗口从左往右滑,先越滑越贴近 x、再越滑越远,最优点两侧一边偏小一边偏大,呈单调走向。单调就能用二分每轮砍掉一半候选左端点,不必逐个试。
中点这一格,凭什么就能判定往哪半收
在 [lo, hi] 取中点 mid,比的不是 arr[mid] 和 x,而是窗口边界两个数:arr[mid] 是窗口最左(一右移就被丢),arr[mid+k] 是紧挨右边、还没进来的那个——窗口右挪一格时正是这俩一出一进,谁离 x 更远谁被排除。
若 x−arr[mid] > arr[mid+k]−x,该丢的 arr[mid] 比该纳的更远,左端点偏小,lo 跳到 mid+1、窗口右移;否则(含两边一样远)hi 收到 mid。这支写 hi=mid 不是 hi=mid−1,因为 mid 本身可能就是最优左端点。
拿题面 [1,2,3,4,5] 手算一遍
n 为 5、k 为 4,左端点范围 [lo, hi]=[0, 1]。lo 小于 hi,进循环。mid=(0+1)//2=0,看下标 0 起的窗口 {1,2,3,4}:要丢的 arr[0]=1 离 x 差 3−1=2,要纳的 arr[4]=5 离 x 差 5−3=2。判 2 大于 2 不成立,走 else,hi 收到 0。
此时 lo、hi 都是 0,不再满足 lo 小于 hi,循环停,返回 arr[0:4]={1,2,3,4},即题面答案。这轮撞上平局:两侧都差 2,走 else 留在左边、丢掉更大的 5。
hi 起手写成 n−1,窗口一伸就出界
二分把左端点范围每轮砍半、约 log(n−k) 轮定位,再切出 k 个数,合起来 O(log(n−k)+k)、空间 O(1),比排序的 O(n log n) 省一截。
hi 的起点最常写坏:写成 n−1,mid 落到靠后下标时 arr[mid+k] 越过数组末尾读到越界,hi 得从 n−k 起。方向也别抄反:丢左端配 lo=mid+1(写成 lo=mid,比过的 mid 没跨过去会卡着缩不动),丢右端配 hi=mid;判定用 > 而非 ≥,否则平局会偏向更大的数。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3数组有序,离 x 最近的 k 个数一定挨在一起。左端点 lo 的范围只有 [0, n−k](再大窗口就出界),在这上面二分,O(log n) 就能锁定。
- 4arr[mid] 是窗口左端外那个数、arr[mid+k] 是右端外那个数。左端外更远 → 丢左端,lo=mid+1;否则 hi=mid(相等也走这支,保留更小的数)。下面盯住 lo/hi/mid 三根指针怎么动。
- 5lo=0, hi=n−k=8换个大点的例子看二分怎么收缩:arr=[1..11](11 个数),k=3,x=8。lo 指下标 0、hi 指下标 8,答案左端点就夹在这两根指针之间。
- 6mid = (0+8)/2 = 4mid 落在 lo 和 hi 正中间,下标 4。接下来把以 mid 为左端的窗口 [4,6] 画出来比一比。
- 7窗口 = arr[mid .. mid+k−1]紫底就是设想的窗口 [4,6],里头是 5、6、7。现在看窗口两侧外面那两个数离 x=8 谁更远。
- 8左端外 arr[4]=5 差3 ┆ 右端外 arr[7]=8 差0左端外 arr[4]=5 离 8 差 3(标灰=不划算),右端外 arr[7]=8 正好差 0。左端更远,保留它不划算——该丢左端、把窗口右移。
- 9排除下标 0~4,lo → 5lo 跳到 5,下标 0~4 整段变灰出局——左端点不可能再落在那里了。范围一下砍掉一半,缩成 [5,8]。
- 10mid = (5+8)/2 = 6在新范围 [5,8] 里重新取中点,mid 落到下标 6。再画出以它为左端的窗口。
- 11窗口 = arr[6 .. 8]新设想窗口 [6,8],里头是 7、8、9。同样看两侧外面哪个离 x=8 更远。
- 12左端外 arr[6]=7 差1 ┆ 右端外 arr[9]=10 差2左端外 arr[6]=7 差 1,右端外 arr[9]=10 差 2(标灰=不划算)。右端更远,丢右端、窗口左移。
- 13排除下标 7~8,hi → 6hi 收到 6,下标 7、8 变灰出局。注意丢右端用 hi=mid(不是 mid−1),因为 mid 本身可能就是答案左端。范围缩成 [5,6]。
- 14mid = (5+6)/2 = 5范围只剩 [5,6],mid 取到下标 5。最后一轮,先把以它为左端的窗口画出来。
- 15窗口 = arr[5 .. 7]紫底窗口 [5,7],里头是 6、7、8。最后比一次两侧外面谁离 x=8 更远。
- 16左端外 arr[5]=6 差2 ┆ 右端外 arr[8]=9 差1左端外 arr[5]=6 差 2(标灰),右端外 arr[8]=9 只差 1。这回是左端更远,丢左端、lo=mid+1=6。
- 17排除下标 5,lo → 6lo 跳到 6,下标 5 变灰出局。现在 lo 和 hi 都指向 6,下一步它们就要撞上了。
- 18lo == hi,停!左端点 = 6lo 和 hi 撞到一起,二分停下。最优窗口左端点就是 6,所有非答案下标都已变灰,11 个数只用 3 轮比较就定下来了。
- 19两端外侧都比窗口内远停之前再确认一眼:窗口 [6,8] 两侧外面 arr[5]=6 和 arr[9]=10 离 8 都差 2,都不如窗口里的近,再怎么挪都不会更优——这就是二分停在这里的底气。
- 20从 lo=6 起点亮第 1 格锁定左端点后,从下标 6 起一格一格往右取。先点亮第 1 格 arr[6]=7。
- 21点亮第 2 格第 2 格 arr[7]=8 点亮——它正好就是 x 本身,离自己距离 0,当然在答案里。
- 22arr[6..8] = [7,8,9]点亮第 3 格 arr[8]=9,凑满 k=3 个:7、8、9,正好是离 8 最近的三个数,本来就是升序,直接返回。
- 23arr=[1..5], k=4, x=3 → [1,2,3,4]用同一招跑题目原例 arr=[1,2,3,4,5]:hi=n−k=1,mid=0 时两侧差打平,按规则走 else 保留更小的数,lo=0,取 arr[0..3] = [1,2,3,4],和答案一致。
- 27x−arr[mid]==arr[mid+k]−x → 走 else原例里 mid=0 时两侧差都是 2(打平)。规则要求平局取更小的数,所以走 else (hi=mid),保留靠左的窗口 [1,2,3,4]。若误用 ≥ 让 lo 右移,就会丢掉更小的 1,答案就错了。
- 29凡是「在有序数组里挑一段固定长度、让它最优」的题都可往这个模板靠。另有双指针从两头往里夹的写法(每次缩离 x 更远的端点),思路直观但要缩 n−k 次,是 O(n−k),n 大时不如二分。
⚠️ 容易写错的地方
✗ 错:hi 初值写成 n−1
✓ 对:hi = n − k
左端点最大只能到 n−k,否则窗口越界
✗ 错:判定写成 ≥ 而非 >
✓ 对:用 x−arr[mid] > arr[mid+k]−x
相等时要保留更小的数 → 归到 hi=mid 一侧,写 ≥ 会偏向更大的数
完整代码(Python / C++ / Java)
Python
class Solution:
def findClosestElements(self, arr, k, x):
lo, hi = 0, len(arr) - k # 左端点范围 [0, n-k]
while lo < hi:
mid = (lo + hi) // 2
# 左端外更远 → 丢左端,窗口右移
if x - arr[mid] > arr[mid + k] - x:
lo = mid + 1
else: # 否则窗口左移
hi = mid
return arr[lo:lo + k]C++
class Solution {
public:
vector<int> findClosestElements(vector<int>& arr, int k, int x) {
int lo = 0, hi = (int)arr.size() - k; // [0, n-k]
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (x - arr[mid] > arr[mid + k] - x)
lo = mid + 1; // 右移
else
hi = mid; // 左移
}
return vector<int>(arr.begin() + lo, arr.begin() + lo + k);
}
};Java
class Solution {
public List<Integer> findClosestElements(int[] arr, int k, int x) {
int lo = 0, hi = arr.length - k; // [0, n-k]
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (x - arr[mid] > arr[mid + k] - x)
lo = mid + 1; // 右移
else
hi = mid; // 左移
}
List<Integer> res = new ArrayList<>();
for (int i = lo; i < lo + k; i++) res.add(arr[i]);
return res;
}
}复杂度
时间复杂度
O(log(n−k) + k)
二分定左端点 O(log(n-k)),再切出 k 个元素 O(k)
空间复杂度
O(1)
只用 lo/hi/mid 几个指针;不计返回结果本身
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 找到 K 个最接近的元素 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
答案为什么一定是挨在一起的一段,不能跳着挑更近的几个?+
因为数组升序,离 x 最近的数在数轴上本就聚成一堆。假设答案里有个空档——跳过了夹在选中范围之间的某个数 y,去选了更远的 z。那 y 一定比 z 离 x 更近(它就夹在中间),用 y 换掉 z 只会让整体更近或持平。反复这样替换,最后必然收成连续的一段,跳着挑绝不会更好。
都说这题二分,到底二分的是哪个量?+
不是数组里的某个元素值,也不是 x,而是答案窗口的左端点下标,取值范围 [0, n−k],最小从 0 起、最大到 n−k(再大窗口尾巴出界)。二分靠的是窗口沿数组滑动时离 x 的远近呈单调走向:在中点摆一个候选左端点,比一下窗口边界该丢和该纳的两个数谁远,就知道最优起点在中点左边还是右边,据此砍掉一半。跟在有序数组里找一个值不同,这里找的是一个位置。
两侧一样近时,为什么偏要往左收、写成 hi=mid?+
题目规定一样近取更小的数,而更小的数在左边,平局时把窗口留在偏左的位置,丢掉的就是右边那个更大的数,正合规则。代码上这体现为判定用严格大于:只有 x−arr[mid] > arr[mid+k]−x(左边严格更远)才右移 lo=mid+1,相等归到 else 这支 hi=mid。要是写成 ≥,相等时就右移、偏向更大的数,题面 [1,2,3,4,5] 找 x 取 3、k 取 4 就会返回 [2,3,4,5]、丢掉更小的 1。另外这支必须 hi=mid 不能 hi=mid−1,因为 mid 本身可能正是最优左端点。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 找到 K 个最接近的元素 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。