【算法】分治法四步走


分治法在每一层递归上都有三个步骤:
1)分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题
2)解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
3)合并:将各个子问题的解合并为原问题的解。

适用场景

适用于我们的问题是一个大问题的时候,且这个大问题能被分解成多个小问题。

二分算法面对中间分叉,不能确定是往左走,还是往右走的时候,我们就可以使用分治算法,递归,给他左右都走一遍

分治四步走

由于分治法一般采用递归方法实现,所以大家可以结合【算法】递归三步走来进行理解。

1. 明确分解策略

1.明确分解策略:明确大问题通过怎样的分解策略一步步分解为最终的小问题,之后我们需要根据分解策略明确函数的功能

如果我们的分解策略如果是折半分解,那么我们的函数就需要有范围域(left, right)来确定分解范围;

注意:如果有多个分解域,我们就需要判断一下是不是每个域都要进行分解,每需要分解一次,就需要一个范围域(left, right)

如果是递减分解,那么我们的函数需要有计数(num),来记录递减分解结果。

最简单的例子:树的先序遍历,分解策略:根左右(根节点、左子树、右子树)

比如说,快速排序的大的问题可以分解为就是将n个元素摆到正确位置,汉诺塔的大的问题就是将n个圆盘由下而上摆到正确位置。

2. 寻找最小问题

2.寻找最小问题:最小问题也就是大问题的最简化版本,问题的起始状态,最小子问题即是出口。

最简单的例子:树的先序遍历,最小问题:根节点为空,返回return

比如说,快速排序没有元素或者只有一个元素(left >= right)的时候。

3. 划分子类问题

子类问题:子类问题与原问题有相同的结构,是一样的,只不过规模小一些\(原问题 ∽ 子类问题\),如 (\(原树 ∽ 左右子树\)
例子:比如树的中序遍历序列,按根节点划分之后,左右还是中序遍历序列。

类比:类似于数学里面的相似三角形

3.划分子类问题:使用分解策略将大问题分解为 当前问题 + 子类问题(即 原问题 = 当前问题 + 子类问题我们只能解决当前问题,子类问题只起到划分的作用)。子类问题也就是介于最小问题与大问题之间的问题,与大问题有相同的结构,比大问题稍稍小那么一点,这使得子类问题具有解决大问题的通用性,即 可以通过子类问题找到大问题的通解。由子类问题得到解决方法。

特别注意:分治算法只去操作解决当前的问题,即 当前元素(如 根节点),对其他划分出来的子类问题(如 左右子树)均不操作解决,只起到划分的作用。

子类问题划分技巧:找相似法
由于原问题与子类问题相似,所以我们可以按照原问题提供的已知条件来寻找相似的子类问题所需要提供的条件

例子:
问题:已知树的先序遍历、中序遍历,求出后序遍历。

  • 原问题:提供大树先序遍历序列大树中序遍历序列
  • 子类问题:提供左右子树中序遍历序列,根据左右子树数量来找出左右子树先序遍历序列

image
你看看,上面的原问题与子类问题是不是一样的结构,相似!


最简单的例子:树的先序遍历,

  • 当前问题:根,
  • 子类问题:根左右(根节点、左子树、右子树),
  • 访问根节点因为只有当前节点根节点能够给我们操作,其他的左右子树都是起到划分的作用

比如说,快速排序的当前问题就是将一个元素摆到正确位置,汉诺塔的当前问题就是将一个最下面的圆盘摆到正确位置。

4. 解决当前问题

4.解决当前问题:这个按照问题的逻辑进行添加,分治算法只去操作解决当前的问题,即 当前元素(如 根节点),对其他划分出来的子类问题(如 左右子树)均不操作解决,只起到划分的作用。

注意:在我们上一步划分子类问题之后,我们应该解决当前问题,但是这个当前问题的解决应该位于子类问题划分的之前、中间还是之后,这个就要具体题目具体分析了
例如:

  • 先序遍历:当前问题(根)、子类问题(左子树)、子类问题(右子树)
  • 中序遍历:子类问题(左子树)、当前问题(根)、子类问题(右子树)
  • 后序遍历:子类问题(左子树)、子类问题(右子树)、当前问题(根)

在解决当前问题的时候,我们可能会需要结合之前划分子类问题的结果返回值(不一定),也可能不需要,作为单机问题去解决。

例如,我们在上一步中划分出了左右子树,这一步中我们可以将根节点与左右子树的根节点相连,形成一个完整的树。

折半分解

分治算法其实是二分算法的递归形式,他代表了并行执行,也就是说,如果我们的二分算法面对中间分叉,并不能确定是往左走,还是往右走,我们就可以使用分治算法,递归,左右都给他走一遍。

相当于二分算法是串行的一种迭代算法,而分治算法是一种并行的递归算法。

折半分解是我们在分治法中最常用的,所以这里提出来特别介绍。
此算法常用于递归操作树划分树的左右子树

  1. 明确分解策略:折半分解,左右边界(left, right),分解线mid = left + (right - left) / 2

  2. 寻找最小问题:left > right或者left >= right

  3. 划分子类问题:f(left, mid - 1)f(mid + 1, right)

  4. 解决当前问题:root.left = f(left, mid - 1)root.right = f(mid + 1, right)

大家可以看看面试题 08.03. 魔术索引这道题,有序,如果使用二分算法,我们不能确定面对分叉是应该往左走,还是往右走,所以我们得使用分治算法,左右都走一遍。

四步走实例

1. 明确分解策略

第一步,明确怎么把大的问题一步步分解为最终的小问题;并明确这个函数的功能是什么,它要完成什么样的一件事。
分解策略:大问题 = n * 小问题。如果大问题是一个数组,那么小问题就是数组中的一个元素。
如果我们的分解策略如果是折半分解,那么我们的函数就需要有范围域(left, right)来确定分解范围;
如果是递减分解,那么我们的函数需要有计数(num),来记录递减分解结果。

比如说,快速排序的大的问题可以分解为就是将n个元素摆到正确位置,汉诺塔的大的问题就是将n个圆盘由下而上摆到正确位置。

比如说,快速排序算法的大问题就是将数组中的n个元素进行排序摆放到正确的位置,那么分解而成的小问题就是将数组中的一个元素摆放到正确的位置。
而汉诺塔的大问题就是将A柱子上的n个盘子借用B柱子由大到小放在C柱子上,那么分解而成的小问题就是将A柱子上的最底下的最大盘子借用B放在C柱子上。

而这个功能,是完全由你自己来定义的。也就是说,我们先不管函数里面的代码是什么、怎么写,而首先要明白,你这个函数是要用来干什么的。

例如:面试题 04.02. 最小高度树
给定一个有序整数数组,元素各不相同且按升序排列,编写一个算法,创建一棵高度最小的二叉搜索树。

示例:

给定有序数组: [-10,-3,0,5,9],

一个可能的答案是:[0,-3,9,-10,null,5],它可以表示下面这个高度平衡二叉搜索树:

          0 
         / \ 
       -3   9 
       /   / 
     -10  5 

要做出这个题,
第一步,要明确我们的分解策略,这里因为是二叉树,所以采用的方法一般是通过根节点划分左右子树,我的分解策略是折半分解;
既然分解策略是折半分解,那么我们即将要写出的这个函数必须指明分解范围,不然没有办法进行折半分解。

明确分解策略:大问题=创建一棵高度最小的二叉搜索树,折半分解,小问题=通过根节点划分左右子树,创建一个根左右3个节点的二叉树。

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public TreeNode sortedArrayToBST(int[] nums) {

        return f(nums, 0, nums.length - 1);
    }

    // 分治法递归需要重新构造函数
    // 按(left, right)区域进行划分子树
    // 明确分解策略:大问题=创建一棵高度最小的二叉搜索树,折半分解,小问题=通过根节点划分左右子树,创建一个根左右3个节点的二叉树。
    public TreeNode f(int[] nums, int left, int right) {
        
    }
}

