看到“IPO”这个题名,第一次刷 LeetCode 的人通常会以为要补一堆金融知识,其实它就是一道非常经典的贪心 + 优先队列题。我当年在面试里也遇到过几乎一样的变形:你是手握一笔初始资金的投资人,每个项目有自己的启动门槛和预期收益,最多投 k 个项目,怎么投才能让最终手里的钱最多。LeetCode 502 把这个问题包装成了公司上市前的资本运作场景,但剥掉外壳,核心是“动态可达集合里选最优”的贪心决策模型。这道题很适合作为优先队列的进阶练习题,因为它不像 Top K 问题那样直接让你用堆,而是要你自己想到用堆去维护“当前能做的项目里利润最大的那个”。
这道题的难点有两个:一是能不能看穿贪心策略,二是能不能把“每次都要重新找可做项目”这个过程优化到 O((n + k) log n)。这篇文章我会从暴力模拟讲起,因为先把正确解写出来,再去优化,是刷算法题最稳的路径。然后再给标准解法“排序 + 大顶堆”,附 C++ 和 Python 双版本代码,最后把边界情况、面试表述和一些延伸题目都过一遍。
1. 先把这个题彻底读透:IPO 到底在考什么
1.1 原题描述与示例
题目给了两个长度相同的数组profits和capital,profits[i]表示第 i 个项目的纯利润,capital[i]表示启动这个项目至少需要多少资本。你初始有w资本,最多能完成k个项目。规则是:只要当前资本w >= capital[i],就可以做第 i 个项目,做完后资本变成w + profits[i],然后这个项目不能重复做。问最终能获得的最大资本是多少。
举个例子,k = 2, w = 0, profits = [1, 2, 3], capital = [0, 1, 1]。初始资金是 0,所以只能做项目 0(门槛 0,利润 1)。做完后w = 1,此时项目 1 和项目 2 都解锁了,它们利润分别是 2 和 3,选利润更大的项目 2,做完后w = 4。最终答案是 4。如果你第一步就贪心地选一个当前做不了的项目,那什么都做不成。
这题返回值是最终资本,不是最大利润总和,所以每一步的收益会直接影响后续可选项目集合,这是它和“背包问题”最大的区别:背包的容量是固定的,这里你的“容量”会因为选择而变化,而且是单调增加的。
1.2 为什么这不是一道简单排序题
很多人第一反应是“把项目按利润从大到小排个序,从前往后做不就行了”。这个思路在“所有项目一开始都能做”的条件下成立,但本题里项目有门槛,一个利润 100 但要求启动资金 200 的项目,在你只有 50 资金时就是不可达的。等你能做到它时,可用的项目可能已经变了。
按资本门槛排序呢?也不行。假设w = 1, k = 2,项目 A 门槛 1 利润 1,项目 B 门槛 2 利润 100,项目 C 门槛 2 利润 99。按门槛排序你会先做 A,得到w = 2,然后可以做 B,最终w = 102。这个例子里按门槛排序恰好是对的,但如果项目 A 变成门槛 1 利润 0,项目 B 是门槛 1 利润 5,项目 C 是门槛 6 利润 100,按门槛排序要先从门槛 1 的项目里选一个做,这时候必须选利润更大的 B,否则做完 A 还是只有 1,做不了 C。所以关键不是“按门槛排完就顺序做”,而是在每一个“当前资金”下,从所有可达项目里挑利润最大的那个。
这也是题目的核心模型:你有多个阶段,每个阶段开始时,资金决定了一个可达项目集合,你要从这个集合里挑一个项目执行,然后资金增加,解锁更多项目,进入下一阶段。“每一步都在当前可达集合里选最优”正是贪心策略可以落地的场景。
1.3 题目给你的隐藏信息
题目里有一个容易被忽略的好性质:做完一个项目后,资金只会增加或不变(题目默认利润非负),不会减少。因为资金单调不减,所以“可达项目集合”只会越来越大,不会越来越小。这个单调性非常关键,它意味着我们不需要每轮重新检查所有项目是否可达,而是可以用一个指针按资本门槛从小到大推进,把新增可达项目不断“解锁”出来。
另一个隐藏信息是项目不能重复做。如果你用“把所有项目按利润放进堆里,每轮取出利润最大的”这种做法,很可能会重复取出同一个项目。所以标准做法里要维护一个“尚未完成”的候选池,从堆里弹出项目后,这个项目就不该再回到堆里。我见过好几个同学在这上面翻车,最后算出来的资本比正确答案大很多。
k 和 n 的关系也要注意。题目里 k 是最多能做的项目数,不一定等于数组长度。当所有可达项目都做完了但还没达到 k 时,就直接停止,返回当前资金。这个“提前终止”的逻辑很多暴力版本容易漏掉,它会直接影响时间复杂度分析和正确性。
2. 思路一:暴力模拟,先把正确解写出来
2.1 第一版代码:每轮全量扫描
最朴素的思路就是模拟真实投资过程:每一轮开始,扫一遍所有还没做的项目,找出所有capital[i] <= w的项目里profits[i]最大的那个,做掉它,更新资金,然后继续下一轮。直到已经做了 k 个项目,或者没有可做项目为止。
// 第一版:暴力模拟,时间复杂度 O(k * n) class Solution { public: int findMaximizedCapital(int k, int w, vector<int>& profits, vector<int>& capital) { int n = profits.size(); vector<bool> done(n, false); for (int round = 0; round < k; ++round) { int bestIdx = -1; int bestProfit = 0; // 题目利润非负 for (int i = 0; i < n; ++i) { if (done[i]) continue; if (capital[i] <= w && profits[i] > bestProfit) { bestProfit = profits[i]; bestIdx = i; } } if (bestIdx == -1) break; // 没有可做项目了 done[bestIdx] = true; w += bestProfit; } return w; } };这段代码虽然效率不高,但它对应的是题目的原始描述,不容易写错。几个细节需要注意:bestProfit初始值设 0,因为题目规约利润非负,这样“找不到可做项目”和“找了一个利润为 0 的项目”可以区分开;done数组用来标记项目是否已经被做过,避免重复选择。
2.2 暴力做法的复杂度账
每做一轮,都要遍历所有 n 个项目,选出利润最大的可达项目,所以单轮时间复杂度是 O(n)。最多做 k 轮,总时间复杂度 O(k * n)。当 n 和 k 都到 10^5 量级时,最坏情况是 10^10 次比较,这个量级在 OJ 上不可能过。
但暴力版的正确性还是能保证的,至少对一个小数据集的测试用例是没问题的。刷题时先把暴力写出来,有一个好处:你可以用它做“对拍”,验证优化版本和暴力版本在小随机数据上的输出是否一致。我个人的刷题习惯是,如果一道题一时半会儿想不出最优解,先写一个能过的朴素版本,再拿它当参照物,这样后面优化时心里有底。
2.3 从暴力里看到优化的钥匙
暴力每一轮都在重复做同一件事:扫描全数组找“可达且利润最大”。问题在于,随着资金增加,可达项目集合越来越大,但暴力不管资金怎么变,每次都从零开始扫,这就浪费了大量重复计算。
优化的突破口有两个。第一,项目按资本门槛排序后,我们可以只用一个指针不断往后推进,把新解锁的项目找出来;那些之前已经判断过“门槛大于当前资金”的项目,在资金涨上去之后才需要重新检查,但排序后指针就不回头,所以每个项目最多被检查一次。第二,已经解锁的项目需要一个数据结构来快速取出最大利润,这个数据结构就是大顶堆。这两个想法组合起来,就是标准解法。
3. 思路二:排序 + 大顶堆,标准解法完整推导
3.1 核心数据结构:资金门槛排序列表与利润大根堆
标准解法用到了两个关键结构:
按
capital[i]升序排序的项目列表。排序后,我们可以用一个指针从左往右扫,把所有capital[i] <= w的项目“解锁”出来。因为资金只增不减,这个指针永远不需要回退,每个项目只会被解锁一次。一个大顶堆
available,用来存放所有已经解锁、还没做的项目的利润。每次解锁一批新项目后,堆顶就是当前所有可达项目里利润最大的那个。从堆顶取项目做掉,资金增加,然后再解锁下一批。
为什么是大顶堆而不是别的东西?因为我们需要反复做两类操作:插入一个“新解锁的项目”、取出“当前最大值”。大顶堆的插入和删除堆顶都是 O(log n),足够快。如果用一个有序数组维护,虽然取出最大值是 O(1),但插入一个元素需要 O(n) 时间;如果用普通数组,插入 O(1) 但取最大值要 O(n) 扫描。堆正好是这两者之间的平衡点,也是“动态集合中反复找最值”问题的最常用工具。
3.2 C++ 实现
class Solution { public: int findMaximizedCapital(int k, int w, vector<int>& profits, vector<int>& capital) { int n = profits.size(); vector<pair<int, int>> projects; // {capital, profit} for (int i = 0; i < n; ++i) { projects.emplace_back(capital[i], profits[i]); } sort(projects.begin(), projects.end()); priority_queue<int> pq; // 大顶堆,存利润 int idx = 0; for (int round = 0; round < k; ++round) { // 把所有当前资金能启动的项目解锁 while (idx < n && projects[idx].first <= w) { pq.push(projects[idx].second); ++idx; } if (pq.empty()) break; // 没有可做项目 w += pq.top(); pq.pop(); } return w; } };这段代码非常短,但要理解每一行为什么存在。projects.emplace_back(capital[i], profits[i])把资本和利润绑定在一起排序,而不是分别存两个数组,否则排序后你还要维护两个数组之间的对应关系,比较容易错。sort默认按 pair 的第一个元素升序,第一个元素相同则按第二个元素升序,这种情况不影响正确性。while循环负责解锁新一轮资金能做的项目,解锁条件用的是当前w,因为在堆里已经做了某个高利润项目后,w会变大,下一轮while自然会把更多项目放进堆。
3.3 Python 实现
Python 的标准库heapq默认是小顶堆,所以存利润时要把利润取负数,取出时再取负回来。逻辑和 C++ 版本完全一致:
import heapq class Solution: def findMaximizedCapital(self, k: int, w: int, profits: List[int], capital: List[int]) -> int: n = len(profits) projects = sorted(zip(capital, profits)) pq = [] # 大顶堆,存负利润 idx = 0 for _ in range(k): while idx < n and projects[idx][0] <= w: heapq.heappush(pq, -projects[idx][1]) idx += 1 if not pq: break w += -heapq.heappop(pq) return w这里有一个很容易踩的坑:Python 直接用heapq.heappush(pq, -profit)时,如果两个项目利润相同,堆里会先弹出哪个?这不影响最终结果,因为利润相同的项目带来的收益一样。但如果你要复现“和 C++ 完全一致”的执行过程,就不要指望它按项目 id 排序了。
3.4 复杂度与代码细节
排序部分时间复杂度 O(n log n)。随后最多执行 k 轮,每一轮会有一个while循环解锁项目,整个算法里每个项目最多被push一次,所以总的push次数是 O(n),总的pop次数是 O(k)。每轮堆操作 O(log n),总时间复杂度 O((n + k) log n)。空间复杂度是 O(n),主要花在排序列表和堆上。
第 19 行(C++ 的if (pq.empty()) break;)是最容易被忽略的一处。如果当前资金不足以解锁任何新项目,而且堆里也没有已经解锁但未做的项目,说明你已经做到做无可做的地步了,再循环下去只会空转。这个 break 和暴力版的bestIdx == -1是同一个逻辑,但用堆实现时代码短了很多,需要你自己意识到这个终止条件存在。
4. 贪心为什么是对的:一次严格的证明
4.1 交换论证:选最大利润项目永远不会亏
面试时你说“每轮选当前能做且利润最大的项目”,面试官通常会追问一句:为什么贪心是对的?这里可以用交换论证来回答。
假设在某一步,你的资金是 w,当前可达项目集合是 S。设最优解在这一步选择了项目 a,而贪心策略选择了项目 b,b 是 S 中利润最大的项目,所以有profit[b] >= profit[a]。现在我们在最优解里把 a 换成 b,其他后续选择都不变。由于 b 的利润不小于 a 的利润,做完 b 后的资金w + profit[b]不少于做完 a 后的资金w + profit[a]。后续最优解里原本安排的每个项目,既然在资金w + profit[a]下能做,那么在资金w + profit[b]下也一定能做,因为资金更多,门槛更容易满足。所以替换后的方案不会比原最优解差。每一轮都做这种替换,最终可以证明贪心解等于某个最优解。
这个证明的成立依赖两个前提:项目之间没有依赖关系,以及资金单调不减。如果某个项目做掉后会导致其他项目不可做,这个论证就失效了,但本题没有这种约束。
4.2 边界推演:为什么指针只扫一遍就够
标准解法里有个细节:while (idx < n && projects[idx].first <= w)这个循环,为什么不在外层 for 循环的一开始把所有小于当前 w 的项目都 push 进去?因为 push 完一批后,w 可能因为做项目而变大,会解锁更多,所以 while 必须放在每一轮里,用最新 w 去解锁。但为什么整个算法里idx不用回退?因为projects按capital升序排列后,如果某个项目的门槛大于当时的 w,那么它后面的项目门槛只会更大,也不可达;而当 w 变大以后,我们再从当前idx继续往后检查,之前已经检查过的项目都已经解锁过了,不需要重新检查。这个“指针单调向右”的性质,保证了每个项目只被判断一次,时间复杂度才能压到 O(n log n)。
如果不想排序,用另一个小顶堆存{capital, profit},每轮从这个小顶堆里弹出所有capital <= w的项目,也可以达到类似效果,但每次都要重构堆或重复弹出,逻辑没有排序后指针扫描清晰,所以实战中我更喜欢排序 + 单指针。
4.3 一个反直觉的例子
我再给一个例子,说明“看似收益差不多的项目,交错选择可能差异很大”。设w = 2, k = 2,项目 A:门槛 1,利润 2;项目 B:门槛 2,利润 3;项目 C:门槛 4,利润 10。按贪心,第一轮资金 2,可做 A 和 B,B 利润 3 最大,做 B,w = 5,第二轮做 C,w = 15。如果第一轮贪心地做 A(因为 A 门槛低容易被先注意到),w = 4,第二轮只能做 C,w = 14。只差 1,但方向错了。
这个例子也说明:门槛低的项目不一定先做,只有“在可达集合里利润最大”的项目才值得先做。这道题里没有“必须先做低门槛项目来解锁高门槛项目”的硬性前提,因为只要资金够了,高门槛项目自然解锁,而做低门槛低利润项目反而浪费了一轮机会,拉低了资金增长的速度。
5. 实战中的边界情况与排查技巧
5.1 边界 case 速查表
我在本地调试这道题时,会准备一组典型输入,覆盖最常见的坑。
| 场景 | 输入示例 | 预期结果 | 说明 |
|---|---|---|---|
| 初始资金做不了任何项目 | k=3, w=5, profits=[1,2], capital=[6,7] | 5 | 直接返回初始资金 |
| 可做项目数不足 k | k=5, w=1, profits=[1], capital=[0] | 2 | 做完唯一项目后就 break |
| 有利润为 0 的项目 | k=2, w=0, profits=[0,5], capital=[0,0] | 5 | 先做利润 5 的,0 利润项目做不做不影响结果 |
| 门槛全部为 0 | k=2, w=0, profits=[3,1], capital=[0,0] | 4 | 等价于从所有项目里按利润从大到小取 k 个 |
| 高利润高门槛项目最后才解锁 | k=1, w=1, profits=[100], capital=[2] | 1 | 只有一轮,做不了门槛 2 的项目 |
这些 case 我在 LeetCode 提交前都会先跑一遍。尤其是一个容易混淆的地方:如果一个利润为 0 的项目是当前唯一可达项目,那么做了它资金不变,下一轮它的可达集合也不会变大,所以它本质上是一个“可做可不做”的项目。算法里因为堆空会导致 break,所以不会死循环。
5.2 语言层面的坑
C++ 优先队列默认是大顶堆priority_queue<int>,取堆顶是top()后pop(),不要和栈的top搞混。Python 的heapq是小顶堆,必须存负值,而且heappop返回的是负数,要记得再取一次负号。
如果利润和资本可能很大,比如题目范围扩展到 10^9,w + profit可能溢出 int。保险的做法是用long long。LeetCode 原题里 int 一般够用,但面试手写时我会直接用 long long,省得在边界被问翻。
还有一个我踩过的坑:vector<pair<int,int>> projects排序时,如果两个项目的资本一样,会按利润升序排。这没问题,因为while会把所有资本小于等于 w 的项目一次性全部 push 进堆,和它们在数组里的相对顺序无关。但如果你在while内部做的是“push 一个就 pop 一个”,那就需要注意顺序了,好在标准解法不做这种操作。
5.3 面试现场怎么说
这道题在面试里出现时,我建议按这个顺序表达:
- 先复述题意,确认 k、w、profits、capital 的含义,问清楚项目是否可以重复做。通常不能重复做。
- 说一句“这题可以用贪心 + 优先队列。每轮我们只需要从所有能启动的项目里选利润最大的,因为利润越大后续资金越多,可达项目集合只会变大,所以不会亏。”
- 再讲数据结构:项目按资本排序,用大顶堆维护当前可达项目的利润。
- 讲时间复杂度 O((n + k) log n),空间 O(n)。
- 最后跑一下题目给的示例,甚至可以口头推演。
这样讲比直接丢代码清晰很多。面试官如果追问“为什么贪心是对的”,就把上一节的交换论证讲出来。我见过不少候选人卡在“知道用堆但说不清为什么”,代码写完但解释含糊,这很可惜。
6. 题型变体与延伸练习
6.1 同一思路的其他题
“排序 + 优先队列,每轮解锁一批候选,从中取最优”这个套路在 LeetCode 里出现频率很高,典型的有:
- LeetCode 630 课程表 III:每门课有持续时间和截止时间,最多能上多少门课。这题也是先按截止时间排序,用小顶堆维护已选课程的持续时间,超过截止时间时就弹出耗时最长的课,本质是“在解锁的候选里淘汰代价最大”。
- LeetCode 871 最低加油次数:沿途每个加油站能加一定油量,求最少加油次数到达终点。每次经过加油站就把油量加入大顶堆,油不够时从堆里取最大的油,和 IPO 的“解锁 + 取最大”可以说是一个模子。
- LeetCode 1353 最多可以参加的会议数目:每天只能参加一个会议,每个会议有开始和结束时间,按开始时间排序,每一天把当天开始的会议加入堆,然后参加结束时间最早的会议。这对应的是“候选集合动态变化”的另一个应用方向。
刷完这四道题,你会对“为什么很多最优解问题要配一个优先队列”有比较深的体感:因为这些问题都需要在动态变化的候选集合里快速做出某种“极值选择”,而堆就是为这种场景准备的数据结构。
热门 100 题里的“合并 K 个升序链表”“数组中的第 K 个最大元素”也是优先队列考点,但它们更偏向堆的基础用法。IPO 的价值在于它把堆和排序、贪心结合起来,是一个综合题。
6.2 从这道题看优先队列的刷题姿势
如果你刚开始刷优先队列,我建议不要只背 API,而是先想清楚三个问题:维护的集合是什么?集合为什么是动态变化的?每一步需要在集合里做哪种极值操作?对 IPO 来说,集合是“当前可达且未做的项目”,变化原因是资金增加解锁新项目,需要的操作是“取利润最大值”。想清楚这三件事,代码自然就写出来了。
还有一个小技巧:解这类题时,先手动推演一遍示例,把“每轮解锁了哪些项目、堆里有哪些值、弹出哪个”记录下来。我刷题时经常在草稿纸上画这样一个三列表格:轮次、解锁的项目、堆里的利润。IPO 这道题推演一遍基本就不会写错边界条件了。
最后再分享一个我个人的体会:这道题如果第一次见面没做出来,不用沮丧,因为“贪心 + 优先队列”的组合本来就需要多次见题才能形成条件反射。关键是做完之后,把“当前可达集合中取最优”这个抽象模型记住。下次再遇到“每个阶段有新的候选、需要从中选一个最优”的题,不管包装成投资、上课还是加油,你都能第一时间想到堆。这个套路的价值远大于这一道题本身。