二叉树算法个人总结

解题的思维模式

1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现,这叫「遍历」的思维模式。

2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。

这两类思路分别对应着 回溯算法核心框架 和 动态规划核心框架

不论哪种方法都要考虑吧**如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?**其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作

深入理解前中后序

这是一个二叉树遍历框架

void traverse(TreeNode root) {
    
    
    if (root == null) {
    
    
        return;
    }
    // 前序位置
    traverse(root.left);
    // 中序位置
    traverse(root.right);
    // 后序位置
}

可以注意到,只要是递归形式的遍历,都可以有前序位置和后序位置,分别在递归之前和递归之后

所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候

理解了上面的话我们就能够写出倒序打印单链表

/* 递归遍历单链表,倒序打印链表元素 */
void traverse(ListNode head) {
    
    
    if (head == null) {
    
    
        return;
    }
    traverse(head.next);
    // 后序位置
    print(head.val);
}

具体应用

通过简单的题来感受这种思维模式

翻转二叉树

  1. 这题能不能用「遍历」的思维模式解决?可以,我写一个 traverse 函数遍历每个节点,让每个节点的左右子节点颠倒过来就行了。
  2. 单独抽出一个节点,需要让它做什么?让它把自己的左右子节点交换一下。
  3. 需要在什么时候做?好像前中后序位置都可以。

综上,可以写出如下解法代码:

// 主函数
TreeNode invertTree(TreeNode root) {
    
    
    // 遍历二叉树,交换每个节点的子节点
    traverse(root);
    return root;
}

// 二叉树遍历函数
void traverse(TreeNode root) {
    
    
    if (root == null) {
    
    
        return;
    }

    /**** 前序位置 ****/
    // 每一个节点需要做的事就是交换它的左右子节点
    TreeNode tmp = root.left;
    root.left = root.right;
    root.right = tmp;

    // 遍历框架,去遍历左右子树的节点
    traverse(root.left);
    traverse(root.right);
}

「分解问题」的思维模式解决

使用分解问题的时候,把递归函数当作已经实现,先写出框架,再想具体实现的细节

// 定义:将以 root 为根的这棵二叉树翻转,返回翻转后的二叉树的根节点
TreeNode invertTree(TreeNode root) {
    
    
    if (root == null) {
    
    
        return null;
    }
    // 利用函数定义,先翻转左右子树
    TreeNode left = invertTree(root.left);
    TreeNode right = invertTree(root.right);

    // 然后交换左右子节点
    root.left = right;
    root.right = left;

    // 和定义逻辑自恰:以 root 为根的这棵二叉树已经被翻转,返回 root
    return root;
}

填充每个节点的下一个右侧节点指针

示例:

在这里插入图片描述

这道题主要难点在于如何连接不在同一父节点下的两个结点,要连接这两个节点,就需要传入两个参数

// 主函数
Node connect(Node root) {
    
    
    if (root == null) return null;
    // 遍历 树,连接相邻节点
    traverse(root.left, root.right);
    return root;
}

void traverse(Node node1, Node node2) {
    
    
    if (node1 == null || node2 == null) {
    
    
        return;
    }
    /**** 前序位置 ****/
    // 将传入的两个节点穿起来
    node1.next = node2;
    
    // 连接相同父节点的两个子节点
    traverse(node1.left, node1.right);
    traverse(node2.left, node2.right);
    // 连接跨越父节点的两个子节点
    traverse(node1.right, node2.left);
}

二叉树展开为链表

在这里插入图片描述

// 定义:将以 root 为根的树拉平为链表
void flatten(TreeNode root) {
    
    
    // base case
    if (root == null) return;
    
    // 利用定义,把左右子树拉平
    flatten(root.left);
    flatten(root.right);

    /**** 后序遍历位置 ****/
    // 1、左右子树已经被拉平成一条链表
    TreeNode left = root.left;
    TreeNode right = root.right;
    
    // 2、将左子树作为右子树
    root.left = null;
    root.right = left;

    // 3、将原先的右子树接到当前右子树的末端
    TreeNode p = root;
    while (p.right != null) {
    
    
        p = p.right;
    }
    p.right = right;
}

