Hot100速记
LeetCode Hot 100 速记
一、哈希
1. 两数之和
- 难度:简单
- 分类:哈希
- 原题:LeetCode 1. 两数之和
题面
给定一个整数数组 nums 和一个整数目标值 target,请在数组中找出和为 target 的两个整数,并返回它们的数组下标。
可以假设每种输入只会对应一个答案,并且不能重复使用同一个元素。答案可以按任意顺序返回。
示例 1
nums = [2,7,11,15], target = 9[0,1]nums[0] + nums[1] == 9,因此返回 [0,1]。示例 2
nums = [3,2,4], target = 6[1,2]示例 3
nums = [3,3], target = 6[0,1]数据范围
2 <= nums.length <= 10^4-10^9 <= nums[i] <= 10^9-10^9 <= target <= 10^9- 只会存在一个有效答案
进阶
能否设计一个时间复杂度小于 O(n²) 的算法?
解法一:哈希表查找补数
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int,int> tab;
for(int i = 0 ; i < nums.size(); i++){
if(tab.count(target-nums[i])){
return {i,tab[target-nums[i]]};
}
tab[nums[i]] = i;
}
return {-1,-1};
}
};
简单解释
tab 保存已经遍历过的元素及其下标,即“元素值 → 下标”。
遍历到 nums[i] 时,先在哈希表中查找补数 target - nums[i]。如果补数已经出现,就返回当前下标和补数的下标;否则将当前元素存入哈希表,供后面的元素查询。
复杂度
- 时间复杂度:平均
O(n) - 空间复杂度:
O(n)
记忆锚点
遍历当前数,先查补数,再存当前数。
面试简述
使用哈希表记录已经遍历过的数字及其下标。对于每个 nums[i],检查 target - nums[i] 是否已经出现;若出现则直接返回两个下标,否则保存当前数字。平均时间复杂度为 O(n),空间复杂度为 O(n)。
49. 字母异位词分组
- 难度:中等
- 分类:哈希
- 原题:LeetCode 49. 字母异位词分组
题面
给定一个字符串数组 strs,将其中的字母异位词组合在一起。可以按任意顺序返回结果列表。
字母异位词由相同字母重新排列而成,各字母的出现次数相同。
示例 1
strs = ["eat", "tea", "tan", "ate", "nat", "bat"][["bat"],["nat","tan"],["ate","eat","tea"]]"nat" 和 "tan" 属于一组;"ate"、"eat" 和 "tea" 属于一组;"bat" 单独一组。示例 2
strs = [""][[""]]示例 3
strs = ["a"][["a"]]数据范围
1 <= strs.length <= 10^40 <= strs[i].length <= 100strs[i]仅包含小写字母
解法一:排序后的字符串作为哈希键
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string,vector<string>> a;
for(string s : strs){
string k = s;
sort(k.begin(),k.end());
a[k].emplace_back(s);
}
vector<vector<string>> ans;
for(auto pair : a){
ans.emplace_back(pair.second);
}
return ans;
}
};
简单解释
字母异位词包含相同的字母,因此排序后会得到相同的字符串。
遍历 strs,将每个字符串的排序结果作为哈希键,把原字符串加入对应分组。遍历结束后,再将哈希表中的所有分组放入答案数组。
复杂度
设字符串数量为 n,字符串的最大长度为 k:
- 时间复杂度:
O(nk log k) - 空间复杂度:
O(nk)
记忆锚点
异位词排序后相同,用排序结果作为分组键。
面试简述
将每个字符串排序,并把排序结果作为哈希表的键。字母异位词排序后完全相同,因此会进入同一个分组。最后收集哈希表中的所有分组即可,时间复杂度为 O(nk log k)。
128. 最长连续序列
- 难度:中等
- 分类:哈希
- 原题:LeetCode 128. 最长连续序列
题面
给定一个未排序的整数数组 nums,找出数字连续的最长序列长度。序列中的元素不要求在原数组中连续。
要求设计并实现时间复杂度为 O(n) 的算法。
示例 1
nums = [100,4,200,1,3,2]4[1,2,3,4],长度为 4。示例 2
nums = [0,3,7,2,5,8,4,6,0,1]9示例 3
nums = [1,0,1,2]3数据范围
0 <= nums.length <= 10^5-10^9 <= nums[i] <= 10^9
解法一:哈希集合寻找序列起点
class Solution {
public:
int longestConsecutive(vector<int>& nums) {
unordered_set<int> s;
for(int& n : nums){
s.insert(n);
}
int ans = 0;
for(auto & n : s){
int count = 1;
if(s.find(n-1) == s.end()){
int target = n+1;
while(s.find(target)!=s.end()){
count++;
target++;
}
}
ans = max(ans,count);
}
return ans;
}
};
简单解释
先将所有数字放入哈希集合,完成去重,并获得平均 O(1) 的查找效率。
遍历集合时,只有当 n - 1 不在集合中,才说明 n 是一段连续序列的起点。随后不断查找 n + 1、n + 2 等数字,统计当前序列长度并更新答案。
复杂度
- 时间复杂度:平均
O(n) - 空间复杂度:
O(n)
注意点
- 只有当
n - 1不存在时才向后查找;否则会重复扫描同一段序列,最坏退化为O(n²)。 - 空数组没有进入遍历,依靠
ans初始化为0返回正确结果。
记忆锚点
只从没有前驱
n - 1的数字开始向后数。
面试简述
先用哈希集合对数组去重。遍历集合时,只从不存在前驱 n - 1 的数字开始向后扩展连续序列,从而保证每个数字最多参与一次有效扩展。平均时间复杂度为 O(n),空间复杂度为 O(n)。
二、双指针
283. 移动零
- 难度:简单
- 分类:双指针
- 原题:LeetCode 283. 移动零
题面
给定一个数组 nums,将所有 0 移动到数组末尾,同时保持非零元素的相对顺序。
必须在不复制数组的情况下,原地修改数组。
示例 1
nums = [0,1,0,3,12][1,3,12,0,0]示例 2
nums = [0][0]数据范围
1 <= nums.length <= 10^4-2^31 <= nums[i] <= 2^31 - 1
进阶
尽量减少操作次数。
解法一:双指针原地交换
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int l = 0, r = 0;
int n = nums.size();
while(r<n){
if(l!=r && nums[l]!=nums[r]){
swap(nums[l],nums[r]);
}
if(nums[l]!=0){
l++;
}
r++;
}
}
};
简单解释
r 从左到右扫描数组,l 指向最靠左、等待被非零元素替换的位置。
当 l 和 r 不在同一位置且二者数值不同时,此时相当于 l 指向零、r 指向非零元素,交换两者。只要 nums[l] 已经是非零元素,就让 l 向后移动;r 每轮都向后扫描。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 快速回忆:
r负责扫描,l守住最左侧的零,遇到后面的非零元素就交换。 r每轮都加一;l只有在nums[l] != 0时才加一。
记忆锚点
r找非零,l找空位,交换后一起向前。
面试简述
使用两个指针原地处理数组:右指针扫描元素,左指针定位最靠左的零。当右侧出现非零元素时,将其交换到左侧空位,并推进左指针。每个元素只扫描一次,时间复杂度为 O(n),空间复杂度为 O(1)。
11. 盛最多水的容器
- 难度:中等
- 分类:双指针
- 原题:LeetCode 11. 盛最多水的容器
题面
给定一个长度为 n 的整数数组 height。第 i 条垂线的两个端点为 (i, 0) 和 (i, height[i])。
找出其中两条线,使它们与 x 轴构成的容器能够容纳最多的水,返回最大水量。容器不能倾斜。
示例 1
height = [1,8,6,2,5,4,8,3,7]4949。示例 2
height = [1,1]1数据范围
n == height.length2 <= n <= 10^50 <= height[i] <= 10^4
解法一:相向双指针
class Solution {
public:
int maxArea(vector<int>& height) {
int ans = 0;
int l = 0, r = height.size()-1;
while(l < r){
int cur = min(height[l],height[r])*(r-l);
ans = max(cur,ans);
if(height[l] < height[r]){
l++;
}else{
r--;
}
}
return ans;
}
};
简单解释
左右指针从数组两端开始,当前容器面积为:
min(height[l], height[r]) × (r - l)
每轮记录当前面积,然后向内移动较短的一侧。虽然宽度会减小,但只有替换掉短板,容器高度才有可能增加,从而获得更大的面积。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 每轮先计算面积,再移动较短的一侧;移动长板无法突破当前短板限制。
- 两侧等高时移动任意一侧都可以,当前代码选择
r--。
记忆锚点
两端向内收:先算面积,再丢掉短板。
面试简述
使用相向双指针,从最大宽度开始枚举。面积由较短的边决定,因此每轮计算面积后移动短边,尝试用更高的边弥补宽度损失。两个指针只向内移动,时间复杂度为 O(n),空间复杂度为 O(1)。
15. 三数之和
- 难度:中等
- 分类:双指针
- 原题:LeetCode 15. 三数之和
题面
给定一个整数数组 nums,找出所有满足下标互不相同且三个元素之和为 0 的三元组。
答案中不能包含重复的三元组,三元组内部以及结果列表的顺序均不重要。
示例 1
nums = [-1,0,1,2,-1,-4][[-1,-1,2],[-1,0,1]]0 的三元组为 [-1,-1,2] 和 [-1,0,1]。示例 2
nums = [0,1,1][]示例 3
nums = [0,0,0][[0,0,0]]数据范围
3 <= nums.length <= 3000-10^5 <= nums[i] <= 10^5
解法一:排序加相向双指针
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(),nums.end());
if(nums[0] > 0) return {};
vector<vector<int>> ans;
int n = nums.size();
for(int i = 0 ; i < n ; i++){
if(nums[i] > 0) break;
if(i>0 && nums[i-1] == nums[i]) continue;
int l = i+1;
int r = n-1;
while(l < r){
int sum = nums[l]+nums[r]+nums[i];
if(sum == 0){
ans.push_back({nums[l],nums[r],nums[i]});
while(l+1<r&&nums[l]==nums[l+1]) l++;
while(r-1>l&&nums[r]==nums[r-1]) r--;
l++;
r--;
}else if(sum < 0){
l++;
}else{
r--;
}
}
}
return ans;
}
};
简单解释
先排序数组,然后枚举第一个数 nums[i]。剩余两个数在区间 [i + 1, n - 1] 中使用相向双指针查找:
sum < 0:需要增大总和,执行l++。sum > 0:需要减小总和,执行r--。sum == 0:记录答案,跳过左右两侧的重复值,再让两个指针同时向内移动。
外层跳过重复的 nums[i],内层跳过重复的 nums[l] 和 nums[r],共同保证答案不重复。
复杂度
- 时间复杂度:
O(n²) - 空间复杂度:
O(log n),来自排序递归栈,不计返回结果
注意点
- 双指针分支要记牢:和小了移动
l,和大了移动r,等于零时记录答案。 - 命中答案后,两个
while只会把指针移动到当前重复值的最后一个位置,因此去重结束后仍要执行一次l++和r--。 - 去重分两层:外层用
i > 0 && nums[i] == nums[i-1];内层在命中答案后分别跳过左右重复值,并始终保证l < r。
记忆锚点
排序后固定一个数:小了左移,大了右移,等于零则记录、去重、双移。
面试简述
先排序并枚举第一个数,再使用相向双指针寻找另外两个数。利用有序性按总和大小移动指针,并分别对固定值、左指针值和右指针值去重。整体时间复杂度为 O(n²)。
42. 接雨水
- 难度:困难
- 分类:双指针
- 原题:LeetCode 42. 接雨水
题面
给定 n 个非负整数,表示每个宽度为 1 的柱子的高度,计算这些柱子在下雨后能够接住多少雨水。
示例 1
height = [0,1,0,2,1,0,1,3,2,1,2,1]66 个单位的雨水。示例 2
height = [4,2,0,3,2,5]9数据范围
n == height.length1 <= n <= 2 * 10^40 <= height[i] <= 10^5
解法一:单调栈
class Solution {
public:
int trap(vector<int>& height) {
int ans = 0;
stack<int> stk;
int n = height.size();
for(int i = 0 ; i < n ; i++){
while(!stk.empty()&&height[i]>=height[stk.top()]){
int t = stk.top();
stk.pop();
if(!stk.empty()){
ans+= (min(height[i],height[stk.top()])-height[t])*(i-stk.top()-1);
}
}
stk.push(i);
}
return ans;
}
};
简单解释
栈中保存柱子的下标,并保持对应高度单调递减。遇到高度不低于栈顶的右边界时,弹出栈顶下标 t,将它视为凹槽底部:
- 当前下标
i是右边界。 - 弹栈后的
stk.top()是左边界。 t是凹槽底部。
按照一层横向区域计算当前凹槽的雨水量,然后继续弹栈,直到重新恢复单调递减。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
注意点
- 栈里必须存下标而不是高度,因为宽度需要用下标计算。
- 弹出凹槽底部
t后,如果栈为空,说明缺少左边界,不能计算面积。 - 记住公式:高度为
min(右边界, 左边界) - 凹槽底部,宽度为右下标 - 左下标 - 1。
记忆锚点
当前柱是右墙,弹出的柱是坑底,新栈顶是左墙。
面试简述
维护一个高度单调递减的下标栈。遇到更高的右边界时不断弹出凹槽底部,并用新栈顶和当前柱构成左右边界,按横向面积累加雨水。每个下标最多入栈和出栈一次,时间复杂度为 O(n)。
解法二:双指针
class Solution {
public:
int trap(vector<int>& height) {
int ans = 0;
int left = 0;
int right = height.size() - 1;
int leftMax = 0, rightMax = 0;
while (left < right) {
leftMax = max(leftMax, height[left]);
rightMax = max(rightMax, height[right]);
if (height[left] < height[right]) {
ans += leftMax - height[left];
left++;
} else {
ans += rightMax - height[right];
right--;
}
}
return ans;
}
};
简单解释
leftMax 和 rightMax 分别记录左右两侧已经遇到的最高柱子。
每轮处理当前高度较低的一侧:如果左侧更低,就能确定左侧当前位置的雨水量为 leftMax - height[left];否则计算右侧的 rightMax - height[right]。处理完该位置后,将对应指针向内移动。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 每轮必须先更新
leftMax和rightMax,再计算当前位置的雨水。 - 比较当前的
height[left]和height[right],处理较低的一侧;两侧等高时当前代码处理右侧。 - 双指针法每轮累加的是一个位置上方的雨水量,不需要乘宽度。
记忆锚点
哪边矮就结算哪边:左边用
leftMax,右边用rightMax。
面试简述
使用左右指针和两侧最大高度。每轮先更新 leftMax、rightMax,再结算当前较低一侧的雨水并移动该侧指针。每个位置只处理一次,时间复杂度为 O(n),额外空间复杂度为 O(1)。
三、滑动窗口
3. 无重复字符的最长子串
- 难度:中等
- 分类:滑动窗口
- 原题:LeetCode 3. 无重复字符的最长子串
题面
给定一个字符串 s,找出其中不含重复字符的最长子串长度。
示例 1
s = "abcabcbb"3"abc"、"bca" 或 "cab"。示例 2
s = "bbbbb"1"b"。示例 3
s = "pwwkew"3"wke"。"pwke" 是子序列,不是子串。数据范围
0 <= s.length <= 5 * 10^4s由英文字母、数字、符号和空格组成
解法一:哈希集合维护滑动窗口
class Solution {
public:
int lengthOfLongestSubstring(string s) {
unordered_set <char> ss;
int n = s.size();
int ans = 0;
int left = 0;
for(int i = 0 ; i < n ; i++){
if(!ss.count(s[i])){
ss.insert(s[i]);
ans = max(ans, i-left+1);
}else{
while(s[left]!=s[i]){
ss.erase(s[left]);
left++;
}
left++;
}
}
return ans;
}
};
简单解释
ss 保存当前窗口中的字符,left 是窗口左边界,i 是右边界。
当前字符没有出现时,将它加入集合并更新窗口最大长度;出现重复字符时,从左侧不断删除字符,直到找到窗口中旧的同字符,再将 left 移到它的下一个位置,使窗口重新满足无重复条件。
复杂度
- 时间复杂度:平均
O(n) - 空间复杂度:
O(|Σ|),其中|Σ|为字符集大小
注意点
- 遇到重复字符时,
while删除旧重复字符之前的内容,最后的left++才真正越过旧重复字符。 - 不需要把重复字符从集合中删除再重新插入;集合中的这个字符可以直接代表当前位置的新字符。
记忆锚点
右边扩窗口;遇到重复,就让左边越过窗口中的旧字符。
面试简述
使用哈希集合维护无重复字符窗口。右指针逐个加入字符;出现重复时,移动左边界并删除沿途字符,直到越过旧的重复字符。每个字符最多被加入和删除一次,平均时间复杂度为 O(n)。
438. 找到字符串中所有字母异位词
- 难度:中等
- 分类:滑动窗口
- 原题:LeetCode 438. 找到字符串中所有字母异位词
题面
给定两个字符串 s 和 p,找到 s 中所有 p 的字母异位词的子串,返回这些子串的起始索引。答案的输出顺序不限。
示例 1
s = "cbaebabacd", p = "abc"[0,6]0 的子串是 "cba",起始索引为 6 的子串是 "bac",它们都是 p 的字母异位词。示例 2
s = "abab", p = "ab"[0,1,2]"ab"、"ba" 和 "ab"。数据范围
1 <= s.length, p.length <= 3 * 10^4s和p仅包含小写英文字母
解法一:固定长度窗口对比频次数组
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
vector<int> ss(26);
vector<int> pp(26);
for (char c : p) {
pp[c - 'a']++;
}
int n = s.size();
int m = p.size();
int left = 0, right = 0;
vector<int> ans;
while (right < n) {
ss[s[right] -'a']++;
if(right-left+1 == m){
if(ss == pp) ans.push_back(left);
ss[s[left]-'a']--;
left++;
}
right++;
}
return ans;
}
};
简单解释
pp 记录 p 的字符频次,ss 记录 s 当前窗口的字符频次。右指针加入字符;窗口长度达到 p.size() 时比较两个频次数组,相同就记录左端点。随后移除窗口最左字符,让窗口继续以固定长度向右滑动。
复杂度
- 时间复杂度:
O(|s| * 26),字符集大小固定,因此可视为O(|s|) - 空间复杂度:
O(26),即O(1)
注意点
- 只在窗口长度等于
p.size()时比较频次数组。 - 无论当前窗口是否匹配,比较后都要移除
s[left]并执行left++,保证窗口长度固定。 - 长度为
26的数组仅适用于题目限定的全小写英文字母。
记忆锚点
右边进一个;凑够
p的长度就比较,左边再出一个。
面试简述
使用两个长度为 26 的数组分别统计目标串和当前窗口的字符频次。窗口固定为 p 的长度,每次比较频次数组并记录匹配窗口的左端点。时间复杂度为 O(|s|),额外空间为 O(1)。
解法二:消耗频次并在超量时收缩
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
int m = s.size();
int n = p.size();
vector<int> nn(26);
for(char c : p){
nn[c-'a']++;
}
int r = 0, l =0;
vector<int> ans;
while(r < m){
nn[s[r]-'a']--;
while(nn[s[r]-'a']<0){
nn[s[l]-'a']++;
l++;
}
if(r-l+1 == n){
ans.push_back(l);
}
r++;
}
return ans;
}
};
简单解释
nn 初始记录 p 中每个字符还需要多少个。右指针加入字符时消耗一次对应频次;若当前字符的频次变为负数,说明它在窗口中出现过多,就从左侧收缩并归还频次,直到不再超量。
此时窗口中不存在超量字符。如果窗口长度又恰好等于 p.size(),窗口的字符频次就一定与 p 完全相同,记录左端点即可。
复杂度
- 时间复杂度:
O(|s|) - 空间复杂度:
O(26),即O(1)
注意点
- 每轮只有新加入的
s[r]可能使频次变成负数,所以while只需检查nn[s[r] - 'a']。 - 收缩窗口时要先执行
nn[s[l] - 'a']++归还频次,再移动l。 - 保证所有频次非负后,窗口长度等于
p.size()就代表完全匹配,不需要再比较整个数组。
记忆锚点
右边进入就消耗;某字符欠账就缩左边;长度刚好就是答案。
面试简述
将目标串频次数组看作剩余需求。右指针加入字符并减少需求,若新字符被使用过量,则移动左指针归还字符,直到窗口重新合法。合法窗口长度等于 p 的长度时即可记录答案,时间复杂度为 O(|s|),额外空间为 O(1)。
四、子串
560. 和为 K 的子数组
- 难度:中等
- 分类:子串
- 原题:LeetCode 560. 和为 K 的子数组
题面
给定一个整数数组 nums 和一个整数 k,统计并返回该数组中和为 k 的子数组个数。
子数组是数组中元素的连续非空序列。
示例 1
nums = [1,1,1], k = 22示例 2
nums = [1,2,3], k = 32数据范围
1 <= nums.length <= 2 * 10^4-1000 <= nums[i] <= 1000-10^7 <= k <= 10^7
解法一:前缀和与哈希表
#include <unordered_map>
#include <vector>
using namespace std;
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
// map: {前缀和 : 出现次数}
unordered_map<int, int> mp;
// 重要!初始化:前缀和为 0 出现了 1 次
// 代表没有任何元素时的和
mp[0] = 1;
int count = 0;
int preSum = 0; // 记录当前的累加和
for (int x : nums) {
preSum += x; // 更新当前前缀和
// 检查之前是否出现过 preSum - k
if (mp.count(preSum - k)) {
count += mp[preSum - k];
}
// 更新哈希表,记录当前前缀和出现的次数
mp[preSum]++;
}
return count;
}
};
简单解释
设当前位置的前缀和为 preSum。如果之前出现过前缀和 preSum - k,那么从那个位置之后到当前位置的子数组和就是:
preSum - (preSum - k) = k
哈希表记录每种前缀和此前出现的次数。若 preSum - k 出现过多次,就说明有多个以当前位置结尾的合法子数组,因此要把它的出现次数全部累加到答案中。
复杂度
- 时间复杂度:平均
O(n) - 空间复杂度:
O(n)
注意点
mp[0] = 1表示空前缀,可统计从下标0开始、和恰好为k的子数组。- 答案要增加
mp[preSum - k],而不是只增加1,因为同一个前缀和可能出现多次。 - 必须先查找
preSum - k,再执行mp[preSum]++;否则k == 0时会把当前前缀和与自身组成的空区间也算进去。
记忆锚点
当前前缀和减去
k,就是要在过去寻找的前缀和。
面试简述
遍历数组并维护当前前缀和。若此前存在前缀和 preSum - k,则这些位置之后到当前位置的子数组和都为 k,将其出现次数累加到答案。随后记录当前前缀和。平均时间复杂度为 O(n),空间复杂度为 O(n)。
239. 滑动窗口最大值
- 难度:困难
- 分类:子串
- 原题:LeetCode 239. 滑动窗口最大值
题面
给定一个整数数组 nums,有一个大小为 k 的滑动窗口从数组最左侧移动到最右侧。每次只能看到窗口内的 k 个数字,且窗口每次只向右移动一位。
返回每个滑动窗口中的最大值。
示例 1
nums = [1,3,-1,-3,5,3,6,7], k = 3[3,3,5,5,6,7]3, 3, 5, 5, 6, 7。示例 2
nums = [1], k = 1[1]数据范围
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^41 <= k <= nums.length
解法一:优先队列
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
priority_queue<pair<int, int>> q;
for (int i = 0; i < k; ++i) {
q.emplace(nums[i], i);
}
vector<int> ans = {q.top().first};
for (int i = k; i < n; ++i) {
q.emplace(nums[i], i);
while (q.top().second <= i - k) {
q.pop();
}
ans.push_back(q.top().first);
}
return ans;
}
};
简单解释
优先队列中保存 {元素值, 下标}。默认大根堆会把值最大的元素放在堆顶,因此每次滑动后,先加入新元素,再不断弹出堆顶已经离开窗口的元素,此时新的堆顶就是当前窗口最大值。
过期元素如果不在堆顶,可以暂时不删除;只有它将来移动到堆顶时才需要清理,这称为延迟删除。
复杂度
- 时间复杂度:
O(n log n) - 空间复杂度:最坏
O(n)
语法速记
priority_queue<pair<int, int>> q; // 默认大根堆,pair 先比较 first
q.emplace(value, index); // 原地构造并入堆
q.top().first; // 堆顶的值
q.top().second; // 堆顶的下标
q.pop(); // 弹出堆顶,无返回值
q.empty(); // 判断是否为空
大根堆与 greater 小根堆:
priority_queue<int> maxHeap;
priority_queue<int, vector<int>, greater<int>> minHeap;
priority_queue<
pair<int, int>,
vector<pair<int, int>>,
greater<pair<int, int>>
> minPairHeap;
pair 默认按字典序比较:先比较 first,相同时再比较 second。使用 greater<pair<int, int>> 后,字典序最小的 pair 位于堆顶。
自定义仿函数:
struct Cmp {
bool operator()(const pair<int, int>& a,
const pair<int, int>& b) const {
return a.first > b.first; // first 较小的元素优先:小根堆
}
};
priority_queue<
pair<int, int>,
vector<pair<int, int>>,
Cmp
> q;
Lambda 比较器:
auto cmp = [](const pair<int, int>& a,
const pair<int, int>& b) {
return a.first > b.first; // first 较小的元素优先
};
priority_queue<
pair<int, int>,
vector<pair<int, int>>,
decltype(cmp)
> q(cmp); // C++17 中要把 cmp 传给构造函数
比较函数返回 true,表示 a 的优先级低于 b。因此:
a > b:较小值优先,是小根堆。a < b:较大值优先,是大根堆。
注意点
- 必须同时存储值和下标;只存值无法判断堆顶元素是否已离开窗口。
- 当前窗口为
[i-k+1, i],因此下标满足index <= i-k的元素已经过期。 - 只需删除堆顶的过期元素,其他过期元素可以延迟处理;因此堆的最坏空间复杂度是
O(n),不一定是O(k)。
记忆锚点
堆里存“值和下标”,堆顶过期就弹,直到堆顶仍在窗口内。
面试简述
使用保存 {值, 下标} 的大根堆。每次加入新元素后,持续弹出下标不在当前窗口内的堆顶元素,剩余堆顶即为窗口最大值。采用延迟删除,时间复杂度为 O(n log n),最坏空间复杂度为 O(n)。
解法二:单调队列
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
deque<int> q;
for(int i = 0; i < k ; i++){
while(!q.empty()&& nums[i]>=nums[q.back()]){
q.pop_back();
}
q.push_back(i);
}
vector<int> ans = {nums[q.front()]};
for(int i = k ; i < n; i++){
while(!q.empty() && nums[i]>=nums[q.back()]){
q.pop_back();
}
q.push_back(i);
while(!q.empty()&& q.front() <= i - k){
q.pop_front();
}
ans.push_back(nums[q.front()]);
}
return ans;
}
};
简单解释
双端队列中保存元素下标,并使对应的值从队头到队尾单调不增。加入 nums[i] 前,从队尾删除所有不大于它的元素,因为这些元素更小且更早离开窗口,以后不可能成为最大值。
新下标加入队尾后,再从队头删除已经离开窗口的下标。队头始终是当前窗口内最大元素的下标,所以答案为 nums[q.front()]。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(k)
语法速记
deque<int> q;
q.push_back(index); // 从队尾加入
q.front(); // 读取队头下标
q.back(); // 读取队尾下标
q.pop_front(); // 删除过期的队头
q.pop_back(); // 删除不可能成为最大值的队尾
q.empty(); // 判断是否为空
注意点
- 队列存下标而不是元素值:既能通过
nums[index]比较大小,也能判断元素是否过期。 - 队列对应的元素值必须从队头到队尾单调不增;队头就是窗口最大值。
nums[i] >= nums[q.back()]会删除较早的相同值,保留生存时间更长的新下标;下标<= i-k时已经离开窗口。
记忆锚点
新元素从队尾“淘汰”所有不如它的元素;队头过期就删除;队头始终最大。
面试简述
用双端队列保存下标,并维护对应值单调不增。新元素加入前删除队尾所有不大于它的元素,再删除队头过期下标,当前队头就是窗口最大值。每个下标最多入队和出队一次,因此时间复杂度为 O(n),空间复杂度为 O(k)。
76. 最小覆盖子串
- 难度:困难
- 分类:子串
- 原题:LeetCode 76. 最小覆盖子串
- 参考题解:两种方法:从 O(52m+n) 到 O(m+n)
题面
给定两个字符串 s 和 t,长度分别为 m 和 n。返回 s 中的最短窗口子串,使该子串包含 t 中的每一个字符,包括重复字符。如果不存在这样的子串,返回空字符串 ""。
测试用例保证答案唯一。
示例 1
s = "ADOBECODEBANC", t = "ABC""BANC""BANC" 包含 t 中的 'A'、'B' 和 'C'。示例 2
s = "a", t = "a""a"示例 3
s = "a", t = "aa"""t 中的两个 'a' 都必须包含在窗口中,因此不存在符合要求的子串。数据范围
m == s.lengthn == t.length1 <= m, n <= 10^5s和t由英文字母组成
进阶:设计一个时间复杂度为 O(m+n) 的算法。
解法一:滑动窗口 + 逐项判断频次
class Solution {
public:
string minWindow(string s, string t) {
vector<int> ss(128);
vector<int> tt(128);
for(char c : t){
tt[c]++;
}
int n = s.size();
int l = 0;
int ansL = -1;
int ansLen = n + 1;
for(int r = 0; r < n; r++){
// 当前字符进入窗口
ss[s[r]]++;
while(l <= r){
// 判断窗口是否涵盖 t 中的全部字符
bool ok = true;
for(int i = 0; i < 128; i++){
if(ss[i] < tt[i]){
ok = false;
break;
}
}
if(!ok) break;
// 当前窗口合法,先更新答案,再收缩左边界
if(r - l + 1 < ansLen){
ansL = l;
ansLen = r - l + 1;
}
ss[s[l]]--;
l++;
}
}
if(ansL == -1) return "";
return s.substr(ansL, ansLen);
}
};
简单解释
tt 记录 t 中每个字符需要的次数,ss 记录当前窗口中的字符次数。右指针不断加入字符;每次扫描长度为 128 的频次数组,检查是否对所有字符都有 ss[i] >= tt[i]。
窗口涵盖 t 时,先更新最短答案,再不断右移左指针,直到窗口不再满足要求。这样可以找到以每个右端点结尾的最短合法窗口。
复杂度
- 时间复杂度:
O(128 * |s| + |t|),可写作O(|Σ| * |s| + |t|) - 空间复杂度:
O(128),即O(1)
注意点
- “涵盖”要求每种字符的窗口频次都不少于
t的频次,重复字符也必须全部计入。 - 窗口合法后必须使用
while持续收缩;更新答案要放在移除s[l]之前。 s.substr(ansL, ansLen)的第二个参数是长度,不是结束下标。
记忆锚点
右边进字符;128 项全部够就记答案并缩左边,直到不再涵盖。
面试简述
使用两个 ASCII 频次数组维护目标串和当前窗口。右端点扩张后,逐项检查窗口是否涵盖目标串;若涵盖,则不断收缩左端点并更新最短窗口。每次判断需要扫描字符集,时间复杂度为 O(|Σ| * |s| + |t|)。
解法二:滑动窗口 + 临界计数优化
class Solution {
public:
string minWindow(string s, string t) {
// diff[c] = 窗口中 c 的数量 - t 中 c 的数量
vector<int> diff(128);
int kind = 0;
for(char c : t){
if(diff[c] == 0) kind++;
diff[c]--;
}
int n = s.size();
int l = 0;
int count = 0;
int ansL = -1;
int ansLen = n + 1;
for(int r = 0; r < n; r++){
// 进入后等于 0:该字符刚好从“不够”变成“足够”
diff[s[r]]++;
if(diff[s[r]] == 0){
count++;
}
// 所有需要的字符种类都已满足
while(count == kind){
if(r - l + 1 < ansLen){
ansL = l;
ansLen = r - l + 1;
}
// 移出前等于 0:移出后该字符会重新变得“不够”
if(diff[s[l]] == 0){
count--;
}
diff[s[l]]--;
l++;
}
}
if(ansL == -1) return "";
return s.substr(ansL, ansLen);
}
};
简单解释
将两个频次数组合并为 diff:
diff[c] = 当前窗口中 c 的数量 - t 中 c 的数量
kind 是 t 中不同字符的种类数,count 是当前已经满足数量要求的字符种类数。字符进入窗口后,只有 diff[c] 从负数变成 0 时,才说明这一种字符刚刚满足要求;字符离开前若 diff[c] == 0,则离开后会重新变得不足。
当 count == kind 时,窗口已经涵盖 t,可以更新答案并继续收缩。整个过程不再扫描频次数组。
复杂度
- 时间复杂度:
O(|s| + |t|) - 空间复杂度:
O(128),即O(1)
注意点
count统计的是已满足的字符种类数,不是窗口中匹配字符的总数量。- 右端字符加入后判断
diff[s[r]] == 0;左端字符移出前判断diff[s[l]] == 0,两个判断的顺序不能反。 - 只有频次跨过
0这个临界点时才修改count,多余字符不能重复增加计数。
记忆锚点
diff = 窗口 - 目标:进后到0就满足一种,出前为0就将失去一种。
面试简述
使用 diff 数组记录窗口频次与目标频次之差,并用 count 维护已经满足要求的字符种类数。字符进入后恰好使差值变为 0 时增加计数;字符移出前差值为 0 时减少计数。窗口合法性可以在 O(1) 时间判断,总时间复杂度为 O(|s| + |t|)。
五、普通数组
53. 最大子数组和
- 难度:中等
- 分类:普通数组
- 原题:LeetCode 53. 最大子数组和
题面
给定一个整数数组 nums,找出一个具有最大和的连续子数组。子数组最少包含一个元素,返回其最大和。
子数组是数组中的一个连续部分。
示例 1
nums = [-2,1,-3,4,-1,2,1,-5,4]6[4,-1,2,1] 的和最大,为 6。示例 2
nums = [1]1示例 3
nums = [5,4,-1,7,8]23数据范围
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4
进阶:在实现 O(n) 解法后,尝试使用分治法求解。
解法一:动态规划
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int ans = INT_MIN;
int cur = 0;
for(int i = 0; i < nums.size(); i++){
cur = max(nums[i],cur+nums[i]);
ans = max(cur,ans);
}
return ans;
}
};
简单解释
cur 表示以当前位置 i 结尾的最大连续子数组和。对于 nums[i],只有两种选择:
- 从当前元素重新开始:
nums[i] - 接在前一个连续子数组后面:
cur + nums[i]
取两者较大值作为新的 cur,再用 ans 记录所有结尾位置中的最大值。这就是 Kadane 算法。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
记忆锚点
以当前元素结尾:要么自己重新开始,要么接上前面的连续和。
面试简述
使用动态规划维护以当前元素结尾的最大连续子数组和,转移方程为 cur = max(nums[i], cur + nums[i]),同时维护全局最大值。每个元素只处理一次,时间复杂度为 O(n),空间复杂度为 O(1)。
56. 合并区间
- 难度:中等
- 分类:普通数组
- 原题:LeetCode 56. 合并区间
题面
用数组 intervals 表示若干区间的集合,其中单个区间为 intervals[i] = [starti, endi]。
合并所有重叠区间,并返回一个互不重叠的区间数组。该数组需要恰好覆盖输入中的所有区间。
示例 1
intervals = [[1,3],[2,6],[8,10],[15,18]][[1,6],[8,10],[15,18]][1,3] 和 [2,6] 重叠,将它们合并为 [1,6]。示例 2
intervals = [[1,4],[4,5]][[1,5]][1,4] 和 [4,5] 也视为重叠区间。示例 3
intervals = [[4,7],[1,4]][[1,7]]数据范围
1 <= intervals.length <= 10^4intervals[i].length == 20 <= starti <= endi <= 10^4
解法一:排序后合并
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
sort(intervals.begin(),intervals.end());
vector<vector<int>> ans;
int l = 0 ;
int r = 0;
while(l < intervals.size()){
r = l+1;
int ll = intervals[l][0] ; int rr = intervals[l][1];
while(r < intervals.size() && rr >= intervals[r][0]){
rr = max(intervals[r][1],rr);
r++;
}
ans.push_back({ll,rr});
l = r;
}
return ans;
}
};
简单解释
先按区间起点排序。l 指向当前准备合并的第一个区间,使用 ll 和 rr 记录合并后区间的左右端点。
r 向后扫描:只要下一个区间的起点不大于当前右端点 rr,两个区间就有重叠,将 rr 更新为两者右端点的最大值。当前连续重叠段处理完后,把 [ll, rr] 加入答案,并令 l = r 开始处理下一段。
复杂度
- 时间复杂度:
O(n log n) - 空间复杂度:
O(n),用于保存答案
注意点
- 输入区间没有预先排序;
vector<int>的默认排序会先比较起点,再比较终点,可以直接使用。 - 合并条件是
rr >= intervals[r][0],端点相等也算重叠。 - 内层循环结束后执行
l = r,一次跳过刚才已经合并的所有区间。
记忆锚点
先按左端点排序;下一区间的左端点不超过当前右端点,就继续向右扩。
面试简述
先按区间起点排序,再从左到右扫描。维护当前合并区间的左右端点,遇到重叠区间时扩展右端点;遇到不重叠区间时保存当前结果并开始新的一段。排序决定时间复杂度为 O(n log n)。
189. 轮转数组
- 难度:中等
- 分类:普通数组
- 原题:LeetCode 189. 轮转数组
题面
给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。
示例 1
nums = [1,2,3,4,5,6,7], k = 3[5,6,7,1,2,3,4][7,1,2,3,4,5,6]、[6,7,1,2,3,4,5]、[5,6,7,1,2,3,4]。示例 2
nums = [-1,-100,3,99], k = 2[3,99,-1,-100]数据范围
1 <= nums.length <= 10^5-2^31 <= nums[i] <= 2^31 - 10 <= k <= 10^5
进阶:尝试使用空间复杂度为 O(1) 的原地算法。
解法一:三次反转
class Solution {
public:
void rotate(vector<int>& nums, int k) {
k=k%nums.size();
reverse(nums.begin(),nums.end());
reverse(nums.begin(),nums.begin()+k);
reverse(nums.begin()+k,nums.end());
}
};
简单解释
将原数组看成前 n-k 个元素和后 k 个元素两部分。先反转整个数组,让原来的后 k 个元素移动到前面,但两部分内部的顺序都被颠倒;再分别反转前 k 个元素和剩余元素,即可恢复两部分内部的正确顺序。
例如 [1,2,3,4,5,6,7]:
[7,6,5,4,3,2,1] -> [5,6,7,4,3,2,1] -> [5,6,7,1,2,3,4]
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
语法速记
reverse(first, last); // 反转 [first, last),左闭右开
reverse(nums.begin(), nums.end()); // 反转整个数组
reverse(nums.begin(), nums.begin() + k); // 反转前 k 个元素
reverse(nums.begin() + k, nums.end()); // 反转下标 [k, n) 的元素
reverse 定义在头文件 <algorithm> 中。
注意点
k可能大于数组长度,必须先执行k %= nums.size()。reverse的第二个迭代器不参与反转,因此前k个元素的区间是[begin, begin+k)。- 三次反转的顺序是:整个数组、前
k个元素、剩余n-k个元素。
记忆锚点
整体翻转,再分别翻转前
k个和后n-k个。
面试简述
先将整个数组反转,使原来的后 k 个元素移动到前面;再分别反转前 k 个和后 n-k 个元素,恢复两部分内部顺序。总共进行三次线性反转,时间复杂度为 O(n),空间复杂度为 O(1)。
解法二:额外数组
class Solution {
public:
void rotate(vector<int>& nums, int k) {
int n = nums.size();
vector<int> ans(n);
for(int i = 0 ; i < n; i++){
int to = (i + k)%n;
ans[to] = nums[i];
}
nums = ans;
}
};
简单解释
原数组下标 i 的元素向右移动 k 位后,新下标为 (i + k) % n。将每个元素直接放到额外数组的目标位置,最后再把结果赋回 nums。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
记忆锚点
原下标
i向右移动k位,新下标就是(i + k) % n。
面试简述
创建一个长度为 n 的额外数组,将原下标 i 的元素放到 (i + k) % n,最后覆盖原数组。时间复杂度为 O(n),空间复杂度为 O(n)。
238. 除了自身以外数组的乘积
- 难度:中等
- 分类:普通数组
- 原题:LeetCode 238. 除了自身以外数组的乘积
题面
给定一个整数数组 nums,返回数组 answer,其中 answer[i] 等于 nums 中除 nums[i] 以外其余各元素的乘积。
题目保证任意元素的全部前缀元素和后缀元素的乘积都在 32 位整数范围内。
不能使用除法,并且需要在 O(n) 时间复杂度内完成。
示例 1
nums = [1,2,3,4][24,12,8,6]示例 2
nums = [-1,1,0,-3,3][0,0,9,0,0]数据范围
2 <= nums.length <= 10^5-30 <= nums[i] <= 30answer[i]保证在 32 位整数范围内
进阶:在不计算输出数组空间的情况下,使用 O(1) 的额外空间完成。
解法一:前缀积与后缀积
class Solution {
public:
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> l(n);
l[0] = nums[0];
vector<int> r(n);
r[n-1] = nums[n-1];
for(int i = 1; i < n; i++){
l[i] = l[i-1] *nums[i];
}
for(int i = n-2 ; i>0;i--){
r[i] = r[i+1] *nums[i];
}
vector<int> ans(n);
ans[0] = r[1];
ans[n-1] = l[n-2];
for(int i = 0 ; i < nums.size(); i++){
if(i==0||i==nums.size()-1) continue;
ans[i] = l[i-1]*r[i+1];
}
return ans;
}
};
简单解释
l[i] 表示从 nums[0] 到 nums[i] 的前缀乘积,r[i] 表示从 nums[i] 到 nums[n-1] 的后缀乘积。
对于中间位置 i,排除 nums[i] 后的乘积为左侧全部元素的乘积乘以右侧全部元素的乘积,即:
ans[i] = l[i-1] * r[i+1]
首元素没有左侧部分,因此答案是 r[1];尾元素没有右侧部分,因此答案是 l[n-2]。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
注意点
l[i]和r[i]都包含nums[i],所以计算答案时必须使用l[i-1]和r[i+1]。- 首尾位置无法直接套用中间位置的公式,需要分别设置为
r[1]和l[n-2];题目保证n >= 2。 - 当前代码不需要计算
r[0],所以后缀循环条件写成i > 0即可。
记忆锚点
排除自己,就是“自己左边的乘积 × 自己右边的乘积”。
面试简述
分别预处理包含当前位置的前缀积和后缀积。对于中间位置 i,答案是 l[i-1] * r[i+1],首尾位置单独处理。整个过程不使用除法,时间复杂度为 O(n),空间复杂度为 O(n)。
41. 缺失的第一个正数
- 难度:困难
- 分类:普通数组
- 原题:LeetCode 41. 缺失的第一个正数
题面
给定一个未排序的整数数组 nums,找出其中没有出现的最小正整数。
要求实现时间复杂度为 O(n)、额外空间复杂度为 O(1) 的解决方案。
示例 1
nums = [1,2,0]3[1,2] 中的数字都在数组中。示例 2
nums = [3,4,-1,1]21 在数组中,但 2 没有出现。示例 3
nums = [7,8,9,11,12]1数据范围
1 <= nums.length <= 10^5-2^31 <= nums[i] <= 2^31 - 1
解法一:原地哈希
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for(int &num : nums){
if(num <= 0){
num = n+1;
}
}
for(int i = 0 ; i < n;++i){
int num = abs(nums[i]);
if(num <= n){ //之前标记的负数和0不管
nums[num-1] = -abs(nums[num-1]);
}
}
for(int i = 0; i< n;++i){
if(nums[i]>0){
return i+1;
}
}
return n+1;
}
};
简单解释
长度为 n 的数组,其缺失的第一个正数一定在 [1, n+1] 中。利用数组本身充当哈希表,用下标 num-1 对应数字 num:
- 将所有非正数改成无关的正数
n+1。 - 对每个绝对值位于
[1,n]的数字num,把nums[num-1]标记为负数,表示num出现过。 - 从左到右寻找第一个仍为正数的位置
i,说明数字i+1没有出现;若全部被标记,则答案为n+1。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 只需要关心
[1,n]:非正数先改成n+1,大于n的数在标记阶段直接忽略。 - 第二遍必须先取
abs(nums[i]),因为该位置可能已被前面的数字标成负数;使用-abs(nums[num-1])可以安全处理重复数字。 - 数字
num映射到下标num-1;最后第一个正数位置i对应缺失值i+1,若没有正数则返回n+1。
记忆锚点
数字
num出现过,就把下标num-1的位置标成负数。
面试简述
将数组本身作为哈希表。先把非正数替换为 n+1,再把每个位于 [1,n] 的数字映射到下标 num-1 并用负号标记出现状态。最后寻找第一个仍为正数的位置,若全部标记则返回 n+1。时间复杂度为 O(n),额外空间为 O(1)。
六、矩阵
73. 矩阵置零
- 难度:中等
- 分类:矩阵
- 原题:LeetCode 73. 矩阵置零
题面
给定一个 m × n 的矩阵,如果一个元素为 0,则将其所在行和列的所有元素都设为 0。需要直接修改原矩阵。
示例 1
matrix = [[1,1,1],[1,0,1],[1,1,1]][[1,0,1],[0,0,0],[1,0,1]]示例 2
matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]][[0,0,0,0],[0,4,5,0],[0,3,1,0]]数据范围
m == matrix.lengthn == matrix[0].length1 <= m, n <= 200-2^31 <= matrix[i][j] <= 2^31 - 1
进阶:尝试使用常量额外空间完成。
解法一:行列标记数组
class Solution {
public:
void setZeroes(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
vector<int> mm(m,0);
vector<int> nn(n,0);
for(int i = 0; i < m ; i++){
for(int j = 0 ; j < n ; j++){
if(matrix[i][j] == 0){
mm[i] = 1;
nn[j] = 1;
}
}
}
for(int i = 0; i < m ; i++){
for(int j = 0 ; j < n ; j++){
if(mm[i] == 1 || nn[j] == 1){
matrix[i][j] = 0;
}
}
}
}
};
简单解释
使用 mm[i] 记录第 i 行是否需要置零,使用 nn[j] 记录第 j 列是否需要置零。
第一遍只读取原矩阵,遇到 matrix[i][j] == 0 时标记对应的行和列;第二遍根据标记修改矩阵,只要当前行或当前列被标记,就将该元素置为 0。
复杂度
- 时间复杂度:
O(mn) - 空间复杂度:
O(m+n)
注意点
- 必须分成两遍:第一遍只记录原始零的位置,第二遍再置零;边遍历边修改会让新增的零继续错误扩散。
- 行标记数组长度为
m,列标记数组长度为n,分别使用mm[i]和nn[j]。 - 当前元素所在行或列任意一个被标记就要置零,因此条件使用
mm[i] == 1 || nn[j] == 1。
记忆锚点
第一遍只记哪些行列有零,第二遍再统一清零。
面试简述
使用两个标记数组分别记录需要置零的行和列。第一遍扫描所有原始零并完成标记,第二遍将处于被标记行或列中的元素置零。时间复杂度为 O(mn),额外空间复杂度为 O(m+n)。
54. 螺旋矩阵
- 难度:中等
- 分类:矩阵
- 原题:LeetCode 54. 螺旋矩阵
题面
给定一个 m 行 n 列的矩阵 matrix,按照顺时针螺旋顺序返回矩阵中的所有元素。
示例 1
matrix = [[1,2,3],[4,5,6],[7,8,9]][1,2,3,6,9,8,7,4,5]示例 2
matrix = [[1,2,3,4],[5,6,7,8],[9,10,11,12]][1,2,3,4,8,12,11,10,9,5,6,7]数据范围
m == matrix.lengthn == matrix[i].length1 <= m, n <= 10-100 <= matrix[i][j] <= 100
解法一:四边界模拟
class Solution {
public:
vector<int> spiralOrder(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
vector<int> ans;
int up = 0; int down = m-1;
int left = 0; int right = n-1;
while(true){
for(int i = left; i <= right; i++){
ans.push_back(matrix[up][i]);
}
if(++up > down) break;
for(int i = up; i <= down ; i++){
ans.push_back(matrix[i][right]);
}
if(--right< left) break;
for(int i = right; i >= left ; i--){
ans.push_back(matrix[down][i]);
}
if(--down < up) break;
for(int i = down ; i >= up ; i--){
ans.push_back(matrix[i][left]);
}
if(++left > right) break;
}
return ans;
}
};
简单解释
使用 up、down、left、right 表示当前尚未遍历区域的四条边界。每一圈按以下顺序遍历:
- 上边:从左到右,然后
up++。 - 右边:从上到下,然后
right--。 - 下边:从右到左,然后
down--。 - 左边:从下到上,然后
left++。
每走完一条边就收缩对应边界;如果边界发生交叉,说明所有元素已经遍历完成,立即退出。
复杂度
- 时间复杂度:
O(mn) - 空间复杂度:
O(1),不计算返回数组
注意点
- 四条边的顺序必须记牢:上边向右、右边向下、下边向左、左边向上。
- 每遍历完一条边,都要先收缩对应边界,再立即判断是否交叉;否则单行或单列矩阵会重复加入元素。
- 访问矩阵时第一个下标是行、第二个下标是列,例如右边界写作
matrix[i][right]。
记忆锚点
右、下、左、上走一圈;每走完一边,就把那条边向内收缩。
面试简述
维护矩阵的上下左右四条边界,依次遍历上边、右边、下边和左边,并在每条边完成后向内收缩对应边界。每个元素只访问一次,时间复杂度为 O(mn),除返回数组外空间复杂度为 O(1)。
48. 旋转图像
- 难度:中等
- 分类:矩阵
- 原题:LeetCode 48. 旋转图像
题面
给定一个 n × n 的二维矩阵 matrix 表示一个图像,将图像顺时针旋转 90 度。
必须直接修改输入矩阵,不能使用另一个矩阵完成旋转。
示例 1
matrix = [[1,2,3],[4,5,6],[7,8,9]][[7,4,1],[8,5,2],[9,6,3]]示例 2
matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]][[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]数据范围
n == matrix.length == matrix[i].length1 <= n <= 20-1000 <= matrix[i][j] <= 1000
解法一:上下翻转后转置
class Solution {
public:
void rotate(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
for(int i = 0 ; i < m/2; i++){
for(int j = 0 ; j < n ; j++){
swap(matrix[i][j],matrix[m-i-1][j]);
}
}
for(int i = 0 ; i < m ; i++){
for(int j = 0 ; j < i; j++){
swap(matrix[i][j],matrix[j][i]);
}
}
}
};
简单解释
先将矩阵上下翻转,即交换第 i 行和第 m-i-1 行;再沿主对角线转置,即交换 matrix[i][j] 和 matrix[j][i]。
原位置 (i,j) 在上下翻转后变为 (n-1-i,j),转置后变为 (j,n-1-i),这正是顺时针旋转 90 度后的目标位置。
复杂度
- 时间复杂度:
O(n^2) - 空间复杂度:
O(1)
注意点
- 题目保证矩阵是
n × n的正方形,转置时才能直接交换matrix[i][j]和matrix[j][i]。 - 上下翻转只处理前
m/2行;转置只遍历j < i的半个矩阵,否则同一对元素会被交换两次。 - 当前顺序是“上下翻转,再沿主对角线转置”,结果才是顺时针旋转
90度。
记忆锚点
顺时针旋转:先上下翻转,再沿主对角线转置。
面试简述
先交换矩阵上下对称的行,再沿主对角线交换对称元素。坐标 (i,j) 最终映射为 (j,n-1-i),实现顺时针旋转 90 度。时间复杂度为 O(n^2),空间复杂度为 O(1)。
240. 搜索二维矩阵 II
- 难度:中等
- 分类:矩阵
- 原题:LeetCode 240. 搜索二维矩阵 II
题面
在一个 m × n 的矩阵 matrix 中搜索目标值 target。矩阵满足:
- 每行元素从左到右升序排列
- 每列元素从上到下升序排列
示例 1
matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 5true示例 2
matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 20false数据范围
m == matrix.lengthn == matrix[i].length1 <= m, n <= 300-10^9 <= matrix[i][j], target <= 10^9
解法一:从右上角开始搜索
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
int m = matrix.size(); int n = matrix[0].size();
int i = 0 ;int j = n-1;
while(i < m && j >= 0){
if(target == matrix[i][j]) return true;
if(target > matrix[i][j]){
i++;
}else{
j--;
}
}
return false;
}
};
简单解释
从矩阵右上角开始,该位置左侧的值更小、下方的值更大,因此每次比较都能排除一整行或一整列:
target > matrix[i][j]:当前行剩余元素都不可能等于目标值,向下移动i++。target < matrix[i][j]:当前列剩余元素都不可能等于目标值,向左移动j--。
指针走出矩阵仍未找到目标值时,返回 false。
复杂度
- 时间复杂度:
O(m+n) - 空间复杂度:
O(1)
注意点
- 要从右上角或左下角开始,因为这两个位置的两个移动方向分别让数值变大和变小;当前代码选择右上角。
- 从右上角出发时,目标更大就向下,目标更小就向左,这两个方向不要写反。
- 循环边界是
i < m && j >= 0,因为i只增大、j只减小。
记忆锚点
站在右上角:目标大就下楼,目标小就向左。
面试简述
从右上角开始搜索。当前值小于目标时排除当前行并向下移动,当前值大于目标时排除当前列并向左移动。每次至少排除一行或一列,时间复杂度为 O(m+n),空间复杂度为 O(1)。
七、链表
160. 相交链表
- 难度:简单
- 分类:链表
- 原题:LeetCode 160. 相交链表
题面
给定两个单链表的头节点 headA 和 headB,找出并返回两个链表相交的起始节点。如果两个链表不相交,返回 null。
题目保证整个链式结构中不存在环,并且函数返回后链表必须保持原始结构。
示例中的 intersectVal 表示相交节点的值,skipA 和 skipB 表示从两个链表头部到相交节点前需要跳过的节点数。实际函数只接收两个头节点。
示例 1
intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3Intersected at '8'1 的节点地址不同,不是交点;值为 8 的节点是同一个节点。示例 2
intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1Intersected at '2'示例 3
intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2No intersection数据范围
- 链表
A和B的节点数分别为m和n 1 <= m, n <= 3 * 10^41 <= Node.val <= 10^50 <= skipA <= m0 <= skipB <= n
解法一:长度对齐后同步前进
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
int m = 0; int n = 0;
ListNode* curA = headA;
ListNode* curB = headB;
while(curA!=nullptr){
m++;
curA = curA->next;
}
while(curB!=nullptr){
n++;
curB = curB->next;
}
int dif = abs(m-n);
if(m>n){
while(dif>0){
headA = headA->next;
dif--;
}
}else if(m<n){
while(dif>0){
headB = headB->next;
dif--;
}
}
// cout << headB->val;
while(headA!=nullptr&&headB!=nullptr){
if(headA == headB) return headA;
headA = headA->next;
headB = headB->next;
}
return nullptr;
}
};
简单解释
先分别统计两个链表的长度 m 和 n。让较长链表的头指针先移动 |m-n| 步,使两个指针到链表末尾的剩余距离相同。
随后两个指针同步向后移动。若它们指向同一个节点,该节点就是第一个交点;如果同时走到链表末尾仍未相等,则两个链表不相交。
复杂度
- 时间复杂度:
O(m+n) - 空间复杂度:
O(1)
注意点
- 判断相交必须比较节点地址
headA == headB,不能比较val;值相同的两个节点不一定是同一个节点。 - 统计长度时使用临时指针
curA、curB,避免提前移动真正用于查找交点的头指针。 - 较长链表先走长度差,完成尾部对齐后两个指针才能同步寻找第一个公共节点。
记忆锚点
长链表先走差值,对齐尾部后一起走;地址相同才是交点。
面试简述
先计算两个链表的长度,让较长链表先移动长度差,使两个指针距末尾的距离相同。之后同步前进,第一个地址相同的节点即为交点;若走到末尾则不存在交点。时间复杂度为 O(m+n),空间复杂度为 O(1)。
206. 反转链表
- 难度:简单
- 分类:链表
- 原题:LeetCode 206. 反转链表
题面
给定单链表的头节点 head,反转链表并返回反转后的链表。
示例 1
head = [1,2,3,4,5][5,4,3,2,1]示例 2
head = [1,2][2,1]示例 3
head = [][]数据范围
- 链表中节点的数目范围是
[0, 5000] -5000 <= Node.val <= 5000
解法一:迭代法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* pre = nullptr;
ListNode* cur = head;
while(cur != nullptr){
ListNode* next = cur->next;
cur->next = pre;
pre = cur;
cur = next;
}
return pre;
}
};
简单解释
使用三个指针完成原地反转:
pre指向已经反转好的链表头部。cur指向当前需要反转的节点。next临时保存原链表中的下一个节点。
每轮先保存 cur->next,再把 cur->next 改为指向 pre,最后同时向后移动 pre 和 cur。循环结束时,pre 就是新的头节点。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 必须先用
next保存cur->next,再修改指针方向,否则会丢失后半段链表。 - 循环结束后
cur == nullptr,新的头节点是pre。
记忆锚点
先存后继,再让当前节点回头,最后两个指针一起后移。
面试简述
用 pre 保存已反转部分、cur 遍历原链表。每轮先保存后继节点,再让当前节点指向前驱,最后移动两个指针。每个节点只处理一次,时间复杂度为 O(n),空间复杂度为 O(1)。
解法二:递归法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* ans = 0;
ListNode* reverseList(ListNode* head) {
reverse(head);
return ans;
}
ListNode* reverse(ListNode* head){
if(!head) return nullptr;
if(head->next == nullptr) {ans = head ;return head;};
ListNode* next = head->next;
head->next = nullptr;
reverse(next)->next = head;
return head;
}
};
简单解释
递归一直走到原链表的最后一个节点,并将它保存为反转后的头节点 ans。回溯时,reverse(next) 返回已反转后半段的尾节点,把它的 next 指向当前 head,即可逐层接回前面的节点。
当前节点在递归前先执行 head->next = nullptr,断开原来的正向连接,避免反转后形成环。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n),来自递归调用栈
注意点
- 空链表直接返回
nullptr;走到最后一个节点时,要将它记录为新的头节点ans。 - 递归前必须保存原来的
head->next,并断开head->next;回溯时再执行reverse(next)->next = head。 - 该写法依赖成员变量
ans保存最终头节点,辅助函数返回的是当前反转部分的尾节点。
记忆锚点
递归找到新头,回溯时让后一个节点指回当前节点。
面试简述
递归到原链表尾节点,将它记录为新的头节点;回溯时逐层把后一个节点的 next 指向当前节点,同时断开原来的正向连接。时间复杂度为 O(n),递归栈空间复杂度为 O(n)。
234. 回文链表
- 难度:简单
- 分类:链表
- 原题:LeetCode 234. 回文链表
题面
给定单链表的头节点 head,判断该链表是否为回文链表。如果是,返回 true;否则,返回 false。
示例 1
head = [1,2,2,1]true示例 2
head = [1,2]false数据范围
- 链表中节点数目在范围
[1, 10^5]内 0 <= Node.val <= 9
进阶
使用 O(n) 时间复杂度和 O(1) 额外空间复杂度解决此题。
解法一:快慢指针 + 反转后半段
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
bool isPalindrome(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while(fast!=nullptr && fast->next!=nullptr){
slow = slow->next;
fast = fast->next->next;
}
ListNode* b = reverse(slow);
while(head!=nullptr&&b!=nullptr){
if(head->val!=b->val) return false;
head = head->next;
b = b ->next;
}
return true;
}
ListNode* reverse(ListNode* head){
ListNode* cur = head;
ListNode* last =nullptr;
while(cur!=nullptr){
ListNode* next = cur->next;
cur->next = last;
last = cur;
cur = next;
}
return last;
}
};
简单解释
先用快慢指针寻找链表中点:slow 每次走一步,fast 每次走两步。循环结束后,偶数长度链表的 slow 指向后半段第一个节点,奇数长度链表的 slow 指向中间节点。
从 slow 开始原地反转链表,得到反转后的后半段头节点 b。随后让原链表头指针和 b 同步前进并比较节点值;只要出现不同就不是回文,直到 b 走到末尾仍全部相同则是回文。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 快指针循环条件必须是
fast != nullptr && fast->next != nullptr,并且先判断fast,避免访问空指针的next。 - 比较时以反转后的后半段
b走完为结束条件;奇数长度时中间节点会与自身比较,不影响结果。 - 当前写法会改变原链表结构且不会恢复;如果面试要求保持链表不变,需要在比较后再次反转后半段,并避免不相等时直接提前返回。
记忆锚点
快二慢一找到中点,反转后半段,再从两端向中间比较。
面试简述
使用快慢指针找到链表中点,从中点开始原地反转后半段,再让链表头和反转后的后半段同步比较。每个节点只被常数次访问,时间复杂度为 O(n),额外空间复杂度为 O(1)。
141. 环形链表
- 难度:简单
- 分类:链表
- 原题:LeetCode 141. 环形链表
题面
给定链表的头节点 head,判断链表中是否存在环。
如果链表中存在某个节点,可以通过连续跟踪 next 指针再次到达该节点,则链表中存在环。评测系统使用整数 pos 表示链表尾连接到链表中的位置,索引从 0 开始;pos 仅用于描述链表结构,不会作为参数传入。
示例 1
head = [3,2,0,-4], pos = 1true示例 2
head = [1,2], pos = 0true示例 3
head = [1], pos = -1false数据范围
- 链表中节点的数目范围是
[0, 10^4] -10^5 <= Node.val <= 10^5pos为-1或链表中的有效索引
进阶
使用 O(1) 额外空间解决此题。
解法一:快慢指针
class Solution {
public:
bool hasCycle(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return false;
}
ListNode* slow = head;
ListNode* fast = head->next;
while (slow != fast) {
if (fast == nullptr || fast->next == nullptr) {
return false;
}
slow = slow->next;
fast = fast->next->next;
}
return true;
}
};
简单解释
慢指针每次走一步,快指针每次走两步:
- 如果链表没有环,快指针会先走到
nullptr。 - 如果链表存在环,两个指针进入环后,快指针每轮会相对慢指针多走一步,因此最终一定会相遇。
当前写法让 slow 从 head 开始、fast 从 head->next 开始,使两个指针初始位置不同。循环退出时如果 slow == fast,说明它们在环中相遇。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 当前循环条件是
while (slow != fast),所以两个指针必须错位初始化;如果都从head开始,循环会直接退出并错误返回true。 - 执行
fast = fast->next->next前,必须先判断fast == nullptr || fast->next == nullptr。 - 空链表或只有一个且不自环的节点,应在初始化
fast = head->next前直接返回false。
记忆锚点
快指针每轮多追一步:无环先到空,有环终会追上慢指针。
面试简述
使用快慢指针遍历链表,慢指针每次走一步,快指针每次走两步。无环时快指针会到达链表末尾;有环时快慢指针最终会在环内相遇。时间复杂度为 O(n),空间复杂度为 O(1)。
解法二:哈希集
class Solution {
public:
bool hasCycle(ListNode *head) {
unordered_set<ListNode*> seen;
while (head != nullptr) {
if (seen.count(head)) {
return true;
}
seen.insert(head);
head = head->next;
}
return false;
}
};
简单解释
遍历链表时,将访问过的节点地址保存在哈希集合 seen 中。如果当前节点地址已经存在于集合中,说明沿着 next 指针再次访问了同一个节点,链表中存在环;如果能够走到 nullptr,则不存在环。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
注意点
- 哈希集合必须保存节点指针
ListNode*,不能保存节点值;不同节点的val可以相同。 - 先检查当前节点是否访问过,再将它插入集合。
记忆锚点
记录走过的节点地址;再次遇到同一地址就是有环。
面试简述
使用哈希集合记录已经访问过的节点地址,遍历过程中若再次遇到同一地址则存在环,走到 nullptr 则不存在环。时间和空间复杂度均为 O(n)。
142. 环形链表 II
- 难度:中等
- 分类:链表
- 原题:LeetCode 142. 环形链表 II
题面
给定链表的头节点 head,返回链表开始入环的第一个节点。如果链表无环,则返回 null。
如果链表中存在某个节点,可以通过连续跟踪 next 指针再次到达该节点,则链表中存在环。评测系统使用整数 pos 表示链表尾连接到链表中的位置,索引从 0 开始;pos 仅用于描述链表结构,不会作为参数传入。
不允许修改链表。
示例 1
head = [3,2,0,-4], pos = 1返回索引为 1 的链表节点示例 2
head = [1,2], pos = 0返回索引为 0 的链表节点示例 3
head = [1], pos = -1null数据范围
- 链表中节点的数目范围是
[0, 10^4] -10^5 <= Node.val <= 10^5pos为-1或链表中的有效索引
进阶
使用 O(1) 额外空间解决此题。
解法一:快慢指针
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode*slow = head , *fast = head;
while(fast!=nullptr){
if(fast->next == nullptr){
return nullptr;
}
slow = slow->next;
fast = fast->next->next;
if(slow==fast){
ListNode* p = head;
while(slow!=p){
slow = slow->next;
p = p->next;
}
return p;
}
}
return nullptr;
}
};
简单解释
第一阶段使用快慢指针判断是否有环。slow 每次走一步,fast 每次走两步;如果快指针走到链表末尾,说明无环。如果二者相遇,说明存在环。
相遇后,将指针 p 放到 head,让 p 和相遇点处的 slow 每次各走一步,它们再次相遇的位置就是入环点。
设头节点到入环点的距离为 a,入环点到首次相遇点的距离为 b,环长为 L。首次相遇时:
slow 走了 a+b,fast 走了两倍距离,因此二者的路程差满足 a+b = kL。于是:
a = kL-b = (k-1)L+(L-b)
也就是说,从头节点到入环点的距离,等价于从相遇点继续走到入环点的距离再加若干整圈,所以两个同速指针会在入环点相遇。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 快指针每次走两步,必须在移动前确认
fast和fast->next都不为空;当前代码使用外层while和内层if分别判断。 - 第一次相遇后,让
p从head出发,而slow留在相遇点;随后两个指针都必须每次只走一步。 - 返回的是入环节点的指针,不是题面中用于描述测试数据的
pos。
记忆锚点
快慢指针先在环中相遇,再从链表头派一个同速指针;下一次相遇就是环入口。
面试简述
先用快慢指针判断链表是否有环并找到环内相遇点。相遇后,再从头节点出发一个指针,与相遇点处的慢指针同速前进,二者会在入环点相遇。整个过程不修改链表,时间复杂度为 O(n),空间复杂度为 O(1)。
21. 合并两个有序链表
- 难度:简单
- 分类:链表
- 原题:LeetCode 21. 合并两个有序链表
题面
将两个升序链表合并为一个新的升序链表并返回。新链表由拼接给定的两个链表的所有节点组成。
示例 1
list1 = [1,2,4], list2 = [1,3,4][1,1,2,3,4,4]示例 2
list1 = [], list2 = [][]示例 3
list1 = [], list2 = [0][0]数据范围
- 两个链表的节点数目范围均为
[0, 50] -100 <= Node.val <= 100list1和list2均按非递减顺序排列
解法一:递归
class Solution {
public:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
if (l1 == nullptr) {
return l2;
} else if (l2 == nullptr) {
return l1;
} else if (l1->val < l2->val) {
l1->next = mergeTwoLists(l1->next, l2);
return l1;
} else {
l2->next = mergeTwoLists(l1, l2->next);
return l2;
}
}
};
简单解释
每次比较两个链表的头节点:
- 如果
l1->val < l2->val,当前结果的头节点就是l1,再递归合并l1->next和l2。 - 否则当前结果的头节点就是
l2,再递归合并l1和l2->next。
当其中一个链表为空时,直接返回另一个链表。递归返回过程中逐层连接节点,最终得到完整的升序链表。
复杂度
- 时间复杂度:
O(m+n) - 空间复杂度:
O(m+n),来自递归调用栈
注意点
- 递归终止条件是任意一个链表为空,此时直接返回另一个链表的剩余部分。
- 选择较小头节点后,只移动被选中的链表,并把递归结果接到该节点的
next。
记忆锚点
较小的头节点留下,剩余部分继续递归合并。
面试简述
比较两个链表的头节点,将较小节点作为当前结果的头部,再递归合并它的后继链表与另一个链表。任意链表为空时直接返回另一条链表。时间复杂度为 O(m+n),递归栈空间复杂度为 O(m+n)。
解法二:迭代归并
class Solution {
public:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode* preHead = new ListNode(-1);
ListNode* prev = preHead;
while (l1 != nullptr && l2 != nullptr) {
if (l1->val < l2->val) {
prev->next = l1;
l1 = l1->next;
} else {
prev->next = l2;
l2 = l2->next;
}
prev = prev->next;
}
// 合并后 l1 和 l2 最多只有一个还未被合并完,我们直接将链表末尾指向未合并完的链表即可
prev->next = l1 == nullptr ? l2 : l1;
return preHead->next;
}
};
简单解释
使用哑节点 preHead 统一处理结果链表的头部,prev 始终指向已经合并部分的最后一个节点。
当两个链表都不为空时,比较头节点并将较小节点直接接到 prev->next,然后移动对应链表的指针和 prev。一条链表耗尽后,将另一条链表的剩余部分整体接到结果末尾。
复杂度
- 时间复杂度:
O(m+n) - 空间复杂度:
O(1),不计算哑节点
注意点
- 接入较小节点后,必须同时移动对应的链表指针和结果尾指针
prev。 - 循环结束后不需要继续逐个遍历,直接执行
prev->next = l1 == nullptr ? l2 : l1接上剩余链表。 preHead使用new创建但没有释放;严格管理内存时可改用栈上哑节点,或先保存结果头节点再释放preHead。
记忆锚点
哑节点起头,较小节点接尾;一条走完,另一条整段接上。
面试简述
使用哑节点和尾指针维护合并结果,每次从两个链表头部选择较小节点直接接到结果末尾,一条链表耗尽后连接另一条的剩余部分。时间复杂度为 O(m+n),额外空间复杂度为 O(1)。
2. 两数相加
- 难度:中等
- 分类:链表
- 原题:LeetCode 2. 两数相加
题面
给定两个非空链表,分别表示两个非负整数。每位数字按照逆序存储,每个节点只存储一位数字。
将两个数相加,并以相同的逆序链表形式返回结果。除数字 0 外,输入数字不会以 0 开头。
示例 1
l1 = [2,4,3], l2 = [5,6,4][7,0,8]342 + 465 = 807示例 2
l1 = [0], l2 = [0][0]示例 3
l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9][8,9,9,9,0,0,0,1]数据范围
- 每个链表中的节点数范围是
[1, 100] 0 <= Node.val <= 9- 题目保证链表表示的数字不含前导零
解法一:模拟竖式加法
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
ListNode dummy;
ListNode* cur = &dummy;
int carry = 0; //进位
while(l1 || l2 || carry){
if(l1){
carry += l1->val;
l1 = l1->next;
}
if(l2){
carry += l2->val;
l2 = l2->next;
}
cur->next = new ListNode(carry % 10);
cur = cur->next;
carry /= 10;
}
return dummy.next;
}
};
简单解释
链表按照个位到高位的顺序存储,正好可以从头开始模拟竖式加法。carry 在每轮开始时保存上一位的进位:
- 如果
l1、l2当前仍有节点,就分别把节点值加到carry中。 carry % 10是结果链表当前位的数字。carry /= 10得到传给下一位的进位。
栈上哑节点 dummy 用于统一创建结果链表,cur 始终指向结果链表的末尾。
复杂度
- 时间复杂度:
O(max(m,n)) - 空间复杂度:额外指针空间为
O(1);返回链表需要O(max(m,n))个节点
注意点
- 输入链表是逆序存储,头节点表示个位,因此可以直接从头到尾逐位相加。
- 循环条件必须包含
carry,即while(l1 || l2 || carry),否则最高位产生的最终进位会丢失。 - 每轮生成节点后执行
carry /= 10,使carry从本轮总和变回下一位进位。
记忆锚点
链表头就是个位:两位加进位,当前位取模,下一位取整。
面试简述
由于数字按逆序存储,可以从两个链表头部开始模拟竖式加法。每轮累加两个当前位和上一位进位,用模 10 生成当前节点、除以 10 更新进位,直到两个链表和进位都为空。时间复杂度为 O(max(m,n)),辅助空间复杂度为 O(1)。
19. 删除链表的倒数第 N 个结点
- 难度:中等
- 分类:链表
- 原题:LeetCode 19. 删除链表的倒数第 N 个结点
题面
给定一个链表,删除链表的倒数第 n 个结点,并返回链表的头结点。
示例 1
head = [1,2,3,4,5], n = 2[1,2,3,5]示例 2
head = [1], n = 1[]示例 3
head = [1,2], n = 1[1]数据范围
- 链表中结点的数目为
sz 1 <= sz <= 300 <= Node.val <= 1001 <= n <= sz
进阶
使用一趟扫描完成删除。
解法一:快慢指针
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummy = new ListNode(-1);
dummy->next = head;
ListNode* slow = dummy;
ListNode* fast = head;
int t = n;
while(t--){
fast = fast->next;
}
while(fast!=nullptr){
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return dummy->next;
}
};
简单解释
使用哑节点 dummy 指向原链表头,使删除头节点和删除中间节点使用同一套逻辑。
fast 从 head 出发先走 n 步,slow 从 dummy 出发。随后两个指针同步移动;当 fast 到达 nullptr 时,slow 正好位于倒数第 n 个节点的前一个节点。执行 slow->next = slow->next->next 即可完成删除。
复杂度
- 时间复杂度:
O(L),L为链表长度 - 空间复杂度:
O(1)
注意点
- 必须使用哑节点,才能统一处理删除头节点的情况;最终返回
dummy->next。 - 当前初始化是
slow = dummy、fast = head,因此快指针先走n步后,同步移动到fast == nullptr,slow就是待删除节点的前驱。 dummy使用new创建但没有释放;严格管理内存时可改用栈上哑节点。
记忆锚点
快指针先走
n步,再和哑节点处的慢指针一起走;快指针到空时,慢指针停在待删除节点之前。
面试简述
建立哑节点并使用快慢指针。快指针先从链表头移动 n 步,再与哑节点处的慢指针同步前进;快指针到达末尾时,慢指针正好位于待删除节点的前驱,修改一次 next 即可删除。时间复杂度为 O(L),空间复杂度为 O(1)。
24. 两两交换链表中的节点
- 难度:中等
- 分类:链表
- 原题:LeetCode 24. 两两交换链表中的节点
题面
给定一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。不能修改节点内部的值,只能交换节点本身。
示例 1
head = [1,2,3,4][2,1,4,3]示例 2
head = [][]示例 3
head = [1][1]数据范围
- 链表中节点的数目范围是
[0, 100] 0 <= Node.val <= 100
解法一:迭代交换
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
ListNode* dummy = new ListNode(-1);
dummy->next = head;
ListNode* cur = dummy;
while(cur->next!=nullptr&&cur->next->next!=nullptr){
ListNode* first = cur->next;
ListNode* second = cur->next->next;
first->next = second->next;
second->next=first;
cur->next = second;
cur = first;
}
return dummy->next;
}
};
简单解释
每轮只观察下面四段:
cur -> first -> second -> rest
交换后要变成:
cur -> second -> first -> rest
对应的三次连接顺序是:
first->next = second->next:让第一个节点先接住后续链表。second->next = first:让第二个节点指回第一个节点。cur->next = second:让前一段链表接到交换后的新头。
交换完成后,原来的 first 已经变成这一对的尾节点,因此执行 cur = first,从下一对前面继续。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 只有
cur->next和cur->next->next都存在时才能交换,奇数长度链表的最后一个节点保持不动。 - 改指针的顺序要记住:先让
first接住rest,再让second指向first,最后让cur指向second。 - 交换后必须执行
cur = first,因为原来的第一个节点现在是这一对的尾节点。
记忆锚点
交换前
前→一→二→后,口诀:一接后、二接一、前接二、前移一。
面试简述
使用哑节点统一处理链表头,每次取出相邻的 first、second,将 cur -> first -> second -> rest 重连为 cur -> second -> first -> rest,再把 cur 移到这一对的尾部。每个节点只处理一次,时间复杂度为 O(n),空间复杂度为 O(1)。
25. K 个一组翻转链表
- 难度:困难
- 分类:链表
- 原题:LeetCode 25. K 个一组翻转链表
题面
给定链表的头节点 head,每 k 个节点一组进行翻转,并返回修改后的链表。
k 是正整数且不超过链表长度。如果节点总数不是 k 的整数倍,最后不足 k 个的节点保持原有顺序。不能只修改节点内部的值,必须实际翻转节点。
示例 1
head = [1,2,3,4,5], k = 2[2,1,4,3,5]示例 2
head = [1,2,3,4,5], k = 3[3,2,1,4,5]数据范围
- 链表中的节点数目为
n 1 <= k <= n <= 50000 <= Node.val <= 1000
进阶
使用 O(1) 额外空间完成翻转。
解法一:分组截断后翻转
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseKGroup(ListNode* head, int k) {
if(!head) return head;
int count = 0;
ListNode* dummy = new ListNode(-1);
dummy->next = head;
ListNode* cur = dummy->next;
ListNode* pre = dummy;
while(cur!=nullptr){
count++;
ListNode* next = cur->next;
if(count==k){
cur->next = nullptr;
pre = reverse(pre,next);
count = 0;
}
cur = next;
}
return dummy->next;
}
ListNode* reverse(ListNode* pre, ListNode* next){
ListNode* head = pre->next;
// cout << head->val;
ListNode* last = nullptr;
ListNode* cur = head;
while(cur!=nullptr){
ListNode* ne = cur->next;
cur->next = last;
last = cur;
cur = ne;
}
pre->next = last;
head->next = next;
return head;
}
};
简单解释
外层循环中三个指针的含义:
pre:当前这一组前面的节点。cur:正在遍历的节点;当count == k时,它就是当前组的最后一个节点。next:当前组后面的第一个节点,也是下一组的起点。
找到完整的 k 个节点后,链表形状为:
pre -> head -> ... -> cur -> next
先保存 next,再执行 cur->next = nullptr 截断当前组。辅助函数把这一组整体翻转,并重新连接前后两段:
pre -> cur -> ... -> head -> next
原组头 head 在翻转后成为新组尾,因此辅助函数返回 head,外层将它赋给 pre,供下一组继续使用。
最后不足 k 个的节点不会触发 count == k,所以会保持原顺序。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(1)
注意点
- 到达第
k个节点时,要先保存next = cur->next,再执行cur->next = nullptr,否则会丢失后续链表。 - 辅助函数返回的是原组头
head,因为它翻转后成为新组尾;外层必须用返回值更新pre。 - 每组结束后执行
count = 0,并让cur = next;不足k个的尾部不能翻转。
记忆锚点
数满
k个:存后继、断开、整组翻转、接回;原组头变新组尾,作为下一组的pre。
面试简述
遍历链表并每次数满 k 个节点后,将当前组从链表中暂时截断,原地翻转,再重新连接组前节点和下一组起点。辅助函数返回翻转后的组尾,供下一组继续处理;不足 k 个的尾部保持不变。时间复杂度为 O(n),空间复杂度为 O(1)。
138. 随机链表的复制
- 难度:中等
- 分类:链表
- 原题:LeetCode 138. 随机链表的复制
题面
给定一个长度为 n 的链表,每个节点除了 next 指针外,还包含一个 random 指针,该指针可以指向链表中的任意节点或空节点。
构造该链表的深拷贝。复制链表由 n 个全新节点组成,每个新节点的值与对应原节点相同,且 next、random 在新链表中保持相同的指向关系。复制链表中的指针不能指向原链表节点。
输入输出使用 [val, random_index] 描述节点,其中 random_index 是随机指针指向节点的索引;不指向任何节点时为 null。函数实际只接收头节点 head。
示例 1
head = [[7,null],[13,0],[11,4],[10,2],[1,0]][[7,null],[13,0],[11,4],[10,2],[1,0]]示例 2
head = [[1,1],[2,1]][[1,1],[2,1]]示例 3
head = [[3,null],[3,0],[3,null]][[3,null],[3,0],[3,null]]数据范围
0 <= n <= 1000-10^4 <= Node.val <= 10^4Node.random为null或指向链表中的节点
解法一:哈希映射
/*
// Definition for a Node.
class Node {
public:
int val;
Node* next;
Node* random;
Node(int _val) {
val = _val;
next = NULL;
random = NULL;
}
};
*/
class Solution {
public:
Node* copyRandomList(Node* head) {
unordered_map<Node*,Node*> mp;
for(auto p = head ; p ; p=p->next){
mp[p] = new Node(p->val);
}
for(auto p = head ; p ; p = p->next){
mp[p]->next = mp[p->next];
mp[p]->random = mp[p->random];
}
return mp[head];
}
};
简单解释
哈希表 mp 保存“原节点地址 → 对应新节点地址”的映射:
- 第一遍遍历只创建所有新节点,并建立
mp[p]。 - 第二遍遍历通过映射设置新节点的
next和random。
例如原节点的随机指针满足 p->random == q,那么复制节点的随机指针就应该是 mp[p]->random = mp[q]。这样新链表中的所有指针都只会指向新节点。
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n),用于保存原节点到新节点的映射
注意点
- 哈希表的键必须是原节点地址
Node*,不能使用节点值;不同节点的值可能相同。 - 必须先创建完所有新节点,再统一连接指针,因为
random可能指向尚未遍历到的节点。 - 当前写法利用
mp[nullptr]会默认得到空指针,因此可以直接处理空的next、random和空链表。
记忆锚点
第一遍只造节点并记映射,第二遍让新节点照着原节点的
next和random连线。
面试简述
使用哈希表建立原节点到复制节点的映射。第一遍遍历创建全部新节点,第二遍根据原节点的 next 和 random,通过映射设置复制节点的对应指针。时间复杂度和额外空间复杂度均为 O(n)。
148. 排序链表
- 难度:中等
- 分类:链表
- 原题:LeetCode 148. 排序链表
题面
给定链表的头节点 head,将链表按升序排列并返回排序后的链表。
示例 1
head = [4,2,1,3][1,2,3,4]示例 2
head = [-1,5,3,4,0][-1,0,3,4,5]示例 3
head = [][]数据范围
- 链表中节点的数目范围是
[0, 5 * 10^4] -10^5 <= Node.val <= 10^5
进阶
在 O(n log n) 时间复杂度和常数级额外空间复杂度下完成排序。
解法一:自顶向下归并排序
class Solution {
public:
ListNode* sortList(ListNode* head) {
// 终止条件:如果没有节点或只有一个节点,直接返回
if (head == nullptr || head->next == nullptr) {
return head;
}
// 1. 找到中点并断开 (利用快慢指针)
ListNode* mid = getMid(head);
ListNode* rightHead = mid->next;
mid->next = nullptr; // 【关键】一定要切断连接!
// 2. 递归排序左右两半
ListNode* left = sortList(head);
ListNode* right = sortList(rightHead);
// 3. 合并两个有序链表
return merge(left, right);
}
// 找中点的函数(之前学过的逻辑)
ListNode* getMid(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head->next; // 注意这里起点错开,方便切分
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
// 合并两个有序链表的函数
ListNode* merge(ListNode* l1, ListNode* l2) {
ListNode dummy(0); // 虚拟头结点,方便合并
ListNode* curr = &dummy;
while (l1 && l2) {
if (l1->val < l2->val) {
curr->next = l1;
l1 = l1->next;
} else {
curr->next = l2;
l2 = l2->next;
}
curr = curr->next;
}
curr->next = l1 ? l1 : l2; // 接上剩余的部分
return dummy.next;
}
};
简单解释
链表适合使用归并排序,整个过程分为三步:
- 切分:使用快慢指针找到左半部分的尾节点
mid,保存右半部分头节点后断开链表。 - 递归排序:分别调用
sortList排序左右两半。 - 归并:复用原节点,将两个有序链表合并为一条有序链表。
getMid 中让 fast 从 head->next 开始,使 slow 在偶数长度时停在左中点。例如两个节点的链表会被正确切成两个单节点链表。
复杂度
- 时间复杂度:
O(n log n) - 空间复杂度:
O(log n),来自递归调用栈
注意点
- 递归终止条件必须包含空链表和单节点链表:
head == nullptr || head->next == nullptr。 fast从head->next开始以找到左中点,并且必须执行mid->next = nullptr断链,否则递归子问题不会缩小。- 当前自顶向下写法使用
O(log n)递归栈;若严格要求常数级额外空间,需要改用自底向上的迭代归并排序。
记忆锚点
快慢指针找左中点并断链,左右递归排好序,最后按第 21 题合并。
面试简述
使用归并排序:快慢指针找到左中点并断开链表,递归排序左右两半,再复用原节点合并两个有序链表。每层归并总共处理 O(n) 个节点,共有 O(log n) 层,因此时间复杂度为 O(n log n),递归栈空间复杂度为 O(log n)。
23. 合并 K 个升序链表
- 难度:困难
- 分类:链表
- 原题:LeetCode 23. 合并 K 个升序链表
题面
给定一个链表数组,每个链表都已经按升序排列。将所有链表合并为一个升序链表,并返回合并后的链表。
示例 1
lists = [[1,4,5],[1,3,4],[2,6]][1,1,2,3,4,4,5,6]1→4→5、1→3→4、2→6 合并为 1→1→2→3→4→4→5→6。示例 2
lists = [][]示例 3
lists = [[]][]数据范围
k == lists.length0 <= k <= 10^40 <= lists[i].length <= 500-10^4 <= lists[i][j] <= 10^4lists[i]按升序排列- 所有链表的节点总数不超过
10^4
解法一:优先队列
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
struct cmp{
bool operator()(ListNode* a , ListNode* b){
return a->val > b->val;
};
};
priority_queue<ListNode*,vector<ListNode*>,cmp> q;
ListNode* mergeKLists(vector<ListNode*>& lists) {
for(auto& node : lists){
if(node!=nullptr) q.push(node);
}
ListNode* dummy = new ListNode(-1);
ListNode* cur = dummy;
while(!q.empty()){
ListNode* node = q.top();
q.pop();
cur->next = node;
cur = cur->next;
if(node->next) q.push(node->next);
}
return dummy->next;
}
};
简单解释
优先队列中始终保存每条尚未合并完的链表的当前头节点:
- 先将所有非空链表的头节点压入小根堆。
- 每次弹出值最小的节点,将它接到结果链表末尾。
- 如果该节点还有后继,就将后继压入堆中。
任意时刻每条链表最多只有一个节点在堆里,因此堆的大小不超过 k。
复杂度
- 时间复杂度:
O(N log k),N为所有链表的节点总数 - 空间复杂度:
O(k),用于优先队列
注意点
- 小根堆语法是
priority_queue<ListNode*, vector<ListNode*>, cmp>;比较器写成a->val > b->val,才能让值更小的节点位于top()。 - 初始化时只压入非空链表的头节点,避免将
nullptr放入堆中。 - 每次弹出节点后,只压入它的后继节点;这样堆中始终只有各链表当前最小的候选节点。
记忆锚点
堆里只放每条链表当前的头:弹出最小节点接到答案,再把它的后继补进堆。
面试简述
将每条非空链表的头节点放入小根堆,每次取出值最小的节点接到结果链表,并将该节点的后继重新入堆。堆大小最多为 k,所以总时间复杂度为 O(N log k),空间复杂度为 O(k)。
146. LRU 缓存
- 难度:中等
- 分类:链表
- 原题:LeetCode 146. LRU 缓存
题面
设计并实现一个满足 LRU(最近最少使用)约束的缓存数据结构。
LRUCache(int capacity):使用正整数容量capacity初始化缓存。int get(int key):如果key存在,返回对应值;否则返回-1。void put(int key, int value):如果key已存在,更新其值;否则插入键值对。插入后超过容量时,淘汰最久未使用的键。
get 和 put 必须以平均 O(1) 时间复杂度运行。
示例
["LRUCache","put","put","get","put","get","put","get","get","get"][[2],[1,1],[2,2],[1],[3,3],[2],[4,4],[1],[3],[4]][null,null,null,1,null,-1,null,-1,3,4]2;访问 1 后它变为最近使用,插入 3 时淘汰 2,插入 4 时淘汰 1。数据范围
1 <= capacity <= 30000 <= key <= 100000 <= value <= 10^5- 最多调用
2 * 10^5次get和put
解法一:哈希表 + 双向链表
struct DLinkedNode{
int key,value;
DLinkedNode* prev;
DLinkedNode* next;
DLinkedNode(): key(0),value(0),prev(nullptr),next(nullptr){}
DLinkedNode(int _key, int _value): key(_key),value(_value) ,prev(nullptr),next(nullptr){}
};
class LRUCache {
private:
unordered_map <int,DLinkedNode*> cache;
DLinkedNode* head;
DLinkedNode* tail;
int size;
int capacity;
public:
LRUCache(int _capacity):capacity(_capacity), size(0) {
// 使用伪头部和伪尾部节点
head = new DLinkedNode();
tail = new DLinkedNode();
head->next = tail;
tail->prev = head;
}
void moveToHead(DLinkedNode* node) {
removeNode(node);
addToHead(node);
}
void removeNode(DLinkedNode* node){
node->prev->next = node->next;
node->next->prev = node->prev;
}
void addToHead(DLinkedNode* node){
node->prev = head;
node->next = head->next;
head->next->prev = node;
head->next = node;
}
int get(int key) {
if(!cache.count(key)){
return -1;
}
// 如果key存在,通过hash表定位再移到链表头部
DLinkedNode* node = cache[key];
moveToHead(node);
return node->value;
}
DLinkedNode* removeTail(DLinkedNode* tail){
DLinkedNode* node = tail->prev;
removeNode(node);
return node;
}
void put(int key, int value) {
if(cache.count(key)){
DLinkedNode* node = cache[key];
node->value = value;
moveToHead(node);
return;
}
if(size+1>capacity){
DLinkedNode* node = removeTail(tail);
cache.erase(node->key);
delete node;
--size;
}
DLinkedNode*node = new DLinkedNode(key,value);
cache[key] = node;
addToHead(node);
size++;
}
};
/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache* obj = new LRUCache(capacity);
* int param_1 = obj->get(key);
* obj->put(key,value);
*/
简单解释
两种数据结构各自负责一件事:
unordered_map<int, DLinkedNode*>:根据key在平均O(1)时间内找到对应节点。- 双向链表:维护使用顺序,
head->next是最近使用的节点,tail->prev是最久未使用的节点。
操作流程:
get命中:取得节点值,并将节点移动到链表头部。put更新已有键:修改节点值,并移动到头部。put插入新键:容量已满时先删除尾部节点,再把新节点加入头部。
伪头、伪尾节点让头插、删除和淘汰操作都不需要单独处理空链表或边界节点。
复杂度
get时间复杂度:平均O(1)put时间复杂度:平均O(1)- 空间复杂度:
O(capacity)
注意点
- 链表顺序必须固定为:越靠近头部越新,越靠近尾部越旧;
head->next是 MRU,tail->prev是 LRU。 - 节点被访问或更新后,要先从原位置删除,再插入头部;不能直接重复插入。
- 淘汰尾节点时必须同步执行:从链表删除、从哈希表
erase、释放节点并更新size;更新已有键时则不能改变size。
记忆锚点
哈希表负责瞬间找到节点;双向链表头放最新、尾放最旧,访问就移头,满了就删尾。
面试简述
使用哈希表保存键到链表节点的映射,使用带伪头尾节点的双向链表维护访问顺序。命中或更新节点时将其移到头部,插入新节点且容量已满时淘汰尾部节点。哈希查找和双向链表增删均为常数时间,因此 get、put 的平均时间复杂度都是 O(1)。

浙公网安备 33010602011771号