LeetCode进阶算法题解详解
本文深入讲解回溯算法、栈、哈希表和树的经典题目,涵盖全排列、有效括号、计算器、单调栈、最近公共祖先等高频面试题,适合有一定算法基础的同学进阶学习。
目录
1. 回溯算法
1.1 全排列 II(Permutations II)
题目描述:给定一个可能包含重复数字的序列,返回所有不重复的全排列。
核心思想 :
回溯算法 = 深度优先搜索 + 剪枝。通过标记已使用元素和去重逻辑,生成所有不重复的排列。
解题思路:
-
排序:先对数组排序,方便去重
-
used数组:标记哪些元素已经被使用
-
去重关键:
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
- 当前元素与前一个相同
- 前一个元素未被使用(说明在同一层已经处理过)
- 跳过这个元素,避免重复排列
-
回溯三步骤:
- 做选择:
used[i] = true; path.push_back(nums[i]);
- 递归:
backtracking(...)
- 撤销选择:
path.pop_back(); used[i] = false;
- 做选择:
代码实现:
cpp
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, vector<bool>& used) {
// 终止条件:路径长度等于数组长度
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
// 遍历所有可能的选择
for (int i = 0; i < nums.size(); i++) {
// 剪枝1:元素已被使用
if (used[i]) continue;
// 剪枝2:去重逻辑(核心)
// 同一层中,相同元素只使用一次
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1])
continue;
// 做选择
used[i] = true;
path.push_back(nums[i]);
// 递归
backtracking(nums, used);
// 撤销选择(回溯)
path.pop_back();
used[i] = false;
}
}
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(), false);
// 排序是去重的前提
sort(nums.begin(), nums.end());
backtracking(nums, used);
return result;
}
};
去重原理图解:
输入: [1, 1, 2]
排序后: [1, 1, 2]
第一层:
选1(索引0) -> used[0]=true
第二层:
选1(索引1) -> used[1]=true
第三层:选2 -> [1,1,2] ✓
选2(索引2) -> [1,2,1] ✓
选1(索引1) -> 剪枝!
因为 nums[1]==nums[0] 且 !used[0]
说明同一层已经用过值为1的元素
选2(索引2) -> used[2]=true
第二层:
选1(索引0) -> [2,1,1] ✓
复杂度分析:
- 时间复杂度:O(n × n!),共n!个排列,每个排列需要O(n)时间构造
- 空间复杂度:O(n),递归栈深度为n
关键点总结:
- 排序是去重的基础
used[i-1] == false
表示同层去重- 回溯模板:选择 → 递归 → 撤销
2. 栈的应用
2.1 有效的括号(Valid Parentheses)
题目描述:判断字符串中的括号是否有效配对。
解题思路:
- 使用栈存储左括号
- 遇到右括号时,检查栈顶是否匹配
- 最后栈必须为空
代码实现:
cpp
class Solution {
public:
bool isValid(string s) {
stack<char> st;
for (char ch : s) {
// 左括号入栈
if (ch == '(' || ch == '[' || ch == '{') {
st.push(ch);
}
// 右括号匹配
else if (ch == ')' || ch == ']' || ch == '}') {
// 栈空说明没有对应的左括号
if (st.empty()) {
return false;
}
char c = st.top();
// 检查是否匹配
if ((ch == ')' && c != '(') ||
(ch == ']' && c != '[') ||
(ch == '}' && c != '{')) {
return false;
}
st.pop();
}
// 非括号字符,返回false
else {
return false;
}
}
// 栈必须为空
return st.empty();
}
};
优化版本(更简洁):
cpp
bool isValid(string s) {
stack<char> st;
unordered_map<char, char> pairs = {
{')', '('}, {']', '['}, {'}', '{'}
};
for (char ch : s) {
if (pairs.count(ch)) {
// 右括号
if (st.empty() || st.top() != pairs[ch]) {
return false;
}
st.pop();
} else {
// 左括号
st.push(ch);
}
}
return st.empty();
}
时间复杂度 :O(n)
空间复杂度:O(n)
2.2 基本计算器 II(Basic Calculator II)
题目描述:实现一个基本的计算器,支持加减乘除。
解题思路:
- 使用栈保存中间结果
- 维护一个
preSign
记录前一个运算符 - 遇到新的运算符或到达末尾时,根据
preSign
处理当前数字:+
:直接入栈-
:负数入栈*
:与栈顶相乘/
:与栈顶相除
- 最后求栈中所有数字之和
代码实现:
cpp
class Solution {
public:
int calculate(string s) {
vector<int> stk;
char preSign = '+'; // 初始化为'+'
int num = 0;
int n = s.length();
for (int i = 0; i < n; ++i) {
// 构建多位数字
if (isdigit(s[i])) {
num = num * 10 + (s[i] - '0');
}
// 遇到运算符或到达末尾,处理前面的数字
if ((!isdigit(s[i]) && s[i] != ' ') || i == n - 1) {
switch (preSign) {
case '+':
stk.push_back(num);
break;
case '-':
stk.push_back(-num);
break;
case '*':
stk.back() *= num;
break;
case '/':
stk.back() /= num;
break;
}
preSign = s[i]; // 更新运算符
num = 0; // 重置数字
}
}
// 累加栈中所有元素
return accumulate(stk.begin(), stk.end(), 0);
}
};
执行过程示例:
输入: "3+2*2"
i=0: '3' -> num=3
i=1: '+' -> preSign='+', stk=[3], preSign='+', num=0
i=2: '2' -> num=2
i=3: '*' -> preSign='+', stk=[3,2], preSign='*', num=0
i=4: '2' -> num=2, 末尾处理 -> preSign='*', stk=[3,4]
结果: 3+4=7
时间复杂度 :O(n)
空间复杂度:O(n)
关键点:
- 乘除法立即计算(修改栈顶)
- 加减法延迟计算(入栈)
- 处理多位数字和空格
2.3 每日温度(Daily Temperatures)
题目描述:给定温度列表,返回每天需要等待多少天才会有更高温度。
解题思路:
- 使用单调栈(维护递减序列)
- 栈中存储索引
- 当前温度大于栈顶温度时,说明找到了更高温度
- 计算天数差并出栈
代码实现:
cpp
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
stack<int> st; // 单调栈,存储索引
vector<int> result(temperatures.size(), 0);
for (int i = 0; i < temperatures.size(); i++) {
// 当前温度大于栈顶索引对应的温度
while (!st.empty() && temperatures[i] > temperatures[st.top()]) {
int idx = st.top();
result[idx] = i - idx; // 计算天数差
st.pop();
}
st.push(i); // 当前索引入栈
}
return result;
}
};
执行过程示例:
输入: [73, 74, 75, 71, 69, 72, 76, 73]
i=0: 73 -> st=[0]
i=1: 74>73 -> result[0]=1, st=[1]
i=2: 75>74 -> result[1]=1, st=[2]
i=3: 71<75 -> st=[2,3]
i=4: 69<71 -> st=[2,3,4]
i=5: 72>69>71 -> result[4]=1, result[3]=2, st=[2,5]
i=6: 76>72>75 -> result[5]=1, result[2]=4, st=[6]
i=7: 73<76 -> st=[6,7]
结果: [1, 1, 4, 2, 1, 1, 0, 0]
时间复杂度 :O(n),每个元素最多入栈出栈一次
空间复杂度:O(n)
单调栈应用场景:
- 下一个更大/更小元素
- 柱状图最大矩形
- 接雨水问题
3. 哈希表技巧
3.1 砖墙(Brick Wall)
题目描述:在砖墙中画一条垂直线,使穿过的砖块数量最少。
解题思路:
- 最少穿过的砖块 = 总行数 - 最多经过的缝隙
- 使用哈希表统计每个位置的缝隙数
- 找出缝隙最多的位置
关键点:
- 不统计最右边的缝隙(边界)
- 使用前缀和定位缝隙位置
代码实现:
cpp
class Solution {
public:
int leastBricks(vector<vector<int>>& wall) {
unordered_map<int, int> cnt; // 位置 -> 缝隙数
// 遍历每一行
for (auto& widths : wall) {
int n = widths.size();
int sum = 0; // 当前位置(前缀和)
// 统计每个缝隙位置(不包括最后)
for (int i = 0; i < n - 1; i++) {
sum += widths[i];
cnt[sum]++;
}
}
// 找出缝隙最多的位置
int maxCnt = 0;
for (auto& [pos, c] : cnt) {
maxCnt = max(maxCnt, c);
}
// 总行数 - 最多缝隙数 = 最少穿过砖块数
return wall.size() - maxCnt;
}
};
图解示例:
输入:
[[1,2,2,1],
[3,1,2],
[1,3,2],
[2,4],
[3,1,2],
[1,3,1,1]]
缝隙位置统计:
位置1: 2行有缝隙 (行0, 行2)
位置3: 3行有缝隙 (行1, 行4, 行5)
位置4: 4行有缝隙 (行0, 行2, 行3, 行5) <- 最多
位置5: 2行有缝隙 (行1, 行4)
结果: 6 - 4 = 2
时间复杂度 :O(n × m),n为行数,m为平均砖块数
空间复杂度:O(k),k为不同缝隙位置数
优化思路:
- 使用哈希表统计频率
- 前缀和定位缝隙
- 转化为求最大值问题
4. 树的算法
4.1 二叉树的最近公共祖先(Lowest Common Ancestor)
题目描述:找到二叉树中两个节点的最近公共祖先。
核心思想 :
后序遍历(左右根),从下往上返回信息。
解题思路:
-
终止条件:
- 遇到空节点:返回null
- 遇到p或q:返回当前节点
-
递归过程:
- 在左子树中查找
- 在右子树中查找
-
返回逻辑:
- 左右子树都找到:当前节点是LCA
- 只有一边找到:返回那一边的结果
- 都没找到:返回null
代码实现:
cpp
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
// 终止条件:空节点或找到目标节点
if (!root || root == p || root == q) {
return root;
}
// 在左右子树中递归查找
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);
// 情况1: 左右子树都找到了,说明p和q分别在两侧
if (left && right) {
return root;
}
// 情况2: 只有一边找到,返回非空的那一边
// 可能是LCA在某一侧,也可能是p/q之一是另一个的祖先
return left ? left : right;
}
};
图解示例:
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
查找 p=5, q=1 的LCA:
后序遍历过程:
1. 访问左子树(5): 找到p,返回5
2. 访问右子树(1): 找到q,返回1
3. 根节点(3): left=5, right=1, 都非空 -> 返回3
查找 p=5, q=4 的LCA:
后序遍历过程:
1. 访问节点5的左子树(6): 返回null
2. 访问节点5的右子树(2):
- 左子树(7): 返回null
- 右子树(4): 找到q,返回4
- 节点2: left=null, right=4 -> 返回4
3. 节点5: left=null, right=4 -> 返回4
4. 根节点3: left=4, right=null -> 返回4
等等,上面有误,让我重新分析...
正确过程:
1. lowestCommonAncestor(5, 5, 4):
- root==p,直接返回5
2. lowestCommonAncestor(3, 5, 4):
- left = lowestCommonAncestor(5, 5, 4) = 5
- right = lowestCommonAncestor(1, 5, 4) = null
- 返回 left = 5
算法正确性证明:
情况1:p和q在root的两侧
- 左子树返回p(或p的祖先)
- 右子树返回q(或q的祖先)
- root是LCA ✓
情况2:p和q都在root的左侧
- 左子树返回LCA
- 右子树返回null
- 返回左子树的结果 ✓
情况3:p是q的祖先(或反之)
- 先遇到的节点直接返回
- 这个节点就是LCA ✓
时间复杂度 :O(n),最坏情况遍历所有节点
空间复杂度:O(h),h为树高,递归栈空间
关键点总结:
- 后序遍历,从下往上返回信息
- 遇到目标节点立即返回
- 根据左右子树返回值判断LCA位置
总结
本文涵盖了四大类进阶算法:
回溯算法
- 核心:选择 → 递归 → 撤销
- 去重:排序 + used数组
- 应用:全排列、组合、子集
栈的应用
- 括号匹配:栈的经典应用
- 计算器:运算符优先级处理
- 单调栈:寻找下一个更大/更小元素
哈希表技巧
- 频率统计:快速查找和计数
- 前缀和:累加定位
- 空间换时间:O(1)查找
树的算法
- 后序遍历:从下往上返回信息
- 递归三要素:终止条件、递归逻辑、返回值
- LCA:利用递归返回值判断
学习建议
- 理解本质:不要死记硬背,理解算法原理
- 画图分析:复杂问题用图示推演过程
- 变式练习:掌握一题多解,举一反三
- 复杂度分析:养成分析时空复杂度的习惯
- 代码规范:注意边界条件和代码可读性
相关题目推荐
回溯:
- 全排列 I/II
- 组合总和 I/II/III
- 子集 I/II
- N皇后
栈:
- 最小栈
- 柱状图最大矩形
- 接雨水
- 逆波兰表达式
哈希表:
- 两数之和
- 字母异位词分组
- 最长连续序列
树:
- 二叉树的序列化与反序列化
- 路径总和 I/II/III
- 验证二叉搜索树
持续练习,定期复习,祝大家刷题顺利!🚀