AIGC标识 Hot100速记-第二部分

Hot 100 速记(第二部分)

本卷从 Hot 100 目录第 36 题开始,收录“二叉树”及后续分类;题目继续按照官网分类和顺序追加。

八、二叉树

94. 二叉树的中序遍历

题面

给定一个二叉树的根节点 root,返回它的中序遍历结果。

示例 1

二叉树 root = [1,null,2,3] 的结构
输入:root = [1,null,2,3]
输出:[1,3,2]

示例 2

输入:root = []
输出:[]

示例 3

输入:root = [1]
输出:[1]

数据范围

  • 树中节点数目在范围 [0, 100]
  • -100 <= Node.val <= 100

进阶

递归算法很简单,你可以通过迭代算法完成吗?

解法一:显式栈迭代

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        stack<TreeNode*> stk;
        vector<int> ans;
        while(!stk.empty() || root!=nullptr){
            while(root!=nullptr){
                stk.push(root);
                root = root->left;
            }
            root = stk.top();
            stk.pop();
            ans.push_back(root->val);
            root = root->right;
        }
        return ans;
    }
};

简单解释

先沿左子树不断向下,并把经过的节点压入栈中。到达空节点后,弹出栈顶并访问它,再转向该节点的右子树,重复这个过程。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h)h 为树的高度

注意点

  • 外层循环条件必须是“当前节点不为空或栈不为空”,否则走到最左侧空节点时会提前结束。
  • 固定顺序:向左压栈 → 弹出并访问栈顶 → 转向右子树。

记忆锚点

左边一路入栈,弹出访问,再走右边。

面试简述

用栈模拟递归过程:先把当前节点及其所有左节点压栈,然后弹出栈顶访问,再处理其右子树。每个节点入栈、出栈各一次,时间复杂度为 O(n),空间复杂度为 O(h)

解法二:递归

class Solution {
public:
    vector<int> ans;
    vector<int> inorderTraversal(TreeNode* root) {
        if(!root) return {};
        inorderTraversal(root->left);
        ans.push_back(root->val);
        inorderTraversal(root->right);
        return ans;
    }
};

简单解释

按照“左子树 → 根节点 → 右子树”的顺序递归访问节点,并将节点值依次加入 ans

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),来自递归调用栈

注意点

  • 中序遍历的代码顺序就是:递归左子树、记录根节点、递归右子树。
  • ans 是成员变量;如果同一个 Solution 对象会被重复调用,需要先清空它。

记忆锚点

中序遍历就是左、根、右。

面试简述

递归遍历左子树,访问当前节点,再递归遍历右子树。每个节点访问一次,时间复杂度为 O(n),递归栈空间复杂度为 O(h)

104. 二叉树的最大深度

题面

给定一个二叉树的根节点 root,返回其最大深度。

二叉树的最大深度是指从根节点到最远叶子节点的最长路径上的节点数。

示例 1

最大深度为 3 的二叉树
输入:root = [3,9,20,null,null,15,7]
输出:3

示例 2

输入:root = [1,null,2]
输出:2

数据范围

  • 树中节点的数量在 [0, 10^4] 区间内
  • -100 <= Node.val <= 100

解法一:递归

class Solution {
public:
    int ans = 0;
    int maxDepth(TreeNode* root) {
        if(!root) return 0;
        return max(maxDepth(root->left),maxDepth(root->right))+1;
    }
};

简单解释

空树的深度为 0。对于非空节点,分别递归求出左右子树的最大深度,取较大值后加上当前节点这一层。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),来自递归调用栈

注意点

  • 递归出口是空节点返回 0
  • 当前节点的深度为 max(左子树深度, 右子树深度) + 1,不要漏掉当前节点这一层。

记忆锚点

空节点为 0,左右最大值加 1。

面试简述

递归计算左右子树的最大深度,当前树的深度就是二者的较大值加一。每个节点访问一次,时间复杂度为 O(n),递归栈空间复杂度为 O(h)

解法二:BFS 层序遍历

class Solution {
public:
    int maxDepth(TreeNode* root) {
        if(!root) return 0;
        int ans = 0;
        queue<TreeNode*> q;
        q.push(root);

        while(!q.empty()){
            ans++;
            int count = q.size();
            while(count>0){
                TreeNode* t = q.front();
                q.pop();
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
                count--;
            }
        }
        return ans;


    }
};

简单解释

使用队列逐层遍历二叉树。每进入一轮外层循环,就完整处理当前一层并将深度 ans 加一;当队列为空时,ans 就是树的层数。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(w)w 为二叉树的最大宽度

注意点

  • 每层开始时先用 count = q.size() 固定本层节点数;处理本层时,新加入队列的是下一层节点。
  • ans 每处理完整一层只增加一次,空树需要直接返回 0

记忆锚点

队列一轮处理一层,外层循环次数就是深度。

面试简述

用队列进行层序遍历,每轮先记录当前层的节点数,只处理这一批节点,并把它们的孩子加入队列。每完成一层将答案加一,时间复杂度为 O(n),队列空间复杂度为 O(w)

226. 翻转二叉树

题面

给定一棵二叉树的根节点 root,翻转这棵二叉树,并返回其根节点。

示例 1

二叉树 [4,2,7,1,3,6,9] 翻转前后对比
输入:root = [4,2,7,1,3,6,9]
输出:[4,7,2,9,6,3,1]

示例 2

二叉树 [2,1,3] 翻转前后对比
输入:root = [2,1,3]
输出:[2,3,1]

示例 3

输入:root = []
输出:[]

数据范围

  • 树中节点数目在范围 [0, 100]
  • -100 <= Node.val <= 100

解法一:递归

class Solution {
public:
    TreeNode* invertTree(TreeNode* root) {
        if(!root) return nullptr;

        TreeNode* left = root->left;
        TreeNode* right = root->right;
        root->left = right;
        root->right = left;

        invertTree(left);
        invertTree(right);
        return root;
    }
};

简单解释

对于每个非空节点,先保存原来的左右子树,再交换两个孩子指针,然后递归翻转原来的左右子树。所有节点都完成相同操作后,整棵树就被镜像翻转。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),来自递归调用栈

注意点

  • 交换前先保存原来的 leftright,交换后仍要递归翻转这两棵原子树。
  • 空节点直接返回 nullptr;其余节点原地修改后返回 root

记忆锚点

每到一个节点就交换左右孩子,再递归翻转两边。

面试简述

递归访问每个节点,并交换它的左右孩子。交换后继续翻转原来的左右子树,最终返回根节点。每个节点只访问一次,时间复杂度为 O(n),递归栈空间复杂度为 O(h)

101. 对称二叉树

题面

给定一个二叉树的根节点 root,检查它是否轴对称。

示例 1

轴对称二叉树示例
输入:root = [1,2,2,3,4,4,3]
输出:true

示例 2

不对称二叉树示例
输入:root = [1,2,2,null,3,null,3]
输出:false

数据范围

  • 树中节点数目在范围 [1, 1000]
  • -100 <= Node.val <= 100

解法一:递归比较镜像节点

class Solution {
public:
    bool isSymmetric(TreeNode* root) {
        if(!root) return false;
        return isSym(root->left,root->right);
    }

    bool isSym(TreeNode* left, TreeNode* right){
        if(!left&&!right) return true; //都为空
        if((!left&&right) || (!right&&left) ) return false; //一个为空一个不为空
        if(left->val!=right->val) return false; //都不为空但不等

        return isSym(left->right,right->left) && isSym(left->left,right->right);
    }
};

简单解释

将根节点的左右子树视为两棵需要互为镜像的树。递归时同时取出一对镜像位置上的节点:两者都为空说明这一对对称,只有一个为空或节点值不同则不对称;当前节点相同后,再继续交叉比较它们的孩子。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),来自递归调用栈

注意点

  • 判断顺序是:都为空、只有一个为空、节点值不同;完成空指针判断后才能访问 val
  • 镜像比较必须交叉进行:left->rightright->leftleft->leftright->right
  • 题目保证根节点存在,因此开头返回 false 不影响提交;若作为通用函数处理空树,通常应认为空树对称。

记忆锚点

两边成对看:空空真、单空假、值不同假,孩子交叉比较。

面试简述

递归比较根节点左右子树中互为镜像的节点。每对节点先检查是否同时为空、是否只有一个为空以及值是否相等,再交叉比较两组孩子。每个节点最多访问一次,时间复杂度为 O(n),递归栈空间复杂度为 O(h)

543. 二叉树的直径

题面

给定一棵二叉树的根节点 root,返回这棵树的直径长度。

二叉树的直径是任意两个节点之间最长路径的长度。这条路径可能经过根节点,也可能不经过根节点。

两节点之间路径的长度由它们之间的边数表示。

示例 1

二叉树最长路径长度为 3 的示例
输入:root = [1,2,3,4,5]
输出:3
解释:最长路径为 [4,2,1,3][5,2,1,3],长度为 3

示例 2

输入:root = [1,2]
输出:1

数据范围

  • 树中节点数目在范围 [1, 10^4]
  • -100 <= Node.val <= 100

解法一:递归求高度

class Solution {
public:
    int ans = 0;
    int diameterOfBinaryTree(TreeNode* root) {
        dfs(root);
        return ans;
    }

    int dfs(TreeNode* root){
        if(!root) return 0;
        int left = dfs(root->left);
        int right = dfs(root->right);
        ans = max(ans,left+right);
        return max(left,right)+1;
    }
};

简单解释

dfs 返回以当前节点为根的子树高度。得到左右子树高度 leftright 后,经过当前节点的最长路径长度就是 left + right,用它更新全局直径;当前节点再向父节点返回较高子树的高度加一。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),来自递归调用栈

注意点

  • dfs 的返回值是子树高度,ans 保存整棵树目前发现的最大直径,不要混用两个含义。
  • 直径按边数计算,因此经过当前节点的路径长度是 left + right,这里不再加一。
  • 最长路径不一定经过整棵树的根节点,所以每个节点都要尝试更新 ans

记忆锚点

左右高度相加更新直径,较大高度加一返回父节点。

面试简述

后序递归计算每个节点左右子树的高度。经过当前节点的最长路径长度为左右高度之和,用它更新全局答案;再把当前子树高度返回给父节点。每个节点访问一次,时间复杂度为 O(n),递归栈空间复杂度为 O(h)

102. 二叉树的层序遍历

题面

给定一个二叉树的根节点 root,返回其节点值的层序遍历结果,即逐层地从左到右访问所有节点。

示例 1

按层遍历二叉树 [3,9,20,null,null,15,7]
输入:root = [3,9,20,null,null,15,7]
输出:[[3],[9,20],[15,7]]

示例 2

输入:root = [1]
输出:[[1]]

示例 3

输入:root = []
输出:[]

数据范围

  • 树中节点数目在范围 [0, 2000]
  • -1000 <= Node.val <= 1000

解法一:BFS 层序遍历

class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
        vector<vector<int>> ans;
        queue<TreeNode*> q;
        if(!root) return {};
        q.push(root);
        while(!q.empty()){
            int size = q.size();
            vector<int> layer;
            while(size>0){
                TreeNode* t = q.front();
                q.pop();
                layer.push_back(t->val);
                if(t->left) q.push(t->left);
                if(t->right) q.push(t->right);
                size--;
            }
            ans.push_back(layer);
        }
        return ans;
    }
};

简单解释

使用队列保存等待访问的节点。每轮外层循环先记录当前队列长度,它就是本层节点数;依次弹出这些节点,将节点值加入 layer,同时把它们的非空孩子加入队列。当前层处理完后,再把 layer 加入答案。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(w)w 为二叉树的最大宽度,不计返回结果

注意点

  • 每层开始时必须先保存 size = q.size();本层遍历过程中入队的是下一层节点。
  • 空树直接返回空数组,孩子节点非空时才入队。

记忆锚点

队列按层取:先定本层数量,再出队节点、入队孩子。

面试简述

使用队列进行 BFS。每轮根据开始时的队列长度处理完整的一层,把该层节点值放入一个数组,同时将下一层节点入队。每个节点入队、出队各一次,时间复杂度为 O(n),辅助空间复杂度为 O(w)

108. 将有序数组转换为二叉搜索树

题面

给定一个按严格升序排列的整数数组 nums,将其转换为一棵高度平衡二叉搜索树。

高度平衡二叉树是指每个节点的左右两个子树的高度差不超过 1

示例 1

数组 [-10,-3,0,5,9] 转换成高度平衡二叉搜索树
输入:nums = [-10,-3,0,5,9]
输出:[0,-3,9,-10,null,5]
解释:[0,-10,5,null,-3,null,9] 也会被视为正确答案。

示例 2

数组 [1,3] 对应的两种高度平衡二叉搜索树
输入:nums = [1,3]
输出:[3,1]
解释:[1,null,3][3,1] 都是高度平衡二叉搜索树。

数据范围

  • 1 <= nums.length <= 10^4
  • -10^4 <= nums[i] <= 10^4
  • nums 按严格递增顺序排列

解法一:分治递归

class Solution {
public:
    TreeNode* sortedArrayToBST(vector<int>& nums) {
        int L = 0 , R = nums.size()-1;
        return findMid(nums,L,R);
    }

    TreeNode* findMid(vector<int>& nums,int L,int R){
        TreeNode* root = nullptr;
        if(L <= R){
            int mid = (L + R)/2;
            root = new TreeNode(nums[mid]);
            root->left = findMid(nums,L,mid-1);
            root->right = findMid(nums,mid+1,R);
        }
        return root;
    }
};

简单解释

每次选择当前区间的中间元素作为根节点。由于数组严格递增,中点左侧的元素全部属于左子树,右侧的元素全部属于右子树;不断递归划分区间,就能同时满足二叉搜索树性质和高度平衡要求。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(log n),不计创建的树节点

注意点

  • 递归区间是闭区间 [L, R],当 L > R 时返回空节点。
  • 左右递归区间必须排除中点,分别是 [L, mid - 1][mid + 1, R]

记忆锚点

有序数组每次取中点做根,左右区间递归建树。

面试简述

使用分治递归,每次取有序区间的中点作为根节点,再递归构造中点左侧和右侧的子树。这样既满足二叉搜索树的顺序,也能让左右子树规模尽量接近。时间复杂度为 O(n),递归栈空间复杂度为 O(log n)

98. 验证二叉搜索树

题面

给定一个二叉树的根节点 root,判断其是否为有效的二叉搜索树。

