news 2026/9/15 7:43:19

LeetCode 502 IPO解析:贪心算法+大顶堆求解最大资本

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 502 IPO解析:贪心算法+大顶堆求解最大资本

看到“IPO”这个题名,第一次刷 LeetCode 的人通常会以为要补一堆金融知识,其实它就是一道非常经典的贪心 + 优先队列题。我当年在面试里也遇到过几乎一样的变形:你是手握一笔初始资金的投资人,每个项目有自己的启动门槛和预期收益,最多投 k 个项目,怎么投才能让最终手里的钱最多。LeetCode 502 把这个问题包装成了公司上市前的资本运作场景,但剥掉外壳,核心是“动态可达集合里选最优”的贪心决策模型。这道题很适合作为优先队列的进阶练习题,因为它不像 Top K 问题那样直接让你用堆,而是要你自己想到用堆去维护“当前能做的项目里利润最大的那个”。

这道题的难点有两个:一是能不能看穿贪心策略,二是能不能把“每次都要重新找可做项目”这个过程优化到 O((n + k) log n)。这篇文章我会从暴力模拟讲起,因为先把正确解写出来,再去优化,是刷算法题最稳的路径。然后再给标准解法“排序 + 大顶堆”,附 C++ 和 Python 双版本代码,最后把边界情况、面试表述和一些延伸题目都过一遍。

1. 先把这个题彻底读透:IPO 到底在考什么

1.1 原题描述与示例