构造二叉树

二叉树的构造问题一般都是使用「分解问题」的思路:构造整棵树 = 根节点 + 构造左子树 + 构造右子树。先找出根节点,然后根据根节点的值找到左右子树的元素,进而递归构建出左右子树。

最大二叉树

在这里插入图片描述

每个二叉树节点都可以认为是一棵子树的根节点,对于根节点,首先要做的当然是把想办法把自己先构造出来,然后想办法构造自己的左右子树。

所以,我们要遍历数组把找到最大值 maxVal,从而把根节点 root 做出来,然后对 maxVal 左边的数组和右边的数组进行递归构建,作为 root 的左右子树

/* 主函数 */
TreeNode constructMaximumBinaryTree(int[] nums) {
    
    
    return build(nums, 0, nums.length - 1);
}

// 定义:将 nums[lo..hi] 构造成符合条件的树,返回根节点
TreeNode build(int[] nums, int lo, int hi) {
    
    
    // base case
    if (lo > hi) {
    
    
        return null;
    }

    // 找到数组中的最大值和对应的索引
    int index = -1, maxVal = Integer.MIN_VALUE;
    for (int i = lo; i <= hi; i++) {
    
    
        if (maxVal < nums[i]) {
    
    
            index = i;
            maxVal = nums[i];
        }
    }

    // 先构造出根节点
    TreeNode root = new TreeNode(maxVal);
    // 递归调用构造左右子树
    root.left = build(nums, lo, index - 1);
    root.right = build(nums, index + 1, hi);
    
    return root;
}

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

类似上一题,我们肯定要想办法确定根节点的值,把根节点做出来,然后递归构造左右子树即可,这也是递归的思想

根据前序和中序我们能由根节点在中序中分别出左右子树,但我们要确定根节点在中序中的下标,这需要一个for循环来完成(题目指定树中无重复元素),我们也可以用哈希表来存储,直接找到对应索引。

// 存储 inorder 中值到索引的映射
HashMap<Integer, Integer> valToIndex = new HashMap<>();

public TreeNode buildTree(int[] preorder, int[] inorder) {
    
    
    for (int i = 0; i < inorder.length; i++) {
    
    
        valToIndex.put(inorder[i], i);
    }
    return build(preorder, 0, preorder.length - 1,
                 inorder, 0, inorder.length - 1);
}

TreeNode build(int[] preorder, int preStart, int preEnd, 
               int[] inorder, int inStart, int inEnd) {
    
    
        
    if (preStart > preEnd) {
    
    
        return null;
    }

    // root 节点对应的值就是前序遍历数组的第一个元素
    int rootVal = preorder[preStart];
    // rootVal 在中序遍历数组中的索引
    int index = valToIndex.get(rootVal);

    int leftSize = index - inStart;

    // 先构造出当前根节点
    TreeNode root = new TreeNode(rootVal);
    // 递归构造左右子树
    root.left = build(preorder, preStart + 1, preStart + leftSize,
                      inorder, inStart, index - 1);

    root.right = build(preorder, preStart + leftSize + 1, preEnd,
                       inorder, index + 1, inEnd);
    return root;
}

还有根据后序和中序,前序和后序来构造

序列化

序列化就是将二叉树转换为数组形式,我们可以根据前中后序选择不同的方式序列化,其实就是在考察二叉树的遍历方式

示例

在这里插入图片描述

前序遍历序列化与反序列化

String SEP = ",";
String NULL = "#";

/* 主函数,将二叉树序列化为字符串 */
String serialize(TreeNode root) {
    
    
    StringBuilder sb = new StringBuilder();
    //StringBuilder 可以用于高效拼接字符串,所以也可以认为是一个列表
    //用 , 作为分隔符,用 # 表示空指针 null
    serialize(root, sb);
    return sb.toString();
}

/* 辅助函数,将二叉树存入 StringBuilder */
void serialize(TreeNode root, StringBuilder sb) {
    
    
    if (root == null) {
    
    
        sb.append(NULL).append(SEP);
        return;
    }

    /****** 前序遍历位置 ******/
    sb.append(root.val).append(SEP);
    /***********************/

    serialize(root.left, sb);
    serialize(root.right, sb);
}

