刷题刷到“剑指 Offer II 159. 库存管理 III”这道题时,我第一反应是:这题表面上叫“库存管理”,但 Leetcode 这道题的 tag 和官方表述已经写得很直白——给一个商品库存余量数组 stock,要返回余量最少的 cnt 个商品余量。说白了,这就是一道标准的 Top K 最小元素问题,也是面试里被问烂了的“最小的 K 个数”换皮题。但对于真正想通过刷题提升工程思维的人来说,这道题值得认真对待:它一题串起了排序、堆、快速选择三种思路,而且每种解法在真实系统里都有对应场景。这篇文章就用我的视角把这道题彻底拆开,从题意到代码,从复杂度到面试追问,一次讲透。
1. 这题到底在考什么:从“库存管理”到 Top K 问题
1.1 题意还原:别被“库存”两个字带偏
题目描述是这么个意思:仓库管理员用一个数组 stock 记录商品库存,stock[i] 表示第 i 件商品的库存余量,函数接收一个参数 cnt,需要返回所有商品中库存余量最小的 cnt 个余量值。比如:
text复制stock = [0, 2, 3, 6, 1, 5, 1]
cnt = 2
期望输出是 [0, 1],或者 [1, 0] 也行。注意这里输出元素的顺序没有硬性要求,你只要把最小的两个数捞出来即可,不需要保证它们排好序。
很多第一次做这道题的人,会被“库存管理”四个字带偏,误以为要维护一个有序库存表、做增量更新、或者模拟先进先出之类。实际上这些都是干扰项。题目核心就是在问:给定一个无序数组和一个整数 cnt,返回最小的 cnt 个数。
这道题在 Leetcode 上对应的还有一道同胞题——剑指 Offer 40. 最小的 k 个数。两题解法完全一样,只是命题背景换成了“库存管理”。所以你看,Leetcode 的题号会变、场景会变,但内核不变:Top K 问题在算法面试中的地位就是这么稳固。
1.2 核心考点拆解
既然是 Top K 最小元素问题,它的核心考点有四个:
- 对排序算法的理解够不够深,能不能想到先排序再取前
cnt个; - 对堆这种数据结构的理解和运用,能不能想到用“大顶堆”做“小顶堆”的活;
- 对快速排序 partition 过程的掌握程度,能不能把快排的 partition 思想迁移到选择问题上;
- 对不同数据量级和场景的权衡能力,比如知道什么时候该用堆、什么时候该用快速选择、什么时候干脆排序。
这四个考点也是面试官在问你这道题时最想听到的四个方向。后面我会一种一种展开,每种都给出可运行的代码、复杂度分析和我的踩坑经验。
1.3 一个关键性质:不需要全局有序
这题最容易忽略的地方在于:只需要部分有序,不需要全部有序。
你要找最小的 cnt 个元素,并不需要关心这 cnt 个元素内部的顺序,也不需要关心剩余 n - cnt 个元素的顺序。整个数组被分成“前一部分”和“后一部分”,前一部分就是答案。所以“排序后再取”是一种可行解,但不是最优解——它做了很多无用功。这个“部分有序”的观察,是后面快速选择解法的理论起点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序解法:最直白的答案,也是面评的及格线
2.1 一行排序的写法
排序是最朴素、最不容易出错的思路:先把整个数组排一遍序,然后取前 cnt 个元素。代码写出来非常短,以 C++ 为例:
cpp复制#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
vector<int> inventoryManagement(vector<int>& stock, int cnt) {
sort(stock.begin(), stock.end());
return vector<int>(stock.begin(), stock.begin() + cnt);
}
};
Java 版本类似:
java复制class Solution {
public int[] inventoryManagement(int[] stock, int cnt) {
Arrays.sort(stock);
return Arrays.copyOf(stock, cnt);
}
}
Python 更简洁:
python复制class Solution:
def inventoryManagement(self, stock: List[int], cnt: int) -> List[int]:
return sorted(stock)[:cnt]
“一行排序”看起来很简单,但它确实能过 Leetcode 的测试。我看到很多题解喜欢直接喷排序解法“太low”,我不太认同。在 n 很小、或者你只需要快速通过笔试验证思路的时候,排序解法完全够用。面试时如果你先说出排序解法,再主动补一句“但排序做了全局有序的工作,复杂度是 O(n log n),在数据量大时不是最优”,这就展示了你有复杂度意识。
2.2 排序解法的时间复杂度账
排序解法的时间复杂度是 O(n log n),其中 n 是 stock 数组长度。空间复杂度依赖排序实现:C++ 的 sort 是内省排序(introsort),平均空间 O(log n);Python 的 Timsort 最坏空间 O(n)。
在 n 比较小的时候,O(n log n) 和 O(n) 的差距感知不出来,但如果你把 n 拉到一千万,这个差距就是质的差别。举一个直观的数:当 n = 10^7 时,排序需要做约 2.3 亿次比较操作,而快速选择的期望比较次数大约是 2.2 千万次左右,差了 10 倍。这还只是比较次数,实际工程里还要考虑内存访问模式,差距只会更大。
2.3 排序解法什么时候还是首选?
我的个人经验是,下面三种情况直接用排序:
cnt非常接近n,比如cnt = n - 1,你几乎要把所有元素捞出来,省不了多少工作;- 你后续还要对结果做进一步有序处理;
- 面试中你需要在 60 秒内写出一个不越界的答案,先保底再说。
但如果你明确知道面试官想考“海量数据下的 Top K”,那排序就要往后放。海量数据意味着 n 可能大到你根本没法一次性排序,这就要引出堆解法了。
3. 大顶堆解法:流式数据的正确打开方式
3.1 为什么是“大顶堆”而不是“小顶堆”
这是这道题最经典的一个坑。很多人一看到“求最小的 K 个”,第一反应是“我用小顶堆,从堆顶拿最小值,拿 K 次不就行了”。思路听着没毛病,但代价是你需要在内存里维护整个数组的堆,空间是 O(n)。而且时间上是 O(n log n),因为要建一个包含所有元素的堆,再取 K 次。
正确姿势是维护一个大小为 cnt 的大顶堆。每一步遍历新元素时:
- 如果堆还没满(堆的大小小于 cnt),直接入堆;
- 如果堆已经满了,且当前元素比堆顶小,就弹出堆顶,把当前元素插进去;
- 如果当前元素比堆顶大或等于堆顶,忽略。
这样遍历完整个数组后,堆里留下的就是当前已经见过的最小 cnt 个元素。为什么是大顶堆?因为堆顶是这 cnt 个元素里最大的那个,当你遇到一个新元素时,只需要跟“这组最小元素中最大的那个”比较,就能判断它有没有资格进入前 cnt 名。这就像班上前三名,新同学要挤进前三,只需要跟第三名比,不需要跟全班每一个人比。
用大顶堆的另一个好处是空间复杂度只有 O(cnt),而不是 O(n)。这在处理大数据流时非常关键:你可以一边读数据一边筛,内存占用跟总数无关,只跟目标规模有关。
3.2 手写堆还是用优先队列?
在 Leetcode 刷题时,直接用语言内置的优先队列即可。C++ 的 priority_queue 默认是大顶堆,正好满足需求。
C++ 代码示例:
cpp复制#include <vector>
#include <queue>
using namespace std;
class Solution {
public:
vector<int> inventoryManagement(vector<int>& stock, int cnt) {
if (cnt <= 0) return {};
if (cnt >= stock.size()) return stock;
priority_queue<int> heap; // 默认大顶堆
for (int num : stock) {
if (heap.size() < cnt) {
heap.push(num);
} else if (num < heap.top()) {
heap.pop();
heap.push(num);
}
}
vector<int> result;
while (!heap.empty()) {
result.push_back(heap.top());
heap.pop();
}
return result;
}
};
注意最后返回的堆内元素是无序的,但这题不要求结果有序,所以没问题。如果你希望返回结果升序,再对 result 排一次序也行,不过那样就多了一次 O(cnt log cnt) 操作。
Java 版本用 PriorityQueue,默认是小顶堆,需要指定比较器反转成大顶堆:
java复制class Solution {
public int[] inventoryManagement(int[] stock, int cnt) {
if (cnt == 0) return new int[0];
if (cnt >= stock.length) return stock;
// 大顶堆:默认是小顶堆,所以 reverseOrder
PriorityQueue<Integer> heap = new PriorityQueue<>(Collections.reverseOrder());
for (int num : stock) {
if (heap.size() < cnt) {
heap.offer(num);
} else if (num < heap.peek()) {
heap.poll();
heap.offer(num);
}
}
int[] res = new int[cnt];
int idx = 0;
for (int num : heap) {
res[idx++] = num;
}
return res;
}
}
Python 版本用 heapq,但 Python 默认是小顶堆,所以要存“相反数”来模拟大顶堆:
python复制import heapq
from typing import List
class Solution:
def inventoryManagement(self, stock: List[int], cnt: int) -> List[int]:
if cnt == 0:
return []
if cnt >= len(stock):
return stock
heap = []
for num in stock:
if len(heap) < cnt:
heapq.heappush(heap, -num) # 取负数,大顶堆
elif num < -heap[0]:
heapq.heapreplace(heap, -num)
return [-x for x in heap]
Python 这种“负数模拟大顶堆”的小技巧,初学者第一次看到会懵,但手写两遍就习惯了这个模式。
3.3 堆解法的复杂度与适用场景
时间复杂度:每个元素最多经历一次入堆和一次出堆,每次操作 O(log cnt),所以总时间 O(n log cnt)。空间复杂度 O(cnt)。
当 cnt 远小于 n 时,O(n log cnt) 明显优于 O(n log n)。举个数:n = 10^7,cnt = 100,则 n log cnt 大约是 10^7 × 6.6 ≈ 6600 万次操作,而排序是 10^7 × 23 ≈ 2.3 亿次。
堆解法的最大魅力在于它天然支持数据流场景。数组是静态的,但真实世界里库存数据是实时更新的:每天入库、出库、退货、报损,数据源源不断。你不需要把所有数据攒到内存里再统一处理,而是来一条处理一条,堆里始终维护着当前 Top K。这也是面试官非常喜欢追问的场景:“如果数据量大到内存放不下怎么办?”答案就是堆。
3.4 我在堆解法上踩过的坑
第一次用 C++ 写这个解法时,我犯过一个低级的错误:忘记处理 cnt == 0 的情况。如果 cnt = 0 而我又先执行 vector<int> result; while (!heap.empty()),那没问题,但如果在遍历前就访问 heap.top(),就是未定义行为。所以任何情况下都先处理 cnt == 0 和 cnt >= n 这两个边界,这属于 OJ 题的老规矩。
另外要注意的是,C++ 的 priority_queue 不支持遍历,我上面代码是把堆整个弹出来放到 result。如果你在 Java 里用 PriorityQueue,它允许迭代遍历,但迭代顺序不是堆序,也不是有序的。如果面试官要求输出有序,那就老老实实弹出再反转。
4. 快速选择:更极致的平均 O(n) 解法
4.1 从快速排序到快速选择
如果你对快排的 partition 过程很熟悉,这道题还有更优解:快速选择(Quick Select)。
快排的 partition 干的事是以一个基准值 pivot 将数组分成两部分:左边都小于等于 pivot,右边都大于等于 pivot。排完之后,pivot 最终落到了它的绝对排序位置上。如果这个位置恰好是 cnt - 1(第 cnt 个位置),那 pivot 左边的所有元素就是最小的 cnt 个元素。如果 pivot 的位置小于 cnt - 1,说明最小的 cnt 个元素有一部分还在右边,只需要在右边继续找剩下的;如果 pivot 的位置大于 cnt - 1,说明答案全在左边,只需要去左边缩小范围。
这个思路把快排的“分治递归”改成了“只往一边递归”,所以平均时间复杂度从 O(n log n) 降到了 O(n)。
4.2 标准快速选择实现
以 C++ 为例,我写一个完整的快速选择解法:
cpp复制#include <vector>
using namespace std;
class Solution {
private:
int partition(vector<int>& nums, int left, int right) {
int pivot = nums[right]; // 直接选最右边元素当基准
int i = left;
for (int j = left; j < right; ++j) {
if (nums[j] < pivot) {
swap(nums[i], nums[j]);
++i;
}
}
swap(nums[i], nums[right]);
return i;
}
public:
vector<int> inventoryManagement(vector<int>& stock, int cnt) {
if (cnt <= 0) return {};
if (cnt >= stock.size()) return stock;
int left = 0, right = stock.size() - 1;
while (left < right) {
int pos = partition(stock, left, right);
if (pos == cnt - 1) {
break;
} else if (pos < cnt - 1) {
left = pos + 1;
} else {
right = pos - 1;
}
}
return vector<int>(stock.begin(), stock.begin() + cnt);
}
};
这段代码用的是迭代式:不断调整 left 和 right 的边界,直到基准位置落在 cnt - 1。每次 partition 后,pos 左侧的元素都不大于 pos 位置的元素,右侧都不小于它。当 pos == cnt - 1 时,[0, cnt) 区间内就是最小的 cnt 个数,而且不需要关心它们彼此的相对顺序。
这里的 partition 是单边扫描法,也叫 Lomuto 分区。它的优点是写起来简单、不容易写出死循环;缺点是当数组中有大量重复元素时,效率会下降。后面我会讲优化。
4.3 平均 O(n) 的直觉解释
为什么平均是 O(n)?第一次 partition 需要扫描整个 n 个元素;理想情况下,pivot 落在中间位置,第二次只需要扫描 n/2 个元素;第三次 n/4……总工作量:
n + n/2 + n/4 + ... ≈ 2n
这就是 O(n) 的由来。注意这不是严格证明,但对直觉建立足够了。严格证明可以用期望来分析,结论是平均 O(n)。
但最坏情况下,如果每次 pivot 都恰好选到当前区间的最小值或最大值,每次只能排除一个元素,总工作量就是 n + (n-1) + (n-2) + ... → O(n^2)。所以快速选择并非银弹,它依赖 pivot 选得够不够好。Leetcode 的单测数据一般不会刻意卡你这种随机分布的题,但面试中如果被问到“最坏情况”,你要能答上来。
4.4 优化一:随机选择基准值
要对抗最坏情况,最简单的办法是不要固定选最右边,而是随机选一个位置当 pivot。实现上,先随机挑一个下标,把它和最右边元素交换,然后走同样的 partition 过程:
cpp复制#include <cstdlib>
#include <ctime>
int partitionWithRandom(vector<int>& nums, int left, int right) {
int randIdx = left + rand() % (right - left + 1);
swap(nums[randIdx], nums[right]);
int pivot = nums[right];
int i = left;
for (int j = left; j < right; ++j) {
if (nums[j] < pivot) {
swap(nums[i], nums[j]);
++i;
}
}
swap(nums[i], nums[right]);
return i;
}
随机化之后,最坏情况发生的概率极低。这是工程上“用随机化换稳定性能”的典型思路。
4.5 优化二:面对大量重复元素时的三路 partition
当数组里有很多重复值,比如 stock = [1, 1, 1, ..., 1, 0, 1, ...],Lomuto 分区会因为等于 pivot 的元素不断被交换而变慢。更严重的问题是,如果 pivot 值出现很多次,你很难确定到底应该把重复值归到左边还是右边,最后可能导致 partition 不平衡。
这时候可以用三路 partition(荷兰国旗问题思路),把数组分成:小于 pivot、等于 pivot、大于 pivot 三块。如果 cnt - 1 落在了等于 pivot 的区间,直接结束;如果在左边,递归左边;如果在右边,递归右边。
这里给出一个简单的三路 partition 版本:
cpp复制void threeWayPartition(vector<int>& nums, int left, int right, int& lt, int& gt) {
int pivot = nums[right];
int i = left;
lt = left;
gt = right;
while (i <= gt) {
if (nums[i] < pivot) {
swap(nums[lt], nums[i]);
++lt;
++i;
} else if (nums[i] > pivot) {
swap(nums[i], nums[gt]);
--gt;
} else {
++i;
}
}
}
这样得到 [left, lt-1] 都小于 pivot,[lt, gt] 都等于 pivot,[gt+1, right] 都大于 pivot。然后根据 cnt - 1 落在哪个区间决定递归方向。三路 partition 在处理大量重复元素的场景下,效果比普通 partition 好很多,也是 C++ std::sort 在数据重复时采用的一种优化策略。
5. 边界条件、复杂度盘点与面试追问
5.1 边界条件清单
这道题边界条件非常典型,我把它们列出来:
cnt == 0:返回空数组 / 空列表。如果不处理,会访问非法内存。cnt >= n:直接返回整个数组。排序解法直接begin() + cnt越界,但很多语言会静默返回整个数组,最好显式判断。n == 0且cnt == 0:返回空数组即可。- 数组有负数:没问题,比较大小逻辑不受影响。
- 数组有大量重复值:结果需要包含所有重复值吗?看题目要求:返回最小的 cnt 个余量。如果重复值本身是最小的那群,就需要全部包含进去。快速选择解法天然会处理重复值,排序解法也自然处理。堆解法也不会丢重复值,因为它只在严格小于堆顶时才替换。
5.2 三种解法复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 是否能应对流式数据 | 是否返回有序结果 |
|---|---|---|---|---|
| 排序 | O(n log n) | O(1) 或 O(n) 取决于语言 | 否 | 是 |
| 大顶堆 | O(n log cnt) | O(cnt) | 是,天然支持 | 否 |
| 快速选择 | 平均 O(n),最坏 O(n^2) | O(1) | 否,需要完整数组 | 否 |
这张表在面试中可以直接画给面试官看。注意堆解法虽然空间是 O(cnt),但如果用语言的内置优先队列,实际额外分配的空间可能会有一些常数开销,但渐进复杂度还是 O(cnt)。
5.3 面试官最爱追问的四个连环问题
第一个追问:“如果数据量非常大,无法一次性载入内存,怎么办?”答案是堆。因为堆只需要 O(cnt) 内存,可以流式处理。排序必须拿到完整数据,快速选择也必须拿到完整数组,所以它们在大数据场景下直接出局。
第二个追问:“如果要求返回结果必须升序呢?”如果数据量不大,直接排序;如果数据量大,用堆挑出 Top K 之后再对堆里的 K 个元素排序,复杂度是 O(n log cnt + cnt log cnt)。
第三个追问:“给你一个数据流,需要在任意时刻都能回答当前 Top K,怎么办?”还是堆。只需要维护一个固定大小为 cnt 的大顶堆,实时更新。快速选择做不到,因为你不知道未来的数据长什么样。
第四个追问:“如果 K 也是动态变化的呢?”那堆就不太好办了,因为堆的大小是固定的。想看动态 K,可能要引入一些更复杂的数据结构,比如两个堆 / 平衡树,但这就超出这道题的范围了。
我面试别人时,会特别留意候选人能不能把“堆的维护逻辑”讲清楚。很多人能写代码,但问他“为什么堆满了之后新元素比堆顶小才替换”,他答不上来。其实答案就一句话:堆顶是当前 K 个最小元素中最大的那个,所以新元素只有比这个最大的小,才有资格晋级;如果新元素比它大,那它肯定不属于最小的 K 个。这句话能说出来,说明你是真懂,而不是背题。
6. 从题目到工程:Top K 的真实落地场景
6.1 库存管理里的 Top K,到底在管什么
“库存管理 III”这个题目名不是随便起的。真实仓库里,库存余量少的商品往往是需要补货的、临期的、滞销的,或者热销到断货的。你想快速找出库存最少的 100 个 SKU,这就是 Top K 问题。仓库数据每天更新,SKU 数量可能上百万,你不可能每天全量排序一次,更合理的做法是维护一个小堆,让每次数据更新都实时更新这个 Top K 列表。
这时候堆解法的优势就很明显:不用等全量数据准备好,来一条数据处理一条,堆里始终是最少的 K 个。你可以把这个堆接到实时报表系统里,让运营每天打开后台就能看到“最需要关注的 100 个 SKU”。
6.2 更广阔的应用
如果你把这道题吃透,你会发现 Top K 思想在很多系统工程里都能用上:
- 日志系统里,筛选出现频率最高的 N 个错误码;
- 推荐系统里,给每个用户维护“近期最可能点击的 N 个商品”;
- 监控系统里,找出耗时最高的 N 个请求;
- 排行榜系统里,维护全服前 100 名玩家。
这些场景共同的特点是:数据实时到来、总量不可预知、K 相对较小。在这种约束下,排序不现实,快速选择也需要缓存全量数据,只有堆能优雅地解决问题。
6.3 刷题之外的思维方式
这道题给我最大的收获不是某个代码模板,而是“从需求中识别问题本质”的能力。题目给你一层“库存管理”的场景包装,底下真正考的是你熟不熟悉 Top K。类似的还有“数据流中的中位数”、“前 K 个高频元素”、“数组中的第 K 个最大元素”,本质都是一家子。
识别出问题本质之后,你还需要根据数据规模选解法:数据小直接排序;数据大且内存够但要求低延迟,选快速选择;数据可能是流式的,选堆。这不是背题,而是工程里的 trade-off 思维。
就我个人刷题习惯来说,这道题我至少重新写了三遍:第一遍排序,第二遍堆,第三遍快速选择。每写一遍,对 partition 和堆操作的理解都会加深一档。不要满足于“过了”,要在过了之后把其他解法也写出来,复杂度也说清楚,面试时才不会露怯。
