AIGC标识 Hot100速记

LeetCode Hot 100 速记

一、哈希

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. 字母异位词分组

题面

给定一个字符串数组 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^4
  • 0 <= strs[i].length <= 100
  • strs[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. 最长连续序列

题面

给定一个未排序的整数数组 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 + 1n + 2 等数字,统计当前序列长度并更新答案。

复杂度

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

注意点

  • 只有当 n - 1 不存在时才向后查找;否则会重复扫描同一段序列,最坏退化为 O(n²)
  • 空数组没有进入遍历,依靠 ans 初始化为 0 返回正确结果。

记忆锚点

只从没有前驱 n - 1 的数字开始向后数。

面试简述

先用哈希集合对数组去重。遍历集合时,只从不存在前驱 n - 1 的数字开始向后扩展连续序列,从而保证每个数字最多参与一次有效扩展。平均时间复杂度为 O(n),空间复杂度为 O(n)

二、双指针

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 指向最靠左、等待被非零元素替换的位置。

lr 不在同一位置且二者数值不同时,此时相当于 l 指向零、r 指向非零元素,交换两者。只要 nums[l] 已经是非零元素,就让 l 向后移动;r 每轮都向后扫描。

复杂度

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

注意点

  • 快速回忆:r 负责扫描,l 守住最左侧的零,遇到后面的非零元素就交换。
  • r 每轮都加一;l 只有在 nums[l] != 0 时才加一。

记忆锚点

r 找非零,l 找空位,交换后一起向前。

面试简述

使用两个指针原地处理数组:右指针扫描元素,左指针定位最靠左的零。当右侧出现非零元素时,将其交换到左侧空位,并推进左指针。每个元素只扫描一次,时间复杂度为 O(n),空间复杂度为 O(1)

11. 盛最多水的容器

题面

给定一个长度为 n 的整数数组 height。第 i 条垂线的两个端点为 (i, 0)(i, height[i])

找出其中两条线,使它们与 x 轴构成的容器能够容纳最多的水,返回最大水量。容器不能倾斜。

盛最多水的容器示例图

示例 1

输入:height = [1,8,6,2,5,4,8,3,7]
输出:49
解释:图中容器能够容纳的最大水量为 49

示例 2

输入:height = [1,1]
输出:1

数据范围

  • n == height.length
  • 2 <= n <= 10^5
  • 0 <= 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. 三数之和

题面

给定一个整数数组 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. 接雨水

题面

给定 n 个非负整数,表示每个宽度为 1 的柱子的高度,计算这些柱子在下雨后能够接住多少雨水。

接雨水高度图示例

示例 1

输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:图中蓝色部分表示雨水,共能接住 6 个单位的雨水。

示例 2

输入:height = [4,2,0,3,2,5]
输出:9

数据范围

  • n == height.length
  • 1 <= n <= 2 * 10^4
  • 0 <= 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;
    }
};

简单解释

leftMaxrightMax 分别记录左右两侧已经遇到的最高柱子。

每轮处理当前高度较低的一侧:如果左侧更低,就能确定左侧当前位置的雨水量为 leftMax - height[left];否则计算右侧的 rightMax - height[right]。处理完该位置后,将对应指针向内移动。

复杂度

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

注意点

  • 每轮必须先更新 leftMaxrightMax,再计算当前位置的雨水。
  • 比较当前的 height[left]height[right],处理较低的一侧;两侧等高时当前代码处理右侧。
  • 双指针法每轮累加的是一个位置上方的雨水量,不需要乘宽度。

记忆锚点

哪边矮就结算哪边:左边用 leftMax,右边用 rightMax

面试简述

使用左右指针和两侧最大高度。每轮先更新 leftMaxrightMax,再结算当前较低一侧的雨水并移动该侧指针。每个位置只处理一次,时间复杂度为 O(n),额外空间复杂度为 O(1)

三、滑动窗口

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^4
  • s 由英文字母、数字、符号和空格组成

解法一:哈希集合维护滑动窗口

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. 找到字符串中所有字母异位词

题面

