代码随想录 | 二叉树(与递归)、回溯、贪心

二叉树

二叉树的题目分类

  1. 遍历方式
  2. 二叉树的属性
  3. 二叉树的修改与构造
  4. 求二叉搜索树的属性
  5. 二叉树的公共祖先
  6. 二叉搜索树的修改与构造

二叉树的种类

  1. 满二叉树

  2. 完全二叉树

  3. 二叉搜索树:有序树
    由小到大:左中右

  4. 平衡二叉搜索树 AVL
    空树;或者,左右两个子树的高度差的绝对值不超过1。

  5. 红黑树
    红黑树就是一种二叉平衡搜索树。

二叉树的存储方式

顺序:如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。

链式:但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树。

二叉树的遍历方式

二叉树主要有两种遍历方式:

  1. 深度优先遍历:先往深走,遇到叶子节点再往回走。
    前序遍历(递归法,迭代法)
    中序遍历(递归法,迭代法)
    后序遍历(递归法,迭代法)
  2. 广度优先遍历:一层一层的去遍历。
    层次遍历(迭代法)
  • 栈(递归):深度优先遍历
    做二叉树相关题目,经常会使用递归的方式来实现深度优先遍历,也就是实现前中后序遍历,使用递归是比较方便的。
    栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用递归的方式来实现的。
  • 队列(FIFO):广度优先遍历
    广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的。因为需要先进先出的结构,才能一层一层的来遍历二叉树。

深度遍历

递归遍历:确定方法论

本篇将介绍前后中序的递归写法,一些同学可能会感觉很简单,其实不然,我们要通过简单题目把方法论确定下来,有了方法论,后面才能应付复杂的递归。

递归三要素
  1. 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
  2. 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
  3. 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
    重点在于递归处理的顺序——处理就是处理本节点,递归就是处理子节点。顺序不同,代表的遍历顺序也不同
遍历的递归写法

重点:在于递归处理的顺序。

// 前序遍历·递归·LC144_二叉树的前序遍历
class Solution {
    public List<Integer> preorderTraversal(TreeNode root) {
        List<Integer> result = new ArrayList<Integer>();
        preorder(root, result);
        return result;
    }

    public void preorder(TreeNode root, List<Integer> result) {
        if (root == null) {
            return;
        }
        result.add(root.val);
        preorder(root.left, result);
        preorder(root.right, result);
    }
}
// 中序遍历·递归·LC94_二叉树的中序遍历
class Solution {
    public List<Integer> inorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        inorder(root, res);
        return res;
    }

    void inorder(TreeNode root, List<Integer> list) {
        if (root == null) {
            return;
        }
        inorder(root.left, list);
        list.add(root.val);             // 注意这一句
        inorder(root.right, list);
    }
}
// 后序遍历·递归·LC145_二叉树的后序遍历
class Solution {
    public List<Integer> postorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        postorder(root, res);
        return res;
    }

    void postorder(TreeNode root, List<Integer> list) {
        if (root == null) {
            return;
        }
        postorder(root.left, list);
        postorder(root.right, list);
        list.add(root.val);             // 注意这一句
    }
}

迭代遍历(栈):比递归更复杂

前序遍历(正常处理)

前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中。出栈后,将右孩子加入栈,再加入左孩子。

但接下来,再用迭代法写中序遍历的时候,会发现套路又不一样了,目前的前序遍历的逻辑无法直接应用到中序遍历上。

  1. 判空
  2. root入栈
  3. 开启while循环:栈非空
  4. 弹栈后的前序处理:处理本节点,压栈左右孩子(先处理本节点、再处理孩子(递归就是处理))
// 前序遍历顺序:中-左-右,入栈顺序:中-右-左
class Solution {
    public List<Integer> preorderTraversal(TreeNode root) {
        List<Integer> result = new ArrayList<>();
        if (root == null){
            return result;
        }
        Stack<TreeNode> stack = new Stack<>();
        stack.push(root); // 压栈根节点(访问),然后开始递归处理
        while (!stack.isEmpty()){
            TreeNode node = stack.pop(); // 访问:访问弹出的节点
            result.add(node.val); // 处理:存入结果中
            if (node.right != null){
                stack.push(node.right); // 压栈
            }
            if (node.left != null){
                stack.push(node.left); // 压栈
            }
        }
        return result;
    }
}
中序遍历(区别很大:访问、处理)

