AIGC标识 Hot100速记-第三部分

Hot 100 速记(第三部分)

十三、堆

215. 数组中的第 K 个最大元素

题面

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

需要找的是数组排序后的第 k 个最大元素,而不是第 k 个不同的元素。

要求设计并实现时间复杂度为 O(n) 的算法。

示例 1

输入:nums = [3,2,1,5,6,4], k = 2
输出:5

示例 2

输入:nums = [3,2,3,1,2,4,5,5,6], k = 4
输出:4

数据范围

  • 1 <= k <= nums.length <= 10^5
  • -10^4 <= nums[i] <= 10^4

解法一:维护大小为 k 的小根堆

class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        priority_queue<int,vector<int>,greater<>> q;
        for(int& n : nums){
            if(q.size() < k){
                q.emplace(n);
            }else{
                if(q.top() < n){
                    q.pop();
                    q.push(n);
                }
            }
        }
        return q.top();
    }
};

简单解释

维护一个大小最多为 k 的小根堆,堆中始终保留遍历过的元素里最大的 k 个数。

  • 堆还不足 k 个元素时,直接将当前数字入堆。
  • 堆已经有 k 个元素时,q.top() 是这 k 个候选值中最小的一个。
  • 如果当前数字比 q.top() 大,说明它应该进入最大的 k 个数中,因此弹出堆顶后压入当前数字。
  • 如果当前数字不大于堆顶,它不可能影响第 k 大,直接忽略。

遍历结束后,堆中保存整个数组最大的 k 个元素,其中最小的那个就是第 k 大,因此返回 q.top()

复杂度

  • 时间复杂度:O(n log k)
  • 空间复杂度:O(k)

注意点

  • priority_queue<int, vector<int>, greater<>> 表示小根堆,堆顶是当前保留的 k 个大数中最小的一个。
  • k 大按照排序后的位置计算,重复元素也要分别计数,不需要去重。
  • 当前官方题面要求 O(n);小根堆解法为 O(n log k),理论上不满足这一条严格复杂度要求。若面试官坚持线性复杂度,需要改用快速选择等方法。

记忆锚点

小根堆只留最大的 k 个数,堆顶是这 k 个数里最小的,所以正好是第 k 大。

面试简述

维护一个容量为 k 的小根堆。先将元素填满,之后只在当前元素大于堆顶时替换堆顶,使堆中始终保留已经遍历元素中的最大 k 个数。最终堆顶就是第 k 大,时间复杂度为 O(n log k),空间复杂度为 O(k)

347. 前 K 个高频元素

题面

给定一个整数数组 nums 和一个整数 k,请返回其中出现频率前 k 高的元素。答案可以按任意顺序返回。

示例 1

输入:nums = [1,1,1,2,2,3], k = 2
输出:[1,2]

示例 2

输入:nums = [1], k = 1
输出:[1]

示例 3

输入:nums = [1,2,1,2,1,2,3,1,3,2], k = 2
输出:[1,2]

数据范围

  • 1 <= nums.length <= 10^5
  • -10^4 <= nums[i] <= 10^4
  • 1 <= k <= nums 中不同元素的个数
  • 题目保证前 k 个高频元素的集合唯一
  • 进阶要求算法的时间复杂度优于 O(n log n)

解法一:维护大小为 k 的小根堆

class Solution {
public:
    static bool cmp(pair<int,int>& a, pair<int,int>& b){
        return a.second > b.second;
    }

    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int,int> fp;
        for(auto& n : nums){
            fp[n]++;
        }
        vector<pair<int,int>> v;
        for(auto& a : fp){
            v.push_back(a);
        }

        priority_queue<pair<int,int>,vector<pair<int,int>>,decltype(&cmp)> q(cmp);

        for(auto& a : v){
            if(q.size() < k){
                q.push(a);
            }else{
                if(q.top().second < a.second){
                    q.pop();
                    q.push(a);
                }
            }
        }

        vector<int> ans;
        while(!q.empty()){
            ans.push_back(q.top().first);
            q.pop();
        }

        return ans;
    }
};

简单解释

先使用哈希表统计每个数字的出现次数,再将 (数字, 频率) 保存到数组 v 中。

维护一个大小最多为 k 的小根堆,比较依据是 pair.second,也就是频率:

  • 堆未满时直接入堆。
  • 堆满后,q.top() 是当前候选中频率最低的元素。
  • 新元素的频率更高时,弹出最低频元素并加入新元素。
  • 新元素的频率不高于堆顶时,它不可能进入前 k 高频,直接忽略。

遍历完成后,堆里正好是前 k 个高频元素。题目允许任意顺序返回,因此直接不断弹出堆顶即可。

比较器语法:

static bool cmp(pair<int,int>& a, pair<int,int>& b){
    return a.second > b.second;
}

priority_queue<
    pair<int,int>,
    vector<pair<int,int>>,
    decltype(&cmp)
> q(cmp);
  • a.second > b.second 使频率较小的元素位于堆顶,形成小根堆。
  • decltype(&cmp) 取得函数指针类型。
  • 构造 q 时还要传入实际比较函数 cmp
  • cmp 声明为 static 后,&cmp 才是普通函数指针,而不是非静态成员函数指针。

复杂度

设不同元素的个数为 m

  • 时间复杂度:O(n + m log k)
  • 空间复杂度:O(m),包括哈希表、数组和最多包含 k 个元素的堆

注意点

  • 堆顶必须是候选元素中频率最低的一个,才能在新元素频率更高时将它淘汰,因此这里使用小根堆。
  • 比较和替换依据是 pair.second,最终答案取的是 pair.first
  • k 接近 n 时,堆解法最坏可接近 O(n log n);若严格要求优于 O(n log n),优先使用下面的 Quickselect。

记忆锚点

统计频率后,只用小根堆保留频率最大的 k 项;堆顶是入选者里最弱的,来一个更强的就替换它。

面试简述

先用哈希表统计频率,再维护一个容量为 k、按频率排序的小根堆。堆满后,只让频率高于堆顶的元素进入,从而始终保留前 k 个高频元素。不同元素有 m 个时,时间复杂度为 O(n + m log k),空间复杂度为 O(m)

解法二:Quickselect

class Solution {
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> counts;
        for (int n : nums) counts[n]++;

        vector<pair<int, int>> v;
        for (auto& it : counts) v.push_back(it);

        // 目标:让前 k 个高频元素排在 v 的左侧 [0, k-1]
        // 我们找的是“下标为 k-1”的那个分界点
        qselect(v, 0, v.size() - 1, k - 1);

        vector<int> ans;
        for (int i = 0; i < k; i++) {
            ans.push_back(v[i].first);
        }
        return ans;
    }

    // k_idx 是我们要找的目标下标(即第 k 个位置)
    void qselect(vector<pair<int, int>>& v, int l, int r, int k_idx) {
        if (l >= r) return;

        // 随机化 Pivot 规避最坏情况
        int pick = rand() % (r - l + 1) + l;
        swap(v[pick], v[l]);

        pair<int, int> pivot = v[l];
        int i = l, j = r;

        // --- 挖坑法 (降序:大的在左) ---
        while (i < j) {
            while (i < j && v[j].second <= pivot.second) j--;
            v[i] = v[j];
            while (i < j && v[i].second >= pivot.second) i++;
            v[j] = v[i];
        }
        v[i] = pivot;
        // ----------------------------

        // 关键边界判断:
        if (i == k_idx) return;          // 运气好,正好分好了
        if (i > k_idx) {
            qselect(v, l, i - 1, k_idx); // 目标在左边
        } else {
            qselect(v, i + 1, r, k_idx); // 目标在右边
        }
    }
};

简单解释

Quickselect 不需要将所有元素完全排序,只需要保证频率最高的 k 个元素位于数组前 k 个位置。

如果按频率降序排列,第 k 个元素的下标是 k - 1,因此目标下标为:

int k_idx = k - 1;

每次分区按照下面四步执行:

  1. [l, r] 中随机选择一个基准,将它交换到 v[l],再保存到 pivot
  2. 从右向左寻找频率严格大于基准的元素,填入左侧空位。
  3. 从左向右寻找频率严格小于基准的元素,填入右侧空位。
  4. i == j 时把 pivot 放回这个位置。

分区完成后:

[频率 >= pivot]  pivot  [频率 <= pivot]
                   i

此时只需要比较基准下标 i 与目标下标 k_idx

  • i == k_idx:前 k 个位置已经是答案,结束。
  • i > k_idx:目标分界点在左侧,只递归 [l, i - 1]
  • i < k_idx:目标分界点在右侧,只递归 [i + 1, r]

因为始终使用同一个数组的绝对下标,所以递归时 k_idx 不需要重新换算。

复杂度

设不同元素的个数为 m

  • 期望时间复杂度:O(n + m),通常可写作 O(n)
  • 最坏时间复杂度:O(m^2)
  • 空间复杂度:O(m);递归栈期望为 O(log m),最坏为 O(m)

注意点

  • 要让高频元素位于左侧,所以右指针跳过 <= pivot 的元素,寻找 > pivot;左指针跳过 >= pivot 的元素,寻找 < pivot
  • 目标是前 k 个位置 [0, k - 1],因此比较的是基准下标与 k - 1,不是与 k 比较。
  • Quickselect 只保证前 k 个元素属于最高频集合,不保证这 k 个元素内部有序;题目允许任意顺序返回。

记忆锚点

降序挖坑分区,目标下标是 k−1;基准偏右递归左边,基准偏左递归右边,只追分界点不做完整排序。

面试简述

先统计每个数字的频率,再对 (数字, 频率) 数组执行按频率降序的随机 Quickselect。每次分区后只递归包含下标 k - 1 的一侧,直到前 k 个位置都是最高频元素。期望时间复杂度为 O(n),额外数据空间为 O(m)

295. 数据流的中位数

题面

中位数是有序整数列表中的中间值。如果列表大小为偶数,则中位数是中间两个值的平均值。

实现 MedianFinder 类:

  • MedianFinder() 初始化对象。
  • void addNum(int num) 将数据流中的整数 num 加入数据结构。
  • double findMedian() 返回目前所有元素的中位数。

示例 1

输入:
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
[[], [1], [2], [], [3], []]

输出:
[null, null, null, 1.5, null, 2.0]

解释:
MedianFinder medianFinder = new MedianFinder();
medianFinder.addNum(1);
medianFinder.addNum(2);
medianFinder.findMedian(); // 返回 1.5
medianFinder.addNum(3);
medianFinder.findMedian(); // 返回 2.0