有效二叉搜索树需要满足以下条件:

  • 节点的左子树只包含严格小于当前节点的数
  • 节点的右子树只包含严格大于当前节点的数
  • 所有左子树和右子树自身也必须是二叉搜索树

示例 1

有效二叉搜索树 [2,1,3]
输入:root = [2,1,3]
输出:true

示例 2

无效二叉搜索树 [5,1,4,null,null,3,6]
输入:root = [5,1,4,null,null,3,6]
输出:false
解释:根节点的值为 5,但右子节点的值为 4

数据范围

  • 树中节点数目在范围 [1, 10^4]
  • -2^31 <= Node.val <= 2^31 - 1

解法一:中序遍历

class Solution {
public:
    bool isValidBST(TreeNode* root) {
        //搜索二叉树中序遍历一定是升序
        vector<int> nums;
        midOrder(nums,root);
        for(int i = 0 ; i < nums.size()-1; i++){
            if(nums[i]>=nums[i+1]){
                return false;
            }
        }
        return true;
    }

    void midOrder(vector<int>& nums,TreeNode* root){
        if(!root) return;
        midOrder(nums,root->left);
        nums.emplace_back(root->val);
        midOrder(nums,root->right);
    }
};

简单解释

二叉搜索树的中序遍历结果一定严格递增。先按照“左子树 → 根节点 → 右子树”的顺序收集所有节点值,再检查每一对相邻元素;只要前一个值大于或等于后一个值,就不是有效的二叉搜索树。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(n),用于保存中序遍历结果;递归栈还需要 O(h)

注意点

  • 必须检查严格递增,所以判断条件是 nums[i] >= nums[i + 1],重复值也不合法。
  • 中序遍历顺序必须是左子树、根节点、右子树。
  • 题目保证至少有一个节点;若处理空数组,nums.size() - 1 会发生无符号下溢,更稳妥的循环条件是 i + 1 < nums.size()

记忆锚点

BST 中序遍历严格递增,相邻元素不能大于或等于后一个。

面试简述

先对二叉树进行中序遍历并保存节点值,再检查结果是否严格递增。若存在相邻元素不满足前一个小于后一个,则不是有效 BST。时间复杂度为 O(n),保存遍历结果需要 O(n) 空间。

230. 二叉搜索树中第 K 小的元素

题面

给定一个二叉搜索树的根节点 root 和一个整数 k,查找其中第 k 小的元素,k1 开始计数。

示例 1

在二叉搜索树 [3,1,4,null,2] 中查找第 1 小元素
输入:root = [3,1,4,null,2], k = 1
输出:1

示例 2

在二叉搜索树 [5,3,6,2,4,null,null,1] 中查找第 3 小元素
输入:root = [5,3,6,2,4,null,null,1], k = 3
输出:3

数据范围

  • 树中的节点数为 n
  • 1 <= k <= n <= 10^4
  • 0 <= Node.val <= 10^4

进阶

如果二叉搜索树经常被修改,并且需要频繁查找第 k 小的值,应如何优化算法?

解法一:迭代中序遍历

class Solution {
public:
    int kthSmallest(TreeNode* root, int k) {
        if(!root) return {};
        stack<TreeNode*> stk;
        int count = 0;
        while(!stk.empty() || root!=nullptr){
            while(root){
                stk.push(root);
                root = root->left;
            }
            TreeNode* node= stk.top();
            stk.pop();
            count++;
            if(count == k) return node->val;
            if(node->right) root = node->right;
        }
        return -1;
    }
};

简单解释

用栈模拟中序遍历。先沿左子树不断入栈,再弹出并访问节点;二叉搜索树的中序遍历严格递增,因此第 k 个弹出的节点就是第 k 小的元素,可以直接返回。

复杂度

  • 时间复杂度:O(h + k),最坏为 O(n)
  • 空间复杂度:O(h)

注意点

  • count0 开始,每弹出并访问一个节点后加一;当 count == k 时返回。
  • 访问当前节点后要转向它的右子树,然后再次沿左侧入栈。
  • 题目保证根节点非空且 k 有效;return {} 在返回类型为 int 时表示返回 0

记忆锚点

BST 中序递增,第 k 个出栈节点就是第 k 小。

面试简述

使用显式栈进行中序遍历,节点按照从小到大的顺序出栈。每访问一个节点就增加计数,到第 k 个节点时立即返回。时间复杂度为 O(h + k),栈空间复杂度为 O(h)

解法二:递归中序遍历

class Solution {
public:
    int kthSmallest(TreeNode* root, int k) {
        vector<int> ord;
        mid(root,ord);
        return ord[k-1];
    }

    void mid(TreeNode* root, vector<int>& nums){
        if(!root) return;
        mid(root->left,nums);
        nums.push_back(root->val);
        mid(root->right,nums);
    }
};

简单解释

递归执行中序遍历,将二叉搜索树的所有节点值按升序保存到 ord。由于 k1 开始计数,最终返回数组下标 k - 1 对应的值。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(n),用于保存遍历结果;递归栈还需要 O(h)

注意点

  • 中序遍历顺序是左子树、根节点、右子树。
  • k1 开始计数,而数组下标从 0 开始,因此答案是 ord[k - 1]

记忆锚点

递归中序得到升序数组,直接取下标 k - 1。

面试简述

递归中序遍历 BST,将所有节点值保存为升序数组,再返回 ord[k - 1]。时间复杂度为 O(n),保存结果需要 O(n) 空间。

199. 二叉树的右视图

题面

给定一个二叉树的根节点 root,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。

示例 1

二叉树 [1,2,3,null,5,null,4] 的右视图
输入:root = [1,2,3,null,5,null,4]
输出:[1,3,4]

示例 2

二叉树 [1,2,3,4,null,null,null,5] 的右视图
输入:root = [1,2,3,4,null,null,null,5]
输出:[1,3,4,5]

示例 3

输入:root = [1,null,3]
输出:[1,3]

示例 4

输入:root = []
输出:[]

数据范围

  • 二叉树的节点个数在范围 [0, 100]
  • -100 <= Node.val <= 100

解法一:BFS 层序遍历

class Solution {
public:
    vector<int> rightSideView(TreeNode* root) {
        vector<int> ans;
        queue<TreeNode*> q;
        if(!root) return {};
        q.push(root);
        while(!q.empty()){
            int size = q.size();
            while(size--){
                TreeNode* top = q.front();
                q.pop();
                if(top->left) q.push(top->left);
                if(top->right) q.push(top->right);
                if(size == 0){
                    ans.push_back(top->val);
                }
            }
        }
        return ans;
    }
};

简单解释

使用队列逐层遍历二叉树,并按照先左孩子、后右孩子的顺序入队。这样每层最后一个出队的节点就是这一层最右侧的节点,将它的值加入答案即可。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(w)w 为二叉树的最大宽度

注意点

  • 每层开始时先固定 size = q.size(),只处理当前层的节点。
  • 代码先让左孩子入队、再让右孩子入队,因此本层最后一个节点才是右侧可见节点。
  • while(size--) 会先判断再递减,所以循环体中的 size == 0 表示当前处理的是本层最后一个节点。

记忆锚点

层序遍历从左到右,每层只记录最后一个节点。

面试简述

使用 BFS 逐层访问二叉树,每层按从左到右的顺序处理节点,并记录该层最后一个节点的值。每个节点入队、出队各一次,时间复杂度为 O(n),队列空间复杂度为 O(w)

114. 二叉树展开为链表

题面

给定二叉树的根节点 root,将它原地展开为一个单链表:

  • 展开后仍然使用 TreeNode,其中 right 指向链表中的下一个节点,left 始终为 null
  • 链表中的节点顺序应与二叉树的前序遍历顺序相同

示例 1

二叉树按照前序遍历顺序展开为右链表
输入:root = [1,2,5,3,4,null,6]
输出:[1,null,2,null,3,null,4,null,5,null,6]

示例 2

输入:root = []
输出:[]

示例 3

输入:root = [0]
输出:[0]

数据范围

  • 树中节点数目在范围 [0, 2000]
  • -100 <= Node.val <= 100

进阶

能否使用 O(1) 额外空间原地展开这棵树?

解法一:反前序递归

class Solution {
public:
    TreeNode* preNode = nullptr;
    void flatten(TreeNode* root) {
        if (root == NULL) return;
        flatten(root->right);
        flatten(root->left);
        root->left = NULL;
        root->right = preNode;
        preNode = root;
    }
};

简单解释

最终链表的顺序是“根 → 左 → 右”,因此反向处理顺序就是“右 → 左 → 根”。递归处理右子树和左子树后,preNode 指向当前节点在最终链表中的下一个节点;将当前节点的 right 接到 preNode,再令 preNode 指向当前节点。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),来自递归调用栈

注意点

  • preNode 必须初始化为 nullptr;原代码未初始化会得到不确定的指针值,文档版已修正。
  • 递归顺序必须是右子树、左子树,完成递归后再调整当前节点。
  • 调整顺序是:left 置空、right 指向 preNode、最后更新 preNode = root

记忆锚点

反着做前序:右、左、根;当前节点的右边接上一个处理好的节点。

面试简述

按照前序遍历的逆序“右、左、根”递归。维护已经展开部分的头节点 preNode,处理当前节点时将其右指针接到 preNode,左指针置空,再更新链表头。时间复杂度为 O(n),递归栈空间复杂度为 O(h)

解法二:前序遍历后重新连接

class Solution {
public:
    void flatten(TreeNode* root) {
        if(!root) return;
        vector<TreeNode*> o;
        f(root,o);
        for(int i = 0; i < o.size()-1; i++){
            o[i]->left = nullptr;
            o[i]->right = o[i+1];
        }
        // return o[0];
    }

    void f(TreeNode* root, vector<TreeNode*>& o){
        if(!root) return ;
        o.push_back(root);
        f(root->left,o);
        f(root->right,o);
    }
};

简单解释

先进行一次前序遍历,按照最终链表顺序保存所有节点指针。遍历数组中的相邻节点,将前一个节点的 left 置空,并让它的 right 指向后一个节点。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(n),用于保存节点指针;递归栈还需要 O(h)

注意点

  • 必须先完成整棵树的前序遍历,再修改节点指针,否则可能丢失尚未遍历的原右子树。
  • 连接相邻节点时,同时执行 left = nullptrright = o[i + 1]

记忆锚点

先把前序节点全部记下来,再按数组顺序串成右链表。

面试简述

先用前序遍历保存全部节点指针,再依次把相邻节点连接起来,并将所有左指针置空。时间复杂度为 O(n),保存节点数组需要 O(n) 额外空间。

解法三:寻找左子树的前驱节点

class Solution {
public:
    void flatten(TreeNode* root) {
        TreeNode* curr = root;
        while(curr){
            if(curr->left){
                auto next = curr->left;
                auto predece = next;
                while(predece->right){
                    predece = predece->right;
                }
                predece->right = curr->right;
                curr->left = nullptr;
                curr->right = next;
            }
            curr = curr->right;
        }
    }
};

简单解释

前序顺序是“当前节点 → 左子树 → 右子树”。当 curr 存在左子树时:

  1. next 保存整棵左子树。
  2. 找到左子树中沿 right 指针能到达的最右节点 predece,用它暂时承接原右子树。
  3. predece->right 接上当前节点原来的右子树。
  4. 将整棵左子树搬到 curr->right,并把 curr->left 置空。

完成调整后,继续沿着 right 指针向后处理。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

注意点

  • 要找的是左子树中最右侧的节点,不是直接使用 curr->left
  • 顺序要记成:左子树最右节点接旧右子树 → left 置空 → 左子树搬到 right
  • curr 每次沿 right 前进,因为已经处理过的部分正在逐渐变成最终链表。

记忆锚点

有左子树:左边最右接旧右,整棵左树搬到右,左指针置空。

面试简述

遍历右链。若当前节点有左子树,就找到左子树的最右节点,让它接上当前节点原来的右子树,再把左子树整体移动到右侧并清空左指针。这样无需额外容器即可原地展开,时间复杂度为 O(n),额外空间复杂度为 O(1)

105. 从前序与中序遍历序列构造二叉树

题面

给定两个整数数组 preorderinorder,其中 preorder 是一棵二叉树的前序遍历,inorder 是同一棵树的中序遍历,请构造这棵二叉树并返回其根节点。

示例 1

由前序和中序遍历构造出的二叉树
输入:preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
输出:[3,9,20,null,null,15,7]

示例 2

输入:preorder = [-1], inorder = [-1]
输出:[-1]

数据范围

  • 1 <= preorder.length <= 3000
  • inorder.length == preorder.length
  • -3000 <= preorder[i], inorder[i] <= 3000
  • preorderinorder 中的元素均不重复
  • inorder 中的每个值都出现在 preorder
  • 两个数组保证分别是同一棵二叉树的前序和中序遍历序列

解法一:哈希表与分治递归

class Solution {
public:
    unordered_map<int,int> index;
    TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
        int n = preorder.size();
        for(int i = 0 ; i < n ; i++){
            index[inorder[i]] = i;
        }
        return build(preorder, inorder, 0 , n-1, 0 , n-1);
    }

    TreeNode* build(vector<int>& preorder, vector<int>& inorder, int pre_l , int pre_r ,
                                                                int in_l , int in_r){
        if(pre_l > pre_r) return nullptr;

        int pre_root = pre_l;
        int in_root = index[preorder[pre_root]];

        TreeNode* root = new TreeNode(preorder[pre_root]);
        int left_size = in_root - in_l;

        root->left = build(preorder,inorder,pre_l+1, pre_l+left_size, in_l,in_root-1);
        root->right = build(preorder,inorder,pre_l+left_size+1,pre_r, in_root+1,in_r);

        return root;
    }
};

简单解释

前序遍历的第一个元素一定是当前子树的根节点。通过哈希表找到根节点在中序遍历中的位置后,中序区间就被分成左右子树;再根据左子树的节点数量,换算出前序遍历中左右子树各自的区间。

left_size = in_root - in_l,区间对应关系如下:

子树 前序遍历区间 中序遍历区间
左子树 [pre_l + 1, pre_l + left_size] [in_l, in_root - 1]
右子树 [pre_l + left_size + 1, pre_r] [in_root + 1, in_r]

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(n),用于哈希表;递归栈还需要 O(h)