刚刚在迭代的过程中,其实我们有两个操作:

  1. 访问:指针遍历节点,将元素压栈。
  2. 处理:将元素弹栈,放进result数组中。
    根据弹栈元素的右孩子,指针进行更新。如果没有,就指向栈顶。

中序与其他的区别
前序:因为前序遍历的顺序是中左右,先访问的元素是中间节点,要处理的元素也是中间节点,所以刚刚才能写出相对简洁的代码,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点。

中序:中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的。

那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素

  1. 判空
  2. 指针指向root而不压栈
  3. 开启while循环:栈非空、指针非空(指针用来访问)
  4. 直到指针非空,才访问完成(都压入栈中)。然后再一个个出栈处理。
  5. 处理之后(放入结果集之后),再查看有没有右孩子。如果有,指针就需要继续访问(一个个压栈)。

总而言之,指针首先访问左孩子(左)。对于出栈的元素(中),会访问右孩子(右)。

// 中序遍历顺序: 左-中-右 入栈顺序: 左-右
class Solution {
    public List<Integer> inorderTraversal(TreeNode root) {
        List<Integer> result = new ArrayList<>();
        if (root == null){
            return result;
        }
        Stack<TreeNode> stack = new Stack<>();
        TreeNode cur = root; // 不压栈  指针用来遍历
        while (cur != null || !stack.isEmpty()){
           if (cur != null){ // 指针不空时:一直压栈,寻找最左孩子
               stack.push(cur); // 压栈
               cur = cur.left; // 指向左孩子
           }else{ // 指针为空时:已找到此时的最左孩子,就在栈顶。
               cur = stack.pop(); // 指向弹出节点
               result.add(cur.val); // 处理:放入结果集
               cur = cur.right; // 指向处理后的节点的右孩子。
               // 如果右孩子为null,就继续弹栈。
               // 如果右孩子不为null,就会找其左孩子。
           }
        }
        return result;
    }
}
后序遍历(反向前序)

后序:修改先序遍历

  • 先序:中左右
  • 改变左右顺序:中右左
  • 将其反转:左右中
// 后序遍历顺序 左-右-中 入栈顺序:中-左-右 出栈顺序:中-右-左, 最后翻转结果
class Solution {
    public List<Integer> postorderTraversal(TreeNode root) {
        List<Integer> result = new ArrayList<>();
        if (root == null){
            return result;
        }
        Stack<TreeNode> stack = new Stack<>();
        stack.push(root);
        while (!stack.isEmpty()){
            TreeNode node = stack.pop();
            result.add(node.val);
            // 先压栈左,再压栈右:处理时,先处理右,再处理左。
            if (node.left != null){
                stack.push(node.left);
            }
            if (node.right != null){
                stack.push(node.right);
            }
        }
        Collections.reverse(result); // 重点!
        return result;
    }
}

  • 前序:

  • 中序:循环条件 遍历的指针cur没有下一个要遍历的、栈为空

    • 访问:一直到没有左孩子为止。
    • 处理:弹栈。该元素既是左孩子,也可能是根,所以检查还有没有右孩子。如果有就压栈。

遍历:

  • 前序:只访问一个。处理一个,再添加两个。
  • 中序:一直左边遍历访问,然后再处理。

广度遍历

递归

result是个二维数组。直接将左右孩子,加到每层。

depth记录深度:如果刚到达下一层,就要new一个数组。

class Solution {
    public List<List<Integer>> resList = new ArrayList<List<Integer>>(); // 二维List保存结果
    public List<List<Integer>> levelOrder(TreeNode root) {
        checkFun01(root,0);
        return resList;
    }
    
    //BFS--递归方式
    public void checkFun01(TreeNode node, Integer deep) { // 节点、对应深度
        if (node == null) return;
        deep++;

        if (resList.size() < deep) { // 初始化一维数组:同一层的就不用再次初始化了
            //当层级增加时,list的Item也增加,利用list的索引值进行层级界定
            List<Integer> item = new ArrayList<Integer>();
            resList.add(item);
        }
        // 处理
        resList.get(deep - 1).add(node.val); 
		// 递归
        checkFun01(node.left, deep);
        checkFun01(node.right, deep);
    }
}

