多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

排序+定长滑动窗口:LeetCode 1984 最小差值题解与原理剖析

排序+定长滑动窗口:LeetCode 1984 最小差值题解与原理剖析 昨天想刷两道不费脑子的题目换换状态顺手打开了 LeetCode 1984。这题属于那种一眼看过去就知道要排序的简单题但我刷完去评论区一转发现很多人的困惑并不在代码而在“为什么排序之后只要检查连续的 k 个数就够了”。这篇文章就是来把这个为什么彻底讲透的。我会从暴力枚举讲起再把排序加定长滑动窗口的原理、代码、边界坑一次说完。无论你是刚开始刷力扣的新手还是准备面试想巩固基础的老手这篇都可以直接当模板看。这题本身热度不算高但它作为“排序 滑窗”的入门模板价值一点不比热门题低。很多刚刷题的朋友只盯着热题 100 那种知名度榜单反而容易错过 1984 这种能帮你把基础打扎实的题目。如果你已经刷过一阵子大概率也能从这道题里找到一些面试时会用上的细节。1. 题目到底在说什么从“任意挑k个”到“排序后看窗口”1.1 原题描述、样例和数据范围LeetCode 1984 的完整题目是 Minimum Difference Between Highest and Lowest of K Scores中文一般翻译为“学生分数数组中任意 k 个分数的最低最高差值”。题面本身并不复杂给定一个数组 numsnums[i] 表示第 i 个学生的分数再给一个整数 k要求从数组中任意挑出 k 个学生的分数使得这 k 个分数里的最大值与最小值的差尽量小最后返回这个最小的差值。比如 nums [9,4,1,7]k 2 时我们可以挑 [1,7]、[4,7]、[1,4]、[9,7] 等等组合。其中 [9,7] 差值是 2[4,7] 差值是 3[1,4] 差值是 3[1,7] 差值是 6所以答案应该是 2。再比如 nums [90]k 1只挑一个学生最高分和最低分是同一个分数差值自然是 0。数据范围这里需要特别看一下1 k nums.length 10000 nums[i] 10^5。n 最大只有 1000所以题目本身连中等题的强度都没到属于标准的 easy。但正因为简单它非常适合用来验证你对排序、滑窗这两类基础工具的理解深度。很多人能 AC 却讲不清原理差的其实不是代码而是对“为什么可以这么做”的推导。1.2 为什么“排序”是第一直觉如果让你在纸上手动挑出 k 个数让极差最小你大概率会先把这些数按大小排成一排然后在排好序的序列里找一段连在一起的数字。这背后的直觉是最大值和最小值之间的差值只与被选中的 k 个数里的两端有关中间怎么选都不影响这个差值。既然只关心两端那么两个端点在数值上越接近最终答案就越小。把数组排序后所有在数值上离得最近的元素都变成了邻居。此时“找 k 个分数使极差最小”这件事就等价于“在数轴上找一个长度尽可能短的区间区间里至少包含 k 个元素”。你不需要关心区间里到底选了哪几个元素因为你只要在这个区间内挑任意连续 k 个极差就能被控制住。这个直觉对大多数人是够用的但面试官往往不满足于直觉。他们会追问一句“为什么跳着选就不可能更优”如果答不上来代码写得再快也容易在评价上打折扣。所以我每次刷到这类题都会强迫自己把“为什么”补全而不仅仅是记住一个套路。1.3 关键转换最优解一定来自一段连续区间假设排序后数组是 a[0] a[1] ... a[n-1]。我们任取 k 个下标再在这 k 个值里找到最小值和最大值记为 a[l] 和 a[r]显然 l r。已知被选中的 k 个数都落在 a[l] 到 a[r] 这个闭区间内。那么问题来了一个非连续的选择有没有可能比所有连续窗口都更优用反证法想。如果最优选择对应的最小值和最大值是 a[l] 和 a[r]而这个区间内还有没被选中的元素那么有两种操作要么从 a[r] 那一端换掉一个最大值把区间往左收缩要么从 a[l] 那一端换掉一个最小值把区间往右收缩。无论哪种操作新的极差都不可能比 a[r] - a[l] 更大因为只是把其中一个端点移向了中间差值只会变小或不变。只要区间里的数量不够 k就继续从更窄的范围里补数最终总能收敛到一个连续窗口的选择上。换句话说对于任意一个非连续选择我们都可以用“把两端向中间收缩”的操作把它变成一个极差不超过原选择的连续窗口选择。既然非连续解做不到比连续窗口更好那么最优答案一定在“排序后任意连续 k 个元素”这个候选集合里。这个压缩选择空间的思路比代码本身重要得多它是很多区间问题的通用范式。想通了这一步顺水推舟就来到了滑动窗口。2. 为什么不能去枚举组合C(1000, k) 的增长让人绝望2.1 暴力枚举的写法和复杂度很多人第一次看到“任意挑 k 个”的第一反应是回溯枚举所有下标组合每个组合算一次最大最小差然后维护全局最小。这个思路非常自然而且在小数据下完全能跑。比如 n10、k3C(10,3)120根本毫无压力。但如果直接用回溯去解这道题核心代码如下# 伪代码不要真的在 LeetCode 上提交这个写法 class Solution: def minimumDifference(self, nums: List[int], k: int) - int: n len(nums) self.ans float(inf) def dfs(start: int, path: List[int]): if len(path) k: self.ans min(self.ans, max(path) - min(path)) return if len(path) (n - start) k: return for i in range(start, n): path.append(nums[i]) dfs(i 1, path) path.pop() dfs(0, []) return self.ans这个写法的时间复杂度大约是 O(k * C(n,k))因为每个组合都要花 O(k) 的时间去算 max 和 min。就算用一种数据结构去维护当前组合的最大最小值组合数本身就是指数级的天文数字数据规模一大就崩。2.2 C(1000, 500) 有多大直观感受n 最大是 1000如果 k 取中间值 500C(1000,500) 大约是 2.7 × 10^299。这个数字有多大可观测宇宙里的原子总数大约是 10^80 的量级C(1000,500) 比它还要大 200 多个数量级。这意味着哪怕你把整个宇宙的原子都变成计算机再把每台计算机跑一万年也枚举不完这些组合。所以在面试或者实际工程中一看到“枚举所有组合”这种方案就要条件反射地警惕只要 n 超过 30组合枚举就已经不现实了。这道题 n 上限是 1000暴力回溯只能拿来说明思路绝对不可能是正解。这也是 LeetCode 出这道题的目的之一让你意识到组合空间必须被压缩而不是试图用更快的计算机去硬扛。2.3 剪枝和 DP 为什么也救不了你可能会想回溯能不能剪枝剪掉大量分支确实能剪比如当前 path 长度加上剩余元素不足 k 时就直接返回但这种剪枝只是把明显不可能完成的分支去掉组合空间本身的规模没有缩小遇到 k500 这种输入依然会超时。那动态规划呢这个问题有两个难点。第一你要选择的不是“满足某种约束的最优子结构”而是任意一个固定数量子集可行集合的数量本身就是组合级。第二目标函数是 max - min如果按某种顺序逐个加入元素这个差值是动态变化的不满足简单的阶段转移。硬去设计状态状态空间至少也是 O(nk值域)在这个题上完全没有必要。这道题真正的出路就是从组合空间跳到连续窗口空间把需要检查的候选数量从指数级直接压成 O(n)。这个降维思路也正是上一节那个“连续区间”定理的价值所在。为了更直观地感受差距我把两种方案放在一起对比方案时间复杂度空间复杂度能跑的数据规模暴力回溯O(k * C(n,k))O(k)n 大概 20 以内排序 滑窗O(n log n)O(log n) 或 O(n)n1000 轻松这两行数字的差距就是组合思维和区间思维的差距。刷题到后面你会发现很多看起来“任意选”的题目最后都被某种排序、某种单调性压缩成了“连续选”这是 LeetCode 里非常高频的套路。3. 排序加定长滑动窗口代码怎么写才对3.1 Python 参考写法到了这一步代码就非常短了。排序之后用右端点 nums[ik-1] 减去左端点 nums[i] 就是当前窗口的极差把每个窗口算一遍取最小值即可class Solution: def minimumDifference(self, nums: List[int], k: int) - int: nums.sort() ans float(inf) for i in range(len(nums) - k 1): ans min(ans, nums[i k - 1] - nums[i]) return ans这段代码里最需要注意的就是循环边界。for i in range(len(nums) - k 1) 意味着 i 的取值是 0 到 n-k对应的窗口右边界是 ik-1最大是 n-1正好覆盖数组最后一个元素。为什么是 n-k 而不是 n因为窗口长度为 k如果 i 超过 n-k右边界就越界了。k1 时这个代码也正确nums[i1-1] - nums[i] 0第一个窗口就会得到 0之后不会再变大也不会更小直接返回 0语义完全符合“只挑一个人最高最低差为 0”。这个现象看着简单但很多人在写特判的时候反而会把自己绕进去后面我会专门说这个坑。3.2 C 与 Java 对照C 写法class Solution { public: int minimumDifference(vectorint nums, int k) { sort(nums.begin(), nums.end()); int ans INT_MAX; for (int i 0; i k - 1 nums.size(); i) { ans min(ans, nums[i k - 1] - nums[i]); } return ans; } };Java 写法class Solution { public int minimumDifference(int[] nums, int k) { Arrays.sort(nums); int ans Integer.MAX_VALUE; for (int i 0; i k - 1 nums.length; i) { ans Math.min(ans, nums[i k - 1] - nums[i]); } return ans; } }C 和 Java 的循环条件写成 i k - 1 nums.size() 会更容易看出右边界不越界比写成 i n-k1 更直观。我推荐新手用这种写法它把“窗口右端点存在”这个条件直接翻译成了代码。这里要多说一句Java 里 Arrays.sort 是对 int[] 原数组进行原地排序C 的 sort 同理。如果面试官说你不能修改输入数组那么在 C 里可以拷贝一份 vector 再排序Java 里可以 Arrays.copyOf(nums, nums.length) 或者 nums.clone()Python 里就直接 sorted(nums) 而不是 nums.sort()。这个细节后面还会再展开因为它确实是一些面试官喜欢追问的点。3.3 循环边界的推导和提前返回我们再用一个具体例子走一遍边界。nums [9,4,1,7]先排序得到 [1,4,7,9]k 2。此时 n 4i 的取值范围是 0、1、2i0窗口 [1,4]差 3i1窗口 [4,7]差 3i2窗口 [7,9]差 2答案是 2。这个例子虽然小但已经能看出窗口在排序数组上逐步滑动的轨迹。如果你写循环的时候不小心把范围写成 range(len(nums))那么 i3 时窗口右边界 ik-14越界读内存或者抛异常如果你写成 range(len(nums) - k) 又少算了最后一个窗口。这类边界问题在面试白板上很容易翻车。我的建议是先写出 i k - 1 n 这个条件再换算成 for 循环范围基本不会错。还有一个可选的加速点因为 nums 已经有序窗口差值 nums[ik-1] - nums[i] 整体上不会一直单调但由于差值不能为负一旦遇到某个窗口差值为 0就可以直接返回 0因为 0 已经是最优解。代码可以这样加class Solution: def minimumDifference(self, nums: List[int], k: int) - int: nums.sort() ans float(inf) for i in range(len(nums) - k 1): ans min(ans, nums[i k - 1] - nums[i]) if ans 0: return 0 return ans这个提前返回在实际运行中影响不大因为 n 只有 1000但它体现了一个良好的思维习惯在搜索类代码里遇到理论下界就终止。面试时主动提一句“0 是理论最小所以可以提前返回”会显得你对问题理解更深一层印象分通常不低。4. 边界条件和刷题时容易忽略的细节坑4.1 k1 要不要特判我见过很多题解会在开头写 if (k 1) return 0; 当然它没有错但带来的问题是它让读者觉得 k1 需要特殊处理程序逻辑反而变复杂了。实际上当 k1 时每个长度为 1 的窗口差值都是 0通用循环天然会得到正确答案。加特判不是必需的唯独要注意的是初始值不能因此设错。如果面试官问你 k1 时怎么处理你可以回答不需要单独处理排序后滑动窗口的每个单元素窗口差值都是 0自然返回 0但如果你想代码意图更显式也可以加个特判提高可读性。两种都行关键是别写出“初始化 ans nums[k-1] - nums[0] 然后循环更新”这种可能漏掉 k1 情况的代码。4.2 初始值千万别写成 0这是一个非常经典的坑。有人觉得答案最小就是 0于是把 ans 初始化为 0结果遇到 nums[1,2,3], k2 这样的数据时正确窗口差是 1程序却永远返回 0因为 min(0,1) 永远是 0。用 0 当初始值等于在告诉程序“我已经找到最优解了”这是错的。正确做法是初始化为一个足够大的数比如 Python 里的 float(inf)C 里的 INT_MAXJava 里的 Integer.MAX_VALUE。这样第一个窗口的差值一定能取代初始值后续再通过 min 操作逐步更新。虽然本题值域很小理论上用 10^9 也没问题但养成用 INT_MAX/inf 的习惯将来遇到数据范围更大的题就不会踩坑。4.3 重复分数与提前结束数组里有重复分数时答案很容易变成 0。比如 nums [1,2,2,3]k 2排序后窗口 [2,2] 差值为 0直接命中提前返回逻辑。你要是没写提前返回程序也会正常通过只是多算几个窗口而已。重复元素这道题的另一个意义是排序后数组里相同元素会聚在一起如果有连续的 k 个相同数字说明答案已经触底。为了加深印象你可以把“连续 k 个元素相等则返回 0”这个判断和上一节提到的提前返回合并理解。处理这种数据时滑动窗口的鲁棒性就体现出来了它的复杂度依然是 O(n)不会因为重复元素退化。这一点在你以后处理更长数组时会很重要因为工程数据里重复值是常态而不是特例。4.4 面试时“要不要动原数组”这个细节很多人在 LeetCode 上写习惯了上来就是 nums.sort()却没注意这是原地修改。在原题场景下没有任何问题因为提交完数组就被丢了。但面试场景不同面试官可能追问如果输入数组是只读的或者函数调用方还希望用原始数组你怎么办答案很简单拷贝一份再排序。Python 用 sorted(nums)C 里vectorint a(nums); sort(a.begin(), a.end());Java 里int[] sorted nums.clone(); Arrays.sort(sorted);。代价是额外的 O(n) 空间。这虽然是个小问题但在面试评分里经常能拉开差距主动说出“我可以先拷贝避免修改原数组”的人和一句“反正 AC 了”的人评价是完全不一样的。5. 复杂度、计数排序的可能性以及同系列题的迁移5.1 时空复杂度与数据规模排序的时间复杂度是 O(n log n)滑动窗口只要遍历 n-k1 个窗口是 O(n)所以整体是 O(n log n)。n 的上限是 1000哪怕不写任何微优化运行时也是毫秒级。空间方面原地排序的递归栈空间可以视为 O(log n)很多时候也直接写 O(1)如果额外拷贝数组就是 O(n)。不少读者看到这个复杂度会说“太简单了”但请想想如果你上来只会写回溯复杂度是 O(k*C(n,k))明明只有 1000 个元素的小数据就能卡死。一道 easy 题考的就是你能不能把指数级降到多项式级哪怕只是 O(n log n)背后也是从组合空间到连续区间的思维跨越。把这一点想清楚你就不容易再做“一看就会、一写就忘”的题解过滤器。5.2 值域只有 10^5计数排序能不能反超有人可能会问nums[i] 的最大值是 10^5n 是 1000值域也不算特别大能不能用计数排序让“排序”变成 O(nV) 从而整体 O(n)理论上当然可以先统计每个分数出现次数然后按分数从小到大枚举再用一个长度为“已选择人数”的窗口去计算差值。代码会复杂不少而且这里 V100000、n1000实际运行时间差异微乎其微计数排序的常数还更大。这个问题的意义不在本题的优化而在于帮助你理解排序算法的选择维度当 n 远大于 V 时计数排序有优势当 n 远小于 V 时比较排序更省事。LeetCode 的数据范围设计得很清楚k 和 n 都只有 1000比较排序就是最优雅的答案。没必要为了炫技去写一个更复杂的东西工程上“足够好”的代码通常胜过“理论上最优”的代码。5.3 同系列题的迁移排序加滑窗是一族题的地基把这道题的“排序 定长窗口”模板记住你可以很快迁移到很多题目LeetCode 643 子数组最大平均数 I定长 k 窗口每次滑动维护区间和求最大平均值。LeetCode 219 存在重复元素 II在 k 大小的窗口内用哈希表判断是否有重复。LeetCode 220 存在重复元素 III窗口内任意两数差值不超过 t需要排序结构或桶。LeetCode 209 长度最小的子数组不再是定长窗口而是变长窗口和当前这道题正好形成对照。这些题的核心都是同一个动作在一个连续区间上维护信息。1984 的差别在于它连信息都不用维护只要右边界减左边界所以它才适合当滑窗入门的第一个模板。如果你已经把这道题吃透了再去刷上面这几道会发现只是从“只取两端”进化为“维护区间内更复杂的状态”思路完全是沿着同一条线走的。5.4 周赛 Q1 的“送分题”规律我在刷周赛时有一种很深的体验很多场次的第一题就是这种“排序 窗口”或者“直接模拟”的送分题LeetCode 1984 正是其中典型。不管第几场周赛Q1 位置上经常出现这类热身题。难点不在算法而在你能不能快速看清数据范围、快速写出干净的循环边界。对于目标是周赛冲分或者面试保底的人来说这种题的正确率不应该低于 100%。它的价值是帮你建立解基础题的肌肉记忆后面碰到中等难度的滑窗题你才有余力去思考额外状态。很多人刷题时喜欢直接上难题但我始终觉得送分题能一次写对、能讲清楚原理才是稳定发挥的底气。LeetCode 1984 这种体量的题目正是练习这种“一遍过 能讲明白”能力的好材料。我个人刷这道题时最大的感触是“连续窗口”这四个字不是凭感觉来的而是可以把组合枚举压缩掉的数学结果。以后再有人问为什么排序后只用看连续 k 个数你如果能像我上面那样从反证法的角度解释一遍面试官基本不会再追问。代码本身两分钟就能写完真正的门槛反而在排序和滑窗之前的那一步“想到要排序”。如果你刚开始刷题建议把它作为滑动窗口系列的第一道模板题如果已经在准备面试不妨顺手把 k1、重复元素、能否改原数组这几个追问也都准备一遍。把这些细节过完LeetCode 1984 就不只是一个 AC 记录而是一个可以反复调用的基础模板。
返回列表