注意点

  • 所有递归区间都是闭区间;当 pre_l > pre_r 时,当前子树为空。
  • preorder[pre_l] 是当前根;它在中序遍历中的位置 in_root 将中序区间切成左右两部分。
  • left_size = in_root - in_l。前序左子树从 pre_l + 1 开始,共有 left_size 个节点,所以结束位置是 pre_l + left_size;右子树紧接着从 pre_l + left_size + 1 开始。
  • 哈希表能直接通过节点值查到中序下标,是因为题目保证所有节点值互不重复。

记忆锚点

前序第一个定根,中序下标切左右,左子树长度换算前序边界。

面试简述

先用哈希表记录每个值在中序遍历中的位置。递归时以前序区间第一个元素作为根,在中序数组中找到根的位置并计算左子树大小,据此划分前序和中序的左右区间,再分别构造左右子树。每个节点处理一次,时间复杂度为 O(n),哈希表空间复杂度为 O(n)

437. 路径总和 III

题面

给定一个二叉树的根节点 root 和一个整数 targetSum,求二叉树中节点值之和等于 targetSum 的路径数目。

路径不需要从根节点开始,也不需要在叶子节点结束,但方向必须向下,只能从父节点走向子节点。

示例 1

路径总和等于 8 的三条向下路径
输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
输出:3
解释:满足条件的路径为 5 → 35 → 2 → 1-3 → 11

示例 2

输入:root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22
输出:3

数据范围

  • 二叉树的节点个数在范围 [0, 1000]
  • -10^9 <= Node.val <= 10^9
  • -1000 <= targetSum <= 1000

解法一:前缀和与回溯

class Solution {
public:
    int ans = 0;

    // 当前递归路径上:前缀和 -> 出现次数
    unordered_map<long long,int> tab;

    int pathSum(TreeNode* root, int targetSum) {
        // 空路径的前缀和为 0,使从根节点开始的路径也能被统计
        tab[0] = 1;
        dfs(root,targetSum,0);
        return ans;
    }

    void dfs(TreeNode* root, int targetSum, long long preSum){
        if(!root) return;

        // 根节点到当前节点的前缀和
        preSum += root->val;

        // preSum - oldPreSum = targetSum
        // 因此查找当前路径上出现过多少次 preSum - targetSum
        if(tab.count(preSum-targetSum)){
            ans += tab[preSum-targetSum];
        }

        // 当前前缀和加入路径,再递归处理左右子树
        tab[preSum]++;
        dfs(root->left,targetSum,preSum);
        dfs(root->right,targetSum,preSum);

        // 回溯:离开当前节点,防止影响其他分支
        tab[preSum]--;
    }
};

简单解释

设从根节点到当前节点的前缀和为 preSum。如果当前路径上的某个旧前缀和为 oldPreSum,并且:

preSum - oldPreSum = targetSum

那么从旧前缀和之后到当前节点的这一段路径就满足要求。因此只需查询哈希表中 preSum - targetSum 出现了多少次,并把次数累加到答案。

哈希表只表示当前递归路径上的前缀和。进入节点时增加当前前缀和的次数,离开节点时撤销,避免把另一条分支上的前缀和错误地用于当前路径。

复杂度

  • 时间复杂度:平均 O(n)
  • 空间复杂度:O(n),用于前缀和哈希表;递归栈还需要 O(h)

注意点

  • tab[0] = 1 表示空前缀,使从根节点开始且总和正好为 targetSum 的路径能够被统计。
  • 必须先查询 preSum - targetSum,再执行 tab[preSum]++,否则当 targetSum == 0 时会把空路径错误地计入答案。
  • 递归左右子树前增加当前前缀和,左右子树处理完后必须执行 tab[preSum]-- 回溯,保证哈希表中只保留当前路径的状态。
  • 前缀和可能超出 int 范围,因此 preSum 和哈希表键使用 long long

记忆锚点

当前前缀减目标,查祖先前缀次数;进入加一,离开减一。

面试简述

在从根到当前节点的递归路径上维护前缀和出现次数。当前前缀和为 preSum 时,之前每出现一次 preSum - targetSum,就对应一条以当前节点结尾的合法路径。递归进入节点时记录前缀和,离开时回溯撤销。平均时间复杂度为 O(n),空间复杂度为 O(n)

236. 二叉树的最近公共祖先

题面

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

最近公共祖先被定义为:对于有根树中的两个节点 pq,最近公共祖先是最深的节点 x,使得 x 同时是 pq 的祖先。一个节点也可以是它自己的祖先。

示例 1 和示例 2 共用的二叉树结构

示例 1

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

示例 2

输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出:5
解释:节点可以是它自己的祖先,因此节点 5 是节点 5 和节点 4 的最近公共祖先。

示例 3

输入:root = [1,2], p = 1, q = 2
输出:1

数据范围

  • 树中节点数目在范围 [2, 10^5]
  • -10^9 <= Node.val <= 10^9
  • 所有节点值互不相同
  • p != q
  • pq 均存在于给定的二叉树中

解法一:递归判断目标节点位置

class Solution {
public:
    TreeNode* ans;
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        if(!root) return nullptr;
        dfs(root,p,q);
        return ans;
    }

    bool dfs(TreeNode* root, TreeNode* p ,TreeNode* q){
        if(!root) return false;
        if(root == q || root == p) {ans = root ; return true;}
        bool L = dfs(root->left,p,q);
        bool R = dfs(root->right,p,q);
        if(L && R) {ans = root; return true;}
        if(L || R) return true;
        return false;
    }
};

简单解释

dfs(root) 的返回值表示:以 root 为根的子树中是否至少包含 pq

  • 当前节点就是 pq 时,先把它记录为候选答案并返回 true
  • 左右子树都返回 true 时,说明 pq 分别位于当前节点两侧,当前节点就是最近公共祖先。
  • 只有一侧返回 true 时,将“找到目标节点”的状态继续向上传递。

如果一个目标节点本身是另一个目标节点的祖先,第一次遇到该目标节点时记录的答案就是正确结果;如果两个节点最终在更高位置分开,L && R 会用真正的公共祖先覆盖 ans

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),来自递归调用栈

注意点

  • dfs 返回的不是最近公共祖先,而是“当前子树是否包含 pq”。
  • 当前节点等于 pq 时可以直接返回 true,因为题目允许一个节点成为它自己的祖先。
  • L && R 表示两个目标节点分居左右子树,此时当前节点是公共祖先;L || R 只负责把找到目标的状态向上传递。
  • 该写法依赖题目保证 pq 都存在于树中。

记忆锚点

碰到目标先记录,左右都找到就用当前节点覆盖,单边找到继续向上报告。

面试简述

递归判断每棵子树中是否包含 pq。遇到目标节点时记录为候选答案;若某个节点的左右子树分别包含目标节点,则该节点就是最近公共祖先并覆盖答案。每个节点最多访问一次,时间复杂度为 O(n),递归栈空间复杂度为 O(h)

124. 二叉树中的最大路径和

题面

二叉树中的路径由一串节点组成,序列中每对相邻节点之间都存在一条边。同一个节点在一条路径中至多出现一次。

路径至少包含一个节点,并且不一定经过根节点。路径和是路径中各节点值的总和。

给定二叉树的根节点 root,返回其最大路径和。

示例 1

二叉树 [1,2,3] 中路径 2→1→3 的和最大
输入:root = [1,2,3]
输出:6
解释:最优路径是 2 → 1 → 3,路径和为 6

示例 2

二叉树中路径 15→20→7 的和最大
输入:root = [-10,9,20,null,null,15,7]
输出:42
解释:最优路径是 15 → 20 → 7,路径和为 42

数据范围

  • 树中节点数目在范围 [1, 3 * 10^4]
  • -1000 <= Node.val <= 1000

解法一:递归计算单边贡献

class Solution {
public:
    int ans = INT_MIN;
    int maxPathSum(TreeNode* root) {
        if(!root) return 0;
        dfs(root);
        return ans;
    }

    int dfs(TreeNode* root){
        if(!root) return 0;
        int L = dfs(root->left);
        int R = dfs(root->right);
        ans = max(ans,L+R+root->val);
        return max(root->val + max(L,R),0);
    }
};

简单解释

dfs 返回当前节点能够向父节点提供的最大单边路径贡献。由于传给父节点的路径不能产生分叉,所以只能选择左右子树中贡献较大的一侧,再加上当前节点值。

计算全局答案时,路径可以在当前节点处连接左右两侧,因此用 L + R + root->val 更新 ans。如果当前子树向上的贡献为负数,就返回 0,表示父节点不选择这条分支。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),来自递归调用栈

注意点

  • 更新全局答案时可以同时取左右两边:L + R + root->val;返回给父节点时只能取一边:root->val + max(L, R)
  • 返回值与 0 取最大值,表示负贡献直接舍弃;但必须先用当前节点更新 ans
  • ans 必须初始化为 INT_MIN,不能初始化为 0,因为路径至少包含一个节点且整棵树可能全是负数。

记忆锚点

当前节点左右都能算答案,向父节点只能带上较大的一边,负贡献不要。

面试简述

后序递归计算每个节点向父节点提供的最大单边贡献,并将负贡献截断为零。对于全局答案,尝试把当前节点的左右贡献连接起来;向父节点返回时则只能选择较大的一侧,避免路径分叉。时间复杂度为 O(n),递归栈空间复杂度为 O(h)

九、图论

200. 岛屿数量

题面

给定一个由 '1'(陆地)和 '0'(水)组成的二维网格 grid,计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向或竖直方向上相邻的陆地连接形成。可以假设网格的四条边均被水包围。

示例 1

输入:
grid = [
  ["1","1","1","1","0"],
  ["1","1","0","1","0"],
  ["1","1","0","0","0"],
  ["0","0","0","0","0"]
]
输出:1

示例 2

输入:
grid = [
  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
输出:3

数据范围

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 300
  • grid[i][j] 的值为 '0''1'

解法一:DFS 沉岛

class Solution {
public:
    int ans = 0;
    int m = 0;
    int n = 0;
    int numIslands(vector<vector<char>>& grid) {
        m = grid.size() ,n= grid[0].size();
        for(int i = 0 ; i < m ; i++){
            for(int j = 0 ; j < n ; j++){
                if(grid[i][j]=='1'){
                    dfs(grid,i,j);
                    ans++;
                }
            }
        }
        return ans;
    }

    void dfs(vector<vector<char>>& grid, int i, int j){
        if(i < 0 || i >= m || j < 0 || j>=n) return;
        if(grid[i][j] != '1') return;

        grid[i][j] = '0';
        dfs(grid,i,j-1);
        dfs(grid,i,j+1);
        dfs(grid,i-1,j);
        dfs(grid,i+1,j);
    }
};

简单解释

遍历整个网格。每当遇到一个尚未访问的陆地 '1',就说明发现了一座新岛屿,将答案加一,并从该位置开始 DFS。DFS 会把与它上下左右相连的所有陆地改为 '0',这样后续遍历时不会重复统计同一座岛。

复杂度

  • 时间复杂度:O(mn)
  • 空间复杂度:最坏为 O(mn),来自递归调用栈

注意点

  • DFS 中必须先判断坐标是否越界,再访问 grid[i][j]
  • 访问陆地后要立刻将其改为 '0',再递归四个方向,避免重复访问和无限递归。
  • 只有外层遍历首次遇到 '1' 时才将 ans 加一;岛屿只按上下左右连通,不包括对角线。

记忆锚点

遇到一块新陆地就计数,再用 DFS 把整座岛沉成水。

面试简述

扫描网格,遇到未访问陆地时岛屿数量加一,并用 DFS 将与它四向连通的所有陆地标记为水。每个格子最多被处理一次,时间复杂度为 O(mn),递归栈最坏需要 O(mn) 空间。

994. 腐烂的橘子

题面

给定一个 m × n 的网格 grid,每个单元格有以下三种取值:

  • 0 表示空单元格
  • 1 表示新鲜橘子
  • 2 表示腐烂橘子

每分钟,腐烂橘子四个方向上相邻的新鲜橘子都会腐烂。返回网格中没有新鲜橘子所需的最少分钟数;如果不可能让所有橘子腐烂,返回 -1

示例 1

腐烂橘子逐分钟向四周传播的过程
输入:grid = [[2,1,1],[1,1,0],[0,1,1]]
输出:4

示例 2

输入:grid = [[2,1,1],[0,1,1],[1,0,1]]
输出:-1
解释:左下角的橘子无法被四个方向上的腐烂橘子传染。

示例 3

输入:grid = [[0,2]]
输出:0
解释:0 分钟时已经没有新鲜橘子。

数据范围

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 10
  • grid[i][j] 只可能是 012

解法一:多源 BFS

class Solution {
public:
    int orangesRotting(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        queue<pair<int,int>> q;
        int fresh = 0;
        for(int i = 0 ; i < m ; i++){
            for(int j = 0 ; j < n ; j++){
                if(grid[i][j]==2){
                    q.push({i,j});
                }
                if(grid[i][j]==1){
                    fresh++;
                }
            }
        }
        if(fresh == 0) return 0;
        if(q.size() == 0) return -1;
        int ans = 0;
        while(!q.empty()){
            int size = q.size();
            while(size > 0){
                auto pair = q.front();
                q.pop();
                int i = pair.first, j = pair.second;
                if(fu(grid,i+1,j,m,n)){ q.push({i+1,j});fresh--;}
                if(fu(grid,i-1,j,m,n)){ q.push({i-1,j});fresh--;}
                if(fu(grid,i,j+1,m,n)){ q.push({i,j+1});fresh--;}
                if(fu(grid,i,j-1,m,n)){ q.push({i,j-1});fresh--;}
                size--;
            }
            ans++;
            if(fresh == 0) break;
        }
        if(fresh>0) return -1;
        return ans;
    }

    int fu(vector<vector<int>>& grid, int i ,int j, int m ,int n){
        if(i < 0 || i>= m || j < 0 || j>=n) return 0;
        if(grid[i][j]!=1) return 0;
        grid[i][j] = 2;
        return 1;
    }
};

简单解释

先把所有初始腐烂橘子同时加入队列,并统计新鲜橘子数量。它们都是第 0 分钟的起点,因此需要进行多源 BFS。

每轮处理队列中当前已有的全部腐烂橘子,把四周的新鲜橘子变为腐烂并加入队列。处理完一整层就表示过去一分钟;当新鲜橘子数量降为 0 时,当前分钟数就是最短时间。

辅助函数 fu 同时完成越界判断、新鲜橘子判断和腐烂标记,返回 1 表示成功感染了一个新鲜橘子。

复杂度

  • 时间复杂度:O(mn)
  • 空间复杂度:O(mn),用于 BFS 队列

注意点

  • 所有初始腐烂橘子必须一起入队,它们会在同一分钟同时向外扩散。
  • fu 中确认是新鲜橘子后要立刻标记为 2,再将其入队,避免同一个橘子被多个方向重复入队和重复减少 fresh
  • 每处理完整一层才执行一次 ans++;感染完最后一个新鲜橘子后立即结束,避免多计算一分钟。
  • 没有新鲜橘子时返回 0;BFS 结束后仍有新鲜橘子时返回 -1

记忆锚点

所有烂橘子同时入队,一层扩散一分钟,入队时立刻标记腐烂。

面试简述

将所有初始腐烂橘子作为多源 BFS 的起点,并统计新鲜橘子数量。每层向四周感染一次,对应一分钟;新鲜橘子入队时立即标记并减少计数。若最终仍有新鲜橘子则返回 -1,否则返回层数。时间和空间复杂度均为 O(mn)

207. 课程表

题面

本学期需要选修 numCourses 门课程,课程编号为 0numCourses - 1

先修课程由数组 prerequisites 给出,其中 prerequisites[i] = [a, b] 表示学习课程 a 之前必须先完成课程 b

判断是否可以完成所有课程;可以则返回 true,否则返回 false

示例 1

输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:先完成课程 0,再完成课程 1

示例 2

输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:课程 0 和课程 1 互为前置课程,形成环。

数据范围

  • 1 <= numCourses <= 2000
  • 0 <= prerequisites.length <= 5000
  • prerequisites[i].length == 2
  • 0 <= a, b < numCourses
  • 所有课程对互不相同

解法一:BFS 拓扑排序

class Solution {
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        vector<int> indegrees(numCourses,0); //该门课有几个前置课程,前置课程都学完 = 0就可以学了
        vector<vector<int>> adj(numCourses); //该课程对应后置课程,这门学了,后置课程的前置数都减1
        queue<int> q;

        for(auto& cp : prerequisites){
            indegrees[cp[0]]++;
            adj[cp[1]].emplace_back(cp[0]);
        }

        for(int i = 0 ; i < numCourses; i++){
            if(indegrees[i] == 0) q.push(i);
        }

        int count = 0;
        while(!q.empty()){
            int curr = q.front();
            q.pop();
            count++;
            for(int c : adj[curr]){
                if(--indegrees[c]==0) q.push(c);
            }
        }
        return count == numCourses;
    }
};