给定两个字符串 sp,找到 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^4
  • sp 仅包含小写英文字母

解法一:固定长度窗口对比频次数组

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 的子数组

题面

给定一个整数数组 nums 和一个整数 k,统计并返回该数组中和为 k 的子数组个数。

子数组是数组中元素的连续非空序列。

示例 1

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

示例 2

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

数据范围

  • 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. 滑动窗口最大值

题面

给定一个整数数组 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^4
  • 1 <= 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. 最小覆盖子串

题面

给定两个字符串 st,长度分别为 mn。返回 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.length
  • n == t.length
  • 1 <= m, n <= 10^5
  • st 由英文字母组成

进阶:设计一个时间复杂度为 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 的数量

kindt 中不同字符的种类数,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. 最大子数组和

题面

给定一个整数数组 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. 合并区间

题面

用数组 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^4
  • intervals[i].length == 2
  • 0 <= 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 指向当前准备合并的第一个区间,使用 llrr 记录合并后区间的左右端点。

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. 轮转数组

题面

给定一个整数数组 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 - 1
  • 0 <= 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. 除了自身以外数组的乘积

题面

给定一个整数数组 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] <= 30
  • answer[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. 缺失的第一个正数

题面

给定一个未排序的整数数组 nums,找出其中没有出现的最小正整数。

要求实现时间复杂度为 O(n)、额外空间复杂度为 O(1) 的解决方案。

示例 1

输入:nums = [1,2,0]
输出:3
解释:范围 [1,2] 中的数字都在数组中。

示例 2

输入:nums = [3,4,-1,1]
输出:2
解释:1 在数组中,但 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

  1. 将所有非正数改成无关的正数 n+1
  2. 对每个绝对值位于 [1,n] 的数字 num,把 nums[num-1] 标记为负数,表示 num 出现过。
  3. 从左到右寻找第一个仍为正数的位置 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. 矩阵置零

题面

给定一个 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.length
  • n == matrix[0].length
  • 1 <= 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. 螺旋矩阵

题面

给定一个 mn 列的矩阵 matrix,按照顺时针螺旋顺序返回矩阵中的所有元素。

示例 1

3×3 矩阵的顺时针螺旋遍历顺序
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[1,2,3,6,9,8,7,4,5]

示例 2

3×4 矩阵的顺时针螺旋遍历顺序
输入: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.length
  • n == matrix[i].length
  • 1 <= 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;
    }
};

简单解释

使用 updownleftright 表示当前尚未遍历区域的四条边界。每一圈按以下顺序遍历:

  1. 上边:从左到右,然后 up++
  2. 右边:从上到下,然后 right--
  3. 下边:从右到左,然后 down--
  4. 左边:从下到上,然后 left++

每走完一条边就收缩对应边界;如果边界发生交叉,说明所有元素已经遍历完成,立即退出。

复杂度

  • 时间复杂度:O(mn)
  • 空间复杂度:O(1),不计算返回数组

注意点

  • 四条边的顺序必须记牢:上边向右、右边向下、下边向左、左边向上。
  • 每遍历完一条边,都要先收缩对应边界,再立即判断是否交叉;否则单行或单列矩阵会重复加入元素。
  • 访问矩阵时第一个下标是行、第二个下标是列,例如右边界写作 matrix[i][right]

记忆锚点

右、下、左、上走一圈;每走完一边,就把那条边向内收缩。

面试简述

维护矩阵的上下左右四条边界,依次遍历上边、右边、下边和左边,并在每条边完成后向内收缩对应边界。每个元素只访问一次,时间复杂度为 O(mn),除返回数组外空间复杂度为 O(1)

48. 旋转图像

题面

给定一个 n × n 的二维矩阵 matrix 表示一个图像,将图像顺时针旋转 90 度。

必须直接修改输入矩阵,不能使用另一个矩阵完成旋转。

示例 1

3×3 矩阵顺时针旋转 90 度前后对比
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[[7,4,1],[8,5,2],[9,6,3]]

示例 2

4×4 矩阵顺时针旋转 90 度前后对比
输入: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].length
  • 1 <= 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

题面

