递归数据结构

MrHe··18 min read

啥叫递归数据结构?你看链表,一个节点后面跟着另一个节点,这不就是自己套自己嘛。你看二叉树,一个根节点下面连着左子树、右子树,子树本身又是二叉树。对付这种结构,递归就是最顺手的兵器。

16.2.1 从链表中移除结点

问题描述:

给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回 新的头节点 。

示例: 输入: head = [1,2,6,3,4,5,6], val = 6 输出: [1,2,3,4,5]

思路分析:

这题太经典了,面试手撕代码经常遇到。

解法一:迭代 + 哨兵节点(Dummy Head)

拿到链表题,尤其是涉及到可能删除头节点的操作,我第一反应就是搞一个虚拟头节点(Dummy Head)。为啥?因为它可以让所有节点的删除操作都统一起来,不用单独处理头节点被删除的特殊情况,代码写起来极其舒爽。

思路很简单:

  1. 创建一个 dummy 节点,让 dummy.next = head

  2. 创建一个 cur 指针,初始指向 dummy

  3. 开始遍历链表,只要 cur.next 不为空,就考察 cur.next 这个节点。

  4. 如果 cur.next.val 是我们要删除的值,那就直接 cur.next = cur.next.next,相当于把 cur.next 这个节点从链表里“摘出去”了。

  5. 如果不是,那 cur 就正常后移,cur = cur.next

  6. 最后返回 dummy.next,这就是新的头节点。

// 链表节点定义
class ListNode {
    int val;
    ListNode next;
    ListNode(int x) { val = x; }
}

// 解法一:迭代 + 虚拟头节点
public ListNode removeElements(ListNode head, int val) {
    if (head == null) {
        return null;
    }
    ListNode dummy = new ListNode(-1);
    dummy.next = head;
    ListNode cur = dummy;
    while (cur.next != null) {
        if (cur.next.val == val) {
            cur.next = cur.next.next;
        } else {
            cur = cur.next;
        }
    }
    return dummy.next;
}

解法二:迭代,不使用虚拟头节点

有人就头铁,说我不想用额外空间,不用虚拟节点行不行?也行,就是代码得多写几行,处理头节点这个特殊情况。

思路:

  1. 先把头节点搞定。如果 head 本身就是 val,那就一直往后挪,head = head.next,直到找到一个不等于 val 的新头或者 head 变成 null

  2. 处理完头节点,剩下的就跟解法一的逻辑类似了。用一个 cur 指针从 head 开始遍历,判断 cur.next,然后决定要不要删除。

// 解法二:迭代,不使用虚拟头节点
public ListNode removeElements_NoDummy(ListNode head, int val) {
    // 1. 处理头节点可能被删除的情况
    while (head != null && head.val == val) {
        head = head.next;
    }
    if (head == null) {
        return null;
    }

    // 2. 处理非头节点
    ListNode cur = head;
    while (cur.next != null) {
        if (cur.next.val == val) {
            cur.next = cur.next.next;
        } else {
            cur = cur.next;
        }
    }
    return head;
}

你看,是不是麻烦一点?所以面试时,除非面试官明确要求,不然直接上虚拟头节点,稳!

解法三:递归大法

这才是本章的重点。怎么用递归想这个问题?咱们可以这么定义一个函数 remove(node, val):它的功能是,删除以 node 为头节点的链表中所有值为 val 的节点,并返回处理后新链表的头节点。

好,定义清楚了,接下来就是递归的套路:

  1. Base Case(递归出口):如果 nodenull,那没啥可删的,直接返回 null

  2. 递归过程

    • 我不管后面的链表有多长,我先让我的小弟(递归调用)去把 node.next 后面的部分处理好。也就是调用 remove(node.next, val)。这会返回一个已经删除了所有 val 的新子链表的头。

    • 我把这个返回的新子链表头接在 node.next 上:node.next = remove(node.next, val)

    • 现在,node 的后面已经完美了。我只需要关心 node 自己是不是该被删除。

    • 如果 node.val == val,那 node 自己也得滚蛋,应该返回它的下一级,也就是 node.next(这个 node.next 已经是处理好的子链表头了)。

    • 如果 node.val != val,那 node 留下,它就是处理后链表的头,返回 node

这思路是不是特别清晰?我只管好我自己这一层,剩下的事儿甩锅给下一层。

// 解法三:递归
public ListNode removeElements_Recursive(ListNode head, int val) {
    // Base Case
    if (head == null) {
        return null;
    }

    // 把“除了我之外”的链表先处理好
    head.next = removeElements_Recursive(head.next, val);

    // 然后再看我自己该不该被删
    if (head.val == val) {
        return head.next; // 我被删了,返回我的下一个节点作为新的头
    } else {
        return head;      // 我不用删,我还得是头
    }
}

你看这代码,多简洁,完美体现了递归的魅力。


16.2.2 合并两个有序链表

问题描述:

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的节点组成的。

示例: 输入:l1 = [1,2,4], l2 = [1,3,4] 输出:[1,1,2,3,4,4]

思路分析:

又是一道经典到不能再经典的题。

解法一:迭代 + 哨兵节点

这个思路和上一题解法一很像,也是用一个虚拟头节点来简化操作。

  1. 创建一个 dummy 节点作为新链表的头,再来一个 tail 指针,用于在新链表上追加节点。

  2. 比较 l1l2 当前节点的值。

  3. 谁小,就把谁接到 tail 后面,然后 tail 和那个被接上的链表的指针同时后移。

  4. 循环直到 l1l2 有一个变成 null

  5. 最后,把没遍历完的那个链表剩下的部分,直接接到 tail 的后面。

  6. 返回 dummy.next

