13-算法与数据结构
常见排序算法的时间复杂度、空间复杂度对比?
原始问法:
- 常见排序算法的时间复杂度、空间复杂度对比?
来源题目:
SRC-13-131-411
面试先答
常见排序算法按实现方式可分为比较类排序和非比较类排序两大类。比较类排序包括冒泡、选择、插入、归并、快速、堆排序等,通过元素间两两比较决定顺序;非比较类排序包括计数排序、桶排序、基数排序等,不依赖比较操作。面试中最常考的是快速排序(平均 O(n log n),最坏 O(n²),不稳定)、归并排序(稳定 O(n log n),需 O(n) 额外空间)和堆排序(不稳定 O(n log n),原地排序)。选择排序算法需综合考虑数据规模、是否稳定、空间约束和预排序程度。没有最好的排序,只有最适合场景的排序。
核心结论
- 快速排序平均最快,但最坏 O(n²);归并排序保证 O(n log n) 且稳定;堆排序原地且 O(n log n)。
- 稳定排序能保持相等元素相对顺序,适用于多字段排序场景。
- 非比较类排序可达 O(n),但受数据范围和分布限制。
1. 是什么
排序算法按核心机制分为两类:
比较类排序:通过比较两个元素的大小关系来决定交换与否,时间复杂度下界为 O(n log n)。
非比较类排序:不通过比较,利用数据本身特性(如整数范围、位数)直接定位,时间复杂度可达 O(n)。
2. 为什么需要它
不同排序算法在不同场景下性能差异巨大。例如数据近乎有序时插入排序 O(n) 远快于快速排序 O(n log n);大规模数据外排序需归并排序;嵌入式系统空间有限需原地排序。
3. 底层原理与完整流程
| 算法 | 最好时间 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 原地性 |
|---|---|---|---|---|---|---|
| 冒泡排序 Bubble Sort | O(n) | O(n²) | O(n²) | O(1) | 稳定 | 原地 |
| 选择排序 Selection Sort | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 | 原地 |
| 插入排序 Insertion Sort | O(n) | O(n²) | O(n²) | O(1) | 稳定 | 原地 |
| 归并排序 Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 | 非原地 |
| 快速排序 Quick Sort | O(n log n) | O(n log n) | O(n²) | O(log n) | 不稳定 | 原地 |
| 堆排序 Heap Sort | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 | 原地 |
| 计数排序 Counting Sort | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 | 非原地 |
| 桶排序 Bucket Sort | O(n+k) | O(n+k) | O(n²) | O(n+k) | 稳定 | 非原地 |
| 基数排序 Radix Sort | O(d(n+k)) | O(d(n+k)) | O(d(n+k)) | O(n+k) | 稳定 | 非原地 |
稳定性定义:相等元素排序后相对顺序保持不变。
4. 怎么使用
// Java Arrays.sort() 对基本类型用快速排序,对对象用TimSort(归并)
int[] arr = {5, 1, 3, 2, 4};
Arrays.sort(arr); // 基本类型:双轴快速排序
List<Integer> list = Arrays.asList(5, 1, 3, 2, 4);
Collections.sort(list); // 对象:TimSort(稳定归并)
// 手动选择排序
// 数据近乎有序 → 插入排序 O(n)
// 大规模数据 → 快速排序 / 归并排序
// 需稳定排序 → 归并排序 / 计数排序
// 空间受限 → 堆排序 / 快速排序
5. 适用场景
- 快速排序:通用排序首选,平均性能最优,Cache 友好。
- 归并排序:外部排序、需要稳定性、链表排序。
- 堆排序:Top-K 问题、优先队列、原地排序且空间受限。
- 计数/桶/基数排序:数据范围可控的整数排序。
6. 不适用场景与替代方案
- 数据范围极大时计数/桶排序不可行,用比较类排序。
- 链表排序不宜用快速排序(随机访问慢),改用归并排序。
- 小规模数据(n<50)直接用插入排序。
7. 优缺点与技术取舍
- 快速排序:平均快但不稳定,最坏 O(n²) 可通过三数取中优化。
- 归并排序:稳定且最坏 O(n log n),但需 O(n) 额外空间。
- 堆排序:原地且稳定 O(n log n),但实际常数因子大于快排。
8. 常见问题及解决方案
- 快排最坏情况:当已有序且选首/尾为基准时 → 三数取中或随机选取基准。
- 归并排序 OOM:大数组递归过深 → 迭代式归并或外排序。
- 稳定性判断:看相等元素比较时是否交换。
9. 版本差异与实现边界
- Java
Arrays.sort(int[])使用双轴快速排序(Dual-Pivot Quicksort),由 Vladimir Yaroslavskiy 于 2009 年设计。 - Java
Arrays.sort(Object[])使用 TimSort(归并+插入的混合稳定排序)。 - C++
std::sort使用 Introsort(快排+堆排+插入排序的混合)。
10. 常见追问
- 为什么快排比堆排快? 快排 Cache 命中率高,常数因子更小。
- TimSort 如何工作? 识别已有 run(有序子序列),直接利用减少比较。
- 如何实现稳定的快排? 使用小于基准的放左边,等于基准的放中间,大于基准的放右边(三路快排)。
11. 易错点
- ❌ 说快速排序是"最快的" → 正确:平均最快,最坏可能 O(n²)。
- ❌ 说堆排序是"不稳定的" → 正确:堆化过程中可能打乱相等元素顺序。
- ❌ 归并排序是原地排序 → 正确:需要 O(n) 额外空间。
一句话总结
排序算法的选择本质是在时间复杂度、空间复杂度和稳定性三者之间根据场景做权衡。
二分查找的原理和实现?
原始问法:
- 二分查找的原理和实现?
来源题目:
SRC-13-131-412
面试先答
二分查找(Binary Search)是一种在有序数组中查找特定元素的高效算法,核心思想是每次将搜索区间缩小一半。基本前提是数组必须有序,时间复杂度 O(log n),空间复杂度 O(1)(迭代实现)。关键在于正确处理边界条件,特别是 mid 计算方式和循环终止条件。面试中需注意搜索左边界和搜索右边界两种变体,以及 mid = left + (right - left) / 2 防整数溢出的写法。
核心结论
- 二分查找必须在有序数组上进行,时间复杂度 O(log n)。
- 核心是循环不变量的维护:
[left, right]始终覆盖可能的目标范围。 - 有四种变体:精确匹配、左边界、右边界、范围查找。
1. 是什么
二分查找是一种分治策略的具体应用:每次取中间元素比较,根据比较结果排除一半的搜索空间。
2. 为什么需要它
在有序数组中,相比线性扫描 O(n),二分查找达到 O(log n),当 n=1000000 时仅需约 20 次比较。在数据库索引、字典查找、游戏中的范围查询等场景广泛应用。
3. 底层原理与完整流程
标准二分查找流程:
循环条件: left <= right
mid = left + (right - left) / 2 // 防溢出
如果 arr[mid] == target → 找到
如果 arr[mid] < target → left = mid + 1(去右半部分)
如果 arr[mid] > target → right = mid - 1(去左半部分)
循环结束: 未找到
循环不变量:每次迭代后,目标元素若存在,必然在新的 [left, right] 区间内。
搜索左边界流程:
循环条件: left < right
mid = left + (right - left) / 2 // 向下取整
如果 arr[mid] < target → left = mid + 1
否则 → right = mid
循环结束: left == right == 左边界
搜索右边界流程:
循环条件: left < right
mid = left + (right - left + 1) / 2 // 向上取整
如果 arr[mid] > target → right = mid - 1
否则 → left = mid
循环结束: left == right == 右边界
4. 怎么使用
// 标准二分查找
public static int binarySearch(int[] arr, int target) {
int left = 0, right = arr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) return mid;
else if (arr[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
// 搜索左边界(第一个 >= target 的位置)
public static int searchLeftBound(int[] arr, int target) {
int left = 0, right = arr.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (arr[mid] < target) left = mid + 1;
else right = mid;
}
return left;
}
// 搜索右边界(最后一个 <= target 的位置)
public static int searchRightBound(int[] arr, int target) {
int left = 0, right = arr.length;
while (left < right) {
int mid = left + (right - left + 1) / 2;
if (arr[mid] > target) right = mid - 1;
else left = mid;
}
return left;
}
5. 适用场景
- 有序数组的精确或范围查找。
- 时间约束查找(某个时间点之前/之后的所有事件)。
- 在答案空间有序的问题中做答案二分(如最小可行值、峰值查找)。
6. 不适用场景与替代方案
- 无序数据:先排序或使用哈希表 O(1) 查找。
- 频繁插入删除:有序数组维护成本高,改用跳表或平衡树。
- 链表:随机访问 O(n),二分查找不可行。
7. 优缺点与技术取舍
- 优点:O(log n) 高效,实现简单,无需额外空间。
- 缺点:仅适用于有序数据,数组随机访问前提。
8. 常见问题及解决方案
- 整数溢出:
mid = (left + right) / 2可能溢出 →left + (right - left) / 2。 - 死循环:边界更新错误 → 严格维护循环不变量,测试边界用例。
- 查找范围:需返回插入位置 → 搜索左边界模式。
9. 版本差异与实现边界
- Java
Arrays.binarySearch():返回负数表示未找到,-(insertion point) - 1。 - Python
bisect模块:bisect_left找左边界,bisect_right找右边界。
10. 常见追问
- 循环条件
left <= rightvsleft < right? 取决于搜索区间是闭区间[left, right]还是左闭右开[left, right)。 - 如何处理重复元素? 搜索左/右边界模式自然处理。
- 旋转数组如何查找? 需要判断有序区间。
11. 易错点
- ❌ 忘记检查数组是否有序 → 二分前提必须有序。
- ❌
mid计算溢出 → 使用left + (right - left) / 2。 - ❌ 左边界和右边界的
mid取整方向搞反 → 左边界向下取整,右边界向上取整。
一句话总结
二分查找的本质是在有序数据上通过循环不变量不断缩小搜索空间的分治算法。
动态规划的解题思路是什么?
原始问法:
- 动态规划的解题思路是什么?
来源题目:
SRC-13-131-413
面试先答
动态规划(Dynamic Programming,DP)是一种将复杂问题分解为重叠子问题并保存中间结果以避免重复计算的算法思想。核心有三要素:状态定义(确定 dp 数组含义)、状态转移方程(建立子问题间关系)和边界初始化。解题步骤一般为:1)定义状态;2)写出转移方程;3)确定初始值;4)确定遍历顺序;5)确定返回值。DP 适用于求最值、方案数、可行性等问题,关键在于正确识别「最优子结构」和「重叠子问题」。
核心结论
- 动态规划 = 重叠子问题 + 最优子结构。
- 五步法:定义状态 → 转移方程 → 初始化 → 遍历顺序 → 返回值。
- 空间优化:若仅依赖前一轮状态,可用滚动数组将 O(n) 空间降至 O(1)。
1. 是什么
动态规划是一种算法范式,将大问题拆成子问题,通过存储子问题的解来避免重复计算。与分治的区别在于子问题存在重叠。
2. 为什么需要它
许多问题存在大量重复计算子问题(如斐波那契数列递归版 O(2^n)),通过存储子问题结果可将指数级复杂度降至多项式级。
3. 底层原理与完整流程
DP 解题五步法:
Step 1 - 定义状态:确定 dp 数组的含义,即 dp[i] 或 dp[i][j] 代表什么。
Step 2 - 写出状态转移方程:根据最后一步的选择,将大问题分解为子问题。
Step 3 - 确定初始化:确定 dp 数组的初始值,如 dp[0]、dp[0][j]、dp[i][0]。
Step 4 - 确定遍历顺序:根据依赖关系确定 i、j 的遍历方向。
Step 5 - 确定返回值:返回 dp 数组中哪个值。
经典问题分类:
| 类型 | 典型问题 | 状态维度 |
|---|---|---|
| 线性 DP | 斐波那契、打家劫舍 | 一维 |
| 背包 DP | 0-1 背包、完全背包 | 二维 |
| 区间 DP | 最长回文子序列 | 二维(区间) |
| 状态压缩 DP | TSP、Hamilton 路径 | 位运算+DP |
| 树形 DP | 二叉树最大路径和 | 后序遍历 |
| 数位 DP | 统计区间内满足条件的数 | 数位拆分 |
4. 怎么使用
// 示例:0-1 背包问题
// 物品重量 w[], 价值 v[], 背包容量 C
// 求最大价值
public static int knapsack(int[] w, int[] v, int C) {
int n = w.length;
int[] dp = new int[C + 1]; // dp[j] 表示容量为 j 时的最大价值
for (int i = 0; i < n; i++) {
for (int j = C; j >= w[i]; j--) { // 逆序遍历保证每个物品只用一次
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[C];
}
// 示例:最长递增子序列 (LIS)
public static int lengthOfLIS(int[] nums) {
int[] dp = new int[nums.length]; // dp[i] 以 nums[i] 结尾的LIS长度
Arrays.fill(dp, 1); // 每个元素自身构成长度为1的子序列
for (int i = 1; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
int maxLen = 0;
for (int len : dp) maxLen = Math.max(maxLen, len);
return maxLen;
}
5. 适用场景
- 求最值问题:最大、最小、最优。
- 方案数问题:共有多少种方案。
- 可行性问题:能否到达、能否成功。
- 具有最优子结构和重叠子问题的场景。
6. 不适用场景与替代方案
- 无重叠子问题:分治法更合适。
- 状态空间爆炸:考虑贪心、启发式搜索。
- 实时性要求高:预处理 + 查表。
7. 优缺点与技术取舍
- 优点:将指数级复杂度降至多项式级。
- 缺点:状态定义和转移方程构建需要经验,部分高维 DP 空间消耗大。
8. 常见问题及解决方案
- 状态定义困难:从最后一步切入,问"最后一步做了什么,之前的状态是什么"。
- 遍历顺序错误:画转移依赖图确定遍历方向。
- 空间过大:滚动数组优化,如 0-1 背包从二维降至一维。
9. 版本差异与实现边界
- Java 无内置 DP 框架,需手动实现。
- 部分场景可用 BitSet 或位运算优化(状态压缩 DP)。
- Python 有
functools.lru_cache自动记忆化(本质是自顶向下 DP)。
10. 常见追问
- DP 和分治的区别? 分治子问题不重叠(如归并排序),DP 子问题重叠。
- 如何确定状态维度? 看题目有多少约束条件。
- 自顶向下 vs 自底向上? 自顶向下用递归+记忆化(易理解),自底向上用迭代(空间优)。
11. 易错点
- ❌ 初始化错误导致结果错误 → 仔细分析 base case。
- ❌ 遍历顺序导致数据复用错误 → 0-1 背包必须逆序。
- ❌ 将贪心问题误判为 DP → 贪心需验证正确性(如会议调度用贪心,0-1 背包用 DP)。
一句话总结
动态规划的核心是通过状态定义和转移方程将复杂问题分解为可复用的重叠子问题。
回溯算法的解题思路是什么?
原始问法:
- 回溯算法的解题思路是什么?
来源题目:
SRC-13-131-414
面试先答
回溯算法(Backtracking)是一种暴力搜索+剪枝的算法思想,核心是在决策树中从根节点出发,深度优先遍历所有可能的解,遇到不满足条件的分支就回溯(剪枝)。适用于组合问题、排列问题、子集问题等需要枚举所有解的场景。三要素:选择列表(每步可选的操作)、路径(已做出的选择序列)、终止条件(找到解或无可选操作)。关键在于剪枝优化,通过可行性剪枝和最优性剪枝大幅减少搜索空间。
核心结论
- 回溯 = DFS + 选择列表 + 路径 + 终止条件 + 剪枝。
- 三类典型问题:组合、排列、子集。
- 剪枝是回溯算法的核心优化手段。
1. 是什么
回溯是一种在解空间树上进行 DFS 遍历的搜索算法。每层代表一次决策,通过选择/撤销选择来探索所有可能的解。
2. 为什么需要它
当问题需要枚举所有可能的解,且解空间树规模巨大时,纯暴力 DFS 不可行。回溯通过剪枝减少不必要的搜索,是平衡了完备性和效率的选择。
3. 底层原理与完整流程
回溯算法模板:
void backtrack(路径, 选择列表):
if 终止条件:
记录解 / 返回
for 选择 in 选择列表:
if 剪枝条件: continue
做选择 (路径.add, 标记已使用)
backtrack(新路径, 新选择列表)
撤销选择 (路径.remove, 恢复未使用)
经典回溯问题分类:
| 问题类型 | 典型问题 | 关键剪枝 |
|---|---|---|
| 子集问题 | 求所有子集 | 每层选择一个数,下一层从下一个数开始 |
| 组合问题 | 组合总和、n皇后 | 同一层去重、可行性剪枝 |
| 排列问题 | 全排列、第k个排列 | used 数组标记、交换法 |
| 约束满足 | N皇后、数独 | 行列对角约束检查 |
完整示例 - N皇后问题:
在 n×n 棋盘上放置 n 个皇后,每行一个,互不攻击。
决策树深度: n(每行放一个皇后)
每层选择: n 列
剪枝: 同列、同对角线冲突
4. 怎么使用
// 示例:全排列
public static List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(nums, new boolean[nums.length], new ArrayList<>(), result);
return result;
}
private static void backtrack(int[] nums, boolean[] used,
List<Integer> path, List<List<Integer>> result) {
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue; // 剪枝:已使用的元素不再选
path.add(nums[i]); // 做选择
used[i] = true;
backtrack(nums, used, path, result);
used[i] = false; // 撤销选择
path.remove(path.size() - 1);
}
}
// 示例:N皇后
public static List<List<String>> solveNQueens(int n) {
List<List<String>> result = new ArrayList<>();
char[][] board = new char[n][n];
for (char[] row : board) Arrays.fill(row, '.');
backtrackQueen(board, 0, result);
return result;
}
private static void backtrackQueen(char[][] board, int row,
List<List<String>> result) {
if (row == board.length) {
result.add(construct(board));
return;
}
for (int col = 0; col < board.length; col++) {
if (isValidQueen(board, row, col)) { // 剪枝
board[row][col] = 'Q'; // 做选择
backtrackQueen(board, row + 1, result);
board[row][col] = '.'; // 撤销选择
}
}
}
5. 适用场景
- 需要枚举所有解的问题。
- 解空间树规模不超过百万级。
- 存在明显的剪枝条件可优化。
6. 不适用场景与替代方案
- 解空间过大且无有效剪枝:考虑启发式算法或近似算法。
- 只需一个解:可考虑贪心或直接构造。
- 需要最优解但不需枚举:DP 或贪心更高效。
7. 优缺点与技术取舍
- 优点:思路清晰,可枚举所有解。
- 缺点:时间复杂度指数级,严重依赖剪枝效率。
8. 常见问题及解决方案
- 超时:优化剪枝条件(可行性剪枝、对称性剪枝)。
- 重复解:使用
start参数控制起始位置或used数组标记。 - 大规模数据:结合位运算(N皇后可用整数位运算加速)。
9. 版本差异与实现边界
- Java 无内置回溯框架,需手动实现。
- 可使用
BitSet或int位运算优化状态标记。
10. 常见追问
- 回溯 vs DFS? 回溯是 DFS 的一种,增加了「选择列表」和「撤销选择」机制。
- 如何计算时间复杂度? 看决策树大小,如排列问题 O(n!),子集问题 O(2^n)。
- 如何优化? 剪枝(可行性、对称性)、位运算、记忆化。
11. 易错点
- ❌ 忘记撤销选择 → 结果集合错误。
- ❌ 剪枝条件不充分 → 超时。
- ❌ 混淆组合和排列的剪枝逻辑 → 组合用
start跳过,排列用used标记。
一句话总结
回溯算法通过深度优先遍历决策树并配合剪枝来高效枚举所有满足条件的解。
贪心算法的适用场景是什么?
原始问法:
- 贪心算法的适用场景是什么?
来源题目:
SRC-13-131-415
面试先答
贪心算法(Greedy Algorithm)是一种在每一步做出当前最优选择,期望最终得到全局最优解的算法策略。核心前提是问题满足贪心选择性质(局部最优能推导出全局最优)和最优子结构。适用场景包括:区间调度(按结束时间排序)、 Huffman 编码(频率优先合并)、Prim/Kruskal 最小生成树、Dijkstra 最短路径等。使用时需证明贪心正确性(如交换论证),因为并非所有问题都能用贪心解决(如 0-1 背包)。面试重点在于识别何时可用贪心、如何证明正确性。
核心结论
- 贪心 = 每步局部最优 + 贪心选择性质 + 最优子结构。
- 必须证明贪心正确性(交换论证、反证法)。
- 不满足贪心选择性质的问题需用 DP 替代。
1. 是什么
贪心算法在每个决策点做出不可撤销的最优选择,通过一系列局部最优选择逐步构造全局最优解。与 DP 的区别在于 DP 考虑所有子问题,贪心只考虑一个。
2. 为什么需要它
相比 DP 枚举所有可能后选最优,贪心每步只做一个选择,实现简单、效率高。当满足贪心条件时,是最优选择。
3. 底层原理与完整流程
贪心算法解题步骤:
- 将问题分解为子问题。
- 对每个子问题定义选择标准。
- 按选择标准做出局部最优选择。
- 迭代直至构造出完整解。
常见贪心场景:
| 场景 | 贪心策略 | 正确性证明 |
|---|---|---|
| 区间调度 | 按结束时间排序,选最早结束 | 交换论证 |
| Huffman 编码 | 频率优先合并 | 归纳法 |
| Dijkstra | 每次选未访问节点中距离最小的 | 三角不等式 |
| Kruskal/Prim | 选最小边/最小权邻接边 | 割性质 |
| 零钱兑换(指定面值) | 最大面值优先 | 需证明(如欧元/美元面值可用) |
经典错误用例:0-1 背包 → 按价值密度贪心会错。
4. 怎么使用
// 示例:区间调度(最多不重叠区间数)
public static int maxNonOverlappingIntervals(int[][] intervals) {
// Step 1: 按结束时间排序
Arrays.sort(intervals, (a, b) -> a[1] - b[1]);
int count = 1;
int end = intervals[0][1];
// Step 2: 贪心选择下一个不重叠且最早结束的
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] >= end) {
count++;
end = intervals[i][1];
}
}
return count;
}
// 示例:Huffman 编码
public static Map<Character, String> huffmanEncode(Map<Character, Integer> freq) {
PriorityQueue<HuffNode> pq = new PriorityQueue<>();
for (var entry : freq.entrySet()) {
pq.offer(new HuffNode(entry.getKey(), entry.getValue()));
}
while (pq.size() > 1) {
HuffNode left = pq.poll();
HuffNode right = pq.poll();
pq.offer(new HuffNode(null, left.weight + right.weight, left, right));
}
// 生成编码...
}
5. 适用场景
- 区间调度、任务分配等可证明贪心正确的问题。
- 图算法:最小生成树、最短路径。
- 编码压缩:Huffman 编码。
- 资源分配:满足单调性的场景。
6. 不适用场景与替代方案
- 0-1 背包 → 动态规划。
- 子集和问题 → 动态规划或回溯。
- 需要考虑所有可能的问题 → DP 或回溯。
7. 优缺点与技术取舍
- 优点:实现简单,效率高。
- 缺点:需证明正确性,应用范围受限于贪心性质。
8. 常见问题及解决方案
- 贪心正确性证明:交换论证法(假设存在更优解,交换后不更优或矛盾)。
- 贪心失败的识别:尝试反例,若贪心结果非最优则不可用。
9. 版本差异与实现边界
- 无语言特定差异,是算法思想层面的概念。
- Java
PriorityQueue可直接用于实现基于优先级的贪心。
10. 常见追问
- 贪心 vs DP? 贪心每步只考虑一个选择,DP 考虑所有选择后取最优。
- 如何快速判断能否用贪心? 先尝试设计贪心策略,再用交换论证法证明。
- Dijkstra 为什么贪心正确? 因为边权非负,一旦确定最短路径就不会被后续路径改善。
11. 易错点
- ❌ 不加证明地使用贪心 → 必须验证贪心选择性质。
- ❌ 将 0-1 背包按价值密度贪心 → 正确:0-1 背包需用 DP。
- ❌ 混淆贪心和动态规划 → 贪心不回溯,DP 考虑所有子问题。
一句话总结
贪心算法的核心是证明每一步的局部最优选择能推导出全局最优解。
BFS和DFS的区别是什么?
原始问法:
- BFS和DFS的区别是什么?
来源题目:
SRC-13-131-416
面试先答
BFS(广度优先搜索)和 DFS(深度优先搜索)是两种基本的图/树遍历方式。BFS 从起点开始逐层扩展,使用队列存储待访问节点,天然适合求最短路径(无权图);DFS 沿一条路径深入到底再回溯,使用栈(或递归)存储待访问节点,适合路径枚举和拓扑排序。核心区别在于搜索顺序:BFS 按层遍历保证最先到达的节点距离最近,DFS 按深度遍历能快速深入搜索空间。选择取决于问题需求:找最短路径用 BFS,找所有路径或拓扑排序用 DFS。
核心结论
- BFS 用队列,逐层扩展,天然保证最短路径;DFS 用栈/递归,深入优先,适合路径枚举。
- BFS 空间 O(n),DFS 空间 O(h)(h 为树高)。
- 无权图最短路径只能用 BFS,带权图需用 Dijkstra(贪心+优先队列)。
1. 是什么
- BFS(Breadth-First Search):广度优先搜索,按距离起点的层级逐层扩展。
- DFS(Depth-First Search):深度优先搜索,沿一条路径深入直到无法继续再回溯。
2. 为什么需要它
图和树的遍历是许多算法的基础:最短路径、连通性检测、拓扑排序、回溯法等。不同遍历方式适配不同问题。
3. 底层原理与完整流程
BFS 流程:
1. 初始化:queue = [start], visited = {start}
2. While queue 非空:
node = queue.poll()
访问 node
for neighbor in node 的邻居:
if neighbor 未访问:
visited.add(neighbor)
queue.offer(neighbor)
DFS 流程(递归):
1. 访问 node, 标记已访问
2. for neighbor in node 的邻居:
if neighbor 未访问:
DFS(neighbor)
完整对比:
| 维度 | BFS | DFS |
|---|---|---|
| 数据结构 | 队列(Queue) | 栈(Stack)或递归 |
| 搜索顺序 | 逐层扩展 | 深入优先 |
| 空间复杂度 | O(n) n=节点数 | O(h) h=树高 |
| 最短路径 | 天然保证(无权图) | 需额外计算 |
| 典型应用 | 最短路径、分层遍历、广度信息 | 拓扑排序、路径枚举、回溯 |
| 实现方式 | 迭代+队列 | 递归/迭代+栈 |
4. 怎么使用
// BFS 示例:二叉树层次遍历
public static List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size();
List<Integer> level = new ArrayList<>();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
result.add(level);
}
return result;
}
// DFS 示例:二叉树前序遍历(递归)
public static List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
dfsPreorder(root, result);
return result;
}
private static void dfsPreorder(TreeNode node, List<Integer> result) {
if (node == null) return;
result.add(node.val); // 访问根
dfsPreorder(node.left, result); // 遍历左
dfsPreorder(node.right, result); // 遍历右
}
// DFS 示例:迭代式(栈实现)
public static List<Integer> preorderIterative(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null) return result;
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
result.add(node.val);
if (node.right != null) stack.push(node.right); // 先压右
if (node.left != null) stack.push(node.left); // 再压左(先出)
}
return result;
}
5. 适用场景
- BFS:无权图最短路径、二叉树层次遍历、图的连通分量、广度信息收集。
- DFS:拓扑排序、关键路径、连通性检测、回溯、路径枚举、树的前/中/后序遍历。
6. 不适用场景与替代方案
- BFS 不适合:深度过大的树/图(空间消耗大);需路径信息的问题。
- DFS 不适合:求最短路径(无权图);广度优先信息收集。
7. 优缺点与技术取舍
- BFS 优点:天然保证最短路径;缺点:空间消耗大。
- DFS 优点:空间消耗小,天然支持回溯;缺点:不保证最短路径。
8. 常见问题及解决方案
- BFS 空间过大:双向 BFS 优化(从起点和终点同时出发,在中间汇合)。
- DFS 递归栈溢出:改用迭代式(栈)实现。
- 访问标记:必须在入队/入栈时标记,而非访问时标记,避免重复入队。
9. 版本差异与实现边界
- Java
ArrayDeque比LinkedList作为队列更高效。 - 递归 DFS 在数据量大时可能栈溢出,需改为迭代式。
- 无权图最短路径 BFS 时间 O(V+E),带权图用 Dijkstra O((V+E)log V)。
10. 常见追问
- BFS 如何求最短路径? 记录每个节点的前驱,最后回溯。
- 双向 BFS 原理? 从起点和终点同时 BFS,在中间相遇,时间复杂度从 O(b^d) 降到 O(b^(d/2))。
- DFS 如何求最短路径? DFS 本身不保证最短,需遍历所有路径取最小或改用 BFS。
11. 易错点
- ❌ BFS 访问标记时机错误 → 入队时就标记,而非出队时。
- ❌ DFS 迭代式中左右子节点压栈顺序搞反 → 先压右再压左,保证左先出。
- ❌ 混淆 BFS 和 DFS 的空间复杂度 → BFS O(n),DFS O(h)。
一句话总结
BFS 按层扩展保证最短路径,DFS 深入优先便于路径枚举,两者是互补的图遍历策略。
手写堆排序
原始问法:
- 手写堆排序
来源题目:
SRC-13-132-417
面试先答
堆排序(Heap Sort)利用完全二叉树的堆结构进行排序,核心分两步:建堆(将无序数组调整为大顶堆/小顶堆)和排序(依次取出堆顶元素与末尾交换,再调整堆)。堆是一棵满足特定性质的完全二叉树:大顶堆每个节点值 ≥ 子节点,小顶堆反之。堆排序不稳定,时间复杂度 O(n log n),空间复杂度 O(1) 原地排序。关键在于**heapify(堆化)**操作的正确实现,以及理解为什么建堆是 O(n) 而非 O(n log n)。
核心结论
- 堆排序 = 建堆 O(n) + 排序 O(n log n),总时间 O(n log n)。
- 空间 O(1),不稳定排序。
- 堆化(heapify)是核心操作,时间 O(log n)。
1. 是什么
堆排序是利用堆这种数据结构的选择排序算法变体。堆是完全二叉树,用数组存储无需指针:arr[i] 的左孩子 arr[2i+1],右孩子 arr[2i+2],父节点 arr[(i-1)/2]。
2. 为什么需要它
相比快速排序最坏 O(n²),堆排序保证 O(n log n) 且原地排序。在内存受限场景(嵌入式、大数据外排序)和优先队列实现中不可替代。
3. 底层原理与完整流程
堆化(Heapify):维护堆性质的核心操作。对节点 i,与其子节点比较并交换,逐级向下调整。
建堆:从最后一个非叶子节点开始,自底向上依次堆化。
排序:每次取堆顶(最大值)与末尾交换,缩小堆范围后对堆顶执行堆化。
为什么建堆是 O(n)?
建堆过程中,不同深度节点的堆化路径长度不同。叶子节点不需要堆化,越靠近底层的节点调整路径越短。数学求和:∑(n/2^(h+1) × h) = O(n)。
4. 怎么使用
public static void heapSort(int[] arr) {
int n = arr.length;
// Step 1: 建大顶堆(自底向上)
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// Step 2: 依次取堆顶放到末尾
for (int i = n - 1; i > 0; i--) {
swap(arr, 0, i); // 最大值移到末尾
heapify(arr, i, 0); // 对剩余元素重新堆化
}
}
private static void heapify(int[] arr, int heapSize, int root) {
int largest = root;
int left = 2 * root + 1;
int right = 2 * root + 2;
if (left < heapSize && arr[left] > arr[largest]) largest = left;
if (right < heapSize && arr[right] > arr[largest]) largest = right;
if (largest != root) {
swap(arr, root, largest);
heapify(arr, heapSize, largest); // 递归调整
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp;
}
复杂度分析:
- 建堆:O(n)
- 排序:O(n) × O(log n) = O(n log n)
- 总体:O(n log n),最坏仍 O(n log n)
- 空间:O(1),原地排序
5. 适用场景
- 内存受限的大规模排序。
- Top-K 问题(用小顶堆)。
- 优先队列实现。
- 嵌入式系统。
6. 不适用场景与替代方案
- 需要稳定排序 → 归并排序。
- 数据量小 → 快速排序(常数因子更小)。
- 频繁插入删除 → 平衡树或跳表。
7. 优缺点与技术取舍
- 优点:最坏仍 O(n log n),原地排序。
- 缺点:不稳定,实际常数因子大于快排。
8. 常见问题及解决方案
- 建堆为什么自底向上? 自顶向下是 O(n log n),自底向上才是 O(n)。
- 堆化是递归还是迭代? 都可以,递归简洁,迭代无栈开销。
- 如何实现小顶堆? 将比较方向反转即可。
9. 版本差异与实现边界
- Java
PriorityQueue默认是小顶堆,可通过Comparator.reverseOrder()转大顶堆。 - Java 中可使用
Arrays.sort()但不使用堆排序实现。
10. 常见追问
- 堆排序 vs 快速排序? 快排平均快但最坏 O(n²),堆排最坏 O(n log n) 但常数因子大。
- 如何实现优先队列? 堆结构,插入 O(log n),取最大值 O(1)。
- Top-K 问题用堆如何解? 维护大小为 K 的小顶堆,时间 O(n log K)。
11. 易错点
- ❌ 建堆从根节点开始向下 → 正确:从最后一个非叶子节点开始。
- ❌ 建堆时间复杂度写成 O(n log n) → 正确:O(n)。
- ❌ 堆排序是稳定的 → 正确:不稳定。
一句话总结
堆排序通过建堆 + 逐步提取堆顶实现 O(n log n) 的原地排序,是优先队列和 Top-K 问题的核心基础。
手写快速排序
原始问法:
- 手写快速排序
来源题目:
SRC-13-132-418
面试先答
快速排序(Quick Sort)采用分治策略,每轮选择一个基准元素(pivot),通过**分区操作(partition)**将数组分为「小于 pivot」和「大于 pivot」两部分,再递归排序左右两部分。核心是 partition 算法的实现。快速排序平均 O(n log n),最坏 O(n²)(已有序且选首/尾为基准时),空间 O(log n)(递归栈),不稳定排序。优化手段:三数取中选基准、随机基准、小区间改用插入排序。Java Arrays.sort(int[]) 使用双轴快速排序。
核心结论
- 快排 = 分治 + partition,平均 O(n log n),最坏 O(n²)。
- 核心是 partition 算法:Lomuto 或 Hoare 分区。
- 优化:三数取中、随机基准、小区间用插入排序。
1. 是什么
快速排序是一种分治排序算法。每轮通过 partition 将数组分为两部分,递归处理。关键在 partition:选择基准并将小于基准的放左边,大于基准的放右边。
2. 为什么需要它
快排是实际应用中最快的通用排序算法,Cache 友好、常数因子小。在大数据量下性能显著优于堆排序和归并排序。
3. 底层原理与完整流程
Lomuto 分区(单指针扫描交换):
选择 pivot = arr[high]
i = low - 1
for j = low to high - 1:
if arr[j] <= pivot:
i++
swap arr[i], arr[j]
swap arr[i+1], arr[high]
返回 i+1 (pivot 最终位置)
Hoare 分区(双指针相向扫描):
选择 pivot = arr[low]
i = low + 1, j = high
循环:
while i <= high and arr[i] < pivot: i++
while j > low and arr[j] > pivot: j--
if i < j: swap arr[i], arr[j]
else: break
swap arr[low], arr[j]
返回 j
双轴快速排序(Java 默认):选择两个基准将数组分为三部分。
4. 怎么使用
public static void quickSort(int[] arr) {
quickSort(arr, 0, arr.length - 1);
}
private static void quickSort(int[] arr, int low, int high) {
if (low >= high) return;
if (high - low < 16) {
insertionSort(arr, low, high); // 小区间用插入排序优化
return;
}
int pivotIndex = partition(arr, low, high);
quickSort(arr, low, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, high);
}
private static int partition(int[] arr, int low, int high) {
// 三数取中选择基准
int mid = low + (high - low) / 2;
if (arr[low] > arr[high]) swap(arr, low, high);
if (arr[mid] > arr[high]) swap(arr, mid, high);
if (arr[mid] > arr[low]) swap(arr, mid, low);
// 基准放到 high-1 位置
swap(arr, mid, high);
int pivot = arr[high];
int i = low;
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
swap(arr, i++, j);
}
}
swap(arr, i, high);
return i;
}
private static void insertionSort(int[] arr, int low, int high) {
for (int i = low + 1; i <= high; i++) {
int key = arr[i];
int j = i - 1;
while (j >= low && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp;
}
复杂度分析:
- 平均时间:O(n log n)
- 最坏时间:O(n²)(已有序且选首/尾为基准)
- 最好时间:O(n log n)
- 空间:O(log n)(递归栈深度)
5. 适用场景
- 通用排序首选。
- 大数据量(千万级)排序。
- 数据库索引构建。
6. 不适用场景与替代方案
- 需要稳定排序 → 归并排序。
- 已有序数据 → 插入排序。
- 数据范围有限的整数 → 计数/桶/基数排序。
7. 优缺点与技术取舍
- 优点:平均最快,Cache 友好,原地排序。
- 缺点:不稳定,最坏 O(n²),递归栈开销。
8. 常见问题及解决方案
- 最坏情况优化:三数取中或随机选择基准。
- 栈溢出:大数据量改用迭代式(显式栈)。
- 重复元素多:使用三路快排(将等于基准的元素集中到中间)。
9. 版本差异与实现边界
- Java
Arrays.sort(int[]):双轴快速排序(Dual-Pivot Quicksort)。 - Java
Arrays.sort(Object[]):TimSort(归并+插入)。 - C++
std::sort:Introsort(快排+堆排+插入排序的混合)。
10. 常见追问
- 快排为什么快? Cache 友好(比较和交换在连续内存),常数因子小。
- 三路快排是什么? 将数组分为小于、等于、大于基准三部分,处理大量重复元素。
- 快排和归并排序如何选择? 快排平均更快且原地,归并稳定且保证 O(n log n)。
11. 易错点
- ❌ 基准选择不做优化 → 最坏 O(n²)。
- ❌ 快排是稳定的 → 正确:不稳定。
- ❌ 空间复杂度 O(1) → 正确:O(log n)(递归栈)。
一句话总结
快速排序通过分治和分区操作实现平均 O(n log n) 的高效不稳定排序,是实际应用中最快的通用排序算法。
手写归并排序
原始问法:
- 手写归并排序
来源题目:
SRC-13-132-419
面试先答
归并排序(Merge Sort)采用分治策略,将数组不断对半分割直到子数组长度为 1,然后将有序子数组两两合并。核心是归并操作:利用两个已排序子数组的双指针高效合并。归并排序稳定,时间复杂度 O(n log n)(最坏仍 O(n log n)),空间复杂度 O(n),非原地排序。相比快排,归并排序保证最坏性能且稳定,但需额外 O(n) 空间。是 Java 对象数组排序(TimSort)的基础。
核心结论
- 归并排序 = 分割 + 合并,稳定,最坏仍 O(n log n)。
- 空间 O(n),非原地。
- 核心是归并操作:双指针合并两个有序数组。
1. 是什么
归并排序是一种分治排序算法。将数组递归分割为两半,分别排序后合并。关键在于利用两个已排序数组的特性高效合并。
2. 为什么需要它
归并排序的稳定性保证相等元素相对顺序不变,适用于多字段排序(如先按姓名再按分数)。最坏情况仍 O(n log n),适合对排序稳定性和可靠性有要求的场景。
3. 底层原理与完整流程
分割阶段:将数组从中间分为两半,递归排序左右两部分。
合并阶段:使用两个指针分别指向两个子数组的起始位置,比较较小的先放入结果数组。
时间复杂度:每层合并 O(n),共 log n 层,总 O(n log n)。
4. 怎么使用
public static void mergeSort(int[] arr) {
int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);
}
private static void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left >= right) return;
int mid = left + (right - left) / 2;
mergeSort(arr, left, mid, temp);
mergeSort(arr, mid + 1, right, temp);
merge(arr, left, mid, right, temp);
}
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left, j = mid + 1, k = left;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++]; // 注意:<= 保证稳定性
} else {
temp[k++] = arr[j++];
}
}
while (i <= mid) temp[k++] = arr[i++];
while (j <= right) temp[k++] = arr[j++];
for (int idx = left; idx <= right; idx++) {
arr[idx] = temp[idx];
}
}
复杂度分析:
- 时间:最好/平均/最坏均 O(n log n)
- 空间:O(n)(临时数组)
- 稳定性:稳定
5. 适用场景
- 需要稳定排序。
- 外部排序(磁盘排序)。
- 链表排序。
- 多字段复合排序。
6. 不适用场景与替代方案
- 内存受限 → 堆排序。
- 基本类型数组 → 快速排序(更快)。
7. 优缺点与技术取舍
- 优点:稳定,最坏仍 O(n log n)。
- 缺点:需 O(n) 额外空间,非原地。
8. 常见问题及解决方案
- 空间优化:可实现原地归并但复杂度上升,通常接受 O(n) 空间。
- 链表归并排序:天然 O(1) 空间(除递归栈)。
- 并行归并:可并行排序左右两部分。
9. 版本差异与实现边界
- Java
TimSort基于归并+插入排序的混合,识别已有 run 优化。 - 外部归并排序用于大数据量磁盘排序。
10. 常见追问
- 归并排序如何稳定? 合并时
<=保证相等元素左边的先输出。 - 外部排序如何实现? 多路归并:将数据分块排序后写回磁盘,再多路归并。
- 归并 vs 快排? 归并稳定且最坏 O(n log n),快排平均更快且原地。
11. 易错点
- ❌ 合并时用
<而非<=→ 破坏稳定性。 - ❌ 归并排序是原地的 → 正确:需 O(n) 额外空间。
- ❌ 时间复杂度最坏是 O(n²) → 正确:最坏仍 O(n log n)。
一句话总结
归并排序通过分治分割和双指针合并实现稳定的 O(n log n) 排序,牺牲空间换取稳定性和最坏性能保证。
反转链表(K个一组翻转链表)
原始问法:
- 反转链表(K个一组翻转链表)
来源题目:
SRC-13-132-420
面试先答
链表反转是高频面试题,分三个层次:单链表整体反转(迭代/递归)、两两交换和K个一组翻转。核心技巧是掌握迭代法的三个指针(prev, curr, nextTemp)和递归法的从尾部处理思路。K个一组翻转需要先判断剩余节点是否 ≥ K,不足则保持原样。关键在于正确处理每组之间的连接和头指针的更新。时间复杂度 O(n),空间复杂度迭代 O(1),递归 O(n)(递归栈)。
核心结论
- 单链表反转:迭代法 O(1) 空间,递归法 O(n) 空间。
- K个一组翻转:需分组处理,不足一组的保持原样。
- 核心技巧:三指针法和递归从尾部处理。
1. 是什么
链表反转是将链表的指针方向反转,使原链表的尾节点成为新的头节点。K个一组翻转是每 K 个节点为一组进行反转,不足 K 个的尾部保持原样。
2. 为什么需要它
链表反转是链表操作的基础,考查对指针操作的熟练程度和边界条件处理。在实际工程中用于反向遍历、回文链表判断等场景。
3. 底层原理与完整流程
迭代法(三个指针):
prev = null, curr = head
while curr != null:
nextTemp = curr.next // 保存下一个节点
curr.next = prev // 反转指针
prev = curr // prev 前移
curr = nextTemp // curr 前移
return prev
递归法:
reverseList(head):
if head == null or head.next == null: return head
newHead = reverseList(head.next) // 先递归到尾部
head.next.next = head // 反转指针
head.next = null // 断开原指针
return newHead
4. 怎么使用
// 单链表整体反转 - 迭代
public static ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode nextTemp = curr.next;
curr.next = prev;
prev = curr;
curr = nextTemp;
}
return prev;
}
// 单链表整体反转 - 递归
public static ListNode reverseListRecursive(ListNode head) {
if (head == null || head.next == null) return head;
ListNode newHead = reverseListRecursive(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
// K个一组翻转
public static ListNode reverseKGroup(ListNode head, int k) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode pre = dummy;
while (pre.next != null) {
// 找到每组的尾节点
ListNode end = pre;
for (int i = 0; i < k && end != null; i++) end = end.next;
if (end == null) break; // 不足一组
ListNode start = pre.next;
ListNode nextGroup = end.next; // 保存下一组起点
// 反转本组
end.next = null;
pre.next = reverseList(start);
start.next = nextGroup;
pre = start; // pre 移到本组的尾(反转后的尾)
}
return dummy.next;
}
class ListNode {
int val;
ListNode next;
ListNode(int val) { this.val = val; }
}
5. 适用场景
- 链表反向遍历。
- 回文链表判断(反转后半段)。
- 链表排序操作中间步骤。
6. 不适用场景与替代方案
- 频繁随机访问 → 数组。
- 双向链表已有反向指针,不需要反转。
7. 优缺点与技术取舍
- 优点:O(n) 时间,迭代 O(1) 空间。
- 缺点:递归版空间 O(n),可能栈溢出。
8. 常见问题及解决方案
- 指针丢失:必须先保存
next再改指针。 - K个一组的边界:不足 K 的尾部保持不变。
- 递归栈溢出:改用迭代式。
9. 版本差异与实现边界
- Java 无内置链表反转 API。
- 注意
null检查和空链表处理。
10. 常见追问
- 递归 vs 迭代? 迭代 O(1) 空间更优,递归代码简洁但栈空间 O(n)。
- 如何判断回文链表? 快慢指针找中点,反转后半段,双指针比较。
- 两两交换如何实现? 复用 K 个一组的代码,k=2。
11. 易错点
- ❌ 忘记保存 next 指针 → 链表断开。
- ❌ K个一组时未处理不足 K 的尾部 → 部分反转。
- ❌ 递归版返回 head 而非 newHead → 结果错误。
一句话总结
链表反转的核心是正确操作指针方向,迭代版用三指针,递归版从尾部处理,K个一组需分组连接。
反转双向链表
原始问法:
- 反转双向链表
来源题目:
SRC-13-132-421
面试先答
双向链表反转比单链表更简单,因为每个节点有 prev 和 next 两个指针。核心操作是将每个节点的 prev 和 next 指针交换,然后遍历整个链表。相比单链表需要保存前驱节点,双向链表天然具备前驱指针,代码更简洁。注意最后需要更新头节点指针。时间复杂度 O(n),空间复杂度 O(1)。
核心结论
- 双向链表反转只需交换
prev和next指针。 - 时间 O(n),空间 O(1)。
- 比单链表反转更简单。
1. 是什么
双向链表每个节点有 prev(前驱)和 next(后继)两个指针。反转就是交换每个节点的这两个指针。
2. 为什么需要它
双向链表的反转常用于:双向遍历、LRU 缓存实现、双向队列操作。掌握双向链表的反转能加深对指针操作的理解。
3. 底层原理与完整流程
curr = head
while curr != null:
swap curr.prev and curr.next
curr = curr.prev (注意:交换后 prev 是原 next)
return newHead (原尾节点)
4. 怎么使用
public static DoublyListNode reverseDoublyList(DoublyListNode head) {
DoublyListNode curr = head;
DoublyListNode newHead = null;
while (curr != null) {
// 交换 prev 和 next
DoublyListNode temp = curr.prev;
curr.prev = curr.next;
curr.next = temp;
// 下一个节点是交换后的 prev(即原 next)
newHead = curr;
curr = curr.prev;
}
return newHead; // 原尾节点成为新头节点
}
// 更简洁的写法
public static DoublyListNode reverseDoublyListV2(DoublyListNode head) {
DoublyListNode curr = head;
while (curr != null) {
DoublyListNode next = curr.next;
curr.next = curr.prev;
curr.prev = next;
if (curr.prev == null) head = curr;
curr = curr.prev;
}
return head;
}
class DoublyListNode {
int val;
DoublyListNode prev;
DoublyListNode next;
DoublyListNode(int val) { this.val = val; }
}
5. 适用场景
- LRU 缓存(双向链表+哈希表)。
- 双向遍历。
- 文本编辑器的 undo/redo。
6. 不适用场景与替代方案
- 单向链表:没有 prev 指针,需保存前驱节点。
- 频繁随机访问:数组更高效。
7. 优缺点与技术取舍
- 优点:实现简单,O(1) 空间。
- 缺点:每个节点多一个指针,空间略增。
8. 常见问题及解决方案
- 头指针更新:最后一个节点(原尾)是新头,需返回它。
- 空链表:直接返回 null。
- 单节点链表:反转后仍是自身。
9. 版本差异与实现边界
- Java
LinkedList内部是双向链表,可通过descendingIterator()反向遍历。 LinkedList反转可使用Collections.reverse(list)。
10. 常见追问
- 双向链表 vs 单链表反转? 双向链表无需前驱节点,交换指针即可。
- LRU 缓存如何实现? 哈希表 + 双向链表,get/put 均 O(1)。
- 如何从尾部开始遍历? 找到尾节点后用 prev 指针向前。
11. 易错点
- ❌ 忘记更新头指针 → 返回错误。
- ❌ 交换后继续用 curr.next → 死循环或断链。
- ❌ 未处理空链表 → 空指针异常。
一句话总结
双向链表反转只需逐个交换 prev 和 next 指针,比单链表更简洁,是 LRU 缓存等高级数据结构的基础操作。
用栈实现队列(线程安全)
原始问法:
- 用栈实现队列(线程安全)
来源题目:
SRC-13-132-422
面试先答
用两个栈实现队列是经典题目,核心思想是一个栈用于入队(push),另一个栈用于出队(pop/peek)。当出队栈为空时,将入队栈所有元素依次弹出压入出队栈,这样就实现了 FIFO 顺序。线程安全版本需要在入栈和出栈转换时加锁。核心操作:push O(1) 均摊,pop 均摊 O(1)。实现需注意:转换时必须一次性全部转移,不能部分转移,否则会破坏 FIFO 顺序。
核心结论
- 双栈实现队列:inStack(入队)+ outStack(出队)。
- 均摊时间 O(1):每个元素最多被转移一次。
- 线程安全:用 synchronized 或 ReentrantLock 保护栈操作。
1. 是什么
用两个栈实现 FIFO 队列:一个栈用于接收新元素(保持 LIFO),另一个栈在出队时将入栈元素依次弹出放入(反转后实现 FIFO)。
2. 为什么需要它
考查对栈和队列数据结构的理解,以及在约束条件(只能用栈)下的创新能力。线程安全版本考查并发编程知识。
3. 底层原理与完整流程
push 操作:直接压入 inStack。
pop 操作:如果 outStack 为空,将 inStack 全部弹出压入 outStack;然后从 outStack 弹出。
peek 操作:与 pop 类似但不弹出。
转移时机:仅在 outStack 为空时才转移,保证均摊 O(1)。
4. 怎么使用
import java.util.Stack;
import java.util.concurrent.locks.ReentrantLock;
public class MyQueue<T> {
private final Stack<T> inStack = new Stack<>();
private final Stack<T> outStack = new Stack<>();
private final ReentrantLock lock = new ReentrantLock();
// 入队
public void push(T x) {
lock.lock();
try {
inStack.push(x);
} finally {
lock.unlock();
}
}
// 出队
public T pop() {
lock.lock();
try {
if (outStack.isEmpty()) {
while (!inStack.isEmpty()) {
outStack.push(inStack.pop());
}
}
return outStack.pop();
} finally {
lock.unlock();
}
}
// 查看队首
public T peek() {
lock.lock();
try {
if (outStack.isEmpty()) {
while (!inStack.isEmpty()) {
outStack.push(inStack.pop());
}
}
return outStack.peek();
} finally {
lock.unlock();
}
}
// 是否为空
public boolean empty() {
lock.lock();
try {
return inStack.isEmpty() && outStack.isEmpty();
} finally {
lock.unlock();
}
}
}
5. 适用场景
- 线程安全的队列实现。
- 面试中的数据结构设计题。
- 需要用栈模拟队列行为的场景。
6. 不适用场景与替代方案
- 高性能队列 →
ArrayDeque或LinkedList。 - 线程安全需求高 →
BlockingQueue实现。
7. 优缺点与技术取舍
- 优点:均摊 O(1),实现简单。
- 缺点:最坏情况 pop 为 O(n)(首次转移时)。
8. 常见问题及解决方案
- 为什么均摊 O(1)? 每个元素最多被转移一次。
- 线程安全问题:synchronized 或 ReentrantLock,注意转移过程的原子性。
- 空队列 pop:应抛异常或返回特殊值。
9. 版本差异与实现边界
- Java
Stack基于Vector,性能略差,可用Deque替代。 ArrayDeque本身可作为队列使用。
10. 常见追问
- 为什么两个栈够? 一个栈反转一次,两个栈恰好还原 FIFO 顺序。
- 均摊时间如何分析? 每个元素最多进栈一次、转移一次、出栈一次,O(1) 均摊。
- 用一个栈实现队列? 需要递归,pop 时弹出所有元素到临时变量,再压回。
11. 易错点
- ❌ 转移时部分转移 → 破坏 FIFO。
- ❌ 转移过程中不加锁 → 并发问题。
- ❌ peek 不转移直接返回 → 返回错误值。
一句话总结
双栈队列通过入栈接收元素、出栈时转移反转实现均摊 O(1) 的线程安全 FIFO 队列。
LRU缓存设计
原始问法:
- LRU缓存设计
来源题目:
SRC-13-132-423
面试先答
LRU(Least Recently Used,最近最少使用)缓存是一种淘汰最近最少使用数据的缓存策略。核心要求是 get 和 put 操作均 O(1)。实现方案是哈希表 + 双向链表:哈希表提供 O(1) 的查找,双向链表维护访问顺序(头部最近,尾部最久未使用)。get 时将节点移到头部,put 时在头部插入,容量满时删除尾部节点。Java 中 LinkedHashMap 已内置 LRU 实现,只需重写 removeEldestEntry 方法。
核心结论
- LRU = 哈希表(O(1) 查找)+ 双向链表(O(1) 顺序调整)。
- get/put 均 O(1)。
- 核心:哈希表存 key→Node 映射,双向链表存访问顺序。
1. 是什么
LRU 缓存是一种内存管理策略,当缓存满时,优先淘汰最久未使用的数据。常见替代策略还有 LFU(最不经常使用)和 FIFO(先进先出)。
2. 为什么需要它
在内存有限的系统中(数据库缓存、操作系统页面置换、Web 缓存),需要淘汰策略来保留最有价值的数据。LRU 基于"最近使用的数据更可能再次使用"的局部性原理。
3. 底层原理与完整流程
数据结构:
HashMap<Integer, Node>:key 到链表节点的映射。DoublyLinkedList:维护访问顺序,头部最近访问,尾部最久未访问。
get 流程:
- 从 HashMap 查找 key。
- 找到则将节点移到链表头部。
- 返回值;未找到返回 -1。
put 流程:
- 如果 key 已存在:更新值,移到头部。
- 如果 key 不存在:创建节点,加到头部。
- 容量满时:删除尾部节点,从 HashMap 移除。
4. 怎么使用
import java.util.HashMap;
public class LRUCache {
static class Node {
int key, value;
Node prev, next;
Node(int k, int v) { key = k; value = v; }
}
private final int capacity;
private final HashMap<Integer, Node> map;
private final Node head, tail; // 伪头和伪尾
public LRUCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>();
this.head = new Node(-1, -1); // 伪头
this.tail = new Node(-1, -1); // 伪尾
head.next = tail;
tail.prev = head;
}
public int get(int key) {
if (!map.containsKey(key)) return -1;
Node node = map.get(key);
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
if (map.containsKey(key)) {
Node node = map.get(key);
node.value = value;
moveToHead(node);
} else {
Node newNode = new Node(key, value);
map.put(key, newNode);
addToHead(newNode);
if (map.size() > capacity) {
Node tailNode = removeTail();
map.remove(tailNode.key);
}
}
}
private void addToHead(Node node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
private Node removeTail() {
Node res = tail.prev;
removeNode(res);
return res;
}
}
Java 内置实现:
import java.util.LinkedHashMap;
import java.util.Map;
public class LRUCacheJava {
private final LinkedHashMap<Integer, Integer> cache;
private final int capacity;
public LRUCacheJava(int capacity) {
this.capacity = capacity;
this.cache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > capacity;
}
};
}
public int get(int key) {
return cache.getOrDefault(key, -1);
}
public void put(int key, int value) {
cache.put(key, value);
}
}
5. 适用场景
- 数据库缓存(Buffer Pool)。
- 操作系统页面置换。
- Web 浏览器缓存。
- API 速率限制。
6. 不适用场景与替代方案
- 需要访问频率信息 → LFU 缓存。
- 线程安全需求 →
ConcurrentHashMap+ 并发链表或加锁。
7. 优缺点与技术取舍
- 优点:get/put 均 O(1),实现简单。
- 缺点:维护双向链表增加内存开销,并发场景需要额外处理。
8. 常见问题及解决方案
- 并发安全:使用
ReentrantLock或ConcurrentHashMap+ 原子操作。 - 缓存穿透:空值也缓存或使用布隆过滤器。
- 缓存一致性:缓存与数据库的一致性策略(Cache-Aside、Write-Through 等)。
9. 版本差异与实现边界
- Java
LinkedHashMap第三个参数accessOrder=true启用访问顺序模式。 - Redis 的
allkeys-lru淘汰策略。 - Caffeine(高性能缓存库)的 LRU 实现。
10. 常见追问
- LRU vs LFU? LRU 基于最近访问,LFU 基于访问频率。LFU 更适合热点稳定场景。
- 为什么用双向链表? 需要 O(1) 删除任意节点。
- 如何实现 LFU? 需要记录频率,可用多个 LRU 链表实现。
11. 易错点
- ❌ 只删除节点未从 HashMap 移除 → 内存泄漏。
- ❌ 容量满时未判断 → 溢出。
- ❌ get 时未移动节点 → LRU 逻辑失效。
一句话总结
LRU 缓存通过哈希表 + 双向链表实现 O(1) 的 get/put,基于局部性原理淘汰最久未使用的数据。
合并两个有序数组
原始问法:
- 合并两个有序数组
来源题目:
SRC-13-132-424
面试先答
合并两个有序数组是双指针的典型应用。LeetCode 88 题是经典变体:给定两个有序整数数组 nums1 和 nums2,将 nums2 合并到 nums1 中,使 nums1 成为有序数组。关键在从后向前合并:因为 nums1 尾部有空位(长度为 m+n),从后向前合并可避免覆盖 nums1 中未处理的元素。时间复杂度 O(m+n),空间复杂度 O(1)。
核心结论
- 双指针从后向前合并,O(m+n) 时间,O(1) 空间。
- 关键:从尾部开始填充,避免覆盖未处理元素。
- 边界处理:nums2 剩余元素需全部拷贝。
1. 是什么
将两个已排序数组合并为一个有序数组。要求原地合并时,从后向前处理避免数据覆盖。
2. 为什么需要它
双指针合并是归并排序的核心操作,也是数据库索引合并、多路归并外排序的基础。
3. 底层原理与完整流程
从后向前合并流程:
三个指针:
p1 = m - 1 (nums1 的有效末尾)
p2 = n - 1 (nums2 的末尾)
p = m + n - 1 (nums1 的填充末尾)
循环:
if p1 >= 0 and (p2 < 0 or nums1[p1] > nums2[p2]):
nums1[p] = nums1[p1]
p1--
else:
nums1[p] = nums2[p2]
p2--
p--
4. 怎么使用
public static void merge(int[] nums1, int m, int[] nums2, int n) {
int p1 = m - 1;
int p2 = n - 1;
int p = m + n - 1;
while (p1 >= 0 || p2 >= 0) {
if (p1 >= 0 && (p2 < 0 || nums1[p1] > nums2[p2])) {
nums1[p] = nums1[p1];
p1--;
} else {
nums1[p] = nums2[p2];
p2--;
}
p--;
}
}
// 合并两个有序数组到新数组
public static int[] mergeToNew(int[] a, int[] b) {
int[] result = new int[a.length + b.length];
int i = 0, j = 0, k = 0;
while (i < a.length && j < b.length) {
if (a[i] <= b[j]) {
result[k++] = a[i++];
} else {
result[k++] = b[j++];
}
}
while (i < a.length) result[k++] = a[i++];
while (j < b.length) result[k++] = b[j++];
return result;
}
5. 适用场景
- 归并排序的核心步骤。
- 多路归并外排序。
- 区间合并。
6. 不适用场景与替代方案
- 无序数组:先排序再合并。
- 大量小数组:考虑归并排序。
7. 优缺点与技术取舍
- 优点:O(m+n) 时间,原地版本 O(1) 空间。
- 缺点:原地合并需从后向前,逻辑略复杂。
8. 常见问题及解决方案
- 正向合并的问题:会覆盖 nums1 中未处理的有效元素。
- nums2 为空:直接返回。
- nums1 有效元素处理完:将 nums2 剩余元素全部拷贝。
9. 版本差异与实现边界
- Java 无内置合并 API,但
Arrays.copyOf+System.arraycopy可辅助。
10. 常见追问
- 为什么从后向前? 避免覆盖 nums1 未处理的有效元素。
- 时间复杂度? O(m+n),每个元素只需比较一次。
- 如何合并 k 个有序数组? 最小堆,时间 O(N log k)。
11. 易错点
- ❌ 从前向后合并 → 覆盖有效元素。
- ❌ 循环条件错误 → 遗漏元素。
- ❌ nums2 剩余未处理 → 结果不完整。
一句话总结
合并有序数组的核心是从后向前双指针,实现 O(m+n) 时间 O(1) 空间的原地合并。
升序数组找 target 的开始位置和结束位置
原始问法:
- 升序数组找 target 的开始位置和结束位置
来源题目:
SRC-13-132-425
面试先答
在有序数组中查找目标值的开始和结束位置(LeetCode 34),核心是两次二分查找:一次找左边界(第一个等于 target 的位置),一次找右边界(最后一个等于 target 的位置)。关键在于正确实现边界收缩逻辑:找左边界时,arr[mid] >= target 时收缩右边界;找右边界时,arr[mid] <= target 时收缩左边界。时间复杂度 O(log n)。如果用线性扫描会退化到 O(n)。
核心结论
- 两次二分查找:左边界+右边界,O(log n)。
- 左边界:
arr[mid] >= target时right = mid。 - 右边界:
arr[mid] <= target时left = mid。
1. 是什么
给定一个升序数组和目标值,找出目标值在数组中的第一个和最后一个位置。要求时间复杂度 O(log n)。
2. 为什么需要它
在实际系统中,如时间范围查询(查找某个时间段内的所有记录)、分数区间统计等场景,需要高效定位范围边界。
3. 底层原理与完整流程
找左边界:[left, right) 区间,arr[mid] >= target 时 right = mid,否则 left = mid + 1。
找右边界:[left, right) 区间,arr[mid] <= target 时 left = mid + 1,否则 right = mid。
统一模板:使用左闭右开区间 [left, right)。
4. 怎么使用
public static int[] searchRange(int[] nums, int target) {
int left = findBound(nums, target, true);
int right = findBound(nums, target, false);
return new int[]{left, right};
}
// isLeft: true 找左边界,false 找右边界
private static int findBound(int[] nums, int target, boolean isLeft) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
if (isLeft) {
right = mid; // 收缩右边界
} else {
left = mid + 1; // 收缩左边界(注意这里)
}
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
// 左边界判断找到与否
if (isLeft) {
if (left == nums.length || nums[left] != target) return -1;
return left;
} else {
if (left == 0 || nums[left - 1] != target) return -1;
return left - 1; // 右边界是 left - 1
}
}
// 更简洁的写法
public static int[] searchRangeSimpler(int[] nums, int target) {
int leftBound = findLeft(nums, target);
if (leftBound >= nums.length || nums[leftBound] != target) {
return new int[]{-1, -1};
}
int rightBound = findRight(nums, target);
return new int[]{leftBound, rightBound};
}
private static int findLeft(int[] nums, int target) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
return left;
}
private static int findRight(int[] nums, int target) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left + 1) / 2; // 向上取整
if (nums[mid] <= target) left = mid;
else right = mid - 1;
}
return left;
}
5. 适用场景
- 时间范围查询。
- 分数区间统计。
- 在数据流中定位某个值的范围。
6. 不适用场景与替代方案
- 无序数组:先排序或线性扫描。
- 范围查询频繁:可用线段树或树状数组。
7. 优缺点与技术取舍
- 优点:O(log n) 高效。
- 缺点:仅适用于有序数组。
8. 常见问题及解决方案
- 找不到 target:返回
[-1, -1]。 - 数组为空:返回
[-1, -1]。 - 边界判断错误:仔细检查循环条件和收缩方向。
9. 版本差异与实现边界
- Java
Arrays.binarySearch可辅助但不直接提供边界查找。
10. 常见追问
- 统一模板的好处? 一套代码适配多种二分变体。
- 时间复杂度? O(log n) + O(log n) = O(log n)。
- 如何处理重复元素? 二分边界查找天然处理重复元素。
11. 易错点
- ❌ 左边界用
left = mid + 1→ 正确:right = mid。 - ❌ 右边界用
right = mid - 1→ 正确:left = mid + 1。 - ❌ mid 计算向下取整和向上取整搞反 → 死循环或错误结果。
一句话总结
查找目标范围的核心是两次二分边界查找,分别收缩左边界和右边界,时间复杂度 O(log n)。
数字1的个数(LC233)
原始问法:
- 数字1的个数(LC233)
来源题目:
SRC-13-132-426
面试先答
LeetCode 233 题「数字1的个数」要求统计整数 n 中所有数字的十进制表示中出现 1 的总个数。核心思路是按位统计:对每一位(个位、十位、百位...),分别计算该位为 1 的数字个数。公式为:count = (高位数字 × 当前位基数) + (当前位为1时的低位部分 + 1) + (当前位大于1时的基数)。时间复杂度 O(log n),空间 O(1)。关键在于理解每一位上 1 出现的规律。
核心结论
- 按位统计:每一位独立计算贡献。
- 公式:
count = (higher × factor) + (当前位==1 ? lower+1 : 当前位>1 ? factor : 0)。 - 时间 O(log n),空间 O(1)。
1. 是什么
给定整数 n,计算所有 0 ≤ x ≤ n 的数字中,十进制表示中出现「1」的总次数。
2. 为什么需要它
考查对数字规律的归纳能力和数学分析能力。在密码学、数据分析等场景中需要统计特定数字的频率。
3. 底层原理与完整流程
按位分析:
以百位为例(基数 factor=100):
- 百位以上的高位:
higher = n / 1000 - 当前位:
curr = (n / 100) % 10 - 低位:
lower = n % 100
当百位为 1 时:
- 高位每变化 1,百位为 1 的范围是 [100, 199],共 100 个数
- 但实际低位有 limit:
[0, lower],共lower + 1个 - 所以贡献 =
higher × 100 + lower + 1
当百位 > 1 时:
- 高位每变化 1,百位为 1 的完整范围是 [100, 199]
- 共有
(higher + 1) × 100个 - 所以贡献 =
(higher + 1) × 100
当百位 = 0 时:
- 高位为当前值时,百位不可能为 1
- 所以贡献 =
higher × 100
4. 怎么使用
public static int countDigitOne(int n) {
int count = 0;
long factor = 1; // 当前位基数:1, 10, 100, ...
while (n >= factor) {
long higher = n / (factor * 10); // 高位
long curr = (n / factor) % 10; // 当前位
long lower = n % factor; // 低位
// 当前位为1时的贡献
long contrib;
if (curr == 0) {
contrib = higher * factor;
} else if (curr == 1) {
contrib = higher * factor + lower + 1;
} else {
contrib = (higher + 1) * factor;
}
count += contrib;
factor *= 10;
}
return count;
}
5. 适用场景
- 数字统计问题。
- 密码学中的数字频率分析。
- 限制数字出现次数的编码系统。
6. 不适用场景与替代方案
- 大数 n 超过 long 范围 → 需用 BigInteger。
- 统计其他数字 → 同样方法,调整条件。
7. 优缺点与技术取舍
- 优点:O(log n) 高效。
- 缺点:需要数学分析,不易想到。
8. 常见问题及解决方案
- 溢出问题:
factor使用long防止溢出。 - n < 0:取绝对值或特殊处理。
- 统计数字 d (1-9):通用公式,
curr == d时用lower + 1。
9. 版本差异与实现边界
- Java
long最大 2^63-1,可处理 n ≤ 10^18。 - 计算
factor * 10时可能溢出,使用long或BigInteger。
10. 常见追问
- 如何统计数字 2-9? 同理,修改
curr == d条件。 - 如何统计 0? 需特殊处理,因 0 不能作为高位首位。
- 时间复杂度? O(log n),按位数循环。
11. 易错点
- ❌ 用
int类型 → 大数溢出。 - ❌ 忘记
curr == 1和curr > 1的区别 → 贡献计算错误。 - ❌ 公式推导错误 → 用具体数字验证。
一句话总结
统计数字 1 的个数核心是按位独立计算贡献,每位的贡献由高位、当前位和低位三部分决定,时间复杂度 O(log n)。
糖果分发(DP)
原始问法:
- 糖果分发(DP)
来源题目:
SRC-13-132-427
面试先答
LeetCode 135 题「分发糖果」要求:n 个孩子站成一排,每个孩子有评分 ratings,按规则分发糖果:每个孩子至少 1 颗,评分比邻居高的孩子比邻居多。求最少糖果总数。核心思路是两次遍历 + 动态规划:第一次从左到右遍历,candies[i] = max(candies[i], candies[i-1] + 1) 当 ratings[i] > ratings[i-1];第二次从右到左遍历,candies[i] = max(candies[i], candies[i+1] + 1) 当 ratings[i] > ratings[i+1]。取两次的最大值保证左右约束都满足。
核心结论
- 两次遍历 DP:左→右、右→左。
- 时间 O(n),空间 O(n)。
- 优化:单遍历(上坡+下坡计数),空间 O(1)。
1. 是什么
分发糖果问题是经典的双向遍历 DP 问题,要求在满足约束的前提下最少化糖果总数。
2. 为什么需要它
考查对 DP 状态定义和多方向遍历的理解。在资源分配、排队策略等实际场景中有应用。
3. 底层原理与完整流程
状态定义:candies[i] 表示第 i 个孩子的糖果数。
左→右遍历:
candies[0] = 1
for i = 1 to n-1:
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
else:
candies[i] = 1
右→左遍历:
for i = n-2 to 0:
if ratings[i] > ratings[i+1]:
candies[i] = max(candies[i], candies[i+1] + 1)
关键:第二次遍历用 max 而非直接赋值,因为左→右已确定的值可能更大。
4. 怎么使用
public static int candy(int[] ratings) {
int n = ratings.length;
if (n == 0) return 0;
int[] candies = new int[n];
Arrays.fill(candies, 1); // 每个孩子至少 1 颗
// 左→右
for (int i = 1; i < n; i++) {
if (ratings[i] > ratings[i - 1]) {
candies[i] = candies[i - 1] + 1;
}
}
// 右→左
for (int i = n - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1]) {
candies[i] = Math.max(candies[i], candies[i + 1] + 1);
}
}
int total = 0;
for (int c : candies) total += c;
return total;
}
// 空间 O(1) 优化版(上坡+下坡计数)
public static int candyO1(int[] ratings) {
int n = ratings.length;
if (n <= 1) return n;
int total = 1;
int up = 1, down = 0, peak = 1;
for (int i = 1; i < n; i++) {
if (ratings[i] >= ratings[i - 1]) {
up++;
peak = up;
down = 0;
total += up;
} else {
down++;
up = 1;
total += down;
if (down >= peak) total++;
}
}
return total;
}
5. 适用场景
- 资源分配问题。
- 排队/排序的公平性分配。
6. 不适用场景与替代方案
- 评分非比较型:直接均分或按比例分配。
- 环形排列:需考虑首尾约束。
7. 优缺点与技术取舍
- 优点:O(n) 时间,思想清晰。
- 缺点:O(n) 空间;O(1) 版本逻辑复杂。
8. 常见问题及解决方案
- 为什么取 max? 左右约束可能冲突,取较大值同时满足。
- 空间优化:上坡+下坡计数,O(1) 空间。
- 重复评分:相邻评分相等时糖果数相同。
9. 版本差异与实现边界
- Java 无内置糖果分配 API。
- LeetCode 135 题要求时间 O(n)。
10. 常见追问
- 为什么两次遍历? 左右方向的约束不能同时处理。
- O(1) 空间如何实现? 记录上升/下降序列长度和峰值。
- 环形糖果(首尾相连)? 需额外处理首尾约束。
11. 易错点
- ❌ 第二次遍历直接赋值而非 max → 破坏左→右约束。
- ❌ 初始化不是全 1 → 违反最少 1 颗的规则。
- ❌ 忽略评分相等的情况 → 糖果数分配错误。
一句话总结
分发糖果通过左右两次遍历取最大值同时满足相邻约束,时间 O(n),是双向 DP 的经典应用。
扁平数组化成树(二叉树)
原始问法:
- 扁平数组化成树(二叉树)
来源题目:
SRC-13-132-428
面试先答
将有序数组转换为二叉搜索树(BST)是 LeetCode 108 题。核心思想是取数组中间元素作为根节点,递归处理左右子数组。这样构造的 BST 是高度平衡的,因为每次取中间点分割,左右子树节点数差不超过 1。时间复杂度 O(n)(每个元素访问一次),空间复杂度 O(log n)(递归栈)。也可以用迭代法(队列)实现层序构造。
核心结论
- 取中间元素为根,递归构造左右子树。
- 保证 BST 性质 + 高度平衡。
- 时间 O(n),空间 O(log n)。
1. 是什么
将一个有序(升序)数组转换为二叉搜索树(BST)。要求构造的 BST 高度平衡。
2. 为什么需要它
考查对 BST 性质的理解和递归构造能力。有序数组与 BST 的转换是双向的:中序遍历 BST 得到有序数组,有序数组可构造 BST。
3. 底层原理与完整流程
递归构造:
buildTree(nums, left, right):
if left > right: return null
mid = left + (right - left) / 2
root = new TreeNode(nums[mid])
root.left = buildTree(nums, left, mid - 1)
root.right = buildTree(nums, mid + 1, right)
return root
平衡性保证:每次从中间分割,左右子树节点数差 ≤ 1。
迭代构造:使用队列,存储 (节点, 左边界, 右边界) 三元组,逐层构造。
4. 怎么使用
public static TreeNode sortedArrayToBST(int[] nums) {
return buildTree(nums, 0, nums.length - 1);
}
private static TreeNode buildTree(int[] nums, int left, int right) {
if (left > right) return null;
int mid = left + (right - left) / 2;
TreeNode root = new TreeNode(nums[mid]);
root.left = buildTree(nums, left, mid - 1);
root.right = buildTree(nums, mid + 1, right);
return root;
}
// 迭代式(层序构造)
public static TreeNode sortedArrayToBSTIterative(int[] nums) {
if (nums.length == 0) return null;
TreeNode root = new TreeNode(0); // 占位
Queue<Object[]> queue = new LinkedList<>();
queue.offer(new Object[]{root, 0, nums.length - 1});
while (!queue.isEmpty()) {
Object[] obj = queue.poll();
TreeNode node = (TreeNode) obj[0];
int left = (int) obj[1];
int right = (int) obj[2];
int mid = left + (right - left) / 2;
node.val = nums[mid]; // 设置根节点值
if (left <= mid - 1) {
node.left = new TreeNode(0);
queue.offer(new Object[]{node.left, left, mid - 1});
}
if (mid + 1 <= right) {
node.right = new TreeNode(0);
queue.offer(new Object[]{node.right, mid + 1, right});
}
}
return root;
}
class TreeNode {
int val;
TreeNode left, right;
TreeNode(int val) { this.val = val; }
}
5. 适用场景
- BST 的有序数据导入。
- 将有序数据可视化展示。
- 数据库索引构建。
6. 不适用场景与替代方案
- 无序数组:先排序再构造,或构造普通二叉树。
- 频繁插入:逐个插入比一次性构造慢。
7. 优缺点与技术取舍
- 优点:构造的 BST 高度平衡,查找效率 O(log n)。
- 缺点:递归栈开销,迭代式逻辑略复杂。
8. 常见问题及解决方案
- 如何保证平衡? 必须从中间分割,不能选其他位置。
- 重复元素:BST 中重复元素可存左子树或右子树,取决于实现。
9. 版本差异与实现边界
- Java
TreeMap和TreeSet基于红黑树实现,不是由数组构造。 - 有序数组转 BST 是面试高频题型,需掌握递归和迭代两种写法。
10. 常见追问
- 为什么必须选中间? 保证 BST 高度平衡,查找效率 O(log n)。
- 能否用迭代? 可以,使用队列存储待构造的节点和边界。
- 中序遍历结果是什么? 构造好的 BST 中序遍历即原数组。
11. 易错点
- ❌ 选非中间元素 → BST 不平衡,查找效率下降。
- ❌ 递归终止条件错误 → 栈溢出或结果不完整。
- ❌ 混淆 BST 和普通二叉树 → 必须满足 BST 性质。
一句话总结
有序数组转 BST 的核心是取中间元素递归构造,保证 BST 性质和高度平衡。
二叉树的层次遍历及变体
原始问法:
- 二叉树的层次遍历及变体
来源题目:
SRC-13-132-429
面试先答
二叉树的层次遍历(Level Order Traversal)即广度优先搜索(BFS),使用队列按层逐层访问。LeetCode 102 题要求按层返回结果,变体包括:自底向上遍历(107)、Z 字形遍历(103)、右视图(199)等。核心实现是用队列存储当前层所有节点,记录每层大小进行分组。时间复杂度 O(n),空间复杂度 O(n)(队列最坏情况存储 n/2 个节点)。
核心结论
- 层次遍历 = BFS + 队列,O(n) 时间。
- 核心:队列存储当前层节点,按层分组处理。
- 变体:自底向上、Z 字形、右视图等。
1. 是什么
二叉树的层次遍历是按从上到下、从左到右的顺序逐层访问节点。是 BFS 在树结构上的具体应用。
2. 为什么需要它
层次遍历是二叉树最基本的遍历方式之一,在树的序列化、层序构建二叉树、图的广度优先搜索等场景中有重要应用。
3. 底层原理与完整流程
标准层次遍历流程:
queue = [root]
while queue 非空:
levelSize = queue.size()
level = []
for i = 0 to levelSize - 1:
node = queue.poll()
level.add(node.val)
if node.left: queue.offer(node.left)
if node.right: queue.offer(node.right)
result.add(level)
Z 字形遍历:偶数层反转结果。
右视图:每层最后一个节点组成的序列。
4. 怎么使用
// 标准层次遍历
public static List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int levelSize = queue.size();
List<Integer> level = new ArrayList<>();
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
result.add(level);
}
return result;
}
// Z 字形遍历
public static List<List<Integer>> zigzagLevelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
boolean leftToRight = true;
while (!queue.isEmpty()) {
int size = queue.size();
LinkedList<Integer> level = new LinkedList<>();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (leftToRight) {
level.addLast(node.val);
} else {
level.addFirst(node.val);
}
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
result.add(level);
leftToRight = !leftToRight;
}
return result;
}
// 二叉树右视图
public static List<Integer> rightSideView(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null) return result;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (i == size - 1) result.add(node.val);
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
}
return result;
}
// 二叉树最大宽度
public static int widthOfBinaryTree(TreeNode root) {
if (root == null) return 0;
int maxWidth = 0;
Queue<Pair<TreeNode, Integer>> queue = new LinkedList<>();
queue.offer(new Pair<>(root, 0));
while (!queue.isEmpty()) {
int size = queue.size();
int leftmost = queue.peek().getValue();
int rightmost = leftmost;
for (int i = 0; i < size; i++) {
Pair<TreeNode, Integer> pair = queue.poll();
TreeNode node = pair.getKey();
int index = pair.getValue();
rightmost = index;
if (node.left != null) queue.offer(new Pair<>(node.left, 2 * index));
if (node.right != null) queue.offer(new Pair<>(node.right, 2 * index + 1));
}
maxWidth = Math.max(maxWidth, rightmost - leftmost + 1);
}
return maxWidth;
}
5. 适用场景
- 树的序列化和反序列化。
- 层序构建二叉树。
- 图的广度优先搜索。
- 最短路径(无权图)。
6. 不适用场景与替代方案
- 需要深度信息 → DFS 前/中/后序。
- 空间受限的深层树 → DFS(空间 O(h) vs BFS 空间 O(n))。
7. 优缺点与技术取舍
- 优点:按层遍历,天然支持分层处理。
- 缺点:空间消耗 O(n),深层树效率低。
8. 常见问题及解决方案
- 如何按层分组? 记录每层开始时的队列大小。
- 空节点处理:层序遍历中空节点也入队(用于序列化)。
- 最大宽度:需给每个节点编号(完全二叉树编号)。
9. 版本差异与实现边界
- Java
ArrayDeque比LinkedList作为队列更高效。 - Python 有
collections.deque更高效。
10. 常见追问
- 如何自底向上遍历? 正常层次遍历后反转结果列表。
- Z 字形遍历如何实现? 记录每层方向,交替使用头插/尾插。
- 如何序列化二叉树? 层序遍历 +
#表示空节点。
11. 易错点
- ❌ 忘记在入队时标记空节点 → 序列化错误。
- ❌ 层大小计算时机错误 → 分组错误。
- ❌ 用
poll()而非peek()先取大小 → 结果错误。
一句话总结
二叉树层次遍历通过队列实现 BFS 逐层访问,是树的基本操作和许多高级算法的基础。