2. 寻找最小问题(初始条件)

分治就是在函数实现的内部代码中,将大问题不断的分解为子类问题,再将小问题进一步分解为更小的问题。所以,我们必须要找出分治的结束条件,即 给定一个分解的阈值,不然的话,它会一直分解自己,无穷无尽。

  • 必须有一个明确的结束条件。因为分治就是有“分”“并”,所以必须又有一个明确的点,到了这个点,就不用“分解下去”,而是开始“合并”。

第二步,我们需要找出当参数为何值、分解到何种程度时,分治结束,之后直接把结果返回。
一般为初始条件,然后从初始条件一步一步扩充到最终结果

注意:这个时候我们必须能根据这个参数的值,能够直接知道函数的结果是什么。

让我们继续完善上面那个创建树。
第二步,寻找最小问题:
left>right时,即 分解为空了,没有根节点,返回null;
那么递归出口就是left>right时函数返回null。

left=right时,即 分解到只剩一个元素了,我们能够直接知道这最后一个元素就是根节点,返回最后一个元素;
left=right时函数返回new TreeNode(nums[left])(这种情况我们折半分解获取mid创建根节点的时候包含了,所以不写了)
并且这个不是最小的问题。

如下:

    // 分治法递归需要重新构造函数
    // 按(left, right)区域进行划分子树
    // 明确分解策略:大问题=创建一棵高度最小的二叉搜索树,折半分解,小问题=通过根节点划分左右子树,创建一个根左右3个节点的二叉树。
    public TreeNode f(int[] nums, int left, int right) {
        if (left > right) {
            return null;
        }
    }

当然,当l=r时,我们也是知道f(r)等于多少的,f(r)也可以作为递归出口。分治出口可能并不唯一的。

3. 划分子类问题

特别注意:分治算法只去操作解决当前的问题,即 当前元素(如 根节点),对其他划分出来的子类问题(如 左右子树)均不操作解决,只起到划分的作用。

第三步,之前我们明确了分解策略,现在正是使用的时候了,我们需要使用这个分解策略,将大问题分解为子类问题。这样就能一步步分解到最小问题,然后作为函数出口。

  • 最小问题:分解至空时,l>r,f(l)即为null
  • 分解策略:折半分解,f(nums, l, r) → f(nums, l, (l+r)/2 - 1) , f(nums, (l+r)/2 + 1, r)
  • 当前问题:根节点,mid
  • 子类问题:左右子树数组,(left, mid-1)和(mid+1, right)。

分治:

  • 分:将f(nums, l, r) → f(nums, l, (l+r)/2) , f(nums, (l+r)/2+1, r)了。这样,问题就由n缩小为了n/2,我们只需要找到这n/2个元素的最大值即可。就这样慢慢从f(n),f(n/2)“分”到f(1)。
  • 并:这样就可以从1,一步一步“并”到n/2,n...
    // 分治法递归需要重新构造函数
    // 按(left, right)区域进行划分子树
    // 明确分解策略:大问题=创建一棵高度最小的二叉搜索树,折半分解,小问题=通过根节点划分左右子树,创建一个根左右3个节点的二叉树。
    public TreeNode f(int[] nums, int left, int right) {
        // 寻找最小问题
        if (left > right) {
            return null;
        }
        
        // 划分子类问题:通过根节点划分左右子树,创建一个根左右3个节点的二叉树。
        int mid = left + (right - left) / 2;
        TreeNode root = new TreeNode(nums[mid]);
        
        f(nums, left, mid - 1);
        f(nums, mid + 1, right);

        return root;
    }