数据范围

  • -10^5 <= num <= 10^5
  • 调用 findMedian 前,数据结构中至少有一个元素
  • 最多调用 addNumfindMedian5 * 10^4

解法一:大根堆 + 小根堆

class MedianFinder {
    // left 保存较小的一半,是大根堆
    priority_queue<int> left;

    // right 保存较大的一半,是小根堆
    priority_queue<int, vector<int>, greater<int>> right;

public:
    MedianFinder() {
    }

    void addNum(int num) {
        // 固定流程 1:新数先进入左堆
        left.push(num);

        // 固定流程 2:把左堆最大值搬到右堆,保证左边都不大于右边
        right.push(left.top());
        left.pop();

        // 固定流程 3:左堆数量不能少于右堆
        if(left.size() < right.size()){
            left.push(right.top());
            right.pop();
        }
    }

    double findMedian() {
        if(left.size() > right.size()){
            return left.top();
        }
        return (left.top() + right.top()) / 2.0;
    }
};

/**
 * Your MedianFinder object will be instantiated and called as such:
 * MedianFinder* obj = new MedianFinder();
 * obj->addNum(num);
 * double param_2 = obj->findMedian();
 */

简单解释

把数据分成左右两半:

  • left 保存较小的一半,使用大根堆,因此 left.top() 是左半部分最大值。
  • right 保存较大的一半,使用小根堆,因此 right.top() 是右半部分最小值。

始终维护两个条件:

left 中所有元素 <= right 中所有元素
left.size() == right.size()
或 left.size() == right.size() + 1

每次加入新数都使用固定的三步:

  1. 新数先压入 left
  2. left 的最大值搬到 right,保证左右顺序正确。
  3. 如果 right 数量更多,再把 right 的最小值搬回 left

因此:

  • 总数为奇数时,left 多一个,中位数为 left.top()
  • 总数为偶数时,两边数量相同,中位数为两个堆顶的平均值。

复杂度

  • addNum 时间复杂度:O(log n)
  • findMedian 时间复杂度:O(1)
  • 空间复杂度:O(n)

注意点

  • 左边必须使用大根堆,右边必须使用小根堆,两个堆顶才是最靠近中间的两个数。
  • 调整后只能让 leftright 数量相等,或者让 left 恰好多一个。
  • 偶数个元素时必须除以 2.0,否则会发生整数除法。

记忆锚点

新数进左堆,左最大搬右堆;右边如果人更多,右最小再搬回左边。

面试简述

使用大根堆保存较小的一半,小根堆保存较大的一半,并让左堆数量与右堆相等或多一个。加入元素时通过两次堆间搬运同时维护顺序和数量,中位数就是一个堆顶或两个堆顶的平均值。

十四、贪心算法

121. 买卖股票的最佳时机

题面

给定数组 prices,其中 prices[i] 表示某只股票第 i 天的价格。

只能选择某一天买入,并在未来某个不同的日子卖出。返回这笔交易能够获得的最大利润;如果不能获得利润,则返回 0

示例 1

输入:prices = [7,1,5,3,6,4]
输出:5
解释:价格为 1 时买入,价格为 6 时卖出,利润为 5

示例 2

输入:prices = [7,6,4,3,1]
输出:0

数据范围

  • 1 <= prices.length <= 10^5
  • 0 <= prices[i] <= 10^4

解法一:记录历史最低价格

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int ans = 0;
        int cur = INT_MAX;
        for(int i = 0 ; i < prices.size() ; i++){
            cur = min(cur,prices[i]);
            ans = max(ans,prices[i]-cur);
        }
        return ans;
    }
};

简单解释

遍历到第 i 天时:

  • cur 表示第 0 天到第 i 天出现过的最低价格,也就是当前最合适的买入价。
  • prices[i] - cur 表示如果今天卖出能够获得的利润。
  • 使用 ans 保存所有卖出日期中的最大利润。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • 必须先维护从左到右的历史最低价,才能保证卖出发生在买入之后。
  • ans 初始化为 0,价格一直下降时自然返回 0,不进行亏损交易。

记忆锚点

一路记录见过的最低买入价,每天都尝试在今天卖出并更新最大利润。

面试简述

从左到右维护历史最低价格,并把当天价格减去历史最低价作为当天卖出的利润,持续更新最大值。时间复杂度为 O(n),空间复杂度为 O(1)

扩展:买卖股票系列常见变形

题目 限制 最好记的状态或规律
122. 买卖股票的最佳时机 II 不限交易次数 累加所有相邻正收益
309. 最佳买卖股票时机含冷冻期 卖出后冷冻一天 hold / sold / rest 三状态
714. 买卖股票的最佳时机含手续费 每次交易收手续费 cash / hold,卖出时扣费
123. 买卖股票的最佳时机 III 最多两次交易 buy1 / sell1 / buy2 / sell2
188. 买卖股票的最佳时机 IV 最多 k 次交易 buy[t] / sell[t] 推广

122:不限交易次数

只要今天比昨天贵,就把这一段上涨利润收入囊中。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int ans = 0;
        for(int i = 1; i < prices.size(); i++){
            if(prices[i] > prices[i-1]){
                ans += prices[i] - prices[i-1];
            }
        }
        return ans;
    }
};

309:卖出后有一天冷冻期

每天只维护三种状态:

  • hold:今天结束时持有股票。
  • sold:今天刚卖出股票。
  • rest:今天没有持股,并且没有在今天卖出。
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int hold = -prices[0];
        int sold = 0;
        int rest = 0;

        for(int i = 1; i < prices.size(); i++){
            int preHold = hold;
            int preSold = sold;
            int preRest = rest;

            hold = max(preHold, preRest - prices[i]);
            sold = preHold + prices[i];
            rest = max(preRest, preSold);
        }
        return max(sold, rest);
    }
};

冷冻限制体现在:今天买入只能从昨天的 rest 转移,不能从昨天刚卖出的 sold 转移。

714:每次交易有手续费

cash 表示不持股时的最大利润,hold 表示持股时的最大利润;卖出时扣除手续费。

class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
        int cash = 0;
        int hold = -prices[0];

        for(int i = 1; i < prices.size(); i++){
            int preCash = cash;
            cash = max(cash, hold + prices[i] - fee);
            hold = max(hold, preCash - prices[i]);
        }
        return cash;
    }
};

123:最多完成两次交易

四个变量分别表示完成第一次买入、第一次卖出、第二次买入、第二次卖出后的最大利润。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int buy1 = -prices[0];
        int sell1 = 0;
        int buy2 = -prices[0];
        int sell2 = 0;

        for(int price : prices){
            buy1 = max(buy1, -price);
            sell1 = max(sell1, buy1 + price);
            buy2 = max(buy2, sell1 - price);
            sell2 = max(sell2, buy2 + price);
        }
        return sell2;
    }
};

188:最多完成 k 次交易

把第 123 题的四个变量推广成数组:

class Solution {
public:
    int maxProfit(int k, vector<int>& prices) {
        int n = prices.size();
        if(n == 0) return 0;

        if(k >= n / 2){
            int ans = 0;
            for(int i = 1; i < n; i++){
                ans += max(0, prices[i] - prices[i-1]);
            }
            return ans;
        }

        vector<int> buy(k+1, INT_MIN / 2);
        vector<int> sell(k+1, 0);

        for(int price : prices){
            for(int t = 1; t <= k; t++){
                buy[t] = max(buy[t], sell[t-1] - price);
                sell[t] = max(sell[t], buy[t] + price);
            }
        }
        return sell[k];
    }
};

系列记忆锚点

一次交易记最低价;无限次收集所有上涨;有限次数用买卖状态;冷冻期限制买入来源;手续费在卖出时扣除。

55. 跳跃游戏

题面

给定一个非负整数数组 nums,初始位置位于第一个下标。数组中的每个元素表示在该位置能够向后跳跃的最大长度。

判断是否能够到达最后一个下标。

示例 1

输入:nums = [2,3,1,1,4]
输出:true

示例 2

输入:nums = [3,2,1,0,4]
输出:false

数据范围

  • 1 <= nums.length <= 10^4
  • 0 <= nums[i] <= 10^5

解法一:维护最远可达位置

class Solution {
public:
    bool canJump(vector<int>& nums) {
        int far = 0;
        int n = nums.size();
        for(int i = 0 ; i < n; i++){
            if(far < i) return false;
            far = max(far,i+nums[i]);
            if(far >= n-1) return true;
        }
        return false;
    }
};

简单解释

far 表示从已经确认能够到达的位置出发,目前最远能够到达的下标。

遍历下标 i 时:

  1. 如果 far < i,说明连当前位置都到不了,直接返回 false
  2. 否则可以站在 i 上继续跳,用 i + nums[i] 更新最远位置。
  3. 一旦 far >= n - 1,说明能够到达终点。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • 必须先判断 far < i,只有当前位置可达时,才能使用它更新更远边界。
  • far 表示整个可达区间的最远边界,不要求真的按照某条具体路径跳过去。

记忆锚点

只管最远能到哪;如果遍历下标跑到了最远边界之外,就说明中途断路。

面试简述

从左到右维护目前能够到达的最远下标。只要当前下标仍在可达范围内,就用当前位置扩展最远边界;如果当前下标超过边界则无法到达,边界覆盖末尾则成功。

45. 跳跃游戏 II

题面

给定一个长度为 n 的整数数组 nums,初始位置位于下标 0nums[i] 表示从下标 i 最远能够向后跳多远。

返回到达下标 n - 1 的最少跳跃次数。题目保证终点可达。

示例 1

输入:nums = [2,3,1,1,4]
输出:2

示例 2

输入:nums = [2,3,0,1,4]
输出:2

数据范围

  • 1 <= nums.length <= 10^4
  • 0 <= nums[i] <= 1000
  • 题目保证可以到达 n - 1

解法一:按跳跃次数分层扩展

class Solution {
public:
    int jump(vector<int>& nums) {
        if(nums.size() == 1) return 0;
        int cur = 0;
        int far = nums[0];
        int count = 0;

        while(far < nums.size()-1){
            count++;
            int tempFar = far;
            int next = cur;
            for(int i = cur; i <= far; i++){
                tempFar = max(i + nums[i],tempFar);
                next = i;
            }
            far = tempFar;
            cur = next;
        }
        return count+1;
    }
};

简单解释

把每一次跳跃能够到达的范围看成 BFS 的一层:

  • far 表示使用当前跳跃次数能够覆盖到的最远位置。
  • 扫描当前可达区间 [cur, far],计算再跳一步能够到达的最远位置 tempFar
  • 每扩展一层,跳跃次数增加一次。