// 解法一:迭代
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(-1);
    ListNode tail = dummy;

    while (l1 != null && l2 != null) {
        if (l1.val <= l2.val) {
            tail.next = l1;
            l1 = l1.next;
        } else {
            tail.next = l2;
            l2 = l2.next;
        }
        tail = tail.next;
    }

    // 处理剩下的链表
    tail.next = l1 == null ? l2 : l1;

    return dummy.next;
}

这方法非常稳健,时间复杂度 O(m+n),空间复杂度 O(1)。

解法二:递归

递归解法同样优雅。我们定义函数 merge(l1, l2) 的作用是合并 l1l2,并返回合并后新链表的头。

  1. Base Case:如果 l1null,那没啥好合的,直接返回 l2。同理,l2null 就返回 l1

  2. 递归过程

    • 比较 l1.vall2.val

    • 如果 l1.val <= l2.val,那么新链表的头节点就是 l1l1 的下一个节点应该是谁呢?应该是 l1.nextl2 合并后的结果。所以,l1.next = merge(l1.next, l2)。然后返回 l1

    • 反之,如果 l2.val < l1.val,那么新链表的头就是 l2l2.next 应该是 l1l2.next 合并后的结果。所以, l2.next = merge(l1, l2.next)。然后返回 l2

这个过程就像是在剥洋葱,我只决定当前最小的节点是谁,然后把剩下的合并任务交给下一层递归。

// 解法二:递归
public ListNode mergeTwoLists_Recursive(ListNode l1, ListNode l2) {
    // Base Case
    if (l1 == null) {
        return l2;
    }
    if (l2 == null) {
        return l1;
    }

    // 递归过程
    if (l1.val <= l2.val) {
        l1.next = mergeTwoLists_Recursive(l1.next, l2);
        return l1;
    } else {
        l2.next = mergeTwoLists_Recursive(l1, l2.next);
        return l2;
    }
}

递归解法的缺点是会消耗栈空间,如果链表特别长,可能会有栈溢出的风险。但代码是真的漂亮。

解法三:原地修改(其实就是迭代的另一种视角)

这个解法本质上还是迭代,但我们可以换个角度思考,不创建新节点(虽然解法一也没创建,只是用了指针),而是将一个链表的节点“插入”到另一个链表中。我们可以假设 l1 是主链表,把 l2 的节点一个个塞进去。

这个思路相对复杂一点,要处理的指针和边界情况更多,容易出错,面试时不推荐。它和解法一本质上都是 O(1) 空间复杂度的迭代,没有性能优势,徒增了实现的复杂性。所以,咱们还是老老实实用带虚拟头节点的迭代或者递归,这两种是正道。为了凑数,我就不写这个容易出错的代码了,掌握前两种足够横着走了。


16.2.3 二叉树剪枝

问题描述:

给定二叉树根结点 root ,此外树的每个结点的值要么是 0 要么是 1。 返回移除了所有不包含 1 的子树的原二叉树。 (节点 X 的子树为 X 本身,以及所有 X 的后代。)

示例: 输入: [1,null,0,0,1] 输出: [1,null,0,null,1] 解释: 只有红色节点满足条件“所有节点的值都是 1”。 右图表示我们保留的结果。

prune-tree-example

思路分析:

这题是后序遍历的绝佳应用场景。为啥?因为我要判断一个父节点该不该被剪掉,得先知道它的左右子树是不是“全0”子树,是不是应该被剪掉。这不就是典型的“先处理孩子,再处理老子”的后序遍历思想嘛。

解法一:递归(后序遍历思想)

定义一个函数 pruneTree(node),它的作用是:修剪以 node 为根的树,并返回修剪后新树的根。

  1. Base Case: 如果 nodenull,直接返回 null

  2. 递归过程:

    • 先去修剪左子树:node.left = pruneTree(node.left)

    • 再去修剪右子树:node.right = pruneTree(node.right)

    • 现在,node 的左右子树都已经是修剪过的“干净”版本了。我们该决定 node 自己是否要被剪掉。

    • node 被剪掉的条件是什么?它自己是 0,并且它修剪后的左子树是 null,修剪后的右子树也是 null

    • 如果满足这个条件,那么以 node 为根的这整个子树都是“全0”子树,应该被剪掉,所以返回 null

    • 否则,node 就应该被保留,返回 node

这个后序遍历的思路,完美地解决了问题。

// 树节点定义
class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
    TreeNode(int x) { val = x; }
}

// 解法一:递归
public TreeNode pruneTree(TreeNode root) {
    if (root == null) {
        return null;
    }

    // 后序遍历位置:先处理子树
    root.left = pruneTree(root.left);
    root.right = pruneTree(root.right);

    // 处理当前节点:判断自己是否该被剪掉
    if (root.val == 0 && root.left == null && root.right == null) {
        return null; // 剪掉自己
    }

    return root; // 保留自己
}

解法二:递归 + 辅助函数判断

上面的解法把“修剪”和“判断是否全0”两个功能合二为一了,非常简洁。我们也可以把它们拆开,思路更清晰。

定义一个辅助函数 containsOne(node),判断以 node 为根的子树是否包含 1。

  1. containsOne(node) 的逻辑:

    • Base Case: node == null,返回 false

    • 递归看左子树是否包含 1:leftContains = containsOne(node.left)

    • 递归看右子树是否包含 1:rightContains = containsOne(node.right)

    • 如果左子树不包含 1 (!leftContains),就把左子树剪掉:node.left = null

    • 如果右子树不包含 1 (!rightContains),就把右子树剪掉:node.right = null

    • 最后,只要 node.val == 1 或者 leftContains 或者 rightContains 任何一个为真,就说明以 node 为根的(原始)子树包含 1,返回 true

主函数里,调用 containsOne(root)。如果它返回 false,说明整个树都不含 1,直接返回 null。否则,返回处理过的 root

// 解法二:递归 + 辅助函数
public TreeNode pruneTree_WithHelper(TreeNode root) {
    if (containsOne(root)) {
        return root;
    } else {
        return null;
    }
}

