ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

排序+滑动窗口:LeetCode 1984 最小差值问题全解析

排序+滑动窗口:LeetCode 1984 最小差值问题全解析 1. 题目拆解从“最小差值”联想到的算法本质LeetCode 1984. 学生分数的最小差值这道题我第一次看到的时候第一反应是“这不就是个排序加滑动窗口吗”——确实是这样但如果只是停留在“排序双指针”这个层面那你可能只学会了这道题却错过了它背后真正值得掌握的算法思维。先看题目本身给定一个整数数组nums和一个整数k你需要从数组中选择k个学生的分数使得这k个分数中最大值和最小值的差值最小返回这个最小差值。说白了就是从一堆数字里挑出k个数让这k个数“最集中”——最高分和最低分之间的差距尽可能小。题目示例也很直白nums [9, 4, 1, 7]k 2那选[4, 7]差值 3选[7, 9]差值 2选别的组合更差所以答案是 2。这道题的难度标的是“简单”但实际上它考察的点并不简单——它要求你对“子数组连续性的代价”有直觉。很多人第一眼会想用组合数去暴力枚举C(n, k)种选法n 一大直接爆炸。但当你意识到“排序之后最优解一定是某个长度为 k 的连续子数组”时这就从一个组合优化问题退化成了一个线性扫描问题。为什么最优解一定是连续子数组我给你一个直觉证明假设你选了 k 个数它们的最大值是 max最小值是 min。如果这 k 个数在排序后的数组里不是连续的那中间一定跳过了某些数。跳过的那部分数它们的值介于 min 和 max 之间。如果把跳过的数换进来max 不变、min 不变或更接近差值只会变得更小或至少不会变大。所以最优解必然出现在排序后的连续区间里。这个结论是整个题目的灵魂。有了它题目就变成了排序后枚举所有长度为 k 的滑动窗口计算窗口首尾元素差值取最小。2. 三种写法与复杂度对比我先把三种能过的写法都给你摆出来从最直观到最优顺便聊聊每种写法里的细节坑。2.1 暴力枚举不推荐但帮你理解为什么不行from itertools import combinations def minimumDifference(nums, k): n len(nums) ans float(inf) for comb in combinations(nums, k): ans min(ans, max(comb) - min(comb)) return ans这种写法在n很小比如不超过 20的时候确实能跑但n一旦上到 1000、k到 500C(1000, 500)是个天文数字这辈子都算不完。你写combinations的时候确实爽但你得清楚它背后的组合爆炸代价。2.2 排序 暴力窗口推荐用于面试手写def minimumDifference(nums, k): nums.sort() ans float(inf) for i in range(len(nums) - k 1): ans min(ans, nums[i k - 1] - nums[i]) return ans这是最常见的解法。先排序然后遍历每个起点i窗口终点是i k - 1直接算首尾差。时间复杂度O(n log n)空间复杂度O(1)不计排序栈空间的话。这里有个细节值得多说一句很多人会一不小心把窗口终点写成i k或者i k 1导致越界或者窗口长度不对。你要记住如果窗口左端是i长度为k那右端就是i k - 1循环范围是[0, n-k]。我见过太多次这个边界错一位的情况了尤其是从 0 开始数的时候脑子里要清楚“长度 k 的窗口索引跨度是 k-1”。2.3 优化写法先处理 k 1 的边界def minimumDifference(nums, k): if k 1: return 0 nums.sort() ans float(inf) for i in range(len(nums) - k 1): diff nums[i k - 1] - nums[i] if diff ans: ans diff return ans当k 1时你只选一个学生最大值等于最小值差值必然是 0直接返回省得排序都省了。这个优化看起来微不足道但在面试场景里是一个很加分的细节——它表明你想过边界条件而不是拿到题就闷头写代码。3. 为什么排序后找连续窗口是“免费的午餐”有读者可能会疑惑排序不是改变了原始顺序吗题目里选的 k 个学生又不是必须连续站在一起凭什么排序之后就只考虑连续窗口了这里的关键在于题目只关心“数值”而不关心“位置”。学生 A 在数组第 3 个位置学生 B 在第 7 个位置这不影响你选它们俩。排序是把数值重新排列但“选哪几个数”这件事只和数值本身有关和它们在原数组中的物理位置无关。换句话说我们把“从数组中挑几个元素”这个问题转换成了“从数值轴上找一段最密集的区间”。这种转换在算法里非常常见比如做哈希统计的时候把“找出现次数最多的元素”转成“找频率最高的桶”做区间合并的时候把“合并重叠区间”转成“按起点排序后线性扫描”。排序是对付这类问题的万能扫描工具代价是对数级的时间复杂度但换来的是把组合爆炸降成线性问题。我举个具体的例子感受一下。假设nums [3, 10, 5, 8, 1, 12]k 3。排序后是[1, 3, 5, 8, 10, 12]。我们枚举长度为 3 的窗口窗口[1,3,5]差值5 - 1 4窗口[3,5,8]差值8 - 3 5窗口[5,8,10]差值10 - 5 5窗口[8,10,12]差值12 - 8 4最小差值是 4。你能找到一组不连续的 3 个数让差值更小吗比如[3,5,8]差值是 5[1,3,5]差值是 4[3,5,10]差值是 7确实没有一个不连续的组合能比连续窗口更优。这就是那个“排序 连续窗口”结论在起作用。4. 扩展思考同类型题目的横向对比做算法题最忌讳的就是“刷一道忘一道”。1984 这道题虽然简单但它和很多经典题目共享同一个骨架——排序 定长窗口。我把它们列在一起对比一下你会发现规律极其相似。4.1 与“子数组最大平均数”对比LeetCode 643 题“子数组最大平均数 I”给定数组和一个长度 k要求长度为 k 的连续子数组的最大平均值。解法也是排序吗不是643 不需要排序因为题目要求子数组必须连续原数组顺序不能动所以直接滑窗累加就行。而 1984 允许任意挑选所以才需要排序把“任意挑选”转化成“连续窗口”。对比总结题目是否允许打乱顺序转化方式核心操作1984 学生分数最小差值允许排序 连续窗口枚举长度 k 窗口取最小差643 子数组最大平均数不允许连续子数组无需排序滑窗累加求最大209 长度最小的子数组不允许无需排序双指针动态窗口窗口不定长219 存在重复元素 II允许值但要求索引差哈希记录索引哈希表维护窗口看到没有同样是“窗口”有的要排序有的不能排有的窗口长度固定有的窗口长度可变。1984 恰好是“允许重排 定长窗口”这个组合所以在排序后滑动即可。4.2 与二分答案题目的关系如果再往深挖一层1984 这种“最小化最大值差”的求法还可以用“二分答案”的思路来做对差值 d 做二分检查是否存在长度为 k 的窗口使得窗口内最大最小差不超过 d。这个思路在更难的题目里比如 LeetCode 2616 最小化数对的最大差值会用到。不过对于 1984 来说直接排序后滑动窗口就够了二分反而画蛇添足。但如果你在面试时主动提一句“这题也可以用二分答案做”面试官会觉得你知识面比一般候选人广哪怕你最后不写二分版本也是加分项。4.3 如果数组非常大怎么办假设数据量不是题目里给的n 1000而是n到了一亿呢排序的O(n log n)可能就扛不住了。这时候可以考虑用“选择算法”或者“堆”的方式来只维护一个大小为 k 的窗口。比如维护一个长度为 k 的堆堆顶是最小值但我们同时还需要最大值所以要用两个堆一个最大堆、一个最小堆或者用平衡树。不过话又说回来工程上真遇到一亿的数据我们往往会先做降采样或者分布式排序而不是死磕单机内存排序。算法题里的最优解和工程上的最优解不一定一样这一点我觉得值得在刷题时多想一步——面试官问的是“理论上怎么最优”实际落地时往往还有 IO、内存、缓存命中率的考量。5. 完整实操过程从读题到 AC 的走查接下来我带你完整走一遍这道题的实操流程包括我在编辑器里实际写的代码、测试的过程和当时踩到的小坑。5.1 第一步确认输入输出输入nums数组长度 1 到 1000整数k1 到nums.length输出一个整数表示最小差值注意一个边界k可能等于nums.length。比如nums [1, 2, 3]k 3那就只能选全部三个数答案是3 - 1 2。我的循环范围range(len(nums) - k 1)在这种情况下只有i 0一个窗口正好覆盖。5.2 第二步写测试用例我写算法题的习惯是先写几个自测用例确保代码逻辑符合预期再提交。这道题的典型用例nums [9, 4, 1, 7], k 2 - 2 nums [90], k 1 - 0 nums [1, 2, 3, 4, 5], k 3 - 2 选 [1,2,3] 或 [2,3,4] 或 [3,4,5]差值都是 2 nums [5, 1, 4, 2, 3], k 2 - 1 排序后相邻差最小是 1最后一个用例想说明一点原始数组乱序并不影响结果排序后相邻最小差就是全局最优的窗口。5.3 第三步跑代码并检查我最初的版本是class Solution: def minimumDifference(self, nums: List[int], k: int) - int: nums.sort() ans nums[-1] - nums[0] # 初始化为最大可能差值 for i in range(len(nums) - k 1): ans min(ans, nums[i k - 1] - nums[i]) return ans这里把ans初始化成nums[-1] - nums[0]也就是整个数组的极差。这么做是可以的因为任何长度为 k 的窗口差值都不可能超过全局极差。但更稳妥的写法是初始化成float(inf)这样即使输入为空虽然题目保证不为空也不会出错。实际提交后我发现一个性能的小细节在 Python 里nums[i k - 1] - nums[i]每次执行都会做两次列表索引操作。如果n特别大这个开销会被放大。虽然 1984 的数据量不至于让你感受到差异但如果是大数据场景可以先把nums转成局部变量arr nums或者直接用nums[ik-1]的引用缓存减少属性查找。5.4 第四步思考是否有更优解做完标准解法之后我会习惯性追问自己这题还能更快吗答案是在“排序”这一步上基本没有办法再优化——任何选择 k 个数并求极差的通用方法最坏情况下都需要知道数组的相对顺序排序已经接近信息论下界了。但如果你知道数据范围很小比如分数 0 到 100可以考虑桶排序。分数只有 0 到 100那桶排是 O(n max_score)比比较排序 O(n log n) 更快。可惜题目给的数据范围是一个通用的整数数组没有分数的上下限否则桶排可以成为一种“脏但快”的实战解法。这里我想分享一个真实的体会很多“简单题”最优解其实藏在一个隐含假设里——数据是否是整数、是否有界、元素是否允许重复。1984 里元素是整数且可能有重复而 k 个元素可以重复选吗不行因为数组里每个位置的元素只能用一次但如果数组本身有重复值那选重复值也没关系因为它们有不同的索引。所以严格来说这道题是“按索引选 k 个不重复位置”但数值可以相同。排序处理重复值的时候相邻关系依然成立不会出问题。6. 常见问题与排查技巧实录我在刷题群和面试辅导里见过不少同学在这道题上踩同样的坑。这里统一整理成一份速查表希望对你有用。现象原因解决方案越界异常 IndexError循环写到range(len(nums))在最后几个 i 上访问ik-1越界循环范围必须是len(nums) - k 1结果恒为 0但显然是错的把k和len(nums)搞混或者窗口长度计算错误用示例数据手推一遍窗口索引k 1 时输出异常细节边界没处理可能数组为空或初始化有误单独处理 k 1 直接返回 0排序后结果依赖于原始输入顺序想错了如果不排序直接滑窗结果必然依赖原顺序先在前面加nums.sort()用了combinations导致超时组合枚举复杂度爆炸改用排序 固定长度窗口扫描除了这些代码层面的坑还有两个“思维层面”的坑值得单独聊聊。第一个思维坑是“以为 k 个元素必须是从小到大连续的数值”。其实不是排序后的连续窗口只是索引上的连续不要求数值上的步长为 1。比如[1, 2, 100, 101]k 2最优选是[1, 2]和[100, 101]都是差值 1而[2, 100]虽然中间隔了很远但它也是排序后索引连续的窗口2 和 100 在排序后的位置可能相邻。比如原数组[2, 100]排序后还是[2, 100]它们索引连续但数值差 98。所以“连续窗口”指的是排序后数组下标连续而不是数值上相邻连续。第二个思维坑是“把复杂度想成 O(nk)”。这是我最常看到错误解法。有同学会写两层循环外层遍历所有起点内层遍历窗口内所有元素求最大最小复杂度 O(nk)也就是 O(n^2)。排序后的窗口最大最小只取决于首尾两个元素所以根本不需要内层再扫一遍直接 O(1) 取首尾即可。这是排序带来的最大红利——它把“选 k 个数求极差”简化成了“看两个端点”。7. 一道简单题的延伸价值最后聊聊这道题在面试和实际工程里的地位。在算法面试中1984 不是一道难题但它很适合用来考察候选人的几个基本功是否具备“排序后简化问题”的直觉能否正确处理索引边界是否能主动讨论边界条件k 1、k n是否知道时间复杂度的推导过程很多候选人写得出代码但问一句“为什么最优解一定在连续窗口”就卡住了。这一问其实很关键——它把“背模板”和“真理解”区分开了。在实际工程中这种“选 k 个最接近的值”的场景也比比皆是。比如推荐系统里要给用户推荐 k 个价格最接近其预算的商品监控系统里要找出 k 个响应时间最接近的节点甚至做统计学分析时要找到 k 个最集中的样本。这些需求背后都是同一套逻辑排序然后滑窗找最小极差。我个人在实际操作中的体会是刷题到一个阶段后不要再追求“做出某一道题”而是开始总结“这一类题的通法”。1984 虽然是简单题但它和很多中等题共享同一个算法骨架你把它吃透了后面遇到 643、219、甚至 2616 都会觉得熟悉。最后再分享一个小技巧做这类“排序 滑窗”的题目我习惯先在草稿纸上手写几个测试用例把窗口的下标变化写出来标清楚每次窗口的首尾索引再动手码代码。这个习惯帮我避免了一半以上的边界 bug也让我在面试时更有底气——因为你不只是“跑通了样例”而是“真的知道每一行代码在干什么”。
返回列表