迭代(队列)

每次while循环就是一层:每层可以根据que.size()获得该层有多少个元素。

// 102.二叉树的层序遍历
class Solution {
    public List<List<Integer>> resList = new ArrayList<List<Integer>>(); // 二维List保存结果
    public List<List<Integer>> levelOrder(TreeNode root) {
        checkFun02(root);
        return resList;
    }

    //BFS--迭代方式--借助队列
    public void checkFun02(TreeNode node) {
        if (node == null) return;
        Queue<TreeNode> que = new LinkedList<TreeNode>();
        que.offer(node); // 第一层的节点先入队

        while (!que.isEmpty()) {
            List<Integer> itemList = new ArrayList<Integer>(); // 初始化一维数组
            int len = que.size(); // 该层的节点个数

            while (len > 0) { // 将该层节点全部出队
                TreeNode tmpNode = que.poll();
                itemList.add(tmpNode.val);
				// 子节点入队
                if (tmpNode.left != null) que.offer(tmpNode.left);
                if (tmpNode.right != null) que.offer(tmpNode.right);
                len--;
            }

            resList.add(itemList);
        }

    }
}

二叉树的属性

翻转二叉树

对称反转:只要将每个节点的左右翻转即可。

但要注意遍历问题:

递归法的中序遍历可能导致翻转两遍,因此使用前序or后序。

迭代法的中序遍历可以反转。因为这是用栈来遍历,而不是靠指针来遍历,避免了递归法中翻转了两次的情况。

对称二叉树

要比较的是根节点的左子树与右子树是不是相互翻转的,理解这一点就知道了其实我们要比较的是两个树(这两个树是根节点的左右子树),所以在递归遍历的过程中,也是要同时遍历两棵树

遍历是后序遍历:只不过左子树是左右中,右子树是右左中。

递归法

三部曲:

  1. 参数和返回值:返回bool。参数是左右两个节点。
  2. 终止条件:左右有空节点、左右不空但是值不相同。
  3. 递归逻辑:外侧是否对称、内侧是否对称
迭代法

本质:把左右两个子树要比较的元素顺序放进一个容器,然后成对成对的取出来进行比较。

使用队列、栈都可以。

队列(不是层序遍历

深度

最大深度

可以使用前序(中左右),也可以使用后序遍历(左右中),使用前序求的就是深度,使用后序求的是高度。

二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从0开始还是从1开始)
二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数或者节点数(取决于高度从0开始还是从1开始)
而根节点的高度就是二叉树的最大深度,

递归法
以下所谓的遍历,都是边遍历,边处理。

后序遍历:

参数和返回值:节点;最大深度。
终止条件:没有子节点
递归逻辑:有子节点
先序遍历:

参数和返回值:节点、该节点对应的深度;无。
终止条件:没有子节点
递归逻辑:有子节点
迭代法
层序遍历:最大的深度就是二叉树的层数,和层序遍历的方式极其吻合。

使用que.size()确定每行有多少,固定pop出size个。

最小深度

递归法
后序遍历:最后总结左右子树高度,与自身的高度。

重点在于递归逻辑:只有左右孩子都没有才是到底了。所以分别判断:只有右孩子、只有左孩子。

前序遍历:首先判断是不是叶子节点。

迭代法
判断深度就对了。

递归的前序与后序

后序:最简单。只需要递归就行,返回值就是所在的深度。

前序:不返回值,需要一个成员变量接收。

此时的遍历:检查是否为叶子节点。

遍历与处理

遍历就是访问到了,处理就是放入到结果中。

完全二叉树的节点个数

普通二叉树

递归法:

迭代法:层序遍历

完全二叉树

只有两种情况,情况一:就是满二叉树,情况二:最后一层叶子节点没有满。

对于情况一,可以直接用 2^树深度 - 1 来计算,注意这里根节点深度为1。