简单解释

将课程依赖关系看成有向图。对于课程对 [a, b],课程 b 是前置课程,因此建立边 b → a,并让课程 a 的入度加一。

先把所有入度为 0 的课程加入队列,它们可以立即学习。每学完一门课程,就将它所有后置课程的入度减一;某门后置课程的入度降为 0 时,再将它加入队列。最终能处理全部课程,说明图中无环。

复杂度

  • 时间复杂度:O(V + E)V 为课程数,E 为先修关系数
  • 空间复杂度:O(V + E)

注意点

  • [a, b] 表示先学 b 再学 a,所以建图方向是 b → aindegrees[a]++adj[b].push_back(a)
  • 只有入度减到恰好 0 时才将课程入队;出队一门课程就表示成功完成一门,count 加一。
  • 如果最终 count < numCourses,说明剩余课程构成环,无法出现入度为 0 的节点。

记忆锚点

前置课程指向后置课程,零入度入队,学完一门就减少后置课程入度。

面试简述

使用 Kahn 算法进行 BFS 拓扑排序。按照“前置课程指向后置课程”建图,将所有零入度课程入队;每取出一门课程,就减少其后置课程的入度。若最终处理的课程数等于总课程数,则不存在环。时间和空间复杂度均为 O(V + E)

208. 实现 Trie(前缀树)

题面

Trie(前缀树)是一种树形数据结构,用于高效存储和检索字符串集合中的键。

实现 Trie 类:

  • Trie():初始化前缀树对象
  • void insert(string word):将字符串 word 插入前缀树
  • bool search(string word):如果 word 已经存在于前缀树中,返回 true
  • bool startsWith(string prefix):如果存在已经插入的单词以 prefix 开头,返回 true

示例

输入:
["Trie", "insert", "search", "search", "startsWith", "insert", "search"]
[[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]]

输出:
[null, null, true, false, true, null, true]

数据范围

  • 1 <= word.length, prefix.length <= 2000
  • wordprefix 仅由小写英文字母组成
  • insertsearchstartsWith 的调用总次数不超过 3 * 10^4

解法一:固定大小孩子数组

struct Node{
    Node* son[26] {};
    bool end = false;
};

class Trie {
    Node* root = new Node();

    int find(string word){
        Node* cur = root;
        for(char c: word){
            c -= 'a';
            if(cur->son[c] == nullptr){ //走了不同路,不匹配
                return 0;
            }
            cur = cur->son[c];
        }
        //走过同样的路 2=完全匹配 1=前缀匹配
        return cur->end ? 2 : 1;
    }

    void destory(Node* node){
        if(node == nullptr) return;
        for(Node* son : node->son){
            destory(son); //递归删除
        }
        delete node;
    }
public:
    ~Trie() {
        destory(root);
    }

    void insert(string word) {
        Node* cur = root;
        for(char c: word){
            c -= 'a';
            if(cur->son[c]==nullptr){
                cur->son[c] = new Node();
            }
            cur = cur->son[c];
        }
        cur->end = true;
    }

    bool search(string word) {
        return find(word) == 2;
    }

    bool startsWith(string prefix) {
        return find(prefix)!=0;
    }
};

/**
 * Your Trie object will be instantiated and called as such:
 * Trie* obj = new Trie();
 * obj->insert(word);
 * bool param_2 = obj->search(word);
 * bool param_3 = obj->startsWith(prefix);
 */

简单解释

每个节点包含 26 个孩子指针,分别对应字母 az,并使用 end 标记是否有一个完整单词在当前节点结束。

  • 插入时沿着每个字符对应的孩子向下走,路径不存在就创建新节点,最后将终点的 end 设为 true
  • 查询时复用 find:中途缺少路径返回 0;走完整条路径但 end == false 返回 1,表示只匹配前缀;end == true 返回 2,表示完整单词匹配。
  • 析构时按照后序顺序递归删除所有孩子,再删除当前节点。

复杂度

设操作字符串的长度为 L

  • insertsearchstartsWith 的时间复杂度:O(L)
  • 空间复杂度:O(S)S 为所有已创建 Trie 节点的总数

注意点

  • Node* son[26] {} 末尾的 {} 会将所有孩子指针初始化为 nullptr;字符通过 c - 'a' 映射到下标 0~25
  • 走完整条字符路径不代表完整单词存在,还必须检查终点的 end;这就是 searchstartsWith 的区别。
  • find 的返回含义要记清:0 表示路径不存在,1 表示仅前缀匹配,2 表示完整单词匹配。
  • 动态创建的节点需要递归释放:先删除所有孩子,再删除当前节点。

记忆锚点

节点是“26 个孩子加 end”;插入缺路就建,查询没路失败,走完再看 end。

面试简述

使用 26 叉树保存小写单词,每条根到节点的路径代表一个前缀,end 标记完整单词结尾。插入和查询都逐字符沿孩子指针移动,时间复杂度为 O(L);所有节点在析构时递归释放。

十、回溯

46. 全排列

题面

给定一个不含重复数字的数组 nums,返回其所有可能的全排列。答案可以按任意顺序返回。

示例 1

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2

输入:nums = [0,1]
输出:[[0,1],[1,0]]

示例 3

输入:nums = [1]
输出:[[1]]

数据范围

  • 1 <= nums.length <= 6
  • -10 <= nums[i] <= 10
  • nums 中的所有整数互不相同

解法一:回溯与原地标记

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> cur;
    vector<vector<int>> permute(vector<int>& nums) {
        dfs(nums);
        return ans;
    }

    void dfs(vector<int>& nums){
        if(cur.size() == nums.size()) {
            ans.emplace_back(cur);
            return;
        }
        for(int i = 0 ; i < nums.size(); i++){
            if(nums[i]!= -100){
                cur.emplace_back(nums[i]);
                int temp = nums[i];
                nums[i] = -100;
                dfs(nums);
                nums[i] = temp;
                cur.pop_back();
            }
        }
    }
};

简单解释

cur 保存当前正在构造的排列。每一层递归都从头遍历 nums,选择一个尚未使用的数字加入 cur,并临时把该位置改为 -100 表示已经使用;递归返回后恢复原值并弹出刚加入的数字。

cur 的长度等于 nums 的长度时,一个完整排列构造完成,将其加入答案。

复杂度

  • 时间复杂度:O(n · n!)
  • 空间复杂度:O(n),不计返回结果;全部结果需要 O(n · n!) 空间

注意点

  • 排列问题的每一层都要从下标 0 开始尝试,因为任意尚未使用的数字都可以成为当前位置。
  • 回溯顺序固定为:选择数字 → 标记已使用 → 递归 → 恢复原值 → cur.pop_back()
  • -100 能作为哨兵,是因为题目保证 nums[i] 位于 [-10, 10];如果数据范围可能包含 -100,应改用独立的 used 数组。
  • cur.size() == nums.size() 时立即保存当前排列并返回。

记忆锚点

每层从头挑一个没用过的数,选完递归,回来恢复现场。

面试简述

使用回溯逐个确定排列中的每个位置。每层尝试所有尚未使用的数字,用哨兵原地标记使用状态;递归结束后恢复数字并撤销当前选择。共有 n! 个排列,每次复制长度为 n 的结果,时间复杂度为 O(n · n!)

78. 子集

题面

给定一个元素互不相同的整数数组 nums,返回该数组所有可能的子集,即幂集。

解集中不能包含重复子集,可以按任意顺序返回。

示例 1

输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2

输入:nums = [0]
输出:[[],[0]]

数据范围

  • 1 <= nums.length <= 10
  • -10 <= nums[i] <= 10
  • nums 中的所有元素互不相同

解法一:回溯枚举子集

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> cur;
    vector<vector<int>> subsets(vector<int>& nums) {
        dfs(nums,0);
        ans.push_back({});
        return ans;
    }

    void dfs(vector<int>& nums, int idx){
        for(int i = idx; i < nums.size(); i++){
            cur.push_back(nums[i]);
            ans.push_back(cur);
            dfs(nums,i+1);
            cur.pop_back();
        }
    }
};

简单解释

cur 保存当前子集,idx 表示下一次可以选择的起始下标。每次选择 nums[i] 后,当前 cur 就是一个合法子集,立即加入答案;下一层只从 i + 1 开始继续选择,避免重复使用元素和生成相同子集。

DFS 生成所有非空子集,最后再将空集单独加入答案。

复杂度

  • 时间复杂度:O(n · 2^n)
  • 空间复杂度:O(n),不计返回结果;全部结果需要 O(n · 2^n) 空间

注意点

  • 子集问题需要携带起始位置 idx,循环从 idx 开始,下一层递归传入 i + 1
  • 与全排列不同,当前路径上的每个节点都是一个合法子集,因此选择元素后立即保存 cur,不必等到固定长度。
  • 回溯顺序是:加入元素 → 保存当前子集 → 递归 → cur.pop_back()
  • 这份代码只在 DFS 中保存非空子集,因此结束后需要单独加入 {}

记忆锚点

从 idx 往后选,选一次就保存,下一层从 i + 1 继续。

面试简述

使用回溯枚举所有递增下标序列。每选择一个元素,当前路径就形成一个子集并加入答案;下一层从后一个下标继续,避免重复。总共有 2^n 个子集,复制结果的时间复杂度为 O(n · 2^n)

17. 电话号码的字母组合

题面

给定一个仅包含数字 2~9 的字符串 digits,返回所有它能表示的字母组合。答案可以按任意顺序返回。

数字到字母的映射与电话按键相同,数字 1 不对应任何字母:

电话键盘上数字 2 到 9 对应的字母
数字字母
2abc
3def
4ghi
5jkl
6mno
7pqrs
8tuv
9wxyz

示例 1

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

示例 2

输入:digits = "2"
输出:["a","b","c"]

数据范围

  • 1 <= digits.length <= 4
  • digits[i] 是范围 ['2', '9'] 内的数字

解法一:按数字逐层回溯

class Solution {
public:
    vector<string> ans;
    string cur;
    unordered_map<char,string> tab;
    vector<string> letterCombinations(string digits) {
        if(digits.empty()) return {};
        tab = {
            {'2',"abc"}, {'3',"def"},{'4',"ghi"},
            {'5',"jkl"}, {'6',"mno"},{'7',"pqrs"},
            {'8',"tuv"}, {'9',"wxyz"}
        };
        dfs(digits,0);
        return ans;
    }

    void dfs(string& digits, int idx){
        if(cur.size() == digits.size()){
            ans.push_back(cur);
            return;
        }
        char c = digits[idx];
        string s = tab[c];
        for(int i = 0; i < s.size(); i++){
            cur.push_back(s[i]);
            dfs(digits,idx+1);
            cur.pop_back();
        }
    }
};

简单解释

每一层递归负责处理 digits[idx]。先从哈希表中取出该数字对应的全部字母,再依次选择一个字母加入 cur,递归处理下一个数字,返回后撤销选择。

cur 的长度等于数字串长度时,说明每个数字都已经选择了一个字母,将当前组合加入答案。

复杂度

设输入中有 a 个数字对应 3 个字母,有 b 个数字对应 4 个字母,且 n = a + b

  • 时间复杂度:O(n · 3^a · 4^b)
  • 空间复杂度:O(n),不计返回结果

注意点

  • 文档版增加了 if(digits.empty()) return {};,避免空输入进入终止条件后产生 [""];当前题面虽然保证长度至少为 1,保留该判断更稳妥。
  • 每层只处理一个数字 digits[idx],从它对应的字母中选择一个后递归 idx + 1
  • 数字 79 各对应 4 个字母,其余数字对应 3 个字母。
  • 回溯顺序是:加入当前字母 → 递归下一个数字 → cur.pop_back()

记忆锚点

一层处理一个数字,枚举它的所有字母,选一个后进入下一层。

面试简述

使用哈希表保存电话按键映射,回溯的第 idx 层枚举第 idx 个数字对应的所有字母。每次选择一个字母后处理下一个数字,组合长度达到输入长度时保存答案。

39. 组合总和

题面

