Skip to content

高频树图

二叉树锯齿形层序遍历

二叉树锯齿形层序遍历

它本质还是 BFS 层序遍历,队列出队顺序不变,仍然是一层一层从左到右取节点。 区别只在于:保存当前层结果时,根据方向决定插到尾部还是头部

比如:

      3
     / \
    9   20
       /  \
      15   7

普通层序是:

[[3], [9, 20], [15, 7]]

锯齿形层序是:

[[3], [20, 9], [15, 7]]

binary-tree-zigzag-level-order-csharp

核心思路

Queue<TreeNode> 做 BFS。 每次先记录当前层节点数量 count = queue.Count,这样就能保证只处理当前这一层。 用 LinkedList<int> 存当前层结果:

  • 从左到右:AddLast(node.val)
  • 从右到左:AddFirst(node.val)
  • 每处理完一层:leftToRight = !leftToRight

C# 代码

c
using System.Collections.Generic; // 引入 Queue、List、LinkedList 等集合类型
public class TreeNode // 定义二叉树节点类
{ // 节点类开始
    public int val; // 当前节点的值
    public TreeNode left; // 当前节点的左子节点
    public TreeNode right; // 当前节点的右子节点
    public TreeNode(int val = 0, TreeNode left = null, TreeNode right = null) // 定义节点构造函数
    { // 构造函数开始
        this.val = val; // 初始化当前节点的值
        this.left = left; // 初始化左子节点
        this.right = right; // 初始化右子节点
    } // 构造函数结束
} // 节点类结束
public class Solution // 定义题解类
{ // 题解类开始
    public IList<IList<int>> ZigzagLevelOrder(TreeNode root) // 定义锯齿形层序遍历方法
    { // 方法开始
        List<IList<int>> result = new List<IList<int>>(); // 创建最终结果列表
        if (root == null) // 如果根节点为空
        { // 空树判断开始
            return result; // 空树直接返回空结果
        } // 空树判断结束
        Queue<TreeNode> queue = new Queue<TreeNode>(); // 创建队列用于 BFS 层序遍历
        queue.Enqueue(root); // 把根节点加入队列
        bool leftToRight = true; // 记录当前层是否从左到右保存结果
        while (queue.Count > 0) // 只要队列不为空就继续遍历
        { // 外层循环开始
            int count = queue.Count; // 固定当前层的节点数量
            LinkedList<int> level = new LinkedList<int>(); // 创建当前层结果链表
            for (int i = 0; i < count; i++) // 遍历当前层的所有节点
            { // for 循环开始
                TreeNode node = queue.Dequeue(); // 从队列中取出当前节点
                if (leftToRight) // 如果当前层要求从左到右
                { // 方向判断开始
                    level.AddLast(node.val); // 把节点值加入当前层尾部
                } // 方向判断结束
                else // 如果当前层要求从右到左
                { // 反方向判断开始
                    level.AddFirst(node.val); // 把节点值加入当前层头部
                } // 反方向判断结束
                if (node.left != null) // 如果左子节点不为空
                { // 左子节点判断开始
                    queue.Enqueue(node.left); // 左子节点入队
                } // 左子节点判断结束
                if (node.right != null) // 如果右子节点不为空
                { // 右子节点判断开始
                    queue.Enqueue(node.right); // 右子节点入队
                } // 右子节点判断结束
            } // for 循环结束
            result.Add(new List<int>(level)); // 把当前层结果加入最终答案
            leftToRight = !leftToRight; // 当前层结束后切换下一层方向
        } // 外层循环结束
        return result; // 返回最终锯齿形层序遍历结果
    } // 方法结束
} // 题解类结束

复杂度

时间复杂度:O(n),每个节点只遍历一次。 空间复杂度:O(n),队列和结果列表最多存储所有节点。

面试记忆

CAUTION

队列负责“按层取节点”,方向变量负责“这一层怎么存结果”。 也就是:BFS 不变,结果插入方向变化。

从前序和中序构造二叉树

从前序和中序构造二叉树

这题的核心口诀是:前序定根,中序分左右

前序遍历顺序是:

根 -> 左子树 -> 右子树

中序遍历顺序是:

左子树 -> 根 -> 右子树

所以前序数组的第一个值,一定是当前子树的根。 然后去中序数组里找到这个根,根左边就是左子树,根右边就是右子树。

build-tree-preorder-inorder-csharp

例子

preorder = [3, 9, 20, 15, 7]
inorder  = [9, 3, 15, 20, 7]