题目给了两个长度相同的数组profitscapitalprofits[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不用回退?因为projectscapital升序排列后,如果某个项目的门槛大于当时的 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直接返回初始资金
可做项目数不足 kk=5, w=1, profits=[1], capital=[0]2做完唯一项目后就 break
有利润为 0 的项目k=2, w=0, profits=[0,5], capital=[0,0]5先做利润 5 的,0 利润项目做不做不影响结果
门槛全部为 0k=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 面试现场怎么说

这道题在面试里出现时,我建议按这个顺序表达:

  1. 先复述题意,确认 k、w、profits、capital 的含义,问清楚项目是否可以重复做。通常不能重复做。
  2. 说一句“这题可以用贪心 + 优先队列。每轮我们只需要从所有能启动的项目里选利润最大的,因为利润越大后续资金越多,可达项目集合只会变大,所以不会亏。”
  3. 再讲数据结构:项目按资本排序,用大顶堆维护当前可达项目的利润。
  4. 讲时间复杂度 O((n + k) log n),空间 O(n)。
  5. 最后跑一下题目给的示例,甚至可以口头推演。

这样讲比直接丢代码清晰很多。面试官如果追问“为什么贪心是对的”,就把上一节的交换论证讲出来。我见过不少候选人卡在“知道用堆但说不清为什么”,代码写完但解释含糊,这很可惜。

6. 题型变体与延伸练习

6.1 同一思路的其他题

“排序 + 优先队列,每轮解锁一批候选,从中取最优”这个套路在 LeetCode 里出现频率很高,典型的有:

  • LeetCode 630 课程表 III:每门课有持续时间和截止时间,最多能上多少门课。这题也是先按截止时间排序,用小顶堆维护已选课程的持续时间,超过截止时间时就弹出耗时最长的课,本质是“在解锁的候选里淘汰代价最大”。
  • LeetCode 871 最低加油次数:沿途每个加油站能加一定油量,求最少加油次数到达终点。每次经过加油站就把油量加入大顶堆,油不够时从堆里取最大的油,和 IPO 的“解锁 + 取最大”可以说是一个模子。
  • LeetCode 1353 最多可以参加的会议数目:每天只能参加一个会议,每个会议有开始和结束时间,按开始时间排序,每一天把当天开始的会议加入堆,然后参加结束时间最早的会议。这对应的是“候选集合动态变化”的另一个应用方向。

刷完这四道题,你会对“为什么很多最优解问题要配一个优先队列”有比较深的体感:因为这些问题都需要在动态变化的候选集合里快速做出某种“极值选择”,而堆就是为这种场景准备的数据结构。

热门 100 题里的“合并 K 个升序链表”“数组中的第 K 个最大元素”也是优先队列考点,但它们更偏向堆的基础用法。IPO 的价值在于它把堆和排序、贪心结合起来,是一个综合题。

6.2 从这道题看优先队列的刷题姿势

如果你刚开始刷优先队列,我建议不要只背 API,而是先想清楚三个问题:维护的集合是什么?集合为什么是动态变化的?每一步需要在集合里做哪种极值操作?对 IPO 来说,集合是“当前可达且未做的项目”,变化原因是资金增加解锁新项目,需要的操作是“取利润最大值”。想清楚这三件事,代码自然就写出来了。

还有一个小技巧:解这类题时,先手动推演一遍示例,把“每轮解锁了哪些项目、堆里有哪些值、弹出哪个”记录下来。我刷题时经常在草稿纸上画这样一个三列表格:轮次、解锁的项目、堆里的利润。IPO 这道题推演一遍基本就不会写错边界条件了。

最后再分享一个我个人的体会:这道题如果第一次见面没做出来,不用沮丧,因为“贪心 + 优先队列”的组合本来就需要多次见题才能形成条件反射。关键是做完之后,把“当前可达集合中取最优”这个抽象模型记住。下次再遇到“每个阶段有新的候选、需要从中选一个最优”的题,不管包装成投资、上课还是加油,你都能第一时间想到堆。这个套路的价值远大于这一道题本身。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 7:42:39

JavaScript内存管理实战:V8堆栈原理与泄漏修复

1. 这不是“理论课”&#xff0c;是前端工程师每天都在面对的内存战场JavaScript 内存问题&#xff0c;从来不是教科书里那个“自动垃圾回收所以不用管”的温柔童话。它是一场无声的消耗战——你刚打开一个电商详情页&#xff0c;Chrome 任务管理器里 tab 进程就稳稳吃掉 800MB…

作者头像 李华
网站建设 2026/9/15 7:40:21

SpringBoot+BS架构文献搜索系统设计与优化

1. 项目概述&#xff1a;文献搜索系统的技术选型与核心价值这个基于SpringBootBS架构的文献搜索系统&#xff0c;本质上是一个面向学术场景的垂直搜索引擎。我在实际开发中发现&#xff0c;相比通用搜索引擎&#xff0c;专业文献检索需要解决三个核心问题&#xff1a;一是对PDF…

作者头像 李华
网站建设 2026/9/15 7:39:39

架构图与流程图设计指南:diagram-design 降低认知成本的完整方法

入行这些年&#xff0c;我在各种文档里见过太多结构混乱、配色随意的架构图和流程图。明明是同一个系统&#xff0c;不同人画出来完全没法看。有人以为 diagram-design 就是把几个方框拖到画布上、拉几条线连起来就算完工&#xff0c;但等到评审会上所有人盯着屏幕发懵的时候&a…

作者头像 李华
网站建设 2026/9/15 7:38:50

MATLAB ode45求解微分方程全攻略:原理、参数与实战

简介&#xff1a;围绕MATLAB求解常微分方程初值问题的核心函数ode45&#xff0c;这份资料系统性整理了函数调用格式、dydt方程定义、参数设置、指定输出点、事件检测、多输出系统等关键用法&#xff0c;并配有可运行的.m示例脚本。ode45基于经典四阶龙格-库塔方法&#xff0c;适…

作者头像 李华
网站建设 2026/9/15 7:38:49

diagram-design 进阶指南:从代码化绘图到架构可视化体系

diagram-design 这个词&#xff0c;你在 GitHub 上能看到一堆同名仓库&#xff0c;在 Figma 社区里也能搜到同名插件&#xff0c;但真要问一句“它到底是干什么的”&#xff0c;十个人能给你八个答案。我自己折腾了几年架构图、流程图、时序图&#xff0c;从最开始的 Visio 画到…

作者头像 李华
网站建设 2026/9/15 7:38:46

diagram-design:用可维护的可视化图谱提升工程沟通效率

1. 什么是 diagram-design&#xff1a;从一张图讲清楚它到底在解决什么问题 diagram-design 不是某个具体软件的名字&#xff0c;也不是某段神秘代码的代号&#xff0c;而是一套围绕“可视化表达逻辑关系”展开的完整工作流。它解决的是一个非常古老但至今依然高频、高痛的问题…

作者头像 李华