给定一个元素互不相同的整数数组 candidates 和一个目标整数 target,找出所有可以使数字和等于 target 的不同组合。

candidates 中的同一个数字可以被无限次重复选择。如果至少一个数字的选择次数不同,则两个组合不同。答案可以按任意顺序返回。

示例 1

输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]

示例 2

输入:candidates = [2,3,5], target = 8
输出:[[2,2,2,2],[2,3,3],[3,5]]

示例 3

输入:candidates = [2], target = 1
输出:[]

数据范围

  • 1 <= candidates.length <= 30
  • 2 <= candidates[i] <= 40
  • candidates 中的所有元素互不相同
  • 1 <= target <= 40
  • 和为 target 的不同组合数少于 150

解法一:可重复选择的回溯

class Solution {
public:

    vector<int> cur;
    vector<vector<int>> ans;
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        dfs(candidates,target,0,0);
        return ans;
    }

    void dfs(vector<int>& candidates, int target, int curSum, int idx){
        if(curSum > target) return;
        if(curSum == target){
            ans.push_back(cur);
            return;
        }
        for(int i = idx; i < candidates.size() ; i++){
            int temp = curSum;
            curSum+=candidates[i];
            cur.push_back(candidates[i]);
            dfs(candidates,target,curSum,i);
            cur.pop_back();
            curSum = temp;
        }

    }
};

简单解释

cur 保存当前组合,curSum 保存组合元素之和,idx 限制当前层只能从该下标之后选择,避免将数字顺序不同但元素相同的组合重复加入答案。

选择 candidates[i] 后,下一层递归仍然传入 i,因此该数字可以继续被重复选择。递归返回后,弹出当前数字并恢复进入本轮前的 curSum

复杂度

d = target / min(candidates),递归深度最多约为 d

  • 时间复杂度:最坏为指数级,可粗略表示为 O(n^d)
  • 空间复杂度:O(d),不计返回结果

注意点

  • 循环从 idx 开始,保证组合中的候选下标单调不减,避免产生 [2,3,2][2,2,3] 这类顺序不同的重复组合。
  • 同一个数字可以无限次使用,所以递归传入的是 i;如果每个数字只能使用一次,才传 i + 1
  • 所有候选数字都是正数,因此 curSum > target 后可以直接剪枝;curSum == target 时保存当前组合并返回。
  • 回溯时既要 cur.pop_back(),也要把 curSum 恢复为选择当前数字之前的值。

记忆锚点

从 idx 往后选;允许重复就递归传 i,超出目标剪枝,回来恢复和与路径。

面试简述

使用回溯枚举组合,通过起始下标保证候选元素按固定顺序选择,从而避免重复组合。选择某个数字后仍从它的下标继续递归,因此可以重复使用;当前和超过目标时剪枝,等于目标时保存答案。

22. 括号生成

题面

数字 n 表示需要生成的括号对数,返回所有可能且有效的括号组合。

示例 1

输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]

示例 2

输入:n = 1
输出:["()"]

数据范围

  • 1 <= n <= 8

解法一:栈记录未匹配左括号

class Solution {
public:
    vector<string> ans;
    stack<char> stk;
    string cur;
    string kh = "()";
    vector<string> generateParenthesis(int n) {
        dfs(2*n);
        return ans;
    }

    void dfs(int length){

        if(cur.size() == length){
            if(stk.empty()){
                ans.push_back(cur);
            }
            return;
        }
        for(char& c : kh){
            if(c == '('){
                stk.push(c);
                cur.push_back(c);
                dfs(length);
                cur.pop_back();
                stk.pop();
            }else{
                if(stk.empty() || stk.top()!='('){
                    return;
                }
                stk.pop();
                cur.push_back(c);
                dfs(length);
                cur.pop_back();
                stk.push('(');
            }
        }
    }
};

简单解释

stk 保存当前字符串中尚未被右括号匹配的左括号。每个位置尝试放入 '('')'

  • 放入左括号时,将它同时加入 cur 和栈;递归返回后从两处撤销。
  • 放入右括号前,栈中必须存在未匹配的左括号。先弹出一个左括号表示完成匹配,再加入右括号;递归返回后恢复字符串和栈。

当字符串长度达到 2n 时,只有栈为空才说明所有括号都已经正确配对,可以加入答案。

复杂度

  • 时间复杂度:指数级,上界为 O(4^n)
  • 空间复杂度:O(n),不计返回结果

注意点

  • 栈中只保存尚未匹配的左括号;栈为空时不能添加右括号,否则会出现前缀中右括号多于左括号的情况。
  • 左括号分支的回溯是 cur.pop_back()stk.pop();右括号分支提前弹栈完成匹配,回溯时需要重新 stk.push('(')
  • 只有 cur.size() == 2nstk.empty() 时,当前字符串才是有效答案。
  • 当前代码中 ')'kh 的最后一个选择,所以无法放右括号时直接 return 不影响其他分支;写成 continue 会更直观。

记忆锚点

左括号入栈,右括号必须弹一个左括号;长度到 2n 且栈空才收答案。

面试简述

使用栈记录未匹配的左括号,并回溯枚举每个位置放左括号还是右括号。右括号只能在栈非空时加入,递归后恢复栈和字符串状态;最终长度达到 2n 且栈为空时得到一个合法组合。

79. 单词搜索

题面

给定一个 m × n 的字符网格 board 和一个字符串 word。如果 word 存在于网格中,返回 true;否则返回 false

单词必须按照字符顺序,由水平或竖直方向相邻的单元格组成。同一个单元格不能被重复使用。

示例 1

在字符网格中沿相邻单元格找到单词 ABCCED
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
输出:true

示例 2

在字符网格中沿相邻单元格找到单词 SEE
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "SEE"
输出:true

示例 3

字符网格中无法重复使用同一格组成单词 ABCB
输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCB"
输出:false

数据范围

  • m == board.length
  • n == board[i].length
  • 1 <= m, n <= 6
  • 1 <= word.length <= 15
  • boardword 仅由大小写英文字母组成

进阶

能否通过搜索剪枝,在网格更大时更快地解决问题?

解法一:四方向 DFS 回溯

class Solution {
public:
    int index = 0;
    bool flag = 0;
    string dir = "wasd";
    int m , n;
    bool exist(vector<vector<char>>& board, string word) {
        m = board.size(); n = board[0].size();
        for(int i = 0 ; i < m ; i++){
            for(int j = 0 ; j < n ; j++){
                if(board[i][j] == word[0]){
                    dfs(board,word,i,j);
                    if(flag == 1) return true;
                }
            }
        }
        return false;
    }

    void dfs(vector<vector<char>>& board, string& word, int i,int j){
        if(i<0||i>=m||j<0||j>=n || board[i][j]=='1' ||board[i][j]!=word[index]) return;
        if(index==word.size()-1){
            flag = 1;
            return;
        }
        index++;
        char temp = board[i][j];
        board[i][j] = '1';
        for(char c : dir){
            int ii = 0 , jj = 0;
            switch (c){
                case 'w' : ii = -1;break;
                case 's' : ii = 1; break;
                case 'a' : jj = -1; break;
                case 'd' : jj = 1; break;
            }
            dfs(board,word,i+ii,j+jj);
        }
        board[i][j] =temp;
        index--;
    }
};

简单解释

外层遍历所有可能的起点,只在当前字符等于 word[0] 时启动 DFS。index 表示当前需要匹配 word 的哪个字符。

DFS 先检查边界、访问标记和字符是否匹配。匹配成功且还没到单词末尾时,将当前格子临时改为 '1' 表示已经使用,令 index 加一,再按照 wasd 对应的上、左、下、右四个方向继续搜索。四个方向处理完后,恢复格子字符并将 index 减一。

复杂度

设单词长度为 L

  • 时间复杂度:最坏约为 O(mn · 3^(L-1))
  • 空间复杂度:O(L),来自递归调用栈

注意点

  • 必须先判断坐标是否越界,再访问 board[i][j];随后检查是否已经使用以及字符是否等于 word[index]
  • 当前格子匹配后,要在搜索相邻格子前标记为已使用;所有分支结束后必须恢复原字符。同一个格子不能在一条路径中重复使用。
  • index 在进入下一层字符前统一加一,四个方向全部搜索完后再减一,不要在每个方向中分别修改。
  • 当前代码设置 flag 后仍会继续搜索本次起点的其余分支。可在每次递归返回后检查 flagbreak,但仍要执行最后的字符恢复和 index--

记忆锚点

匹配当前字符,原地标记已使用,四方向找下一个字符,回来恢复格子和 index。

面试简述

枚举每个可能起点,并用 DFS 按顺序匹配单词字符。当前格子匹配后临时标记为已访问,再搜索四个相邻位置;递归返回时恢复现场。这样保证同一条路径不会重复使用格子。

131. 分割回文串

题面

给定一个字符串 s,将它分割成若干子串,使每个子串都是回文串,返回所有可能的分割方案。

示例 1

输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]

示例 2

输入:s = "a"
输出:[["a"]]

数据范围

  • 1 <= s.length <= 16
  • s 仅由小写英文字母组成

解法一:枚举下一段回文子串

class Solution {
public:
    vector<vector<string>> ans;
    vector<string> cur;
    vector<vector<string>> partition(string s) {
        dfs(s,0);
        return ans;
    }

    void dfs(string& s, int start){
        if(start == s.size()){
            ans.emplace_back(cur);
            return;
        }
        int sz = 0;
        while(start+sz < s.size()){
            if(isCircle(s,start,sz)){
                cur.emplace_back(s.begin()+start,s.begin()+start+sz+1);
                dfs(s,start+sz+1);
                cur.pop_back();
            }
            sz++;
        }
    }

    bool isCircle(string& s , int start, int sz){
        int l = start ; int r = start+sz;
        while(l<r){
            if(s[l]!=s[r]) return false;
            l++;
            r--;
        }
        return true;
    }
};

简单解释

start 表示当前还没有被分割的字符串起点。从 start 开始不断扩大下一段子串的结尾,只有当子串 [start, start + sz] 是回文串时,才将它加入当前方案,并从 start + sz + 1 继续分割剩余部分。

start == s.size() 时,说明整个字符串已经被恰好分割完,当前路径 cur 就是一种合法答案。递归返回后删除刚加入的子串,继续尝试更长的下一段。

isCircle 使用双指针从子串两端向中间移动,判断当前子串是否为回文串。

复杂度

  • 时间复杂度:最坏可记作 O(n² · 2^n),需要枚举分割方案,并重复检查子串是否为回文
  • 空间复杂度:O(n),来自递归调用栈和当前分割路径,不计返回结果

注意点

  • 这里的 sz 表示“相对结尾偏移”,不是子串长度;当前子串区间是 [start, start + sz],实际长度为 sz + 1
  • 字符串迭代器构造区间是左闭右开,所以结尾必须写成 s.begin() + start + sz + 1
  • 只有当前子串是回文串才能进入下一层;递归起点要移动到 start + sz + 1,返回后必须执行 cur.pop_back()

记忆锚点

从 start 枚举下一段结尾,是回文就加入路径并递归分割剩余部分,回来弹出。

面试简述

使用回溯枚举每一段子串的结束位置。若当前子串是回文串,就将它加入路径并继续处理后面的字符;当起点到达字符串末尾时保存当前分割方案,递归返回后恢复路径。

51. N 皇后

题面

按照国际象棋的规则,皇后可以攻击与它处在同一行、同一列或同一条斜线上的棋子。

n 皇后问题研究如何将 n 个皇后放置在 n × n 的棋盘上,并且使皇后彼此之间不能相互攻击。给定整数 n,返回所有不同的解决方案。

每种解法用一个字符串数组表示,其中 'Q' 表示皇后,'.' 表示空位。

示例 1

4 皇后问题的两种不同解法
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如图所示,4 皇后问题存在两种不同的解法。

示例 2

输入:n = 1
输出:[["Q"]]

数据范围

  • 1 <= n <= 9

解法一:逐格扫描对角线

class Solution {
public:
    vector<int> flag = {0,0,0,0,0,0,0,0,0,0};
    vector<vector<string>> ans;
    vector<vector<string>> solveNQueens(int n) {
        vector<string> cur(n,string(n,'.'));
        dfs(n,0,cur);
        return ans;
    }

    void dfs(int n ,int row, vector<string>& cur){
        if(row == n){
            ans.push_back(cur);
            return;
        }
        for(int i = 0 ; i < n; i++){
            if(flag[i]==-1) continue;
            // 检查对角线
            int valid = 0;
            if(n > 0){
                int r = row-1;
                int c = i-1;
                while(c>=0&&r>=0){
                    if(cur[r][c] == 'Q'){
                        valid = 1;
                        break;
                    }
                    c--;
                    r--;
                }
                if(valid == 1) continue;
                r = row-1;
                c = i+1;
                while(c<n&&r>=0){
                    if(cur[r][c] == 'Q'){
                        valid = 1;
                        break;
                    }
                    c++;
                    r--;
                }
                if(valid == 1) continue;
            }
            cur[row][i] = 'Q';
            flag[i] = -1;
            dfs(n,row+1,cur);
            flag[i] = 0;
            cur[row][i] = '.';
        }
    }
};

简单解释

按照从上到下的顺序,每一行只放置一个皇后。flag[i] 记录第 i 列是否已经存在皇后;因为当前行以下还没有放置任何皇后,所以只需要向左上和右上扫描,检查当前位置是否与之前放置的皇后处在同一条对角线上。

如果列和两条向上的对角线都没有冲突,就在当前位置放置皇后并递归处理下一行。递归返回后,恢复列标记和棋盘,继续尝试当前行的下一列。

复杂度

  • 时间复杂度:通常记作 O(n · n!),每次尝试位置时最多扫描 O(n) 个对角线格子
  • 空间复杂度:O(n²),用于棋盘和递归状态,不计返回结果

注意点

  • 因为按行从上到下放置,所以只检查左上和右上,不需要检查当前行、下方或竖直方向;同列冲突已经由 flag 处理。
  • 放置时要同时修改 cur[row][i]flag[i],递归返回后两者都必须恢复。
  • 当前 flag 固定为 10 个元素,在本题数据范围内足够;更通用的写法是根据 n 动态创建。

记忆锚点

一行放一个,flag 管列;当前位置只向左上、右上找皇后,递归回来恢复现场。

面试简述

逐行回溯放置皇后,用数组记录已占用的列,并通过向左上、右上扫描判断对角线冲突。位置合法时放置皇后并处理下一行,返回后撤销选择。扫描对角线会使每次合法性判断最多花费 O(n) 时间。