对于情况二,分别递归左孩子,和右孩子,递归到某一深度一定会有左孩子或者右孩子为满二叉树,然后依然可以按照情况1来计算。

可以看出如果整个树不是满二叉树,就递归其左右孩子,直到遇到满二叉树为止,用公式计算这个子树(满二叉树)的节点数量。

关键在于如何去判断一个左子树或者右子树是不是满二叉树呢?

​在完全二叉树中,如果递归向左遍历的深度等于递归向右遍历的深度,那说明就是满二叉树。

递归法:

参数和返回值:节点;以该节点为根的节点个数。
终止条件:该树是一颗满二叉树——返回个数。
递归逻辑:计算左子树、右子树的个数。

平衡二叉树

深度与高度不同:(leetcode的题目中都是以节点为一度,即根节点深度是1)

求深度可以从上到下去查 所以需要前序遍历(中左右),
而高度只能从下到上去查,所以只能后序遍历(左右中)

递归:返回值是高度——如果子树不是平衡二叉树,那就返回-1。

当然此题用迭代法,其实效率很低,因为没有很好的模拟回溯的过程,所以迭代法有很多重复的计算。回溯法其实就是递归,但是很少人用迭代的方式去实现回溯算法!

回溯和递归是一一对应的,有一个递归,就要有一个回溯

可以使用层序遍历来求深度,但是就不能直接用层序遍历来求高度了

迭代:层序遍历。效率很低,因为没有很好的模拟回溯的过程,所以迭代法有很多重复的计算。——单独求每个节点的高度(重复计算)、

用栈模拟后序遍历:

先弹出根节点。
然后依次压栈:根节点(刚弹出的),NULL(避免根节点重复消费),右孩子、左孩子。
先通过栈模拟的后序遍历找每一个节点的高度;

然后再用栈来模拟后序遍历,遍历每一个节点的时候,再去判断左右孩子的高度是否符合。

回溯

理论

本质理念

  • 回溯与递归:
    回溯是递归的副产品,只要有递归就会有回溯。回溯函数也就是递归函数,指的都是一个函数。

  • 性能:本质是穷举。最多最多只能剪枝。
    ​回溯法的性能如何呢,这里要和大家说清楚了,虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。
    因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。

  • 为什么用回溯?
    没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。

组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等

  • 回溯是树形结构
    回溯法解决的问题都可以抽象为树形结构,是的,我指的是所有回溯法的问题都可以抽象为树形结构!
    因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。
    递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

  • 回溯法
    for循环横向遍历,递归纵向遍历,回溯不断调整结果集,这个理念贯穿整个回溯法系列

模板

  • 函数名、返回值、参数
    在回溯算法中,我的习惯是函数起名字为backtracking,这个起名大家随意。
    回溯算法中函数返回值一般为void:因为会将结果保存在参数中(或者成员变量中)
    参数:因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。

  • 回溯函数终止条件:叶子
    既然是树形结构,那么我们在讲解​​二叉树的递归 (opens new window)​​的时候,就知道遍历树形结构一定要有终止条件。所以回溯也有要终止条件。
    什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归

  • 回溯搜索的遍历过程:for横向、backtracking纵向
    回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
    大家可以从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。

其他

组合、切割
startIndex的需要与否
  • 组合问题:并不依赖于,是否可以取过的重复取。因为若需排除重复的组合,就要使用startIndex。(详见组合总和Ⅲ,组合总和)
    • 一个集合来求组合的话,就需要startIndex。
    • 多个集合取组合,各个集合之间相互不影响,那么就不用startIndex。
  • 排列问题:不需要startIndex。
去重

组合总和、组合总和Ⅲ:集合不会有重复元素,但是可能可以重复取
组合总和Ⅱ:集合中有重复元素,但是结果组合不能重。
此时的去重:集合中有重复元素——used数组

必要条件:

  • used数组
  • 排序

分类:

  • 树枝去重 true
  • 树层去重(效率更高)false
子集

在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。

集合中有重复元素的去重:同上

集合中有重复元素但无法排序:使用set树层去重、使用startIndex避免结果重复。

排列问题

处理排列问题就不用使用startIndex了。但是需要used数组记录path里都放了哪些元素了。

set去重:相较于used,效率低