那么,反序列化过程也是一样,先确定根节点 root,然后遵循前序遍历的规则,递归生成左右子树即可

/* 主函数,将字符串反序列化为二叉树结构 */
TreeNode deserialize(String data) {
    
    
    // 将字符串转化成列表
    LinkedList<String> nodes = new LinkedList<>();
    for (String s : data.split(SEP)) {
    
    
        nodes.addLast(s);
    }
    return deserialize(nodes);
}

/* 辅助函数,通过 nodes 列表构造二叉树 */
TreeNode deserialize(LinkedList<String> nodes) {
    
    
    if (nodes.isEmpty()) return null;

    /****** 前序遍历位置 ******/
    // 列表最左侧就是根节点
    String first = nodes.removeFirst();
    if (first.equals(NULL)) return null;
    TreeNode root = new TreeNode(Integer.parseInt(first));
    /***********************/

    root.left = deserialize(nodes);
    root.right = deserialize(nodes);

    return root;
}

前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。

那么换句话说,一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了

寻找重复的子树

我想知道自己为根的子树是不是重复的就像需要知道两点:

  1. 我的子树是什么样的
  2. 别人的子树是什么样的

我们来解决他,我们可以利用序列化来存储每个结点为根的二叉树,我们利用哈希表记录子树以及其出现的次数

最近公共祖先

如果我们想实现在二叉树当中找到值为 k的结点

我们来看一段糟糕的代码

TreeNode find(TreeNode root, int val) {
    
    
    if (root == null) {
    
    
        return null;
    }
    // 前序位置,看看 root 是不是目标节点
    if (root.val == val) {
    
    
        return root;
    }
    
    // 再去左右子树寻找
    TreeNode left = find(root.left, val);
    TreeNode right = find(root.right, val);
    
    // root 不是目标节点,再去看看哪边的子树找到了
    return left != null ? left : right;
}

按照二叉树的前序遍历查找肯定是正确的,如果放在后序的话,不管怎么样都要遍历整个二叉树。但是如果左子树中找到了,代码人要去去右子树找

我们如果想要分别找到val1val2的值呢,代码基本也是一样的,但上面这个糟糕的代码却是最近公共祖先的框架

寻找两个节点的公共祖先

给你输入一棵不含重复值的二叉树,以及存在于树中的两个节点pq,请你计算pq的最近公共祖先节点。

那么对于任意一个节点,它怎么才能知道自己是不是pq的最近公共祖先?如果一个节点能够在它的左右子树中分别找到pq,则该节点为LCA(Lowest Common Ancestor)最近公共祖先节点节点

很简单,只需要在糟糕的代码上加一个判断逻辑就行了

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    
    
    return find(root, p.val, q.val);
}

// 在二叉树中寻找 val1 和 val2 的最近公共祖先节点
TreeNode find(TreeNode root, int val1, int val2) {
    
    
    if (root == null) {
    
    
        return null;
    }
    
    // 前序位置
    if (root.val == val1 || root.val == val2) {
    
    
        // 如果遇到目标值,直接返回
        return root;
    }
    
    TreeNode left = find(root.left, val1, val2);
    TreeNode right = find(root.right, val1, val2);
    // 后序位置,已经知道左右子树是否存在目标值
    if (left != null && right != null) {
    
    
        // 当前节点是 LCA 节点
        return root;
    }

    return left != null ? left : right;
}

因为题目说了pq一定存在于二叉树中而且不含重复值(这点很重要),所以即便我们遇到q就直接返回,根本没遍历到p,也依然可以断定pq底下

寻找一系列结点的公共祖先

给定不含重复树的二叉树,以及包含若干节点的列表nodes这些节点不一定存在于二叉树中

这次结点不一定存在于二叉树中,我们不能一遇到相关的值就返回,必须遍历整个二叉树。而正好,我们前面提过如果把判断放在后序位置,我们就能遍历整个二叉树