4. 解决当前问题

第四步:解决当前问题。
上一步我们将大问题分解为了当前问题+子类问题,那么这个当前问题怎么解决呢,解决这个当前问题,也是为我们接下来的分解提供问题的解决方案,所以不能大意。
解决当前问题的方法,就是比较两个当前问题的大小,得出最大的一个值,问题即可解决。

特别注意:分治算法只去操作解决当前的当前问题(如 根节点),对其他划分出来的子类问题(如 左右子树)均不操作解决,只起到划分的作用。

这里的解决合并就是将子类问题合并为一个完整的大问题,方便我们求出大问题的答案,即 创建一个根左右3个节点的二叉树。

    // 分治法递归需要重新构造函数
    // 按(left, right)区域进行划分子树
    // 明确分解策略:大问题=创建一棵高度最小的二叉搜索树,折半分解,小问题=通过根节点划分左右子树,创建一个根左右3个节点的二叉树。
    public TreeNode f(int[] nums, int left, int right) {
        // 寻找最小问题
        if (left > right) {
            return null;
        }
        
        // 划分子类问题:通过根节点划分左右子树,创建一个根左右3个节点的二叉树。
        int mid = left + (right - left) / 2;
        TreeNode root = new TreeNode(nums[mid]);
        
        // 解决当前问题:创建一个根左右3个节点的二叉树
        root.left = f(nums, left, mid - 1);
        root.right = f(nums, mid + 1, right);

        return root;
    }

到这里,分治五步走就完成了,那么这个分治函数的功能我们也就实现了。
可能初学的读者会感觉很奇妙,这就能得到最大值了?
那么,我们来一步一步推一下。
假设n为数组中元素的个数,f(n)则为函数
f(1)只有一个元素,可以得到一个确定的值
f(2)根据f(1)的值,也能确定了
f(4)根据f(2)的值,也能确定下来了
...
f(n/2)
f(n)根据f(n/2)也能确定下来了
你看看是不是解决了,n都能分治得到结果!


当前问题解决技巧

记录第一次满足条件的元素

增加递归出口:

// 结果集:res
if (res != null) {
    return;
}

当前节点操作:

// 结果集:res
if (条件 && res == null) {
    res = node;
    return;
}

记录最后一次满足条件的元素

当前节点操作:与记录第一次满足条件的元素的区别在于没有判断res == null

// 结果集:res
if (条件) {
    res = node;
}

记录当前问题节点的前驱节点

遍历完当前问题节点node后,使用pre记录当前节点,然后使用node去继续前进遍历。

dfs(node.left);

visit(node);
pre = node; // 记录当前节点,下一步当前节点就会移到其他位置,所以pre会记录当前节点的前驱结点

dfs(node.right);    //node的下一次递归

多种分治法解法

面试题 04.06. 后继者

设计一个算法,找出二叉搜索树中指定节点的“下一个”节点(也即中序后继)。

如果指定节点没有对应的“下一个”节点,则返回null。

示例 1:

输入: root = [2,1,3], p = 1

  2
 / \
1   3

输出: 2

示例 2:

输入: root = [5,3,6,2,4,null,null,1], p = 6

      5
     / \
    3   6
   / \
  2   4
 /   
1

输出: null

按题意写函数功能

inorderSuccessor(root, p)方法就是在树root中找p节点的后继节点。

class Solution {

    public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
        if (root == null) {
            return null;
        }

        if (p.val >= root.val) {
            return inorderSuccessor(root.right, p);
        } else {
            TreeNode leftRet = inorderSuccessor(root.left, p);
            if (leftRet == null) {
                return root;
            } else {
                return leftRet;
            }
        }
    }
}

记录第一次满足条件的元素

如果找到p节点,那就置flag为true,中序遍历到后一个节点时设置结果res。

class Solution {
    boolean flag;   // 如果找到p节点,那就置为true,遍历到后一个节点时设置结果
    TreeNode res;

    public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
        dfs(root, p);
        return res;
    }

    public void dfs(TreeNode root, TreeNode p) {
        if (root == null) {
            return;
        }

        dfs(root.left, p);

        // 要求答案只改一次,不能被覆盖,就只要第一次成功的
        if (flag == true && res == null) {  // 不能放在最前面作为递归出口,因为需要中序遍历的后一个节点,此处是当前问题(当前节点)
            res = root;
            return;
        }
        if (root == p) {
            flag = true;
        }
        
        dfs(root.right, p);
    }
}

记录最后一次满足条件的元素

这个题目只要第一次满足条件,所以下面这种解法不成立,但是可以给大家一个样例看看。

class Solution {
    boolean flag;   // 如果找到p节点,那就置为true,遍历到后一个节点时设置结果
    TreeNode res;

    public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
        dfs(root, p);
        return res;
    }

    public void dfs(TreeNode root, TreeNode p) {
        if (root == null) {
            return;
        }

        dfs(root.left, p);
        
        // 区别在这里!!!!!!!!!!!!!!!!!!!!!!
        // 要求答案次次修改,能被覆盖,就只要最后一次成功的
        if (flag == true) { // 不能放在最前面作为递归出口,因为需要中序遍历的后一个节点,此处是当前问题(当前节点)
            res = root;
            return;
        }
        if (root == p) {
            flag = true;
        }
        
        dfs(root.right, p);
    }
}

记录当前问题节点的前驱节点

我们使用pre节点来记录当前问题节点的前驱结点,每次遍历都需要前进(pre = root)。

class Solution {
    TreeNode res;
    TreeNode pre;