以上我都是统一使用used数组来去重的,其实使用set也可以用来去重!
​主要是因为程序运行的时候对unordered_set 频繁的insert,unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值)相对费时间,而且insert的时候其底层的符号表也要做相应的扩充,也是费时的。
使用used数组在时间复杂度上几乎没有额外负担
使用set去重,不仅时间复杂度高了,空间复杂度也高了
那有同学可能疑惑 用used数组也是占用O(n)的空间啊?
used数组可是全局变量,每层与每层之间公用一个used数组,所以空间复杂度是O(n + n),最终空间复杂度还是O(n)。​​​​

N皇后与数独

N皇后:每行填一个——每行视为一层。
数独:每格填一个——每格视为一层。

复杂度:指数级

子集问题分析:

  • 时间复杂度:O(2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2n)
  • 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)

排列问题分析:

  • 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
  • 空间复杂度:O(n),和子集问题同理。

组合问题分析:

  • 时间复杂度:O(2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
  • 空间复杂度:O(n),和子集问题同理。

N皇后问题分析:

  • 时间复杂度:O(n!) ,其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!),n!表示n * (n-1) * … * 1。
  • 空间复杂度:O(n),和子集问题同理。

解数独问题分析:

  • 时间复杂度:O(9^m) , m是’.'的数目。
  • 空间复杂度:O(n2),递归的深度是n2

一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!

题目

组合问题

上面我们说了要解决 n为100,k为50的情况,暴力写法需要嵌套50层for循环,那么回溯法就用递归来解决嵌套层数的问题

递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了

组合(剪枝)

剪枝优化:i <= n - (k - path.size()) + 1

电话号的字母组合

回溯藏在递归:不推荐,这样很不直观。

隐藏:作为递归的参数,传递。
不隐藏:作为成员变量,每次手动回溯。

组合总和

candidates 中的每个数字在每个组合中只能使用一次。解集不能包含重复的组合。

重点在于:去重逻辑。

  • 先排序。

  • 同一树层进行去重,而非同一树枝进行去重。(因此需要先排序)

  • 使用used数组 或 不用也可以

  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过

  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过

分割回文串

每个子节点,就是一次分割。

判断回文串的方法

  • 双指针:重复计算
  • 动态规划:使用二维数组进行记录,动态求是否为回文串。

子集问题

如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点

因为要遍历整棵树,所以不需要剪枝

每个节点包括:

  • 子集
  • 剩余集合

每次都会将剩余集合中的一个元素,放入子集中。
为了去重,对于本层其他节点所取的元素,不会将startIndex之前的元素还留在剩余集合中。

startIndex的使用与否

组合问题

一个集合求组合:需要
多个集合求组合,各组合之间互不影响:不需要

排列问题

子集问题(有重复元素)

去重必须要先排序。

递增子序列

需要去重但不能排序时:使用set记录,本层元素是否重复使用。

回溯之后不需要remove,因为只影响本层。

全排列【排列】

排列问题的不同:

  • 每层都是从0开始搜索而不是startIndex
  • 需要used数组记录path里都放了哪些元素了

由于此处题目中的数组不能重复,因此可以用:​​path.contains(nums[i])​​来判断是否取过。

全排列【题目可重复、结果不重复】

去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了

组合问题和排列问题是在树形结构的叶子节点上收集结果,而子集问题就是取树上所有节点的结果

对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高

重新安排行程

所有的机票必须都用一次 且 只能用一次

按字符自然排序返回最小的行程组合:

N皇后

每层看作树的一层。

解数独

需要二维递归:一格就是树的一层。

N皇后问题 (opens new window)是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来遍历列,然后一行一列确定皇后的唯一位置。