前序第一个是 3,所以 3 是根节点。

在中序里:

[9, 3, 15, 20, 7]

3 左边是 [9],所以左子树是 93 右边是 [15, 20, 7],所以右子树继续递归构造。

C# 代码

c
using System.Collections.Generic; // 引入 Dictionary 集合类型

public class TreeNode // 定义二叉树节点类
{ // 节点类开始
    public int val; // 当前节点的值
    public TreeNode left; // 当前节点的左子节点
    public TreeNode right; // 当前节点的右子节点
    public TreeNode(int val = 0, TreeNode left = null, TreeNode right = null) // 定义构造函数
    { // 构造函数开始
        this.val = val; // 初始化当前节点的值
        this.left = left; // 初始化左子节点
        this.right = right; // 初始化右子节点
    } // 构造函数结束
} // 节点类结束

public class Solution // 定义题解类
{ // 题解类开始
    private int preIndex; // 记录当前用到了前序数组的哪个位置
    private int[] preorderArray; // 保存前序数组,方便递归函数使用
    private Dictionary<int, int> inorderIndexMap; // 保存中序数组中每个值对应的下标

    public TreeNode BuildTree(int[] preorder, int[] inorder) // 根据前序和中序构造二叉树
    { // 方法开始
        preIndex = 0; // 前序数组从第 0 个位置开始取根节点
        preorderArray = preorder; // 保存前序数组引用
        inorderIndexMap = new Dictionary<int, int>(); // 创建哈希表用于快速定位根节点
        for (int i = 0; i < inorder.Length; i++) // 遍历中序数组
        { // for 循环开始
            inorderIndexMap[inorder[i]] = i; // 记录每个节点值在中序数组中的下标
        } // for 循环结束
        return Build(0, inorder.Length - 1); // 从整个中序范围开始递归构造
    } // 方法结束

    private TreeNode Build(int inLeft, int inRight) // 构造中序区间 [inLeft, inRight] 对应的子树
    { // 递归函数开始
        if (inLeft > inRight) // 如果左边界超过右边界,说明当前子树为空
        { // 空区间判断开始
            return null; // 返回空节点
        } // 空区间判断结束
        int rootValue = preorderArray[preIndex]; // 前序当前位置就是当前子树根节点的值
        preIndex++; // 前序指针后移,准备给下一棵子树取根
        TreeNode root = new TreeNode(rootValue); // 创建当前根节点
        int rootIndex = inorderIndexMap[rootValue]; // 找到根节点在中序数组中的位置
        root.left = Build(inLeft, rootIndex - 1); // 递归构造左子树
        root.right = Build(rootIndex + 1, inRight); // 递归构造右子树
        return root; // 返回当前子树的根节点
    } // 递归函数结束
} // 题解类结束

复杂度

时间复杂度:O(n),每个节点只创建一次。 空间复杂度:O(n),哈希表和递归栈会占用额外空间。

面试记忆

IMPORTANT

前序负责告诉你:当前根是谁。 中序负责告诉你:左子树和右子树的范围在哪里

验证二叉搜索树

验证二叉搜索树

二叉搜索树 BST 的规则不是“左孩子小于我,右孩子大于我”这么简单,而是:

左子树所有节点 < 当前节点 < 右子树所有节点

所以验证 BST 时,最稳的写法是:递归时带上下界

validate-binary-search-tree-csharp

核心思路

比如这棵树:

      10
     /  \
    5    15
        /  \
       6    20

很多新手会误判成合法,因为:

c
5 < 10
15 > 10
6 < 15
20 > 15

但它其实不是 BST,因为 610 的右子树里,必须大于 10,可是 6 < 10

所以递归时要传范围:

根节点 10:范围是 (-∞, +∞)
左子树 5:范围是 (-∞, 10)
右子树 15:范围是 (10, +∞)
节点 6:范围是 (10, 15),但 6 不在这个范围里

C# 代码

