LeetCode 502. IPO:两种思路搞定最大资本最大化问题

0 阅读7分钟

在算法刷题中,贪心算法结合堆(优先队列)的题目往往是面试高频考点,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. 思路分析

贪心策略的核心的是:每次都选择当前资本能启动的项目中,利润最大的那个。因为只有每次都选利润最大的,才能保证最终资本最大化,这是局部最优推导全局最优的典型思路。

具体步骤:

  1. 将每个项目的「利润、启动资本、是否已做」三个信息组合成数组,方便后续操作;

  2. 将所有项目按「利润从大到小」排序,优先考虑高利润项目;

  3. 循环k次(最多k个项目),每次遍历所有项目,找到「未做过、且启动资本 ≤ 当前资本」的第一个项目(因为已按利润排序,第一个就是当前能做的最大利润项目);

  4. 完成该项目,更新当前资本,标记项目为已做,进入下一轮循环;

  5. 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. 思路分析

核心思路不变(每次选当前能启动的最大利润项目),但用堆优化“找最大利润”的过程,具体步骤调整如下:

  1. 将每个项目按「启动资本从小到大」排序,这样可以按顺序将“当前资本能启动的项目”全部加入堆;

  2. 创建一个最大堆(存储利润),用于维护当前所有可启动项目的利润;

  3. 循环k次:

    • 将所有「启动资本 ≤ 当前资本」的项目的利润加入最大堆(因为项目按启动资本排序,一旦遇到启动资本大于当前资本的,后面的都不行,可终止遍历);

    • 若堆为空,说明没有可启动的项目,提前终止循环;

    • 弹出堆顶元素(最大利润),加入当前资本;

  4. 循环结束后,返回当前资本。

这里有个关键细节:项目按启动资本排序后,我们只需要遍历一次项目数组,因为每次资本增加后,能启动的项目只会增多,不会减少,无需重复遍历。

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大于项目数”“当前资本无法启动任何项目”等边界情况,避免死循环或错误结果;

  • 面试考点:面试官可能会先让你写出暴力解法,再引导你优化到堆的解法,考察你对时间复杂度的优化能力,以及堆的实现细节。