    public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
        dfs(root, p);
        return res;
    }

    public void dfs(TreeNode root, TreeNode p) {
        if (root == null || res != null) {
            return;
        }

        dfs(root.left, p);

        // 要求答案只改一次,不能被覆盖,就只要第一次成功的
        if (pre == p && res == null) {
            res = root;
            return;
        }
        pre = root; // 前进
        dfs(root.right, p);
    }
}

疑似二分算法

面试题 08.03. 魔术索引

魔术索引。 在数组A[0...n-1]中,有所谓的魔术索引,满足条件A[i] = i。给定一个有序整数数组,编写一种方法找出魔术索引,若有的话,在数组A中找出一个魔术索引,如果没有,则返回-1。若有多个魔术索引,返回索引值最小的一个。

示例1:

 输入:nums = [0, 2, 3, 4, 5]
 输出:0
 说明: 0下标的元素为0

示例2:

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

答案

这题我一看,有序,二分查找!结果根本就没办法确定是往左走还是往右走,直接给我没辙了。

就试了试分治算法,递归的二分算法,问题迎刃而解。

// 很遗憾,二分查找并不行,因为无论找到了什么满足条件的元素,其左右都可能出现满足条件的元素
// 只能分治算法
class Solution {
    public int findMagicIndex(int[] nums) {

        return f(nums, 0, nums.length - 1);
    }

    public int f(int[] nums, int left, int right) {
        if (left > right) {
            return -1;
        }

        int mid = left + (right - left) / 2;

        // 左边
        int leftAns = f(nums, left, mid - 1);

        // 中间
        if (leftAns != -1) {
            return leftAns;
        } else if (mid == nums[mid]) {
            return mid;
        }

        // 右边
        return f(nums, mid + 1, right);
    }
}

面试题 10.05. 稀疏数组搜索

稀疏数组搜索。有个排好序的字符串数组,其中散布着一些空字符串,编写一种方法,找出给定字符串的位置。

示例1:

 输入: words = ["at", "", "", "", "ball", "", "", "car", "", "","dad", "", ""], s = "ta"
 输出:-1
 说明: 不存在返回-1。

示例2:

 输入:words = ["at", "", "", "", "ball", "", "", "car", "", "","dad", "", ""], s = "ball"
 输出:4

答案

很明显,虽然有序,但是无法判断中间分叉该向左走还是向右走,所以采用分治算法。

class Solution {
    public int findString(String[] words, String s) {
        // 有序,二分,可是无法判断中间分叉该左走还是右走
        // 分治,递归
        return f(words, s, 0, words.length - 1);
    }

    public int f(String[] words, String s, int left, int right) {
        if (left > right) {
            return -1;
        }

        int mid = left + (right - left) / 2;

        if (s.equals(words[mid])) {
            return mid;
        }

        int leftAns = f(words, s, left, mid - 1);
        if (leftAns != -1) {
            return leftAns;
        }

        int rightAns = f(words, s, mid + 1, right);
        if (rightAns != -1) {
            return rightAns;
        }

        return -1;
    }
}

实例

最大数

在一个数组中找最大的数字。

分解策略:对半分

从l到r中找到最大的一个元素。

// 明确分解策略:大问题=从n个元素中找到最大的数字并返回,折半分解,小问题=从2个元素比较大小找到最大数字并返回。
int f(int[] nums, int l, int r) {

      // 寻找最小问题:最小问题即是只有一个元素的时候
      if (l >= r) {
            return nums[l];
      }

      // 使用分解策略
      int lMax = f(nums, l, (l+r)/2);
      int rMax = f(nums, (l+r)/2+1, r);

      // 解决次小问题:比较两个元素得到最大的数字

      return lMax > rMax ? lMax : rMax;
}

汉诺塔

汉诺塔的传说

汉诺塔:汉诺塔(又称河内塔)问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着 64 片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。

假如每秒钟一次,共需多长时间呢?移完这些金片需要 5845. 54 亿年以上,太阳系的预期寿命据说也就是数百亿年。真的过了 5845. 54 亿年,地球上的一切生命,连同梵塔、庙宇等,都早已经灰飞烟灭。

汉诺塔游戏的演示和思路分析:

1 ) 如果是有一个盘,A->C

如果我们有 n>= 2 情况,我们总是可以看做是两个盘 1 .最下边的盘 2. 上面的盘
2 ) 先把 最上面的盘A->B
3 ) 把最下边的盘A->C
4 ) 把B塔的所有盘 从 B->C

汉诺塔游戏的代码实现:

看老师代码演示:

package com.atguigu.dac;

public class Hanoitower {

    public static void main(String[] args) {
        hanoiTower(10, 'A', 'B', 'C');
    }
    
    //汉诺塔的移动的方法
    //使用分治算法
    // 明确分解策略:我们的问题是有n个盘子,可是如果是n个盘子的话我们不会分,不知道结果;如果盘子数量为1、2、3就好了,所以我们按盘子数依次减一分解
    public static void hanoiTower(int num, char a, char b, char c) {
        // 寻找最小问题:只有一个盘
        //如果只有一个盘
        if(num == 1) {
            System.out.println("第1个盘从 " + a + "->" + c);
        } else {
            // 解决次小问题:由于我们是按盘子数-1来进行分解的,所以次小问题是一个盘子和n-1个盘子的汉诺塔,将一个最下面的盘子摆放到正确的位置
            //如果我们有 n >= 2 情况,我们总是可以看做是两个盘 1.最下边的一个盘 2. 上面的所有盘
            //1. 先把 最上面的所有盘 A->B, 移动过程会使用到 c
            hanoiTower(num - 1, a, c, b);
            //2. 把最下边的盘 A->C
            System.out.println("第" + num + "个盘从 " + a + "->" + c);
            //3. 把B塔的所有盘 从 B->C , 移动过程使用到 a塔  
            hanoiTower(num - 1, b, a, c);
        }
    }
}