解法二:列与对角线占用数组

class Solution {
public:
    vector<vector<string>> ans;
    vector<int> col;
    vector<int> diag1;
    vector<int> diag2;

    vector<vector<string>> solveNQueens(int n) {
        ans.clear();
        col.assign(n, 0);
        diag1.assign(2 * n - 1, 0);
        diag2.assign(2 * n - 1, 0);

        vector<string> cur(n, string(n, '.'));
        dfs(n, 0, cur);
        return ans;
    }

    void dfs(int n, int row, vector<string>& cur) {
        if(row == n){
            ans.push_back(cur);
            return;
        }

        for(int c = 0; c < n; c++){
            int d1 = row - c + n - 1;
            int d2 = row + c;

            if(col[c] || diag1[d1] || diag2[d2]){
                continue;
            }

            cur[row][c] = 'Q';
            col[c] = 1;
            diag1[d1] = 1;
            diag2[d2] = 1;

            dfs(n, row + 1, cur);

            col[c] = 0;
            diag1[d1] = 0;
            diag2[d2] = 0;
            cur[row][c] = '.';
        }
    }
};

简单解释

仍然按照从上到下的顺序,每一行放置一个皇后。区别是不用再逐格扫描对角线,而是用三个数组直接记录列和两类对角线是否已经被占用:

  • col[c]:第 c 列是否已经存在皇后。
  • diag1[row - c + n - 1]:当前位置所在的 \ 对角线是否已被占用。
  • diag2[row + c]:当前位置所在的 / 对角线是否已被占用。

沿着 \ 方向移动时,行号和列号同时增加或减少,所以 row - c 不变;沿着 / 方向移动时,一个增加、另一个减少,所以 row + c 不变。因此,每条对角线都可以通过一个固定的数值唯一标识。

例如位置 (2, 1)

  • 与它处在同一条 \ 对角线上的 (1, 0)(2, 1)(3, 2)row - c 都等于 1
  • 与它处在同一条 / 对角线上的 (0, 3)(1, 2)(2, 1)(3, 0)row + c 都等于 3

因此只需查询三个数组,就能在 O(1) 时间内判断当前位置是否冲突。

复杂度

  • 时间复杂度:回溯搜索通常记作 O(n!);保存所有解时还需要计入复制棋盘的输出开销
  • 空间复杂度:O(n²),棋盘占 O(n²),三个占用数组和递归栈占 O(n),不计返回结果

注意点

  • \ 对角线用 row - c 标识,但结果可能为负数,所以数组下标要写成 row - c + n - 1
  • / 对角线用 row + c 标识,不会出现负数。两类对角线的取值范围都是 02n - 2,因此数组长度是 2n - 1
  • 判断冲突后,放置皇后时必须同时标记列和两类对角线;递归返回后,三个标记和棋盘字符必须全部恢复。

记忆锚点

一行放一个;列看 col,反斜线看 row-col,正斜线看 row+col,放下全标记,回来全恢复。

面试简述

逐行进行回溯,并用三个数组分别记录已经占用的列、\ 对角线和 / 对角线。同一条 \ 对角线上的 row - col 相同,同一条 / 对角线上的 row + col 相同,因此可以在 O(1) 时间判断冲突。放置后更新三个标记,递归返回时恢复现场。

十一、二分查找

35. 搜索插入位置

题面

给定一个无重复元素的升序数组 nums 和一个目标值 target。如果目标值存在,返回它的下标;否则返回它按照升序插入数组时所在的位置。

算法的时间复杂度必须为 O(log n)

示例 1

输入:nums = [1,3,5,6], target = 5
输出:2

示例 2

输入:nums = [1,3,5,6], target = 2
输出:1

示例 3

输入:nums = [1,3,5,6], target = 7
输出:4

数据范围

  • 1 <= nums.length <= 10^4
  • -10^4 <= nums[i], target <= 10^4
  • nums 是无重复元素的升序数组

解法一:闭区间二分查找

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int l = 0 ; int r = nums.size()-1;
        int mid = 0;
        while(l <= r){
            mid = (l + r)/2;
            if(nums[mid] == target) return mid;
            if(nums[mid] > target){
                r = mid-1;
            }else{
                l = mid+1;
            }
        }
        if(target < nums[mid]){
            return mid;
        }else{
            return mid+1;
        }
    }
};

简单解释

使用闭区间 [l, r] 进行二分查找:

  • 如果 nums[mid] == target,直接返回 mid
  • 如果 nums[mid] > target,目标只可能在左侧,将右边界更新为 mid - 1
  • 如果 nums[mid] < target,目标只可能在右侧,将左边界更新为 mid + 1

如果没有找到目标,最后一次比较的位置 mid 附近就是插入位置。当前代码通过比较 targetnums[mid],决定插在 mid 还是 mid + 1

闭区间二分结束时一定有 l == r + 1,此时 l 本身就是目标值的插入位置,因此最后的判断也可以直接简化为:

return l;

复杂度

  • 时间复杂度:O(log n)
  • 空间复杂度:O(1)

注意点

  • 搜索区间是闭区间 [l, r],因此循环条件是 l <= r;排除 mid 后,边界分别更新为 mid - 1mid + 1
  • 查找失败时,l 指向第一个应该放置 target 的位置,包括插在数组最前面或末尾的情况。
  • 当前代码使用最后一次 mid 决定返回 mid 还是 mid + 1;虽然正确,但直接 return l 更容易记忆。

记忆锚点

闭区间二分找 target;找到返回 mid,找不到时 l 就停在插入位置。

面试简述

在升序数组上使用闭区间二分查找。目标存在时直接返回下标;若查找失败,循环结束后的左边界 l 正好是第一个可以插入目标值的位置。时间复杂度为 O(log n),空间复杂度为 O(1)

74. 搜索二维矩阵

题面

给定一个 m × n 的整数矩阵 matrix,矩阵满足:

  • 每行中的整数从左到右按非严格递增顺序排列
  • 每行的第一个整数大于前一行的最后一个整数

给定整数 target,如果它存在于矩阵中则返回 true,否则返回 false

示例 1

在有序矩阵中查找存在的目标值 3
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3
输出:true

示例 2

在有序矩阵中查找不存在的目标值 13
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 13
输出:false

数据范围

  • m == matrix.length
  • n == matrix[i].length
  • 1 <= m, n <= 100
  • -10^4 <= matrix[i][j], target <= 10^4

解法一:从右上角开始搜索

class Solution {
public:
    bool searchMatrix(vector<vector<int>>& matrix, int target) {
        int m = matrix.size() , n = matrix[0].size();
        int i = 0 , j = n-1;
        while(i < m && j >=0){
            if(target==matrix[i][j]) return true;
            if(target > matrix[i][j]){
                i++;
            }else{
                j--;
            }
        }
        return false;
    }
};

简单解释

从矩阵右上角 (0, n - 1) 开始:

  • 当前值小于 target:当前行左侧的值只会更小,这一整行都可以排除,向下移动。
  • 当前值大于 target:当前列下方的值只会更大,这一整列都可以排除,向左移动。
  • 当前值等于 target:直接返回 true

由于下一行的第一个元素大于上一行的最后一个元素,所以本题矩阵不仅每行有序,同一列从上到下也一定递增,因此可以使用与第 240 题相同的搜索方式。

复杂度

  • 时间复杂度:O(m + n)
  • 空间复杂度:O(1)

注意点

  • 起点必须选右上角或左下角,这两个位置的两个移动方向分别对应“变大”和“变小”。
  • 从右上角出发时,target 更大就向下走,target 更小就向左走;循环边界是 i < m && j >= 0

记忆锚点

站在右上角:小了向下,大了向左,每次排除一行或一列。

面试简述

从矩阵右上角开始比较。当前值小于目标时排除当前行并向下移动,大于目标时排除当前列并向左移动。每次至少排除一行或一列,时间复杂度为 O(m+n),空间复杂度为 O(1)

解法二:二分定位候选行后扫描

class Solution {
public:
    bool searchMatrix(vector<vector<int>>& matrix, int target) {
        int m = matrix.size(); int n = matrix[0].size();
        int l = 0, r = m-1;
        int mid;
        while(l <= r){
            mid = (l + r)/2;
            if(matrix[mid][0] == target) return true;
            if(matrix[mid][0] > target){
                r = mid-1;
            }else{
                l = mid+1;
            }
        }

        for(int i = 0 ; i < n ; i++){
            if(target == matrix[mid][i]) return true;
        }

        if(target < matrix[mid][0]){
            if(mid == 0) return false;
            for(int i = 0 ; i < n ; i++){
                if(target == matrix[mid-1][i]) return true;
            }
        }
        return false;
    }
};

简单解释

先只看每行第一个元素,对行号进行二分查找:

  • 如果某行首元素等于 target,直接返回 true
  • 如果没有相等元素,目标只可能位于“首元素不大于 target 的最后一行”。

当前代码通过最后一次二分位置 mid 恢复候选行:先扫描第 mid 行;如果 target < matrix[mid][0],候选行还可能是 mid - 1,于是再扫描上一行。

更容易理解二分结果的方式是:循环结束后,l 指向第一行首元素大于 target 的位置,因此真正的候选行就是 l - 1

复杂度

  • 时间复杂度:O(log m + n),二分定位行后线性扫描候选行
  • 空间复杂度:O(1)

注意点

  • 真正要找的是“首元素不大于 target 的最后一行”,因为每一行的第一个元素都大于上一行的最后一个元素,目标最多只可能落在这一行。
  • 当前代码使用最后一次 mid 判断候选行是 mid 还是 mid - 1;当 mid == 0 时不能访问 mid - 1
  • 更好记的候选行下标是 l - 1;若 l == 0,说明 target 小于矩阵第一个元素,可以直接返回 false

记忆锚点

先在每行首元素中找最后一个不大于 target 的行,再只搜索这一行。

面试简述

先对每行首元素进行二分查找,确定目标唯一可能出现的候选行,再在线性扫描该行。二分行号需要 O(log m),扫描一行需要 O(n),总时间复杂度为 O(log m+n)

34. 在排序数组中查找元素的第一个和最后一个位置

题面

给定一个按照非递减顺序排列的整数数组 nums 和目标值 target,找出目标值在数组中的开始位置和结束位置。

如果数组中不存在 target,返回 [-1,-1]。算法的时间复杂度必须为 O(log n)

示例 1

输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]

示例 2

输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]

示例 3

输入:nums = [], target = 0
输出:[-1,-1]

数据范围

  • 0 <= nums.length <= 10^5
  • -10^9 <= nums[i], target <= 10^9
  • nums 是非递减数组

错误尝试:从两端线性收缩

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        int L = 0 ; int R = nums.size()-1;
        if(nums.size() == 1){
            if(nums[0]=target) return {0,0};
        }
        while(L < R){
            if(nums[L]!= target) L++;
            if(nums[R]!= target) R--;
            if(nums[L]==target&&nums[R]==target) return {L,R};
        }
        return {-1,-1};
    }
};

存在的问题

  • 从左右两端逐个位置向内检查,最坏需要遍历整个数组,时间复杂度为 O(n),不满足题目要求的 O(log n)
  • if(nums[0]=target) 使用的是赋值运算符,会修改 nums[0] 并用赋值结果参与条件判断;比较相等必须写成 nums[0] == target
  • 这段代码只适合作为思路对比,不能作为本题的正式提交答案。

解法一:两次二分查找

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        int l = 0; int r = nums.size()-1;
        int start = -1, end = -1;
        while(l <= r){
            int mid = (l+r)/2;
            if(nums[mid]==target){
                start = mid;
                r = mid-1;
            }else if(nums[mid]<target) {
                l = mid+1;
            }else{
                r = mid-1;
            }
        }

        l = 0 , r = nums.size()-1;
        while(l <= r){
            int mid = (l+r)/2;
            if(nums[mid]==target){
                end = mid;
                l = mid+1;
            }else if(nums[mid]<target) {
                l = mid+1;
            }else{
                r = mid-1;
            }
        }
        return {start,end};
    }
};

简单解释

进行两次二分查找:

  1. 第一次寻找左边界。遇到 target 时先记录 start = mid,但不立即返回,而是令 r = mid - 1,继续向左寻找更早出现的位置。
  2. 第二次寻找右边界。遇到 target 时记录 end = mid,再令 l = mid + 1,继续向右寻找更晚出现的位置。

如果目标不存在,两次二分都不会更新答案,startend 会保持为 -1

复杂度

  • 时间复杂度:O(log n),执行两次二分查找
  • 空间复杂度:O(1)

注意点

  • 找到 target 后不能直接返回:寻找左边界时继续向左,即 r = mid - 1;寻找右边界时继续向右,即 l = mid + 1
  • startend 都初始化为 -1,这样目标不存在或数组为空时可以直接返回 [-1,-1]
  • 两次二分中,nums[mid] < targetnums[mid] > target 的普通边界更新完全相同,只有“等于目标”时的移动方向不同。

记忆锚点

两次二分:等于 target 时,找左端就继续向左,找右端就继续向右。

面试简述

分别用两次二分查找目标值的左右边界。第一次命中目标后继续收缩右边界,得到最左位置;第二次命中后继续增大左边界,得到最右位置。总时间复杂度为 O(log n),额外空间复杂度为 O(1)

33. 搜索旋转排序数组

题面

整数数组 nums 原本按升序排列且所有值互不相同,之后可能在某个下标 k 处进行旋转。

例如,[0,1,2,4,5,6,7] 在下标 3 处旋转后可以变成 [4,5,6,7,0,1,2]

给定旋转后的数组 nums 和整数 target,如果目标值存在则返回其下标,否则返回 -1。算法的时间复杂度必须为 O(log n)

示例 1

输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4

示例 2

输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1

示例 3

输入:nums = [1], target = 0
输出:-1

数据范围

  • 1 <= nums.length <= 5000
  • -10^4 <= nums[i], target <= 10^4
  • nums 中的每个值都互不相同
  • 数组可能在下标 0 处旋转,即保持原本的升序

解法一:判断哪一半有序