TreeNode lowestCommonAncestor(TreeNode root, TreeNode[] nodes) {
    
    
    // 将列表转化成哈希集合,便于判断元素是否是要查找的元素
    HashSet<Integer> values = new HashSet<>();
    for (TreeNode node : nodes) {
    
    
        values.add(node.val);
    }

    // 再创建一个字典检测是否所有元素都在二叉树中
	HashMap<int,boolean> dict = new HashMap<>();
    for (TreeNode node : nodes) {
    
    
        dict.put(node.val,false);
    }
    
    TreeNode res = find(root, values);
    
    // 判断是否都存在,不然返回null
    if(dict.containValue(false)){
    
    
        return null;
    }
    
    return res;
}

// 在二叉树中寻找 values 的最近公共祖先节点
TreeNode find(TreeNode root, HashSet<Integer> values) {
    
    
    if (root == null) {
    
    
        return null;
    }
    
    TreeNode left = find(root.left, values);
    TreeNode right = find(root.right, values);
    
    // 后序位置,已经知道左右子树是否存在目标值
    if (left != null && right != null) {
    
    
        // 当前节点是 LCA 节点
        return root;
    }
    
    if (values.contains(root.val)){
    
    
        dict.put(root.val,true);
        return root;
    }


    return left != null ? left : right;
}

二叉搜索树的最近公共祖先

假设val1 < val2,那么val1 <= root.val <= val2则说明当前节点就是LCA;若root.valval1还小,则需要去值更大的右子树寻找LCA;若root.valval2还大,则需要去值更小的左子树寻找LCA

二叉搜索树

二叉搜索树(Binary Search Tree,简写 BST)

1、对于 BST 的每一个节点 node,左子树节点的值都比 node 的值要小,右子树节点的值都比 node 的值大。

2、对于 BST 的每一个节点 node,它的左侧子树和右侧子树都是 BST。

从做算法题的角度来看 BST,除了它的定义,还有一个重要的性质:BST 的中序遍历结果是有序的(升序)

也就是说,如果输入一棵 BST,以下代码可以将 BST 中每个节点的值升序打印出来:

寻找第 K 小的元素

BST 的中序遍历其实就是升序排序的结果,找第 k 个元素肯定不是什么难事。

void traverse(TreeNode root) {
    
    
    if (root == null) return;
    traverse(root.left);
    // 中序遍历代码位置
    print(root.val);
    traverse(root.right);
}

利用这个性质,时间复杂度到达了O(N), 但是BST 性质是非常牛逼的,像红黑树这种改良的自平衡 BST,增删查改都是 O(logN) 的复杂度

我们想一下 BST 的操作为什么这么高效?就拿搜索某一个元素来说,BST 能够在对数时间找到该元素的根本原因还是在 BST 的定义里,左子树小右子树大嘛,所以每个节点都可以通过对比自身的值判断去左子树还是右子树搜索目标值,从而避免了全树遍历,达到对数级复杂度。

比如说你让我查找排名为 k 的元素,当前节点知道自己排名第 m,那么我可以比较 mk 的大小,只需要在二叉树结点中添加对应字段就行了

从二叉搜索树到更大和树

给定一个二叉搜索树 root (BST),请将它的每个节点的值替换成树中大于或者等于该节点值的所有节点值之和。

初步思路是,根据BST性质右子树都比他大,只需要加上右子树的所有值就行了。但是发现一个问题,他的父节点有可能等于他,但二叉树中又没有指向父节点的指针

我们之前能够升序打印值,自然也可以降序排列,只需要先递归右子树再递归左子树就好了。利用累加和sum来存储比他大的值之和,就能解决

TreeNode convertBST(TreeNode root) {
    
    
    traverse(root);
    return root;
}

// 记录累加和
int sum = 0;
void traverse(TreeNode root) {
    
    
    if (root == null) {
    
    
        return;
    }
    traverse(root.right);
    // 维护累加和
    sum += root.val;
    // 将 BST 转化成累加树
    root.val = sum;
    traverse(root.left);
}

BST的增删查改

对于BST相关问题基本逻辑是下面这样的

void BST(TreeNode root, int target) {
    
    
    if (root.val == target)
        // 找到目标,做点什么
    if (root.val < target) 
        BST(root.right, target);
    if (root.val > target)
        BST(root.left, target);
}

