7.1 数据结构
7.1.1 链表
什么是链表?
线性数据结构,由节点组成,每个节点包含数据和指向下一个节点的指针。
链表 vs 数组有什么区别?
-
内存布局:连续 vs 非连续
-
插入/删除效率:O(1)(已知位置) vs O(n)
-
随机访问:不支持(O(n)) vs 支持(O(1))
链表的类型有哪些?
-
单链表(Singly Linked List)
-
双链表(Doubly Linked List):每个节点有前驱和后继指针
-
循环链表(Circular Linked List):尾节点指向头节点
-
循环双链表
如何反转单链表?
迭代法:
使用三个指针:
-
prev:指向已经反转部分的头结点,初始为nullptr -
curr:当前要处理的节点,初始为链表头 -
next:保存当前节点的下一个节点,防止链表断开
步骤如下:
-
保存
curr->next到next -
将
curr->next指向prev(反转) -
移动
prev和curr:prev = curr,curr = next -
重复直到
curr == nullptr -
最后
prev就是新链表的头节点
除了迭代法,还可以使用递归方式反转链表,但递归空间复杂度为 O(n),因为涉及函数调用栈(不推荐)。
递归思路简述:
-
基本情况:如果链表为空或只有一个节点,直接返回头节点
-
递归反转后面的链表:
newHead = reverseList(head->next) -
将当前节点的下一个节点的 next 指向自己,然后自己的 next 置空
-
返回新的头节点 newHead
链表如何优化去支持随机存取?
-
跳表
-
优点:
-
支持快速的按索引访问(通过维护额外信息或搜索)
-
插入、删除也相对高效(O(log n))
-
相比完全转为数组,节省空间,保留链式结构的灵活性
-
-
缺点:
-
实现较复杂
-
不是严格的 O(1) 随机访问,但比普通链表强很多(O(log n))
-
-
-
维护一个额外的数组或哈希表,记录节点指针 / 索引
-
优点:
-
真正实现 O(1) 的随机访问
-
链表本身的插入/删除逻辑仍然可用(但需要同步更新索引结构)
-
-
缺点:
-
插入和删除时需要额外维护索引结构,可能增加时间成本(比如插入到中间位置时,数组中间插入是 O(n))
-
占用额外的空间,存储所有节点的引用
-
如果链表频繁增删,维护成本较高
-
-
-
块状链表:每个链表节点(块)包含一个小的数组(如 16/32/64 个元素)和指向下一个块的指针
-
优点:
-
比纯链表有更好的缓存局部性和访问效率
-
相对于数组,插入/删除仍然有一定灵活性(只需在块内调整,块满时可分裂)
-
若设计得当,可以实现接近 O(√n) 或更优的随机访问性能
-
-
缺点:
-
实现较为复杂
-
依然不是严格意义上的 O(1) 随机访问,但比普通链表好很多
-
块的大小需要权衡(太小失去意义,太大降低灵活性)
-
-
7.1.2 树
什么是树?
一种非线性的层次结构数据结构,由节点【Node】和边【Edge】组成,没有环。
一个树结构有哪些基本元素?
-
根节点(Root)
-
叶子节点(Leaf)
-
父节点、子节点、兄弟节点
-
节点的度(子节点个数)
-
树的深度(Height)、高度(Depth)、层数(Level)
二叉树(Binary Tree)是什么?
每个节点最多有两个子节点:左子节点 和 右子节点。
满二叉树是什么?
所有非叶子节点都有两个子节点,所有叶子节点都在同一层。
完全二叉树是什么?
除了最后一层,其他层都满,且最后一层从左到右连续。
二叉搜索树(BST,Binary Search Tree)是什么?
-
左子树上所有节点的值 < 根节点的值
-
右子树上所有节点的值 > 根节点的值
-
左右子树也分别为 BST
平衡二叉树(AVL)是什么?
任意节点的左右子树高度差不超过 1。
【必问】红黑树是什么?
自平衡二叉查找树,广泛用于语言标准库(如 C++ STL map/set)。
-
节点是红色或黑色
-
根节点是黑色
-
所有叶子节点(NIL节点)都是黑色
-
红色节点的两个子节点都必须是黑色(即不能有两个连续的红色节点)
-
从任一节点到其每个叶子节点的所有路径上包含相同数目的黑色节点(称为黑高,Black Height)
红黑树有哪些应用?
-
set、map数据结构。
-
Linux 完全公平调度器(CFS, Completely Fair Scheduler) 使用红黑树来维护进程的运行队列。
-
epoll。
Trie树(字典树/前缀树)是什么?
多叉树,常用于字符串检索。
前缀共享:公共前缀的字符串共享同一个路径,节省空间
高效查找:查找一个字符串是否存在:O(L),L 是字符串长度
高效前缀查找:可快速查找具有某前缀的所有字符串
如何实现一个Trie树?
-
每个节点代表一个字符
-
从根节点到某一节点的路径构成一个字符串(或前缀)
-
通常用标记(如 bool isEnd)表示某个节点是否为一个单词的结束
#include <iostream> #include <vector> using namespace std; const int ALPHABET_SIZE = 26; // 假设只处理小写字母 a-z // Trie 节点 struct TrieNode { TrieNode* children[ALPHABET_SIZE]; // 子节点指针数组 bool isEndOfWord; // 标记是否是某个单词的结尾 TrieNode() { for (int i = 0; i < ALPHABET_SIZE; ++i) children[i] = nullptr; isEndOfWord = false; } }; class Trie { private: TrieNode* root; // 辅助函数:字符转索引(a -> 0, b -> 1, ..., z -> 25) int charToIndex(char ch) { return ch - 'a'; } public: Trie() { root = new TrieNode(); } // 插入一个单词到 Trie 树 void insert(const string& word) { TrieNode* curr = root; for (char ch : word) { int idx = charToIndex(ch); if (!curr->children[idx]) curr->children[idx] = new TrieNode(); // 如果不存在则创建 curr = curr->children[idx]; // 移动到子节点 } curr->isEndOfWord = true; // 标记单词结束 } // 查找一个单词是否在 Trie 中 bool search(const string& word) { TrieNode* curr = root; for (char ch : word) { int idx = charToIndex(ch); if (!curr->children[idx]) return false; curr = curr->children[idx]; } return curr->isEndOfWord; // 必须是一个完整单词的结尾 } // 查找是否有以 prefix 为前缀的单词 bool startsWith(const string& prefix) { TrieNode* curr = root; for (char ch : prefix) { int idx = charToIndex(ch); if (!curr->children[idx]) return false; curr = curr->children[idx]; } return true; // 只要前缀路径存在即可 } };
B树是什么?
-
多路分支(多叉树):
-
每个节点可以有多个子节点(通常远多于二叉树的2个),比如 2 到 m 个子节点(m 称为 B树的阶数)。
-
相比二叉搜索树,B树的每个节点能存储更多的键(key),从而减少树的高度,提高 IO 效率(尤其对磁盘友好)。
-
-
有序性:
-
每个节点中的键值按升序排列。
-
对于任意一个键 key[i],其左子树中所有键都 小于 key[i],右子树中所有键都 大于 key[i]。
-
-
平衡性:
-
所有叶子节点都位于同一层,保证了查询效率的稳定。
-
-
节点填充度有规范:
-
除了根节点,每个节点至少有 ⌈m/2⌉ - 1 个键(即至少半满)。
-
根节点至少有 1 个键(除非整棵树为空)。
-
每个节点最多有 m - 1 个键,对应最多 m 个子节点。
-
B树的插入流程?复杂度如何?
-
从根节点开始,按照二叉搜索树的规则找到合适的叶子节点。
-
将新键插入到该叶子节点的合适位置(保持有序)。
-
如果插入后该节点的键数 ≤ m - 1,插入成功,结束。
-
如果插入后节点键数 > m - 1(即节点已满),则需要进行分裂(Split)操作,以维持 B树规则。
-
将该节点从中间位置(通常是第 ⌈m/2⌉ 个键)进行分裂:
-
左半部分保留前 ⌈m/2⌉ - 1 个键;
-
中间的第 ⌈m/2⌉ 个键上提到父节点;
-
右半部分为后 m - ⌈m/2⌉ 个键,形成一个新的右兄弟节点。
-
-
如果父节点因此也满了,则递归向上分裂,直到根节点。
-
如果分裂传播到根节点,并导致根节点分裂,则树的高度增加 1。
-
-
分裂可能递归向上传播,直至不再有节点违反规则或影响到根节点。
复杂度:O(log n)
B树的删除流程?
-
找到待删除的键所在的节点。
-
如果键在叶子节点中,直接删除。
-
如果键在内部节点中,通常需要用前驱或后继(一般来自叶子节点)替换后再删除。
-
-
删除后检查节点是否仍然满足 B树的最小键数要求(即至少 ⌈m/2⌉ - 1 个键)。
-
如果满足,结束。
-
如果不满足,需要通过以下手段进行调整:
-
分裂(Split):节点插入后键数 > m - 1,就可以将节点一分为二,中间键上提到父节点。
-
借位(Borrow):删除后节点键数 < 最小值,但兄弟有多余键,就可以从兄弟借一个键,通过父节点协调。
-
合并(Merge):删除后节点键数 < 最小值,兄弟也无多余键,就可以将当前节点、一个兄弟节点及父节点的一个键合并成一个新节点。
-
-
-
调整操作可能会递归向上传播,影响父节点,甚至可能导致树高降低(如根节点被合并)。
【必问】B+树是什么?
B+树(B+ Tree) 是 B树(B-Tree)的一种变种,它在数据库管理系统(如 MySQL 的 InnoDB)、文件系统等需要高效磁盘存储与检索的场景中被广泛使用,尤其适合做索引结构。
-
非叶子节点(索引节点)
-
每个非叶子节点最多有 m 个子节点,即最多有 m - 1 个键。
-
每个键起 索引作用,用于指引去哪个子节点查找。
-
非叶子节点不存储实际数据,只存储键和指向子节点的指针。
-
子节点的键范围是:左子树所有键 ≤ 当前键 < 右子树所有键。
-
-
叶子节点
-
所有的数据记录(或数据指针)都存储在叶子节点中。
-
叶子节点也是有序排列的(通常是按主键升序)。
-
叶子节点之间通过指针连接,形成一个双向链表(或单向链表),方便范围扫描。
-
每个叶子节点可以存储多个键值对(具体数量取决于节点大小和阶数 m)。
-
-
所有叶子节点都在同一层,保证平衡。
B*树是什么?
B*树 是 B树的一种优化变种,主要改进点在于:它通过更智能的节点填充策略,减少节点分裂的频率,从而提升整体空间的利用率和性能。
当一个节点满了,B*树 不会立即分裂,而是:
-
先尝试将一部分数据移到相邻的兄弟节点(左或右,哪个有空间就往哪移);
-
如果兄弟节点也满了,那么三个节点(当前节点 + 兄弟节点 + 父节点的一个键)一起参与分裂;
-
最终将数据分成三部分,生成两个新节点,并将一个新键提升到父节点。
结果:尽量推迟分裂、减少分裂次数、提高每个节点的填充率(通常保持在 2/3 以上)
B树/B+树有哪些应用?
B树:
-
磁盘存储
-
文件系统
B+树:
-
数据库索引
-
文件系统索引
-
搜索引擎 & 日志系统
B树 vs B+树有什么不同?
B树:
-
数据存储位置:所有节点(包括内部节点和叶子节点)都可以存储数据(或指向数据的指针) 只有叶子节点存储实际数据(或数据指针),内部节点只存索引键(用于路由)
-
叶子节点是否有链表:叶子节点之间没有链接 叶子节点通过指针连接成有序链表(双向或单向),支持高效的范围查询
-
查询方式:查询可能在内部节点就结束(如果命中) 所有查询都必须走到叶子节点才能获取数据,查询路径长度稳定
-
范围查询支持:不友好,需要中序遍历或额外操作 非常高效,叶子节点是链表,顺序访问即可
B+树:
-
数据存储位置:只有叶子节点存储实际数据(或数据指针),内部节点只存索引键(用于路由)
-
叶子节点是否有链表:叶子节点通过指针连接成有序链表(双向或单向),支持高效的范围查询
-
查询方式:所有查询都必须走到叶子节点才能获取数据,查询路径长度稳定
-
范围查询支持:非常高效,叶子节点是链表,顺序访问即可
【必问】树的深度优先遍历(DFS)是什么?前序中序后序遍历是什么?
深度优先遍历(Depth-First Search,DFS) 是一种用于遍历或搜索树(或图)结构的算法。它的核心思想是:从根节点出发,沿着一个分支尽可能深地访问下去,直到到达叶子节点,然后回溯到上一个未完全访问的节点,继续深入其另一个分支,以此类推,直到所有节点都被访问过。
前序遍历(Preorder Traversal):
访问顺序:根节点 → 左子树 → 右子树
即:先访问当前节点,然后递归地前序遍历左子树,再递归地前序遍历右子树。
中序遍历(Inorder Traversal):
访问顺序:左子树 → 根节点 → 右子树
即:先递归地中序遍历左子树,然后访问当前节点,最后递归地中序遍历右子树。
后序遍历(Postorder Traversal):
访问顺序:左子树 → 右子树 → 根节点
即:先递归地后序遍历左子树,再递归地后序遍历右子树,最后访问当前节点。
// 前序遍历:根 -> 左 -> 右 void preOrder(TreeNode* root) { if (!root) return; cout << root->val << " "; // 访问根 preOrder(root->left); // 遍历左子树 preOrder(root->right); // 遍历右子树 } // 中序遍历:左 -> 根 -> 右 void inOrder(TreeNode* root) { if (!root) return; inOrder(root->left); // 遍历左子树 cout << root->val << " "; // 访问根 inOrder(root->right); // 遍历右子树 } // 后序遍历:左 -> 右 -> 根 void postOrder(TreeNode* root) { if (!root) return; postOrder(root->left); // 遍历左子树 postOrder(root->right); // 遍历右子树 cout << root->val << " "; // 访问根 }
【必问】树的广度优先遍历(BFS)是什么?如何层序遍历?
广度优先遍历(Breadth-First Search,BFS) 是一种用于遍历或搜索树(或图)的算法,其核心思想是:
从根节点开始,先访问当前层的所有节点,再依次访问下一层的所有节点,按照从上到下、从左到右的顺序逐层遍历整棵树。
换句话说,BFS 是 按层次(Level)从上往下、从左往右依次访问每个节点,因此 BFS 也常被称为 层序遍历(Level Order Traversal)。
由于 BFS 要按层次遍历,它通常使用 队列(Queue) 数据结构来辅助实现,步骤如下:
-
先将根节点入队
-
当队列不为空时循环:
-
出队一个节点并访问它
-
将其左子节点(如果有)入队
-
将其右子节点(如果有)入队
-
-
重复上述过程,直到队列为空
这样就能保证:先访问当前层的节点,再依次处理它们的子节点(即下一层节点),实现逐层遍历。
// 广度优先遍历(BFS)—— 层序遍历 void bfsTraversal(TreeNode* root) { if (!root) return; queue<TreeNode*> q; q.push(root); // 根节点入队 while (!q.empty()) { TreeNode* curr = q.front(); // 取队首节点 q.pop(); // 出队 cout << curr->val << " "; // 访问当前节点 // 将左子节点入队 if (curr->left) { q.push(curr->left); } // 将右子节点入队 if (curr->right) { q.push(curr->right); } } }
如何根据遍历结果构造二叉树(前+中)?
-
前序遍历的第一个节点就是当前子树的根节点。
-
在中序遍历中找到这个根节点的位置,其左侧是左子树,右侧是右子树。
-
根据左子树的节点数量,可以确定:
-
前序遍历中,紧跟着根节点的若干个节点是左子树的前序遍历,
-
然后才是右子树的前序遍历。
-
-
递归构建左子树和右子树。
#include <iostream> #include <unordered_map> #include <vector> using namespace std; // 二叉树节点定义 struct TreeNode { char val; TreeNode* left; TreeNode* right; TreeNode(char x) : val(x), left(nullptr), right(nullptr) {} }; class Solution { public: // 用于快速在中序中查找根节点位置 unordered_map<char, int> inorderMap; TreeNode* buildTree(vector<char>& preorder, vector<char>& inorder) { // 构建中序的 value -> index 映射,加速查找 for (int i = 0; i < inorder.size(); ++i) { inorderMap[inorder[i]] = i; } // 递归构建,传入对应范围 return build(preorder, 0, preorder.size() - 1, inorder, 0, inorder.size() - 1); } private: TreeNode* build(vector<char>& preorder, int preStart, int preEnd, vector<char>& inorder, int inStart, int inEnd) { if (preStart > preEnd || inStart > inEnd) return nullptr; // 前序的第一个元素就是当前子树的根 char rootVal = preorder[preStart]; TreeNode* root = new TreeNode(rootVal); // 在中序中找到根节点的位置 int inRootIndex = inorderMap[rootVal]; // 左子树的节点个数 int leftSize = inRootIndex - inStart; // 递归构建左子树 root->left = build(preorder, preStart + 1, preStart + leftSize, inorder, inStart, inRootIndex - 1); // 递归构建右子树 root->right = build(preorder, preStart + leftSize + 1, preEnd, inorder, inRootIndex + 1, inEnd); return root; } };
如何根据遍历结果构造二叉树(中+后)?
-
后序遍历的最后一个元素就是当前子树的根节点。
-
在中序遍历中找到这个根节点,其左侧是左子树的中序,右侧是右子树的中序。
-
根据左子树的节点数量,可以确定:
-
后序中,哪部分是左子树的后序遍历
-
哪部分是右子树的后序遍历
-
-
递归构建左子树和右子树。
#include <iostream> #include <unordered_map> #include <vector> using namespace std; // 定义树节点 struct TreeNode { char val; TreeNode* left; TreeNode* right; TreeNode(char x) : val(x), left(nullptr), right(nullptr) {} }; class Solution { public: unordered_map<char, int> inorderMap; // 用于快速查找中序的索引 TreeNode* buildTree(vector<char>& inorder, vector<char>& postorder) { // 构建中序的值 -> 索引 映射 for (int i = 0; i < inorder.size(); ++i) { inorderMap[inorder[i]] = i; } // 递归构建,传入对应区间 return build(inorder, 0, inorder.size() - 1, postorder, 0, postorder.size() - 1); } private: TreeNode* build(vector<char>& inorder, int inStart, int inEnd, vector<char>& postorder, int postStart, int postEnd) { if (inStart > inEnd || postStart > postEnd) return nullptr; // 后序的最后一个节点就是当前子树的根 char rootVal = postorder[postEnd]; TreeNode* root = new TreeNode(rootVal); // 在中序中找到根节点的位置 int inRootIndex = inorderMap[rootVal]; // 左子树的节点个数 int leftSize = inRootIndex - inStart; // 递归构建左子树 root->left = build(inorder, inStart, inRootIndex - 1, postorder, postStart, postStart + leftSize - 1); // 递归构建右子树 root->right = build(inorder, inRootIndex + 1, inEnd, postorder, postStart + leftSize, postEnd - 1); return root; } };
如何判断一棵二叉树是否是BST?
中序遍历法(推荐):
BST 的中序遍历结果一定是一个 严格递增(或非降,根据定义)的序列。
BST如何插入删除以维持BST性质?
插入:
-
从根节点开始,比较要插入的值与当前节点的值
-
如果 小于当前节点值,则转向 左子树
-
如果 大于当前节点值,则转向 右子树
-
重复此过程,直到找到一个空位置(nullptr)
-
-
将新节点插入到这个空位置
删除:
-
情况 1:N 是叶子节点(无左右孩子)
-
直接删除 N(将其父节点对应指针置为 nullptr)
-
-
情况 2:N 只有一个孩子(只有左或只有右)
-
用其孩子节点代替 N 的位置
-
比如 N 只有右孩子 → 用右孩子替换 N
-
N 只有左孩子 → 用左孩子替换 N
-
-
-
情况 3:N 有两个孩子(左 + 右)👉 最复杂
-
此时我们不能直接删除 N,否则会破坏 BST 结构。
-
通常选择:中序后继(即右子树中的最左节点,最小值)
-
用这个 后继节点的值 替换 当前节点 N 的值
-
然后 删除那个后继节点(它最多只有一个右孩子,转为情况1或2)
-
如何判断一棵二叉树是否是AVL?
AVL 树是一种自平衡的二叉搜索树(BST),它满足以下两个条件:
-
它首先必须是一棵二叉搜索树(BST),这个见上面的问题
-
对于树中的每一个节点,它的左子树和右子树的高度差(平衡因子)不超过 1
本题只看AVL的是否平衡性。
-
计算每个节点的左右子树高度
-
计算该节点的平衡因子 = |左子树高度 - 右子树高度|
-
如果 平衡因子 > 1 → 不是 AVL 树
-
-
递归地检查每个节点是否都满足平衡因子 ≤ 1
-
只要有一个节点不满足 ⇒ 整棵树就不是 AVL
-
AVL如何插入删除以维持AVL性质?
-
像普通 BST 一样,先插入或删除目标节点
-
更新从插入/删除点往上到根节点路径上,每个节点的高度
-
检查每个节点的平衡因子(左右子树高度差)
-
一旦发现某个节点平衡因子超过 1(即 |BF| > 1),通过相应的旋转操作恢复平衡
-
旋转后,继续向上回溯,确保整棵树都保持平衡
当某个节点 不平衡(|BF| > 1) 时,根据 不平衡的形态,分为四种情况,对应不同的旋转:
-
LL 型(左左情况) → 右旋(Right Rotate)
-
不平衡节点 右倾,且 左孩子的左子树导致失衡
-
解决方法:对该节点进行 一次右旋
-
-
RR 型(右右情况) → 左旋(Left Rotate)
-
不平衡节点 左倾,且 右孩子的右子树导致失衡
-
解决方法:对该节点进行 一次左旋
-
-
LR 型(左右情况) → 先左旋后右旋
-
不平衡节点的 左孩子右倾
-
先对该左孩子左旋(转为 LL),再对当前节点右旋
-
-
RL 型(右左情况) → 先右旋后左旋
-
不平衡节点的 右孩子左倾
-
先对该右孩子右旋(转为 RR),再对当前节点左旋
-
路径总和问题:判断是否存在从根到叶子节点的路径和等于目标值?
方法:递归深度优先搜索(DFS)
我们从 根节点开始,递归地向下遍历每条从根到叶子的路径,并累加路径上的节点值,判断是否存在某条路径的和等于目标值。
核心思想:
-
从根节点开始,用
当前剩余的目标值 = targetSum - 当前节点值 -
递归地进入左子树和右子树,传入新的目标值
-
当遇到叶子节点时(即左右子节点都为空),判断剩余的目标值是否等于当前节点的值
-
如果相等,说明找到了一条符合要求的路径,返回 true
-
-
如果左右子树中任意一条路径满足条件,就返回 true
如何求二叉树的最大路径和?
这条路径可以从任意节点出发,到达任意节点,但路径中不能重复经过同一个节点(即路径是简单路径)
这条路径可以是:
-
一个节点自身
-
一个节点 + 它的左子节点
-
一个节点 + 它的右子节点
-
一个节点 + 左子节点 + 右子节点
-
任意跨子树的组合,但必须是一条不走回头路的合法路径
核心思想:递归 + 后序遍历 + 动态维护最大路径和
我们采用 后序遍历(左右根) 的方式,自底向上计算每个节点的贡献值,并动态更新全局最大路径和。
关键概念:
-
当前节点的贡献值(单边最大和)
对于当前节点,它能为其父节点所在路径贡献的最大和是:
当前节点值 + max(左子贡献, 右子贡献)
但注意:如果这个贡献值为负,不如不选(即贡献为 0),也就是说:
贡献值 = max(0, 当前节点值 + max(左贡献, 右贡献))
这个贡献值用于告诉父节点:“你可以选择带上我这一边的最大和路径”
-
当前节点的最大路径和(可能作为拐点)
当前节点可以作为路径的拐点(最高点),即:
当前节点值 + 左子贡献 + 右子贡献
这种路径形态是:
左子树 —— 当前节点 —— 右子树
我们要计算这种可能性,并与当前已知的最大路径和进行比较、更新
总结:每层递归要做两件事
-
计算左右子树的贡献值(递归):左子贡献 = 递归左子树,右子贡献 = 递归右子树
-
计算当前节点的最大路径和(可能作为拐点):当前节点值 + 左贡献 + 右贡献,尝试更新全局最大值
-
返回当前节点的贡献值(给父节点使用):当前节点值 + max(左贡献, 右贡献),且如果为负则取 0
如何求树的高度?
递归方式求树的高度(推荐,简单高效)
思路:
-
树的高度 = 1(当前节点) + max(左子树高度, 右子树高度)
-
如果是空树(nullptr),高度为 0(或 1,根据定义,下面代码按 节点数 = 1 + max(左, 右),空树返回 0)
如何求树的最近公共祖先节点?
方法一:递归法(后序遍历,推荐),这是面试中最常见、最通用的解法,适用于 普通二叉树(不一定是 BST)
我们从 根节点开始递归遍历整棵树(后序遍历:左右根),对于当前节点,考虑以下情况:
-
如果当前节点是 nullptr → 返回 nullptr
-
如果当前节点是 p 或 q → 返回当前节点(找到了其中一个目标)
-
递归地在左子树中查找 p 和 q
-
递归地在右子树中查找 p 和 q
-
然后根据左右子树的返回值来判断:
-
如果左右子树都返回非空 → 说明 p 和 q 分别在当前节点的左右两侧 → 当前节点就是 LCA!
-
如果只有左子树返回非空 → 说明 p 和 q 都在左子树 → 返回左子树的返回值
-
如果只有右子树返回非空 → 说明 p 和 q 都在右子树 → 返回右子树的返回值
-
(追问)如果树是二叉搜索树(BST),如何更高效求 LCA?
✅ 利用 BST 的性质:左子树 < 当前节点 < 右子树
-
从根开始,若 p 和 q 都小于当前节点,LCA 在左子树
-
若都大于当前节点,LCA 在右子树
-
否则当前节点就是 LCA
7.1.3 图
什么是图?
-
图是由一组顶点(Vertex / Node)和一组边(Edge)组成的数据结构,用于表示多对多的关系。
-
边可以是有方向的(有向图)或无方向的(无向图),也可以有权重(网图 / 加权图)或无权重。
一个图结构有哪些基本元素?
-
顶点(Vertex / Node):图中的基本元素,如城市、人等
-
边(Edge):连接两个顶点的线,表示关系
-
权重(Weight):边上的数值,表示代价、距离、时间等
-
有向图(Directed Graph):边有方向,如 A → B
-
无向图(Undirected Graph):边无方向,如 A — B
-
权重图(Weighted Graph):边具有权重,如距离
-
无权图(Unweighted Graph):边没有权重
-
度(Degree):与顶点相连的边的数量(无向图);入度、出度(有向图)
-
连通图 / 强连通图:图中任意两个顶点之间都存在路径
-
邻接矩阵、邻接表:图的两种常见存储方式
图的拓扑排序是什么?
拓扑排序是针对有向无环图(DAG, Directed Acyclic Graph)的一种线性排序算法,使得对于图中的每一条有向边 (u → v),顶点 u 在排序结果中总是位于顶点 v 的前面。
方法一:Kahn 算法(基于入度的 BFS 方法)
基本步骤:
-
统计每个节点的入度(即有多少边指向该节点)。
-
将所有入度为 0 的节点加入队列(这些节点没有前置依赖,可以立即执行)。
-
依次从队列中取出节点:
-
将该节点加入拓扑排序结果。
-
遍历该节点的所有邻接节点(即它指向的节点),将它们的入度减 1。
-
如果某个邻接节点的入度变为 0,则将其加入队列。
-
-
循环直到队列为空。
-
判断是否所有节点都被排序:
-
如果排序结果中的节点数 == 图中总节点数,则拓扑排序成功。
-
否则,说明图中存在环,无法进行拓扑排序。
-
方法二:DFS 方法
基本思路:
利用深度优先搜索,在回溯时(即一个节点的所有后继都访问完毕时)将该节点加入排序结果,最终将结果逆序即为拓扑排序。
关键点:
-
使用一个
visited数组记录节点的访问状态:-
0:未访问 -
1:正在访问(在当前 DFS 路径上,用于检测环) -
2:已访问完成
-
-
如果在 DFS 过程中发现某个节点状态为
1,说明出现了环。
时间复杂度:O(V + E),其中 V 是顶点数,E 是边数。
空间复杂度:O(V + E),用于存储图和辅助数据结构。
图的深度优先遍历(DFS)是什么?
-
访问当前节点,并标记为已访问(防止重复访问、死循环)。
-
选择一个相邻且未访问的节点,递归地进行深度优先搜索。
-
如果没有未访问的相邻节点,则回溯到上一个节点,继续尝试访问其它分支。
图的广度优先遍历(BFS)是什么?
-
先将起始节点放入队列,并标记为已访问。
-
从队列中取出一个节点,访问它。
-
将该节点的所有未访问的相邻节点加入队列,并标记为已访问。
-
重复上述过程,直到队列为空。
图的最短路径之Dijkstra算法是什么?
Dijkstra 算法基于 贪心策略(Greedy),其核心思想可以概括为:
每次从尚未确定最短路径的节点中,选择一个距离起点最近的节点,然后以该节点为跳板,更新它所有邻居节点的最短路径估计值。
简单来说就是:
-
初始化: 起点距离为 0,其他节点距离为 ∞(表示尚未确定)。
-
每次选取当前距离起点最近的未确定节点。
-
“确定”该节点的最短路径,并用它去更新其邻居的最短路径。
-
重复以上过程,直到所有节点的最短路径都被确定。
图的最短路径之Bellman-Ford算法是什么?
Bellman-Ford 算法 是一种用于计算 带权图中从单个源点到其他所有顶点的最短路径 的经典算法。
它最大的特点是:可以处理图中存在负权边(negative weight edges)的情况,这是它与 Dijkstra 算法最显著的区别。
此外,Bellman-Ford 还可以 检测图中是否存在负权环(negative weight cycle),即环路的总权值为负,从而导致最短路径无界(无限小)。
Bellman-Ford 算法基于 动态规划 和 松弛操作(Relaxation),其核心思想是:
对图中的所有边进行 V - 1 轮松弛操作(V 是顶点数),每轮遍历所有边,逐步更新从起点到各个节点的最短路径估计值。
-
松弛操作: 对于一条边
(u → v),检查是否可以通过 u 来获得一条更短的路径到达 v,即:-
if (dist[v] > dist[u] + weight(u, v)) dist[v] = dist[u] + weight(u, v)
-
-
经过 V - 1 轮这样的更新后,所有节点的最短路径就应该已经确定(如果没有负权环)。
-
第 V 轮 再次检查,如果还能更新,说明图中存在 负权环,最短路径无意义(可以无限绕圈降低代价)。
图的最短路径之Floyd-Warshall算法是什么?
Floyd-Warshall 算法 是一种用于求解 图中所有顶点对之间的最短路径 的经典算法。
它可以计算出 图中任意两个节点之间的最短路径长度,适用于 有向图或无向图,并且 可以处理负权边(但不能有负权环)。
Floyd-Warshall 算法基于 动态规划(Dynamic Programming) 的思想,其核心是:
对于图中的 每一个节点 k(作为中间节点),检查是否通过 k 可以让 i 到 j 的路径更短,即:
if (dist[i][j] > dist[i][k] + dist[k][j]) dist[i][j] = dist[i][k] + dist[k][j]
-
dist[i][j]表示从节点 i 到节点 j 的当前最短路径长度。 -
我们通过逐步考虑 每一个可能的中间节点 k,来更新所有 i 和 j 的最短路径。
算法会对所有三元组 (i, j, k) 进行遍历,最终得到所有节点对之间的最短路径。
最小生成树之Prim算法是什么?
在图论中,给定一个 带权的无向连通图,它的 最小生成树(Minimum Spanning Tree, MST) 是指:
一个包含图中所有顶点的树(无环且连通),并且所有边的权值之和最小。
Prim 算法基于 贪心策略(Greedy Algorithm),其核心思想是:
从任意一个顶点开始,逐步扩展生成树,每一步都选择一条连接 “已在树中的顶点” 和 “未在树中的顶点” 的权值最小的边,将该边加入生成树,并把对应的新顶点加入树中,直到所有顶点都被包含。
时间复杂度:优先队列(最小堆)优化可达O(E log V)。
最小生成树之Kruskal算法是什么?
Kruskal(克鲁斯卡尔)算法 是用于求解 带权无向连通图的最小生成树(MST) 的经典算法之一。
Kruskal 算法基于 贪心策略(Greedy Algorithm),其步骤如下:
-
将图中所有边按权值从小到大排序。
-
依次取出权值最小的边,判断这条边的两个顶点是否已经连通:
-
如果 未连通,则选择这条边加入最小生成树,同时合并这两个顶点所在的集合;
-
如果 已连通(会形成环),则 跳过这条边。
-
-
重复上述过程,直到选出
n - 1条边(n 为顶点数,一棵树的边数 = 顶点数 - 1)。
如何判断图的连通性?
方法一:DFS(深度优先搜索)或 BFS(广度优先搜索)
从任意一个顶点(比如 0)开始进行 DFS 或 BFS,遍历所有能够到达的节点。 如果遍历结束后,访问过的节点数等于图中的总节点数,则图是连通的;否则,是非连通的。
(追问)求无向图的连通分量个数?
遍历所有节点,对于 每一个未访问的节点,进行一次 DFS/BFS,每启动一次新的遍历,连通分量数 +1。
7.1.4 堆
什么是堆?
堆是一种特殊的完全二叉树,满足以下性质:
-
小顶堆(Min Heap):每个节点的值都 小于或等于 其子节点的值,根节点是最小值
-
大顶堆(Max Heap):每个节点的值都 大于或等于 其子节点的值,根节点是最大值
堆通常使用数组来实现(而非指针形式的树结构),因为它是完全二叉树,可以紧凑存储。
堆有哪些基本操作?时间复杂度多少?
-
插入(Push):将新元素添加到堆的末尾,然后向上调整(Heapify Up),O(logN)
-
删除堆顶(Pop):删除堆顶元素(最值),将最后一个元素放到堆顶,然后向下调整(Heapify Down),O(logN)
-
获取堆顶元素:O(1) 时间获取最值,O(1)
-
堆化(Heapify):将一个无序数组调整为堆,通常从最后一个非叶子节点开始向下调整,O(N)(不是 O(NlogN)!)
【必问】如何用数组实现一个小顶堆/大顶堆?
假设堆使用数组 heap[] 存储,索引从 0 开始(C++习惯),那么:
-
父节点 parent(i) = (i - 1) / 2
-
左孩子 left(i) = 2 * i + 1
-
右孩子 right(i) = 2 * i + 2
从下往上堆化(用于插入后调整):
-
将新元素插入到数组末尾(即树的最后一个叶子节点)。
-
比较该节点与其父节点的值:
-
小顶堆: 如果当前节点值 < 父节点值 → 不满足,需要交换
-
大顶堆: 如果当前节点值 > 父节点值 → 不满足,需要交换
-
-
如果不符合堆性质,就交换当前节点与父节点,然后继续向上比较(移动到父节点位置)。
-
重复此过程,直到当前节点不再违反堆性质,或已经到达根节点。
从上往下堆化(用于删除堆顶后调整):
-
将堆顶元素(通常是 index 0)与数组最后一个元素交换,然后原堆顶。
-
现在,新的元素位于堆顶(index 0),但它可能比它的孩子节点大(小顶堆)或小(大顶堆),所以需要调整。
-
比较该节点与其左右孩子:
-
找出 最小(小顶堆)或最大(大顶堆)的孩子
-
如果当前节点不符合堆性质(比如小顶堆中当前节点 > 最小的孩子),则交换它们
-
-
继续向下与新的孩子节点比较,直到满足堆性质,或到达叶子节点。
求堆的Top K?
求 Top K 最大元素
维护一个大小为 K 的小顶堆,堆顶是这 K 个元素里最小的,也就是第 K 大的元素。
每来一个新元素:
-
如果堆未满(< K),直接插入;
-
如果堆已满,但新元素 > 堆顶(当前第 K 大),则弹出堆顶,插入新元素。
最终,堆中保存的就是最大的 K 个元素。
求 Top K 最小元素
维护一个大小为 K 的大顶堆,堆顶是这 K 个元素里最大的,也就是第 K 小的元素。
每来一个新元素:
-
如果堆未满(< K),直接插入;
-
如果堆已满,但新元素 < 堆顶(当前第 K 小),则弹出堆顶,插入新元素。
最终,堆中保存的就是最小的 K 个元素。
7.2 基础算法
7.2.1 排序算法
冒泡排序是什么?时间复杂度?空间复杂度?稳定性?
冒泡排序的基本步骤(以升序为例):
假设有一个数组 arr[],长度为 n:
-
从第一个元素开始,依次比较
arr[i]和arr[i+1]; -
如果
arr[i] > arr[i+1],则交换这两个元素; -
这样一轮下来,最大的元素就会“冒泡”到数组的最后;
-
然后对剩下的
n-1个元素重复上述过程,直到整个数组有序。
时间复杂度为:O(n²)
空间复杂度为:O(1)
稳定性:稳定
选择排序是什么?时间复杂度?空间复杂度?稳定性?
选择排序的基本步骤(以升序为例):
假设有一个数组 arr[],长度为 n:
-
从第 1 个位置开始,假设当前位置
i是未排序部分的起始位置; -
在
[i, n-1]范围内找到最小的元素,记下其索引minIndex; -
将
arr[i]和arr[minIndex]交换,即把最小值放到第i个位置; -
然后
i++,重复上述过程,直到整个数组有序。
时间复杂度为:O(n²)
空间复杂度为:O(1)
稳定性:不稳定
插入排序是什么?时间复杂度?空间复杂度?稳定性?
插入排序的基本步骤(以升序为例):
假设有一个数组 arr[],长度为 n:
-
初始时,认为
arr[0]是已排序部分,其余[1 ~ n-1]是未排序部分; -
从未排序部分取出第一个元素
arr[i](i 从 1 开始); -
将
arr[i]与已排序部分的元素从后往前逐一比较; -
如果已排序部分的元素比
arr[i]大,则将该元素往后移动一位; -
找到第一个小于或等于 arr[i] 的位置,将
arr[i]插入进去; -
重复上述过程,直到所有元素都插入到正确位置。
时间复杂度为:O(n²)
空间复杂度为:O(1)
稳定性:稳定
希尔排序是什么?时间复杂度?空间复杂度?稳定性?
希尔排序的基本步骤:
假设数组为 arr[],长度为 n:
-
选择一个增量序列(gap),通常初始值为
n/2,之后逐步减半,直到 gap = 1; -
对于每一个 gap 值,将数组分成 gap 个子序列(下标为 0, gap, 2gap... 为一组,1, 1+gap, 1+2gap... 为另一组,以此类推);
-
对每个子序列进行插入排序;
-
缩小 gap(如 gap /= 2),重复上述操作,直到 gap == 1;
-
最后进行一次 gap=1 的插入排序,完成排序。
时间复杂度为:O(n²)(最坏情况)
空间复杂度为:O(1)
稳定性:不稳定
【必问】快速排序是什么?时间复杂度?空间复杂度?稳定性?
快速排序的基本步骤(以升序为例):
假设数组为 arr[left...right]:
-
选择基准(pivot):一般选择
arr[right](或 left, 或随机); -
分区(Partition)操作:
-
设定两个指针:
i(跟踪小于 pivot 的边界)、j(当前遍历的元素); -
遍历数组,将小于 pivot 的元素交换到左侧;
-
最后将 pivot 放到正确的位置(即所有小于它的在左,大于它的在右);
-
-
递归调用:对 pivot 左侧和右侧的子数组分别进行快速排序。
时间复杂度为:O(n log n),最坏变成O(n²)
空间复杂度为:O(log n),最坏变成O(n)
稳定性:不稳定
#include <iostream> #include <vector> using namespace std; // 分区函数(Lomuto Partition Scheme) int partition(vector<int>& arr, int low, int high) { int pivot = arr[high]; // 选择最后一个元素作为基准 int i = low - 1; // i 是小于 pivot 的区域的最后一个索引 for (int j = low; j < high; j++) { if (arr[j] < pivot) { i++; swap(arr[i], arr[j]); // 把小于 pivot 的元素交换到左边 } } swap(arr[i + 1], arr[high]); // 最后把 pivot 放到正确的位置 return i + 1; // 返回 pivot 的索引 } // 快速排序主函数 void quickSort(vector<int>& arr, int low, int high) { if (low < high) { int pi = partition(arr, low, high); // 获取分区点 quickSort(arr, low, pi - 1); // 递归排序左半部分 quickSort(arr, pi + 1, high); // 递归排序右半部分 } } int main() { vector<int> arr = {10, 7, 8, 9, 1, 5}; int n = arr.size(); quickSort(arr, 0, n - 1); cout << "Sorted array: "; for (int num : arr) { cout << num << " "; } return 0; }
归并排序是什么?时间复杂度?空间复杂度?稳定性?
归并排序的基本步骤(以升序为例):
假设我们有一个数组 arr[left...right]:
-
分解:
-
如果
left < right(即子数组中有多个元素):-
找到中间点:
mid = left + (right - left) / 2 -
递归排序左半部分:
mergeSort(arr, left, mid) -
递归排序右半部分:
mergeSort(arr, mid + 1, right)
-
-
-
合并:
-
将两个已排序的子数组
arr[left...mid]和arr[mid+1...right]合并为一个有序数组; -
合并过程中使用辅助数组(临时数组)存放合并结果,再将结果拷贝回原数组。
-
时间复杂度为:O(n log n)
空间复杂度为:O(n)
稳定性:稳定
堆排序是什么?时间复杂度?空间复杂度?稳定性?
堆排序的基本步骤(升序为例,使用最大堆):
假设我们有一个数组 arr[],长度为 n:
-
步骤 1:建堆(Heapify)
-
从最后一个非叶子节点开始(即
n/2 - 1),自底向上对每个节点进行下沉操作,将数组调整为最大堆; -
最终堆顶
arr[0]就是最大值。
-
步骤 2:排序
-
将堆顶元素(当前最大值)与堆的最后一个元素交换,最大值放到了数组末尾;
-
缩小堆范围:排除最后一个已排序元素,对剩余的堆(即
arr[0...i-1])重新调整成最大堆; -
重复上述过程,直到堆中只剩下一个元素,排序完成。
-
时间复杂度为:O(n log n)
空间复杂度为:O(1)
稳定性:不稳定
计数排序是什么?时间复杂度?空间复杂度?稳定性?
计数排序的工作流程可以分为以下几个步骤:
-
统计频率:统计待排序数组中每个元素出现的次数,存入一个计数数组(Count Array);
-
计算位置(累加计数):将计数数组进行累加,得到每个元素在排序后数组中的最后一个位置或起始位置;
-
排序(放置元素):根据计数信息,将原数组中的每个元素放到它在结果数组中的正确位置,并保证稳定性(从后往前遍历);
-
拷贝回原数组(可选):将排序结果拷贝回原数组(如果需要)。
时间复杂度为:O(n + 最大值 - 最小值 + 1)
空间复杂度为:O(n + 最大值 - 最小值 + 1)
稳定性:稳定
桶排序是什么?时间复杂度?空间复杂度?稳定性?
桶排序的基本流程可以分为以下几步:
-
创建若干个桶(Bucket):根据数据的范围和分布,确定桶的数量和每个桶负责的范围;
-
将元素分配到对应的桶中:根据元素的值,将其放入某个桶(通常通过映射函数确定归属);
-
对每个桶中的元素进行排序:可以使用插入排序、快速排序等算法;
-
按顺序合并所有桶中的元素:按桶的顺序,将桶内已排序的元素依次取出,合并成最终的有序序列。
时间复杂度为:O(n)~O(n²),平均大约O(n)
空间复杂度为:O(n + 桶数)
稳定性:桶内稳定,则稳定
基数排序是什么?时间复杂度?空间复杂度?稳定性?
基数排序的基本步骤(以整数为例,从低位到高位排序):
假设我们要排序的是一组非负整数,例如:[170, 45, 75, 90, 802, 24, 2, 66]
步骤:
-
确定最大位数(maxDigit):找到数组中最大数字的位数,决定总共要排序多少轮(比如最大是 802 → 3 位);
-
从最低位(个位)开始,到最高位(最高位)依次进行排序:
-
按当前位(比如个位、十位、百位)的值,使用稳定的排序算法(常用计数排序)对所有元素进行排序;
-
-
重复上述过程,直到最高位排序完毕;
-
最终数组整体有序。
时间复杂度为:O(d × (n + k))
-
n = 元素个数
-
d = 最大数字的位数(比如最大是 802 → d = 3)
-
k = 基数(对于十进制数字,k = 10,即 0~9)
空间复杂度为:O(n + k)
稳定性:稳定
7.2.2 查找算法
顺序查找是什么?时间复杂度?空间复杂度?
顺序查找(Sequential Search 或 Linear Search) 是一种最简单直观的查找算法。它的基本思想是:
从数据结构(通常是数组或列表)的第一个元素开始,逐个与目标值进行比较,直到找到目标值或者遍历完整个数据结构为止。
时间复杂度为:O(n)
空间复杂度为:O(1)
【必问】二分查找是什么?时间复杂度?空间复杂度?
二分查找(Binary Search,又称折半查找) 是一种在 有序数据集合 中快速查找某一目标值的算法。它的核心思想是:
每次将查找范围缩小一半,通过比较中间元素与目标值,决定接下来在前半部分还是后半部分继续查找,直到找到目标值或者确定目标不存在。
时间复杂度为:O(log n)
空间复杂度为:O(1)
7.2.3 哈希算法
哈希函数是什么?
-
确定性:相同的输入始终产生相同的哈希值。
-
高效性:计算速度快。
-
均匀分布(均匀性):不同的输入应尽可能均匀地映射到不同的输出,减少冲突。
-
抗碰撞性(在密码学中尤其重要):难以找到两个不同输入产生相同输出。
哈希表是什么?
通过哈希函数将键(key)映射到数组的某个位置,实现 Key-Value 快速存取。
【必问】hash冲突之链地址法是什么?
-
思路:哈希表的每个槽(bucket)对应一个链表(或其他容器,如红黑树),所有哈希到同一位置的键值对都存储在该链表中。
-
查找时,先定位桶,再在链表中搜索。
-
优点:实现简单,支持高负载因子。
-
缺点:链表过长时性能下降(可优化为红黑树,如 Java HashMap 在链表长度超过阈值时转为红黑树)。
(追问)hash最差能退化到什么复杂度?
变成链表,变成O(n)。
(追问)链地址法链表过长如何解决?
-
当链表过长时,转为红黑树(Treeify)
-
优化哈希函数
-
改用开放地址法、再哈希法
【必问】hash冲突之开放地址法是什么?
-
思路:如果目标位置被占用,则按照某种探测策略去寻找下一个可用位置。
-
常见的探测方法:
-
线性探测(Linear Probing):依次检查下一个位置(index + 1, index + 2, ...)
-
二次探测(Quadratic Probing):按平方间隔查找(index + 1², index + 2², ...)
-
双重哈希(Double Hashing):使用第二个哈希函数计算步长
-
-
优点:不需要额外存储结构(如链表),节省内存。
-
缺点:对装载因子敏感,容易产生聚集(clustering),删除操作较复杂(需标记墓碑)。
hash冲突之再哈希法是什么?
-
当哈希表达到一定负载因子时,进行扩容并重新计算所有元素的哈希位置,以减少冲突概率。
hash有哪些应用?
-
hash分流用于负载均衡
-
hash散列表在redis中应用
-
布隆过滤器
7.3 高级算法
7.3.1 递归/分治/回溯
求一个集合的所有子集?
方法一:递归(回溯)
递归的思路是:对于每个元素,我们有两个选择——包含它或不包含它。我们可以递归地处理剩下的元素。
以 {1, 2, 3} 为例:
-
开始时,子集为空
[]。 -
对于元素
1:-
不包含
1:继续处理[2, 3],当前子集[]。 -
包含
1:将1加入当前子集,得到[1],然后继续处理[2, 3]。
-
-
对于
[2, 3]:-
如果之前是
[]:-
不包含
2:继续处理[3],子集[]。 -
包含
2:子集[2],继续处理[3]。
-
-
如果之前是
[1]:-
不包含
2:继续处理[3],子集[1]。 -
包含
2:子集[1, 2],继续处理[3]。
-
-
-
以此类推,直到处理完所有元素。
这种方法可以系统地生成所有可能的组合。
#include <vector> using namespace std; void backtrack(int start, vector<int>& nums, vector<int>& current, vector<vector<int>>& result) { result.push_back(current); // 当前路径是一个子集 for (int i = start; i < nums.size(); ++i) { current.push_back(nums[i]); // 选择当前元素 backtrack(i + 1, nums, current, result); // 递归处理下一个元素 current.pop_back(); // 撤销选择,回溯 } } vector<vector<int>> subsetsBacktracking(vector<int>& nums) { vector<vector<int>> result; vector<int> current; backtrack(0, nums, current, result); return result; }
方法二:迭代(位运算)
另一个思路是利用二进制数的表示。对于一个 n 元素的集合,可以用 n 位二进制数来表示一个子集,每一位表示对应位置的元素是否在子集中。
例如,{1, 2, 3}:
-
000(0):{} -
001(1):{3} -
010(2):{2} -
011(3):{2, 3} -
100(4):{1} -
101(5):{1, 3} -
110(6):{1, 2} -
111(7):{1, 2, 3}
因此,我们可以从 0 到 2^n - 1 枚举所有数字,每个数字对应一个子集。
#include <vector> using namespace std; vector<vector<int>> subsetsBitmask(vector<int>& nums) { int n = nums.size(); int total = 1 << n; // 2^n vector<vector<int>> result; for (int mask = 0; mask < total; ++mask) { vector<int> subset; for (int i = 0; i < n; ++i) { if (mask & (1 << i)) { subset.push_back(nums[i]); } } result.push_back(subset); } return result; }
时间复杂度:O(n × 2^n)
排列问题?
给定一个 vector<int>(比如 nums),以及一个整数 k,要求:从 nums 中选取 k 个不同的元素,输出这 k 个元素组成的 所有可能的排列。
方法:回溯法(DFS)
最常用且通用的方法是使用 回溯法(Backtracking),即:
-
逐个选择元素,维护一个当前正在构建的排列
current。 -
每选择一个元素后,将其标记为已使用(或从候选集中移除),避免重复选择同一个元素。
-
当
current的长度达到k时,将其加入结果。 -
回溯:撤销最后的选择,继续尝试其他可能性。
#include <iostream> #include <vector> using namespace std; // 回溯函数 void backtrack(int start, int k, vector<int>& nums, vector<int>& current, vector<vector<int>>& result) { if (current.size() == k) { result.push_back(current); return; } for (int i = 0; i < nums.size(); ++i) { current.push_back(nums[i]); backtrack(i + 1, k, nums, current, result); // 避免重复选同一个元素,用 i + 1 current.pop_back(); // 回溯 } } // 主函数:返回所有 k 个数的排列 vector<vector<int>> permuteK(vector<int>& nums, int k) { vector<vector<int>> result; vector<int> current; backtrack(0, k, nums, current, result); return result; } // 打印结果的辅助函数 void printResult(const vector<vector<int>>& result) { for (const auto& vec : result) { cout << "["; for (size_t i = 0; i < vec.size(); ++i) { cout << vec[i]; if (i < vec.size() - 1) cout << ", "; } cout << "]" << endl; } } int main() { vector<int> nums = {1, 2, 3}; int k = 2; vector<vector<int>> result = permuteK(nums, k); printResult(result); return 0; }
时间复杂度:O(P(n, k) × k)
(追问)全排列问题?
方法一:回溯法(DFS,推荐)
核心思想:
-
维护一个
current数组,表示当前正在构建的排列; -
维护一个
used数组(或哈希集合),记录哪些数字已经被使用; -
每次从未使用的数字中选择一个,加入
current,然后递归; -
当
current的大小等于原数组大小时,将其加入结果; -
回溯时撤销选择,继续尝试其他数字。
#include <iostream> #include <vector> using namespace std; // 回溯函数 void backtrack(vector<int>& nums, vector<bool>& used, vector<int>& current, vector<vector<int>>& result) { if (current.size() == nums.size()) { result.push_back(current); return; } for (int i = 0; i < nums.size(); ++i) { if (!used[i]) { used[i] = true; current.push_back(nums[i]); backtrack(nums, used, current, result); current.pop_back(); // 撤销选择 used[i] = false; // 标记为未使用 } } } // 主函数:返回全排列 vector<vector<int>> permute(vector<int>& nums) { vector<vector<int>> result; vector<int> current; vector<bool> used(nums.size(), false); // 标记数字是否被使用 backtrack(nums, used, current, result); return result; } // 打印结果的辅助函数 void printResult(const vector<vector<int>>& result) { for (const auto& vec : result) { cout << "["; for (size_t i = 0; i < vec.size(); ++i) { cout << vec[i]; if (i < vec.size() - 1) cout << ", "; } cout << "]" << endl; } } int main() { vector<int> nums = {1, 2, 3}; auto permutations = permute(nums); printResult(permutations); return 0; }
方法二:使用 C++ 标准库 next_permutation
std::next_permutation 要求输入的范围 必须是已排序的(升序),才能生成全排列。
#include <iostream> #include <vector> #include <algorithm> // for next_permutation using namespace std; vector<vector<int>> permuteWithSTL(vector<int> nums) { vector<vector<int>> result; sort(nums.begin(), nums.end()); // 必须先排序 do { result.push_back(nums); } while (next_permutation(nums.begin(), nums.end())); return result; } void printResult(const vector<vector<int>>& result) { for (const auto& vec : result) { cout << "["; for (size_t i = 0; i < vec.size(); ++i) { cout << vec[i]; if (i < vec.size() - 1) cout << ", "; } cout << "]" << endl; } } int main() { vector<int> nums = {1, 2, 3}; auto permutations = permuteWithSTL(nums); printResult(permutations); return 0; }
时间复杂度:O(n! × n)
【必问】组合问题?
这是一个非常经典的 组合生成问题(Combination Generation),通常使用 回溯算法(DFS, Depth-First Search) 来解决。
-
我们需要从
n个元素中选出k个,不考虑顺序,且不能重复选同一个元素。 -
用回溯的方法:
-
逐个决定“是否选择当前元素”;
-
一旦选够
k个元素,就记录这个组合; -
通过递归和回溯,枚举所有可能的组合。
-
#include <iostream> #include <vector> using namespace std; // 回溯函数 void backtrack(int start, vector<int>& nums, int k, vector<int>& current, vector<vector<int>>& result) { if (current.size() == k) { result.push_back(current); return; } for (int i = start; i < nums.size(); ++i) { current.push_back(nums[i]); // 选择当前元素 backtrack(i + 1, nums, k, current, result); // 递归,注意是 i+1,避免重复选同一个元素 current.pop_back(); // 撤销选择(回溯) } } // 主函数:返回所有长度为 k 的组合 vector<vector<int>> combine(vector<int>& nums, int k) { vector<vector<int>> result; vector<int> current; backtrack(0, nums, k, current, result); return result; } // 打印结果的辅助函数 void printResult(const vector<vector<int>>& result) { for (const auto& vec : result) { cout << "["; for (size_t i = 0; i < vec.size(); ++i) { cout << vec[i]; if (i < vec.size() - 1) cout << ", "; } cout << "]" << endl; } } int main() { vector<int> nums = {1, 2, 3}; int k = 2; auto combinations = combine(nums, k); printResult(combinations); return 0; }
时间复杂度:O(C(n, k) × k)
(追问)可重复选取元素的组合问题?
#include <iostream> #include <vector> using namespace std; // 回溯函数 void backtrack(int start, vector<int>& nums, int k, vector<int>& current, vector<vector<int>>& result) { if (current.size() == k) { result.push_back(current); return; } for (int i = start; i < nums.size(); ++i) { current.push_back(nums[i]); // 选择当前数字 backtrack(i, nums, k, current, result); // 关键:传入 i 而不是 i+1,允许重复选自己 current.pop_back(); // 撤销选择(回溯) } } // 主函数:返回所有长度为 k 的可重复组合 vector<vector<int>> combineWithRepetition(vector<int>& nums, int k) { vector<vector<int>> result; vector<int> current; backtrack(0, nums, k, current, result); return result; } // 打印结果的辅助函数 void printResult(const vector<vector<int>>& result) { for (const auto& vec : result) { cout << "["; for (size_t i = 0; i < vec.size(); ++i) { cout << vec[i]; if (i < vec.size() - 1) cout << ", "; } cout << "]" << endl; } } int main() { vector<int> nums = {1, 2}; int k = 3; auto combinations = combineWithRepetition(nums, k); printResult(combinations); return 0; }
-
backtrack(i, ...):注意这里递归调用时传的是i而不是i + 1,这就意味着 当前数字可以被重复选择多次。 -
由于我们是从
start开始遍历,且每次递归都从当前或之后开始选,因此可以保证组合中的元素是 非递减的,从而避免了生成[1,2,1]这种顺序不同但元素相同的无效组合。
时间复杂度:O( C(n + k - 1, k) × k )
洗牌算法?
将该数组中的元素顺序完全随机打乱,即生成一个随机的排列(shuffle),使得每一种可能的排列出现的概率尽可能相等。
这通常被称为 洗牌算法(Shuffling Algorithm),经典且高效的实现是 👉 Fisher-Yates 洗牌算法。
每一种可能的排列(5! = 120 种)都应该有 近似相等的概率 被生成。
从最后一个元素开始,向前遍历数组:
-
对于当前位置
i(比如从n-1到1),随机选择一个索引j,其中0 <= j <= i; -
交换
nums[i]和nums[j]; -
这样能确保每个元素都有公平的概率出现在每一个位置上;
-
最终整个数组就是一个均匀随机的排列。
#include <iostream> #include <vector> #include <cstdlib> // 早期C标准,不推荐在新代码中使用 #include <ctime> // 用于种子 #include <algorithm> // for std::swap using namespace std; // Fisher-Yates 洗牌算法 void fisherYatesShuffle(vector<int>& nums) { int n = nums.size(); // 使用 C++11 更优的随机数库更好,这里先用传统方式演示 srand(time(0)); // 设置随机种子,一般只在 main 函数中调用一次即可 for (int i = n - 1; i > 0; --i) { // 生成 [0, i] 范围内的随机索引 int j = rand() % (i + 1); // 交换 nums[i] 和 nums[j] swap(nums[i], nums[j]); } } int main() { vector<int> nums = {1, 2, 3, 4, 5}; fisherYatesShuffle(nums); cout << "Shuffled array: "; for (int num : nums) { cout << num << " "; } cout << endl; return 0; }
C++标准库更简单的方式:std::shuffle。
大数字符串相乘?
-
不能直接将字符串转为整数类型(如
int或long long)来计算,因为输入可能非常大(比如几百位甚至上千位的数字),超出所有内置整数类型的表示范围; -
必须通过 模拟手工乘法(竖式乘法)的方式,逐位计算并处理进位,最终组合得到结果;
-
最终结果也要以 字符串形式返回。
-
双层循环,逐位相乘:
-
外层循环遍历
num1(从后往前,即从低位到高位); -
内层循环遍历
num2(同样从后往前); -
计算
digit1 * digit2,然后加上原来该位置上已有的值; -
更新当前位
pos2 = i + j + 1和进位位pos1 = i + j。
-
-
处理进位:
-
当前位只保留
sum % 10; -
进位部分加到
result[pos1],会在后续处理中继续进位。
-
-
构造答案字符串:
-
从
result数组构建字符串,跳过前导零; -
如果结果全为零(如
"0" × "123"),返回"0"。
-
#include <iostream> #include <vector> #include <string> #include <algorithm> // for reverse using namespace std; string multiply(string num1, string num2) { int n = num1.size(), m = num2.size(); if (n == 0 || m == 0 || num1 == "0" || num2 == "0") return "0"; // 结果最多为 n + m 位 vector<int> result(n + m, 0); // 从右往左遍历 num1 和 num2 for (int i = n - 1; i >= 0; --i) { for (int j = m - 1; j >= 0; --j) { int digit1 = num1[i] - '0'; int digit2 = num2[j] - '0'; int mul = digit1 * digit2; // 当前乘积的位置:i + j + 1 int pos1 = i + j; // 进位位置 int pos2 = i + j + 1; // 当前位 int sum = mul + result[pos2]; // 加上之前的结果 result[pos2] = sum % 10; // 当前位 result[pos1] += sum / 10; // 进位 } } // 构造结果字符串(跳过前导零) string ans; for (int num : result) { if (!(ans.empty() && num == 0)) { // 跳过前导零 ans.push_back(num + '0'); } } return ans.empty() ? "0" : ans; } int main() { string num1 = "123"; string num2 = "456"; cout << multiply(num1, num2) << endl; // 输出: 56088 return 0; }
N皇后问题?
在一个 N×N 的棋盘上,放置 N 个皇后,要求:任意两个皇后都不能处于同一行、同一列或同一斜线上。
换句话说,要找到所有可能的摆放方式,使得这些皇后互相之间不会攻击到对方(在国际象棋中,皇后可以横、竖、斜走任意格数)。
回溯法的核心思想是:“试错 + 撤销”,也就是:
-
逐行放置皇后(每行只能放一个皇后)
-
对于当前行,尝试在该行的每一列放置皇后
-
检查这个位置是否与已经放置的皇后冲突(同一列、同一对角线)
-
如果冲突,则跳过该列(回溯)
-
如果不冲突,则放置皇后,并进入下一行继续放置
-
-
当成功放置了 N 个皇后(即到达第 N 行),说明找到了一个解,记录下来
-
继续回溯,寻找其他可能的解
在放置第 i 行的皇后到第 j 列时,需要检查:
-
列冲突:之前是否已经有皇后放在第 j 列?
-
对角线冲突:
-
主对角线(左上到右下):行号 - 列号 = 常数
-
副对角线(右上到左下):行号 + 列号 = 常数
-
所以我们可以用三个集合或数组来记录:
-
cols:记录哪些列已经被占用 -
diagonals1:记录主对角线是否被占用(可用row - col标识,范围是 -(N-1) ~ (N-1)) -
diagonals2:记录副对角线是否被占用(可用row + col标识,范围是 0 ~ 2N-2)
汉诺塔问题?
有三根柱子,编号为 A、B、C。在柱子 A 上从下到上按大小顺序叠放着若干个圆盘(通常为 N 个),大的在下,小的在上。目标是将所有圆盘从柱子 A 移动到柱子 C,并保持原有顺序(即大的始终在下面),且遵守以下规则:
-
一次只能移动一个圆盘(最上面的那个);
-
任何时候,都不能将较大的圆盘放在较小的圆盘上面;
-
可以借助中间柱子(如 B)进行过渡。
递归思想分解:
-
将上面的 N-1 个盘子从 A 移到 B(借助 C)
-
将第 N 个(最大的)盘子从 A 移到 C
-
将那 N-1 个盘子从 B 移到 C(借助 A)
这是一个递归定义:要移动 N 个盘子,先移动 N-1 个,再移动最大的,再移动 N-1 个。
对于 N 个盘子,最少需要的移动次数是:$$移动次数=2^N−1$$
7.3.2 动态规划
动态规划算法是什么?
动态规划(Dynamic Programming,简称 DP)是一种通过把原问题分解为相对简单的子问题,并保存子问题的解以避免重复计算,从而解决复杂问题的算法策略。
动态规划的核心可以总结为:将大问题分解为小问题,保存小问题的解(避免重复计算),利用这些解逐步构建出原问题的解。
它是一种空间换时间的策略,通过记录中间结果,将原本可能是指数级复杂度的问题,优化为多项式级别。
【必问】打家劫舍问题的解法是什么?
dp_A[i] = 到第 i 天为止,你当天XX时你最大的利润值。
dp_B[i] = 到第 i 天为止,你当天不XX时你最大的利润值。
dp_A[i] = XX + nums[i]。
dp_B[i] = max(dp_A[i - 1], dp_B[i - 1])。
结果:max(dp_A[M], dp_B[M])。
【必问】背包问题的解法是什么?
背包问题描述的是:给定一组物品,每个物品有自己的重量和价值,在限定的背包容量下,如何选择物品使得背包中物品的总价值最大,或是求其他的内容。
设置物品数量N,背包容量V,重量w = {2,3,4,5},价值v = {3,4,5,8}。
背包问题的种类:
-
0-1背包问题:每种物品最多只能选择一次(选或不选)
-
完全背包问题:每种物品可以选择无限次
-
可重复的背包问题:例如选择(1,1,2)和(1,2,1)算作不同的组合。需要调换内外层。
-
求总数最少/组合总数/最大价值
解决方法:
-
可以重复算组合吗?例如选择(1,1,2)和(1,2,1)是一种组合吗?若可以:
-
0-1背包:
-
外层逆序遍历背包容量V
-
遍历每一件物品n
-
-
完全背包:
-
外层正序遍历背包容量V
-
遍历每一件物品n
-
-
-
可以重复算组合吗?例如选择(1,1,2)和(1,2,1)是一种组合吗?若不可以:
-
0-1背包:
-
遍历每一件物品n
-
内层逆序遍历背包容量V
-
-
完全背包:
-
遍历每一件物品n
-
内层正序遍历背包容量V
-
-
-
计算结果:
-
总数最少:
dp[j] =min(dp[j], dp[j - w[i]] + v[i]) -
组合总数:
dp[j]+=dp[j - w[i]] -
最大价值:
dp[j] =max(dp[j], dp[j - w[i]] + v[i]) -
二维最大价值:
dp[j][k] =max(dp[j][k], dp[j - w1[i]][k - w2[i]] + v[i])
-
编辑距离问题的解法是什么?
dp[i][j] = A 的前 i 个字母和 B 的前 j 个字母之间的编辑距离。
若 A 和 B 的最后一个字母相同:dp[i][j] = XXX。
若 A 和 B 的最后一个字母不同:dp[i][j] = XXX。
需要考虑 dp[i][0] 和 dp[0][j] 的边界条件。
股票问题/状态机问题的解法是什么?
dp_state1[i] = 第 i 天能够获得的最多的钱,且在这一天状态1。
dp_state2[i] = 第 i 天能够获得的最多的钱,且在这一天状态2。
dp_state3[i] = 第 i 天能够获得的最多的钱,且在这一天状态3。
dp_state4[i] = 第 i 天能够获得的最多的钱,且在这一天状态4。
状态机:
状态1 -> 状态X 或 状态Y ……
状态2 -> 状态X 或 状态Y ……
状态3 -> 状态X 或 状态Y ……
状态4 -> 状态X 或 状态Y ……
状态转移方程:
dp_state1[i] = XX。
dp_state2[i] = XX。
dp_state3[i] = XX。
dp_state4[i] = XX。
最后需要考虑最后一天只可能是某几种状态之一,而非所有可能状态。
【必问】最长递增子序列的解法是什么?
dp[i] = 以第 i 个数字结尾的最长上升子序列的长度。注意 nums[i] 必须被选取。
dp[i] = max(dp[j]) + 1, 其中0 ≤ j< i且num[j] < num[i]。
ans = max(dp[i])。
【必问】最长公共子序列的解法是什么?
dp[i][j] = [i, j]内的最长回文子序列的长度。
i、j 符合XX:dp[i][j] = dp[i + 1][j − 1] + X。
i、j 不符合XX:dp[i][j] = max(dp[i + 1][j], dp[i][j − 1])。
外层可能需要逆序。
最长定差子序列的解法是什么?
hash_dp[i] = 以第 i 个数字结尾的最长定差子序列的长度。注意 nums[i] 必须被选取。
hash_dp[i] = hash_dp[i − diff] + 1。
ans = max(hash_dp[i])。
状压动态规划是什么?旅行商问题如何解决?
状压动态规划(状压 DP)是一种利用 二进制位运算来压缩状态表示,从而在动态规划中高效处理小规模集合状态(比如若干个物品选或不选、若干个位置的状态等)的优化技巧。
它是动态规划 + 位运算 + 状态压缩的结合,常用于处理状态空间较小但数量较多的问题,尤其在状态可以用“集合”表示时非常高效。
-
有 n 个城市,编号 0 ~ n-1
-
一个起点(比如 0)
-
已知任意两城市之间的距离
-
要求从起点出发,经过所有城市恰好一次,最后回到起点,求最短路径长度
状态定义:
-
dp[mask][u]:表示当前已经访问过的城市集合为mask,且当前位于城市u时,所达到的最短路径长度 -
比如
mask = 5(二进制 0101)表示已经访问过城市 0 和 2
状态转移方程:
-
对于每一个状态
(mask, u),尝试从未访问的城市中选一个v,转移到(mask | (1<<v), v) -
转移方程为:
dp[mask | (1 << v)][v] = min(dp[mask | (1 << v)][v], dp[mask][u] + dist[u][v])
初始状态:
-
dp[1 << 0][0] = 0(从起点 0 出发,只访问过城市 0)
最终答案:
-
遍历所有城市作为最后一个访问的城市,再回到起点 0,取最小值:
min(dp[(1 << n) − 1][u] + dist[u][0])
#include <iostream> #include <vector> #include <climits> #include <cstring> using namespace std; int tsp(int n, vector<vector<int>>& dist) { // dp[mask][u] 表示经过 mask 这些城市,当前在 u 的最短路径 vector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX / 2)); // 初始状态:从 0 出发,只访问过城市 0 dp[1 << 0][0] = 0; // 枚举所有状态 mask for (int mask = 0; mask < (1 << n); ++mask) { for (int u = 0; u < n; ++u) { if (!(mask & (1 << u))) continue; // 当前状态没访问过 u,跳过 if (dp[mask][u] == INT_MAX / 2) continue; // 当前状态不可达,跳过 // 尝试访问所有未访问的城市 v for (int v = 0; v < n; ++v) { if (mask & (1 << v)) continue; // 已经访问过 v,跳过 int new_mask = mask | (1 << v); dp[new_mask][v] = min(dp[new_mask][v], dp[mask][u] + dist[u][v]); } } } // 找最终答案:访问完所有城市后,从各个 u 回到起点 0 int final_mask = (1 << n) - 1; int ans = INT_MAX / 2; for (int u = 0; u < n; ++u) { if (dp[final_mask][u] != INT_MAX / 2) { ans = min(ans, dp[final_mask][u] + dist[u][0]); } } return ans; }
7.3.3 贪心算法及其他
贪心算法是什么?
贪心算法是一种在每一步选择中都采取当前状态下最优(局部最优)的选择,从而希望导致结果是全局最优的算法策略。
在每一步选择中,都采取当前看起来最优的选项,不考虑未来后果,即“短视”地做出局部最优决策,寄希望于这些局部最优能导向全局最优。
它不回溯、不保存之前的选择、也不重新考虑之前的决定,是一种“目光短浅”但有时极其高效的算法策略。
【必问】双指针思想是什么?
lpos:数组最X侧,rpos:数组最X侧。
若左侧XX,++lpos,若右侧XX,--/++rpos,直到XX。
【必问】类接雨水的思想是什么?
额外设置两个数组:从左到右算累计XX、和从右到左累计XX。
结果 = XX(left2right[i], right2left[i])。
最后可能还要对结果再处理一下(例如求和)。
9999

被折叠的 条评论
为什么被折叠?