class Solution {
public:
    int search(vector<int>& nums, int target) {
        int L = 0 , R = nums.size()-1;
        while(L<=R){
            int mid = (L + R)/2;
            if(target == nums[mid]) return mid;

            // 左半段 [L, mid] 有序
            if(nums[L] <= nums[mid]){
                // target 位于左半段的有序范围 [nums[L], nums[mid])
                if(nums[L] <= target && target < nums[mid]){
                    R = mid-1;
                }else{
                    L = mid+1;
                }
            }else{
                // 否则右半段 [mid, R] 一定有序
                // target 位于右半段的有序范围 (nums[mid], nums[R]]
                if(nums[mid] < target && target <= nums[R]){
                    L = mid +1;
                }else{
                    R = mid -1;
                }
            }
        }
        return -1;
    }
};

简单解释

旋转数组虽然整体不再有序,但以 mid 为分界时,左右两部分中至少有一部分仍然有序:

  1. 如果 nums[L] <= nums[mid],说明左半段 [L, mid] 有序。判断 target 是否落在 [nums[L], nums[mid]) 内;在就搜索左半段,否则搜索右半段。
  2. 否则右半段 [mid, R] 有序。判断 target 是否落在 (nums[mid], nums[R]] 内;在就搜索右半段,否则搜索左半段。

每轮都能排除一半区间,因此仍然是二分查找。

复杂度

  • 时间复杂度:O(log n)
  • 空间复杂度:O(1)

注意点

  • 先判断哪一半有序,再判断 target 是否位于这个有序区间,不能直接根据 targetnums[mid] 的大小决定方向。
  • 左半段有序的条件是 nums[L] <= nums[mid];等号用于兼容区间只剩一个元素的情况。
  • mid 已经确认不等于 target,所以左侧目标范围写成 [nums[L], nums[mid]),右侧写成 (nums[mid], nums[R]]

记忆锚点

每次先找有序的一半:target 在有序范围里就进去,否则去另一半。

面试简述

旋转数组以中点分割后,至少有一半保持有序。每轮先判断哪一半有序,再判断目标是否落在该有序区间内,从而决定保留哪一半。时间复杂度为 O(log n),空间复杂度为 O(1)

解法二:先找旋转点,再二分两段

class Solution {
public:
    int search(vector<int>& nums, int target) {
        int pivot = findMinIndex(nums);

        // 旋转点将数组分成两个升序区间
        int l = 0 , r = pivot-1;
        while(l <= r){
            int mid = (l+r)/2;
            if(nums[mid] == target) {
                return mid;
            }else if(nums[mid] > target){
                r = mid-1;
            }else{
                l = mid+1;
            }
        }

        l = pivot, r = nums.size()-1;
        while(l <= r){
            int mid = (l+r)/2;
            if(nums[mid] == target) {
                return mid;
            }else if(nums[mid] > target){
                r = mid-1;
            }else{
                l = mid+1;
            }
        }
        return -1;
    }

    // 返回数组最小值所在的下标,也就是旋转点
    int findMinIndex(vector<int>& nums){
        int n = nums.size();

        // 数组没有旋转时,最小值下标是 0
        if(nums[0] <= nums[n-1]) return 0;

        int l = 0 , r = n-1;
        int pivot = 0;
        while(l <= r){
            int mid = (l+r)/2;
            if(nums[mid] >= nums[0]){
                // mid 位于旋转点左侧的较大区间
                l = mid+1;
            }else{
                // mid 位于旋转点右侧,记录后继续向左找
                pivot = mid;
                r = mid-1;
            }
        }
        return pivot;
    }
};

简单解释

先找到最小值所在下标 pivot。它将旋转数组分成两个普通升序区间:

  • [0, pivot - 1]
  • [pivot, n - 1]

然后分别对两个区间执行普通二分查找,任意一段找到目标就立即返回下标。

寻找旋转点时,以 nums[0] 为参照:

  • nums[mid] >= nums[0]mid 仍在左侧较大的升序区间,旋转点在右边。
  • nums[mid] < nums[0]mid 已经进入右侧较小的区间,记录它并继续向左寻找更早的位置。

复杂度

  • 时间复杂度:O(log n),寻找旋转点和搜索两个区间都是二分
  • 空间复杂度:O(1)

注意点

  • findMinIndex 返回的是最小值的下标,不是最小值本身。原代码在数组未旋转时写成 return nums[0],文档版已明确修正为 return 0
  • 数组未旋转时 pivot == 0,第一个区间 [0, -1] 为空,第二个区间就是整个数组。
  • 后面两份原始代码都属于“找旋转点后搜索两段”的同一种算法,因此这里只保留结构更清晰的修正版。

记忆锚点

先找第一个小于 nums[0] 的位置作为旋转点,再对旋转点左右两段普通二分。

面试简述

先用二分查找最小值下标,将数组切分为两个升序区间,再分别进行普通二分查找。寻找旋转点和搜索区间都只需要对数时间,因此总时间复杂度仍为 O(log n)

153. 寻找旋转排序数组中的最小值

题面

给定一个元素互不相同的整数数组 nums。它原本按升序排列,之后进行了若干次旋转。

找出并返回数组中的最小元素,算法的时间复杂度必须为 O(log n)

示例 1

输入:nums = [3,4,5,1,2]
输出:1

示例 2

输入:nums = [4,5,6,7,0,1,2]
输出:0

示例 3

输入:nums = [11,13,15,17]
输出:11

数据范围

  • 1 <= nums.length <= 5000
  • -5000 <= nums[i] <= 5000
  • nums 中的所有整数互不相同
  • nums 原本是升序数组,并进行了 1n 次旋转

解法一:以数组首元素判断旋转区间

class Solution {
public:
    int findMin(vector<int>& nums) {
        int L = 0 , R = nums.size()-1;
        int min = 0;

        // 没有发生有效旋转,数组本身仍然升序
        if(nums[0] <= nums[R]) return nums[0];

        while(L <= R){
            int mid = (L + R)/2;
            if(nums[mid] >= nums[0]){
                // mid 在左侧较大的升序区间,最小值在右边
                L = mid+1;
            }else{
                // mid 在右侧较小的区间,先记录候选值再继续向左找
                min = nums[mid];
                R = mid -1;
            }
        }
        return min;
    }
};

简单解释

如果 nums[0] <= nums[n - 1],说明数组当前整体有序,最小值就是 nums[0]

否则数组由左侧较大的升序区间和右侧较小的升序区间组成。以 nums[0] 为参照进行二分:

  • nums[mid] >= nums[0]mid 位于左侧较大区间,最小值一定在右边,令 L = mid + 1
  • nums[mid] < nums[0]mid 已进入右侧较小区间,nums[mid] 可能是最小值;先记录,再令 R = mid - 1 继续向左寻找。

最终记录到的最左侧候选值就是整个数组的最小值。

复杂度

  • 时间复杂度:O(log n)
  • 空间复杂度:O(1)

注意点

  • 未旋转或旋转整整 n 次时,数组仍然整体升序,必须提前返回 nums[0];否则变量 min 不会在循环中得到有效更新。
  • 本题函数返回最小值,所以记录的是 min = nums[mid];第 33 题寻找旋转点时需要记录的则是下标 mid
  • nums[mid] < nums[0] 时,当前值只是候选最小值,还要继续向左搜索更早进入较小区间的位置。

记忆锚点

先判断是否整体有序;比 nums[0] 大就在左段向右走,比 nums[0] 小就记下并向左找。

面试简述

先处理数组没有有效旋转的情况。对于旋转数组,以首元素为参照进行二分:中间值不小于首元素时向右搜索,否则记录当前候选最小值并继续向左收缩。时间复杂度为 O(log n),空间复杂度为 O(1)

4. 寻找两个正序数组的中位数

题面

给定两个大小分别为 mn 的正序数组 nums1nums2,找出并返回这两个正序数组的中位数。

算法的时间复杂度应该为 O(log(m+n))

示例 1

输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并后的数组是 [1,2,3],中位数为 2

示例 2

输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并后的数组是 [1,2,3,4],中位数为 (2+3)/2 = 2.5

数据范围

  • 0 <= nums1.length, nums2.length <= 1000
  • 1 <= nums1.length + nums2.length <= 2000
  • -10^6 <= nums1[i], nums2[i] <= 10^6

解法一:寻找两个数组中的第 k 小元素

class Solution {
public:
    double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
        int total = nums1.size() + nums2.size();

        // 奇数时 left == right;偶数时对应中间两个位置
        int left = (total + 1) / 2;
        int right = (total + 2) / 2;

        return (getKth(nums1, nums2, left)
              + getKth(nums1, nums2, right)) / 2.0;
    }

    // k 从 1 开始计数:寻找剩余元素中的第 k 小
    int getKth(vector<int>& nums1, vector<int>& nums2, int k) {
        int i = 0;
        int j = 0;
        int m = nums1.size();
        int n = nums2.size();

        while(true){
            // 一个数组已经被排除完,答案直接在另一个数组中
            if(i == m) return nums2[j + k - 1];
            if(j == n) return nums1[i + k - 1];

            // 第 1 小就是两个数组当前首元素的较小值
            if(k == 1) return min(nums1[i], nums2[j]);

            int half = k / 2;

            // 数组剩余长度不足 half 时,最多取到最后一个元素
            int ni = min(i + half, m) - 1;
            int nj = min(j + half, n) - 1;

            if(nums1[ni] <= nums2[nj]){
                // nums1[i..ni] 都不可能是第 k 小,全部排除
                k -= ni - i + 1;
                i = ni + 1;
            }else{
                // nums2[j..nj] 都不可能是第 k 小,全部排除
                k -= nj - j + 1;
                j = nj + 1;
            }
        }
    }
};

简单解释

中位数可以统一转化成“第 k 小元素”:

  • 总长度为奇数时,中位数就是第 (total + 1) / 2 小。
  • 总长度为偶数时,中位数是第 total / 2 小和第 total / 2 + 1 小的平均值。

代码使用:

int left = (total + 1) / 2;
int right = (total + 2) / 2;

奇数时 left == right,偶数时二者就是中间两个位置,因此可以统一求平均。

getKth 每次查看两个数组剩余部分中大约第 k/2 个元素:

  • 如果 nums1[ni] <= nums2[nj],那么 nums1[i..ni] 这一段不可能包含第 k 小元素,可以整段排除。
  • 否则排除 nums2[j..nj]
  • 排除了多少元素,就把 k 减少多少。

例如在 nums1 = [1,3,5]nums2 = [2,4,6] 中寻找第 4 小:

  1. 比较两个数组当前第 2 个元素:3 < 4,排除 nums1[1,3]k 变成 2
  2. 比较剩余部分当前第 1 个元素:5 > 2,排除 nums2[2]k 变成 1
  3. 两个数组当前首元素是 54,较小的 4 就是答案。

复杂度

  • 时间复杂度:O(log(m+n)),每轮大约排除 k/2 个候选元素
  • 空间复杂度:O(1)

注意点

  • k 表示“第几个”,从 1 开始;数组下标从 0 开始,所以另一个数组直接取答案时使用 index + k - 1
  • 边界判断顺序不能乱:先判断某个数组是否已经排除完,再判断 k == 1,最后才能访问两个数组的候选位置。
  • 数组剩余长度可能不足 k/2,因此候选下标要用 min(index + half, size) - 1;更新 k 时减去实际排除数量,不能固定减 half

记忆锚点

中位数转成第 k 小;比较两边第 k/2 个候选,谁小就淘汰谁的前半段,并同步减小 k。

面试简述

把中位数问题转化为寻找两个有序数组中的第 k 小元素。每轮比较两个数组剩余部分的第 k/2 个候选,排除较小候选所在数组的一段前缀,并减少 k。这样查找范围不断减半,时间复杂度为 O(log(m+n)),额外空间复杂度为 O(1)

十二、栈

20. 有效的括号

题面

给定一个只包括 '('')''{''}''['']' 的字符串 s,判断字符串是否有效。

有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。
  • 每个右括号都有一个对应的相同类型的左括号。

示例 1

输入:s = "()"
输出:true

示例 2

输入:s = "()[]{}"
输出:true

示例 3

输入:s = "(]"
输出:false

示例 4

输入:s = "([])"
输出:true

示例 5

输入:s = "([)]"
输出:false

数据范围

  • 1 <= s.length <= 10^4
  • s 仅由括号 '()[]{}' 组成

解法一:栈匹配括号

class Solution {
public:
    bool isValid(string s) {
        stack<char> stk;
        for(char& c : s){
            if(c == '(' ||c ==  '{' || c == '['){
                stk.push(c);
            }else{
                if(stk.empty()) return false;
                char top = stk.top();
                stk.pop();
                if(c == ')' && top != '('){
                    return false;
                }else if(c == '}'&& top != '{'){
                    return false;
                }else if(c== ']'&& top!='['){
                    return false;
                }
            }
        }
        if(stk.empty()) return true;
        return false;
    }
};

简单解释

遍历字符串时,遇到左括号就压入栈中;遇到右括号时,它必须与最近出现且尚未匹配的左括号配对,因此取出栈顶进行判断。

如果右括号出现时栈为空,或者栈顶左括号与当前右括号类型不同,字符串都无效。遍历结束后,栈为空才说明所有左括号都已经成功匹配。

复杂度

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

注意点

  • 调用 stk.top()stk.pop() 前必须先判断栈是否为空,防止出现没有对应左括号的右括号。
  • 右括号必须与弹出的栈顶左括号类型一致,不能只判断左右括号数量。
  • 遍历结束后栈必须为空,否则还有未闭合的左括号。

记忆锚点

左括号入栈;右括号先判空,再弹栈匹配;最后栈必须为空。

面试简述

使用栈保存尚未匹配的左括号。遇到右括号时,检查栈非空并判断栈顶是否为对应的左括号;任一条件不满足就返回 false。遍历完成后根据栈是否为空判断所有括号是否完全匹配。

155. 最小栈

题面

设计一个支持 pushpoptop 操作,并能在常数时间内检索到最小元素的栈。

实现 MinStack 类:

  • MinStack() 初始化栈对象。
  • void push(int val) 将元素 val 推入栈中。
  • void pop() 删除栈顶部的元素。
  • int top() 获取栈顶部的元素。
  • int getMin() 获取栈中的最小元素。

示例 1

输入:
["MinStack","push","push","push","getMin","pop","top","getMin"]
[[],[-2],[0],[-3],[],[],[],[]]

输出:
[null,null,null,null,-3,null,0,-2]

解释:
MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.getMin(); --> 返回 -3
minStack.pop();
minStack.top(); --> 返回 0
minStack.getMin(); --> 返回 -2

数据范围

  • -2^31 <= val <= 2^31 - 1
  • poptopgetMin 总是在非空栈上调用
  • pushpoptopgetMin 最多被调用 3 * 10^4