面试题 08.06. 汉诺塔问题

在经典汉诺塔问题中,有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。一开始,所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制:

(1) 每次只能移动一个盘子;

(2) 盘子只能从柱子顶端滑出移到下一根柱子;

(3) 盘子只能叠在比它大的盘子上。

请编写程序,用栈将所有盘子从第一根柱子移到最后一根柱子。

你需要原地修改栈。

示例1:

 输入:A = [2, 1, 0], B = [], C = []
 输出:C = [2, 1, 0]

示例2:

 输入:A = [1, 0], B = [], C = []
 输出:C = [1, 0]

答案

  • 当前问题:把最底下的第n块移过去
  • 子类问题:把n-1块移过去
class Solution {
    /**
     * 将 A 上的所有盘子,借助 B,移动到C 上
     * @param A 原柱子
     * @param B 辅助柱子
     * @param C 目标柱子
     */
    public void hanota(List<Integer> A, List<Integer> B, List<Integer> C) {
        movePlate(A.size(), A, B, C);
    }

    private void movePlate(int num, List<Integer> A, List<Integer> B, List<Integer> C) {
        if (num == 1) {    // 只剩一个盘子时,直接移动即可
            C.add(A.remove(A.size() - 1));
            return;
        }

        movePlate(num - 1, A, C, B);   // 将 size-1 个盘子,从 A 移动到 B
        movePlate(1, A, B, C);
        // C.add(A.remove(A.size() - 1));   // 将 第size个盘子,从 A 移动到 C
        movePlate(num - 1, B, A, C);   // 将 size-1 个盘子,从 B 移动到 C
    }
}

面试题 04.02. 最小高度树

给定一个有序整数数组,元素各不相同且按升序排列,编写一个算法,创建一棵高度最小的二叉搜索树。

示例:

给定有序数组: [-10,-3,0,5,9],

一个可能的答案是:[0,-3,9,-10,null,5],它可以表示下面这个高度平衡二叉搜索树:

          0 
         / \ 
       -3   9 
       /   / 
     -10  5 

答案

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public TreeNode sortedArrayToBST(int[] nums) {

        return f(nums, 0, nums.length - 1);
    }

    // 二分法需要重新构造函数
    public TreeNode f(int[] nums, int left, int right) {
        if (left > right) {
            return null;
        }
        int mid = left + (right - left) / 2;
        TreeNode root = new TreeNode(nums[mid]);
        root.left = f(nums, left, mid - 1);
        root.right = f(nums, mid + 1, right);

        return root;
    }

}

通过先序和中序数组生成后序数组

给出一棵二叉树的先序和中序数组,通过这两个数组直接生成正确的后序数组。

示例1
输入

[1,2,3],[2,1,3]

输出

[2,3,1]

答案

