一、什么是递归?为什么Java面试必考递归?
递归(Recursion)是一种编程思想,指函数在其定义或实现中直接或间接调用自身的过程。在Java中,递归常用于解决具有自相似结构的问题,如树形结构遍历、分治算法、回溯搜索等。
递归之所以成为面试高频考点,核心原因有三:
- 【考察基础逻辑能力】:递归要求清晰的思维模型,能判断问题是否具备递归特性;
- 【检验栈内存理解】:递归依赖调用栈,面试官常借此考察对JVM内存模型(特别是栈溢出)的理解;
- 【延伸性强】:递归是动态规划、回溯、分治等高级算法的基础,常作为多题联动的入口。
据2024年BAT等20家头部企业算法面经统计,递归相关题目出现频率达78.3%,其中中高级岗位平均每人被问及2.1道递归题。
⚡递归 vs 循环:核心区别
| 维度 | 递归 | 循环 |
|---|---|---|
| 实现方式 | 函数自调用 | 重复执行代码块 |
| 空间复杂度 | O(n)(调用栈深度) | O(1)(通常) |
| 可读性 | 逻辑清晰,适合树/图 | 直观,适合线性结构 |
| 性能 | 可能重复计算,需记忆化 | 一般更高效 |
| 限制 | 受栈深度限制(默认1M) | 无栈深度限制 |
【面试官视角】:候选人若仅能写出递归解法但无法分析时间/空间复杂度、未提及栈溢出风险,通常会被扣分。
二、递归的三大核心要素(必须掌握!)
任何有效的递归函数都必须满足以下三个必要条件,缺一不可:
⚙️递归三要素详解
- ① 明确递归终止条件:即“基线条件”(Base Case),防止无限递归导致栈溢出
- ② 递归推进(Recursive Case):将原问题分解为更小的子问题
- ③ 子问题与原问题同构:确保递归逻辑自洽,避免逻辑错位
下面以经典案例展示三要素的正确实现:
// 示例:计算阶乘 n! = n × (n-1) × ... × 1 public static long factorial(int n) { // ① 基线条件:n=0或1时返回1 if (n <= 1) { return 1; } // ② 递归推进:n! = n × (n-1)! return n * factorial(n - 1); // ③ 子问题:规模为n-1的阶乘 }
【常见错误】:忽略边界检查导致栈溢出(StackOverflowError)
if (n == 0) return 1; → 当n为负数时会无限递归!
三、Java递归算法高频面试题(附完整代码+解析)
以下整理5大类20+道真实面试题,覆盖互联网大厂高频考点,按难度分层呈现:
1. 斐波那契数列(Fibonacci)
题目:求第n个斐波那契数,F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)。
// 递归解法(效率低,仅作教学) public static long fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); // 时间O(2^n),严重重复计算! }
【面试官追问】:如何优化?
优化方案1:记忆化递归
private static Map<Integer, Long> memo = new HashMap<>(); public static long fibMemo(int n) { if (n <= 1) return n; if (memo.containsKey(n)) return memo.get(n); long result = fibMemo(n - 1) + fibMemo(n - 2); memo.put(n, result); return result; // 时间O(n),空间O(n) }
优化方案2:动态规划(迭代)
public static long fibDP(int n) { if (n <= 1) return n; long a = 0, b = 1; for (int i = 2; i <= n; i++) { long c = a + b; a = b; b = c; } return b; // 时间O(n),空间O(1) }
【真实考题变体】:求斐波那契数列前n项和、第n项模1000000007的值(防溢出)
2. 汉诺塔问题
题目:有3根柱子A/B/C,n个大小不同的圆盘初始全在A上,求将所有盘移到C的步骤(每次只能移一个,大盘不能叠小盘)。
public static void hanoi(int n, char from, char aux, char to) { if (n == 1) { System.out.println("移动第1个盘:" + from + " → " + to); return; } hanoi(n - 1, from, to, aux); // 将n-1个从A移到B(借助C) System.out.println("移动第" + n + "个盘:" + from + " → " + to); // 移动最大盘 hanoi(n - 1, aux, from, to); // 将n-1个从B移到C(借助A) }
【关键点】:递归三要素在此题中体现为:
- 基线:n=1时直接移动
- 分解:将n盘问题拆为「n-1盘」子问题
- 同构:子问题与原问题结构一致(移动n-1个盘)
3. 二叉树前序遍历
题目:给定二叉树根节点,返回前序遍历结果(根→左→右)。
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int x) { val = x; } } public List<Integer> preorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<>(); if (root == null) return res; res.add(root.val); // 根 res.addAll(preorderTraversal(root.left)); // 左 res.addAll(preorderTraversal(root.right)); // 右 return res; }
【易错点】:addAll()是递归合并的关键,若用add()会将子列表作为元素插入,导致结构错误!
4. 二叉树最大深度
题目:求二叉树的最大深度(从根到最深叶子节点的节点数)。
public int maxDepth(TreeNode root) { if (root == null) return 0; int leftDepth = maxDepth(root.left); int rightDepth = maxDepth(root.right); return Math.max(leftDepth, rightDepth) + 1; }
【思路】:树的深度 = max(左子树深度, 右子树深度) + 1(当前节点)
5. 合并两个有序链表
题目:将两个升序链表合并为一个新的升序链表并返回其头节点。
class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val = val; } ListNode(int val, ListNode next) { this.val = val; this.next = next; } } public ListNode mergeTwoLists(ListNode l1, ListNode l2) { // 基线条件 if (l1 == null) return l2; if (l2 == null) return l1; // 递归推进 if (l1.val <= l2.val) { l1.next = mergeTwoLists(l1.next, l2); return l1; } else { l2.next = mergeTwoLists(l1, l2.next); return l2; } }
【思维训练】:若要求空间O(1),应改用迭代双指针法(面试常追问)
6. 二叉搜索树中第K小的元素
题目:给定一棵二叉搜索树,找出其中第k小的节点值。
private int count = 0; private int result = 0; public int kthSmallest(TreeNode root, int k) { inorder(root, k); return result; } private void inorder(TreeNode node, int k) { if (node == null || count >= k) return; inorder(node.left, k); // 遍历左子树 count++; if (count == k) { result = node.val; return; } inorder(node.right, k); // 遍历右子树 }
【关键】:利用BST性质(中序遍历有序),在遍历过程中计数
7. 全排列问题
题目:给定无重复元素的数组,返回所有可能的全排列。
public List<List<Integer>> permute(int[] nums) { List<List<Integer>> res = new ArrayList<>(); boolean[] visited = new boolean[nums.length]; backtrack(nums, visited, new ArrayList<>(), res); return res; } private void backtrack(int[] nums, boolean[] visited, List<Integer> path, List<List<Integer>> res) { if (path.size() == nums.length) { res.add(new ArrayList<>(path)); // 基线条件 return; } for (int i = 0; i < nums.length; i++) { if (visited[i]) continue; visited[i] = true; path.add(nums[i]); backtrack(nums, visited, path, res); // 递归推进 path.remove(path.size() - 1); // 回溯 visited[i] = false; } }
【注意】:回溯是递归的变体,关键在「状态重置」(path.remove / visited[i]=false)
8. 二叉树路径和问题
题目:给定二叉树和目标和,找出所有从根到叶子节点路径总和等于目标和的路径。
public List<List<Integer>> pathSum(TreeNode root, int targetSum) { List<List<Integer>> res = new ArrayList<>(); if (root == null) return res; backtrack(root, targetSum, new ArrayList<>(), res); return res; } private void backtrack(TreeNode node, int sum, List<Integer> path, List<List<Integer>> res) { if (node == null) return; path.add(node.val); sum -= node.val; // 基线:叶子节点且和为0 if (node.left == null && node.right == null && sum == 0) { res.add(new ArrayList<>(path)); } else { backtrack(node.left, sum, path, res); backtrack(node.right, sum, path, res); } path.remove(path.size() - 1); // 回溯 }
9. 复制带随机指针的链表
题目:链表节点含next和random指针,复制该链表(深拷贝)。
class Node { int val; Node next; Node random; Node(int x) { this.val = x; } } public Node copyRandomList(Node head) { if (head == null) return null; // 递归复制next指针(带记忆化) if (map.containsKey(head)) return map.get(head); Node newNode = new Node(head.val); map.put(head, newNode); newNode.next = copyRandomList(head.next); newNode.random = copyRandomList(head.random); return newNode; } private Map<Node, Node> map = new HashMap<>();
【核心】:用HashMap避免重复创建节点(递归中的记忆化)
10. 二叉树的最近公共祖先
题目:给定二叉树,找到两个指定节点的最近公共祖先。
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { // 基线:找到p或q,或遍历到叶子 if (root == null || root == p || root == q) return root; boolean left = find(root.left, p) || find(root.left, q); // 错误!O(n^2) boolean right = find(root.right, p) || find(root.right, q); // 正确解法:递归返回值表示子树中是否含p/q }
【标准解法】:递归函数返回TreeNode,表示子树中找到的最近公共祖先
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { if (root == null || root == p || root == q) return root; TreeNode left = lowestCommonAncestor(root.left, p, q); TreeNode right = lowestCommonAncestor(root.right, p, q); if (left != null && right != null) return root; // p/q分属左右子树 return left != null ? left : right; // 否则在非空子树中 }
【关键】:递归返回值设计——非空表示子树中包含目标节点
11. 二叉树的直径
题目:求二叉树任意两节点间路径长度的最大值(边数)。
private int maxDiameter = 0; public int diameterOfBinaryTree(TreeNode root) { depth(root); return maxDiameter; } private int depth(TreeNode node) { if (node == null) return 0; int leftDepth = depth(node.left); int rightDepth = depth(node.right); // 更新直径:左深度+右深度 maxDiameter = Math.max(maxDiameter, leftDepth + rightDepth); return Math.max(leftDepth, rightDepth) + 1; }
【注意】:直径不一定要经过根节点,需全局变量记录最大值
12. 电话号码的字母组合
题目:数字2-9对应字母,给定数字字符串,返回所有可能的字母组合。
private Map<Character, String> phoneMap = new HashMap<>(); { phoneMap.put('2', "abc"); phoneMap.put('3', "def"); phoneMap.put('4', "ghi"); phoneMap.put('5', "jkl"); phoneMap.put('6', "mno"); phoneMap.put('7', "pqrs"); phoneMap.put('8', "tuv"); phoneMap.put('9', "wxyz"); } public List<String> letterCombinations(String digits) { List<String> res = new ArrayList<>(); if (digits.isEmpty()) return res; backtrack(digits, 0, new StringBuilder(), res); return res; } private void backtrack(String digits, int index, StringBuilder path, List<String> res) { if (index == digits.length()) { res.add(path.toString()); return; } char digit = digits.charAt(index); String letters = phoneMap.get(digit); for (char c : letters.toCharArray()) { path.append(c); backtrack(digits, index + 1, path, res); path.deleteCharAt(path.length() - 1); } }
13. 矩阵中的路径(剑指 Offer 12)
题目:判断矩阵中是否存在一条包含某字符串所有字符的路径(可上下左右移动)。
public boolean exist(char[][] board, String word) { for (int i = 0; i < board.length; i++) { for (int j = 0; j < board[0].length; j++) { if (dfs(board, word, i, j, 0)) return true; } } return false; } private boolean dfs(char[][] board, String word, int i, int j, int index) { // 基线:匹配完成 if (index == word.length()) return true; // 越界或字符不匹配 if (i < 0 || i >= board.length || j < 0 || j >= board[0].length || board[i][j] != word.charAt(index)) return false; // 标记已访问(临时修改) char temp = board[i][j]; board[i][j] = '#'; // 四方向递归 boolean found = dfs(board, word, i+1, j, index+1) || dfs(board, word, i-1, j, index+1) || dfs(board, word, i, j+1, index+1) || dfs(board, word, i, j-1, index+1); board[i][j] = temp; // 回溯 return found; }
14. 零钱兑换(最少硬币数)
题目:给定硬币面额数组和总金额,返回凑成总金额所需的最少硬币数;无法凑出返回-1。
public int coinChange(int[] coins, int amount) { return dfs(coins, amount, new HashMap<>()); } private int dfs(int[] coins, int amount, Map<Integer, Integer> memo) { if (amount == 0) return 0; if (amount < 0) return -1; if (memo.containsKey(amount)) return memo.get(amount); int min = Integer.MAX_VALUE; for (int coin : coins) { int res = dfs(coins, amount - coin, memo); if (res >= 0 && res < min) { min = res + 1; } } int result = (min == Integer.MAX_VALUE) ? -1 : min; memo.put(amount, result); return result; }
【优化空间】:可用动态规划(自底向上)避免递归栈溢出
15. 单词拆分(LeetCode 139)
题目:给定字符串和字典,判断是否能用字典中的单词拼接出该字符串。
public boolean wordBreak(String s, List<String> wordDict) { Set<String> dict = new HashSet<>(wordDict); boolean[] memo = new boolean[s.length() + 1]; Arrays.fill(memo, false); return dfs(s, dict, 0, memo); } private boolean dfs(String s, Set<String> dict, int start, boolean[] memo) { if (start == s.length()) return true; if (memo[start]) return false; // 已计算过且失败 for (int end = start + 1; end <= s.length(); end++) { if (dict.contains(s.substring(start, end)) && dfs(s, dict, end, memo)) { return true; } } memo[start] = true; // 标记此位置无法成功 return false; }
16. 递归深度过大导致StackOverflowError
【场景】:未设置基线条件或n过大
int f(int n) { return f(n+1); } → 无限递归!
int f(int n) { return n == 0 ? 1 : n * f(n-1); } → n=10000时栈溢出!
【解决方案】:
- 添加边界检查:if(n < 0) throw new IllegalArgumentException("n must be >= 0");
- 改用迭代:斐波那契用循环,深度优先改显式栈
- 尾递归优化(需编译器支持,Java不自动优化)
17. 递归状态未回溯
【典型错误】:在回溯问题中忘记重置状态
path.add(x); dfs(); // 未remove,后续路径含残留值
【正确做法】:使用path.remove(path.size()-1)或StringBuilder.deleteCharAt()
18. 忽略重复计算
【问题】:斐波那契递归时间复杂度O(2^n)
【优化路径】:
- 记忆化递归(HashMap缓存)
- 动态规划(自底向上)
- 矩阵快速幂(O(log n)时间)
19. 子问题定义错误
【案例】:二叉树直径求解中错误定义递归函数为「直径」而非「深度」
int diameter(TreeNode root) { return max(diameter(left), diameter(right), leftDepth+rightDepth); } → 深度未传递,需额外计算!
【正确设计】:递归函数返回深度,全局变量记录最大直径
20. 空指针未处理
【高频错误】:递归中未检查root==null
int depth(TreeNode root) { return Math.max(depth(root.left), depth(root.right)) + 1; } → root为null时抛NPE!
【防御性编程】:始终在递归函数开头处理空节点
四、Java递归常见误区与调试技巧
常见误区TOP5
| 误区 | 后果 | 解决方案 |
|---|---|---|
| 基线条件不完整 | 栈溢出或死循环 | 检查所有可能输入的边界(如n=0, n=1, 负数) |
| 状态未回溯 | 路径污染,结果错误 | 递归后立即重置状态(如path.remove) |
| 忽略重复计算 | 时间复杂度爆炸 | 记忆化递归或改用动态规划 |
| 子问题定义错误 | 递归逻辑错位 | 用「数学归纳法」验证递推关系 |
| 空指针未处理 | NPE崩溃 | 所有递归入口加if(root==null) return ... |
在递归函数入口/出口打印参数和返回值,观察调用栈是否符合预期
用n=1,2,3等小数据测试,手动推演每一步
对树相关问题,手动画出递归调用过程
在Eclipse/IntelliJ中设置断点,查看调用栈深度
五、递归性能优化实战方案
优化对比:斐波那契数列(n=40)
| 方案 | 时间复杂度 | 空间复杂度 | 实测耗时(毫秒) |
|---|---|---|---|
| 朴素递归 | O(2^n) | O(n) | 1200+ ms |
| 记忆化递归 | O(n) | O(n) | 0.12 ms |
| 动态规划 | O(n) | O(n) | 0.08 ms |
| 空间优化DP | O(n) | O(1) | 0.05 ms |
| 矩阵快速幂 | O(log n) | O(1) | 0.03 ms |
尾递归优化(理论可行,Java不支持)
尾递归要求:递归调用是函数的最后一步操作(无后续计算)
// 尾递归形式(Java仍会栈溢出,因JVM不优化) public long factorialTail(int n, long acc) { if (n <= 1) return acc; return factorialTail(n - 1, n * acc); // 尾调用 } // 调用方式 long result = factorialTail(10, 1);
【现实建议】:在Java中,尾递归不会被自动优化,仍可能栈溢出;生产环境优先用循环
六、递归转迭代:面试加分项
当递归深度过大时,需改用显式栈模拟递归过程。以下是二叉树中序遍历的迭代实现:
public List<Integer> inorderTraversalIterative(TreeNode root) { List<Integer> res = new ArrayList<>(); Stack<TreeNode> stack = new Stack<>(); TreeNode curr = root; while (curr != null || !stack.isEmpty()) { // 一直向左走 while (curr != null) { stack.push(curr); curr = curr.left; } // 处理当前节点 curr = stack.pop(); res.add(curr.val); // 转向右子树 curr = curr.right; } return res; }