本题就不一样了,本题中棋盘的每一个位置都要放一个数字(而N皇后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。

贪心

理论

本质理念

贪心的本质是选择每一阶段的局部最优,从而达到全局最优。

如果是 有一堆盒子,你有一个背包体积为n,如何把背包尽可能装满,如果还每次选最大的盒子,就不行了。这时候就需要动态规划。

什么时候用贪心?:举反例

唯一的难点就是如何通过局部最优,推出整体最优。
靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。
最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧。

解题步骤:太抽象

贪心算法一般分为如下四步:

  • 将问题分解为若干个子问题
  • 找出适合的贪心策略
  • 求解每一个子问题的最优解
  • 将局部最优解堆叠成全局最优解

这个四步其实过于理论化了,我们平时在做贪心类的题目时,如果按照这四步去思考,真是有点“鸡肋”。

做题的时候,只要想清楚 局部最优 是什么,如果推导出全局最优,其实就够了

其他

  • 两个维度:两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度。如果两个维度一起考虑一定会顾此失彼。
    先考虑哪个维度也有讲究:根据身高重建队列——按照该维度排列后,一定要确定下来该维度。
  • for循环适合模拟从头到尾的遍历,而while循环适合模拟环形遍历。对于加油站要善于使用while!

题目

喂饼干

局部最优到全局最优没有反例
局部最优:不要造成饼干尺寸的浪费。(充分利用饼干尺寸)

大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。

全局最优​:喂饱尽可能多的小孩。

循环设置:不同思路,循环不同

  • 大饼干喂给大胃口
    先遍历胃口,再遍历饼干。
  • 小饼干先喂给小胃口(这种思路更好一点)
    先遍历饼干,再遍历胃口。

可以不使用两个for循环:
外层for循环负责先遍历,内层用if判断进行查找——符合了就可以遍历下一个了(自减)。

摆动序列

局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值。

整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列。

实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了(相当于是删除单一坡度上的节点,然后统计长度)
这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点

贪心算法

  • 计算 prediff(nums[i] - nums[i-1]) curdiff(nums[i+1] - nums[i]),如果​​prediff < 0 && curdiff > 0​​ 或者 ​​prediff > 0 && curdiff < 0​​ 此时就有波动就需要统计。
  • 三种情况:
    • 上下坡中有平坡
      ​记录峰值的条件应该是: ​​(preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)​​,这样会删掉左边的数字,只保留最右边的。
    • 数组首尾两端:如果只有两个元素,就有两个峰。
      result初始化为1:默认最右边有一个峰值
      初始化 preDiff = 0
    • 单调坡中有平坡
      坡度 摆动变化的时候(遇到峰的时候),更新 prediff 就行,这样 prediff 在 单调区间有平坡的时候 就不会发生变化,造成我们的误判。

动态规划

  • 对于我们当前考虑的这个数,要么是作为山峰(即 nums[i] > nums[i-1]),要么是作为山谷(即 nums[i] < nums[i - 1])。
    dp 状态​​dp[i][0]​​,表示考虑前 i 个数,第 i 个数作为山峰的摆动子序列的最长长度
    dp 状态​​dp[i][1]​​,表示考虑前i个数,第 i 个数作为山谷的摆动子序列的最长长度
  • 则转移方程为:
    ​​​dp[i][0] = max(dp[i][0], dp[j][1] + 1)​​,其中​​0 < j < i​​且​​nums[j] < nums[i]​​,表示将 nums[i]接到前面某个山谷后面,作为山峰。
    ​​dp[i][1] = max(dp[i][1], dp[j][0] + 1)​​​,其中​​0 < j < i​​​且​​nums[j] > nums[i]​​,表示将 nums[i]接到前面某个山峰后面,作为山谷。
  • 初始状态:
    由于一个数可以接到前面的某个数后面,也可以以自身为子序列的起点,所以初始状态为:​​dp[0][0] = dp[0][1] = 1​​。
  • 进阶:可以用两棵线段树来维护区间的最大值
    每次更新​​dp[i][0]​​,则在​​tree1​​​​​nums[i]​​​位置值更新为​​dp[i][0]​​
    每次更新​​dp[i][1]​​​,则在​​tree2​​​的​​nums[i]​​​位置值更新为​​dp[i][1]​​
    则 dp 转移方程中就没有必要 j0 遍历到 i-1,可以直接在线段树中查询指定区间的值即可。

最大子序和

贪心:

  • 局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。
  • 全局最优:选取最大“连续和”
  • 重新调整区间起始位置:
    遍历 nums,从头开始用 count 累积,如果 count 一旦加上 nums[i]变为负数,那么就应该从 nums[i+1]开始从 0 累积 count 了,因为已经变为负数的 count,只会拖累总和。
  • 终止位置:
    count累计,最大值存入成员变量中。

动规:

​​vector<int> dp(nums.size(), 0); // dp[i]表示包括i之前的最大连续子序列和​​
​​dp[i] = max(dp[i - 1] + nums[i], nums[i]); // 状态转移公式​​

买卖股票

难点:想到其实最终利润是可以分解的

如何分解呢?

假如第 0 天买入,第 3 天卖出,那么利润为:prices[3] - prices[0]。
相当于(prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0])。
此时就是把利润分解为每天为单位的维度,而不是从 0 天到第 3 天整体去考虑!
那么根据 prices 可以得到每天的利润序列:(prices[i] - prices[i - 1])…(prices[1] - prices[0])。