在一个 m × n 的矩阵 matrix 中搜索目标值 target。矩阵满足:

  • 每行元素从左到右升序排列
  • 每列元素从上到下升序排列

示例 1

在行列递增矩阵中搜索目标值 5
输入: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 = 5
输出:true

示例 2

在行列递增矩阵中搜索不存在的目标值 20
输入: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 = 20
输出:false

数据范围

  • m == matrix.length
  • n == matrix[i].length
  • 1 <= 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. 相交链表

题面

给定两个单链表的头节点 headAheadB,找出并返回两个链表相交的起始节点。如果两个链表不相交,返回 null

两个链表从节点 c1 开始相交

题目保证整个链式结构中不存在环,并且函数返回后链表必须保持原始结构。

示例中的 intersectVal 表示相交节点的值,skipAskipB 表示从两个链表头部到相交节点前需要跳过的节点数。实际函数只接收两个头节点。

示例 1

相交链表示例 1
输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出:Intersected at '8'
解释:两个链表中值为 1 的节点地址不同,不是交点;值为 8 的节点是同一个节点。

示例 2

相交链表示例 2
输入:intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1
输出:Intersected at '2'

示例 3

不相交链表示例
输入:intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2
输出:No intersection

数据范围

  • 链表 AB 的节点数分别为 mn
  • 1 <= m, n <= 3 * 10^4
  • 1 <= Node.val <= 10^5
  • 0 <= skipA <= m
  • 0 <= 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;
    }
};

简单解释

先分别统计两个链表的长度 mn。让较长链表的头指针先移动 |m-n| 步,使两个指针到链表末尾的剩余距离相同。

随后两个指针同步向后移动。若它们指向同一个节点,该节点就是第一个交点;如果同时走到链表末尾仍未相等,则两个链表不相交。

复杂度

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

注意点

  • 判断相交必须比较节点地址 headA == headB,不能比较 val;值相同的两个节点不一定是同一个节点。
  • 统计长度时使用临时指针 curAcurB,避免提前移动真正用于查找交点的头指针。
  • 较长链表先走长度差,完成尾部对齐后两个指针才能同步寻找第一个公共节点。

记忆锚点

长链表先走差值,对齐尾部后一起走;地址相同才是交点。

面试简述

先计算两个链表的长度,让较长链表先移动长度差,使两个指针距末尾的距离相同。之后同步前进,第一个地址相同的节点即为交点;若走到末尾则不存在交点。时间复杂度为 O(m+n),空间复杂度为 O(1)

206. 反转链表

题面

给定单链表的头节点 head,反转链表并返回反转后的链表。

示例 1

将链表 1→2→3→4→5 反转为 5→4→3→2→1
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]

示例 2

将链表 1→2 反转为 2→1
输入: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,最后同时向后移动 precur。循环结束时,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. 回文链表

题面

给定单链表的头节点 head,判断该链表是否为回文链表。如果是,返回 true;否则,返回 false

示例 1

回文链表 1→2→2→1
输入:head = [1,2,2,1]
输出:true

示例 2

非回文链表 1→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. 环形链表

题面

给定链表的头节点 head,判断链表中是否存在环。

如果链表中存在某个节点,可以通过连续跟踪 next 指针再次到达该节点,则链表中存在环。评测系统使用整数 pos 表示链表尾连接到链表中的位置,索引从 0 开始;pos 仅用于描述链表结构,不会作为参数传入。

示例 1

链表尾连接到索引 1 的节点形成环
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中存在环,尾节点连接到第二个节点。

示例 2

链表尾连接到索引 0 的节点形成环
输入:head = [1,2], pos = 0
输出:true
解释:链表中存在环,尾节点连接到第一个节点。

示例 3

只有一个节点且不存在环
输入:head = [1], pos = -1
输出:false
解释:链表中不存在环。

数据范围

  • 链表中节点的数目范围是 [0, 10^4]
  • -10^5 <= Node.val <= 10^5
  • pos-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
  • 如果链表存在环,两个指针进入环后,快指针每轮会相对慢指针多走一步,因此最终一定会相遇。