判断BST合法性

你会想这不是很简单吗,判断左右节点是不是分别小于和大于他就行了吗。但是也看出来了每个节点只能判断自己的左右节点,无法判断左右子树中的所有节点。

很简单我们只需要将左子树最大值和右子树最小值的约束作为参数传入就好了

boolean isValidBST(TreeNode root) {
    
    
    return isValidBST(root, null, null);
}

/* 限定以 root 为根的子树节点必须满足 max.val > root.val > min.val */
boolean isValidBST(TreeNode root, TreeNode min, TreeNode max) {
    
    
    // base case
    if (root == null) return true;
    // 若 root.val 不符合 max 和 min 的限制,说明不是合法 BST
    if (min != null && root.val <= min.val) return false;
    if (max != null && root.val >= max.val) return false;
    // 限定左子树的最大值是 root.val,右子树的最小值是 root.val
    return isValidBST(root.left, min, root) 
        && isValidBST(root.right, root, max);
}

删除

删除包含了查找操作,利用BST性质很容易找到。

如果要删除的结点的子节点不等于2,直接删除或者让子节点接管就行了

如果有两个子节点,必须找到左子树中最大(最右边)的那个节点,或者右子树中最小(最左边)的那个节点来接替自己。

BST的构建

BST的个数

给你一个整数 n ,求恰由 n 个节点组成且节点值从 1n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。

在这里插入图片描述

很容易看出,总的BST个数就是每个元素作为根节点时左右子树组合数乘积之和

/* 主函数 */
int numTrees(int n) {
    
    
    // 计算闭区间 [1, n] 组成的 BST 个数
    return count(1, n);
}

/* 计算闭区间 [lo, hi] 组成的 BST 个数 */
int count(int lo, int hi) {
    
    
    // base case
    if (lo > hi) return 1;

    int res = 0;
    for (int i = lo; i <= hi; i++) {
    
    
        // i 的值作为根节点 root
        int left = count(lo, i - 1);
        int right = count(i + 1, hi);
        // 左右子树的组合数乘积是 BST 的总数
        res += left * right;
    }

    return res;
}

然后再利用动态规划降低时间复杂度

// 备忘录
int[][] memo;

int numTrees(int n) {
    
    
    // 备忘录的值初始化为 0
    memo = new int[n + 1][n + 1];
    return count(1, n);
}

int count(int lo, int hi) {
    
    
    if (lo > hi) return 1;
    // 查备忘录
    if (memo[lo][hi] != 0) {
    
    
        return memo[lo][hi];
    }

    int res = 0;
    for (int mid = lo; mid <= hi; mid++) {
    
    
        int left = count(lo, mid - 1);
        int right = count(mid + 1, hi);
        res += left * right;
    }
    // 将结果存入备忘录
    memo[lo][hi] = res;

    return res;
}

构建所有BST

95. 不同的二叉搜索树 II - 力扣(LeetCode)

给你一个整数 n ,请你生成并返回所有由 n 个节点组成且节点值从 1n 互不相同的不同 二叉搜索树 。可以按 任意顺序 返回答案。

明白了上道题构造合法 BST 的方法,这道题的思路也是一样的

1、穷举root节点的所有可能。

2、递归构造出左右子树的所有合法 BST。

3、给root节点穷举所有左右子树的组合。

/* 主函数 */
public List<TreeNode> generateTrees(int n) {
    
    
    if (n == 0) return new LinkedList<>();
    // 构造闭区间 [1, n] 组成的 BST 
    return build(1, n);
}

/* 构造闭区间 [lo, hi] 组成的 BST */
List<TreeNode> build(int lo, int hi) {
    
    
    List<TreeNode> res = new LinkedList<>();
    // base case
    if (lo > hi) {
    
    
        res.add(null);
        return res;
    }

    // 1、穷举 root 节点的所有可能。
    for (int i = lo; i <= hi; i++) {
    
    
        // 2、递归构造出左右子树的所有合法 BST。
        List<TreeNode> leftTree = build(lo, i - 1);
        List<TreeNode> rightTree = build(i + 1, hi);
        //列表中的每个元素代表着左右子树的头节点
        
        // 3、给 root 节点穷举所有左右子树的组合。
        for (TreeNode left : leftTree) {
    
    
            for (TreeNode right : rightTree) {
    
    
                // i 作为根节点 root 的值
                TreeNode root = new TreeNode(i);
                root.left = left;
                root.right = right;
                res.add(root);
            }
        }
    }

    return res;
}