常规做法:先序遍历划分,中序遍历划分,public void f(int[] preOrder, int left1, int right1, int[] inOrder, int left2, int right2) {

  1. 先序遍历的第一个元素作为根节点
  2. 然后拿着根节点去中序遍历寻找可以得知其左右子树分别的数量
  3. 拿着这个数量,我们就可以再去先序遍历里面进行划分左右子树
  4. 这样就能递归得到后序遍历(左右根)了

image

import java.util.*;

public class Solution {
    /**
     * 
     * @param preOrder int整型一维数组 the array1
     * @param inOrder int整型一维数组 the array2
     * @return int整型一维数组
     */
    public int[] postOrder;
    public int num;
    public int[] findOrder (int[] preOrder, int[] inOrder) {
        // write code here
        // 先序:根左右
        // 中序:左根右
        // 后序:左右根
        postOrder = new int[preOrder.length];
        f(preOrder, 0, preOrder.length - 1, inOrder, 0, preOrder.length - 1);
        return postOrder;
    }
    // 从先序里面找到第一个元素,就是根节点,
    // 然后带着根节点去中序里面划分出左右子树的个数
    // 再带着左右子树的个数去划分先序数组
    // 函数功能:从先序找出第一个root,划分中序的左右
    // root仅与先序遍历中的根节点索引有关
    // left和right仅与中序遍历中的左右指针划分有关
    public void f(int[] preOrder, int left1, int right1, int[] inOrder, int left2, int right2) {
        if (left1 > right1 || left2 > right2) {
            return;
        }
        
        // 在中序中找到根节点位置i
        int i = 0;
        for (i = left2; i < right2 && inOrder[i] != preOrder[left1]; i++);
        
        int leftLength = i - left2;
        int rightLength = right2 - i;

        // 左
        f(preOrder, left1 + 1, left1 + leftLength, inOrder, left2, i - 1);
        // 右
        f(preOrder, right2 - rightLength + 1, right2, inOrder, i + 1, right2);
        // 根
        postOrder[num++] = preOrder[left1];
    }
}

答案二

简化做法:先序遍历找根,中序遍历划分,public void f(int[] preOrder, int root, int[] inOrder, int left, int right) {

  1. 在先序遍历中找出根节点
  2. 在中序遍历中进行左右划分
  3. 在中序遍历中划分后,利用划分的左右子树数量,重新在先序遍历中找到根节点。

原问题 = 当前问题 + 子类问题

  • 当前问题:找到根节点进行划分,然后按后序遍历(左右根)去存储
  • 子类问题:依据根节点将中序遍历划分为左右子树中序遍历,用左右子树数量去先序遍历中找到左右子树根节点。(即 组成了与原问题相同结构的子类问题,左右子树中序遍历+左右子树先序遍历根节点
import java.util.*;

public class Solution {
    /**
     * 
     * @param preOrder int整型一维数组 the array1
     * @param inOrder int整型一维数组 the array2
     * @return int整型一维数组
     */
    public int[] postOrder;
    public int num;
    public int[] findOrder (int[] preOrder, int[] inOrder) {
        // write code here
        // 先序:根左右
        // 中序:左根右
        // 后序:左右根
        postOrder = new int[preOrder.length];
        f(preOrder, 0, inOrder, 0, preOrder.length - 1);
        return postOrder;
    }
    // 从先序里面找到第一个元素,就是根节点,
    // 然后带着根节点去中序里面划分出左右子树的个数
    // 再带着左右子树的个数去划分先序数组
    // 函数功能:从先序找出第一个root,划分中序的左右
    // root仅与先序遍历中的根节点索引有关
    // left和right仅与中序遍历中的左右指针划分有关
    public void f(int[] preOrder, int root, int[] inOrder, int left, int right) {
        if (left > right) {
            return;
        }
        
        // 在中序中找到根节点位置i
        int i = 0;
        for (i = left; i < right && inOrder[i] != preOrder[root]; i++);
        // 左
        f(preOrder, root + 1, inOrder, left, i - 1);
        // 右
        f(preOrder, root + i - left + 1, inOrder, i + 1, right);
        // 根
        postOrder[num++] = preOrder[root];
    }
}

面试题 17.12. BiNode

二叉树数据结构TreeNode可用来表示单向链表(其中left置空,right为下一个链表节点)。实现一个方法,把二叉搜索树转换为单向链表,要求依然符合二叉搜索树的性质,转换操作应是原址的,也就是在原始的二叉搜索树上直接修改。

返回转换后的单向链表的头节点。

注意:本题相对原题稍作改动

示例:

输入: [4,2,5,1,3,null,6,0]
输出: [0,null,1,null,2,null,3,null,4,null,5,null,6]

只能操作当前元素

下面一定要注意了,只能操作当前元素,左右子树只起到划分的作用。

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {

    TreeNode pre;   // 链表的上一个元素

    // 二叉搜索树中序遍历有序
    public TreeNode convertBiNode(TreeNode root) {

        TreeNode head = new TreeNode(0);    // 单链表的伪头节点
        pre = head;
        f(root);
        return head.right;
    }

    // 二叉搜索树中序遍历有序
    public void f(TreeNode root) {

        if (root == null) {
            return;
        }

        // 划分左子树
        f(root.left);

        // 只能操作当前元素
        pre.right = root;
        root.left = null;
        pre = root;
        
        // 划分右子树
        f(root.right);
    }
}

剑指 Offer 40. 最小的k个数

最小的k个数:输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。

示例 1:
输入:arr = [3,2,1], k = 2
输出:[1,2] 或者 [2,1]

示例 2:
输入:arr = [0,1,2,1], k = 1
输出:[0]

限制:
0 <= k <= arr.length <= 10000
0 <= arr[i] <= 10000

堆和大小顶堆

这道题出自《剑指offer》,是一道非常高频的题目。可以通过排序等多种方法求解。但是这里,我们使用较为经典的大顶堆(大根堆)解法进行求解。因为我知道有很多人可能一脸懵逼,所以,我们先复习一下大顶堆。

首先复习一下,堆(Heap)是计算机科学中一类特殊的数据结构的统称,我们通常是指一个可以被看做一棵完全二叉树的数组对象。

堆的特性是父节点的值总是比其两个子节点的值大或小。如果父节点比它的两个子节点的值都要大,我们叫做大顶堆。如果父节点比它的两个子节点的值都要小,我们叫做小顶堆

我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子。

大顶堆,满足以下公式

\[arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]\]

小顶堆也一样:

小顶堆,满足以下公式

\[arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]\]

答案

上面我们学习了大顶堆,现在考虑如何用大根堆进行求解。

首先,我们创建一个大小为k的大顶堆。假如数组为[4,5,1,6,2,7,3,8],k=4。大概是下面这样:

我想肯定这里有不知道如何建堆的同学。记住:对于一个没有维护过的堆(完全二叉树),我们可以从其最后一个节点的父节点开始进行调整。这个不需要死记硬背,其实就是一个层层调节的过程。


(从最后一个节点的父节点调整)


(继续向上调整)


(继续向上调整)

建堆+调整的代码大概就是这样:(翻Java牌子)

   //建堆。对于一个还没维护过的堆,从他的最后一个节点的父节点开始进行调整。
    private void buildHeap(int[] nums) {
        //最后一个节点
        int lastNode = nums.length - 1;
        //记住:父节点 = (i - 1) / 2  左节点 = 2 * i + 1  右节点 = 2 * i + 2;
        //最后一个节点的父节点
        int startHeapify = (lastNode - 1) / 2;
        while (startHeapify >= 0) {
            //不断调整建堆的过程
            heapify(nums, startHeapify--);
        }
    }

    //调整大顶堆的过程
    private void heapify(int[] nums, int i) {
        //和当前节点的左右节点比较,如果节点中有更大的数,那么交换,并继续对交换后的节点进行维护
        int len = nums.length;
        if (i >= len)
            return;
        //左右子节点
        int c1 = ((i << 1) + 1), c2 = ((i << 1) + 2);
        //假定当前节点最大
        int max = i;
        //如果左子节点比较大,更新max = c1;
        if (c1 < len && nums[c1] > nums[max]) max = c1;
        //如果右子节点比较大,更新max = c2;
        if (c2 < len && nums[c2] > nums[max]) max = c2;
        //如果最大的数不是节点i的话,那么heapify(nums, max),即调整节点i的子树。
        if (max != i) {
            swap(nums, max, i);
            //递归处理
            heapify(nums, max);
        }
    }

    private void swap(int[] nums, int i, int j) {
        nums[i] = nums[i] + nums[j] - (nums[j] = nums[i]);
    }

然后我们从下标 k 继续开始依次遍历数组的剩余元素。如果元素小于堆顶元素,那么取出堆顶元素,将当前元素入堆。在上面的示例中 ,因为2小于堆顶元素6,所以将2入堆。我们发现现在的完全二叉树不满足大顶堆,所以对其进行调整。


(调整前)


(调整后)

继续重复上述步骤,依次将7,3,8入堆。这里因为7和8都大于堆顶元素5,所以只有3会入堆。


(调整前)


(调整后)

最后得到的堆,就是我们想要的结果。由于堆的大小是 K,所以这里空间复杂度是O(K),时间复杂度是O(NlogK)。

根据分析,完成代码:

//java
class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0)
            return new int[0];
        int len = arr.length;
        if (k == len)
            return arr;

        //对arr数组的前k个数建堆
        int[] heap = new int[k];
        System.arraycopy(arr, 0, heap, 0, k);
        buildHeap(heap);

        //对后面较小的树建堆
        for (int i = k; i < len; i++) {
            if (arr[i] < heap[0]) {
                heap[0] = arr[i];
                heapify(heap, 0);
            }
        }
        //返回这个堆
        return heap;
    }

    private void buildHeap(int[] nums) {
        int lastNode = nums.length - 1;
        int startHeapify = (lastNode - 1) / 2;
        while (startHeapify >= 0) {
            heapify(nums, startHeapify--);
        }
    }

    private void heapify(int[] nums, int i) {
        int len = nums.length;
        if (i >= len)
            return;
        int c1 = ((i << 1) + 1), c2 = ((i << 1) + 2);
        int max = i;
        if (c1 < len && nums[c1] > nums[max]) max = c1;
        if (c2 < len && nums[c2] > nums[max]) max = c2;
        if (max != i) {
            swap(nums, max, i);
            heapify(nums, max);
        }
    }

    private void swap(int[] nums, int i, int j) {
        nums[i] = nums[i] + nums[j] - (nums[j] = nums[i]);
    }
}