c
public class TreeNode // 定义二叉树节点类
{ // 节点类开始
    public int val; // 当前节点的值
    public TreeNode left; // 当前节点的左子节点
    public TreeNode right; // 当前节点的右子节点
    public TreeNode(int val = 0, TreeNode left = null, TreeNode right = null) // 定义构造函数
    { // 构造函数开始
        this.val = val; // 初始化当前节点的值
        this.left = left; // 初始化左子节点
        this.right = right; // 初始化右子节点
    } // 构造函数结束
} // 节点类结束
public class Solution // 定义题解类
{ // 题解类开始
    public bool IsValidBST(TreeNode root) // 定义验证二叉搜索树的方法
    { // 方法开始
        return Check(root, long.MinValue, long.MaxValue); // 从根节点开始,用 long 的最小值和最大值作为初始范围
    } // 方法结束
    private bool Check(TreeNode node, long min, long max) // 定义递归检查函数,min 和 max 表示当前节点允许的取值范围
    { // 递归函数开始
        if (node == null) // 如果当前节点为空
        { // 空节点判断开始
            return true; // 空树天然是合法的二叉搜索树
        } // 空节点判断结束
        if (node.val <= min || node.val >= max) // 如果当前节点不在合法范围内
        { // 范围判断开始
            return false; // 当前节点不合法,整棵树直接不合法
        } // 范围判断结束
        bool leftOk = Check(node.left, min, node.val); // 检查左子树,左子树的最大值必须小于当前节点
        bool rightOk = Check(node.right, node.val, max); // 检查右子树,右子树的最小值必须大于当前节点
        return leftOk && rightOk; // 左右子树都合法,当前子树才合法
    } // 递归函数结束
} // 题解类结束

复杂度

时间复杂度:O(n),每个节点只检查一次。 空间复杂度:O(h)h 是树的高度,主要来自递归栈。

面试记忆

IMPORTANT

验证 BST 不能只比较父子节点。 要么用 中序遍历严格递增,要么用 递归上下界。 面试里我更推荐说上下界法,因为它能直接解释“祖先约束”。

二叉搜索树第 K 小元素

二叉搜索树第 K 小元素

这题的核心是:BST 的中序遍历结果一定是升序序列

BST 满足:

左子树 < 根 < 右子树

中序遍历顺序是:

左 -> 根 -> 右

所以对 BST 做中序遍历,访问到的节点顺序就是从小到大。 第 k 次访问到的节点,就是第 k 小元素。

bst-kth-smallest-csharp

例子

        5
       / \
      3   6
     / \
    2   4
   /
  1

中序遍历结果是:

c
[1, 2, 3, 4, 5, 6]

如果 k = 3,答案就是 3

C# 代码:迭代中序遍历

c
using System.Collections.Generic; // 引入 Stack 栈集合类型
public class TreeNode // 定义二叉树节点类
{ // 节点类开始
    public int val; // 当前节点的值
    public TreeNode left; // 当前节点的左子节点
    public TreeNode right; // 当前节点的右子节点
    public TreeNode(int val = 0, TreeNode left = null, TreeNode right = null) // 定义构造函数
    { // 构造函数开始
        this.val = val; // 初始化当前节点的值
        this.left = left; // 初始化左子节点
        this.right = right; // 初始化右子节点
    } // 构造函数结束
} // 节点类结束
public class Solution // 定义题解类
{ // 题解类开始
    public int KthSmallest(TreeNode root, int k) // 定义寻找 BST 第 k 小元素的方法
    { // 方法开始
        Stack<TreeNode> stack = new Stack<TreeNode>(); // 创建栈,用来模拟递归中序遍历
        TreeNode current = root; // current 表示当前正在遍历的节点
        while (current != null || stack.Count > 0) // 当前节点不为空或者栈不为空时继续遍历
        { // 外层循环开始
            while (current != null) // 一直向左走,找到当前子树最小的节点
            { // 内层循环开始
                stack.Push(current); // 把当前节点压入栈中,等左子树处理完再访问它
                current = current.left; // 继续走向左子节点
            } // 内层循环结束
            current = stack.Pop(); // 弹出栈顶节点,这就是当前应该访问的节点
            k--; // 每访问一个节点,说明找到了一个更小排名的元素
            if (k == 0) // 如果已经访问到第 k 个节点
            { // 判断开始
                return current.val; // 当前节点的值就是第 k 小元素
            } // 判断结束
            current = current.right; // 访问完当前节点后,转向它的右子树
        } // 外层循环结束
        return -1; // 正常题目保证 k 合法,这一行理论上不会执行
    } // 方法结束
} // 题解类结束

复杂度

时间复杂度:O(h + k)h 是树高,因为先走到最左边要压栈;之后访问到第 k 个节点就可以提前停止。

空间复杂度:O(h)。 栈里最多存一条从根到叶子的路径。

面试记忆

NOTE

这题不要把整棵树转成数组再取第 k 个。 更好的说法是:中序遍历 BST 是升序,访问一个节点就 k--,k 为 0 时返回。