二叉树视角下的算法

快速排序

首先我们看一下快速排序的代码框架:

void sort(int[] nums, int lo, int hi) {
    
    
    if (lo >= hi) {
    
    
        return;
    }
    // 对 nums[lo..hi] 进行切分
    // 使得 nums[lo..p-1] <= nums[p] < nums[p+1..hi]
    int p = partition(nums, lo, hi);
    // 去左右子数组进行切分
    sort(nums, lo, p - 1);
    sort(nums, p + 1, hi);
}

这个结构和二叉树前序遍历非常相似

快速排序是先将一个元素排好序,然后再将剩下的元素排好序

快速排序的核心无疑是nums[lo..hi] 中寻找一个分界点 p,通过交换元素使得 nums[lo..p-1] 都小于等于 nums[p],且 nums[p+1..hi] 都大于 nums[p]

从二叉树的视角,我们可以把子数组 nums[lo..hi] 理解成二叉树节点上的值,sort 函数理解成二叉树的遍历函数

请添加图片描述

我们观察一下又可以看出,左边都比他小,右边都比他大,这不就是BST吗?

为了方便,这里给出快速排序的代码

class Quick {
    
    

    public static void sort(int[] nums) {
    
    
        // 为了避免出现耗时的极端情况,先随机打乱
        shuffle(nums);
        // 排序整个数组(原地修改)
        sort(nums, 0, nums.length - 1);
    }

    private static void sort(int[] nums, int lo, int hi) {
    
    
        if (lo >= hi) {
    
    
            return;
        }
        // 对 nums[lo..hi] 进行切分
        // 使得 nums[lo..p-1] <= nums[p] < nums[p+1..hi]
        int p = partition(nums, lo, hi);

        sort(nums, lo, p - 1);
        sort(nums, p + 1, hi);
    }

    // 对 nums[lo..hi] 进行切分
    private static int partition(int[] nums, int lo, int hi) {
    
    
        int pivot = nums[lo];
        // 关于区间的边界控制需格外小心,稍有不慎就会出错
        // 我这里把 i, j 定义为开区间,同时定义:
        // [lo, i) <= pivot;(j, hi] > pivot
        // 之后都要正确维护这个边界区间的定义
        int i = lo + 1, j = hi;
        // 当 i > j 时结束循环,以保证区间 [lo, hi] 都被覆盖
        while (i <= j) {
    
    
            while (i < hi && nums[i] <= pivot) {
    
    
                i++;
                // 此 while 结束时恰好 nums[i] > pivot
            }
            while (j > lo && nums[j] > pivot) {
    
    
                j--;
                // 此 while 结束时恰好 nums[j] <= pivot
            }
            // 此时 [lo, i) <= pivot && (j, hi] > pivot

            if (i >= j) {
    
    
                break;
            }
            swap(nums, i, j);
        }
        // 将 pivot 放到合适的位置,即 pivot 左边元素较小,右边元素较大
        swap(nums, lo, j);
        return j;
    }

    // 洗牌算法,将输入的数组随机打乱
    private static void shuffle(int[] nums) {
    
    
        Random rand = new Random();
        int n = nums.length;
        for (int i = 0 ; i < n; i++) {
    
    
            // 生成 [i, n - 1] 的随机数
            int r = i + rand.nextInt(n - i);
            swap(nums, i, r);
        }
    }