private boolean containsOne(TreeNode node) {
    if (node == null) {
        return false;
    }

    // 递归地判断左右子树是否包含1
    boolean leftContains = containsOne(node.left);
    boolean rightContains = containsOne(node.right);

    // 根据判断结果进行剪枝
    if (!leftContains) {
        node.left = null;
    }
    if (!rightContains) {
        node.right = null;
    }

    // 返回以当前节点为根的(原始)子树是否包含1
    return node.val == 1 || leftContains || rightContains;
}

这个解法虽然代码多了点,但职责更分明,也更容易理解。它本质上也是后序遍历,判断发生在递归返回之后。

解法三:其实没有本质区别的第三种解法

所有基于递归的解法,本质都是后序遍历。我们可以变体一下,让递归函数返回布尔值,表示是否要保留当前节点,同时通过引用修改树。但Java中没有引用传递,所以通常还是返回节点本身最方便。前两种解法已经覆盖了最核心、最高效的思路。硬要说第三种,可能就是用迭代+栈模拟后序遍历,但那纯属给自己找麻烦,代码会变得极其复杂,完全失去了二叉树递归解法的简洁之美,面试官看了都得皱眉头。所以,这题掌握好第一种递归解法就足够了。


16.2.4 二叉树的最近公共祖先 (LCA)

问题描述:

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”

示例: 输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1 输出: 3 解释: 节点 5 和节点 1 的最近公共祖先是节点 3。

思路分析:

LCA是二叉树里的一个重头戏,解法多样,但递归解法是最精妙的。

解法一:递归(后序遍历思想)

这个解法的思想非常巧妙。我们定义函数 lowestCommonAncestor(root, p, q) 的含义是:在以 root 为根的树里查找 pq 的LCA。

递归过程是这么设计的:

  1. Base Case

    • 如果 rootnull,说明这条路走到底了也没找到,返回 null

    • 如果 root 本身就是 p 或者 q,根据定义,它就是LCA(或者LCA的候选之一),直接返回 root

  2. 递归分解

    • 去左子树里找 pq 的LCA:left = lowestCommonAncestor(root.left, p, q)

    • 去右子树里找 pq 的LCA:right = lowestCommonAncestor(root.right, p, q)

  3. 结果合并:这一步是精髓。递归调用返回后,leftright 的值有几种情况:

    • 如果 leftright 都非空:这意味着什么?pq 分别在 root 的左右两棵子树里!那 root 不就是它俩的LCA嘛!直接返回 root

    • 如果 left 非空right 为空:这说明 pq 都在左子树里,并且左子树的递归调用已经帮我们找到了它俩的LCA,就是 left。所以我们直接返回 left 就行。

    • 如果 left 为空right 非空:同理,pq 都在右子树,LCA就是 right

    • 如果 leftright 都为空:说明左右子树里都找不到 pq,返回 null

你看,整个过程就像信息在逐层向上传递,非常优雅。

// 解法一:递归
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    // Base Case
    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) {
        // p和q在异侧,当前root就是LCA
        return root;
    }
    if (left != null) {
        // p和q都在左子树,LCA在左子树
        return left;
    }
    if (right != null) {
        // p和q都在右子树,LCA在右子树
        return right;
    }
    return null;
}

解法二:存储父节点 + 哈希表

如果不能用递归,或者想换个思路,可以这样做:

  1. 先遍历一遍整个树,用一个哈希表 Map<TreeNode, TreeNode> 记录下每个节点的父节点是谁。

  2. 有了父节点信息,找 p 的LCA就变成了找从 p 到根节点的路径。我们可以先把这条路径上的所有节点(包括 p 自己)都存到一个 HashSet 里。

  3. 然后,再从 q 开始,沿着它的父节点往上走,遇到的第一个也存在于 HashSet 里的节点,就是 pq 的LCA。

这就像是找两条相交链表的第一个交点,只不过链表是通过父指针串起来的。

// 解法二:存储父节点
public TreeNode lowestCommonAncestor_ParentMap(TreeNode root, TreeNode p, TreeNode q) {
    Map<TreeNode, TreeNode> parentMap = new HashMap<>();
    Deque<TreeNode> stack = new ArrayDeque<>();

    parentMap.put(root, null);
    stack.push(root);

    // 1. 遍历树,填充parentMap
    while (!parentMap.containsKey(p) || !parentMap.containsKey(q)) {
        TreeNode node = stack.pop();
        if (node.left != null) {
            parentMap.put(node.left, node);
            stack.push(node.left);
        }
        if (node.right != null) {
            parentMap.put(node.right, node);
            stack.push(node.right);
        }
    }

    // 2. 从p往上走到根,记录路径
    Set<TreeNode> ancestors = new HashSet<>();
    while (p != null) {
        ancestors.add(p);
        p = parentMap.get(p);
    }

    // 3. 从q往上走,遇到的第一个p的祖先就是LCA
    while (!ancestors.contains(q)) {
        q = parentMap.get(q);
    }

    return q;
}

这个方法需要额外的空间存父节点和路径,时间上也要遍历两次(一次建表,一次查找),不如递归解法简洁高效。

解法三:其实还是递归,但返回信息更丰富

解法一虽然巧妙,但它有一个前提:pq 必须存在于树中。如果 pq 可能不存在,解法一就会出问题。我们可以设计一个返回信息更丰富的递归。

比如,定义一个类 ResultType(TreeNode node, boolean findP, boolean findQ),递归函数返回这个类的实例。 node 表示找到的LCA候选,findP 表示是否找到了 pfindQ 表示是否找到了 q

这个过程就复杂了,需要合并左右子树返回的 ResultType,判断当前root是不是LCA,然后构造一个新的 ResultType 向上返回。这其实是把解法一的隐式逻辑显式化了,代码会啰嗦很多,但更鲁棒。在常规面试中,解法一已经足够惊艳了,除非面试官追问节点可能不存在的情况,否则没必要上这么复杂的结构。

这里就不写代码了,因为思路和解法一是一脉相承的,只是把判断逻辑复杂化了。


16.2.5 二叉树展开为链表

问题描述:

给你二叉树的根结点 root ,请你将它展开为一个单链表。

  1. 展开后的单链表应该同样使用 TreeNode ,其中 right 指针指向链表中下一个结点,而 left 指针始终为 null

  2. 展开后的单链表应该与二叉树 先序遍历 的顺序相同。

示例: 输入:root = [1,2,5,3,4,null,6] 输出:[1,null,2,null,3,null,4,null,5,null,6]

思路分析:

这题要求把一个二叉树原地拍平,变成一个链表。顺序是先序遍历的顺序。

解法一:递归(后序遍历思想)

这题用递归解,思路有点反直觉。虽然要求是先序遍历的顺序,但处理起来用后序遍历的思路最方便。

我们定义一个 flatten(node) 函数,它的作用是把以 node 为根的子树拍平。

  1. Base Case: nodenull,直接返回。

  2. 递归过程:

    • 先把左子树拍平:flatten(node.left)

    • 再把右子树拍平:flatten(node.right)

    • 好了,现在 node 的左右子树都已经是拉直的链表了。我们要做的是把它们和 node 拼接起来。

    • 怎么拼?顺序是 根 -> 左子树链表 -> 右子树链表

    • 步骤如下: a. 记录下 node 原来的左子树(现在是链表头)left 和右子树(现在是链表头)right。 b. node.left = null。 c. node.right = left (把左子树链表接到根节点的右边)。 d. 找到左子树链表的末尾节点。这个末尾节点需要接上右子树链表的头。所以,一路 cur = cur.right 找到这个末尾。 e. cur.right = right

这个后序遍历的思路,确保了我们在处理一个节点时,它的左右子树已经被处理好了,我们只需要做拼接工作。

// 解法一:递归(后序遍历)
public void flatten(TreeNode root) {
    if (root == null) {
        return;
    }

    // 递归地拉平左右子树
    flatten(root.left);
    flatten(root.right);

    // 后序遍历位置:开始拼接
    TreeNode left = root.left; // 拉平后的左子树链表
    TreeNode right = root.right; // 拉平后的右子树链表

    // 1. 将左子树作为新的右子树
    root.left = null;
    root.right = left;

    // 2. 找到新右子树(原左子树)的末尾
    TreeNode p = root;
    while (p.right != null) {
        p = p.right;
    }

    // 3. 将原右子树接到末尾
    p.right = right;
}

解法二:递归(记录前驱节点)

解法一每次都要遍历左子树链表去找尾巴,有点浪费。咱们可以优化一下。 换个思路,还是后序遍历,但我们维护一个全局或通过引用传递的 prev 节点,表示上一个被访问的节点。 遍历顺序是 右 -> 左 -> 根(反向的先序遍历)。 当我们处理 node 时,prev 刚好是它在先序遍历中的后继节点。

  1. 定义一个全局变量 prev = null

  2. 递归函数 flatten_rev(node)

    • Base Case: nodenull,返回。

    • 递归处理右子树:flatten_rev(node.right)

    • 递归处理左子树:flatten_rev(node.left)

    • 处理当前节点 node

      • node.right = prev (把当前节点的右指针指向它的后继)。

      • node.left = null

      • 更新 prev = node (当前节点成为下一个节点的前驱)。

这个思路非常巧妙,避免了重复查找尾节点的问题。

// 解法二:递归(反向先序遍历)
private TreeNode prev = null;

public void flatten_rev(TreeNode root) {
    if (root == null) {
        return;
    }
    // 倒着来:右 -> 左 -> 根
    flatten_rev(root.right);
    flatten_rev(root.left);

    // 处理当前节点
    root.right = prev;
    root.left = null;
    prev = root; // 更新 prev 为当前节点
}

解法三:迭代(寻找前驱节点)

我们也可以不用递归,用迭代来模拟这个过程。 思路是:对于当前节点 curr,找到它左子树的最右节点(这个节点是 curr 在先序遍历中的前驱)。然后把 curr 的右子树,接到这个前驱节点的右边,再把 curr 的左子树,整体移到 curr 的右边。

  1. curr 指向 root,开始循环,直到 currnull

  2. 如果 curr 有左子树:

    • 找到左子树的最右节点,我们叫它 predecessor

    • predecessor.right = curr.right

    • curr.right = curr.left

    • curr.left = null

  3. 处理完当前 curr 后,移动到下一个节点:curr = curr.right

这其实是 Morris 遍历思想的一个应用。

// 解法三:迭代
public void flatten_iterative(TreeNode root) {
    TreeNode curr = root;
    while (curr != null) {
        if (curr.left != null) {
            // 找到左子树的最右节点(前驱)
            TreeNode predecessor = curr.left;
            while (predecessor.right != null) {
                predecessor = predecessor.right;
            }
            // 拼接
            predecessor.right = curr.right;
            curr.right = curr.left;
            curr.left = null;
        }
        // 移动到下一个节点
        curr = curr.right;
    }
}

16.3 基于归纳的递归算法设计

这一类问题,数据结构本身不一定是递归的,但问题的解法可以被归纳成一个递归的形式。本质上就是“大问题”可以分解成规模更小的“子问题”。

16.3.1 电话号码的字母组合

问题描述:

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例: 输入:digits = "23" 输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

思路分析:

这题是典型的回溯(Backtracking)问题,而回溯天然就是用递归来实现的。

解法一:回溯(DFS)

回溯算法,说白了就是在一个决策树上做深度优先搜索(DFS)。 我们定义一个 backtrack(index, path) 函数:

  • index: 当前正在处理 digits 字符串的第几个字符。

  • path: 从根节点到当前节点已经形成的字母组合(一个 StringBuilder)。

  1. 递归出口: 如果 index 等于 digits 的长度,说明我们已经处理完了所有数字,得到了一个完整的组合。把 path 的内容加入结果集 res,然后返回。

  2. 递归过程:

    • 获取当前数字 digits[index] 对应的字母们,比如 '2' 对应 "abc"。

    • 遍历这些字母('a', 'b', 'c')。

    • 做选择: 把当前字母(比如 'a')追加到 path 后面。

    • 进入下一层决策: 递归调用 backtrack(index + 1, path),去处理下一个数字。

    • 撤销选择: 当下一层的递归返回后,要把刚才追加的字母从 path 中删除,这样才能回去尝试其他的选择(比如 'b')。这个“撤销”就是回溯的精髓。

// 解法一:回溯
class Solution_LetterCombinations {
    private String[] map = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
    private List<String> res;

    public List<String> letterCombinations(String digits) {
        res = new ArrayList<>();
        if (digits == null || digits.length() == 0) {
            return res;
        }
        backtrack(digits, 0, new StringBuilder());
        return res;
    }

    private void backtrack(String digits, int index, StringBuilder path) {
        // 递归出口
        if (index == digits.length()) {
            res.add(path.toString());
            return;
        }

        // 获取当前数字对应的字母
        String letters = map[digits.charAt(index) - '0'];
        for (char c : letters.toCharArray()) {
            // 做选择
            path.append(c);
            // 进入下一层
            backtrack(digits, index + 1, path);
            // 撤销选择
            path.deleteCharAt(path.length() - 1);
        }
    }
}

解法二:队列(BFS)

除了深度优先的思路,我们也可以用广度优先(BFS)来解决。 这个思路很像按层遍历。

  1. 初始化一个队列 queue,里面只放一个空字符串 ""

  2. 遍历 digits 里的每个数字。

  3. 对于每个数字,比如 '2' 对应的 "abc":

    • 看一下当前队列里有多少个元素(比如 size)。

    • 循环 size 次,把队列里的老组合一个个取出来。

    • 对于每个取出来的老组合 s(比如 "a"),把它和 "abc" 的每个字母拼接,形成新组合("aa", "ab", "ac"),然后把这些新组合入队。

  4. 当所有数字都处理完后,队列里剩下的就是所有最终的组合。

// 解法二:队列(BFS)
public List<String> letterCombinations_BFS(String digits) {
    List<String> result = new ArrayList<>();
    if (digits == null || digits.length() == 0) {
        return result;
    }
    String[] map = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};

    Queue<String> queue = new LinkedList<>();
    queue.offer("");

    for (char digit : digits.toCharArray()) {
        String letters = map[digit - '0'];
        int levelSize = queue.size();
        for (int i = 0; i < levelSize; i++) {
            String current = queue.poll();
            for (char letter : letters.toCharArray()) {
                queue.offer(current + letter);
            }
        }
    }

    result.addAll(queue);
    return result;
}

这个方法也很巧妙,用队列的先进先出特性,一层一层地生成组合。

解法三:纯递归(不带显式回溯)

这种解法也可以看作递归,但形式上和标准回溯模板略有不同。它通过函数返回值来传递和构建结果。 定义 combine(digits_substring) 返回该子字符串能生成的所有组合。 combine("23") 的结果,可以由 combine("3") 的结果(["d", "e", "f"])和 '2' 对应的 "abc" 组合而成。 即 'a' + ["d", "e", "f"] -> ["ad", "ae", "af"], 'b' + ..., 'c' + ... 这个思路自底向上构建,也是一种递归思想。

// 解法三:纯粹的递归分解
public List<String> letterCombinations_PureRecursive(String digits) {
    List<String> result = new ArrayList<>();
    if (digits == null || digits.length() == 0) {
        return result;
    }
    String[] map = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};

    // Base Case
    if (digits.length() == 1) {
        String letters = map[digits.charAt(0) - '0'];
        for (char c : letters.toCharArray()) {
            result.add(String.valueOf(c));
        }
        return result;
    }

    // 递归分解
    char firstDigit = digits.charAt(0);
    String remainingDigits = digits.substring(1);

    List<String> subCombinations = letterCombinations_PureRecursive(remainingDigits);
    String firstLetters = map[firstDigit - '0'];

    for (char c : firstLetters.toCharArray()) {
        for (String s : subCombinations) {
            result.add(c + s);
        }
    }

    return result;
}

这种方法虽然可行,但在Java里由于字符串拼接会产生很多临时对象,效率可能不如回溯法里的 StringBuilder 高。


后面的几个小题比较简单,咱们快速过一下。

16.3.2 位 1 的个数

问题描述:

编写一个函数,输入是一个无符号整数(以二进制串的形式),返回其二进制表达式中数字位数为 '1' 的个数(也被称为汉明重量)。

思路分析:

解法一:循环与位移动

最直观的,循环32次(因为int是32位),每次检查最后一位是不是1,然后把数右移一位。

// 解法一:循环检查每一位
public int hammingWeight(int n) {
    int count = 0;
    for (int i = 0; i < 32; i++) {
        if ((n & 1) == 1) {
            count++;
        }
        n >>>= 1; // 无符号右移
    }
    return count;
}

注意要用无符号右移 >>>,否则对于负数,高位会补1,导致死循环。

解法二:巧妙的位运算 n & (n - 1)

这是一个非常有名的技巧。n & (n - 1) 的作用是:将 n 的二进制表示中最低位的那个 1 置为 0。 比如 n = 6 (二进制 0110),n-1 = 5 (二进制 0101)。n & (n-1) 就是 0100 (4)。 我们只需要不断重复这个操作,直到 n 变成0,操作了多少次,就有多少个1。

// 解法二:n & (n-1)
public int hammingWeight_trick(int n) {
    int count = 0;
    while (n != 0) {
        n = n & (n - 1);
        count++;
    }
    return count;
}

这是面试中最受欢迎的解法,高效且秀出了你的位运算功底。

解法三:调用库函数

调包侠来了。Java的 Integer 类里直接提供了这个方法。

// 解法三:库函数
public int hammingWeight_lib(int n) {
    return Integer.bitCount(n);
}

写业务代码这么写没问题,面试要是这么写,估计就直接下一位了。


16.3.3 2 的幂

问题描述:

给你一个整数 n,请你判断它是否是 2 的幂次方。如果是,返回 true ;否则,返回 false 。

思路分析:

解法一:循环除法

不断地用n除以2,如果中间某一步余数不是0(除了最后n=1的情况),那就不是。

// 解法一:循环
public boolean isPowerOfTwo(int n) {
    if (n <= 0) return false;
    while (n % 2 == 0) {
        n /= 2;
    }
    return n == 1;
}

解法二:位运算

2的幂的二进制表示有一个特点:只有一位是1,其他全是0。比如 1(0001), 2(0010), 4(0100), 8(1000)。 那么,利用上题的 n & (n-1) 技巧,如果一个数是2的幂,那么 n & (n-1) 的结果一定是0。 别忘了处理 n <= 0 的情况。

// 解法二:位运算
public boolean isPowerOfTwo_bit(int n) {
    return n > 0 && (n & (n - 1)) == 0;
}

一行代码,简洁优雅,面试官最爱。

解法三:数学方法

最大的 int 型 2 的幂是 2^30。如果 n 是 2 的幂,那它必然能整除这个最大的 2^30

// 解法三:数学
public boolean isPowerOfTwo_math(int n) {
    int maxPowerOfTwo = 1 << 30; // 2^30
    return n > 0 && (maxPowerOfTwo % n == 0);
}

这算是个取巧的办法,利用了 int 范围的限制。


16.3.4 字符串解码

问题描述:

给定一个经过编码的字符串,返回它解码后的字符串。 编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。 你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。

示例: 输入: s = "3[a]2[bc]" 输出: "aaabcbc"

思路分析:

这题用栈来做最合适,括号匹配问题嘛,天然适合用栈。

解法一:双栈(一个存数字,一个存字符串)

遍历字符串:

  • 遇到数字,解析出来,压入数字栈 numStack

  • 遇到 [,把当前正在拼接的字符串 res 压入字符串栈 strStack,然后 res 清空,准备拼接 [ 内部的内容。

  • 遇到字母,直接拼接到当前 res 上。

  • 遇到 ],这是关键。从 numStack 弹出一个数字 k,从 strStack 弹出一个字符串 prev_res。然后把当前 res 重复 k 次,拼接到 prev_res 后面,形成新的 res

// 解法一:双栈
public String decodeString(String s) {
    StringBuilder res = new StringBuilder();
    int multi = 0;
    Deque<Integer> numStack = new LinkedList<>();
    Deque<StringBuilder> strStack = new LinkedList<>();

    for (char c : s.toCharArray()) {
        if (c >= '0' && c <= '9') {
            multi = multi * 10 + (c - '0');
        } else if (c == '[') {
            numStack.push(multi);
            strStack.push(res);
            multi = 0;
            res = new StringBuilder();
        } else if (c == ']') {
            int k = numStack.pop();
            StringBuilder temp = strStack.pop();
            for (int i = 0; i < k; i++) {
                temp.append(res);
            }
            res = temp;
        } else {
            res.append(c);
        }
    }
    return res.toString();
}

解法二:递归

字符串本身就带有递归的结构 k[...]。我们可以定义一个递归函数来解码。 用一个全局索引或者引用来跟踪当前处理到字符串的哪个位置。 递归函数 dfs(s) 的作用是解码 s 的一部分,直到遇到 ] 或者字符串末尾。

// 解法二:递归
class Solution_DecodeString {
    int index = 0;

    public String decodeString_recursive(String s) {
        StringBuilder res = new StringBuilder();
        int multi = 0;

        while (index < s.length()) {
            char c = s.charAt(index);
            if (c >= '0' && c <= '9') {
                multi = multi * 10 + (c - '0');
                index++;
            } else if (c == '[') {
                index++; // skip '['
                String sub = decodeString_recursive(s);
                for (int i = 0; i < multi; i++) {
                    res.append(sub);
                }
                multi = 0;
            } else if (c == ']') {
                index++; // skip ']'
                return res.toString(); // 一层递归结束
            } else {
                res.append(c);
                index++;
            }
        }
        return res.toString();
    }
}

递归解法把状态(比如 multi 和当前的 res)保存在了每一层的函数栈帧里,也很清晰。

解法三:单栈

其实可以只用一个栈,栈里存数字或者已解码的字符串片段。但这样处理起来类型不统一,比较麻烦。双栈的思路是最清晰的。硬要说第三种,可以看作是双栈思路的变种,比如栈里存一个 Pair<Integer, StringBuilder> 对象,把数字和字符串绑在一起入栈,本质没变。所以这题掌握双栈和递归就足够了。


两两交换链表中的节点

问题描述:

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。

示例: 输入: head = [1,2,3,4] 输出: [2,1,4,3]

解法一:迭代 + 虚拟头节点

这题画个图就很清楚了。我们需要三个指针来操作:一个指向要交换的两个节点的前一个节点 prev,一个指向第一个节点 node1,一个指向第二个节点 node2prev -> node1 -> node2 -> next_pair_start 交换后变成: prev -> node2 -> node1 -> next_pair_start 然后更新 prevnode1,继续下一轮。用虚拟头节点可以简化对头两个节点的处理。