大根堆(前 K 小) / 小根堆(前 K 大),Java中有现成的 PriorityQueue,实现起来最简单:\(O(NlogK)\)O(NlogK)
本题是求前 K 小,因此用一个容量为 K 的大根堆,每次 poll 出最大的数,那堆中保留的就是前 K 小啦(注意不是小根堆!小根堆的话需要把全部的元素都入堆,那是 \(O(NlogN\))O(NlogN)😂,就不是 \(O(NlogK)\)O(NlogK)啦~~)
这个方法比快排慢,但是因为 Java 中提供了现成的 PriorityQueue(默认小根堆),所以实现起来最简单,没几行代码~

上面自己实现堆可能有点麻烦,所以我们使用Java自带的PriorityQueue优先队列,它是使用堆来实现的,默认为小顶堆,我们改一下比较器即可。
使用API:

// 保持堆的大小为K,然后遍历数组中的数字,遍历的时候做如下判断:
// 1. 若目前堆的大小小于K,将当前数字放入堆中。
// 2. 否则判断当前数字与大根堆堆顶元素的大小关系,如果当前数字比大根堆堆顶还大,这个数就直接跳过;
//    反之如果当前数字比大根堆堆顶小,先poll掉堆顶,再将该数字放入堆中。
class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {

        if (k == 0 || arr.length == 0) {
            return new int[0];  // 返回长度为0的空数组
        }

        // 默认是小根堆,实现大根堆需要重写一下比较器。
        // 我们需要一个容量为k的大顶堆,后面的数字来和顶进行比较,比它小就替换,调整堆
        Queue<Integer> heap = new PriorityQueue<>((v1, v2) -> v2 - v1);

        // 建立一个容量为k的大顶堆
        for (int i = 0; i < k; i++) {
            heap.add(arr[i]);
        }

        // 后面的数字和顶进行比较,比他小就替换,调整堆
        for (int i = k; i < arr.length; i++) {
            if (arr[i] < heap.peek()) {

                heap.remove();
                heap.add(arr[i]);
            }
        }

        // 将队列转化为int[]数组
        return heap.stream().mapToInt(Integer::valueOf).toArray();
    }
}

大佬解法

大佬解法:更多解法

解题思路:
对于经典TopK问题,本文给出 4 种通用解决方案。

一、用快排最最最高效解决 TopK 问题:O(N)O(N)

注意找前 K 大/前 K 小问题不需要对整个数组进行 O(NlogN)O(NlogN) 的排序!
例如本题,直接通过快排切分排好第 K 小的数(下标为 K-1),那么它左边的数就是比它小的另外 K-1 个数啦~
下面代码给出了详细的注释,没啥好啰嗦的,就是快排模版要记牢哈~

Java

class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        // 最后一个参数表示我们要找的是下标为k-1的数
        return quickSearch(arr, 0, arr.length - 1, k - 1);
    }

    private int[] quickSearch(int[] nums, int lo, int hi, int k) {
        // 每快排切分1次,找到排序后下标为j的元素,如果j恰好等于k就返回j以及j左边所有的数;
        int j = partition(nums, lo, hi);
        if (j == k) {
            return Arrays.copyOf(nums, j + 1);
        }
        // 否则根据下标j与k的大小关系来决定继续切分左段还是右段。
        return j > k? quickSearch(nums, lo, j - 1, k): quickSearch(nums, j + 1, hi, k);
    }

    // 快排切分,返回下标j,使得比nums[j]小的数都在j的左边,比nums[j]大的数都在j的右边。
    private int partition(int[] nums, int lo, int hi) {
        int v = nums[lo];
        int i = lo, j = hi + 1;
        while (true) {
            while (++i <= hi && nums[i] < v);
            while (--j >= lo && nums[j] > v);
            if (i >= j) {
                break;
            }
            int t = nums[j];
            nums[j] = nums[i];
            nums[i] = t;
        }
        nums[lo] = nums[j];
        nums[j] = v;
        return j;
    }
}

