算道浅行
引言
题还是得时常刷刷。时间一久,许多思路就淡了,手感也会生疏。
这次重新来一遍,参照的是《代码随想录》的顺序。 之前那次是按照题号一路刷下来,虽然也学到了不少,但是体系松散,难以融会贯通。有些题只是记住了解法,却不太能灵活应用。
于是想再刷一遍,理清思路,补全结构,也让手感回来一些。
数组
数组是一段在虚拟地址空间中连续存储、可通过索引直接访问的同类型元素集合。
int arr = ;
二分查找
写二分查找时常见两种写法,主要区别在于搜索区间的开闭方式:
一种是左闭右闭 [left, right],另一种是左闭右开 [left, right)。
不同的区间定义会影响在更新子区间时的处理方式,因此保持区间定义的一致性非常重要。 如果采用左闭右闭,就要始终维持这一不变式(invariant)。
对我来说,第一种写法更自然一些。
;
// time: O(log n)
// space: O(1)
也来看看循环结束的状态。以闭合区间的写法 [left, right], 在循环结束时如果target存在, left会最终指向这个target。如果target不存在,left会最终指向第一个大于target的元素(即插入点)。
Example: nums = [1, 3, 5, 6], target = 4
Final state after loop:
nums: [ 1, 3, 5, 6 ]
index: 0 1 2 3
↑ ↑
right left
right → last element < target (nums[1] = 3)
left → first element >= target (nums[2] = 5, insertion position)
换言之,当循环结束时:
right指向最后一个<target的元素left指向第一个≥target的元素left > right,left = right + 1left即插入位置
... [ right ] | [ left ] ...
< t ≥ t
;
// time: O(log n)
// space: O(1)
#34. Find First and Last Position of Element in Sorted Array
如果数组中的元素不唯一,我们最终会找到的是一个区间,而不是单一索引。 思路基本相同,只是在遇到相等元素时, 取决于我们如何更新搜索范围(向左还是向右继续查找), 从而决定我们找到的是左边界还是右边界。
;
// time: O(log n)
// space: O(1)
这次我们需要向下取整,也即是找下边界right。
... [ right ] | [ left ] ...
< t ≥ t
相等的情况(如果存在)经常需要单独处理,所以具体一点是这样的。
... [ right ] mid [ left ] ...
< t = > t
// for x < 2, sqrt(x) equals x itself
// for x >=2, search range is [1, x/2] (inclusive)
;
// time: O(log n)
// space: O(1)
这题与sqrt(x)基本相同。
;
// time: O(log n)
// space: O(1)
移除元素
这里用到了双指针。 双指针是一种常用的遍历与操作序列的技巧,通过同时维护两个下标或指针在数组或链表上移动,以更高效的解决问题。常见于去重、分区、反转、查找区间等场景。根据用途不同,又可分为快慢指针(一个遍历,一个构建或判断) 和左右指针(从两端向中间收缩)两种常见形式。
// i: next position for valid element
// j: next element to check
// time: O(n)
// space: O(1)
#26. Remove Duplicates from Sorted Array
这次用快慢指针来做本地去重,两个指针根据应用场景含义发生了变化,但是大致处理结构相同。
// i: last unique element
// j: next element to check
;
// time: O(n)
// space: O(1)
同样的技巧,可以观察到操作后元素的相对位置是保持不变的,将剩余元素置零即可。
// i: next position for valid element
// j: next element to check
;
// time: O(n)
// space: O(1)
#844. Backspace String Compare
逆向双指针结合跳过逻辑:
这里指针不是从前往后扫描,而是从后往前。这在处理删除、退格、合并排序结果、字符串等对齐场景非常常见。
同时代码中通过跳过逻辑(skip logic),用计数器来跳过被退格的字符。这种控制结构本质上是在双指针基础上加上了状态机逻辑,常见于带有“无效区段”或“需过滤元素”的问题。
代码层面我们也可以把它总结为双指针加嵌套跳过循环(two pointers with inner skip loop)。这种写法有几个特点:
- 外层
while (i >= 0 || j >= 0)同步推进两个指针。 - 内层
while用于跳过特定状态(这里是退格后的字符)。 - 退出内层循环后,比较两边的有效字符。
;
// time: O(m + n) where m = len(s), n = len(t)
// space: O(1)
算法层面没有显示判断i < 0 && j < 0返回true的情况,但逻辑上是隐式判断了。只要有一个指针还在范围内,就继续循环。 当两个指针都小于 0(即 i < 0 && j < 0)时,循环退出并返回true。
有序数组的平方
#977. Square of a Sorted Array
双指针从两端向中间靠拢:平方后负数会变大,因此平方后最大值一定在两端。
;
// time: O(n)
// space: O(n)
长度最小的字数组
#209. Minimum Size Subarray Sum
滑动窗口(sliding window)是数组与字符串题里常用的思想之一。滑动窗口维护一个动态区间[left, right],
不断地扩展右边界来满足条件,再收缩左边界来优化结果。
常见于在连续序列中,寻找满足某种条件的最短/最长/固定长度区间。 left和right单调向前移动,因此大多能做到O(n)的时间复杂度。
;
// time: O(n)
// space: O(1)
同样是滑动窗口的移动方式:
- 右指针
right一格一格前进 → 扩大窗口; - 左指针
left有时连续前进多步 → 缩小窗口以恢复合法状态; - 每次扩张后都要判断“当前窗口是否合法”;
- 不合法则继续收缩;
- 合法则计算/更新答案。
;
// time: O(n)
// space: O(1)
滑动窗口配合计数:
right扩张窗口加入字符,统计匹配数量matched;- 当
matched == need.size()说明窗口已包含全部字符; - 然后尝试收缩
left以最短化, 每次收缩时更新最短区间; - 若窗口不再满足条件,继续右扩。
;
// time: O(m + n), where m = len(s), n = len(t)
// space: O(m + n)
螺旋矩阵II
一道模拟过程的题目。模拟生成矩阵的过程时,可以很自然地分成四个方向依次遍历。 每一轮循环填充一层“外圈”。在开始每轮循环前,要先想清楚当前的边界是否仍然有效。
每一轮的填充顺序为:
- 从左到右,填充当前最上方一行;
- 从上到下,填充当前最右侧一列;
- 从右到左,填充当前最下方一行;
- 从下到上,填充当前最左侧一列。
当进入循环时,我们已经确保 left <= right 且 top <= bottom,
因此第 1、2 步总是在边界内可以安全执行。
但在执行完它们后,我们更新了边界:top++, right--。
此时再进入第 3、4 步前,就需要重新检查是否仍在有效范围内。
这一步实际上是为了避免在 n 为奇数时中心点被重复填充或越界。
对于二维矩阵,通常约定:
i表示行索引,从上到下增加;j表示列索引,从左到右增加。
;
// time: O(n^2)
// space: O(n^2)
与上一题基本相同,这次不是写而是读,也是要时常注意边界条件(是否会越界)。
;
区间和
前缀和(Prefix Sum or presum)是一种通过“累计”预处理来实现快速区间求和的技巧。
对于数组 $nums=[a_0, a_1, a_2, \ldots, a_{n-1}]$
定义前缀和数组 $p[i]$ 表示前i个数的总和。
即:
p[0] = 0p[1] = nums[0]p[2] = nums[0] + nums[1]p[3] = nums[0] + nums[1] + nums[2]p[i] = nums[0] + nums[1] + ... + nums[i-1]
于是任意闭合区间[l, r]的和就可以写为:sum(l, r) = p[r+1] - p[l]。
前缀和有两种常见的写法(是否在前面多加一个零)。上述是第一种写法,区间求和公式比较自然。
特别在l = 0时sum(0, r) = p[r+1] - p[0] = p[r+1]。
而如果是第二种写法我们不在前面多加一个零, 此时p[0] = nums[0]。求和时就需要对l = 0单独判断了: sum(l, r) = (l == 0 ? p[r] : p[r] - p[l-1])。
using namespace std;
int
// time: O(n)
// space: O(n)
#303. Range Sum Query - Immutable
同样也用前缀和数组。
;
/**
* Your NumArray object will be instantiated and called as such:
* NumArray* obj = new NumArray(nums);
* int param_1 = obj->sumRange(left,right);
*/
// time: O(n)
// space: O(n) = construct prefix sum array O(n) + range sum query O(1)
开发商购买土地
虽然没有直接构造二维前缀和数组,但本题依然体现了前缀和的思想。
通过提前计算每行、每列的总和,在两个方向上各自求一次前缀和,从而避免了重复累加,降低了复杂度。
using namespace std;
int
// time: O(m x n)
// space: O(m + n)
链表
链表是一种由节点顺次连接而成的线性结构,每个节点保存数据和指向下一个节点的指针,因此不需要连续内存。它的主要优点是插入、删除某个已知位置的节点可以在 O(1) 时间完成,结构灵活、内存利用弹性高。缺点是无法随机访问,访问任意位置都必须从头依次遍历,缓存局部性差、整体速度通常不如数组。
链表常用于需要频繁插入删除的场景,比如实现队列、LRU 缓存内部的双向链、或操作系统中的任务调度队列。
// node in a singly linked list
移除链表元素
#203. Remove Linked List Elements
在删除链表节点时,如果删除的是头节点,需要单独处理 head 的更新。
如果删除的是中间节点,只需让前驱节点 prev->next = cur->next。为了避免对头节点特判,我们通常在 head 前加一个 dummy 节点,把所有删除操作统一成“删除某节点的下一节点”,这是链表中常用的技巧。
/**
* 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) {}
* };
*/
;
// time: O(n)
// space: O(1)
LeetCode 环境下一般会省略 delete,真实工程需显式释放被删除的节点。
设计链表
插入或删除一个节点都必须先找到它的前驱节点。引入 dummy node 后,链表的头节点也拥有“前驱”,从而统一了所有位置的操作逻辑。size 则用于 O(1) 判断越界,使边界处理更简单可靠。以下是单链表的实现,省略了析构函数。
;
/**
* Your MyLinkedList object will be instantiated and called as such:
* MyLinkedList* obj = new MyLinkedList();
* int param_1 = obj->get(index);
* obj->addAtHead(val);
* obj->addAtTail(val);
* obj->addAtIndex(index,val);
* obj->deleteAtIndex(index);
*/
翻转链表
# Reverse Linked List (A → B → C → D → NULL)
----------------------------------------------------
NULL A → B → C → D → NULL
^ ^
prev cur
----------------------------------------------------
NULL ← A B → C → D → NULL
^ ^
prev cur
----------------------------------------------------
NULL ← A ← B C → D → NULL
^ ^
prev cur
----------------------------------------------------
NULL ← A ← B ← C D → NULL
^ ^
prev cur
----------------------------------------------------
NULL ← A ← B ← C ← D NULL
^ ^
prev curr
(new head) (stop)
反转链表时,我们用 prev 指向已经反转好的前一节点,用 cur 指向当前处理的节点。每一步先保存 cur 的下一个节点避免链表断开,然后让 cur->next 指向 prev 完成指针反转,接着把 prev 移到 cur,cur 再移动到原来的下一个节点。这样 prev 不断向右推进、链表指针不断从右向左翻转,直到 cur 变为 NULL,此时 prev 就是整个链表反转后的新头节点。
/**
* 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) {}
* };
*/
;
// time: O(n)
// space: O(1)
两两交换链表中的节点
prev → first → second → next
- 让
first指向second之后的节点:first → next - 把
second放到first前面:second → first - 把交换好的这一对接回链表:
prev → second
curr → second → first → next
然后把 prev 前进到 first,继续下一对处理。先操作第一步以防链表结构断掉。
由于交换操作需要访问每一对节点的前驱,因此同样要使用 dummy 节点,使头节点也拥有统一的前驱。prev 始终指向已完成交换部分的最后一个节点,用来连接下一对将要交换的节点。
/**
* 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) {}
* };
*/
;
// time: O(n)
// space: O(1)
删除链表的倒数第N个节点
#19. Remove Nth Node From End of List
采用 dummy 节点 + 快慢指针。让 fast 先走 n 步,形成间隔;然后让 fast 和 slow 一起走,当 fast 到达链表末尾时,slow 刚好停在目标节点的前一个位置。此时将 slow->next 指向下下个节点即可完成删除。使用 dummy 能统一处理删除头节点等情况,使逻辑更加简洁稳定。
dummy → 1 → 2 → 3 → 4 → 5, n = 2
^slow
^fast
1) fast moves n steps:
dummy → 1 → 2 → 3 → 4 → 5
^slow ^fast
2) fast and slow move together until fast hits the end:
dummy → 1 → 2 → 3 → 4 → 5
^slow ^fast
3) slow->next is the target, skip it:
dummy → 1 → 2 → 3 → 5
/**
* 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) {}
* };
*/
;
// time: O(n)
// space: O(1)
链表相交
#160. Intersection of Two Linked Lists
双指针交换走法, 两人走一样长的路,必在交点相遇。
A: a1 → a2 → a3 → c1 → c2
B: b1 → b2 → b3 → b4 → c1 → c2
让两个指针:
- A 走完后去 B
- B 走完后去 A
最终路径:
pA: A → C → B
pB: B → C → A
两者走过的总长度都是a + b + c 若有交点则必在 c1 同步到达。
若无交点, 则都会走到 nullptr。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
;
// time: O(m + n), where m = len(a), n = len(b)
// space: O(1)
环形链表II
Floyd判圈 (弗洛伊德判环算法或Floyd Cycle Detection Algorithm)
阶段一:用两个指针(slow、fast)检测链表是否有环:
slow每次走 1 步fast每次走 2 步- 如果链表中没有环:
fast会先走到nullptr,不会相遇 - 如果链表中有环:
fast必然会追上slow,最终两者会相遇
- 如果链表中没有环:
阶段二:寻找环入口
- 让一个指针从
head出发 - 另一指针从相遇点出发
- 两者都每次走 1 步
- 最终相遇的位置就是环的入口
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
;
// time: O(n)
// space: O(1)
从 slow 的角度,它始终以 1 步/次向前移动。无环时 slow 最多走完整个链表一次;有环时 slow 到达环入口后在环内最多再走一圈就会被 fast 追上,而环长也不超过链表长度。因此 slow 的总步数始终在链表节点数量级内,整体时间复杂度为 O(n)。
哈希表
哈希表利用哈希函数把 key 映射到数组的某个位置,只要分布足够均匀,插入、删除、查找通常都能做到平均 O(1)。
核心做法就是先把 key 哈希成一个下标,再用这个下标快速定位到对应的桶(bucket),如果发生冲突则通过链表或开放寻址处理。只要负载因子不过高、冲突不集中,整体性能依然接近常数级,因此哈希表也成为工程和算法中使用最广泛的基础结构之一,C++ 的 unordered_map / unordered_set 就是典型实现。
| 操作 | 平均复杂度 | 最坏复杂度 | 说明 |
|---|---|---|---|
| 插入 | O(1) | O(n) | rehash 或大量冲突时 |
| 查找 | O(1) | O(n) | 所有元素落在一个桶里 |
| 删除 | O(1) | O(n) | 同查找一样 |
| 扩容(rehash) | armotized O(1) | O(n) | 需要重分布全部 key |
// hash set
// stores unique elements. provides O(1) average-time lookup
unordered_set<int> set = ;
// hash map
// key value mapping implemented with a hash table
unordered_map<string, int> freq = ;
// perfect hash (array counting)
// when the key space is small and continuous (e.g. 'a' to 'z')
// an array can be used as a perfect hash table
// no collisions, fastest lookup
int cnt = ;
有效的字母异位词
判断两个字符串是否由相同字符频次组成。最优方法是用大小为 26 的计数数组做频率差值,通过一次遍历解决,时间 O(n)、空间 O(1)。若字符集未知,则用哈希表。排序是最简单但不是最优的通用解法。
;
// time: O(n)
// space: O(1)
;
// time: O(n x k) where k = average length of strs
// space: O(n x k)
采用固定格式的 key(如带分隔符的 26 项编码 #1#0#3#0#0#...)更稳定也更安全:结构固定、无歧义,不会因为省略 0 而让 key 长度不一致,避免 C++ 的隐式转换问题,方便调试和扩展到更多字符集,同时在哈希表中能得到更均匀的分布。
#438. Find All Anagrams in a String
;
// time: O(|s| + |t|) => O(|s|) since |t| <= |s|
// space: O(1)
算法对字符串 s 做一次滑动窗口遍历。 每一步只更新固定大小(26)的频次数组,检查相等也是常数时间。 因此总时间复杂度为 O(|s|)。
两个数组的交集
#349. Intersection of Two Arrays
// time: O(m + n) where m = len(nums1), n = len(nums2)
// space: O(m + n)
#350. Intersection of Two Arrays II
;
// time: O(m + n) where m = len(nums1), n = len(nums2)
// space: O(m + n)
快乐数
;
// time: O(log n) need some math
// space: O(log n)
两数之和
;
// time: O(n)
// space: O(n)
四数相加II
数组长度 n ≤ 200 时,四重循环是 n⁴ ≈ 1.6e9,必然超时。 常用优化是把四个数组拆成两组:先枚举 A+B 的所有和并计数,再在 C+D 中查找对应的相反数。这样把 O(n⁴) 降为 O(n²),n²=4×10⁴,轻松通过。这是典型的 k-sum 降维技巧。
;
// time: O(n^2)
// space: O(n^2)
赎金信
;
// time: O(m + n) where m = len(s1), n = len(s2)
// space: O(1)
三数之和
3Sum 的核心是「排序 + 去重 + 双指针」。排序确保数组有序,可以自然地跳过重复元素。固定第一个数后,在右侧通过双指针根据 sum 的大小移动指针,以线性时间找到所有二元组。哈希查补数在 3Sum 中也能使用,但相比双指针较难控制去重,并且无法利用有序性减少搜索空间,因此属于次优策略。
双指针做法:
// two pointers (optimal)
;
// time: O(n^2)
// space: O(1)
哈希表做法(不推荐):
// hash set (sub-optimal)
;
// time: O(n^2) but much slower in practice
// space: O(n^2)
四数之和
承接之前的三数之和「排序 + 去重 + 双指针」,加上了一重循环。此时哈希表已经不合适了。
;
// time: O(n^3)
// space: O(1)
字符串
string s = "hello";
反转字符串
;
// time: O(n)
// space: O(1)
反转字符串II
// time: O(n)
// space: O(1)
替换数字
std::string 底层是动态数组,当容量不够时会重新分配更大的缓冲区,并把已有内容全部拷贝过去。
如果不断追加而又没有提前预留空间,就会发生多次扩容和拷贝,从而导致最坏情况下的 O(n²) 行为。
using namespace std;
int
// time: O(n)
// space: O(n)
翻转字符串里的单词
#151. Reverse Words in a String
原地解法(O(1)空间):
- 原字符串: " the sky is blue "
- 去掉首尾空格: "the sky is blue"
- 整体反转: "eulb si yks eht"
- 对每个单词单独反转:"blue is sky the"
;
// time: O(n)
// space: O(1)
右旋字符串
三次反转法: 符串的旋转,本质都是把字符串分成两段,然后交换顺序。
适用于:
- 左旋 k(把前 k 个字符移到后面)
- 右旋 k(把后 k 个字符移到前面)
时间复杂度 O(n),空间 O(1)。
== 左旋 k ==
原始:
a b c d e f g
1. reverse whole
g f e d c b a
2. reverse the first n-k elements
g f e d c | b a
↓ ↓ ↓ ↓ ↓
c d e f g | b a
3. reverse the last k elements
c d e f g | b a
↓ ↓
c d e f g | a b
== 右旋 k ==
原始:
a b c d e f g
1. reverse whole
g f e d c b a
2. reverse the first k elements
g f | e d c b a
↓ ↓
f g | e d c b a
3. reverse the last n-k elements
f g | e d c b a
↓ ↓ ↓ ↓ ↓
f g | a b c d e
// rotate right
using namespace std;
int
// time: O(n)
// space: O(1)
实现strStr()
#28. Find the Index of the First Occurrence in a String
瞻仰一下 KMP, 建议看看相关视频讲解。(代码随想录 - KMP 理论)
KMP 是一种高效的子串匹配算法(substring search)。
普通暴力算法每次失败都会让文本串回退,导致时间复杂度 O(n x m)。
KMP 利用模式串自身的信息,使匹配只向前,做到线性时间 O(n + m)。
核心思想:失败时不回退文本串,而是利用 LPS 表回退模式串的位置,继续匹配。
前缀:从第一个字符开始,但不能包含最后一个字符的所有连续子串。
例:"ababa" 的前缀(prefix)有:
a
ab
aba
abab
后缀: 以最后一个字符结束,但不能包含第一个字符的所有连续子串。
例:"ababa" 的后缀(suffix)有:
baba
aba
ba
a
LPS: Longest Prefix Suffix 即"最长前后缀"是 KMP 的核心概念。
lps[i] 表示:
pattern[0..i] 这个子串中,最长的 “相等前缀 = 相等后缀” 的长度。
例: pattern = "aabaa"
| i | 子串 | 最长相等前后缀 | lps[i] |
|---|---|---|---|
| 0 | "a" | 无 | 0 |
| 1 | "aa" | "a" | 1 |
| 2 | "aab" | 无 | 0 |
| 3 | "aaba" | "a" | 1 |
| 4 | "aabaa" | "aa" | 2 |
lps = [0, 1, 0, 1, 2]
实现
构建 LPS(前缀表)和搜索阶段使用 LPS 的方式,是完全相同的逻辑。
它们遵循同一个 loop invariant(循环不变式):
j 始终表示当前已经匹配的“最长前后缀长度”也同时指向“下一次应该尝试匹配的位置”。
在 LPS 构建阶段: i 表示正在分析的模式串下标。
扫描 pattern 本身,用来计算 pattern[0..i] 的最长前后缀。
在搜索阶段:i 表示正在比较的文本串下标。
扫描 haystack,是 KMP 的主驱动。
无论在哪个阶段,i 永远只前进不后退。
所有回退都由 j 与 LPS 表完成。
;
// time: O(m + n) where m = len(haystack), n = len(needle)
// space: O(n)
#459. Repeated Substring Pattern
- 构建 LPS 数组,
lps[i]表示最长前后缀长度。 - 若
lps[n-1] == 0→ 一定不是重复串。 - 最小重复单元长度 =
n - lps[n-1]。 - 若
n % (n - lps[n-1]) == 0→ 是重复串。
若 l == 0, 说明没有任何非空前后缀相等所以不可能是重复构成。(n - l) 是最小重复单元长度,
如果 n % (n - l) == 0 字符串长度必须能整除单元长度,满足时说明字符串由该单元反复拼接而成。
"ababab"
n = 6
l = 4
n - l = 2 → 子串 "ab"
6 % 2 == 0 → ✔
;
// time: O(n)
// space: O(n)
双指针法
大部分题目在以上其实已经总结过便不再赘述,刷一遍练手。(~ 表示重复项)
移除元素
;
// time: O(n)
// space: O(n)
反转字符串
;
// time: O(n)
// space: O(1)
栈与队列
栈
C++ 的 std::stack 是一个典型的后进先出(LIFO)容器适配器,默认使用 deque 作为底层实现,只允许从栈顶进行 push、pop 以及读取 top,这些操作都在 O(1) 时间内完成。
stack<int> s;
s.;
s.;
int x = s.; // 20
s.;
队列
C++ 的 std::queue 是一种先进先出(FIFO)的容器适配器,默认使用 deque 作为底层结构,只允许从队尾 push、从队头 pop,并通过 front() 与 back() 访问两端元素;这些操作同样在 O(1) 时间内完成。
queue<int> q;
q.; // push at tail
q.;
q.;
// Queue: head [10, 20, 30] tail
cout << q. << endl; // 10 (head)
cout << q. << endl; // 30 (tail)
q.; // pop from head (removes 10)
// Queue: head [20, 30] tail
cout << q. << endl; // 20
cout << q. << endl; // 30
用栈实现队列
#232. Implement Queue using Stacks
用两个栈:in 负责入队,out 负责出队;当 out 空时,把 in 全部倒进去。
这个实现的核心在于用两个栈分别承担入队和出队,把顺序反转一次就能得到队列的先入先出行为。绝大多数操作都是常数时间,只有在出队栈为空时才会把入队栈整体倒过去,但每个元素只经历一次倒转,因此整体仍保持接近 O(1) 的平均效率。
push 1: in=[1] out=[]
push 2: in=[1,2] out=[]
push 3: in=[1,2,3] out=[]
pop → shift:
in=[] out=[3,2,1] → return 1
push 4: in=[4] out=[3,2]
pop: in=[4] out=[3,2] → return 2
;
/**
* Your MyQueue object will be instantiated and called as such:
* MyQueue* obj = new MyQueue();
* obj->push(x);
* int param_2 = obj->pop();
* int param_3 = obj->peek();
* bool param_4 = obj->empty();
*/
// time: see each method
// space: O(n)
用队列实现栈
#225. Implement Stack using Queues
用一个队列即可:每次 push 时先把元素入队,然后把它之前的所有元素依次出队再入队,让新元素被“旋转”到队头, 这样队头始终是最新压入的元素,于是 pop 就是直接弹队头,top 直接查看队头,就像在用栈一样。
push(1): [1]
push(2): [1,2] → rotate → [2,1]
push(3): [2,1,3] → rotate → [3,2,1]
pop(): 3
;
/**
* Your MyStack object will be instantiated and called as such:
* MyStack* obj = new MyStack();
* obj->push(x);
* int param_2 = obj->pop();
* int param_3 = obj->top();
* bool param_4 = obj->empty();
*/
// time: see each method
// space: O(n)
有效的括号
当然这题有很多小的优化方式,大处着眼于 stack 的用法就好。
;
// time: O(n)
// space: O(n)
删除字符串中的所有相邻重复项
#1047. Remove All Adjacent Duplicates In String
在 C++ 中,用 string 本身当作栈非常高效,因为 std::string 是可变的、底层是连续内存,push_back/pop_back 都是摊销 O(1)。
但在许多其他语言里,字符串往往是不可变类型,例如 Java 的 String、Python 的 str、JavaScript 的 string、Go 的 string 等,对字符串进行拼接或截断都会重新创建新对象,代价是 O(n),如果频繁操作会退化成 O(n²)。
;
// time: O(n)
// space: O(n)
二叉树
二叉树(Binary Tree)是每个节点最多只有两个子节点(left、right)的树结构。
1
/ \
2 3
;
二叉树有几种常见的分类:
满二叉树 (Full Binary Tree)
每个节点要么是 没有子节点,要么同时有 两个子节点。
1
/ \
2 3
/ \ / \
4 5 6 7
完全二叉树(Complete Binary Tree)
除了最后一层外,其他层都必须是满的。 最后一层的节点从左向右连续排列,中间不能断。
1
/ \
2 3
/ \ /
4 5 6
二叉搜索树(BST, Binary Search Tree)
对于任意一个节点:
- 左子树所有值 < 当前节点值
- 右子树所有值 > 当前节点值
- 左右子树本身也是 BST
5
/ \
3 7
/ \ / \
2 4 6 9
中序遍历(左→根→右)会得到严格递增序列:[2, 3, 4, 5, 6, 7, 9]。
堆 (Heap)
与之相关,我们说说堆这个概念。堆是建立在完全二叉树基础上的一个结构,再加上堆序性质(Heap Property)。
堆的两种常见形式:
最大堆(Max-Heap):
- 每个节点都 ≥ 子节点
- 根节点是整个树的最大值
10
/ \
8 7
/ \ /
5 3 6
int
最小堆(Min-Heap):
- 每个节点都 ≤ 子节点
- 根节点是整个树的最小值
1
/ \
3 2
/ \ /
7 6 4
int
顺序存储(数组)
完全二叉树与堆结构紧凑,没有空洞,所以非常适合用数组连续存储。
数组存储方式(从 0 开始):
对某一节点 i:
- 左孩子:
2*i + 1 - 右孩子:
2*i + 2 - 父节点:
(i - 1) / 2
arr[0]=1
/ \
arr[1]=2 arr[2]=3
/ \ /
4 5 6
树结构
1
/ \
3 2
/ \ /
7 6 4
数组表示
index: 0 1 2 3 4 5
value: 1 3 2 7 6 4
平衡二叉树 (Balanced BST)
平衡树是一种在插入、删除后自动调整结构,使整棵树高度保持在 O(log n) 的二叉搜索树,从而保证查找、插入、删除等操作在最坏情况下仍能做到 O(log n),其意义在于避免普通 BST 因数据顺序不佳而退化成链表。常用的实现方式主要有两类:如 AVL Tree,通过维护节点高度并在失衡时做旋转,实现非常严格的平衡、查找更快;以及 Red-Black Tree,通过颜色规则和较宽松的平衡要求,让插入/删除旋转更少、整体性能稳定,是实际工程(如 C++ map/set、Java TreeMap)中最常用的平衡树。平衡树也广泛用于实现数据库索引(如 B+ 树)。
在二叉搜索树的基础上,额外限制:任何节点的左右子树高度差 ≤ 1
4
/ \
2 6
/ \ / \
1 3 5 7
二叉树的递归遍历
深度优先遍历(DFS):一路往下走到叶子,再回溯,包括前序、中序、后序等形式。
#144. Binary Tree Preorder Traversal
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
;
// time: O(n)
// space: O(n)
二叉树的层序遍历
广度优先遍历(BFS):按层逐层访问,例如从上到下、从左到右,按层推进。
#102. Binary Tree Level Order Traversal
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
;
// time: O(n)
// space: O(n)
#116. Populating Next Right Pointers in Each Node
采用层序遍历把一层的 node 逐一连接起来是一种比较直接的做法。这题存在另一种 O(1) 的空间最优解法。
/*
// Definition for a Node.
class Node {
public:
int val;
Node* left;
Node* right;
Node* next;
Node() : val(0), left(NULL), right(NULL), next(NULL) {}
Node(int _val) : val(_val), left(NULL), right(NULL), next(NULL) {}
Node(int _val, Node* _left, Node* _right, Node* _next)
: val(_val), left(_left), right(_right), next(_next) {}
};
*/
;
// time: O(n)
// space: O(n)
#104. Maximum Depth of Binary Tree
递归或层序遍历都可以。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
;
// time: O(n)
// space: O(height)
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
;
#111. Minimum Depth of Binary Tree
用层序遍历与上题逻辑基本统一,注意叶子节点的判断就好。
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
;
// time: O(n)
// space: O(n)
翻转二叉树
人生的有些阶段,你需要翻转一个二叉树。
+----------------------+----------------------+
| Step 0 | Step 1 |
| (original) | (swap at 4) |
| | |
| 4 | 4 |
| / \ | / \ |
| 2 7 | 7 2 |
| / \ / \ | / \ / \ |
| 1 3 6 9 | 6 9 1 3 |
+----------------------+----------------------+
| Step 2 | Step 3 |
| (swap at 7) | (swap at 2) |
| | (final result) |
| 4 | 4 |
| / \ | / \ |
| 7 2 | 7 2 |
| / \ / \ | / \ / \ |
| 9 6 1 3 | 9 6 3 1 |
+----------------------+----------------------+
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
;
// time: O(n)
// space: O(h) where h is the height of the tree
对称二叉树
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
;
// time: O(n)
// space: O(h)
回溯
回溯(Backtracking)是在一棵隐式搜索树里试探性地做选择,每走一步都继续向下探索,如果发现当前路径不合法或走不通,就撤销这一步并回到上一个分叉点继续尝试其他选择。它本质上是带有“撤销操作”的深度优先搜索,用来穷举所有可能解。
关键思想:
- 路径(
path):当前做出的选择(递归走过的路)。 - 选择列表(
choices):当前状态还能做哪些选择。 - 结束条件(
终点):达到某种合法结果时加入答案。
void backtrack(vector<int>& path) {
if (满足结束条件) {
ans.push_back(path);
return;
}
for (选择 in 选择列表) {
做选择;
path.push_back(选择);
backtrack(path);
撤销选择;
path.pop_back();
}
}
回溯是一种很慢的算法。之所以慢,是因为它要探索成倍增长的搜索空间,时间复杂度通常呈指数级,但在需要遍历所有可能解时,它依然是最直接、最清晰的做法。
;
;
贪心算法
贪心算法(Greedy Algorithm)是在每一步都作出当前看起来最优的选择(局部最优),希望最终得到全局最优解的方法。
它的特点是:
- 不回溯,不全局搜索
- 一次决策影响后续,但不重新考虑之前的选择
- 快、实现简单、但并非对所有问题都适用
贪心算法通过在每一步做出当前最优(局部最优)的选择来构建整体解,要求问题满足贪心选择性质与最优子结构。它通常以“排序 + 单次决策”的形式出现,优点是简单高效,缺点是只对结构良好的问题有效,不能解决需要回溯或动态规划的问题。
贪心:小胃口孩子用小饼干优先满足,避免浪费大饼干。
做法:排序孩子 g、饼干 s,双指针从小到大匹配。饼干够就同时移动,否则换更大的饼干。
;
// time: O(n log n)
// space: O(1)
动态规划
动态规划的核心特征是一种通过“状态”描述子问题、通过“状态转移”递推大问题解的思想。它适用于具有重叠子问题与最优子结构的问题:大问题可以被拆成相同类型的更小子问题,而这些子问题的最优解又能组合成整体最优解。动态规划通常先定义状态 dp 表示“当前子问题的答案”,再根据题意写出状态转移方程,配合正确初始化与遍历顺序,最终自底向上地填表得到答案。它的本质是“用空间换时间”,通过记录中间结果避免重复计算,从而显著减少复杂度。
往往我们能看到在 dp 之上对空间的进一步优化。个人觉得初学动态规划时,如果不是很好理解,可以暂时忽略各种空间优化技巧(如滚动数组、状态压缩)。这些优化只是在你已经完全理解“状态表示什么、转移怎么来、遍历顺序为什么这样”之后才有意义。第一遍学习更重要的是把 状态定义 + 转移逻辑 理顺,把题目推导清楚、dp 表画对、代码写稳。等核心思路扎实了,再回头做空间优化会非常自然,也不会增加认知负担。
;
// time: O(n)
// space: O(1)