路径总和

路径总和

这题通常指:判断二叉树里是否存在一条 从根节点到叶子节点 的路径,使路径上的节点值之和等于 targetSum

注意两个条件:

1. 必须从 root 开始
2. 必须到叶子节点结束

中间某一段凑够了不算。

path-sum-csharp

核心思路

每往下走一个节点,就把目标值减掉当前节点值。

比如目标值是 22

5 -> 4 -> 11 -> 2

计算过程可以理解成:

c
22 - 5 = 17
17 - 4 = 13
13 - 11 = 2
最后走到叶子节点 2,刚好等于剩余值 2

所以返回 true

C# 代码

c
public class TreeNode // 定义二叉树节点类
{ // 节点类开始
    public int val; // 当前节点的值
    public TreeNode left; // 当前节点的左子节点
    public TreeNode right; // 当前节点的右子节点
    public TreeNode(int val = 0, TreeNode left = null, TreeNode right = null) // 定义构造函数
    { // 构造函数开始
        this.val = val; // 初始化当前节点的值
        this.left = left; // 初始化左子节点
        this.right = right; // 初始化右子节点
    } // 构造函数结束
} // 节点类结束
public class Solution // 定义题解类
{ // 题解类开始
    public bool HasPathSum(TreeNode root, int targetSum) // 定义判断路径总和的方法
    { // 方法开始
        if (root == null) // 如果当前节点为空
        { // 空节点判断开始
            return false; // 空节点无法形成根到叶子的路径
        } // 空节点判断结束
        if (root.left == null && root.right == null) // 如果当前节点是叶子节点
        { // 叶子节点判断开始
            return root.val == targetSum; // 判断叶子节点值是否刚好等于剩余目标值
        } // 叶子节点判断结束
        int remain = targetSum - root.val; // 用目标值减去当前节点值,得到子节点需要凑出的剩余值
        bool leftHasPath = HasPathSum(root.left, remain); // 递归判断左子树是否存在合法路径
        bool rightHasPath = HasPathSum(root.right, remain); // 递归判断右子树是否存在合法路径
        return leftHasPath || rightHasPath; // 左子树或右子树任意一边存在合法路径即可
    } // 方法结束
} // 题解类结束

复杂度

时间复杂度:O(n),最坏情况下每个节点都要看一次。 空间复杂度:O(h)h 是树的高度,主要来自递归调用栈。

面试记忆

NOTE

这题不是求任意路径和,而是 根到叶子路径和。 递归时记住一句话:当前节点吃掉一部分 target,剩下的交给左右子树。

二叉树直径

二叉树直径

二叉树直径指的是:二叉树中任意两个节点之间最长路径的长度

注意:这条最长路径 不一定经过根节点。 所以我们不能只算根节点左边多深、右边多深,而是要让 每个节点都尝试成为最长路径的拐点

binary-tree-diameter-csharp

核心思路

对每个节点来说:

经过当前节点的最长路径 = 左子树高度 + 右子树高度

比如:

      1
     / \
    2   3
   / \
  4   5

最长路径可以是:

c
4 -> 2 -> 1 -> 3

这条路径有 3 条边,所以直径是 3

DFS 的时候做两件事:

1. 返回当前节点的高度
2. 用 leftHeight + rightHeight 更新最大直径

C# 代码

c
public class TreeNode // 定义二叉树节点类
{ // 节点类开始
    public int val; // 当前节点的值
    public TreeNode left; // 当前节点的左子节点
    public TreeNode right; // 当前节点的右子节点
    public TreeNode(int val = 0, TreeNode left = null, TreeNode right = null) // 定义构造函数
    { // 构造函数开始
        this.val = val; // 初始化当前节点的值
        this.left = left; // 初始化左子节点
        this.right = right; // 初始化右子节点
    } // 构造函数结束
} // 节点类结束
public class Solution // 定义题解类
{ // 题解类开始
    private int maxDiameter; // 记录当前找到的最大直径
    public int DiameterOfBinaryTree(TreeNode root) // 定义求二叉树直径的方法
    { // 方法开始
        maxDiameter = 0; // 初始化最大直径为 0
        GetHeight(root); // 通过 DFS 计算高度,同时更新最大直径
        return maxDiameter; // 返回最终最大直径
    } // 方法结束
    private int GetHeight(TreeNode node) // 定义求当前节点高度的方法
    { // 方法开始
        if (node == null) // 如果当前节点为空
        { // 空节点判断开始
            return 0; // 空节点高度为 0
        } // 空节点判断结束
        int leftHeight = GetHeight(node.left); // 递归求左子树高度
        int rightHeight = GetHeight(node.right); // 递归求右子树高度
        int currentDiameter = leftHeight + rightHeight; // 经过当前节点的最长路径长度
        maxDiameter = System.Math.Max(maxDiameter, currentDiameter); // 用当前路径长度更新最大直径
        return System.Math.Max(leftHeight, rightHeight) + 1; // 返回当前节点的高度
    } // 方法结束
} // 题解类结束

复杂度

时间复杂度:O(n),每个节点只访问一次。 空间复杂度:O(h)h 是树的高度,主要来自递归调用栈。

面试记忆

WARNING

DFS 返回的是 高度。 全局答案更新的是 左高度 + 右高度。 因为这表示:从左子树最深处,经过当前节点,再到右子树最深处的路径长度

拓扑排序

拓扑排序

拓扑排序用来解决 有依赖关系的排序问题

比如:

A 必须在 B 前面
B 必须在 C 前面

那合法顺序可以是:

c
A -> B -> C

在图里,如果有一条边:

c
u -> v

意思就是:u 必须排在 v 前面

topological-sort-csharp

核心思路:入度 + 队列

入度就是:有多少条边指向当前节点

入度为 0:没有前置依赖,可以先做
入度不为 0:还有前置任务没完成,暂时不能做

流程:

1. 统计每个节点的入度
2. 把所有入度为 0 的节点放进队列
3. 每次弹出一个节点,加入结果
4. 删除它指向别人的边,也就是让后继节点入度减 1
5. 如果某个后继节点入度变成 0,就加入队列
6. 最后如果结果数量少于节点数量,说明有环

C# 代码

c
using System.Collections.Generic; // 引入 List 和 Queue 等集合类型
public class Solution // 定义题解类
{ // 类开始
    public List<int> TopologicalSort(int nodeCount, int[][] edges) // 定义拓扑排序方法,nodeCount 是节点数量,edges 是有向边
    { // 方法开始
        List<int>[] graph = new List<int>[nodeCount]; // 创建邻接表,用来保存每个节点指向哪些节点
        int[] indegree = new int[nodeCount]; // 创建入度数组,记录每个节点有多少前置依赖
        for (int i = 0; i < nodeCount; i++) // 遍历所有节点
        { // 循环开始
            graph[i] = new List<int>(); // 给每个节点初始化一个邻接列表
        } // 循环结束
        for (int i = 0; i < edges.Length; i++) // 遍历所有有向边
        { // 循环开始
            int from = edges[i][0]; // 取出边的起点
            int to = edges[i][1]; // 取出边的终点
            graph[from].Add(to); // 在邻接表中记录 from 指向 to
            indegree[to]++; // 因为有一条边指向 to,所以 to 的入度加一
        } // 循环结束
        Queue<int> queue = new Queue<int>(); // 创建队列,用来保存当前入度为 0 的节点
        for (int i = 0; i < nodeCount; i++) // 遍历所有节点
        { // 循环开始
            if (indegree[i] == 0) // 如果当前节点没有前置依赖
            { // 判断开始
                queue.Enqueue(i); // 把当前节点加入队列
            } // 判断结束
        } // 循环结束
        List<int> result = new List<int>(); // 创建结果列表,用来保存拓扑排序结果
        while (queue.Count > 0) // 只要队列中还有可以处理的节点
        { // 循环开始
            int current = queue.Dequeue(); // 弹出一个入度为 0 的节点
            result.Add(current); // 把当前节点加入拓扑排序结果
            for (int i = 0; i < graph[current].Count; i++) // 遍历当前节点指向的所有后继节点
            { // 循环开始
                int next = graph[current][i]; // 取出一个后继节点
                indegree[next]--; // 删除 current 指向 next 的边,所以 next 入度减一
                if (indegree[next] == 0) // 如果 next 的所有前置依赖都已经处理完
                { // 判断开始
                    queue.Enqueue(next); // 把 next 加入队列等待处理
                } // 判断结束
            } // 循环结束
        } // 循环结束
        if (result.Count != nodeCount) // 如果没有处理完所有节点
        { // 判断开始
            return new List<int>(); // 说明图中有环,无法完成拓扑排序,返回空列表
        } // 判断结束
        return result; // 返回拓扑排序结果
    } // 方法结束
} // 类结束