代码初始化 far = nums[0],相当于已经把第一次跳跃能够覆盖的范围计算出来。因此循环只统计后续扩层次数,最终使用 count + 1 把第一次跳跃补回来。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • 数组只有一个元素时已经位于终点,答案为 0
  • 初始 far = nums[0] 已经代表第一次跳跃,所以最终返回 count + 1,这是本写法最容易忘记的偏移。
  • 代码中的 next 在循环结束后实际等于旧的 far,作用是推进下一轮扫描起点;它不是“本轮选择的最佳落脚点”。

记忆锚点

每次跳跃扩展一层可达区间;扫完整层只取下一层最远边界,不关心具体落在哪个点。

面试简述

将当前跳跃次数能够到达的区间视为一层,扫描这一层的所有位置,计算再跳一步能够覆盖的最远边界,然后进入下一层。每次扩层对应一次跳跃,因此能够得到最少跳跃次数。

763. 划分字母区间

题面

给定一个字符串 s,将它划分为尽可能多的片段,使同一字母最多出现在一个片段中。

划分结果按顺序连接后仍然是原字符串。返回每个片段的长度。

示例 1

输入:s = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:划分为 "ababcbaca""defegde""hijhklij"

示例 2

输入:s = "eccbbbbdec"
输出:[10]

数据范围

  • 1 <= s.length <= 500
  • s 仅由小写英文字母组成

解法一:维护当前片段的最远右边界

class Solution {
public:
    vector<int> partitionLabels(string s) {
        vector<int> ans;
        int n = s.size();
        int r = -1;
        int last = 0;
        for(int i = 0 ; i < n; i++){
            int rr= findlast(s,s[i]);
            if(rr>r) r =rr;
            if(r == i){
                ans.emplace_back(i-last+1);
                last = r+1;
                r = -1;
            }
        }
        return ans;
    }

    int findlast(string&s,char& c){
        for(int i = s.size()-1; i >= 0 ; i--){
            if(c == s[i]){
                return i;
            }
        }
        return -1;
    }
};

简单解释

r 表示当前片段必须覆盖到的最远右边界。

遍历到字符 s[i] 时,找到该字符在整个字符串中的最后出现位置 rr,并更新:

r = max(r, rr);

只要当前片段中又遇到了一个最后位置更靠后的字符,片段右边界就必须继续向后扩展。当遍历下标终于到达 r 时,说明当前片段出现过的所有字符都不会在后面再次出现,此时可以立即切分,得到尽可能多的片段。

复杂度

  • 时间复杂度:O(n^2),每个字符都会调用 findlast 从后向前扫描
  • 空间复杂度:O(1),不计算答案数组

注意点

  • 只有当 i == r 时才能切分;在此之前遇到的任何字符都可能把右边界继续向后推。
  • 新片段起点使用 last = r + 1,当前片段长度为 i - last + 1
  • 当前写法重复寻找最后位置,复杂度为 O(n^2);面试中如果要求线性复杂度,可以先用长度为 26 的数组预处理每个字母的最后下标。

记忆锚点

当前片段里见过的所有字母,谁最后出现得最晚,谁就决定右边界;走到边界立刻切一刀。

面试简述

遍历字符串并维护当前片段中所有字符最后出现位置的最大值。当前位置到达这个最大右边界时,当前片段中的字符都不会出现在后续位置,因此可以切分。当前实现因重复反向查找最后位置为 O(n^2)

十五、动态规划

70. 爬楼梯

题面

假设你正在爬楼梯,需要 n 阶才能到达楼顶。

每次可以爬 12 个台阶,求有多少种不同的方法可以爬到楼顶。

示例 1

输入:n = 2
输出:2
解释:可以爬两次 1 阶,也可以一次爬 2 阶。

示例 2

输入:n = 3
输出:3

数据范围

  • 1 <= n <= 45

解法一:滚动变量动态规划

class Solution {
public:
    int climbStairs(int n) {
        if(n == 1) return 1;
        int a = 1;
        int b = 2;
        for(int i = 2; i < n; i++){
            int temp = a+b;
            a = b;
            b = temp;
        }
        return b;
    }
};

简单解释

到达第 n 阶的最后一步只有两种可能:从第 n - 1 阶爬 1 阶,或者从第 n - 2 阶爬 2 阶。因此:

f(n) = f(n - 1) + f(n - 2)
f(1) = 1,f(2) = 2

这和斐波那契数列的递推形式相同,只是初始项发生了偏移。ab 分别保存前两项,每轮计算下一项,不需要完整的 dp 数组。

为什么不能直接使用递归

不是递归本身不能用,而是不能使用没有记忆化的裸递归。直接递归时,f(n - 2)f(n - 3) 等子问题会被反复计算,时间复杂度接近 O(2^n)。加入记忆化以后递归也可以做到 O(n),但本题的迭代写法只需要 O(1) 额外空间。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • n == 1 时直接返回 1,否则初始化 b = 2 才有意义。
  • 更新顺序是先计算新值,再执行 a = bb = temp

记忆锚点

最后一步来自前一阶或前两阶,所以方案数等于前两项之和;这是初始项错位的斐波那契数列。

面试简述

定义 f(i) 为爬到第 i 阶的方案数,最后一步只能爬 1 阶或 2 阶,所以 f(i) = f(i - 1) + f(i - 2)。使用两个变量滚动保存前两项,时间复杂度 O(n),空间复杂度 O(1)

118. 杨辉三角

题面

给定整数 numRows,生成杨辉三角的前 numRows 行。

在杨辉三角中,每个内部元素是它左上方和右上方两个元素之和,两侧的元素均为 1

杨辉三角中每个内部元素由上一行相邻两个元素相加得到

示例 1

输入:numRows = 5
输出:[[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]

示例 2

输入:numRows = 1
输出:[[1]]

数据范围

  • 1 <= numRows <= 30

解法一:逐行递推

class Solution {
public:
    vector<vector<int>> generate(int numRows) {
        vector<vector<int>> ans(numRows);
        for(int i = 0 ; i < numRows; i++){
            ans[i].resize(i+1,1);
            for(int j = 1 ; j < i; j++){
                ans[i][j] = ans[i-1][j-1] + ans[i-1][j];
            }
        }
        return ans;
    }
};

简单解释

i 行有 i + 1 个元素。先用 resize(i + 1, 1) 将整行初始化为 1,这样左右边界无需单独处理;随后只计算内部位置:

ans[i][j] = ans[i - 1][j - 1] + ans[i - 1][j];

复杂度

  • 时间复杂度:O(numRows^2)
  • 空间复杂度:O(numRows^2),即返回结果本身占用的空间

注意点

  • i 行的下标范围是 [0, i],只有 j = 1 ... i - 1 的内部元素需要递推。
  • 两侧元素始终为 1,提前初始化后不要在内部循环中覆盖。

记忆锚点

每行先全部填 1,再用上一行的左上角与右上角之和覆盖中间位置。

面试简述

逐行生成答案,第 i 行先创建 i + 11。对内部位置 j,使用上一行的 ans[i - 1][j - 1] + ans[i - 1][j] 计算,时间复杂度为 O(numRows^2)

198. 打家劫舍

题面

给定一个代表每间房屋存放金额的非负整数数组 nums。如果同一晚上闯入两间相邻的房屋,防盗系统会自动报警。

计算在不触动警报的情况下,一夜之内能够偷窃到的最高金额。

示例 1

输入:nums = [1,2,3,1]
输出:4
解释:偷窃第 1 间和第 3 间房屋,金额为 1 + 3 = 4

示例 2

输入:nums = [2,7,9,3,1]
输出:12

数据范围

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 400

解法一:一维动态规划

class Solution {
public:
    int rob(vector<int>& nums) {
        int ans = 0;
        int n = nums.size();
        if(n<=1) return nums[0];

        vector<int> dp(n);
        dp[0] = nums[0];
        dp[1] = max(nums[0],nums[1]);
        for(int i = 2; i < n; i++){
            dp[i] = max(nums[i]+dp[i-2],dp[i-1]);
        }
        return dp[n-1];
    }
};

简单解释

dp[i] 表示只考虑下标 0 ... i 的房屋时,能够获得的最高金额。面对第 i 间房屋只有两种选择:

  • 偷第 i 间:不能偷第 i - 1 间,金额为 dp[i - 2] + nums[i]
  • 不偷第 i 间:金额保持为 dp[i - 1]

因此:

dp[i] = max(nums[i] + dp[i - 2], dp[i - 1]);

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

注意点

  • dp[i] 表示前 i + 1 间房屋的最优答案,不是必须偷第 i 间。
  • 初始化 dp[1] 时应取前两间房屋金额的较大值。
  • 代码中的 ans 没有参与计算,可以删除,但不影响结果。

记忆锚点

当前房屋只有“偷”和“不偷”:偷就接 i - 2,不偷就继承 i - 1

面试简述

定义 dp[i] 为考虑前 i + 1 间房屋的最大金额。偷当前房屋时只能加上 dp[i - 2],不偷时答案为 dp[i - 1],两者取最大值,时间复杂度 O(n)

279. 完全平方数

题面

给定整数 n,返回和为 n 的完全平方数的最少数量。

完全平方数是能够写成某个整数平方的整数,例如 14916

示例 1

输入:n = 12
输出:3
解释:12 = 4 + 4 + 4

示例 2

输入:n = 13
输出:2
解释:13 = 4 + 9

数据范围

  • 1 <= n <= 104

解法一:完全背包式动态规划

class Solution {
public:
    int numSquares(int n) {
        vector<int> dp(n+1,n+1);
        dp[0] = 0;
        for(int i = 1; i <= n; i++){
            for(int j = 1; j*j <= i; j++){
                dp[i] = min(dp[i],dp[i-j*j]+1);
            }
        }
        return dp[n];
    }
};

简单解释

dp[i] 表示凑出整数 i 所需的最少完全平方数数量。枚举最后选择的平方数 j * j,此前需要凑出的值就是 i - j * j

dp[i] = min(dp[i], dp[i - j * j] + 1);

同一个平方数可以重复使用,因此这是完全背包问题。初始时把状态设为不可能成为答案的 n + 1,并令 dp[0] = 0

复杂度

  • 时间复杂度:O(n * sqrt(n))
  • 空间复杂度:O(n)

注意点

  • dp[0] = 0 是所有有效转移的起点。
  • 内层只枚举满足 j * j <= i 的平方数,同一个平方数可以重复使用。

记忆锚点

枚举答案中的最后一个平方数:拿掉 后,答案就是 dp[i - j²] + 1

面试简述

定义 dp[i] 为组成 i 的最少平方数数量。枚举最后使用的完全平方数 ,由 dp[i - j²] + 1 更新当前状态。共有 n 个状态,每个状态枚举至多 sqrt(n) 个平方数。

322. 零钱兑换

题面

给定不同面额的硬币数组 coins 和一个总金额 amount,计算凑成该金额所需的最少硬币数量。

每种硬币的数量可以认为是无限的。如果无法凑成该金额,返回 -1

示例 1

输入:coins = [1,2,5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

示例 2

输入:coins = [2], amount = 3
输出:-1

示例 3

输入:coins = [1], amount = 0
输出:0

数据范围

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 231 - 1
  • 0 <= amount <= 104

解法一:完全背包式动态规划

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount+1,amount+1);
        dp[0] = 0;
        for(int i = 1 ; i <= amount ; i++){
            for(int& c : coins){
                if(i-c<0) continue;
                dp[i] = min(dp[i],dp[i-c]+1);
            }
        }
        if(dp[amount] > amount) return -1;
        return dp[amount];
    }
};

简单解释

dp[i] 表示凑成金额 i 所需的最少硬币数。枚举最后使用的硬币 c,若 i >= c,则:

dp[i] = min(dp[i], dp[i - c] + 1);

状态从小金额递推到大金额,dp[i - c] 已经计算完成;同一种面额可以被重复选择。amount + 1 大于任何可能的有效答案,可作为不可达的哨兵值。

复杂度

  • 时间复杂度:O(amount * coins.size())
  • 空间复杂度:O(amount)

注意点

  • dp[0] = 0 表示凑成金额 0 不需要硬币,也是后续转移的起点。
  • 只有 i >= c 时才能从 dp[i - c] 转移。
  • 最终状态仍大于 amount,说明目标金额不可达,应返回 -1

记忆锚点

枚举最后放进来的硬币 c,当前最少数量由 dp[i - c] + 1 更新。

面试简述

定义 dp[i] 为凑成金额 i 的最少硬币数,初始化为不可达的 amount + 1,并设置 dp[0] = 0。对每个金额枚举最后一枚硬币,由更小金额的最优解转移,最终判断 dp[amount] 是否仍为哨兵值。

139. 单词拆分

题面

给定字符串 s 和字符串列表 wordDict,判断是否可以利用字典中的单词拼接出 s

字典中的单词可以重复使用,不要求使用字典中的全部单词。

示例 1

输入:s = "leetcode", wordDict = ["leet","code"]
输出:true
解释:"leetcode" 可以拆分为 "leet" + "code"

示例 2

输入:s = "applepenapple", wordDict = ["apple","pen"]
输出:true

示例 3

输入:s = "catsandog", wordDict = ["cats","dog","sand","and","cat"]
输出:false

数据范围

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • swordDict[i] 仅由小写英文字母组成

解法一:按前缀长度动态规划

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        int n = s.size();
        vector<int> dp(n+1,-1);
        dp[0] = 1;
        for(int i = 1; i <= n; i++){
            for(string& w : wordDict){
                int m = w.size();
                if(i-m < 0) continue;
                if(dp[i-m] ==1 && s.substr(i-m,m) == w){
                    dp[i] = 1;
                    break;
                }
            }
        }
        if(dp[n] == 1) return true;
        return false;
    }
};

简单解释

dp[i] 表示字符串的前 i 个字符,即 s[0 ... i - 1],能否由字典单词拼出。尝试让字典单词 w 成为当前前缀的最后一个单词:

  • w 的长度为 m,它应对应 s[i - m ... i - 1]
  • 前面的 i - m 个字符必须已经能够拆分,即 dp[i - m] == 1
  • 当前末尾子串必须等于 w

两个条件都满足时,dp[i] = 1

复杂度

  • 时间复杂度:O(n * wordDict.size() * L)L 为字典单词的最大长度,构造和比较子串需要线性时间
  • 空间复杂度:O(n)

注意点

  • i 表示前缀长度而不是字符下标,所以单词的起点是 i - m
  • dp[0] = 1 表示空前缀可以被成功拼出,是第一个单词能够转移成功的基础。
  • 题目允许重复使用字典中的同一个单词。

记忆锚点

猜当前前缀的最后一个单词:末尾能匹配,并且它前面的前缀也能拆分,当前状态就成立。

面试简述

定义 dp[i] 表示前 i 个字符能否被拆分。对每个前缀枚举最后一个字典单词 w,若前 i - |w| 个字符可拆分,且末尾子串等于 w,则当前前缀可拆分。

300. 最长递增子序列

题面

给定整数数组 nums,找到其中最长严格递增子序列的长度。

子序列由原数组删除或不删除某些元素后得到,剩余元素的相对顺序不能改变。

示例 1

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列可以是 [2,3,7,101],长度为 4

示例 2

输入:nums = [0,1,0,3,2,3]
输出:4

示例 3

输入:nums = [7,7,7,7,7,7,7]
输出:1

数据范围

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

解法一:枚举前驱的动态规划

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n,1);
        int ans = 1;
        for(int i = 0 ; i < n ; i++){
            for(int j = 0 ; j < i ; j++){
                if(nums[i]>nums[j]){
                    dp[i] = max(dp[i],dp[j]+1);
                    ans = max(dp[i],ans);
                }
            }
        }
        return ans;
    }
};

简单解释

dp[i] 表示以 nums[i] 结尾的最长严格递增子序列长度。枚举所有位于 i 前面的下标 j

  • nums[j] < nums[i],可以把 nums[i] 接到以 nums[j] 结尾的子序列后面;
  • 此时使用 dp[j] + 1 更新 dp[i]

每个 dp[i] 只代表“以 i 结尾”的答案,因此还需要全局变量 ans 记录所有结尾中的最大值。

复杂度

  • 时间复杂度:O(n^2)
  • 空间复杂度:O(n)

注意点

  • 题目要求严格递增,条件必须是 nums[i] > nums[j],相等时不能连接。
  • dp[i] 至少为 1,表示只选择 nums[i] 自身。
  • 最长递增子序列不一定以最后一个元素结尾,因此必须维护全局最大值。

记忆锚点

dp[i] 只看“以我结尾”;向前寻找所有比我小的元素,接在其中最长的序列后面。

面试简述

定义 dp[i] 为以 nums[i] 结尾的最长递增子序列长度。枚举所有 j < i,当 nums[j] < nums[i] 时用 dp[j] + 1 更新,最后取所有 dp[i] 的最大值,时间复杂度 O(n^2)

152. 乘积最大子数组

题面

给定一个整数数组 nums,找出数组中乘积最大的非空连续子数组,并返回该子数组所对应的乘积。

示例 1

输入:nums = [2,3,-2,4]
输出:6
解释:子数组 [2,3] 的乘积为 6

示例 2

输入:nums = [-2,0,-1]
输出:0
解释:不能取不连续的 [-2,-1]

数据范围

  • 1 <= nums.length <= 2 * 104
  • -10 <= nums[i] <= 10
  • 任意前缀或后缀的乘积都在 32 位整数范围内

解法一:同时维护最大乘积和最小乘积

原代码中的 temp\_maxtemp\_min 是 Markdown 转义后残留的反斜杠,会导致 C++ 无法编译。下面仅移除反斜杠,算法没有改变。

class Solution {
public:
    int maxProduct(vector<int>& nums) {
        int ans = INT_MIN;
        int n = nums.size();
        int cur_min = 1;
        int cur_max = 1;

        for(int i = 0; i < n; i++){
            int temp_min = cur_min;
            int temp_max = cur_max;
            cur_min = min(nums[i], min(temp_min * nums[i], temp_max * nums[i]));
            cur_max = max(nums[i], max(temp_max * nums[i], temp_min * nums[i]));
            ans = max(ans, cur_max);
        }
        return ans;
    }
};

简单解释

只维护最大乘积不够,因为遇到负数时,之前的最小负数乘积可能翻转成新的最大正数乘积。因此对每个位置同时记录:

  • cur_max:以当前位置结尾的最大乘积;
  • cur_min:以当前位置结尾的最小乘积。

新状态有三种来源:

  1. 只选择当前元素 nums[i],相当于从当前位置重新开始;
  2. 旧的最大乘积乘以 nums[i]
  3. 旧的最小乘积乘以 nums[i]

分别对三者取最大值和最小值即可。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • 计算 cur_mincur_max 时都必须使用上一轮的旧值,所以要先保存 temp_mintemp_max
  • 必须把 nums[i] 本身也作为候选值,这样遇到 0 或此前乘积变差时能够从当前位置重新开始。
  • 负数会让最大值和最小值的角色互换,因此两个状态缺一不可。

记忆锚点

加法只记最大值;乘法遇到负数会翻转,所以最大值和最小值都要带着走。

面试简述

对每个位置维护以它结尾的最大乘积与最小乘积。由于负数可能把最小乘积翻转成最大乘积,两个状态都从当前元素、旧最大值乘当前元素、旧最小值乘当前元素这三项中更新。遍历一次即可,时间 O(n)、空间 O(1)

416. 分割等和子集

题面

给定一个只包含正整数的非空数组 nums,判断是否可以将数组分割成两个子集,使两个子集的元素和相等。

示例 1

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1,5,5][11]

示例 2

输入:nums = [1,2,3,5]
输出:false

数据范围

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100

解法一:一维 0-1 背包

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int n = nums.size();
        if(n < 2) return false;
        int sum = 0;
        int maxx = 0;
        for(int& n : nums){
            maxx = max(maxx,n);
            sum+=n;
        }
        if(sum%2 != 0 || maxx > (sum/2)) return false;
        int target = sum/2;
        vector<int> dp(target+1,-1);
        dp[0] = 1;
        for(int& n : nums){
            for(int j = target ; j >= n ; j--){ // 倒着遍历保证只用一次
                if(dp[j-n] == 1){
                    dp[j] = 1;
                }
            }
            if(dp[target] == 1) return true;
        }
        return false;
    }
};

简单解释

设整个数组的元素和为 sum。如果能分成两个和相等的子集,那么每个子集的和一定都是:

target = sum / 2

因此不必同时构造两个子集,只需要判断:能否从数组中挑出若干元素,使它们的和恰好等于 target。每个数组元素只能选一次或不选,这正是 0-1 背包。

状态定义与转移

dp[j] == 1 表示:使用目前已经遍历过的元素,能否凑出和 j

  • dp[0] = 1:一个元素都不选时可以凑出 0
  • 遍历当前元素 n 时,如果此前能凑出 j - n,加入当前元素后就能凑出 j
if(dp[j - n] == 1){
    dp[j] = 1;
}

为什么必须倒序遍历

对当前元素 n,容量 j 必须从 target 倒序走到 n。这样读取的 dp[j - n] 仍是处理当前元素之前的状态,保证当前元素只被使用一次。

如果改成正序,刚刚由当前元素更新出的 dp[n] 可能继续更新 dp[2 * n],相当于在同一轮重复使用当前元素,错误地变成完全背包。

例如只有一个元素 2

正序:dp[2] <- dp[0],随后 dp[4] <- dp[2]

这会把同一个 2 使用两次;倒序就不会发生。

复杂度

  • 时间复杂度:O(n * target)
  • 空间复杂度:O(target)

注意点

  • sum 为奇数时不可能平均分成两个整数和,直接返回 false
  • 最大元素大于 target 时,它不可能被放入任何一个和为 target 的子集,直接返回 false
  • 这是每个元素只能使用一次的 0-1 背包,所以一维 dp 必须倒序更新。

记忆锚点

两个子集和相等,就是找一个和为总和一半的子集;每个数只能用一次,所以容量倒着扫。

面试简述

先求数组总和,若为奇数则无解。问题等价于从数组中选择若干元素,使其和为 sum / 2。用一维布尔状态记录各个和是否可达,对每个元素从 target 向下更新,倒序保证每个元素只使用一次,时间复杂度 O(n * target)

32. 最长有效括号

题面

给定一个只包含 '('')' 的字符串 s,找出最长有效(格式正确且连续)括号子串的长度。

示例 1

输入:s = "(()"
输出:2
解释:最长有效括号子串是 "()"

示例 2

输入:s = ")()())"
输出:4
解释:最长有效括号子串是 "()()"

示例 3

输入:s = ""
输出:0

数据范围

  • 0 <= s.length <= 3 * 104
  • s[i]'('')'

解法一:下标栈与无效边界

class Solution {
public:
    int longestValidParentheses(string s) {
        int ans = 0;
        stack<int> stk;
        stk.push(-1); // 第一个有效子串左边的虚拟边界

        for(int i = 0; i < s.size(); i++){
            if(s[i] == '('){
                stk.push(i);
            }else{
                stk.pop();
                if(stk.empty()){
                    // 当前右括号无法匹配,它成为新的无效边界
                    stk.push(i);
                }else{
                    // 新栈顶是当前有效子串左侧的边界
                    ans = max(ans, i - stk.top());
                }
            }
        }
        return ans;
    }
};

简单解释

栈里保存的是下标,而不是括号字符。栈顶始终表示当前有效括号子串左边最近的边界:

  • 遇到 '(':把它的下标压栈,等待右括号匹配;
  • 遇到 ')':弹出一个下标,尝试匹配最近的左括号;
  • 弹出后栈不为空:当前有效长度为 i - stk.top()
  • 弹出后栈为空:说明当前 ')' 没有左括号可以匹配,它会截断所有跨过它的有效子串,因此把当前下标压入栈,作为新的无效边界。

开头先压入 -1,是为了让从下标 0 开始的有效子串也能直接使用同一个长度公式。例如 "()" 扫描到下标 1 时,长度为:

1 - (-1) = 2

手动走一遍 ")()())"

初始:栈 [-1]
i=0,')':弹出后为空,0 成为新边界       栈 [0]
i=1,'(':压入 1                         栈 [0,1]
i=2,')':弹出 1,长度 2-0=2             栈 [0]
i=3,'(':压入 3                         栈 [0,3]
i=4,')':弹出 3,长度 4-0=4             栈 [0]
i=5,')':弹出后为空,5 成为新边界       栈 [5]

答案为 4

复杂度

  • 时间复杂度:O(n),每个下标最多入栈和出栈一次
  • 空间复杂度:O(n)

注意点

  • 栈中必须保存下标,因为最终需要计算连续子串的长度。
  • 初始哨兵 -1 表示字符串开头之前的边界。
  • 遇到无法匹配的右括号时,不是简单丢弃,而是把它的下标压栈,更新新的无效边界。
  • 计算长度时使用弹栈后的新栈顶:i - stk.top()

记忆锚点

左括号下标入栈;右括号先弹栈。栈空就把当前右括号设为新边界,不空就用 当前下标 - 新栈顶 算长度。

面试简述

用栈保存未匹配左括号的下标,并在栈底保留最近一个无效右括号的下标作为边界。遇到右括号先弹栈,若栈为空则当前右括号成为新边界,否则从当前下标减去新栈顶得到以当前位置结尾的有效括号长度。时间复杂度 O(n)

十六、多维动态规划

62. 不同路径

题面

一个机器人位于一个 m x n 网格的左上角。机器人每次只能向下或者向右移动一步,求它到达网格右下角共有多少条不同的路径。

机器人从网格左上角移动到右下角的不同路径示意图

示例 1

输入:m = 3, n = 7
输出:28

示例 2

输入:m = 3, n = 2
输出:3
解释:三条路径分别为“右、下、下”“下、下、右”和“下、右、下”。

数据范围

  • 1 <= m, n <= 100
  • 题目数据保证答案小于等于 2 * 109

解法一:二维动态规划

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> dp(m, vector<int>(n, 0));
        for (int i = 0; i < m; i++) {
            dp[i][0] = 1;
        }
        for (int i = 0; i < n; i++) {
            dp[0][i] = 1;
        }

        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[m - 1][n - 1];
    }
};

简单解释

dp[i][j] 表示机器人从左上角走到位置 (i, j) 的不同路径数量。

因为机器人只能向下或向右移动,所以到达 (i, j) 的最后一步只可能来自:

  • 上方 (i - 1, j)
  • 左方 (i, j - 1)

因此状态转移为:

dp[i][j] = dp[i - 1][j] + dp[i][j - 1];

第一列只能一直向下走,第一行只能一直向右走,所以它们的路径数都初始化为 1

复杂度

  • 时间复杂度:O(m * n)
  • 空间复杂度:O(m * n)

注意点

  • 必须先把第一行和第一列初始化为 1,它们是内部状态递推的边界。
  • 内部循环从 i = 1j = 1 开始,避免访问 dp[-1][j]dp[i][-1]
  • m 表示行数,n 表示列数,最终答案是 dp[m - 1][n - 1]

记忆锚点

只能向右或向下,所以到达当前格子的路径数,就是上方路径数加左方路径数。

面试简述

定义 dp[i][j] 为到达网格位置 (i, j) 的路径数。第一行和第一列都只有一种走法;其他位置只能从上方或左方到达,因此两者路径数相加。时间和空间复杂度均为 O(m * n)

64. 最小路径和

题面

给定一个包含非负整数的 m x n 网格 grid,找出一条从左上角到右下角的路径,使路径上的数字总和最小。

每次只能向下或者向右移动一步。

从网格左上角到右下角的最小路径和示意图

示例 1

输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:路径 1 → 3 → 1 → 1 → 1 的总和最小。

示例 2

输入:grid = [[1,2,3],[4,5,6]]
输出:12

数据范围

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 200
  • 0 <= grid[i][j] <= 200

解法一:二维动态规划

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size();int n = grid[0].size();
        vector<vector<int>> dp(m,vector<int>(n,0));
        int sum = 0;
        for(int i = 0 ; i < m ; i++){
            sum+=grid[i][0];
            dp[i][0] = sum;
        }
        sum = 0;
        for(int j = 0; j < n ; j++){
            sum+=grid[0][j];
            dp[0][j] = sum;
        }
        for(int i = 1 ; i < m ; i++){
            for(int j = 1 ; j < n ; j++){
                dp[i][j] = min(dp[i-1][j],dp[i][j-1])+grid[i][j];
            }
        }
        return dp[m-1][n-1];
    }
};

简单解释

dp[i][j] 表示从左上角走到位置 (i, j) 时,路径上的最小数字总和。

因为只能向下或向右移动,到达当前格子的最后一步只可能来自上方或左方。为了让路径和最小,选择两者中较小的一个,再加上当前格子的值:

dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];

第一列只能从上向下走,所以是该列的前缀和;第一行只能从左向右走,所以是该行的前缀和。

和 62. 不同路径的区别

两题的移动方向相同,区别在于 dp 的含义:

62:到达当前格子的路径数量 = 上方数量 + 左方数量
64:到达当前格子的最小路径和 = min(上方路径和, 左方路径和) + 当前值

复杂度

  • 时间复杂度:O(m * n)
  • 空间复杂度:O(m * n)

注意点

  • 第一行和第一列不是全部初始化为 1,而是分别计算累计路径和。
  • dp[0][0] 会在初始化第一列和第一行时各赋值一次,两次结果都是 grid[0][0],不影响正确性。
  • 内部状态必须先取上方、左方的较小值,再加当前格子的 grid[i][j]

记忆锚点

边界做前缀和;内部格子选上方、左方中代价更小的那条路,再加自己。

面试简述

定义 dp[i][j] 为走到 (i, j) 的最小路径和。第一行和第一列分别用前缀和初始化;其他位置只能从上方或左方到达,因此取二者的较小路径和并加上当前格子的值。时间和空间复杂度均为 O(m * n)

5. 最长回文子串

题面

给定一个字符串 s,找到 s 中最长的回文子串。

回文串是正着读和反着读都相同的字符串;子串必须在原字符串中连续。

示例 1

输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

示例 2

输入:s = "cbbd"
输出:"bb"

数据范围

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成

解法一:中心扩散

class Solution {
public:
    string longestPalindrome(string s) {
        if(s.empty()) return "";
        int n = s.size();
        int start = 0 , maxLen = 0;
        for(int i = 0; i < n; i++){
            int len1 = expand(s,i,i);
            int len2 = expand(s,i,i+1);

            int curMax = max(len1,len2);
            if(curMax > maxLen){
                maxLen = curMax;
                start = i - (maxLen-1)/2;
            }
        }
        return s.substr(start,maxLen);
    }

    int expand(string& s, int left , int right){
        while(left>=0 && right < s.size() && s[left] == s[right]){
            left--;
            right++;
        }
        // 退出循环时,回文范围是 [left + 1, right - 1]
        return right-left-1;
    }
};

简单解释

任何回文串都可以从中心向两边扩散得到。回文中心有两种:

  • 奇数长度回文:中心是一个字符,使用 expand(s, i, i)
  • 偶数长度回文:中心是两个字符之间的位置,使用 expand(s, i, i + 1)

expand 在左右字符相等时不断向外移动,返回以该中心能够得到的最长回文长度。

复杂度

  • 时间复杂度:O(n^2),共有 2n - 1 个中心,每次最坏扩散 O(n)
  • 空间复杂度:O(1)

注意点

  • 扩散条件的顺序必须先检查 left >= 0right < s.size(),再访问 s[left]s[right]
  • 循环退出时 leftright 已经越过有效回文边界,所以真实范围是 [left + 1, right - 1],长度为 right - left - 1
  • 每个位置都要检查 (i, i)(i, i + 1),分别覆盖奇数、偶数长度回文。
  • 起点统一写成 start = i - (maxLen - 1) / 2,奇偶长度都适用。

边界公式推导

设扩散得到的长度为 maxLen,中心左侧需要退回的距离为:

奇数长度 2k + 1:起点为 i - k
偶数长度 2k:    起点为 i - k + 1

整数除法下,两种情况都可以统一成:

start = i - (maxLen - 1) / 2;

记忆锚点

每个位置试两个中心;退出扩散后长度是 right - left - 1,起点是 i - (len - 1) / 2

面试简述

枚举每个可能的回文中心,分别按奇数中心 (i, i) 和偶数中心 (i, i + 1) 向两侧扩散,记录最长长度及起点。时间复杂度 O(n^2),额外空间 O(1)

解法二:区间动态规划

class Solution {
public:
    string longestPalindrome(string s) {
        int n = s.size();
        if (n<2) return s;

        int maxLen = 1;
        int begin = 0;

        vector<vector<bool>> dp(n,vector<bool> (n,false));

        // 长度为 1 的子串都是回文串
        for(int i = 0 ; i < n ; i++){
            dp[i][i] = true;
        }

        for(int i = n - 1; i >= 0 ; i--){
            for(int j = i ; j < n ; j++){
                if(s[i]==s[j]){
                    if(j-i<3){
                        dp[i][j] = true;
                    }else{
                        dp[i][j] = dp[i+1][j-1];
                    }
                }
                if(dp[i][j]&& j-i+1 >maxLen){
                    maxLen = j-i+1;
                    begin = i;
                }
            }
        }

        string ans = s.substr(begin,maxLen);
        return ans;
    }
};

简单解释

dp[i][j] 表示闭区间子串 s[i ... j] 是否为回文串。

当两端字符 s[i] == s[j] 时:

  • 如果子串长度不超过 3,中间为空或只有一个字符,可以直接判断为回文;
  • 如果长度大于 3,还要求内部子串 s[i + 1 ... j - 1] 是回文。

状态转移为:

dp[i][j] = (s[i] == s[j])
        && (j - i < 3 || dp[i + 1][j - 1]);

为什么 i 要倒序遍历

计算 dp[i][j] 时可能需要读取 dp[i + 1][j - 1]。因此必须先计算更靠下的第 i + 1 行,再计算第 i 行,所以 in - 10 遍历。ji 向右遍历即可。

复杂度

  • 时间复杂度:O(n^2)
  • 空间复杂度:O(n^2)

注意点

  • j - i < 3 覆盖长度为 123 的子串;两端相等时,它们内部为空或只有一个字符,不需要访问 dp[i + 1][j - 1]
  • i 必须倒序遍历,保证依赖的内部区间已经计算完成。
  • dp[i][j] 表示闭区间,因此子串长度是 j - i + 1

记忆锚点

两端字符相等,再看里面是不是回文;短串长度不超过 3 时,两端相等就够了。因为依赖 i + 1,所以 i 倒着走。

面试简述

定义 dp[i][j] 表示子串 s[i ... j] 是否为回文。若两端字符相等,短子串可直接成立,长子串则取决于内部状态 dp[i + 1][j - 1]。按 i 从后向前遍历以满足依赖顺序,并记录最长区间。

1143. 最长公共子序列

题面

给定两个字符串 text1text2,返回它们的最长公共子序列长度。如果不存在公共子序列,返回 0

子序列是在不改变剩余字符相对顺序的情况下,删除某些字符或不删除任何字符后得到的新字符串。子序列不要求连续。

示例 1

输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace"

示例 2

输入:text1 = "abc", text2 = "abc"
输出:3

示例 3

输入:text1 = "abc", text2 = "def"
输出:0

数据范围

  • 1 <= text1.length, text2.length <= 1000
  • text1text2 仅由小写英文字母组成

解法一:二维动态规划求长度

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int m = text1.size() ,n = text2.size();
        vector<vector<int>> dp(m+1,vector<int>(n+1));
        dp[0][0] = 0;
        for(int i = 1 ; i <= m ; i++){
            for(int j = 1 ; j <= n ; j++){
                if(text1[i-1]==text2[j-1]){
                    dp[i][j] = dp[i-1][j-1]+1;
                }else{
                    dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
                }
            }
        }
        return dp[m][n];
    }
};

简单解释

dp[i][j] 表示 text1 的前 i 个字符和 text2 的前 j 个字符的最长公共子序列长度。

比较两个前缀的最后一个字符:

  • text1[i - 1] == text2[j - 1]:这个相同字符可以加入公共子序列,答案为 dp[i - 1][j - 1] + 1
  • 两个字符不同:不能同时保留它们,分别尝试舍弃 text1text2 的最后一个字符,取 dp[i - 1][j]dp[i][j - 1] 的较大值。

复杂度

  • 时间复杂度:O(m * n)
  • 空间复杂度:O(m * n)

注意点

  • dp 的下标表示前缀长度,因此访问原字符串时要使用 i - 1j - 1
  • dp[0][j]dp[i][0] 都表示其中一个字符串为空,公共子序列长度为 0vector 默认初始化为 0,无需逐个赋值。
  • 字符相等时使用左上角状态 dp[i - 1][j - 1] + 1;不相等时取上方、左方的最大值。

记忆锚点

字符相等就走左上角并加一;字符不同就在上方、左方中取较大值。

面试简述

定义 dp[i][j] 为两个字符串前 i、前 j 个字符的最长公共子序列长度。末尾字符相等时由左上角状态加一转移,否则舍弃其中一个末尾字符并取较大值,时间和空间复杂度均为 O(m * n)

解法二:根据 DP 表回溯并输出具体序列

原代码中的恢复过程不是 DFS,而是从 dp[m][n] 开始沿状态转移方向回溯。此外,原循环在两个字符相等时只加入字符,却没有同时执行 i--j--,随后又只移动一个下标,可能重复加入同一个匹配。例如 text1 = "aa"text2 = "aa" 时可能错误地得到 "aaa"

下面保留原有思路,修正回溯分支:

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int m = text1.size() ,n = text2.size();
        vector<vector<int>> dp(m+1,vector<int>(n+1));
        dp[0][0] = 0;
        for(int i = 1 ; i <= m ; i++){
            for(int j = 1 ; j <= n ; j++){
                if(text1[i-1]==text2[j-1]){
                    dp[i][j] = dp[i-1][j-1]+1;
                }else{
                    dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
                }
            }
        }

        string res = "";
        int i = m , j = n;
        while(i > 0 && j > 0){
            if(text1[i-1] == text2[j-1]){
                res += text1[i-1];
                i--;
                j--;
            }else if(dp[i-1][j] > dp[i][j-1]){
                i--;
            }else{
                j--;
            }
        }

        // 回溯得到的字符顺序与答案相反
        reverse(res.begin(),res.end());
        cout << res;

        return dp[m][n];
    }
};

简单解释

dp[m][n] 反向恢复路径:

  • 当前两个字符相等:它们属于所恢复的公共子序列,记录字符并同时向左上角移动;
  • 当前字符不等:比较 dp[i - 1][j]dp[i][j - 1],向数值较大的方向移动;
  • 两个方向数值相等时任选一个即可,可能得到不同但长度相同的最长公共子序列。

回溯按“从后向前”的顺序记录字符,所以最后需要 reverse

复杂度

  • 构造 DP 表:时间 O(m * n),空间 O(m * n)
  • 恢复具体序列:时间 O(m + n),结果字符串最多占用 O(min(m, n))

注意点

  • 字符相等时必须同时执行 i--j--,直接移动到左上角。
  • 字符不等时才比较上方、左方状态,向能够保持当前最优长度的方向移动。
  • 回溯得到的字符是逆序的,输出前必须调用 reverse(res.begin(), res.end())
  • 最长公共子序列可能不唯一;上方、左方状态相等时选择任意方向都能恢复出一个合法答案。

记忆锚点

建表向右下,恢复向左上:相等就收字符并走左上,不等就沿较大的 DP 值走,最后反转。

面试简述

先用二维 DP 求最长公共子序列长度,再从右下角回溯。当两个字符相等时记录该字符并同时减小两个下标;不相等时沿较大的相邻状态移动。由于记录顺序从后向前,最终反转字符串即可得到一条最长公共子序列。

72. 编辑距离

题面

给定两个单词 word1word2,返回将 word1 转换成 word2 所需的最少操作数。

每次可以进行以下一种操作:

  • 插入一个字符;
  • 删除一个字符;
  • 替换一个字符。

示例 1

输入:word1 = "horse", word2 = "ros"
输出:3
解释:horse → rorse → rose → ros,依次进行一次替换和两次删除。

示例 2

输入:word1 = "intention", word2 = "execution"
输出:5

数据范围

  • 0 <= word1.length, word2.length <= 500
  • word1word2 仅由小写英文字母组成

解法一:二维动态规划

class Solution {
public:
    int minDistance(string word1, string word2) {
        int m = word1.size();
        int n = word2.size();
        vector<vector<int>> dp(m+1,vector<int>(n+1)) ;

        for(int i = 0; i <= m; i++) dp[i][0] = i;
        for(int j = 0 ; j <= n ; j++) dp[0][j] = j;

        for(int i = 1 ; i <= m ; i++){
            for(int j = 1; j <= n ; j++){
                if(word1[i-1]==word2[j-1]){
                    dp[i][j] = dp[i-1][j-1];
                }else{
                    dp[i][j] = min({dp[i-1][j-1],dp[i-1][j],dp[i][j-1]})+1;
                }
            }
        }
        return dp[m][n];
    }
};

简单解释

dp[i][j] 表示把 word1 的前 i 个字符转换成 word2 的前 j 个字符,所需的最少操作次数。

当前比较的是两个前缀的最后一个字符:

word1[i - 1] 和 word2[j - 1]

如果两个字符相等,不需要对它们进行操作,直接继承去掉这两个字符后的答案:

dp[i][j] = dp[i - 1][j - 1];

如果两个字符不相等,就尝试插入、删除、替换三种操作,并选择代价最小的一种。

三种操作与状态对应关系

操作 转移前的状态 为什么
替换 word1[i - 1] dp[i - 1][j - 1] 最后两个字符通过一次替换完成匹配,两个前缀长度都减一
删除 word1[i - 1] dp[i - 1][j] 删除 word1 的最后一个字符后,只需把它的前 i - 1 个字符转换成 word2 的前 j 个字符
word1 末尾插入 word2[j - 1] dp[i][j - 1] 插入最后一个目标字符之前,先把 word1 的前 i 个字符转换成 word2 的前 j - 1 个字符

每种操作本身都需要一次,所以:

dp[i][j] = min({
    dp[i - 1][j - 1], // 替换
    dp[i - 1][j],     // 删除
    dp[i][j - 1]      // 插入
}) + 1;

为什么插入对应 dp[i][j - 1]

这是最容易混淆的一种状态。目标前缀比当前已经处理好的目标多一个字符 word2[j - 1]。先把 word1 的前 i 个字符变成 word2 的前 j - 1 个字符,再在末尾插入这个新字符,就得到了前 j 个目标字符。因此 i 不变,j 减一。

边界初始化

  • dp[i][0] = i:把 word1 的前 i 个字符变为空串,需要删除 i 次;
  • dp[0][j] = j:把空串变成 word2 的前 j 个字符,需要插入 j 次。

这两条边界也分别对应“连续删除”和“连续插入”。

复杂度

  • 时间复杂度:O(m * n)
  • 空间复杂度:O(m * n)

注意点

  • dp 下标表示前缀长度,访问字符串时使用 word1[i - 1]word2[j - 1]
  • 字符相等时不增加操作次数,直接取左上角 dp[i - 1][j - 1]
  • 字符不等时:左上角是替换,上方是删除,左方是插入,三者取最小值再加 1
  • 初始化第一列为 0 ... m、第一行为 0 ... n,同时覆盖字符串为空的情况。

记忆锚点

相等走左上不加一;不等就在左上、上、左中取最小再加一。左上替换、上方删除、左方插入。

面试简述

定义 dp[i][j] 为将 word1i 个字符转换成 word2j 个字符的最少操作数。末尾字符相等时继承左上角状态;不相等时分别考虑替换、删除和插入,对应左上、上方和左方三个状态,取最小值加一。时间和空间复杂度均为 O(m * n)

十七、技巧

136. 只出现一次的数字

题面

给定一个非空整数数组 nums,除了某个元素只出现一次以外,其余每个元素均出现两次,找出只出现一次的元素。

算法需要满足线性时间复杂度,并且只使用常量额外空间。

示例 1

输入:nums = [2,2,1]
输出:1

示例 2

输入:nums = [4,1,2,1,2]
输出:4

示例 3

输入:nums = [1]
输出:1

数据范围

  • 1 <= nums.length <= 3 * 104
  • -3 * 104 <= nums[i] <= 3 * 104
  • 除一个元素出现一次外,其余元素均出现两次

解法一:异或消除成对元素

class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int ans = 0;
        for(int n : nums){
            ans ^= n;
        }
        return ans;
    }
};

简单解释

异或运算符是 ^。对整数的每个二进制位分别计算:两个二进制位相同得到 0,不同得到 1

0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0

本题只需要记住三条性质:

x ^ x = 0
x ^ 0 = x
异或满足交换律和结合律

因为异或顺序不影响结果,所有出现两次的数字都会成对抵消为 0,最终只剩出现一次的数字。

例如:

4 ^ 1 ^ 2 ^ 1 ^ 2
= 4 ^ (1 ^ 1) ^ (2 ^ 2)
= 4 ^ 0 ^ 0
= 4

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • C++ 中 ^ 表示按位异或,不表示乘方。
  • ans 必须初始化为 0,因为 0 ^ x = x
  • 该方法依赖“其余元素恰好出现两次”的条件;如果出现三次,不能直接这样抵消。

记忆锚点

相同数字异或为零,零和任何数异或仍是它自己;成对消失,单身留下。

面试简述

利用异或的自反性 x ^ x = 0 和单位元性质 0 ^ x = x,把数组所有元素依次异或。由于异或满足交换律和结合律,所有出现两次的数字都会抵消,最终结果就是只出现一次的数字。时间 O(n)、空间 O(1)

169. 多数元素

题面

给定一个大小为 n 的数组 nums,返回其中的多数元素。

多数元素是指在数组中出现次数大于 ⌊n / 2⌋ 的元素。数组非空,并且保证多数元素一定存在。

示例 1

输入:nums = [3,2,3]
输出:3

示例 2

输入:nums = [2,2,1,1,1,2,2]
输出:2

数据范围

  • n == nums.length
  • 1 <= n <= 5 * 104
  • -109 <= nums[i] <= 109
  • 输入保证多数元素一定存在

解法一:栈模拟两两抵消(修正版)

原代码问题

原代码在栈顶元素与当前数字不同时执行:

s.pop();
s.push(n);

这不是把两个不同元素一起抵消,而是用当前元素替换栈顶。因为栈中始终最多只有一个元素,最终结果接近数组的最后一个元素。例如 nums = [3,3,4] 时,多数元素是 3,原代码却会返回 4

正确的栈抵消规则是:

  • 当前数字与栈顶相同:将当前数字压栈,相当于增加一票;
  • 当前数字与栈顶不同:只弹出栈顶,让两个不同元素互相抵消;
  • 栈为空:直接压入当前数字。
class Solution {
public:
    int majorityElement(vector<int>& nums) {
        stack<int> s;
        for(int n : nums){
            if(s.empty() || s.top() == n){
                s.push(n);
            }else{
                s.pop();
            }
        }
        return s.top();
    }
};

简单解释

每次从数组中消去两个不同的元素,不会改变多数元素最终仍会剩下这一事实。

因为多数元素的数量超过数组长度的一半,即使每个其他元素都和一个多数元素配对抵消,多数元素仍然至少剩下一个。因此栈最终的顶部一定是多数元素。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(n),最坏情况下所有元素都相同并全部入栈

注意点

  • 遇到不同元素时只能 pop(),不能弹出后再把当前元素压回去,否则没有完成两两抵消。
  • 题目保证多数元素存在,因此抵消结束后栈一定非空,栈顶就是答案。

记忆锚点

相同就抱团入栈,不同就一换一抵消;人数超过一半的一方一定能留到最后。

面试简述

使用栈模拟不同元素两两抵消。相同元素压栈,不同元素弹出一个栈顶。由于多数元素的数量超过其他所有元素数量之和,它不可能被完全抵消,最终栈顶就是多数元素。

解法二:Boyer–Moore 投票法

栈中真正有用的信息只有“当前候选人”和“它抵消后还剩多少票”,因此可以用两个整数替代整个栈。

class Solution {
public:
    int majorityElement(vector<int>& nums) {
        int candidate = 0;
        int count = 0;

        for(int n : nums){
            if(count == 0){
                candidate = n;
            }

            if(n == candidate){
                count++;
            }else{
                count--;
            }
        }
        return candidate;
    }
};

简单解释

  • candidate 表示当前候选多数元素;
  • count 表示候选元素经过两两抵消后剩余的票数;
  • 遇到相同元素就加一票,遇到不同元素就减一票;
  • count == 0 表示此前候选票数已经全部抵消,从当前元素开始选择新候选人。

因为真正的多数元素出现次数大于 n / 2,它最终一定会成为最后留下的候选人。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • 必须在 count == 0 时先把当前元素设为新候选人,再计算当前这一票。
  • 本题保证多数元素存在,所以最后可以直接返回 candidate;如果题目不保证存在,还需要再遍历一次验证出现次数。

记忆锚点

同候选加一票,不同候选减一票;票数归零就换候选。真正超过一半的人一定赢到最后。

面试简述

Boyer–Moore 投票法把不同元素两两抵消。维护候选元素和票数,相同则加一,不同则减一,票数归零时更换候选人。由于多数元素数量超过一半,它最终一定不会被完全抵消,时间 O(n)、空间 O(1)

75. 颜色分类

题面

给定一个包含红色、白色和蓝色的数组 nums,使用整数 012 分别表示三种颜色。

需要在原数组上进行排序,使相同颜色的元素相邻,并按照 012 的顺序排列。不能使用库内置的排序函数。

示例 1

输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]

示例 2

输入:nums = [2,0,1]
输出:[0,1,2]

数据范围

  • n == nums.length
  • 1 <= n <= 300
  • nums[i]012

解法一:两次遍历依次放置 0 和 1

class Solution {
public:
    void sortColors(vector<int>& nums) {
        int n = nums.size();
        int ptr = 0;
        for (int i = 0; i < n; ++i) {
            if (nums[i] == 0) {
                swap(nums[i], nums[ptr]);
                ++ptr;
            }
        }
        for (int i = ptr; i < n; ++i) {
            if (nums[i] == 1) {
                swap(nums[i], nums[ptr]);
                ++ptr;
            }
        }
    }
};

简单解释

ptr 始终表示下一种目标数字应该放置的位置。

第一遍扫描整个数组,把所有 0 依次交换到数组前面。结束后:

[0, 0, ..., 0, 未处理的 1 和 2]
                ↑ ptr

第二遍从 ptr 开始扫描,把所有 1 依次放到 0 后面。因为数组中只有 012,前面放完 01 后,剩余位置自然全部是 2

复杂度

  • 时间复杂度:O(n),最多完整扫描两遍
  • 空间复杂度:O(1)

注意点

  • 第二遍必须从 ptr 开始,因为 [0, ptr - 1] 已经全部是排好位置的 0
  • 每次交换目标数字后都要执行 ptr++,让它指向下一个待填位置。
  • 不需要专门处理 2,放完 01 后,剩余元素只能是 2

记忆锚点

第一遍把 0 搬到最前面,第二遍从断点开始把 1 接上,剩下的自然全是 2。

面试简述

使用一个指针记录下一个放置位置。第一遍遍历把所有 0 交换到数组前部;第二遍从该指针开始,把所有 1 放到 0 后面,剩余元素自然都是 2。虽然扫描两遍,时间复杂度仍为 O(n),空间为 O(1)

解法二:荷兰国旗三指针

class Solution {
public:
    void sortColors(vector<int>& nums) {
        int p0 = 0; int p2 = nums.size()-1;
        int cur =0;
        while(cur<=p2){
            if(nums[cur]==0){
                swap(nums[p0],nums[cur]);
                cur++;
                p0++;
            }else if(nums[cur]==2){
                swap(nums[p2],nums[cur]);
                p2--;
            }else{
                cur++;
            }
        }
    }
};

简单解释

三个指针把数组划分为四个区间:

[0 ... p0-1]      全是 0
[p0 ... cur-1]    全是 1
[cur ... p2]      尚未检查
[p2+1 ... n-1]    全是 2

检查 nums[cur]

  • 等于 0:与 nums[p0] 交换,p0cur 同时右移;
  • 等于 1:位置已经正确,只移动 cur
  • 等于 2:与 nums[p2] 交换,只把 p2 左移。

cur > p2 时,未知区间为空,排序完成。

复杂度

  • 时间复杂度:O(n),每个元素最多被检查或交换常数次
  • 空间复杂度:O(1)

注意点

  • 循环条件必须是 cur <= p2,因为 p2 所在位置仍属于未检查区间。
  • 遇到 2 与右侧交换后不能立即执行 cur++,因为从 p2 换过来的元素还没有检查,可能是 012
  • 遇到 0 时可以同时移动 p0cur,因为换到 cur 的元素来自已经检查过的 [p0, cur - 1] 区间。

记忆锚点

0 扔左边,2 扔右边,1 自己往前走;换来右侧元素时来源未知,所以 cur 不能动。

面试简述

维护 p0curp2 三个指针,将数组分为 0 区、1 区、未知区和 2 区。根据 nums[cur] 的值决定与左侧或右侧交换。遇到 2 时不移动 cur,因为交换回来的元素仍需检查,从而在一趟扫描内原地完成排序。

31. 下一个排列

题面

整数数组的下一个排列,是指按字典序排列时紧接在当前排列之后、比当前排列更大的那个排列。

如果不存在更大的排列,则将数组重排成字典序最小的排列,也就是升序排列。必须原地修改,并且只使用常量额外空间。

示例 1

输入:nums = [1,2,3]
输出:[1,3,2]

示例 2

输入:nums = [3,2,1]
输出:[1,2,3]

示例 3

输入:nums = [1,1,5]
输出:[1,5,1]

数据范围

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 100

解法一:寻找右侧下降断点

class Solution {
public:
    void nextPermutation(vector<int>& nums) {
        int n = nums.size();
        int idx = 0;
        for(int i = n-1; i > 0 ; i--){
            if(nums[i-1] < nums[i]){
                idx = i;
                break;
            }
        }
        reverse(nums.begin()+idx,nums.end());
        if(idx==0) return;
        for(int i = idx; i < n; i++){
            if(nums[idx-1]<nums[i]){
                swap(nums[idx-1],nums[i]);
                break;
            }
        }
    }
};

核心目标

下一个排列需要同时满足:

  1. 比当前排列更大;
  2. 在所有更大的排列中尽可能小。

为了让变化尽量小,应尽可能保持左侧高位不变,因此要从右向左寻找第一个能够增大的位置。

第一步:寻找右侧下降断点

从数组末尾向左寻找第一个满足:

nums[i - 1] < nums[i]

找到后:

  • idx = i 是后缀的起点;
  • idx - 1 是需要增大的基准位置;
  • 原后缀 nums[idx ... n - 1] 一定是非递增的。

非递增后缀已经是这些元素能够组成的最大排列,仅调整后缀无法得到更大的结果,所以必须增大它左边的 nums[idx - 1]

第二步:把后缀变成最小排列

reverse(nums.begin() + idx, nums.end());

原后缀是非递增的,反转后变成非递减,也就是该后缀能够组成的最小排列。

如果 idx == 0,说明整个数组都是非递增的,当前排列已经是最大排列。反转整个数组后得到最小排列,直接返回。

第三步:让基准值只增大一点

后缀已经升序排列,从左向右寻找第一个严格大于 nums[idx - 1] 的元素:

if(nums[idx - 1] < nums[i])

它就是后缀中比基准值大的最小元素。交换后,基准位置获得最小幅度的增大,后缀仍保持尽可能小,因此得到的就是下一个排列。

为什么先反转再交换仍然正确

常见模板通常先交换、再反转后缀;这份代码的顺序相反,但同样正确。

后缀反转成升序后,选择的是第一个大于基准值的元素。它前面的元素都小于等于基准值,后面的元素都大于等于被选元素。交换后,基准值被放入这个位置,后缀仍然保持升序,因此无需再次排序。

示例推演

[1,2,7,4,3,1] 为例:

1. 从右寻找断点:
   2 < 7,idx = 2,基准值为 nums[1] = 2

2. 反转后缀 [7,4,3,1]:
   [1,2,1,3,4,7]

3. 后缀中第一个大于 2 的元素是 3,交换:
   [1,3,1,2,4,7]

这就是比原排列大的最小排列。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • 寻找断点时必须使用严格小于 nums[i - 1] < nums[i],相等不能让排列变大。
  • idx - 1 才是要增大的基准位置,idx 是后缀起点。
  • idx == 0 表示整个数组非递增,反转后直接返回。
  • 后缀升序后要寻找第一个严格大于基准值的元素,它才是“刚好大一点”的选择。
  • reverse(first, last) 的右边界 last 不包含在反转范围内。

记忆锚点

从右找第一个能变大的数;后缀先升序,再找一个刚好比它大的数交换。找不到断点就整体反转。

面试简述

为了得到字典序中紧邻的更大排列,从右向左找到第一个上升关系,将其左侧元素作为基准值。右侧后缀原本非递增,先反转成升序,再找到第一个大于基准值的元素进行交换,从而让高位只增加最小幅度,同时保持后缀最小。整体时间 O(n)、空间 O(1)

287. 寻找重复数

题面

给定一个包含 n + 1 个整数的数组 nums,其中每个整数都在 [1, n] 范围内。数组中只有一个重复的整数,返回这个重复数。

重复的整数可能出现两次以上。解决方案不能修改数组,并且只能使用常量级额外空间。

示例 1

输入:nums = [1,3,4,2,2]
输出:2

示例 2

输入:nums = [3,1,3,4,2]
输出:3

示例 3

输入:nums = [3,3,3,3,3]
输出:3

数据范围

  • 1 <= n <= 105
  • nums.length == n + 1
  • 1 <= nums[i] <= n
  • 只有一个整数出现两次或多次,其余整数均只出现一次

解法一:把数组映射成链表并寻找环入口

class Solution {
public:
    int findDuplicate(vector<int>& nums) {
        int slow = nums[0];
        int fast = nums[nums[0]];

        while(slow!=fast){
            slow = nums[slow];
            fast = nums[nums[fast]];
        }

        fast = 0;
        while(fast!=slow){
            fast = nums[fast];
            slow = nums[slow];
        }
        return slow;
    }
};

如何把数组看成链表

把每个数组下标看成一个节点,并定义:

节点 i 的下一跳 = nums[i]

因为数组下标范围是 [0, n],而 nums[i] 始终位于 [1, n],所以每个数组值都可以安全地作为下一次访问的下标。

从下标 0 出发会得到一条路径:

0 → nums[0] → nums[nums[0]] → ...

数组只有有限个下标,不断沿下一跳移动一定会重复访问某个节点,因此形成环。

为什么环入口就是重复数

如果数字 x 重复出现,说明数组中至少有两个不同下标都满足:

nums[a] = x
nums[b] = x

在链表映射中,这表示至少有两条边都指向节点 x。从节点 0 出发的路径进入环时,环入口同时接收:

  • 环外路径指向它的一条边;
  • 环内前驱指向它的一条边。

因此环入口正是拥有多个来源的重复数字。

例如 nums = [1,3,4,2,2]

0 → 1 → 3 → 2 → 4
              ↑   ↓
              └───┘

环入口是 2,也就是重复数。

第一阶段:让快慢指针在环内相遇

慢指针每次走一步:

slow = nums[slow];

快指针每次走两步:

fast = nums[nums[fast]];

只要存在环,快指针最终一定会在环内追上慢指针。

为什么这样初始化

链表的真正起点是下标 0。如果把两个指针都初始化为 0,却直接使用:

while(slow != fast)

那么两者一开始就相等,循环一次都不会执行,程序会错误地认为已经在环中相遇。

当前代码使用:

int slow = nums[0];
int fast = nums[nums[0]];

等价于让两个指针都从节点 0 出发,并提前执行第一轮移动:

slow:0 出发走 1 步
fast:0 出发走 2 步

这样再进入普通 while(slow != fast) 就不会在起点发生假相遇。

另一种等价写法是让两者都初始化为 0,但使用 do...while 保证先移动一次:

int slow = 0;
int fast = 0;
do{
    slow = nums[slow];
    fast = nums[nums[fast]];
}while(slow != fast);

第二阶段:寻找环入口

第一次相遇后,将其中一个指针重新放回真正的起点 0

fast = 0;

随后两个指针每次都走一步:

fast = nums[fast];
slow = nums[slow];

根据 Floyd 判圈算法,它们下一次相遇的位置就是环入口,也就是重复数。

这里必须重置到下标 0,而不是 nums[0],因为 Floyd 算法计算的入环距离是从整条路径的起点开始的。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • 数组值能作为下标,是因为题目保证 1 <= nums[i] <= n
  • slow = nums[0]fast = nums[nums[0]] 是提前完成第一轮移动,不是随意选择的初始化位置。
  • 第一次相遇只说明找到了环,不一定是环入口;必须再执行第二阶段。
  • 第二阶段要把一个指针重置到下标 0,然后两个指针都改为每次走一步。
  • 题目禁止修改数组,因此不能使用原地负号标记等方法。

记忆锚点

下标指向数组值,把数组变成链表;慢指针先走一步、快指针先走两步,环内相遇后把一个指针放回 0,同速再走,相遇点就是重复数。

面试简述

将下标 inums[i] 的映射看成链表的下一跳。由于数组值位于 [1, n],从下标 0 出发一定会进入环,而环入口就是具有多个前驱的重复数字。先用快慢指针在环内相遇,再将一个指针重置到 0,两者同速前进,第二次相遇点即为重复数。时间 O(n)、空间 O(1),且不修改原数组。

posted @ 2026-07-29 15:20  clayJ  阅读(7)  评论(0)    收藏  举报