LeetCode进阶算法题解详解
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;
- 做选择:
代码实现:
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)
题目描述:判断字符串中的括号是否有效配对。
解题思路:
- 使用栈存储左括号
- 遇到右括号时,检查栈顶是否匹配
- 最后栈必须为空
代码实现:
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();} // 非括号字符,返回falseelse {return false;}}// 栈必须为空return st.empty();}
};
优化版本(更简洁):
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
处理当前数字:+
:直接入栈-
:负数入栈*
:与栈顶相乘/
:与栈顶相除
- 最后求栈中所有数字之和
代码实现:
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)
题目描述:给定温度列表,返回每天需要等待多少天才会有更高温度。
解题思路:
- 使用单调栈(维护递减序列)
- 栈中存储索引
- 当前温度大于栈顶温度时,说明找到了更高温度
- 计算天数差并出栈
代码实现:
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)
题目描述:在砖墙中画一条垂直线,使穿过的砖块数量最少。
解题思路:
- 最少穿过的砖块 = 总行数 - 最多经过的缝隙
- 使用哈希表统计每个位置的缝隙数
- 找出缝隙最多的位置
关键点:
- 不统计最右边的缝隙(边界)
- 使用前缀和定位缝隙位置
代码实现:
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
代码实现:
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
- 验证二叉搜索树
持续练习,定期复习,祝大家刷题顺利!🚀