public ListNode swapPairs(ListNode head) {
    ListNode dummy = new ListNode(-1);
    dummy.next = head;
    ListNode prev = dummy;

    while (prev.next != null && prev.next.next != null) {
        ListNode node1 = prev.next;
        ListNode node2 = prev.next.next;

        // 交换
        prev.next = node2;
        node1.next = node2.next;
        node2.next = node1;

        // 移动prev到下一对的前面
        prev = node1;
    }

    return dummy.next;
}

解法二:递归

递归的定义 swap(node):两两交换以 node 开头的链表,并返回交换后新链表的头。

  1. Base Case: node 为空或只有一个节点,没得换,直接返回 node

  2. 递归过程:

    • 我需要交换的两个节点是 nodenode.next

    • 交换后的新头是 node.next。我们叫它 newHead

    • node 应该接在哪里?它应该接在后面那一大串链表交换好之后的新链表头上。

    • 后面那一大串是 newHead.next 开始的,所以我们递归调用 swap(newHead.next)

    • 于是,node.next = swap(newHead.next)

    • 最后,newHead 应该指向 node,所以 newHead.next = node

    • 返回 newHead

public ListNode swapPairs_recursive(ListNode head) {
    // Base Case
    if (head == null || head.next == null) {
        return head;
    }

    // 待交换的两个节点
    ListNode node1 = head;
    ListNode node2 = head.next;

    // node1 应该连接到 后面交换好的链表的头部
    node1.next = swapPairs_recursive(node2.next);
    // node2 应该成为新的头部,并指向 node1
    node2.next = node1;

    // 返回新头部
    return node2;
}

解法三:迭代,指针操作的另一种写法

本质还是迭代,只是指针的命名和移动方式上略有不同,但核心思想都是操作三个节点的关系。解法一的写法已经非常经典和清晰了,没必要再搞一个花里胡哨但易错的版本。所以我们还是聚焦前两种,一种代表迭代的缜密,一种代表递归的优雅。


螺旋矩阵

问题描述:

给你一个 mn 列的矩阵 matrix ,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。

示例: 输入: matrix = [[1,2,3],[4,5,6],[7,8,9]] 输出: [1,2,3,6,9,8,7,4,5]

解法一:按层模拟

这个最直观。我们可以想象成一层一层地“剥洋葱”。 设置四个边界:top, bottom, left, right。 在一个循环里,只要 top <= bottom 并且 left <= right,就执行四步:

  1. leftright 遍历 top 行。然后 top++

  2. topbottom 遍历 right 列。然后 right--

  3. rightleft 遍历 bottom 行。然后 bottom--

  4. bottomtop 遍历 left 列。然后 left++。 注意在第3、4步之前要加判断,防止单行或单列时重复遍历。

public List<Integer> spiralOrder(int[][] matrix) {
    List<Integer> res = new ArrayList<>();
    if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
        return res;
    }
    int rows = matrix.length, cols = matrix[0].length;
    int left = 0, right = cols - 1, top = 0, bottom = rows - 1;

    while (left <= right && top <= bottom) {
        // 从左到右
        for (int i = left; i <= right; i++) res.add(matrix[top][i]);
        top++;
        // 从上到下
        for (int i = top; i <= bottom; i++) res.add(matrix[i][right]);
        right--;
        // 关键判断:防止单行/单列时重复
        if (top <= bottom) {
            // 从右到左
            for (int i = right; i >= left; i--) res.add(matrix[bottom][i]);
            bottom--;
        }
        if (left <= right) {
            // 从下到上
            for (int i = bottom; i >= top; i--) res.add(matrix[i][left]);
            left++;
        }
    }
    return res;
}

解法二:模拟行走 + 标记已访问

我们可以模拟一个人在矩阵里走。定义四个方向 (0, 1), (1, 0), (0, -1), (-1, 0)。 每当要撞墙(越界)或者走到一个已经访问过的格子时,就顺时针转个弯。 用一个和 matrix 等大的 visited 数组来标记。

public List<Integer> spiralOrder_simulation(int[][] matrix) {
    List<Integer> res = new ArrayList<>();
    if (matrix.length == 0) return res;
    int rows = matrix.length, cols = matrix[0].length;
    boolean[][] visited = new boolean[rows][cols];
    int[][] directions = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 右下左上
    int dirIndex = 0;
    int r = 0, c = 0;

    for (int i = 0; i < rows * cols; i++) {
        res.add(matrix[r][c]);
        visited[r][c] = true;
        int nextR = r + directions[dirIndex][0];
        int nextC = c + directions[dirIndex][1];

        if (nextR < 0 || nextR >= rows || nextC < 0 || nextC >= cols || visited[nextR][nextC]) {
            dirIndex = (dirIndex + 1) % 4; // 转向
        }
        r += directions[dirIndex][0];
        c += directions[dirIndex][1];
    }
    return res;
}

解法三:递归

这可以看作是解法一的递归版本。 定义一个递归函数,负责打印最外一圈,然后对小一圈的内部矩阵进行递归调用。 这个实现起来容易出错,边界条件特别多,不如迭代清晰。但思路是成立的。


后面的题目比较类似,我快速给出核心思路和代码。

移除链表元素

这个问题在 16.2.1 已经详细讲过了,这里不再赘述,请参考前面的三种解法。

回文链表

问题描述:

请判断一个链表是否为回文链表。

解法一:转成数组

最蠢的办法,遍历链表,把值存到 ArrayList 里,然后用双指针判断数组是不是回文。时间O(N),空间O(N)。

public boolean isPalindrome(ListNode head) {
    List<Integer> vals = new ArrayList<>();
    ListNode curr = head;
    while (curr != null) {
        vals.add(curr.val);
        curr = curr.next;
    }
    int left = 0, right = vals.size() - 1;
    while (left < right) {
        if (!vals.get(left).equals(vals.get(right))) {
            return false;
        }
        left++;
        right--;
    }
    return true;
}

解法二:快慢指针 + 反转后半部分

