LeetCode Hot 100 背诵笔记
官方题单:LeetCode 热题 100
单题笔记模板
每道题尽量保持简短:用一句话概括题意,用 2~4 条面试时可以直接复述的要点讲清思路,配上最优算法代码,并写明时间、空间复杂度。
> 说明:`0000` 是四位 LeetCode 题号的占位符,请替换为实际题号;例如第 1 题写作 `0001`。
### 0000 题目名称
题目链接:[LeetCode 0000 题目名称](题目链接)
> 核心题意:用一句话说明题目要求。
题解:[灵茶山爱府-LeetCode-0000](我提供给你的链接)
**解法思路:**
- 先说明最关键的观察或解题突破口。
- 再说明核心数据结构、算法和遍历过程。
- 最后说明为什么这样做,以及复杂度如何得到优化。
代码注释要求:只注释关键变量、核心步骤和边界处理,解释“为什么这样做”,避免逐行翻译代码。
```cpp
class Solution {
public:
// 用一句话说明算法的核心做法
ReturnType functionName(/* 参数 */) {
// 关键变量:说明变量的含义和作用
// 按照解法思路执行核心步骤
return result;
}
};
```
**时间和空间复杂度分析:**
- 时间复杂度:$O(?)$,简要说明原因。
- 空间复杂度:$O(?)$,简要说明原因。
哈希
0001 两数之和
题目链接:LeetCode 0001 两数之和
核心题意:在数组中找到两个不同位置的元素,使它们的和等于
target,返回这两个元素的下标。
解法思路:
- 暴力枚举所有数对需要
;关键优化是把“寻找另一个数”变成哈希表的 查询。 - 从左到右遍历
nums[i],如果target - nums[i]已经在哈希表中,就直接得到答案;否则记录nums[i]及其下标。 - 先查再存很关键:哈希表中始终只有当前位置之前的元素,因此既不会使用同一个元素两次,又只需遍历一次数组。
class Solution {
public:
// 一边遍历,一边用哈希表查找当前数字所需要的另一个数
vector<int> twoSum(vector<int>& nums, int target) {
// value -> index:只记录已经遍历过的元素,方便 O(1) 查找补数
unordered_map<int, int> idx;
for (int i = 0; i < nums.size(); i++) {
int x = nums[i];
// 先查再存,保证匹配的是之前的元素,不会和自己重复使用
auto it = idx.find(target - x);
if (it != idx.end()) {
return {it->second, i};
}
idx[x] = i;
}
return {};
}
};
时间和空间复杂度分析:
- 时间复杂度:
,数组只遍历一次,哈希表查询和插入平均为 。 - 空间复杂度:
,最坏情况下哈希表需要保存 个元素。
0049 字母异位词分组
核心题意:把由相同字母组成、只是排列顺序不同的字符串分到同一组。
解法思路:
- 两个字符串互为字母异位词,当且仅当它们排序后的字符串完全相同,所以排序结果可以作为一组字符串的唯一标识。
- 遍历每个字符串,复制一份并排序,用哈希表维护
排序后的字符串 -> 原字符串列表,相同 key 的字符串自然进入同一组。 - 最后把哈希表中的每个
value取出来就是答案,不需要两两比较字符串。
class Solution {
public:
// 把排序后的字符串作为分组标识,相同标识的字符串放到同一组
vector<vector<string>> groupAnagrams(vector<string>& strs) {
// key:排序后的标准形式;value:属于这一组的所有原字符串
unordered_map<string, vector<string>> groups;
for (string& s : strs) {
// 异位词包含完全相同的字符,因此排序后一定得到相同的 key
string key = s;
sort(key.begin(), key.end());
groups[key].push_back(s);
}
vector<vector<string>> ans;
// 每个 key 对应的字符串列表就是一组字母异位词
for (auto& [_, group] : groups) {
ans.push_back(move(group));
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,其中 是字符串数量, 是字符串的平均长度,主要开销是对每个字符串排序。 - 空间复杂度:
,哈希表需要保存分组 key 和所有字符串。
0128 最长连续序列
题目链接:LeetCode 0128 最长连续序列
核心题意:在未排序数组中找到最长的连续整数序列,并返回它的长度,要求做到
时间复杂度。
解法思路:
- 不能排序,否则需要
;先把所有数字放入哈希集合,既去重,又能平均 判断某个数字是否存在。 - 最关键的优化是:只从连续序列的起点开始枚举。如果
x - 1存在,说明x一定不是起点,直接跳过。 - 如果
x - 1不存在,就从x开始不断寻找x+1、x+2...,直到序列断开;这样每段连续序列只会被完整扫描一次,整体保持。 - ⚠️注意:遍历元素的时候,要遍历哈希集合,而不是 nums!否则会超时
class Solution {
public:
// 只从连续序列的起点向后查找,避免对同一段序列重复扫描
int longestConsecutive(vector<int>& nums) {
// 去重,同时支持平均 O(1) 判断某个整数是否存在
unordered_set<int> st(nums.begin(), nums.end());
int ans = 0;
for (int x : st) {
// x-1 存在说明前面还有数字,x 不是起点,从这里扫描会产生重复
if (st.contains(x - 1)) {
continue;
}
// x 是起点,从这里一直向右扩展到连续序列的末尾
int y = x + 1;
while (st.contains(y)) {
y++;
}
// 序列为 [x, y-1],长度正好是 y-x
ans = max(ans, y - x);
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,只从每个连续序列的起点向后扫描,每个不同的数字只会被有限次访问。 - 空间复杂度:
,哈希集合最多保存数组中的所有不同元素。
双指针
0283 移动零
题目链接:LeetCode 0283 移动零
核心题意:原地把数组中的所有
0移到末尾,同时保持所有非零元素的相对顺序不变。
解法思路:
- 不要想着主动移动
0,而是把所有非零元素按原顺序搬到数组前面,这样0自然会被交换到后面。 - 用快指针
i遍历数组,慢指针i0表示下一个非零元素应该放的位置;遇到非零元素,就和nums[i0]交换,然后i0++。 - 因为快指针从左到右依次处理非零元素,所以它们进入前半部分的顺序不会改变;整个过程只扫描一次数组。
class Solution {
public:
// 快指针寻找非零元素,慢指针记录下一个非零元素应放的位置
void moveZeroes(vector<int>& nums) {
int i0 = 0; // [0, i0) 已经是处理好的非零元素
for (int i = 0; i < nums.size(); i++) {
if (nums[i] != 0) {
// 把当前非零元素放到前面,原来的 0 被交换到后面
swap(nums[i], nums[i0]);
i0++;
}
}
}
};
时间和空间复杂度分析:
- 时间复杂度:
,每个元素只遍历一次。 - 空间复杂度:
,只使用一个额外指针变量。
0011 盛最多水的容器
核心题意:从数组中选择两根竖线,使它们和横轴围成的容器面积最大。
解法思路:
- 容器面积是
(right - left) * min(height[left], height[right]),所以面积由宽度和较短的那根柱子共同决定。 - 一开始把两个指针放在数组两端,此时宽度最大;之后每次必须缩小宽度,因此要移动较短的那根柱子,才有可能通过更高的柱子弥补宽度损失。
- 如果移动较高的一边,较短边不变、宽度却变小,面积一定不会更优,所以可以直接排除这种情况。
class Solution {
public:
// 从最大宽度开始,每次淘汰较短的那根柱子
int maxArea(vector<int>& height) {
int ans = 0;
int left = 0, right = height.size() - 1;
while (left < right) {
int area = (right - left) * min(height[left], height[right]);
ans = max(ans, area);
// 宽度必然减小,只有换掉较短边才可能得到更大的面积
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,左右指针总共最多移动 次。 - 空间复杂度:
,只使用常数个额外变量。
0015 三数之和
题目链接:LeetCode 0015 三数之和
核心题意:找出数组中所有和为
0且不重复的三元组。
解法思路:
- 先排序,然后枚举第一个数
nums[i],问题就变成:在右侧有序区间中寻找两个数,使三数之和为0,可以用相向双指针把降到 。 - 如果三数之和小于
0,说明需要更大的数,移动左指针;大于0就移动右指针;等于0就记录答案,并跳过重复元素。 - 排序后还能剪枝:如果当前
nums[i]加上后面两个最小值都大于0,后面只会更大,直接结束;如果加上最大的两个数仍小于0,说明当前数太小,直接枚举下一个。 - 第一个数以及找到答案后的左右指针都要去重,否则会产生重复三元组。灵神的配套题目明确包含这两个剪枝优化。
class Solution {
public:
// 排序后固定第一个数,用相向双指针寻找另外两个数
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<vector<int>> ans;
int n = nums.size();
for (int i = 0; i < n - 2; i++) {
// 相同的第一个数会产生重复三元组
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
// 当前三个最小值已经大于 0,后面不可能再有答案
if (nums[i] + nums[i + 1] + nums[i + 2] > 0) {
break;
}
// 当前数和最大的两个数仍小于 0,当前 nums[i] 太小
if (nums[i] + nums[n - 2] + nums[n - 1] < 0) {
continue;
}
int left = i + 1, right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum < 0) {
left++;
} else if (sum > 0) {
right--;
} else {
ans.push_back({nums[i], nums[left], nums[right]});
// 找到答案后跳过重复的第二、第三个数
for (left++; left < right && nums[left] == nums[left - 1]; left++);
for (right--; left < right && nums[right] == nums[right + 1]; right--);
}
}
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,排序为 ,枚举第一个数并进行双指针扫描为 ,整体由 主导。 - 空间复杂度:
,忽略排序的栈开销和返回的答案数组。
0042 接雨水
题目链接:LeetCode 0042 接雨水
核心题意:给定每根柱子的高度,计算下雨后这些柱子之间一共能够接住多少水。
解法思路:
- 对位置
i来说,它能接的水量是min(左侧最高柱, 右侧最高柱) - height[i],所以关键是确定两侧最大高度。 - 用
preMax和sufMax分别维护当前左右两侧最大高度;如果preMax < sufMax,那么左侧当前位置的水位已经确定为preMax,可以计算左边并移动left,反之计算右边。 - 这样不需要预处理完整的前缀最大值和后缀最大值数组,把
的额外空间优化成 ,同时仍然只遍历一遍。
class Solution {
public:
// 双指针同时维护左右最大高度,谁的最大高度小就计算谁
int trap(vector<int>& height) {
int ans = 0;
int left = 0, right = height.size() - 1;
int preMax = 0, sufMax = 0;
while (left < right) {
// 分别维护 [0, left] 和 [right, n-1] 的最大高度
preMax = max(preMax, height[left]);
sufMax = max(sufMax, height[right]);
if (preMax < sufMax) {
// 右侧一定存在不低于 preMax 的墙,左侧水位已经确定
ans += preMax - height[left];
left++;
} else {
// 左侧一定存在不低于 sufMax 的墙,右侧水位已经确定
ans += sufMax - height[right];
right--;
}
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,左右指针一共只遍历整个数组一次。 - 空间复杂度:
,只维护左右指针、两侧最大高度和答案。
滑动窗口
0003 无重复字符的最长子串
核心题意:找出字符串中不包含重复字符的最长连续子串,并返回其长度。
解法思路讲解:
- 用滑动窗口维护
[left, right],保证窗口内始终没有重复字符,并用哈希表记录每个字符在当前窗口中的出现次数。 - 右指针加入字符
c后,如果cnt[c] > 1,说明出现重复,就不断移动left并移除左端字符,直到c只出现一次。 - 此时窗口一定合法,用
right - left + 1更新最长长度;左右指针都只向右移动,所以整体是线性复杂度。
class Solution {
public:
// 滑动窗口维护一个没有重复字符的最长区间
int lengthOfLongestSubstring(string s) {
int n = s.length(), ans = 0, left = 0;
// 记录当前窗口中每个字符的出现次数
unordered_map<char, int> cnt;
for (int right = 0; right < n; right++) {
char c = s[right];
cnt[c]++;
// c 重复时缩小左边界,直到窗口重新合法
while (cnt[c] > 1) {
cnt[s[left]]--;
left++;
}
ans = max(ans, right - left + 1);
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,每个字符最多被右指针加入一次、被左指针移出一次。 - 空间复杂度:
,哈希表记录窗口中字符的出现次数, 为字符集大小。
0438 找到字符串中所有字母异位词
题目链接:LeetCode 0438 找到字符串中所有字母异位词
核心题意:在字符串
s中找到所有与p字母出现次数完全相同的长度为p.size()的子串,并返回这些子串的起始下标。
解法思路讲解:
- 异位词只关心每种字母的出现次数,因此用两个长度为
26的数组分别统计p和当前窗口中的字母频率。 - 用长度固定为
p.size()的滑动窗口遍历s:右端字符进入窗口,当窗口长度达到p.size()时比较两个计数数组。 - 如果两个数组相同,说明当前窗口就是
p的异位词,记录左端点;检查完成后让左端字符出窗口,继续向右滑动。
class Solution {
public:
// 定长滑窗维护长度为 p.size() 的子串,并比较字符出现次数
vector<int> findAnagrams(string s, string p) {
array<int, 26> cnt_s{};
array<int, 26> cnt_p{};
vector<int> ans;
// 目标频率:p 中每个字母需要出现多少次
for (char c : p) {
cnt_p[c - 'a']++;
}
int m = p.length();
for (int right = 0; right < s.length(); right++) {
// 当前字符进入窗口
cnt_s[s[right] - 'a']++;
int left = right - m + 1;
// 窗口长度还不足 m,暂时不检查
if (left < 0) {
continue;
}
if (cnt_s == cnt_p) {
ans.push_back(left);
}
// 检查完成后移除左端字符,为下一个窗口做准备
cnt_s[s[left] - 'a']--;
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,遍历 p一次、s一次;每次比较长度为26的数组,可视为。 - 空间复杂度:
,只使用两个固定长度为 26的计数数组。
子串
0560 和为 K 的子数组
核心题意:统计数组中元素和恰好等于
k的连续子数组个数。
解法思路讲解:
- 设当前前缀和为
s,如果之前出现过前缀和s - k,那么两者之间的子数组和就是k;出现多少次s-k,就能得到多少个答案。 - 用哈希表
cnt记录之前每种前缀和的出现次数,并初始化cnt[0] = 1,这样从数组开头开始、和恰好为k的子数组也能被统计。 - 必须先统计
cnt[s-k],再执行cnt[s]++,因为当前前缀和不能和自己配对;否则k=0时会把空子数组错误地算进去。
class Solution {
public:
// 一边计算前缀和,一边用哈希表统计满足 s[j] - s[i] = k 的左端点数量
int subarraySum(vector<int>& nums, int k) {
int ans = 0, s = 0;
// 空前缀 s[0] = 0 出现一次
unordered_map<int, int> cnt{{0, 1}};
for (int x : nums) {
s += x;
// 若之前出现过 s-k,则这些位置都可以作为当前子数组的左边界
ans += cnt.contains(s - k) ? cnt[s - k] : 0;
// 当前前缀和只能供后面的元素使用,所以最后再加入哈希表
cnt[s]++;
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,只需遍历数组一次,哈希表查询和插入平均为 。 - 空间复杂度:
,最坏情况下需要记录 种不同的前缀和。
0239 滑动窗口最大值
核心题意:维护长度为
k的滑动窗口,并返回每个窗口中的最大值。
解法思路讲解:
- 用双端队列维护一个单调递减队列,队列中存下标,因此队首
q.front()对应的始终是当前窗口最大值。 - 新元素
nums[right]进入时,把队尾所有<= nums[right]的元素删除:因为新元素不仅更大,而且出现得更晚,这些旧元素以后永远不可能成为窗口最大值。 - 再删除已经滑出窗口的队首下标;每个下标最多入队一次、出队一次,因此把暴力的
优化到 。
class Solution {
public:
// 用单调递减队列维护当前窗口中可能成为最大值的元素下标
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
vector<int> ans;
deque<int> q;
for (int right = 0; right < nums.size(); right++) {
// 当前元素更大且更晚出现,队尾较小元素以后不可能成为最大值
while (!q.empty() && nums[q.back()] <= nums[right]) {
q.pop_back();
}
q.push_back(right);
int left = right - k + 1;
// 队首已经不属于当前窗口
if (q.front() < left) {
q.pop_front();
}
// 窗口形成后,队首就是当前窗口最大值
if (left >= 0) {
ans.push_back(nums[q.front()]);
}
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,每个元素最多进入队列一次、弹出队列一次。 - 空间复杂度:
,单调队列中最多保存当前窗口相关的下标。
0076 最小覆盖子串
题目链接:LeetCode 0076 最小覆盖子串
核心题意:在
s中找到包含t所有字符及其对应出现次数的最短连续子串。
解法思路讲解:
- 用两个计数数组
cnt_s和cnt_t分别统计当前滑动窗口与字符串t中各字符的出现次数;通过is_covered判断窗口中每种字符的数量是否都不少于t中的需求。 - 枚举右端点
right,把s[right]加入窗口;一旦当前窗口已经覆盖t,说明可以尝试不断右移左端点left,缩小窗口。 - 在窗口仍然满足覆盖条件时持续更新最短答案,并将
s[left]移出窗口;直到窗口不再覆盖t,再继续扩展右端点。 - 滑动窗口的核心是:右指针负责找到一个可行窗口,左指针负责把可行窗口尽可能缩短,从而枚举出每个右端点对应的最短合法窗口。
class Solution {
bool is_covered(int cnt_s[], int cnt_t[]) {
for (int i = 'A'; i <= 'Z'; i++) {
if (cnt_s[i] < cnt_t[i]) {
return false;
}
}
for (int i = 'a'; i <= 'z'; i++) {
if (cnt_s[i] < cnt_t[i]) {
return false;
}
}
return true;
}
public:
string minWindow(string s, string t) {
int cnt_s[128]{}; // s 子串字母的出现次数
int cnt_t[128]{}; // t 中字母的出现次数
for (char c : t) {
cnt_t[c]++;
}
int m = s.size();
int ans_left = -1, ans_right = m;
int left = 0;
for (int right = 0; right < m; right++) { // 移动子串右端点
cnt_s[s[right]]++; // 右端点字母移入子串
while (is_covered(cnt_s, cnt_t)) { // 涵盖
if (right - left < ans_right - ans_left) { // 找到更短的子串
ans_left = left; // 记录此时的左右端点
ans_right = right;
}
cnt_s[s[left]]--; // 左端点字母移出子串
left++;
}
}
return ans_left < 0 ? "" : s.substr(ans_left, ans_right - ans_left + 1);
}
};
时间和空间复杂度分析:
- 时间复杂度:
,左右指针都只向右移动,每个字符最多被加入和移出窗口一次。 - 空间复杂度:
,哈希表记录字符的需求数量, 为字符集大小。
普通数组
0053 最大子数组和
题目链接:LeetCode 0053 最大子数组和
核心题意:找出数组中和最大的非空连续子数组,并返回这个最大和。
解法思路讲解:
- 子数组
[l,r]的和等于pre[r] - pre[l-1],所以枚举右端点时,只需要找到它左边的最小前缀和。 - 从左到右维护当前前缀和
pre_sum和历史最小前缀和min_pre_sum,当前能够得到的最大子数组和就是pre_sum - min_pre_sum。 - 注意要先更新答案,再更新最小前缀和,否则可能减去当前前缀和,相当于选择空子数组。
class Solution {
public:
// 枚举子数组右端点,用当前前缀和减去此前的最小前缀和
int maxSubArray(vector<int>& nums) {
int ans = INT_MIN;
int pre_sum = 0;
int min_pre_sum = 0; // 当前右端点之前的最小前缀和
for (int x : nums) {
pre_sum += x;
ans = max(ans, pre_sum - min_pre_sum);
// 必须在计算答案之后更新,保证子数组非空
min_pre_sum = min(min_pre_sum, pre_sum);
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,只需要从左到右遍历数组一次。 - 空间复杂度:
,只维护前缀和、最小前缀和与答案。
0056 合并区间
题目链接:LeetCode 0056 合并区间
核心题意:将所有存在重叠的区间合并,返回最终互不重叠的区间集合。
解法思路讲解:
- 先按照区间左端点从小到大排序,这样遍历到新区间时,只需要判断它能否和答案中的最后一个区间合并。
- 如果当前左端点
p[0] <= ans.back()[1],说明有重叠,把最后一个区间的右端点扩大到两者最大值;否则直接加入答案。 - 按左端点排序的关键是保证区间从左向右出现;如果按右端点排序,则需要反向遍历才能保持同样的性质。
class Solution {
public:
// 按左端点排序后,只需和最后一个已合并区间比较
vector<vector<int>> merge(vector<vector<int>>& intervals) {
ranges::sort(intervals);
vector<vector<int>> ans;
for (auto& p : intervals) {
if (!ans.empty() && p[0] <= ans.back()[1]) {
// 有重叠,只需要扩大右端点
ans.back()[1] = max(ans.back()[1], p[1]);
} else {
ans.emplace_back(p);
}
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,主要耗时在区间排序,之后只遍历一次。 - 空间复杂度:
,主要来自排序的递归栈;不计算返回数组。
0189 轮转数组
题目链接:LeetCode 0189 轮转数组
核心题意:将数组中的元素整体向右轮转
k个位置,并要求原地修改数组。
解法思路讲解:
- 将结果看成“最后
k个元素 + 前n-k个元素”,目标就是把后半段移动到前面。 - 先整体反转数组,后
k个元素就来到前面,但两部分内部顺序都是反的;再分别反转前k个和后n-k个元素即可恢复。 k可能大于数组长度,所以先令k %= n,避免无意义的完整轮转。
class Solution {
public:
// 整体反转,再分别反转两部分,实现原地右轮转
void rotate(vector<int>& nums, int k) {
int n = nums.size();
k %= n;
reverse(nums.begin(), nums.end());
// 恢复原数组最后 k 个元素的内部顺序
reverse(nums.begin(), nums.begin() + k);
// 恢复其余元素的内部顺序
reverse(nums.begin() + k, nums.end());
}
};
时间和空间复杂度分析:
- 时间复杂度:
,三次反转总共仍然是线性时间。 - 空间复杂度:
,所有操作都直接在原数组上进行。
0238 除了自身以外数组的乘积
题目链接:LeetCode 0238 除了自身以外数组的乘积
核心题意:对于每个位置
i,计算数组中除nums[i]外所有元素的乘积,且不能使用除法。
解法思路讲解:
answer[i]可以拆成“i左边所有数的乘积 ×i右边所有数的乘积”,因此可以使用前后缀分解。- 第一遍从左向右,直接利用答案数组存储每个位置的左侧乘积;第二遍从右向左,用变量
suf维护右侧乘积。 - 这样不需要额外创建两个前后缀数组,在
时间内完成,额外空间只有一个变量。
class Solution {
public:
// 答案 = 左侧所有元素乘积 × 右侧所有元素乘积
vector<int> productExceptSelf(vector<int>& nums) {
int n = nums.size();
vector<int> ans(n, 1);
// ans[i] 先保存 nums[i] 左侧所有元素的乘积
for (int i = 1; i < n; i++) {
ans[i] = ans[i - 1] * nums[i - 1];
}
int suf = 1; // 当前下标右侧所有元素的乘积
for (int i = n - 1; i >= 0; i--) {
ans[i] *= suf;
suf *= nums[i];
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,分别进行一次正向遍历和一次反向遍历。 - 空间复杂度:
,不计算必须返回的答案数组,只额外使用 suf等常数变量。
0041 缺失的第一个正数
核心题意:在未排序数组中找出没有出现的最小正整数,并要求
时间、 额外空间。
解法思路讲解:
- 长度为
n的数组中,答案一定在[1,n+1],因此只关心值在[1,n]内的元素。 - 把数组想成座位:数字
x应该坐到下标x-1的位置;遍历每个位置,不断交换,直到当前数字越界或者它已经坐到了正确的位置。 - 换座位结束后再次遍历,第一个满足
nums[i] != i+1的位置对应的i+1就是答案;如果全部正确,则答案为n+1。
class Solution {
public:
// 把值 x 原地放到下标 x-1,使数组本身充当哈希表
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for (int i = 0; i < n; i++) {
// 当前数字合法且目标座位还没坐着同一个数字,就继续换座位
while (1 <= nums[i] && nums[i] <= n &&
nums[i] != nums[nums[i] - 1]) {
swap(nums[i], nums[nums[i] - 1]);
}
}
// 第一个没有坐对人的座位,就是缺失的最小正整数
for (int i = 0; i < n; i++) {
if (nums[i] != i + 1) {
return i + 1;
}
}
return n + 1;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,虽然有 while,但每次交换都会让至少一个合法数字回到正确位置,因此总交换次数是。 - 空间复杂度:
,直接在原数组中完成“换座位”,没有使用额外哈希表。
矩阵
0073 矩阵置零
题目链接:LeetCode 0073 矩阵置零
核心题意:如果矩阵中某个元素为
0,就把它所在的整行和整列全部置为0,并要求原地修改。
解法思路讲解:
- 关键是利用矩阵的第一行和第一列充当标记数组:
matrix[i][0] = 0表示第i行要清零,matrix[0][j] = 0表示第j列要清零。 - 由于第一行、第一列本身也可能原来就有
0,需要提前用两个变量记录,避免后续作为标记时丢失原始信息。 - 先扫描内部元素设置标记,再根据标记清零内部元素,最后单独处理第一行和第一列,从而把额外空间优化到
。
class Solution {
public:
// 用第一行和第一列记录哪些行、列最终需要置零
void setZeroes(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
bool row0 = false;
bool col0 = false;
// 第一行和第一列后面会被当作标记,因此先保存它们原本是否含 0
for (int j = 0; j < n; j++) {
if (matrix[0][j] == 0) {
row0 = true;
}
}
for (int i = 0; i < m; i++) {
if (matrix[i][0] == 0) {
col0 = true;
}
}
// 用第一列标记行,用第一行标记列
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (matrix[i][j] == 0) {
matrix[i][0] = 0;
matrix[0][j] = 0;
}
}
}
// 根据标记处理内部元素
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (matrix[i][0] == 0 || matrix[0][j] == 0) {
matrix[i][j] = 0;
}
}
}
// 最后处理第一行和第一列,避免提前修改标记
if (row0) {
for (int j = 0; j < n; j++) {
matrix[0][j] = 0;
}
}
if (col0) {
for (int i = 0; i < m; i++) {
matrix[i][0] = 0;
}
}
}
};
时间和空间复杂度分析:
- 时间复杂度:
,只对矩阵进行常数次遍历。 - 空间复杂度:
,直接复用第一行和第一列作为标记,只使用常数个额外变量。
0054 螺旋矩阵
题目链接:LeetCode 0054 螺旋矩阵
核心题意:按照右、下、左、上的顺时针螺旋顺序,返回矩阵中的所有元素。
解法思路讲解:
- 螺旋遍历的方向始终按照右 → 下 → 左 → 上循环,可以用方向数组统一表示。
- 第一次向右走
n步,接着向下走m-1步,再向左走n-1步……所以每走完一个方向后执行n, m = m-1, n,就能直接得到下一次需要走的步数。 - 提前保存矩阵元素总数,当答案长度达到总数时结束,无需
visited数组,也无需复杂的边界分类讨论。
class Solution {
public:
// 按右、下、左、上的方向循环,并不断缩短每次需要走的步数
vector<int> spiralOrder(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
int size = m * n;
const int DIRS[4][2] = {
{0, 1}, // 右
{1, 0}, // 下
{0, -1}, // 左
{-1, 0} // 上
};
vector<int> ans;
int i = 0, j = -1; // 从第一行左侧出发,使第一次移动直接进入 (0,0)
for (int d = 0; ans.size() < size; d = (d + 1) % 4) {
for (int k = 0; k < n; k++) {
i += DIRS[d][0];
j += DIRS[d][1];
ans.push_back(matrix[i][j]);
}
// 下一方向的步数依次为 n, m-1, n-1, m-2...
int tmp = n;
n = m - 1;
m = tmp;
}
return ans;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,矩阵中的每个元素恰好加入答案一次。 - 空间复杂度:
,不计算返回答案数组,只维护位置、方向和步数等常数变量。
0048 旋转图像
题目链接:LeetCode 0048 旋转图像
核心题意:将一个
矩阵原地顺时针旋转 。
解法思路讲解:
- 顺时针旋转后,元素坐标会从
(i,j)变成(j,n-1-i),可以把这个变化拆成转置 + 每行翻转两步。 - 第一步沿主对角线转置:交换
matrix[i][j]和matrix[j][i];只遍历对角线一侧,避免同一对元素交换两次。 - 第二步把每一行左右翻转,就得到最终顺时针旋转
的矩阵,而且两步都可以原地完成。
class Solution {
public:
// 先沿主对角线转置,再将每一行左右翻转
void rotate(vector<vector<int>>& matrix) {
int n = matrix.size();
// 只遍历上三角,避免交换两次又恢复原状
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
swap(matrix[i][j], matrix[j][i]);
}
}
// 转置后的每一行再左右翻转
for (auto& row : matrix) {
reverse(row.begin(), row.end());
}
}
};
时间和空间复杂度分析:
- 时间复杂度:
,转置和翻转都只需要遍历矩阵中的元素。 - 空间复杂度:
,所有交换都直接在原矩阵上完成。
0240 搜索二维矩阵 II
核心题意:在每行、每列都递增的矩阵中,高效判断是否存在目标值
target。
解法思路讲解:
- 从右上角开始最关键:当前位置左边的数都更小,下边的数都更大,因此比较一次就能确定排除一整行或一整列。([CSDN Blog][8])
- 如果
matrix[i][j] > target,这一列当前及下方元素都更大,直接j--排除这一列;如果< target,这一行剩余元素都更小,直接i++排除这一行。 - 每轮至少排除一行或一列,因此最多移动
m+n-1次,比逐行搜索更直接。
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 (matrix[i][j] == target) {
return true;
}
if (matrix[i][j] > target) {
// 当前列往下只会更大,因此整列可以排除
j--;
} else {
// 当前行往左只会更小,因此整行可以排除
i++;
}
}
return false;
}
};
时间和空间复杂度分析:
- 时间复杂度:
,每次循环至少排除一行或一列。 - 空间复杂度:
,只使用两个下标变量维护当前位置。
链表
- 0160 相交链表
- 0206 反转链表
- 0234 回文链表
- 0141 环形链表
- 0142 环形链表 II
- 0021 合并两个有序链表
- 0002 两数相加
- 0019 删除链表的倒数第 N 个结点
- 0024 两两交换链表中的节点
- 0025 K 个一组翻转链表
- 0138 随机链表的复制
- 0148 排序链表
- 0023 合并 K 个升序链表
- 0146 LRU 缓存
二叉树
- 0094 二叉树的中序遍历
- 0104 二叉树的最大深度
- 0226 翻转二叉树
- 0101 对称二叉树
- 0543 二叉树的直径
- 0102 二叉树的层序遍历
- 0108 将有序数组转换为二叉搜索树
- 0098 验证二叉搜索树
- 0230 二叉搜索树中第 K 小的元素
- 0199 二叉树的右视图
- 0114 二叉树展开为链表
- 0105 从前序与中序遍历序列构造二叉树
- 0437 路径总和 III
- 0236 二叉树的最近公共祖先
- 0124 二叉树中的最大路径和
图论
回溯
二分查找
- 0035 搜索插入位置
- 0074 搜索二维矩阵
- 0034 在排序数组中查找元素的第一个和最后一个位置
- 0033 搜索旋转排序数组
- 0153 寻找旋转排序数组中的最小值
- 0004 寻找两个正序数组的中位数
栈
堆
贪心算法
动态规划
- 0070 爬楼梯
- 0118 杨辉三角
- 0198 打家劫舍
- 0279 完全平方数
- 0322 零钱兑换
- 0139 单词拆分
- 0300 最长递增子序列
- 0152 乘积最大子数组
- 0416 分割等和子集
- 0032 最长有效括号