Hot100速记-第三部分
Hot 100 速记(第三部分)
十三、堆
215. 数组中的第 K 个最大元素
- 难度:中等
- 分类:堆
- 原题:LeetCode 215. 数组中的第 K 个最大元素
题面
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
需要找的是数组排序后的第 k 个最大元素,而不是第 k 个不同的元素。
要求设计并实现时间复杂度为 O(n) 的算法。
示例 1
nums = [3,2,1,5,6,4], k = 25示例 2
nums = [3,2,3,1,2,4,5,5,6], k = 44数据范围
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 个高频元素
- 难度:中等
- 分类:堆
- 原题:LeetCode 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^41 <= 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;
每次分区按照下面四步执行:
- 在
[l, r]中随机选择一个基准,将它交换到v[l],再保存到pivot。 - 从右向左寻找频率严格大于基准的元素,填入左侧空位。
- 从左向右寻找频率严格小于基准的元素,填入右侧空位。
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. 数据流的中位数
- 难度:困难
- 分类:堆
- 原题:LeetCode 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前,数据结构中至少有一个元素 - 最多调用
addNum和findMedian共5 * 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
每次加入新数都使用固定的三步:
- 新数先压入
left。 - 把
left的最大值搬到right,保证左右顺序正确。 - 如果
right数量更多,再把right的最小值搬回left。
因此:
- 总数为奇数时,
left多一个,中位数为left.top()。 - 总数为偶数时,两边数量相同,中位数为两个堆顶的平均值。
复杂度
addNum时间复杂度:O(log n)findMedian时间复杂度:O(1)- 空间复杂度:
O(n)
注意点
- 左边必须使用大根堆,右边必须使用小根堆,两个堆顶才是最靠近中间的两个数。
- 调整后只能让
left与right数量相等,或者让left恰好多一个。 - 偶数个元素时必须除以
2.0,否则会发生整数除法。
记忆锚点
新数进左堆,左最大搬右堆;右边如果人更多,右最小再搬回左边。
面试简述
使用大根堆保存较小的一半,小根堆保存较大的一半,并让左堆数量与右堆相等或多一个。加入元素时通过两次堆间搬运同时维护顺序和数量,中位数就是一个堆顶或两个堆顶的平均值。
十四、贪心算法
121. 买卖股票的最佳时机
- 难度:简单
- 分类:贪心算法
- 原题:LeetCode 121. 买卖股票的最佳时机
题面
给定数组 prices,其中 prices[i] 表示某只股票第 i 天的价格。
只能选择某一天买入,并在未来某个不同的日子卖出。返回这笔交易能够获得的最大利润;如果不能获得利润,则返回 0。
示例 1
prices = [7,1,5,3,6,4]51 时买入,价格为 6 时卖出,利润为 5。示例 2
prices = [7,6,4,3,1]0数据范围
1 <= prices.length <= 10^50 <= 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. 跳跃游戏
- 难度:中等
- 分类:贪心算法
- 原题:LeetCode 55. 跳跃游戏
题面
给定一个非负整数数组 nums,初始位置位于第一个下标。数组中的每个元素表示在该位置能够向后跳跃的最大长度。
判断是否能够到达最后一个下标。
示例 1
nums = [2,3,1,1,4]true示例 2
nums = [3,2,1,0,4]false数据范围
1 <= nums.length <= 10^40 <= 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 时:
- 如果
far < i,说明连当前位置都到不了,直接返回false。 - 否则可以站在
i上继续跳,用i + nums[i]更新最远位置。 - 一旦
far >= n - 1,说明能够到达终点。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 必须先判断
far < i,只有当前位置可达时,才能使用它更新更远边界。 far表示整个可达区间的最远边界,不要求真的按照某条具体路径跳过去。
记忆锚点
只管最远能到哪;如果遍历下标跑到了最远边界之外,就说明中途断路。
面试简述
从左到右维护目前能够到达的最远下标。只要当前下标仍在可达范围内,就用当前位置扩展最远边界;如果当前下标超过边界则无法到达,边界覆盖末尾则成功。
45. 跳跃游戏 II
- 难度:中等
- 分类:贪心算法
- 原题:LeetCode 45. 跳跃游戏 II
题面
给定一个长度为 n 的整数数组 nums,初始位置位于下标 0。nums[i] 表示从下标 i 最远能够向后跳多远。
返回到达下标 n - 1 的最少跳跃次数。题目保证终点可达。
示例 1
nums = [2,3,1,1,4]2示例 2
nums = [2,3,0,1,4]2数据范围
1 <= nums.length <= 10^40 <= 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. 划分字母区间
- 难度:中等
- 分类:贪心算法
- 原题:LeetCode 763. 划分字母区间
题面
给定一个字符串 s,将它划分为尽可能多的片段,使同一字母最多出现在一个片段中。
划分结果按顺序连接后仍然是原字符串。返回每个片段的长度。
示例 1
s = "ababcbacadefegdehijhklij"[9,7,8]"ababcbaca"、"defegde"、"hijhklij"。示例 2
s = "eccbbbbdec"[10]数据范围
1 <= s.length <= 500s仅由小写英文字母组成
解法一:维护当前片段的最远右边界
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. 爬楼梯
- 难度:简单
- 分类:动态规划
- 原题:LeetCode 70. 爬楼梯
题面
假设你正在爬楼梯,需要 n 阶才能到达楼顶。
每次可以爬 1 或 2 个台阶,求有多少种不同的方法可以爬到楼顶。
示例 1
n = 22示例 2
n = 33数据范围
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
这和斐波那契数列的递推形式相同,只是初始项发生了偏移。a、b 分别保存前两项,每轮计算下一项,不需要完整的 dp 数组。
为什么不能直接使用递归
不是递归本身不能用,而是不能使用没有记忆化的裸递归。直接递归时,f(n - 2)、f(n - 3) 等子问题会被反复计算,时间复杂度接近 O(2^n)。加入记忆化以后递归也可以做到 O(n),但本题的迭代写法只需要 O(1) 额外空间。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
n == 1时直接返回1,否则初始化b = 2才有意义。- 更新顺序是先计算新值,再执行
a = b、b = temp。
记忆锚点
最后一步来自前一阶或前两阶,所以方案数等于前两项之和;这是初始项错位的斐波那契数列。
面试简述
定义 f(i) 为爬到第 i 阶的方案数,最后一步只能爬 1 阶或 2 阶,所以 f(i) = f(i - 1) + f(i - 2)。使用两个变量滚动保存前两项,时间复杂度 O(n),空间复杂度 O(1)。
118. 杨辉三角
- 难度:简单
- 分类:动态规划
- 原题:LeetCode 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 + 1 个 1。对内部位置 j,使用上一行的 ans[i - 1][j - 1] + ans[i - 1][j] 计算,时间复杂度为 O(numRows^2)。
198. 打家劫舍
- 难度:中等
- 分类:动态规划
- 原题:LeetCode 198. 打家劫舍
题面
给定一个代表每间房屋存放金额的非负整数数组 nums。如果同一晚上闯入两间相邻的房屋,防盗系统会自动报警。
计算在不触动警报的情况下,一夜之内能够偷窃到的最高金额。
示例 1
nums = [1,2,3,1]41 + 3 = 4。示例 2
nums = [2,7,9,3,1]12数据范围
1 <= nums.length <= 1000 <= 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. 完全平方数
- 难度:中等
- 分类:动态规划
- 原题:LeetCode 279. 完全平方数
题面
给定整数 n,返回和为 n 的完全平方数的最少数量。
完全平方数是能够写成某个整数平方的整数,例如 1、4、9 和 16。
示例 1
n = 12312 = 4 + 4 + 4。示例 2
n = 13213 = 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的平方数,同一个平方数可以重复使用。
记忆锚点
枚举答案中的最后一个平方数:拿掉
j²后,答案就是dp[i - j²] + 1。
面试简述
定义 dp[i] 为组成 i 的最少平方数数量。枚举最后使用的完全平方数 j²,由 dp[i - j²] + 1 更新当前状态。共有 n 个状态,每个状态枚举至多 sqrt(n) 个平方数。
322. 零钱兑换
- 难度:中等
- 分类:动态规划
- 原题:LeetCode 322. 零钱兑换
题面
给定不同面额的硬币数组 coins 和一个总金额 amount,计算凑成该金额所需的最少硬币数量。
每种硬币的数量可以认为是无限的。如果无法凑成该金额,返回 -1。
示例 1
coins = [1,2,5], amount = 11311 = 5 + 5 + 1。示例 2
coins = [2], amount = 3-1示例 3
coins = [1], amount = 00数据范围
1 <= coins.length <= 121 <= coins[i] <= 231 - 10 <= 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. 单词拆分
- 难度:中等
- 分类:动态规划
- 原题:LeetCode 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 <= 3001 <= wordDict.length <= 10001 <= wordDict[i].length <= 20s和wordDict[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. 最长递增子序列
- 难度:中等
- 分类:动态规划
- 原题:LeetCode 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. 乘积最大子数组
- 难度:中等
- 分类:动态规划
- 原题:LeetCode 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\_max、temp\_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:以当前位置结尾的最小乘积。
新状态有三种来源:
- 只选择当前元素
nums[i],相当于从当前位置重新开始; - 旧的最大乘积乘以
nums[i]; - 旧的最小乘积乘以
nums[i]。
分别对三者取最大值和最小值即可。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 计算
cur_min和cur_max时都必须使用上一轮的旧值,所以要先保存temp_min、temp_max。 - 必须把
nums[i]本身也作为候选值,这样遇到0或此前乘积变差时能够从当前位置重新开始。 - 负数会让最大值和最小值的角色互换,因此两个状态缺一不可。
记忆锚点
加法只记最大值;乘法遇到负数会翻转,所以最大值和最小值都要带着走。
面试简述
对每个位置维护以它结尾的最大乘积与最小乘积。由于负数可能把最小乘积翻转成最大乘积,两个状态都从当前元素、旧最大值乘当前元素、旧最小值乘当前元素这三项中更新。遍历一次即可,时间 O(n)、空间 O(1)。
416. 分割等和子集
- 难度:中等
- 分类:动态规划
- 原题:LeetCode 416. 分割等和子集
题面
给定一个只包含正整数的非空数组 nums,判断是否可以将数组分割成两个子集,使两个子集的元素和相等。
示例 1
nums = [1,5,11,5]true[1,5,5] 和 [11]。示例 2
nums = [1,2,3,5]false数据范围
1 <= nums.length <= 2001 <= 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. 最长有效括号
- 难度:困难
- 分类:动态规划
- 原题:LeetCode 32. 最长有效括号
题面
给定一个只包含 '(' 和 ')' 的字符串 s,找出最长有效(格式正确且连续)括号子串的长度。
示例 1
s = "(()"2"()"。示例 2
s = ")()())"4"()()"。示例 3
s = ""0数据范围
0 <= s.length <= 3 * 104s[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. 不同路径
- 难度:中等
- 分类:多维动态规划
- 原题:LeetCode 62. 不同路径
题面
一个机器人位于一个 m x n 网格的左上角。机器人每次只能向下或者向右移动一步,求它到达网格右下角共有多少条不同的路径。
示例 1
m = 3, n = 728示例 2
m = 3, n = 23数据范围
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 = 1、j = 1开始,避免访问dp[-1][j]或dp[i][-1]。 m表示行数,n表示列数,最终答案是dp[m - 1][n - 1]。
记忆锚点
只能向右或向下,所以到达当前格子的路径数,就是上方路径数加左方路径数。
面试简述
定义 dp[i][j] 为到达网格位置 (i, j) 的路径数。第一行和第一列都只有一种走法;其他位置只能从上方或左方到达,因此两者路径数相加。时间和空间复杂度均为 O(m * n)。
64. 最小路径和
- 难度:中等
- 分类:多维动态规划
- 原题:LeetCode 64. 最小路径和
题面
给定一个包含非负整数的 m x n 网格 grid,找出一条从左上角到右下角的路径,使路径上的数字总和最小。
每次只能向下或者向右移动一步。
示例 1
grid = [[1,3,1],[1,5,1],[4,2,1]]71 → 3 → 1 → 1 → 1 的总和最小。示例 2
grid = [[1,2,3],[4,5,6]]12数据范围
m == grid.lengthn == grid[i].length1 <= m, n <= 2000 <= 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. 最长回文子串
- 难度:中等
- 分类:多维动态规划
- 原题:LeetCode 5. 最长回文子串
题面
给定一个字符串 s,找到 s 中最长的回文子串。
回文串是正着读和反着读都相同的字符串;子串必须在原字符串中连续。
示例 1
s = "babad""bab""aba" 同样是符合题意的答案。示例 2
s = "cbbd""bb"数据范围
1 <= s.length <= 1000s仅由数字和英文字母组成
解法一:中心扩散
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 >= 0、right < s.size(),再访问s[left]、s[right]。 - 循环退出时
left、right已经越过有效回文边界,所以真实范围是[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 行,所以 i 从 n - 1 向 0 遍历。j 从 i 向右遍历即可。
复杂度
- 时间复杂度:
O(n^2) - 空间复杂度:
O(n^2)
注意点
j - i < 3覆盖长度为1、2、3的子串;两端相等时,它们内部为空或只有一个字符,不需要访问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. 最长公共子序列
- 难度:中等
- 分类:多维动态规划
- 原题:LeetCode 1143. 最长公共子序列
题面
给定两个字符串 text1 和 text2,返回它们的最长公共子序列长度。如果不存在公共子序列,返回 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 <= 1000text1和text2仅由小写英文字母组成
解法一:二维动态规划求长度
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;- 两个字符不同:不能同时保留它们,分别尝试舍弃
text1或text2的最后一个字符,取dp[i - 1][j]和dp[i][j - 1]的较大值。
复杂度
- 时间复杂度:
O(m * n) - 空间复杂度:
O(m * n)
注意点
dp的下标表示前缀长度,因此访问原字符串时要使用i - 1、j - 1。dp[0][j]和dp[i][0]都表示其中一个字符串为空,公共子序列长度为0;vector默认初始化为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. 编辑距离
- 难度:中等
- 分类:多维动态规划
- 原题:LeetCode 72. 编辑距离
题面
给定两个单词 word1 和 word2,返回将 word1 转换成 word2 所需的最少操作数。
每次可以进行以下一种操作:
- 插入一个字符;
- 删除一个字符;
- 替换一个字符。
示例 1
word1 = "horse", word2 = "ros"3horse → rorse → rose → ros,依次进行一次替换和两次删除。示例 2
word1 = "intention", word2 = "execution"5数据范围
0 <= word1.length, word2.length <= 500word1和word2仅由小写英文字母组成
解法一:二维动态规划
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] 为将 word1 前 i 个字符转换成 word2 前 j 个字符的最少操作数。末尾字符相等时继承左上角状态;不相等时分别考虑替换、删除和插入,对应左上、上方和左方三个状态,取最小值加一。时间和空间复杂度均为 O(m * n)。
十七、技巧
136. 只出现一次的数字
- 难度:简单
- 分类:技巧
- 原题:LeetCode 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. 多数元素
- 难度:简单
- 分类:技巧
- 原题:LeetCode 169. 多数元素
题面
给定一个大小为 n 的数组 nums,返回其中的多数元素。
多数元素是指在数组中出现次数大于 ⌊n / 2⌋ 的元素。数组非空,并且保证多数元素一定存在。
示例 1
nums = [3,2,3]3示例 2
nums = [2,2,1,1,1,2,2]2数据范围
n == nums.length1 <= 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. 颜色分类
- 难度:中等
- 分类:技巧
- 原题:LeetCode 75. 颜色分类
题面
给定一个包含红色、白色和蓝色的数组 nums,使用整数 0、1、2 分别表示三种颜色。
需要在原数组上进行排序,使相同颜色的元素相邻,并按照 0、1、2 的顺序排列。不能使用库内置的排序函数。
示例 1
nums = [2,0,2,1,1,0][0,0,1,1,2,2]示例 2
nums = [2,0,1][0,1,2]数据范围
n == nums.length1 <= n <= 300nums[i]为0、1或2
解法一:两次遍历依次放置 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 后面。因为数组中只有 0、1、2,前面放完 0 和 1 后,剩余位置自然全部是 2。
复杂度
- 时间复杂度:
O(n),最多完整扫描两遍 - 空间复杂度:
O(1)
注意点
- 第二遍必须从
ptr开始,因为[0, ptr - 1]已经全部是排好位置的0。 - 每次交换目标数字后都要执行
ptr++,让它指向下一个待填位置。 - 不需要专门处理
2,放完0和1后,剩余元素只能是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]交换,p0、cur同时右移; - 等于
1:位置已经正确,只移动cur; - 等于
2:与nums[p2]交换,只把p2左移。
当 cur > p2 时,未知区间为空,排序完成。
复杂度
- 时间复杂度:
O(n),每个元素最多被检查或交换常数次 - 空间复杂度:
O(1)
注意点
- 循环条件必须是
cur <= p2,因为p2所在位置仍属于未检查区间。 - 遇到
2与右侧交换后不能立即执行cur++,因为从p2换过来的元素还没有检查,可能是0、1或2。 - 遇到
0时可以同时移动p0和cur,因为换到cur的元素来自已经检查过的[p0, cur - 1]区间。
记忆锚点
0 扔左边,2 扔右边,1 自己往前走;换来右侧元素时来源未知,所以
cur不能动。
面试简述
维护 p0、cur、p2 三个指针,将数组分为 0 区、1 区、未知区和 2 区。根据 nums[cur] 的值决定与左侧或右侧交换。遇到 2 时不移动 cur,因为交换回来的元素仍需检查,从而在一趟扫描内原地完成排序。
31. 下一个排列
- 难度:中等
- 分类:技巧
- 原题:LeetCode 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 <= 1000 <= 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;
}
}
}
};
核心目标
下一个排列需要同时满足:
- 比当前排列更大;
- 在所有更大的排列中尽可能小。
为了让变化尽量小,应尽可能保持左侧高位不变,因此要从右向左寻找第一个能够增大的位置。
第一步:寻找右侧下降断点
从数组末尾向左寻找第一个满足:
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. 寻找重复数
- 难度:中等
- 分类:技巧
- 原题:LeetCode 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 <= 105nums.length == n + 11 <= 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,同速再走,相遇点就是重复数。
面试简述
将下标 i 到 nums[i] 的映射看成链表的下一跳。由于数组值位于 [1, n],从下标 0 出发一定会进入环,而环入口就是具有多个前驱的重复数字。先用快慢指针在环内相遇,再将一个指针重置到 0,两者同速前进,第二次相遇点即为重复数。时间 O(n)、空间 O(1),且不修改原数组。

浙公网安备 33010602011771号