快排切分时间复杂度分析: 因为我们是要找下标为k的元素,第一次切分的时候需要遍历整个数组 (0 ~ n) 找到了下标是 j 的元素,假如 k 比 j 小的话,那么我们下次切分只要遍历数组 (0~k-1)的元素就行啦,反之如果 k 比 j 大的话,那下次切分只要遍历数组 (k+1~n) 的元素就行啦,总之可以看作每次调用 partition 遍历的元素数目都是上一次遍历的 1/2,因此时间复杂度是 N + N/2 + N/4 + ... + N/N = 2N, 因此时间复杂度是 O(N)O(N)。

二、大根堆(前 K 小) / 小根堆(前 K 大),Java中有现成的 PriorityQueue,实现起来最简单:O(NlogK)O(NlogK)

本题是求前 K 小,因此用一个容量为 K 的大根堆,每次 poll 出最大的数,那堆中保留的就是前 K 小啦(注意不是小根堆!小根堆的话需要把全部的元素都入堆,那是 O(NlogN)O(NlogN)😂,就不是 O(NlogK)O(NlogK)啦~~)
这个方法比快排慢,但是因为 Java 中提供了现成的 PriorityQueue(默认小根堆),所以实现起来最简单,没几行代码~

Java

// 保持堆的大小为K,然后遍历数组中的数字,遍历的时候做如下判断:
// 1. 若目前堆的大小小于K,将当前数字放入堆中。
// 2. 否则判断当前数字与大根堆堆顶元素的大小关系,如果当前数字比大根堆堆顶还大,这个数就直接跳过;
//    反之如果当前数字比大根堆堆顶小,先poll掉堆顶,再将该数字放入堆中。
class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        // 默认是小根堆,实现大根堆需要重写一下比较器。
        Queue<Integer> pq = new PriorityQueue<>((v1, v2) -> v2 - v1);
        for (int num: arr) {
            if (pq.size() < k) {
                pq.offer(num);
            } else if (num < pq.peek()) {
                pq.poll();
                pq.offer(num);
            }
        }
        
        // 返回堆中的元素
        int[] res = new int[pq.size()];
        int idx = 0;
        for(int num: pq) {
            res[idx++] = num;
        }
        return res;
    }
}

三、二叉搜索树也可以 O(NlogK)O(NlogK)解决 TopK 问题哦

BST 相对于前两种方法没那么常见,但是也很简单,和大根堆的思路差不多~
要提的是,与前两种方法相比,BST 有一个好处是求得的前K大的数字是有序的。

因为有重复的数字,所以用的是 TreeMap 而不是 TreeSet(有的语言的标准库自带 TreeMultiset,也是可以的)。

TreeMap的key 是数字,value 是该数字的个数。
我们遍历数组中的数字,维护一个数字总个数为 K 的 TreeMap:
1.若目前 map 中数字个数小于 K,则将 map 中当前数字对应的个数 +1;
2.否则,判断当前数字与 map 中最大的数字的大小关系:若当前数字大于等于 map 中的最大数字,就直接跳过该数字;若当前数字小于 map 中的最大数字,则将 map 中当前数字对应的个数 +1,并将 map 中最大数字对应的个数减 1。

Java

class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        // TreeMap的key是数字, value是该数字的个数。
        // cnt表示当前map总共存了多少个数字。
        TreeMap<Integer, Integer> map = new TreeMap<>();
        int cnt = 0;
        for (int num: arr) {
            // 1. 遍历数组,若当前map中的数字个数小于k,则map中当前数字对应个数+1
            if (cnt < k) {
                map.put(num, map.getOrDefault(num, 0) + 1);
                cnt++;
                continue;
            } 
            // 2. 否则,取出map中最大的Key(即最大的数字), 判断当前数字与map中最大数字的大小关系:
            //    若当前数字比map中最大的数字还大,就直接忽略;
            //    若当前数字比map中最大的数字小,则将当前数字加入map中,并将map中的最大数字的个数-1。
            Map.Entry<Integer, Integer> entry = map.lastEntry();
            if (entry.getKey() > num) {
                map.put(num, map.getOrDefault(num, 0) + 1);
                if (entry.getValue() == 1) {
                    map.pollLastEntry();
                } else {
                    map.put(entry.getKey(), entry.getValue() - 1);
                }
            }
            
        }

        // 最后返回map中的元素
        int[] res = new int[k];
        int idx = 0;
        for (Map.Entry<Integer, Integer> entry: map.entrySet()) {
            int freq = entry.getValue();
            while (freq-- > 0) {
                res[idx++] = entry.getKey();
            }
        }
        return res;
    }
}

四、数据范围有限时直接计数排序就行了:O(N)O(N)

Java

class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        // 统计每个数字出现的次数
        int[] counter = new int[10001];
        for (int num: arr) {
            counter[num]++;
        }
        // 根据counter数组从头找出k个数作为返回结果
        int[] res = new int[k];
        int idx = 0;
        for (int num = 0; num < counter.length; num++) {
            while (counter[num]-- > 0 && idx < k) {
                res[idx++] = num;
            }
            if (idx == k) {
                break;
            }
        }
        return res;
    }
}

最后
雷同题目 215. 数组中的第K个最大元素 常考哦~


因为分治算法一般都应用于操作树,所以大家可以看看:【数据结构】树


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM