题目描述
思路解析
一句话答案: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;判定用 > 而非 ≥,否则平局会偏向更大的数。
▶ 动画逐步走查(文字版)——想跟着上方动画一帧帧对照就展开
数组有序,离 x 最近的 k 个数一定挨在一起。左端点 lo 的范围只有 [0, n−k](再大窗口就出界),在这上面二分,O(log n) 就能锁定。
arr[mid] 是窗口左端外那个数、arr[mid+k] 是右端外那个数。左端外更远 → 丢左端,lo=mid+1;否则 hi=mid(相等也走这支,保留更小的数)。下面盯住 lo/hi/mid 三根指针怎么动。
开局 · 二分范围:换个大点的例子看二分怎么收缩:arr=[1..11](11 个数),k=3,x=8。lo 指下标 0、hi 指下标 8,答案左端点就夹在这两根指针之间。
第1轮 · mid 落点:mid 落在 lo 和 hi 正中间,下标 4。接下来把以 mid 为左端的窗口 [4,6] 画出来比一比。
第1轮 · 设想窗口 [4,6]:紫底就是设想的窗口 [4,6],里头是 5、6、7。现在看窗口两侧外面那两个数离 x=8 谁更远。
第1轮 · 比两端外侧:左端外 arr[4]=5 离 8 差 3(标灰=不划算),右端外 arr[7]=8 正好差 0。左端更远,保留它不划算——该丢左端、把窗口右移。
第1轮 · 丢左端 lo=mid+1:lo 跳到 5,下标 0~4 整段变灰出局——左端点不可能再落在那里了。范围一下砍掉一半,缩成 [5,8]。
第2轮 · mid 落点:在新范围 [5,8] 里重新取中点,mid 落到下标 6。再画出以它为左端的窗口。
第2轮 · 设想窗口 [6,8]:新设想窗口 [6,8],里头是 7、8、9。同样看两侧外面哪个离 x=8 更远。
第2轮 · 比两端外侧:左端外 arr[6]=7 差 1,右端外 arr[9]=10 差 2(标灰=不划算)。右端更远,丢右端、窗口左移。
第2轮 · 丢右端 hi=mid:hi 收到 6,下标 7、8 变灰出局。注意丢右端用 hi=mid(不是 mid−1),因为 mid 本身可能就是答案左端。范围缩成 [5,6]。
第3轮 · mid 落点:范围只剩 [5,6],mid 取到下标 5。最后一轮,先把以它为左端的窗口画出来。
第3轮 · 设想窗口 [5,7]:紫底窗口 [5,7],里头是 6、7、8。最后比一次两侧外面谁离 x=8 更远。
第3轮 · 比两端外侧:左端外 arr[5]=6 差 2(标灰),右端外 arr[8]=9 只差 1。这回是左端更远,丢左端、lo=mid+1=6。
第3轮 · 丢左端 lo=mid+1:lo 跳到 6,下标 5 变灰出局。现在 lo 和 hi 都指向 6,下一步它们就要撞上了。
二分结束 · lo=hi=6:lo 和 hi 撞到一起,二分停下。最优窗口左端点就是 6,所有非答案下标都已变灰,11 个数只用 3 轮比较就定下来了。
停前校验 · 窗口最优:停之前再确认一眼:窗口 [6,8] 两侧外面 arr[5]=6 和 arr[9]=10 离 8 都差 2,都不如窗口里的近,再怎么挪都不会更优——这就是二分停在这里的底气。
逐格取答案 · 第1格:锁定左端点后,从下标 6 起一格一格往右取。先点亮第 1 格 arr[6]=7。
逐格取答案 · 第2格:第 2 格 arr[7]=8 点亮——它正好就是 x 本身,离自己距离 0,当然在答案里。
取出答案窗口:点亮第 3 格 arr[8]=9,凑满 k=3 个:7、8、9,正好是离 8 最近的三个数,本来就是升序,直接返回。
回到原例验证:用同一招跑题目原例 arr=[1,2,3,4,5]:hi=n−k=1,mid=0 时两侧差打平,按规则走 else 保留更小的数,lo=0,取 arr[0..3] = [1,2,3,4],和答案一致。
雷区实演 · 相等时往哪挪:原例里 mid=0 时两侧差都是 2(打平)。规则要求平局取更小的数,所以走 else (hi=mid),保留靠左的窗口 [1,2,3,4]。若误用 ≥ 让 lo 右移,就会丢掉更小的 1,答案就错了。
边界三连:三种极端:全选、x 在最左外、x 在最右外。范围 [0,n−k] 和判定式天然把它们都兜住了。
凡是「在有序数组里挑一段固定长度、让它最优」的题都可往这个模板靠。另有双指针从两头往里夹的写法(每次缩离 x 更远的端点),思路直观但要缩 n−k 次,是 O(n−k),n 大时不如二分。
面试追问:把「答案为何连续」和「二分对象是左端点」讲透,是这题的面试加分项。
参考代码
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]复杂度
- 时间复杂度:O(log(n−k) + k),二分定左端点 O(log(n-k)),再切出 k 个元素 O(k)
- 空间复杂度:O(1),只用 lo/hi/mid 几个指针;不计返回结果本身
易错点
面试追问把动画讲成自己的话
追问为什么答案一定是连续的?
追问二分的对象是什么?
追问复杂度?
这道题到这就讲完了。动画和文字是同一套思路——别光看,关掉页面自己默写一遍。然后顺着主线继续:
适龄的朋友
LeetCode 825 · 中等 · 沿着 二分套路 继续往下推进
把这道题真正学会,再走
图解算法年卡 ¥99 /年
- ✓本题每步动画的吴师兄语音讲解(全站陆续覆盖)
- ✓小欧带 8 步通关训练——追问到不看答案也能写对、能 30 秒讲给面试官听
- ✓学习报告,记录每道题的掌握程度
76k+ GitHub Star · 吴师兄开源图解算法,几十万开发者在看的算法讲解
想成体系刷透这类套路?去图解算法专题