在算法刷题中,贪心算法结合堆(优先队列)的题目往往是面试高频考点,LeetCode 502. IPO 就是一道典型的这类题目。它不仅考察我们对贪心策略的理解,还需要灵活运用堆来优化时间复杂度,今天我们就来详细拆解这道题,同时分析两种不同解法的思路、代码细节以及各自的优劣。
一、题目解读:核心需求与约束
先明确题目核心:力扣要在IPO前完成最多k个不同项目,初始资本为w,每个项目有「启动资本」和「纯利润」,完成项目后利润会加入总资本,目标是最大化最终资本。
关键约束梳理:
-
最多完成k个项目(可以少于k个,若没有可启动的项目则提前终止);
-
每个项目只能做一次;
-
启动项目的前提是当前资本 ≥ 该项目的最小启动资本;
-
利润是纯利润,直接累加,无需扣除启动资本(这一点很关键,简化了计算)。
举个简单例子:若初始资本w=0,k=1,profits=[1,2,3],capital=[0,1,2],最优选择是先做启动资本为0、利润1的项目,最终资本变为1,无法再做其他项目,最终结果就是1。
二、解法一:暴力贪心(简单易理解,效率较低)
1. 思路分析
贪心策略的核心的是:每次都选择当前资本能启动的项目中,利润最大的那个。因为只有每次都选利润最大的,才能保证最终资本最大化,这是局部最优推导全局最优的典型思路。
具体步骤:
-
将每个项目的「利润、启动资本、是否已做」三个信息组合成数组,方便后续操作;
-
将所有项目按「利润从大到小」排序,优先考虑高利润项目;
-
循环k次(最多k个项目),每次遍历所有项目,找到「未做过、且启动资本 ≤ 当前资本」的第一个项目(因为已按利润排序,第一个就是当前能做的最大利润项目);
-
完成该项目,更新当前资本,标记项目为已做,进入下一轮循环;
-
k次循环结束后,返回当前资本。
2. 代码实现(TypeScript)
function findMaximizedCapital_1(k: number, w: number, profits: number[], capital: number[]): number {
const arr: number[][] = new Array();
for (let i = 0; i < profits.length; i++) {
// 存储格式:[利润, 启动资本, 是否已做(0未做,1已做)]
arr.push([profits[i], capital[i], 0]);
}
// 按利润从大到小排序,优先选高利润
arr.sort((a, b) => b[0] - a[0]);
let res: number = w;
while (k > 0) {
let found = false;
for (let i = 0; i < profits.length; i++) {
// 找到当前能启动且未做过的项目
if (arr[i][1] <= res && !arr[i][2]) {
res += arr[i][0];
arr[i][2] = 1; // 标记为已做
found = true;
break; // 找到最大利润项目,退出循环
}
}
if (!found) break; // 没有可做的项目,提前终止
k--;
}
return res;
};
3. 优缺点分析
优点:思路简单,代码易写,不需要额外的数据结构,适合初学者理解贪心思想;
缺点:时间复杂度较高。排序时间为O(nlogn),每次循环k都要遍历n个项目,总时间复杂度为O(nlogn + k*n)。当n和k都很大时(比如n=1e5,k=1e5),会超时,无法通过所有测试用例。
三、解法二:贪心 + 最大堆(最优解法,效率拉满)
解法一的痛点是「每次找最大利润项目都要遍历所有项目」,效率太低。我们可以用「最大堆」来优化这一步——最大堆的特点是能在O(1)时间获取最大值,O(logn)时间插入和删除,正好解决“找最大利润”的效率问题。
1. 思路分析
核心思路不变(每次选当前能启动的最大利润项目),但用堆优化“找最大利润”的过程,具体步骤调整如下:
-
将每个项目按「启动资本从小到大」排序,这样可以按顺序将“当前资本能启动的项目”全部加入堆;
-
创建一个最大堆(存储利润),用于维护当前所有可启动项目的利润;
-
循环k次:
-
将所有「启动资本 ≤ 当前资本」的项目的利润加入最大堆(因为项目按启动资本排序,一旦遇到启动资本大于当前资本的,后面的都不行,可终止遍历);
-
若堆为空,说明没有可启动的项目,提前终止循环;
-
弹出堆顶元素(最大利润),加入当前资本;
-
-
循环结束后,返回当前资本。
这里有个关键细节:项目按启动资本排序后,我们只需要遍历一次项目数组,因为每次资本增加后,能启动的项目只会增多,不会减少,无需重复遍历。
2. 代码实现(TypeScript)
首先实现最大堆(TypeScript没有内置堆,需手动实现):
class MyMaxHeap {
private heap: number[];
constructor() { this.heap = []; }
// 插入元素,插入后维护最大堆性质
push(val: number) {
this.heap.push(val);
this.shiftUp(this.heap.length - 1);
}
// 弹出堆顶(最大元素),弹出后维护最大堆性质
pop(): number {
const max = this.heap[0];
const last = this.heap.pop()!;
if (this.heap.length) {
this.heap[0] = last;
this.shiftDown(0);
}
return max;
}
// 判断堆是否为空
isEmpty(): boolean { return this.heap.length === 0; }
// 向上调整:插入元素后,向上冒泡,确保父节点大于子节点
private shiftUp(i: number) {
while (i > 0) {
const p = (i - 1) >> 1; // 父节点索引(等价于Math.floor((i-1)/2))
if (this.heap[p] >= this.heap[i]) break;
// 交换父节点和子节点
[this.heap[p], this.heap[i]] = [this.heap[i], this.heap[p]];
i = p; // 继续向上调整
}
}
// 向下调整:弹出堆顶后,向下冒泡,确保父节点大于子节点
private shiftDown(i: number) {
const len = this.heap.length;
while (true) {
let l = i * 2 + 1, r = i * 2 + 2, max = i;
// 找到左、右子节点中最大的那个
if (l < len && this.heap[l] > this.heap[max]) max = l;
if (r < len && this.heap[r] > this.heap[max]) max = r;
if (max === i) break; // 父节点已是最大,无需调整
// 交换父节点和最大子节点
[this.heap[i], this.heap[max]] = [this.heap[max], this.heap[i]];
i = max; // 继续向下调整
}
}
}
然后实现核心算法:
function findMaximizedCapital_2(k: number, w: number, profits: number[], capital: number[]): number {
const n = profits.length;
// 存储项目:[启动资本, 利润]
const projects: [number, number][] = [];
for (let i = 0; i < n; i++) {
projects.push([capital[i], profits[i]]);
}
// 按启动资本从小到大排序
projects.sort((a, b) => a[0] - b[0]);
const heap = new MyMaxHeap();
let cur = w; // 当前资本
let idx = 0; // 记录已加入堆的项目索引,避免重复加入
for (let i = 0; i < k; i++) {
// 将所有当前资本能启动的项目加入最大堆
while (idx < n && projects[idx][0] <= cur) {
heap.push(projects[idx][1]);
idx++;
}
// 没有可做的项目,提前终止
if (heap.isEmpty()) break;
// 加入最大利润
cur += heap.pop();
}
return cur;
};
3. 优缺点分析
优点:时间复杂度最优。排序时间O(nlogn),堆的插入和弹出操作各为O(logn),总操作次数为O(n + k),因此总时间复杂度为O(nlogn + klogn),能轻松应对大数据量(n和k达1e5也能通过);
缺点:需要手动实现最大堆,对堆的原理掌握要求较高,代码量稍多。
四、两种解法对比与总结
| 解法 | 时间复杂度 | 空间复杂度 | 核心亮点 | 适用场景 |
|---|---|---|---|---|
| 暴力贪心 | O(nlogn + k*n) | O(n) | 思路简单,代码简洁 | 小数据量、初学者理解贪心思想 |
| 贪心+最大堆 | O(nlogn + klogn) | O(n) | 效率最优,贴合面试考点 | 大数据量、面试实战 |
五、关键知识点与面试提示
-
贪心策略:这道题的贪心选择是“每次选当前可启动的最大利润项目”,核心逻辑是“局部最优→全局最优”,这类问题的关键是证明贪心策略的正确性(本题可通过反证法证明:若某次不选最大利润项目,最终资本会更小);
-
堆的应用:最大堆用于快速获取最大值,解决“找最优解”的效率问题,类似题目还有「最大滑动窗口」「任务调度」等;
-
边界处理:需要注意“k大于项目数”“当前资本无法启动任何项目”等边界情况,避免死循环或错误结果;
-
面试考点:面试官可能会先让你写出暴力解法,再引导你优化到堆的解法,考察你对时间复杂度的优化能力,以及堆的实现细节。