当前写法让 slowhead 开始、fasthead->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

题面

给定链表的头节点 head,返回链表开始入环的第一个节点。如果链表无环,则返回 null

如果链表中存在某个节点,可以通过连续跟踪 next 指针再次到达该节点,则链表中存在环。评测系统使用整数 pos 表示链表尾连接到链表中的位置,索引从 0 开始;pos 仅用于描述链表结构,不会作为参数传入。

不允许修改链表。

示例 1

链表尾连接到索引 1 的节点形成环
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中存在环,尾节点连接到第二个节点。

示例 2

链表尾连接到索引 0 的节点形成环
输入:head = [1,2], pos = 0
输出:返回索引为 0 的链表节点
解释:链表中存在环,尾节点连接到第一个节点。

示例 3

只有一个节点且不存在环
输入:head = [1], pos = -1
输出:null
解释:链表中不存在环。

数据范围

  • 链表中节点的数目范围是 [0, 10^4]
  • -10^5 <= Node.val <= 10^5
  • pos-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+bfast 走了两倍距离,因此二者的路程差满足 a+b = kL。于是:

a = kL-b = (k-1)L+(L-b)

也就是说,从头节点到入环点的距离,等价于从相遇点继续走到入环点的距离再加若干整圈,所以两个同速指针会在入环点相遇。

复杂度

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

注意点

  • 快指针每次走两步,必须在移动前确认 fastfast->next 都不为空;当前代码使用外层 while 和内层 if 分别判断。
  • 第一次相遇后,让 phead 出发,而 slow 留在相遇点;随后两个指针都必须每次只走一步。
  • 返回的是入环节点的指针,不是题面中用于描述测试数据的 pos

记忆锚点

快慢指针先在环中相遇,再从链表头派一个同速指针;下一次相遇就是环入口。

面试简述

先用快慢指针判断链表是否有环并找到环内相遇点。相遇后,再从头节点出发一个指针,与相遇点处的慢指针同速前进,二者会在入环点相遇。整个过程不修改链表,时间复杂度为 O(n),空间复杂度为 O(1)

21. 合并两个有序链表

题面

将两个升序链表合并为一个新的升序链表并返回。新链表由拼接给定的两个链表的所有节点组成。

示例 1

将链表 1→2→4 和 1→3→4 合并为 1→1→2→3→4→4
输入: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 <= 100
  • list1list2 均按非递减顺序排列

解法一:递归

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->nextl2
  • 否则当前结果的头节点就是 l2,再递归合并 l1l2->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. 两数相加

题面

给定两个非空链表,分别表示两个非负整数。每位数字按照逆序存储,每个节点只存储一位数字。

将两个数相加,并以相同的逆序链表形式返回结果。除数字 0 外,输入数字不会以 0 开头。

示例 1

链表 2→4→3 与 5→6→4 相加得到 7→0→8
输入: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 在每轮开始时保存上一位的进位:

  1. 如果 l1l2 当前仍有节点,就分别把节点值加到 carry 中。
  2. carry % 10 是结果链表当前位的数字。
  3. 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 个结点

题面

给定一个链表,删除链表的倒数第 n 个结点,并返回链表的头结点。

示例 1

从链表 1→2→3→4→5 中删除倒数第 2 个节点 4
输入: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 <= 30
  • 0 <= Node.val <= 100
  • 1 <= 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 指向原链表头,使删除头节点和删除中间节点使用同一套逻辑。

fasthead 出发先走 n 步,slowdummy 出发。随后两个指针同步移动;当 fast 到达 nullptr 时,slow 正好位于倒数第 n 个节点的前一个节点。执行 slow->next = slow->next->next 即可完成删除。

复杂度

  • 时间复杂度:O(L)L 为链表长度
  • 空间复杂度:O(1)

注意点

  • 必须使用哑节点,才能统一处理删除头节点的情况;最终返回 dummy->next
  • 当前初始化是 slow = dummyfast = head,因此快指针先走 n 步后,同步移动到 fast == nullptrslow 就是待删除节点的前驱。
  • dummy 使用 new 创建但没有释放;严格管理内存时可改用栈上哑节点。