复杂度

时间复杂度:O(V + E)V 是节点数,E 是边数。 空间复杂度:O(V + E),主要是邻接表、入度数组和队列。

面试记忆

CAUTION

拓扑排序一句话:入度为 0 的先做,做完就删掉它的出边。 如果最后还有节点没处理完,说明它们互相依赖,图里有环。

课程表

课程表

这题是拓扑排序的经典应用:判断课程依赖图里有没有环

如果有环,比如:

学 0 之前要先学 1
学 1 之前又要先学 0

那就永远无法开始,说明不能完成所有课程。

course-schedule-csharp

核心思路

prerequisites[i] = [a, b] 的意思是:

想学 a,必须先学 b

所以建边是:

c
b -> a

然后用入度表:

入度 = 当前课程还有几门前置课没学

流程:

1. 建图,统计每门课的入度
2. 把入度为 0 的课程放入队列
3. 每次从队列取一门课,表示学完它
4. 它指向的后续课程入度减 1
5. 如果某门课入度变成 0,就加入队列
6. 最后看学完的课程数量是否等于 numCourses

C# 代码

c
using System.Collections.Generic; // 引入 List 和 Queue 等集合类型
public class Solution // 定义题解类
{ // 类开始
    public bool CanFinish(int numCourses, int[][] prerequisites) // 定义判断是否能完成所有课程的方法
    { // 方法开始
        List<int>[] graph = new List<int>[numCourses]; // 创建邻接表,graph[i] 表示学完课程 i 后可以学哪些课程
        int[] indegree = new int[numCourses]; // 创建入度数组,indegree[i] 表示课程 i 还有几门前置课程
        for (int i = 0; i < numCourses; i++) // 遍历所有课程
        { // 循环开始
            graph[i] = new List<int>(); // 初始化每门课程对应的后续课程列表
        } // 循环结束
        for (int i = 0; i < prerequisites.Length; i++) // 遍历所有先修关系
        { // 循环开始
            int course = prerequisites[i][0]; // 取出要学习的课程
            int preCourse = prerequisites[i][1]; // 取出它的先修课程
            graph[preCourse].Add(course); // 建边 preCourse -> course,表示学完 preCourse 才能学 course
            indegree[course]++; // course 多了一门前置课程,所以入度加一
        } // 循环结束
        Queue<int> queue = new Queue<int>(); // 创建队列,用来保存当前可以学习的课程
        for (int i = 0; i < numCourses; i++) // 遍历所有课程
        { // 循环开始
            if (indegree[i] == 0) // 如果当前课程没有任何前置课程
            { // 判断开始
                queue.Enqueue(i); // 当前课程可以立即学习,加入队列
            } // 判断结束
        } // 循环结束
        int learnedCount = 0; // 记录已经学完的课程数量
        while (queue.Count > 0) // 只要还有可以学习的课程
        { // 循环开始
            int current = queue.Dequeue(); // 取出一门当前可以学习的课程
            learnedCount++; // 学完这门课,已学课程数量加一
            for (int i = 0; i < graph[current].Count; i++) // 遍历学完 current 后可以解锁的课程
            { // 循环开始
                int next = graph[current][i]; // 取出一门后续课程
                indegree[next]--; // current 已经学完,所以 next 少了一门未完成的前置课程
                if (indegree[next] == 0) // 如果 next 的所有前置课程都学完了
                { // 判断开始
                    queue.Enqueue(next); // next 现在可以学习,加入队列
                } // 判断结束
            } // 循环结束
        } // 循环结束
        return learnedCount == numCourses; // 如果学完数量等于课程总数,说明可以完成所有课程
    } // 方法结束
} // 类结束

复杂度

时间复杂度:O(V + E)V 是课程数,E 是先修关系数。 空间复杂度:O(V + E),主要是邻接表、入度数组和队列。

面试记忆

IMPORTANT

课程表就是:拓扑排序判断有向图是否有环[a, b] 一定要记住是:先学 b,再学 a,所以建边 b -> a

最小生成树

最小生成树

最小生成树 MST 解决的是:用最小总代价,把所有点连起来,并且不能形成环

它通常出现在:

修路最小成本
网络布线最小成本
连接所有城市的最小费用

minimum-spanning-tree-csharp

核心思路:Kruskal

Kruskal 很适合面试手写:

1. 把所有边按权重从小到大排序
2. 从最小的边开始尝试选择
3. 如果这条边连接的是两个不同集合,就选它
4. 如果这条边会形成环,就跳过
5. 选够 n - 1 条边时结束