解法一:普通栈 + 辅助最小栈

class MinStack {
    stack<int> stk;
    stack<int> min_stk;
public:
    MinStack() {
        min_stk.push(INT_MAX);
    }

    void push(int val) {
        stk.push(val);
        min_stk.push(min(val,min_stk.top()));
    }

    void pop() {
        stk.pop();
        min_stk.pop();
    }

    int top() {
        return stk.top();
    }

    int getMin() {
        return min_stk.top();
    }
};

/**
 * Your MinStack object will be instantiated and called as such:
 * MinStack* obj = new MinStack();
 * obj->push(val);
 * obj->pop();
 * int param_3 = obj->top();
 * int param_4 = obj->getMin();
 */

简单解释

使用两个栈:

  • stk 正常保存所有元素。
  • min_stkstk 同步入栈,保存每一层对应的当前最小值。

压入 val 时,新的最小值是:

min(val, min_stk.top())

因此无论压入的值是否为新的最小值,min_stk 都会增加一层。删除元素时两个栈同步弹出,min_stk.top() 就会自动恢复为删除前一层所对应的最小值。

例如依次压入 -2、0、-3

操作 stk 栈顶 min_stk 栈顶
push(-2) -2 -2
push(0) 0 -2
push(-3) -3 -3
pop() 0 -2

复杂度

  • pushpoptopgetMin 的时间复杂度均为:O(1)
  • 空间复杂度:O(n)

注意点

  • min_stk 不是只在出现更小值时才入栈,而是每次 push 都要同步压入当前最小值,这样两个栈才能一一对应。
  • pop 时两个栈必须同步弹出,否则辅助栈记录的最小值会与当前数据栈错位。
  • 构造函数先压入 INT_MAX 作为哨兵,使第一次 push 也可以直接执行 min(val, min_stk.top());题目保证 getMin 只会在数据栈非空时调用。

记忆锚点

两个栈同进同出:普通栈存当前元素,最小栈存当前这一层的最小值。

面试简述

使用一个普通栈保存数据,再使用一个辅助栈同步保存每一层对应的最小值。压栈时把当前值与旧的最小值取较小者压入辅助栈,弹栈时两个栈一起弹出,因此栈顶元素和当前最小值都可以在 O(1) 时间内获得。

394. 字符串解码

题面

给定一个经过编码的字符串,返回它解码后的字符串。

编码规则为 k[encoded_string],表示方括号内部的 encoded_string 正好重复 k 次,其中 k 保证为正整数。

输入字符串总是有效的,没有额外空格,方括号总是符合格式要求。原始数据不包含数字,所有数字只表示重复次数,不会出现 3a2[4] 这样的输入。

测试用例保证输出字符串的长度不会超过 10^5

示例 1

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

示例 2

输入:s = "3[a2[c]]"
输出:"accaccacc"

示例 3

输入:s = "2[abc]3[cd]ef"
输出:"abcabccdcdcdef"

示例 4

输入:s = "abc3[cd]xyz"
输出:"abccdcdcdxyz"

数据范围

  • 1 <= s.length <= 30
  • s 由小写英文字母、数字和方括号 '[]' 组成
  • s 保证是有效输入
  • s 中所有整数的取值范围为 [1, 300]

解法一:迭代 + 栈保存外层状态

class Solution {
public:
    string decodeString(string s) {
        stack<pair<string,int>> stk;
        string res;
        int k = 0;

        for(char c : s){
            if(isalpha(c)){
                res+= c;
            }else if(isdigit(c)){
                k = k*10+ (c-'0');
            }else if(c == '['){
                stk.emplace(move(res),k);
                k = 0;
            }else{
                auto [pre_res,pre_k] = stk.top();
                stk.pop();
                while(pre_k--){
                    pre_res+=res;
                }
                res = move(pre_res);
            }
        }
        return res;
    }
};

简单解释

先记住三个状态的含义:

  • res:当前这一层方括号内已经解码出的字符串。
  • k:当前方括号前读取到的重复次数。
  • stk:进入内层方括号前,暂存外层的 (字符串, 重复次数)

遍历时只处理四种字符:

  1. 字母:直接追加到当前层的 res
  2. 数字:用 k = k * 10 + (c - '0') 累积多位数。
  3. 左方括号 [:把外层的 (res, k) 入栈,然后开始解析一个新的内层。
  4. 右方括号 ]:弹出外层状态,把当前 res 重复 pre_k 次并接到 pre_res 后面,合并结果成为新的 res

3[a2[c]] 为例:

读到的内容 当前 res 当前 k 发生的操作
3[ "" 0 ("", 3) 入栈,进入第一层
a2[ "" 0 ("a", 2) 入栈,进入第二层
c] "acc" 0 弹出 ("a", 2),得到 "a" + 2 * "c"
] "accaccacc" 0 弹出 ("", 3),把 "acc" 重复三次

复杂度

设解码后字符串长度为 L

  • 时间复杂度:O(L),每个最终输出字符都需要被构造
  • 空间复杂度:O(L + n),包括结果字符串和保存嵌套状态的栈

注意点

  • 重复次数可能有多位,必须使用 k = k * 10 + (c - '0'),不能把每个数字单独处理。
  • 读到 [ 时保存的是进入内层之前的外层状态;读到 ] 时的合并顺序必须是 pre_res + pre_k * res
  • 这份代码在 move(res) 后直接把 res 当作空字符串继续使用;移动后的字符串只保证有效,标准不保证其内容一定为空。严格可移植的写法应在 emplace 后补一句 res.clear()

记忆锚点

左括号保存外层并进入新层,右括号弹出外层并执行:外层前缀 + 次数 × 当前层。

面试简述

使用 resk 记录当前层的解码结果与重复次数,使用栈保存进入内层前的外层状态。遇到 [ 时保存现场,遇到 ] 时恢复现场并把当前层重复指定次数后拼接到外层。整个过程按照括号嵌套从内向外完成解码。

解法二:递归解析当前编码段

class Solution {
public:
    string decodeString(string s) {

        if(s.empty()){
            return s;
        }


        if(isalpha(s[0])){
            return s[0] + decodeString(s.substr(1));
        }

        int i = s.find('[');
        int balance = 1;
        for(int j = i + 1; ; j++){
            if(s[j]=='['){
                balance++;
            }else if(s[j] == ']'){
                balance--;
                if(balance ==0){
                    int k = stoi(s.substr(0,i));
                    string t = decodeString(s.substr(i+1,j-i-1));
                    string res ;
                    while(k--){
                        res+=t;
                    }
                    return res + decodeString(s.substr(j+1));
                }
            }
        }
    }
};

简单解释

每次递归只看当前字符串的开头:

  • 字符串为空:当前部分解析完成,返回空字符串。
  • 开头是字母:保留这个字母,递归解码剩余后缀。
  • 开头是数字:当前部分一定形如 k[encoded_string] + 后续字符串

开头是数字时,先用 find('[') 找到重复次数与编码内容的分界。然后从这个 [ 向后扫描,并使用 balance 记录尚未配对的左方括号数量:

  • 遇到 [balance++
  • 遇到 ]balance--
  • balance == 0 时,才找到了与当前第一个 [ 配对的右方括号。

找到右边界后,将问题拆成两次递归:

当前结果 = k × decodeString(方括号内部)
         + decodeString(右方括号后面的字符串)

复杂度

设原字符串长度为 n,解码后字符串长度为 L

  • 时间复杂度:当前 substr 写法存在字符串复制,最坏可达到 O(n^2 + L)
  • 空间复杂度:递归栈深度为 O(n),同时还会创建若干子串和解码结果

注意点

  • 不能找到第一个 ] 就结束,嵌套结构中必须用 balance 找到与当前 [ 真正配对的右括号。
  • stoi(s.substr(0, i)) 取得开头完整的重复次数,因此能够处理多位数。
  • 递归需要同时处理两部分:先解码括号内部,再继续解码右括号之后的后缀,不能漏掉后半段。

记忆锚点

开头是字母就取一个继续;开头是数字就找配对括号,递归解码“括号里面”和“括号后面”。

面试简述

递归处理当前字符串:如果开头是字母,就保留该字母并处理剩余部分;如果开头是数字,就利用括号平衡找到当前编码段的右边界,递归解码括号内部并重复 k 次,再拼接递归解码后的剩余后缀。写法直观,但使用 substr 会产生额外复制。

739. 每日温度

题面

给定一个整数数组 temperatures,表示每天的温度,返回一个数组 answer。其中 answer[i] 表示对于第 i 天,下一个更高温度出现在几天后;如果之后的气温都不会升高,则在该位置填入 0

示例 1

输入:temperatures = [73,74,75,71,69,72,76,73]
输出:[1,1,4,2,1,1,0,0]

示例 2

输入:temperatures = [30,40,50,60]
输出:[1,1,1,0]

示例 3

输入:temperatures = [30,60,90]
输出:[1,1,0]

数据范围

  • 1 <= temperatures.length <= 10^5
  • 30 <= temperatures[i] <= 100

解法一:单调栈

class Solution {
public:
    vector<int> dailyTemperatures(vector<int>& temperatures) {
        stack<int> stk;
        vector<int> ans(temperatures.size(),0);
        for(int i = 0; i < temperatures.size(); i++){
            while(!stk.empty() && temperatures[stk.top()] < temperatures[i]){
                int top = stk.top();
                stk.pop();
                ans[top] = i-top;
            }
            stk.push(i);
        }
        return ans;
    }
};

简单解释

栈中保存的是还没有找到下一个更高温度的日期下标。

遍历到第 i 天时,如果当前温度高于栈顶下标对应的温度,说明第 i 天就是栈顶那一天等待的下一个更高温度:

ans[top] = i - top;

弹出栈顶后要继续比较,因为当前温度可能同时解决前面多天的问题。所有比当前温度低的下标弹出后,再将当前下标 i 入栈。

从栈底到栈顶,对应的温度保持单调不升;栈中的每个下标都代表“仍在等待更高温度的一天”。

复杂度

  • 时间复杂度:O(n),每个下标最多入栈一次、出栈一次
  • 空间复杂度:O(n)

注意点

  • 栈中必须保存下标,而不是温度值,因为答案需要计算相隔天数 i - top
  • 弹栈条件必须是 temperatures[stk.top()] < temperatures[i];相同温度不算更高,不能使用 <=
  • ans 初始化为 0,遍历结束后仍留在栈中的下标不需要处理,答案自然保持为 0

记忆锚点

栈里放还没等到升温的下标;当前温度更高就连续弹栈,答案等于当前下标减旧下标。

面试简述

使用单调栈保存尚未找到下一个更高温度的日期下标。遍历到更高温度时,连续弹出所有温度更低的栈顶下标,并用下标差填写等待天数,最后压入当前下标。每个下标最多进出栈一次,因此时间复杂度为 O(n)

84. 柱状图中最大的矩形

题面

给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1

求在该柱状图中能够勾勒出来的矩形的最大面积。

由 heights 数组形成的柱状图

示例 1

输入:heights = [2,1,5,6,2,3]
输出:10
解释:最大的矩形为图中红色区域,面积为 10
柱状图中面积为 10 的最大矩形

示例 2

输入:heights = [2,4]
输出:4

数据范围

  • 1 <= heights.length <= 10^5
  • 0 <= heights[i] <= 10^4

解法一:单调递增栈 + 左右边界

class Solution {
public:
    int largestRectangleArea(vector<int>& heights) {
        int n = heights.size();
        vector<int> left(n), right(n,n);
        stack<int> stk;


        for(int i = 0 ; i < n ; i++){
            while(!stk.empty() && heights[i] <= heights[stk.top()]){
                right[stk.top()] = i;
                stk.pop();
            }
            if(stk.empty()){
                left[i] = -1;
            }else{
                left[i] = stk.top();
            }
            stk.push(i);
        }

        int ans = 0;
        for(int i = 0 ; i < n ; i++){
            ans = max(ans,(right[i]-left[i]-1)*heights[i]);
        }

        return ans;
    }
};

简单解释

枚举每根柱子作为矩形的高度。对于下标 i,需要知道这个高度最多能向左右延伸到哪里:

  • left[i]:左侧第一个高度严格小于 heights[i] 的下标。
  • right[i]:右侧使当前柱子出栈的位置;默认是数组末尾之后的 n

栈中保存柱子的下标,并使对应高度保持严格递增。处理第 i 根柱子时:

  1. 如果当前高度小于或等于栈顶高度,栈顶柱子不能再继续向右延伸,因此记录 right[stk.top()] = i 并弹栈。
  2. 弹出所有不低于当前柱子的高度后,剩余栈顶就是当前柱子左侧第一个更矮的位置;栈空则使用边界 -1
  3. 将当前下标压入栈中。

左右两个位置本身都不能包含在以 heights[i] 为高度的矩形中,因此可用宽度为:

right[i] - left[i] - 1

面积就是:

(right[i] - left[i] - 1) * heights[i]

例如 heights = [2,1,5,6,2,3]

i 高度 left[i] right[i] 面积
0 2 -1 1 2
1 1 -1 6 6
2 5 1 4 10
3 6 2 4 6
4 2 1 6 8
5 3 4 6 3

最大面积为 10

复杂度

  • 时间复杂度:O(n),每个下标最多入栈一次、出栈一次
  • 空间复杂度:O(n)

注意点

  • 栈中存下标,栈内下标对应的高度严格递增;判断条件使用 heights[i] <= heights[stk.top()]
  • 左边没有更矮柱子时使用 -1,右边没有使其出栈的位置时使用默认值 n,宽度统一写成 right[i] - left[i] - 1
  • 使用 <= 时,相等高度会让较早的柱子先出栈,较晚的同高度柱子继承更靠左的边界;较早柱子的宽度可能变短,但最大面积会由后面的同高度柱子计算,不会遗漏。

记忆锚点

枚举每根柱子当高度,单调栈找左右矮墙;有效区间不含两堵矮墙,所以宽度是右减左再减一。

面试简述

枚举每根柱子作为矩形高度,使用严格递增的单调栈计算它能够延伸的左右边界。当前柱子不高于栈顶时,当前下标就是栈顶柱子的右边界;弹栈后剩余栈顶则是当前柱子的左边界。最后用高度乘以左右边界之间的宽度更新最大面积,整体时间复杂度为 O(n)

posted @ 2026-07-29 00:58  clayJ  阅读(10)  评论(0)    收藏  举报