记忆锚点

快指针先走 n 步,再和哑节点处的慢指针一起走;快指针到空时,慢指针停在待删除节点之前。

面试简述

建立哑节点并使用快慢指针。快指针先从链表头移动 n 步,再与哑节点处的慢指针同步前进;快指针到达末尾时,慢指针正好位于待删除节点的前驱,修改一次 next 即可删除。时间复杂度为 O(L),空间复杂度为 O(1)

24. 两两交换链表中的节点

题面

给定一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。不能修改节点内部的值,只能交换节点本身。

示例 1

将链表 1→2→3→4 两两交换为 2→1→4→3
输入: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

对应的三次连接顺序是:

  1. first->next = second->next:让第一个节点先接住后续链表。
  2. second->next = first:让第二个节点指回第一个节点。
  3. cur->next = second:让前一段链表接到交换后的新头。

交换完成后,原来的 first 已经变成这一对的尾节点,因此执行 cur = first,从下一对前面继续。

复杂度

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

注意点

  • 只有 cur->nextcur->next->next 都存在时才能交换,奇数长度链表的最后一个节点保持不动。
  • 改指针的顺序要记住:先让 first 接住 rest,再让 second 指向 first,最后让 cur 指向 second
  • 交换后必须执行 cur = first,因为原来的第一个节点现在是这一对的尾节点。

记忆锚点

交换前 前→一→二→后,口诀:一接后、二接一、前接二、前移一。

面试简述

使用哑节点统一处理链表头,每次取出相邻的 firstsecond,将 cur -> first -> second -> rest 重连为 cur -> second -> first -> rest,再把 cur 移到这一对的尾部。每个节点只处理一次,时间复杂度为 O(n),空间复杂度为 O(1)

25. K 个一组翻转链表

题面

给定链表的头节点 head,每 k 个节点一组进行翻转,并返回修改后的链表。

k 是正整数且不超过链表长度。如果节点总数不是 k 的整数倍,最后不足 k 个的节点保持原有顺序。不能只修改节点内部的值,必须实际翻转节点。

示例 1

链表 1→2→3→4→5 每 2 个一组翻转
输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]

示例 2

链表 1→2→3→4→5 每 3 个一组翻转
输入:head = [1,2,3,4,5], k = 3
输出:[3,2,1,4,5]

数据范围

  • 链表中的节点数目为 n
  • 1 <= k <= n <= 5000
  • 0 <= 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. 随机链表的复制

题面

给定一个长度为 n 的链表,每个节点除了 next 指针外,还包含一个 random 指针,该指针可以指向链表中的任意节点或空节点。

构造该链表的深拷贝。复制链表由 n 个全新节点组成,每个新节点的值与对应原节点相同,且 nextrandom 在新链表中保持相同的指向关系。复制链表中的指针不能指向原链表节点。

输入输出使用 [val, random_index] 描述节点,其中 random_index 是随机指针指向节点的索引;不指向任何节点时为 null。函数实际只接收头节点 head

示例 1

包含五个节点的随机链表及其 random 指向
输入: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^4
  • Node.randomnull 或指向链表中的节点

解法一:哈希映射