判断是否成环,用 并查集 Union-Find

C# 代码

c
using System; // 引入基础系统命名空间
using System.Collections.Generic; // 引入 List 集合类型

public class Edge // 定义边类
{ // 边类开始
    public int From; // 边的起点
    public int To; // 边的终点
    public int Weight; // 边的权重
    public Edge(int from, int to, int weight) // 定义边的构造函数
    { // 构造函数开始
        From = from; // 初始化起点
        To = to; // 初始化终点
        Weight = weight; // 初始化权重
    } // 构造函数结束
} // 边类结束

public class UnionFind // 定义并查集类
{ // 并查集类开始
    private int[] parent; // parent[i] 表示 i 的父节点
    private int[] rank; // rank[i] 表示集合树的大致高度
    public UnionFind(int n) // 定义并查集构造函数
    { // 构造函数开始
        parent = new int[n]; // 创建父节点数组
        rank = new int[n]; // 创建秩数组
        for (int i = 0; i < n; i++) // 初始化每个节点
        { // 循环开始
            parent[i] = i; // 每个节点一开始的父节点都是自己
            rank[i] = 0; // 每个集合一开始高度为 0
        } // 循环结束
    } // 构造函数结束
    public int Find(int x) // 查找 x 所在集合的根节点
    { // 查找函数开始
        if (parent[x] != x) // 如果 x 不是根节点
        { // 判断开始
            parent[x] = Find(parent[x]); // 路径压缩,让 x 直接指向根节点
        } // 判断结束
        return parent[x]; // 返回 x 所在集合的根节点
    } // 查找函数结束
    public bool Union(int a, int b) // 合并 a 和 b 所在的两个集合
    { // 合并函数开始
        int rootA = Find(a); // 找到 a 的根节点
        int rootB = Find(b); // 找到 b 的根节点
        if (rootA == rootB) // 如果两个点已经在同一个集合
        { // 判断开始
            return false; // 再连接会形成环,所以合并失败
        } // 判断结束
        if (rank[rootA] < rank[rootB]) // 如果 A 集合高度更低
        { // 判断开始
            parent[rootA] = rootB; // 把 A 集合挂到 B 集合下面
        } // 判断结束
        else if (rank[rootA] > rank[rootB]) // 如果 B 集合高度更低
        { // 判断开始
            parent[rootB] = rootA; // 把 B 集合挂到 A 集合下面
        } // 判断结束
        else // 如果两个集合高度一样
        { // 分支开始
            parent[rootB] = rootA; // 把 B 集合挂到 A 集合下面
            rank[rootA]++; // A 集合高度加一
        } // 分支结束
        return true; // 合并成功,说明这条边可以选择
    } // 合并函数结束
} // 并查集类结束

public class Solution // 定义题解类
{ // 题解类开始
    public int MinimumSpanningTree(int n, int[][] edges) // 定义求最小生成树总权重的方法
    { // 方法开始
        List<Edge> edgeList = new List<Edge>(); // 创建边列表
        for (int i = 0; i < edges.Length; i++) // 遍历输入的所有边
        { // 循环开始
            int from = edges[i][0]; // 取出边的起点
            int to = edges[i][1]; // 取出边的终点
            int weight = edges[i][2]; // 取出边的权重
            edgeList.Add(new Edge(from, to, weight)); // 把边加入边列表
        } // 循环结束
        edgeList.Sort((a, b) => a.Weight.CompareTo(b.Weight)); // 按边权从小到大排序
        UnionFind unionFind = new UnionFind(n); // 创建并查集
        int totalWeight = 0; // 记录最小生成树的总权重
        int selectedCount = 0; // 记录已经选中的边数量
        for (int i = 0; i < edgeList.Count; i++) // 从小到大遍历所有边
        { // 循环开始
            Edge edge = edgeList[i]; // 取出当前边
            if (unionFind.Union(edge.From, edge.To)) // 如果当前边连接的是两个不同集合
            { // 判断开始
                totalWeight += edge.Weight; // 把当前边权重加入答案
                selectedCount++; // 选中的边数量加一
                if (selectedCount == n - 1) // 如果已经选够 n - 1 条边
                { // 判断开始
                    break; // 最小生成树已经完成,退出循环
                } // 判断结束
            } // 判断结束
        } // 循环结束
        if (selectedCount != n - 1) // 如果最后没有选够 n - 1 条边
        { // 判断开始
            return -1; // 说明图不连通,无法生成最小生成树
        } // 判断结束
        return totalWeight; // 返回最小生成树的总权重
    } // 方法结束
} // 题解类结束

