leetcode 写题的笔记
编码问题记录
- 你的代码问题在于:每次拼接字符串时都创建了新对象:
1 | new_str = new_str + c; // ← 每次都创建新字符串,效率极低! |
这会导致大量的内存分配和拷贝操作。应该使用 push_back() 或 += 操作符:
- 简化 pair 构造语法
1 | // ❌ 冗长写法 |
LeetCode笔记
1. 队列与栈
1.1 (单调队列) LeetCode.239. 滑动窗口最大值
题目介绍
1 | 题目描述 |
示例:
1 | 示例 1: |
1 | 示例 2: |
提示:
- $1 \le \text{nums.length} \le 10^5$
- $-10^4 \le \text{nums}[i] \le 10^4$
- $1 \le k \le \text{nums.length}$
📝 单调队列 (Monotonic Queue)
核心定义:持续维护队列里面的元素单调递增或单调递减的队列。
💡 如何维护单调递减队列?
- 维护原则:新元素入队前,清除队尾所有比它小的元素。
- 根本目的:确保队列头部始终是当前窗口的最大值。
- 底层逻辑:若新元素
x大于队尾元素y,则y在x离开窗口前永无出头之日(既小又老),故可直接删除y。
📊 演示范例
输入数组:vec = {1, 3, 1, -1, 2}
(注:在此维护下,队列始终保持递减状态)
| 操作 | 动作描述 | 队列状态 (队首 -> 队尾) | 当前最大值 |
|---|---|---|---|
push(1) |
队空,直接入队 | [1] |
1 |
push(3) |
3 > 1,弹出 1,入队 3 |
[3] |
3 |
push(1) |
1 < 3,直接入队 |
[3, 1] |
3 |
push(-1) |
-1 < 1,直接入队 |
[3, 1, -1] |
3 |
push(2) |
2 > -1 弹出 -1,2 > 1 弹出 1,2 < 3 入队 2 |
[3, 2] |
3 |
- 注意:以上演示暂未考虑滑动窗口大小。如果滑动窗口划走的元素恰好是队列的头元素,则需要把头元素移除。
- 核心洞察:我们本来就不需要维护当前位置之前、且比当前元素更小的元素,因为在当前元素被替换掉之前,那个更小的元素就已经被“干掉”了。
💻 核心代码实现
1. 单调队列类封装
1 | class monotonic_queue { |
2. 滑动窗口构建逻辑 (Main)
1 | int main() { |
1.2 (优先级队列(最大最小堆)) LeetCode.347 前 K 个高频元素
题目介绍
| 类别 | 难度 | 喜欢 | 不喜欢 |
|---|---|---|---|
| 算法 (Algorithms) | 中等 (Medium) 65.72% | 2184 | - |
标签: 哈希表 数组 分治 桶排序 计数 快速选择 排序 堆(优先队列)
题目描述
给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
示例:
示例 1:
1 | 输入: nums = [1,1,1,2,2,3], k = 2 |
示例 2:
1 | 输入: nums = [1], k = 1 |
提示:
- $1 \le \text{nums.length} \le 10^5$
- $-10^4 \le \text{nums}[i] \le 10^4$
- $k$ 的取值范围是 $[1, \text{数组中不相同的元素的个数}]$
- 题目数据保证答案唯一,换句话说,数组中前 $k$ 个高频元素的集合是唯一的
进阶:
你所设计算法的时间复杂度 必须 优于 $O(n \log n)$ ,其中 $n$ 是数组大小。
📝 优先级队列 (Priority Queue)
核心定义:基于堆 (Heap) 数据结构实现的队列。元素出队顺序不由入队顺序决定,而是由元素的优先级(通常指大小)决定。
💡 如何维护最大堆(默认优先队列)?
- 核心特性:
- 非先进先出:元素入队顺序不影响出队顺序。
- 自动排序:每次插入或删除元素后,内部会自动调整结构,确保堆顶元素始终是优先级最高(最大或最小)的元素。
- 底层实现:默认使用
std::vector作为底层容器,通过算法维护堆性质。
- 维护原则:新元素入队后,通过“上浮”操作调整位置,确保父节点始终大于等于子节点。
- 底层逻辑:堆是一种完全二叉树结构。对于最大堆,任何节点的值都大于或等于其子节点的值。因此,根节点(
top())必然是全局最大值。插入时只需在末尾添加并向上调整,删除时只需将末尾元素移至根部并向下调整,效率极高。
⚙️ 核心参数与复杂度
1. 模板定义
1 | template< |
T:存储元素的类型。Container:底层容器,默认为std::vector<T>。Compare:比较规则,默认为std::less<T>(即最大堆)。
2. 时间复杂度
- 插入 (
push): O(logN) - 删除堆顶 (
pop): O(logN) - 获取堆顶 (
top): O(1)
3. 常用成员函数
| 函数 | 描述 | 复杂度 |
|---|---|---|
push(const T& val) |
插入元素并调整堆 | |
pop() |
删除堆顶元素 | |
top() |
返回堆顶元素的引用(不删除) | |
empty() |
判断队列是否为空 | |
size() |
返回元素个数 |
📊 演示范例
输入数组:vec = {1, 3, 1, -1, 2}
(注:这里演示的是 priority_queue<int> 最大堆的入队逻辑)
| 操作 | 动作描述 | 堆内部结构 (逻辑上的完全二叉树) | 堆顶 (top) |
|---|---|---|---|
push(1) |
堆空,直接入队 | [1] |
1 |
push(3) |
3 > 1,3 上浮成为根,1 下沉 |
[3, 1] |
3 |
push(1) |
1 < 3,1 作为左子节点,无需上浮 |
[3, 1, 1] |
3 |
push(-1) |
-1 < 1,-1 作为右子节点,无需上浮 |
[3, 1, 1, -1] |
3 |
push(2) |
2 > -1,2 上浮替换 -1;2 > 1? 否,停止 |
[3, 2, 1, -1, 1] |
3 |
pop() |
移除堆顶 3,将末尾 1 移至根,向下调整 | [2, 1, 1, -1] |
2 |
- 注意:
priority_queue底层通常由vector实现,它不保证非堆顶元素的有序性,只保证堆顶是极值。
🛠️ 进阶:底层容器 Container 的选择(参数2)
std::priority_queue 的第二个模板参数 Container 指定了底层用于存储数据的容器。它必须支持随机访问迭代器以及 back(), push_back(), pop_back() 操作。
- 场景 A:追求极致性能(保持默认 vector)
在 99% 的情况下,不要修改这个参数。std::vector的连续内存布局使得 CPU 缓存命中率极高,对于堆这种频繁访问父子节点的数据结构,vector的性能通常优于deque。 - 场景 B:避免大规模扩容时的停顿(使用 deque)
如果数据量极大且不可预测,std::vector在容量不足时会重新分配内存并复制所有元素,这可能导致短暂的延迟抖动。std::deque按需分配小块内存,不会发生整体复制。
💻 核心代码实战
1. 基础最大堆与最小堆
1 |
|
2. 经典应用:前 K 个高频元素(也就是本题的问题)
1 | // 场景3:解决 "前 K 个高频元素" (结合 Hash Map) |
2. 二叉树
2.1:递归遍历(基础)
递归三步曲:
确定递归函数的参数和返回值
确定终止条件
确定单层递归的逻辑
LeetCode:144.前序遍历,145.后序遍历,94.中序遍历
以前序遍历为例:
参数:
没有必要一次性去确认,需要什么参数加什么,返回值1
void pre_order(cur,vec);
返回值:
我们的前序遍历是深度优先遍历,一定是遇到null节点的时候返回
所以终止条件是
1
2
3if(cur == nullptr){
return
}确定单层递归的逻辑:
1 | // 我们要实现中左右,而当前节点是中(要处理的目标),所以需要对中进行操作逻辑 |
2.2 二叉树的非递归遍历(迭代法)
非递归就俩类思路,要么就是循环,要么就是循环+栈
2.2.1 前序遍历(Leetcode 94)
此处只展示非递归
1 | vector<int> preorderTraversal(TreeNode *root) |
注意,这里是先处理中节点后,将右节点先入栈,左节点后入栈,因为栈是FILO结构,先左,则左后进
2.3.2 后序遍历(Leetcode 145)
已知前序遍历的结果是中左右,如果在上一题中调换right和left的入栈顺序,就可以得到 中右左的结果,倒置,则为左右中
1 | vector<int> postorderTraversal(TreeNode* root) { |
2.3.3 中序遍历(Leetcode 94)
有个很反常识的点是,中序遍历的代码要比前序和后序难得多,这是有个很难受的原因
我们可以发现,之前我们处理的方案,都是优先去访问中节点,拿到中节点的结果(无论中节点该在前还是在后),这就代表,最起码我们当前访问的节点的数据,当前必然能遇到
但是中序遍历她不一样,她第一个访问的必定是最左下角的元素,即使倒置,第一个访问的也是最右下角的元素,这个元素和我们的根节点是没有关系的
这就要求着我们必须要保证我们当前访问的元素,一定是剩余未访问节点的最左下角,才能得到中序遍历的答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22vector<int> inorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> s;
TreeNode* curr = root;
while(curr || !s.empty()){
while(curr)
{
// 所有左子树入栈
s.push(curr);
curr = curr->left;
}
// 处理栈中元素
curr = s.top();
s.pop();
res.push_back(curr->val);
// 转向右子树(如果当前节点右右子树,则处理中节点后,优先处理右子树的内容)
curr = curr-> right;
}
return res;
}
2.3.4 后序遍历如果不借助前序遍历倒置的话,该怎么做?(小拓展)
是啊,该怎么做?咳咳,开玩笑的,那就只能参考中序遍历的处理方式来思考了
既然我们的中节点被放在了最低优先级,那在我们处理当前节点时,必须确保所有的子节点都已经处理了。
在之前我们处理中序遍历的流程中,遍历到最左节点的时候,此时的元素,可能会有右节点(对于右节点来说,我们当前的节点是中节点,优先级更低)
所以需要添加检查右节点的逻辑
但是有个很大的问题是,这里会有一个逻辑闭环:
对于一个节点A,检查右节点B,如果没有,就优先处理右边
右边的节点B处理完后(即右边的左右孩子都已得到处理)再处理节点A
对于一个节点A,检查右节点B,如果没有,就优先处理右边
右边的节点B处理完后(即右边的左右孩子都已得到处理)再处理栈里拿出来的节点A
………….
所以我们必须得知道上一个处理的到底是哪个节点!又因为右子树满足左右中遍历顺序,所以右子树最后遍历的节点肯定是中节点。我们只需要记录上一个遍历的节点是哪个即可,如果上一个节点是自己的右子树,那就没必要再加入栈,优先处理右节点了
这时的逻辑就成了:
- 对于一个节点A,检查右节点B,如果没有,就优先处理右边
- 右边的节点B处理完后(即右边的左右孩子都已得到处理)再处理节点A
- 对于一个节点A,检查右节点B,B是上一个处理的节点,无需再处理,直接处理A
这样逻辑闭环就解开了,我们就可以按顺序遍历完所有的节点!
1 | vector<int> postorderTraversal(TreeNode* root) { |
2.3.5 层序遍历(Leetcode 102)
1 | vector<vector<int>> levelOrder(TreeNode* root) { |
时间复杂度 O(N) : N 为二叉树的节点数量,即 BFS 需循环 N 次。
空间复杂度 O(N) : 最差情况下,即当树为平衡二叉树时,最多有 N/2 个树节点同时在 queue 中,使用 O(N) 大小的额外空间。
2.4 一些典型的二叉树问题
2.4.1 翻转二叉树 (Leetcode 226)
题目详情
这道题可以理解为对所有节点进行左右孩子的交换
- 但是,遍历方式有讲究
- 中序遍历是不好做的
因为中序遍历我们在处理右孩子前,对中节点进行了左右节点互换,导致右孩子变成了左孩子,无法达到预期
解决方案:
1
2
3
4
5
6
7
8TreeNode* invertTree(TreeNode* root) {
if(root == nullptr) return nullptr;
invertTree(root->left);
swap(root->left, root->right);
invertTree(root->left);
return root;
}
2.4.2 对称二叉树 (Leetcode 101)
- 代码记录 其实俩种思路基本一个意思,解法2看起来会更明确,更像后序遍历
- 此题的重点是要想明白该用什么遍历解决问题,我们在判断当前节点是否是对称二叉树的时候,必须要知道自己的所有子树是否对称,需要知道左右子树的条件,所以这种情况需要用后序遍历
- 适用场景: 当前节点的处理依赖于左右子树的计算结果
- 类似的:
- 前序遍历: 当前节点的处理不依赖子节点,但 需要向所有子节点传递信息
- 中序遍历: 需要按特定顺序访问节点,通常用于BST或生成有序序列,常用于BST的结果排序(升序)
- 层序遍历: 需要按层依次处理节点,例如求树的最小深度
- 类似的:
1 | class Solution { |
2.4.3 二叉树的最大深度(Leetcode 104)
1 | int maxDepth(TreeNode* root) { |
- 也是一种后序处理思路
2.4.4 二叉树的最小深度 (LeetCode 111)
📌 核心思路
本题的关键在于遍历方式的选择对效率的影响:
- DFS (前/中/后序): 必须遍历完整棵树才能确定最小深度,无法有效剪枝
- BFS (层序遍历): 利用层级特性,遇到第一个叶子节点立即返回,实现天然剪枝
🔴 重点: BFS 在”浅层存在叶子节点”的场景下,性能远优于 DFS!
🔍 两种解法对比
方案1: DFS 后序遍历 (基础解法)
1 | cpp |
问题分析:
- ❌ 最坏情况: 完全二叉树需遍历所有节点 O(n)
- ❌ 无法提前终止: 即使左子树深度为1,仍会完整遍历右子树
- ❌ 递归开销: 每层函数调用都有栈帧分配
方案2: BFS 层序遍历 (优化解法) ✅
1 | cpp |
优势分析:
- ✅ 提前终止: 找到第一个叶子即返回,无需遍历整棵树
- ✅ 时间复杂度: O(k),k为最小深度对应的节点数(通常 k << n)
- ✅ 空间复杂度: O(w),w为树的最大宽度
📊 性能对比示例
假设树结构如下:
1 | 1 |
| 遍历方式 | 访问节点数 | 说明 |
|---|---|---|
| DFS 后序 | 6个 | 必须遍历完整棵树才能确定最小深度=2 |
| BFS 层序 | 3个 | 遇到节点3立即返回,减少50%计算量 ✅ |
💡 关键要点总结
💡 维护原则:
- 何时选择 BFS:
- 求最小深度、最短路径等”首次遇到即最优”的问题
- 树的宽度远小于高度时(BFS队列开销小)
- 需要按层级处理节点的场景
- 何时选择 DFS:
- 需要遍历所有节点或所有路径的问题
- 树的高度较浅时(递归栈开销小)
- 需要自底向上汇总信息的场景(如对称性判断)
- BFS 剪枝技巧:
- 利用
levelSize控制层级边界 - 在循环内部检查终止条件,找到即返回
- 避免使用额外变量记录全局最优值
- 利用
2.4.5 完全二叉树的节点数
在完全二叉树中:
如果 最左侧深度 == 最右侧深度 → 说明最后一层也是满的 → 整棵树是满二叉树
如果 最左侧深度 > 最右侧深度 → 说明最后一层不满 → 需要递归处理
为什么?
完全二叉树的定义保证了”最后一层节点靠左排列”
如果最右侧深度等于最左侧深度,说明最后一层已经填满
此时整棵树满足满二叉树的定义
1 | int isFullTree(TreeNode* node){ |
用位运算替代 $2^n$
节点数 = $2^h - 1 $
用位运算表示 $(1 << h) - 1$ 注意,这里的括号不可以省略,因为<<运算符的优先级没有 - 运算符高
要养成2的n次方使用位运算,而不是pow(2,n);