贪心
局部最优:只收集每天的正利润,全局最优:求得最大利润。

动规

// dp[i][1]第i天持有的最多现金
// dp[i][0]第i天持有股票后的最多现金

vector<vector<int>> dp(n, vector<int>(2, 0));
dp[0][0] -= prices[0]; // 持股票


// 第i天持股票所剩最多现金 = max(第i-1天持股票所剩现金, 第i-1天持现金-买第i天的股票)
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
// 第i天持有最多现金 = max(第i-1天持有的最多现金,第i-1天持有股票的最多现金+第i天卖出股票)
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);

跳跃游戏

元素值:可以跳跃的最大长度。

那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!
贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点。

巧妙构造:for循环,i<=cover
在循环过程中,重新赋给cover值。

跳跃游戏Ⅱ

目标是使用最少的跳跃次数到达数组的最后一个位置。

思路:碰到前一跳的位置(及其之前的位置)所覆盖的最大范围,就会跳一次。
这样,每次都是碰到最大范围了才会跳。
不断更新最大范围。

取反数组元素获取最大值

两次贪心:

​局部最优:让绝对值大的负数变为正数,当前数值达到最大,整体最优:整个数组和达到最大。
​局部最优:只找数值最小的正整数进行反转,当前数值和可以达到最大(例如正整数数组{5, 3, 1},反转1 得到-1 比 反转5得到的-5 大多了),全局最优:整个 数组和 达到最大。

加油站

贪心1

​直接从全局进行贪心选择,情况如下:

  • 情况一:如果gas的总和小于cost总和,那么无论从哪里出发,一定是跑不了一圈的
  • 情况二:rest[i] = gas[i]-cost[i]为一天剩下的油,i从0开始计算累加到最后一站,如果累加没有出现负数,说明从0出发,油就没有断过,那么0就是起点。
  • 情况三:如果累加的最小值是负数,汽车就要从非0节点出发,从后向前,看哪个节点能把这个负数填平,能把这个负数填平的节点就是出发节点。

贪心2

​首先如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明 各个站点的加油站 剩油量rest[i]相加一定是大于等于零的。
每个加油站的剩余量rest[i]为gas[i] - cost[i]。
i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i这里都会断油,那么起始位置从i+1算起,再从0计算curSum。

分发糖果

两次贪心:

  • 只比较右边孩子评分比左边大的情况。
  • 只比较左边孩子评分比右边大的情况。
    遍历顺序:保证
    赋值策略:两种贪心均要满足,取两种贪心的最大值。

柠檬水找零

贪心:5元比10元更有用,而20元没用。

根据身高重排队列

两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度。
如果两个维度一起考虑一定会顾此失彼

先按照哪个维度排列?最终确定一个维度。

  • 按照k来从小到大排序,排完之后,会发现k的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。
  • ​按照身高h来排序呢,身高一定是从大到小排(身高相同的话则k小的站前面),让高个子在前面。​

所以在按照身高从大到小排序后:
局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性
全局最优:最后都做完插入操作,整个队列满足题目队列属性

再按照k来排序:
先插入队列的元素,一定比后插入队列的元素高(因为身高已经排序过)。因此可以直接插入下标为k的位置(例如,k=2,则2的位置前,有两个元素比它高。如果以后的元素再插到它之前,那么一定是比他低的)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值