    // 原地交换数组中的两个元素
    private static void swap(int[] nums, int i, int j) {
    
    
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

partition 执行的次数是二叉树节点的个数,每次执行的复杂度就是每个节点代表的子数组 nums[lo..hi] 的长度,所以总的时间复杂度就是整棵树中「数组元素」的个数
在这里插入图片描述

假设数组元素个数为 N,那么二叉树每一层的元素个数之和就是 O(N);分界点分布均匀的理想情况下,树的层数为 O(logN),所以理想的总时间复杂度为 O(NlogN)

由于快速排序没有使用任何辅助数组,所以空间复杂度就是递归堆栈的深度,也就是树高 O(logN)

前面寻找BST中第k小的元素就是用的这个思想

归并排序

先把左半边数组排好序,再把右半边数组排好序,然后把两半数组合并。

为什么要将归并排序放在这里呢?

我们在前中后序实现具体代码的思想方法可以用于归并的merge过程中,在里面加点实现代码。

计算右侧小于当前元素的个数

这题和归并排序什么关系呢,主要在 merge 函数,我们在使用 merge 函数合并两个有序数组的时候,其实是可以知道一个元素 nums[i] 后边有多少个元素比 nums[i] 小的

在这里插入图片描述

在对 nuns[lo..hi] 合并的过程中,每当执行 nums[p] = temp[i] 时,就可以确定 temp[i] 这个元素后面比它小的元素个数为 j - mid - 1

当然,nums[lo..hi] 本身也只是一个子数组,这个子数组之后还会被执行 merge,其中元素的位置还是会改变。但这是其他递归节点需要考虑的问题,我们只要在 merge 函数中做一些手脚,叠加每次 merge 时记录的结果即可

class Solution {
    
    
    private class Pair {
    
    
        int val, id;
        Pair(int val, int id) {
    
    
            // 记录数组的元素值
            this.val = val;
            // 记录元素在数组中的原始索引
            this.id = id;
        }
    }
    
    // 归并排序所用的辅助数组
    private Pair[] temp;
    // 记录每个元素后面比自己小的元素个数
    private int[] count;
    
    // 主函数
    public List<Integer> countSmaller(int[] nums) {
    
    
        int n = nums.length;
        count = new int[n];
        temp = new Pair[n];
        Pair[] arr = new Pair[n];
        // 记录元素原始的索引位置,以便在 count 数组中更新结果
        for (int i = 0; i < n; i++)
            arr[i] = new Pair(nums[i], i);
        
        // 执行归并排序,本题结果被记录在 count 数组中
        sort(arr, 0, n - 1);
        
        List<Integer> res = new LinkedList<>();
        for (int c : count) res.add(c);
        return res;
    }
    
    // 归并排序
    private void sort(Pair[] arr, int lo, int hi) {
    
    
        if (lo == hi) return;
        int mid = lo + (hi - lo) / 2;
        sort(arr, lo, mid);
        sort(arr, mid + 1, hi);
        merge(arr, lo, mid, hi);
    }
    
    // 合并两个有序数组
    private void merge(Pair[] arr, int lo, int mid, int hi) {
    
    
        for (int i = lo; i <= hi; i++) {
    
    
            temp[i] = arr[i];
        }
        
        int i = lo, j = mid + 1;
        for (int p = lo; p <= hi; p++) {
    
    
            //检查是否有一个数组已经排序完
            if (i == mid + 1) {
    
    
                arr[p] = temp[j++];
            } else if (j == hi + 1) {
    
    
                arr[p] = temp[i++];
                // 更新 count 数组
                count[arr[p].id] += j - mid - 1;
            } else if (temp[i].val > temp[j].val) {
    
    
                arr[p] = temp[j++];
            } else {
    
    
                arr[p] = temp[i++];
                // 更新 count 数组
                count[arr[p].id] += j - mid - 1;
            }
        }
    }
}

翻转对

给定一个数组 nums ,如果 i < jnums[i] > 2*nums[j] 我们就将 (i, j) 称作一个重要翻转对

和上一题的思路非常相似,只不过需要注意一个可能超时的问题

private void merge(int[] nums, int lo, int mid, int hi) {
    
    
    //...
    
    // 在合并有序数组之前,加点私货
    for (int i = lo; i <= mid; i++) {
    
    
        // 对于左半边的每个 nums[i],都去右半边寻找符合条件的元素
        for (int j = mid + 1; j <= hi; j++) {
    
    
            // nums 中的元素可能较大,乘 2 可能溢出,所以转化成 long
            if ((long)nums[i] > (long)nums[j] * 2) {
    
    
                count++;
            }
        }
    }
    
--------------------------------------------
    
	// 进行效率优化,维护左闭右开区间 [mid+1, end) 中的元素乘 2 小于 nums[i]
    // 为什么 end 是开区间?因为这样的话可以保证初始区间 [mid+1, mid+1) 是一个空区间
    int end = mid + 1;
    for (int i = lo; i <= mid; i++) {
    
    
        // nums 中的元素可能较大,乘 2 可能溢出,所以转化成 long
        while (end <= hi && (long)nums[i] > (long)nums[end] * 2) {
    
    
            end++;
        }
        count += end - (mid + 1);
    }
    //同样是两个循环为什么后面一个效率更高?因为前面的j每次循环会回退,而end不会
    
    //...
}

区间和的个数

计算元素和落在 [lower, upper] 中的所有子数组的个数

只给出merge函数

private int lower, upper;

public int countRangeSum(int[] nums, int lower, int upper) {
    
    
this.lower = lower;
    this.upper = upper;
    // 构建前缀和数组,注意 int 可能溢出,用 long 存储
    long[] preSum = new long[nums.length + 1];
    for (int i = 0; i < nums.length; i++) {
    
    
        preSum[i + 1] = (long)nums[i] + preSum[i];
    }
    
    // 对前缀和数组进行归并排序
    sort(preSum);
    return count;
}

private long[] temp;

public void sort(long[] nums) {
    
    
    temp = new long[nums.length];
    sort(nums, 0, nums.length - 1);
}

private void sort(long[] nums, int lo, int hi) {
    
    
    if (lo == hi) {
    
    
        return;
    }
    int mid = lo + (hi - lo) / 2;
    sort(nums, lo, mid);
    sort(nums, mid + 1, hi);
    merge(nums, lo, mid, hi);
}

private int count = 0;

private void merge(long[] nums, int lo, int mid, int hi) {
    
    
    for (int i = lo; i <= hi; i++) {
    
    
        temp[i] = nums[i];
    }
    
    // 在合并有序数组之前加点私货(这段代码会超时)
    // for (int i = lo; i <= mid; i++) {
    
    
    //     for (int j = mid + 1; j <= hi; k++) {
    
    
    //         // 寻找符合条件的 nums[j]
    //         long delta = nums[j] - nums[i];
    //         if (delta <= upper && delta >= lower) {
    
    
    //             count++;
    //         }
    //     }
    // }
    
    // 使用滑动窗口进行效率优化
    // 维护左闭右开区间 [start, end) 中的前缀元素和 nums[i] 的差在 [lower, upper] 中
    int start = mid + 1, end = mid + 1;
    for (int i = lo; i <= mid; i++) {
    
    
        // 如果 nums[i] 对应的区间是 [start, end),
        // 那么 nums[i+1] 对应的区间一定会整体右移,类似滑动窗口
        while (start <= hi && nums[start] - nums[i] < lower) {
    
    
            start++;
        }
        while (end <= hi && nums[end] - nums[i] <= upper) {
    
    
            end++;
        }
        count += end - start;
    }

    // 数组双指针技巧,合并两个有序数组
    int i = lo, j = mid + 1;
    for (int p = lo; p <= hi; p++) {
    
    
        if (i == mid + 1) {
    
    
            nums[p] = temp[j++];
        } else if (j == hi + 1) {
    
    
            nums[p] = temp[i++];
        } else if (temp[i] > temp[j]) {
    
    
            nums[p] = temp[j++];
        } else {
    
    
            nums[p] = temp[i++];
        }
    }
}

在这里插入图片描述

总结

所有递归的算法,本质上都是在遍历一棵(递归)树,然后在节点(前中后序位置)上执行代码。你要写递归算法,本质上就是要告诉每个节点需要做什么

比如归并排序算法,递归的 sort 函数就是二叉树的遍历函数,而 merge 函数就是在每个节点上做的事情,有没有品出点味道?

猜你喜欢

转载自blog.csdn.net/jkkk_/article/details/126527008