/*
// 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 保存“原节点地址 → 对应新节点地址”的映射:

  1. 第一遍遍历只创建所有新节点,并建立 mp[p]
  2. 第二遍遍历通过映射设置新节点的 nextrandom

例如原节点的随机指针满足 p->random == q,那么复制节点的随机指针就应该是 mp[p]->random = mp[q]。这样新链表中的所有指针都只会指向新节点。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(n),用于保存原节点到新节点的映射

注意点

  • 哈希表的键必须是原节点地址 Node*,不能使用节点值;不同节点的值可能相同。
  • 必须先创建完所有新节点,再统一连接指针,因为 random 可能指向尚未遍历到的节点。
  • 当前写法利用 mp[nullptr] 会默认得到空指针,因此可以直接处理空的 nextrandom 和空链表。

记忆锚点

第一遍只造节点并记映射,第二遍让新节点照着原节点的 nextrandom 连线。

面试简述

使用哈希表建立原节点到复制节点的映射。第一遍遍历创建全部新节点,第二遍根据原节点的 nextrandom,通过映射设置复制节点的对应指针。时间复杂度和额外空间复杂度均为 O(n)

148. 排序链表

题面

给定链表的头节点 head,将链表按升序排列并返回排序后的链表。

示例 1

将链表 4→2→1→3 排序为 1→2→3→4
输入:head = [4,2,1,3]
输出:[1,2,3,4]

示例 2

将链表 -1→5→3→4→0 排序为 -1→0→3→4→5
输入: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;
    }
};

简单解释

链表适合使用归并排序,整个过程分为三步:

  1. 切分:使用快慢指针找到左半部分的尾节点 mid,保存右半部分头节点后断开链表。
  2. 递归排序:分别调用 sortList 排序左右两半。
  3. 归并:复用原节点,将两个有序链表合并为一条有序链表。

getMid 中让 fasthead->next 开始,使 slow 在偶数长度时停在左中点。例如两个节点的链表会被正确切成两个单节点链表。

复杂度

  • 时间复杂度:O(n log n)
  • 空间复杂度:O(log n),来自递归调用栈

注意点

  • 递归终止条件必须包含空链表和单节点链表:head == nullptr || head->next == nullptr
  • fasthead->next 开始以找到左中点,并且必须执行 mid->next = nullptr 断链,否则递归子问题不会缩小。
  • 当前自顶向下写法使用 O(log n) 递归栈;若严格要求常数级额外空间,需要改用自底向上的迭代归并排序。

记忆锚点

快慢指针找左中点并断链,左右递归排好序,最后按第 21 题合并。

面试简述

使用归并排序:快慢指针找到左中点并断开链表,递归排序左右两半,再复用原节点合并两个有序链表。每层归并总共处理 O(n) 个节点,共有 O(log n) 层,因此时间复杂度为 O(n log n),递归栈空间复杂度为 O(log n)

23. 合并 K 个升序链表

题面

给定一个链表数组,每个链表都已经按升序排列。将所有链表合并为一个升序链表,并返回合并后的链表。

示例 1

输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:1→4→51→3→42→6 合并为 1→1→2→3→4→4→5→6

示例 2

输入:lists = []
输出:[]

示例 3

输入:lists = [[]]
输出:[]

数据范围

  • k == lists.length
  • 0 <= k <= 10^4
  • 0 <= lists[i].length <= 500
  • -10^4 <= lists[i][j] <= 10^4
  • lists[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;
    }
};

简单解释

优先队列中始终保存每条尚未合并完的链表的当前头节点:

  1. 先将所有非空链表的头节点压入小根堆。
  2. 每次弹出值最小的节点,将它接到结果链表末尾。
  3. 如果该节点还有后继,就将后继压入堆中。

任意时刻每条链表最多只有一个节点在堆里,因此堆的大小不超过 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 缓存

题面

设计并实现一个满足 LRU(最近最少使用)约束的缓存数据结构。

  • LRUCache(int capacity):使用正整数容量 capacity 初始化缓存。
  • int get(int key):如果 key 存在,返回对应值;否则返回 -1
  • void put(int key, int value):如果 key 已存在,更新其值;否则插入键值对。插入后超过容量时,淘汰最久未使用的键。

getput 必须以平均 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 <= 3000
  • 0 <= key <= 10000
  • 0 <= value <= 10^5
  • 最多调用 2 * 10^5getput

解法一:哈希表 + 双向链表

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

记忆锚点

哈希表负责瞬间找到节点;双向链表头放最新、尾放最旧,访问就移头,满了就删尾。

面试简述

使用哈希表保存键到链表节点的映射,使用带伪头尾节点的双向链表维护访问顺序。命中或更新节点时将其移到头部,插入新节点且容量已满时淘汰尾部节点。哈希查找和双向链表增删均为常数时间,因此 getput 的平均时间复杂度都是 O(1)

posted @ 2026-07-29 00:55  clayJ  阅读(18)  评论(0)    收藏  举报