这是最优解。空间O(1)。

  1. 用快慢指针找到链表中点。

  2. 反转后半部分链表。

  3. 用两个指针,一个从头开始,一个从反转后的后半部分头开始,逐一比较。

  4. (可选)恢复链表结构。

public boolean isPalindrome_optimal(ListNode head) {
    if (head == null) return true;

    // 1. 找中点
    ListNode slow = head, fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }

    // 2. 反转后半部分
    ListNode secondHalf = reverse(slow.next);

    // 3. 比较
    ListNode p1 = head;
    ListNode p2 = secondHalf;
    boolean result = true;
    while (result && p2 != null) {
        if (p1.val != p2.val) {
            result = false;
        }
        p1 = p1.next;
        p2 = p2.next;
    }

    // 4. 恢复链表(可选)
    slow.next = reverse(secondHalf);

    return result;
}

private ListNode reverse(ListNode head) {
    ListNode prev = null;
    ListNode curr = head;
    while (curr != null) {
        ListNode nextTemp = curr.next;
        curr.next = prev;
        prev = curr;
        curr = nextTemp;
    }
    return prev;
}

解法三:递归

递归的思路是,利用递归的调用栈,实现“从后往前”的比较。 定义一个全局的 left 指针指向头部。递归函数深入到链表末尾,然后随着栈帧的回退,用全局的 left 指针和当前递归的 right 节点进行比较。

private ListNode frontPointer;

public boolean isPalindrome_recursive(ListNode head) {
    frontPointer = head;
    return recursivelyCheck(head);
}

private boolean recursivelyCheck(ListNode currentNode) {
    if (currentNode != null) {
        if (!recursivelyCheck(currentNode.next)) {
            return false;
        }
        if (currentNode.val != frontPointer.val) {
            return false;
        }
        frontPointer = frontPointer.next;
    }
    return true;
}

这个方法虽然空间复杂度也是O(N)(递归栈),但代码很巧妙。


3 的幂

与2的幂类似。

解法一:循环除法

while (n % 3 == 0) { n /= 3; }

解法二:换底公式

log3(n) = log10(n) / log10(3)。判断这个结果是不是整数。

public boolean isPowerOfThree(int n) {
    if (n <= 0) return false;
    double logResult = Math.log10(n) / Math.log10(3);
    // 检查是否为整数,注意精度问题
    return (logResult - (int)logResult) < 1e-10;
}

解法三:整数限制法

int 范围内最大的 3 的幂是 3^19 = 1162261467。如果 n 是 3 的幂,它必然能整除这个数。

public boolean isPowerOfThree_math(int n) {
    return n > 0 && 1162261467 % n == 0;
}

这个是最骚的。


有效的完全平方数

问题描述:

给定一个正整数 num,编写一个函数,如果 num 是一个完全平方数,则返回 true,否则返回 false

解法一:暴力法

从 1 开始循环到 num,看 i*i 是否等于 num。太慢,会超时。

解法二:二分查找

在一个有序的范围内(1 到 num)找一个数 mid,使得 mid * mid == num。这是标准解法。

public boolean isPerfectSquare(int num) {
    if (num < 1) return false;
    if (num == 1) return true;
    long left = 1, right = num; // 用long防止mid*mid溢出
    while (left <= right) {
        long mid = left + (right - left) / 2;
        long square = mid * mid;
        if (square == num) {
            return true;
        } else if (square < num) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return false;
}

解法三:数学性质

完全平方数有个性质:1 + 3 + 5 + ... + (2n-1) = n^2。 我们可以从 num 里依次减去 1, 3, 5, ... 看最后是否能减到0。

public boolean isPerfectSquare_math(int num) {
    int i = 1;
    while (num > 0) {
        num -= i;
        i += 2;
    }
    return num == 0;
}

剩下两题比较冷门,简要说下思路。

三元表达式解析器

问题描述:

给定一个代表三元表达式的字符串,求出这个表达式的值。 ... (T?T?F:T:F) ...

思路: 从右往左找 ?:。因为三元表达式是右结合的。用一个栈,遇到 ? 前的字母和 : 后的字母就入栈,遇到 ? 及其前面的 T/F,就弹栈决定取哪个,然后把结果再入栈。递归解法更清晰:parse(exp),找到最外层的 ? 和与之匹配的 :,如果条件为 T,就递归 parse 第一个分支,否则递归 parse 第二个分支。

输出二叉树

问题描述:

在一个 m*n 的二维字符串数组中输出二叉树。 ... (格式化输出)

思路: 这是个典型的双递归过程。

  1. 第一次遍历(DFS):计算树的高度 height

  2. 计算二维数组尺寸:宽度是 2^height - 1,高度是 height

  3. 第二次遍历(DFS):填充数组。定义一个 fill(node, r, c, height) 方法。r, c 是当前节点要填入的位置。根节点在 (0, (width-1)/2)。它的左孩子在 (r+1, c - 2^(height-r-2)),右孩子在 (r+1, c + 2^(height-r-2))

二进制链表转整数

问题描述:

给你一个单链表的头节点 head,其中的节点要么是 0 要么是 1,代表一个二进制数。返回这个链表所代表的十进制值。

解法一:数组转换

遍历链表存到数组,再从数组转成十进制。空间O(N)。

解法二:迭代

遍历链表,维护一个 sum。每次 sum = sum * 2 + node.val

public int getDecimalValue(ListNode head) {
    int sum = 0;
    ListNode curr = head;
    while (curr != null) {
        sum = sum * 2 + curr.val;
        // 也可以用位运算: sum = (sum << 1) | curr.val;
        curr = curr.next;
    }
    return sum;
}

解法三:递归

定义 dfs(node),返回以node结尾的二进制值。这个不好直接得到全局和。更好的递归思路是定义一个全局sum,后序遍历链表,用一个全局power记录当前是2的几次方。但实现起来远不如迭代简洁。解法二就是最优解。