1. 先破题:移除最少到底等价于留下最多
先说题目本身:给一个整数数组 nums 和一个整数 k,定义“平衡数组”为非空数组,且数组中最大值与最小值的比值不超过 k。现在允许从 nums 中移除任意数量的元素,问最少移除多少个,能让剩下的数组变成平衡数组。
我第一次看到这个题,脑子里冒出来的第一个思路是:每次看当前数组的最大值和最小值,如果比值大于 k,就移除其中一个,至于移哪个,看哪个更“极端”就移哪个。这个思路听起来挺合理,但它是个典型的贪心陷阱,后面我会专门举反例。
真正该做的是先把问题换个说法:移除最少,等价于保留最多。假设原数组长度是 n,最多能留下 m 个元素组成一个平衡数组,那么答案就是 n - m。所以这个题的核心从“怎么删”变成了“怎么留”,而且留得越多越好。
这看起来只是语言上的变化,但解题方向完全不同。如果直接想“删哪些”,你面对的是一个组合爆炸的搜索空间;如果改成“留哪些”,你会意识到:一个数组是否平衡,只取决于它的最大值和最小值,跟中间那些元素完全无关。这句话是整个题目的钥匙。
1.1 为什么直接贪心删大值或删小值是错的
举个具体例子:nums = [1, 2, 10, 100],k = 10。如果按“比值大于 k 就移除当前最大”的贪心策略:
- 第一步,最大值 100,最小值 1,比值 100 > 10,移除 100
- 剩余
[1, 2, 10],最大值 10,最小值 1,比值 10 <= 10,正好平衡
总共移除 1 个,看起来是对的。但换个例子就会翻车:nums = [2, 4, 9, 100],k = 5。按同样的策略:
- 最大值 100,最小值 2,比值 50 > 5,移除 100
- 剩余
[2, 4, 9],最大值 9,最小值 2,比值 4.5 <= 5,平衡
又是移除 1 个,好像还行。但问题是,你凭什么每次只动最大值?如果最大的那个比值超了,可能是因为最小值太小,而不是最大值太大。比如 nums = [1, 100, 101, 102],k = 2:
- 按“移除最大值”来:最大值 102,最小值 1,比值 102 > 2,移除 102
- 剩余
[1, 100, 101],最大值 101,最小值 1,比值 101 > 2,继续移除 101 - 剩余
[1, 100],比值 100 > 2,移除 100 - 剩余
[1],平衡,这回去了 3 个
但其实最优解是移除 1,留下 [100, 101, 102],它们三个的最大值 102、最小值 100,比值 1.02 <= 2,只移除 1 个。所以贪心策略在“极端小值拖累整体”的场景下会完全跑偏。
1.2 把“删”倒过来想,问题立刻清晰
一旦把目标改成“最多能留下多少”,我们只需要关心一件事:选一个尽可能大的元素集合,让这个集合里的最大值和最小值满足 max / min <= k。
注意到一个容易被忽略的事实:如果你选了一个集合,它满足条件,那么你把这个集合的最小值和最大值之间的所有元素全部加进来,它依旧满足条件。因为新增的这些元素不会让最小值更小,也不会让最大值更大,比值不会变差。
这意味着,最优解不需要考虑那些弯弯绕绕的“隔一个选一个”的组合,它一定是一段“完整连续”的元素区间。怎么理解“连续”?把数组排个序,然后在这个有序数组上选一个连续的区间,比如 nums[l] 到 nums[r],其中 l <= r,这个区间里的所有元素就是我们要保留的集合,剩下的全部移除。
所以问题的最终形态变成了:排序后,找最长的连续子数组区间 [l, r],使得 nums[r] <= k * nums[l]。
这里有一个前提要说明白:原题说的是“从 nums 中移除”,并没有要求剩下的元素必须保持原来的相对顺序,也没有要求剩下的元素在原数组里是连续的一整段。所以我们可以任意挑元素留下来,排序是允许的。如果题目改成“移除后剩下的必须是原数组的连续子数组”,那这题解法就完全不同了,这个我在后面专门讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一个关键性质:最优保留集在排序后必然是连续区间
上一节我直接抛出了“排序后找连续区间”这个结论,这里认真证明一下,因为它不是显然的。
假设我们有一个平衡集合 S,它里面的最小值是 mn,最大值是 mx,满足 mx / mn <= k。现在任取一个数 x,如果 mn <= x <= mx,那么把 x 加进 S,新集合的最小值还是 mn,最大值还是 mx,所以新集合依然是平衡的。
这说明什么?说明一个平衡集合如果“中间有空缺”,我们可以把空缺补上,集合只会变大,不会坏事。所以最优解一定是一个“填满的”集合:它包含最小值到最大值之间的所有候选元素。把数组排序之后,这个集合天然就是一段连续的区间。
反过来说,如果排序后某段连续区间满足 区间最大值 <= k * 区间最小值,那这段区间本身就是一个平衡集合。于是“找最大的平衡集合”和“找最长的满足条件的排序连续区间”完全等价。
这个性质极大缩小了搜索范围。如果不排序,直接在原数组上随便选子集,那最多要考虑 2^n 种情况;排序之后,只需要从 n 个起点、n 个终点里找一对最优的左右端点,直接变成 O(n^2) 可以解决的问题,再往下压一压就是 O(n)。
这里还想多提一句:正因为有这个“区间闭包”性质,这类题目的通用套路才是“排序 + 双指针”。如果你在别的题里看到“子集满足某个只跟最大值最小值有关的条件”,大概率也能用这个思路。
2.1 条件用乘法写,而不是直接除
题目描述里写的是“最大值与最小值之比不超过 k”,也就是 max / min <= k。但在写代码的时候,我不建议直接做除法,因为涉及浮点数精度和除数为零的问题。
更稳妥的做法是把条件改写成乘法:max <= k * min。
举个例子,max = 5,min = 2,k = 2,用除法判断:5 / 2 = 2.5 > 2,不满足;用乘法判断:5 <= 2 * 2 = 4,不满足。结果一致。但如果 max = 4,min = 2,k = 2,除法 4 / 2 = 2 <= 2,乘法 4 <= 2 * 2 = 4,都满足。
乘法方式还有个好处:它天然避开了“min 会不会是 0”的尴尬。虽然这个题的正常数据范围应该都是正整数,但万一出现 0,除法直接崩,乘法不会。
2.2 如果数组里有负数,情况会怎样
严格来说,如果数组元素有负数,“最大值和最小值之比”会出现一个很反直觉的结果:当最小值是负数时,比值是负数,而 k 一般是正整数,所以负数一定小于等于 k,这就导致任何包含负数的数组都自动“平衡”,题目就失去了意义。
比如 [-100, 1],最大值 1,最小值 -100,比值是 1 / (-100) = -0.01,这确实小于任何正数 k。那这个数组算平衡吗?按理说不应该算,因为 1 和 -100 差了 101,怎么看都不该因为“比值是负数”就说是平衡的。
所以这题在出题时默认数组元素是正整数,或者说题意真正想表达的是“最大值不超过最小值的 k 倍”,即 max <= k * min。我知道有些朋友会拿这道题去改造成“数组里有负数”的版本,我的建议是:如果真想处理负数,你要单独讨论“最大正数、最小负数、绝对值”这些维度,复杂度会明显上升,不太适合作为这道题的延伸。刷题阶段,默认正整数就好。
3. 双指针为什么能跑 O(n):单调性推导
现在问题已经简化成:排序后,找满足 nums[r] <= k * nums[l] 的最长连续区间。
最笨的方法是对每个 l,往右枚举 r,找到一个最远的合法位置。这样是 O(n^2),对于 n 到 10^5 的题目来说会超时。需要更快的做法。
这里用双指针。先排序,然后维护两个指针 left 和 right,right 从头走到尾,同时根据条件调整 left 的位置,让窗口 [left, right] 始终满足 nums[right] <= k * nums[left]。在窗口满足条件时,用 right - left + 1 更新答案。
为什么这样可以做到 O(n)?关键在于排序后的单调性:
- 当
right向右移动时,nums[right]变大或不变,条件更容易被破坏 - 一旦
nums[right] > k * nums[left],说明左边界太小了,需要把left右移 - 当
left右移时,nums[left]变大或不变,所以k * nums[left]也变大或不变,条件会更容易被满足
也就是说,left 在整个过程中只会向右移动,永远不会回退。right 也只会向右移动。两个指针各自最多走 n 步,整体复杂度 O(n)。
这个性质值得多说一句:双指针不是万金油,能用它的前提是窗口移动具有单调性。你往右扩 right,不满足条件了就收缩 left;收缩之后,之前判断过的东西不会因为收缩而需要重新判断。如果不满足这个单调性,双指针就会漏解,必须老老实实二分或者用别的数据结构。
3.1 双指针的循环不变量
写代码之前,先明确循环不变量:在每次外层迭代开始时,[left, right] 是当前以 right 为右端点的最长合法区间。也就是说,left 已经移动到了最小可能位置,任何比 left 更小的左端点都会让条件失效。
为什么能做到这一点?因为当 right 变成某个新值时,我们需要从当前 left 开始往右找第一个满足 k * nums[left] >= nums[right] 的位置。由于 k * nums[left] 随 left 递增,一旦某个 left 满足,后面所有的 left 也都满足,所以我们只需要持续右移 left 直到条件恢复。
这个不变量保证了答案的正确性:对于每一个 right,我们得到的是以它为右端点的最优窗口,所有 right 遍历完,全局最优一定在其中。
3.2 为什么窗口里至少要有一个人
有个细节:窗口 [left, right] 不能是空窗口。极端情况下,即使 left 一路右移到了 right 的位置,窗口里也还有一个元素。一个元素的最大值等于最小值,比值是 1,只要 k >= 1,它一定是平衡的。所以窗口至少可以有大小 1,不会出现“找不到任何合法区间”的尴尬。
这个细节直接关系到代码里 left 的移动边界。如果你在内部 while 循环里无脑 left++,有可能让 left 越过 right,导致 nums[left] 访问到错误位置。我见过不少人在这一步写出数组越界。正确做法是:当 left 移动到和 right 相等时,直接跳出内部循环,因为此时窗口只有一个元素,必然满足条件。
4. Go 代码实现与三个容易翻车的细节
直接给出核心代码:
go复制package main
import (
"fmt"
"sort"
)
func minRemovals(nums []int, k int) int {
n := len(nums)
if n <= 1 {
return 0
}
sort.Ints(nums)
left := 0
maxKeep := 1
for right := 0; right < n; right++ {
// 不满足 nums[right] <= k*nums[left] 时,右移 left
for int64(nums[right]) > int64(k)*int64(nums[left]) {
left++
if left == right {
break
}
}
if cur := right - left + 1; cur > maxKeep {
maxKeep = cur
}
}
return n - maxKeep
}
func main() {
fmt.Println(minRemovals([]int{1, 2, 3, 4, 5}, 2)) // 2
fmt.Println(minRemovals([]int{1, 10, 100, 1000}, 10)) // 2
fmt.Println(minRemovals([]int{1, 1, 1}, 1)) // 0
fmt.Println(minRemovals([]int{100, 1, 101, 102}, 2)) // 1
}
代码很短,但里面藏着三个容易翻车的细节,我逐个说。
4.1 第一个坑:乘法溢出
k * nums[left] 在 nums[left] 和 k 都比较大的时候,直接用 int 乘会溢出。比如 nums[left] = 10^9,k = 10^9,乘积是 10^18,在 64 位系统里 int 是 64 位,能扛住 9.22 * 10^18,勉强可以;但如果是 10^10 * 10^9 = 10^19,直接超出 int64 范围,结果会变成负数,然后排序后的正数跟负数比较,整个逻辑就乱了。
稳妥做法是转成 int64 再相乘。大多数算法竞赛环境下,nums[i] 和 k 的乘积不会超过 2^63 - 1,所以 int64 足够。如果你遇到的数据范围真的超大,建议用 math/big,或者干脆把条件改写为 float64(nums[right]) > float64(k)*float64(nums[left]),但浮点数在超过 2^53 时会丢精度,所以也不完美。实际刷题时,int64 是我最常用也最推荐的做法。
4.2 第二个坑:left 不能越过 right
内部循环里,left 不断右移,可能会出现 left == right 的情况。此时窗口只有一个元素,条件一定满足,继续移动 left 只会越界。所以一定要加判断,到了 left == right 就 break。
有的同学会把内部循环写成:
go复制for int64(nums[right]) > int64(k)*int64(nums[left]) {
left++
}
这个版本在有些测试用例下会 panic,因为 left 可能超过 right。比如 nums = [1, 100],k = 1,当 right = 1 时,nums[1] = 100,k * nums[0] = 1,条件成立,left 变成 1;此时窗口 [1,1],其实已经合法,但如果 left 再动一下,就越界了。加上 left == right 的判断后,循环及时退出,安全。
4.3 第三个坑:排序后,原数组下标不能回推
题目问的是“最少移除多少个数”,我们算的是排序后最长合法区间的长度。但是如果你还想知道具体要移除哪些元素,就不能只用排序后的区间端点去回推原数组下标,因为排序会打乱原来的位置。
如果题目要求输出具体方案,你需要额外记录每个元素原来的下标。方法是:构造一个结构体数组,存 (value, index),按 value 排序。排序后找最长合法区间,得到的区间里的 index 集合就是要保留的元素,剩下的是要移除的元素。输出时再按原数组顺序遍历一遍即可。
因为这个题只问数量,所以代码里直接用 sort.Ints 就够了。
5. 边界条件与自测用例设计
刷题最怕的就是代码在核心用例上跑通了,边界条件却炸了。这道题有几个典型的边界场景,我在本地跑过一组用例,整理成表格:
| 输入 nums | k | 排序后最长合法区间长度 | 输出(最少移除数) | 说明 |
|---|---|---|---|---|
[1] |
1 | 1 | 0 | 单元素永远平衡 |
[1, 1, 1] |
1 | 3 | 0 | 全相等时不需要移除 |
[1, 2, 3] |
1 | 1 | 2 | k=1 时只允许完全相等的数组 |
[1, 2, 3] |
2 | 2 | 1 | 可保留 [1,2] 或 [2,3] |
[1, 10, 100, 1000] |
10 | 2 | 2 | 相邻两个刚好满足,跨一个就不行 |
[100, 1, 101, 102] |
2 | 3 | 1 | 排序后是 [1,100,101,102],保留 [100,101,102] |
[5, 1, 4, 2, 3] |
2 | 4 | 1 | 排序后 [1,2,3,4,5],保留 [1,2,3,4] 或 [2,3,4,5] |
这里特别看一下 [100, 1, 101, 102] 这个用例,它对应了前面说的“贪心移最大值会翻车”的反例。用双指针做,排序后是 [1, 100, 101, 102]:
right = 0,窗口[1],最长 1right = 1,nums[1] = 100,k * nums[0] = 2,100 > 2,left 右移left = 1,nums[1] = 100,k * nums[1] = 200,100 <= 200,满足,窗口[100],长度 1
right = 2,nums[2] = 101,k * nums[1] = 200,满足,窗口[100, 101],长度 2right = 3,nums[3] = 102,k * nums[1] = 200,满足,窗口[100, 101, 102],长度 3
答案 4 - 3 = 1,和预期一致。
5.1 空数组怎么处理
题目明确说了“非空数组”才叫平衡数组,所以最终结果至少为 0,不会出现“必须留空数组”的情况。我的代码里对 n <= 1 直接返回 0,是因为长度 1 的时候不管 k 是多少(只要 k >= 1),单元素都是平衡的。如果 n = 0,理论上输入就不合法,但防御式处理一下也无妨。
如果你在原题OJ上发现没有空数组的测试点,也别省这个判断,写上是好习惯。
5.2 k 的值会不会小于 1
题目描述里没有明说 k 的范围,但从“最大值与最小值之比不超过 k”这个定义看,正常逻辑是 k >= 1。因为最大值与最小值的比值至少是 1(当最大和最小相等时),所以如果 k < 1,除非是空数组,否则没有任何非空数组能平衡,这显然不符合题目本意。
写代码时,如果你要适配 k < 1 的情况,注意单元素数组的比值是 1,它不一定小于等于 k,双指针的“窗口至少一个元素”就不再成立了。我的建议是保持题目默认逻辑,不特地兼容 k < 1,但你要知道这个边界,面试时如果被追问可以主动说明。
6. 从这题延伸出去:排序 + 双指针的通用套路
这个题做完,我最大的感受是:它不是一个孤立的脑筋急转弯,而是“排序 + 双指针”这一类问题的标准样本。
这类问题通常长这样:给你一个数组,要求选出某个子集或子序列,使得这个集合里最大和最小之间满足某种不等关系,问最大长度或最小移除数量。遇到这种题,第一反应应该是:如果能排序,先排序,然后问题就变成一个区间问题。
为什么排序往往有效?因为题目只关心元素的值,不关心它们在原数组里的相对顺序,比如“最大值、最小值、比值、差值”这些指标,排序不会改变它们。一旦排序,整个数组就变成了一个单调递增的序列,这时候“满足条件的区间”会呈现出很强的结构性,可以用双指针、二分、甚至单调栈去优化。
6.1 常见的几个变体
- 变体一:找最长子数组(连续),使
max - min <= k。这个也是排序后双指针,但注意原题要求“连续子数组”时不能排序,要在原数组上滑动窗口,用两个单调队列维护最大值和最小值。 - 变体二:找最长子序列,使所有元素都能被某个值整除或整除关系。同样可以参考“排序后区间”的思路。
- 变体三:题目改成一个二维数组或者给的是坐标点,要求选出最多的点,使它们落在某个边长受限的正方形内。这是把一维双指针扩展到二维,常见做法是固定一个维度,另一个维度用双指针。
6.2 如果题目要求保留连续子数组,还能用排序吗
这个问题值得单独强调。有一种常见陷阱是,题目说“从 nums 中移除”,有些人会误读成“剩下的必须是原数组中的连续子数组”。
如果真是后者,排序就不行。因为原数组的顺序是固定的,[100, 1, 101, 102] 排序后 [1, 100, 101, 102],看起来保留 [100,101,102] 很容易,但实际上在原数组里 100 和 101 之间夹着一个 1,如果必须连续,这个 1 不能“跳过”,那就只能把 1 也留下来,导致 [100,1,101,102] 最大值 102、最小值 1,比值 102,远超 k,不可行。此时正确的解法是在原数组上做滑动窗口,同时维护当前窗口的最大值和最小值,用两个单调队列或者 heap 结构来保证 O(n log n) 或 O(n) 的复杂度。
所以拿到题目第一步,一定要先确认“移除”之后留下的是子序列还是子数组。这个前提直接决定用排序还是用滑动窗口。
6.3 这道题可以怎么考变形
如果面试官想加难度,会往这些方向变:
- 输出具体移除方案,而不是只输出数量。需要记录原下标,排序时带上索引,最后根据保留区间反推要删除的下标集合。
nums长度极大,k也极大,要求用二分优化。其实双指针已经是 O(n),如果排序是瓶颈,那可以用桶排序、基数排序等把排序降到 O(n),但一般不用。- 给你多个
k的查询,每次问最少移除多少。可以先预处理所有排序后区间长度,然后回答每个查询,比如用二分找每个左端点的最远右端点,存下来,查询时二分答案。 - 改成求最少移除元素后数组平衡,但允许你先对这个区间做一次整体平移(比如每个元素加一个偏移量)。这类题就更综合了,通常还需要配合前缀和、差分等技巧。
我个人在实际刷题时,遇到这个题之后顺手做了几个同类型变体,它们共同沉淀下来一个习惯:先问“能不能排序”,再问“排序之后能不能用双指针”,最后再问“双指针的单调性来自哪里”。 这三个问题问完,题目基本就透了。
写这段的时候我又想了想最开始那个贪心陷阱,其实很多人不是不会双指针,而是没有识别出“移除最少等价于保留最多”这个转换。算法题翻车往往不是难在数据结构,而是难在第一步的视角切换。多刷几道这种“最大值最小值相关”的题之后,你就会形成肌肉记忆:凡是条件只依赖 max 和 min 的,排序 + 双指针是第一优先级的尝试方向。