复杂度

时间复杂度:O(E log E),主要花在边排序上。 空间复杂度:O(V + E),主要是边列表和并查集。

面试记忆

TIP

最小生成树有三个关键词:连通所有点、不能有环、总代价最小。 Kruskal 的口诀是:边从小到大选,能连不同集合就选,会成环就跳过。

单词接龙

单词接龙

这题本质是:在一个隐式图里做 BFS 最短路

每个单词是一个节点。 如果两个单词只差一个字母,它们之间就有一条边。 因为每次转换的代价都是 1,所以用 BFS,第一次遇到 endWord 时就是最短路径。

word-ladder-csharp

例子

c
beginWord = "hit"
endWord = "cog"
wordList = ["hot","dot","dog","lot","log","cog"]

一条最短路径是:

c
hit -> hot -> dot -> dog -> cog

返回 5,因为题目要的是 接龙序列里的单词数量,不是修改次数。

C# 代码

c
using System.Collections.Generic; // 引入 HashSet、Queue、IList 等集合类型
public class Solution // 定义题解类
{ // 类开始
    public int LadderLength(string beginWord, string endWord, IList<string> wordList) // 定义求最短接龙长度的方法
    { // 方法开始
        HashSet<string> wordSet = new HashSet<string>(wordList); // 把 wordList 放进哈希表,方便 O(1) 查询单词是否存在
        if (!wordSet.Contains(endWord)) // 如果终点单词不在字典里
        { // 判断开始
            return 0; // 无论怎么转换都到不了终点,直接返回 0
        } // 判断结束
        Queue<string> queue = new Queue<string>(); // 创建 BFS 队列
        queue.Enqueue(beginWord); // 把起点单词加入队列
        HashSet<string> visited = new HashSet<string>(); // 创建访问集合,避免重复搜索同一个单词
        visited.Add(beginWord); // 标记起点单词已经访问过
        int step = 1; // step 表示当前接龙长度,起点本身算 1 个单词
        while (queue.Count > 0) // 只要队列不为空,就继续 BFS
        { // 外层循环开始
            int count = queue.Count; // 固定当前层的单词数量
            for (int i = 0; i < count; i++) // 遍历当前 BFS 层的所有单词
            { // for 循环开始
                string current = queue.Dequeue(); // 取出当前单词
                if (current == endWord) // 如果当前单词已经是终点单词
                { // 判断开始
                    return step; // 返回当前 BFS 层数,也就是最短接龙长度
                } // 判断结束
                char[] chars = current.ToCharArray(); // 把当前单词转成字符数组,方便修改某一位
                for (int index = 0; index < chars.Length; index++) // 枚举要修改的字符位置
                { // 位置循环开始
                    char originalChar = chars[index]; // 保存当前位置原来的字符
                    for (char c = 'a'; c <= 'z'; c++) // 尝试把当前位置替换成 a 到 z
                    { // 字符循环开始
                        if (c == originalChar) // 如果替换后的字符和原字符一样
                        { // 判断开始
                            continue; // 没有产生新单词,直接跳过
                        } // 判断结束
                        chars[index] = c; // 修改当前位置字符
                        string next = new string(chars); // 生成新的候选单词
                        if (wordSet.Contains(next) && !visited.Contains(next)) // 如果候选单词在字典中,并且还没有访问过
                        { // 判断开始
                            visited.Add(next); // 标记候选单词已经访问
                            queue.Enqueue(next); // 把候选单词加入队列,等待下一层 BFS
                        } // 判断结束
                    } // 字符循环结束
                    chars[index] = originalChar; // 恢复当前位置字符,避免影响下一轮修改
                } // 位置循环结束
            } // for 循环结束
            step++; // 当前层处理完,进入下一层,接龙长度加一
        } // 外层循环结束
        return 0; // BFS 结束仍然没有找到终点,说明无法转换
    } // 方法结束
} // 类结束

复杂度

时间复杂度:常见面试口径是 O(N * L * 26)N 是单词数量,L 是单词长度。

空间复杂度:O(N)。 主要是 wordSetvisited 和 BFS 队列。

面试记忆

NOTE

单词接龙就是:隐式图 + BFS 最短路。 每次枚举一个单词的每一位,把它替换成 a-z,能在字典中找到的新单词就是邻居。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0