羊羊刷题笔记Day35/60 | 第八章 贪心算法P4 | 860. 柠檬水找零、406. 根据身高重建队列、452. 用最少数量的箭引爆气球

104 阅读9分钟

860 柠檬水找零

同样第一题是简单题,本题情况较少 [5 10 20] 可分类讨论把情况都罗列出来

思路

这道题目刚一看,可能会有点懵,这要怎么找零才能保证完成全部账单的找零呢?
但仔细一琢磨就会发现,可供我们做判断的空间非常少!
只需要维护三种金额的数量,5,10和20。
有如下三种情况:

  • 情况一:账单是5,直接收下。
  • 情况二:账单是10,消耗一个5,增加一个10
  • 情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5

此时大家就发现 情况一,情况二,都是固定策略,都不用我们来做分析了,而唯一不确定的其实在情况三,涉及到【优先】【如果不够】,因此采用贪心思想。
账单是20的情况,为什么要优先消耗一个10和一个5呢?
因为美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能!
所以局部最优:遇到账单20,优先消耗美元10,完成本次找零。
全局最优:完成全部账单的找零。
局部最优可以推出全局最优,并找不出反例,那么就试试贪心算法!

整体代码如下:

public boolean lemonadeChange(int[] bills) {
    // 初始值
    int five = 0,ten = 0;

    // 遍历bills
    for (int bill : bills) {
        // 支付5元
        if (bill == 5) five++;
            // 支付10元
        else if (bill == 10) {
            if (five == 0) return false;
            else {
                five--;
                ten++;
            }
        }
            // 支付20元
        else if (bill == 20) {
            if (ten > 0 && five > 0){
                ten--;
                five--;
                // 此处没必要记录20,20不可找零
            } else if (five >= 3) {
                five -= 3;
                // 同样没必要记录20
            }else return false;
        }
    }

    // 遍历完,能找玩零钱则true
    return true;

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

总结

咋眼一看好像很复杂,分析清楚之后,会发现逻辑其实非常固定
这道题目可以告诉大家,遇到感觉没有思路的题目,可以静下心来把能遇到的情况分析一下,只要分析到具体情况了,一下子就豁然开朗了。
如果一直陷入想从整体上寻找找零方案,就会把自己陷进去,各种情况一交叉,只会越想越复杂了。

460 根据身高重建队列

135. 分发糖果一样需要考虑两个维度

思路

本题有两个维度,h和k,看到这种题目一定要想如何确定一个维度,然后再按照另一个维度重新排列
就像135. 分发糖果一样,如果两头都考虑只会顾此失彼。遇到两个维度权衡的时候,应该先确定一个维度,再确定另一个维度。


对于本题困惑的点是先确定k还是先确定h呢,也就是究竟先按h排序呢,还是先按照k排序呢?
那就都试试~
如果按照k来从小到大排序,排完之后,会发现k的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。
如果按照身高h来排序,身高一定是从大到小排(身高相同的话则k小的站前面),让高个子在前面。


此时我们可以确定一个维度了,就是身高,前面的节点一定都比本节点高!
那么只需要按照k为下标重新插入队列就可以了,为什么呢?
以图中{5,2} 为例:
image.png
按照身高排序之后,优先按身高高的people的k来插入,后序插入节点也不会影响前面已经插入的节点,最终按照k的规则完成了队列。
所以在按照身高从大到小排序后:
局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性
全局最优:最后都做完插入操作,整个队列满足题目队列属性
局部最优可推出全局最优,找不出反例,那就试试贪心。


整个插入过程如下:
根据身高排序完(身高一样的k小的在前面)的people: [[7,0], [7,1], [6,1], [5,0], [5,2],[4,4]]
插入的过程:(根据k值作为坐标插入)

  • 插入[7,0]:[[7,0]]
  • 插入[7,1]:[[7,0],[7,1]]
  • 插入[6,1]:[[7,0],[6,1],[7,1]]
  • 插入[5,0]:[[5,0],[7,0],[6,1],[7,1]]
  • 插入[5,2]:[[5,0],[7,0],[5,2],[6,1],[7,1]]
  • 插入[4,4]:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]

此时就按照题目的要求完成了重新排列。
整体代码如下:

public int[][] reconstructQueue(int[][] people) {
    // 按身高[0]排序 如果身高相同按[1]排序
    Arrays.sort(people,(a,b) -> {
        if (a[0] == b[0]) return a[1] - b[1];
        return b[0] - a[0];
    });

    // 设置队列 or 数组方便插入
    LinkedList<int[]> que = new LinkedList<>();
    // ArrayList<int[]> que = new ArrayList<>();

    // 遍历数组 元素向前插入
    for (int[] person : people) {
        // [1]即为要插入的下标(不需要考虑身高)
        que.add(person[1],person);
    }

    // 队列转换成数组
    return que.toArray(new int[people.length][]);
}
  • 时间复杂度:O(nlog n + n^2)
  • 空间复杂度:O(n)

在Java中,ArrayList底层是数组实现的,类似于C++的vector。当插入元素导致容量不足时,ArrayList会进行自动扩容,但扩容方法略有不同。ArrayList会创建一个更大的数组,然后将原数组内容拷贝到新数组中,同样涉及元素的复制过程。
所以使用ArrayList扩容的话,是费时的,插入再拷贝的话,单纯一个插入的操作就是O(n^2)了,甚至可能拷贝好几次,就不止O(n^2)了。
相比之下,LinkedList在Java中使用链表的数据结构实现。它不需要进行数组的扩容操作,而是通过节点之间的链接来存储和操作元素。因此,在插入操作时,LinkedList只需要修改节点的链接,不需要移动和复制元素,所以插入操作的时间复杂度为O(1)。本题涉及元素较少,使用链表实现效率会更快
综上所述,使用LinkedList在插入操作频繁的情况下效率较高,因为插入操作的时间复杂度为O(1),但在访问和查找特定位置的元素时效率较低。而ArrayList在访问和查找元素时由于可以通过索引直接访问元素,效率较高,但在插入操作频繁的情况下效率较低。

具体分析见这里👈(C++版)

总结

这种需要考虑两个维度的题目,其技巧都是确定一边然后贪心另一边。[两边一起考虑,就会顾此失彼]

这道题目可以说比135. 分发糖果难不少,其贪心的策略也是比较巧妙。
最后根据数据结构特点优化算法,链表的效率比数组高的。

452 用最少数量的箭引爆气球

又是一题需要考虑两个维度

思路

如何使用最少的弓箭呢?
直觉上来看,貌似只射重叠最多的气球,用的弓箭一定最少,那么有没有当前重叠了三个气球,我射两个,留下一个和后面的一起射这样弓箭用的更少的情况呢?
尝试一下举反例,发现没有这种情况。那么就试一试贪心吧!
局部最优:当气球出现重叠,一起射,所用弓箭最少。
全局最优:把所有气球射爆所用弓箭最少。


算法确定下来了,那么如何模拟气球射爆的过程呢?是在数组中移除元素还是做标记呢?
如果真实的模拟射气球的过程,应该射一个,气球数组就remove一个元素,这样最直观,毕竟气球被射了。
但仔细思考一下就发现:如果把气球排序之后,从前到后遍历气球,被射过的气球仅仅跳过就行了,没有必要让气球数组remove气球,只要记录一下箭的数量就可以了
以上为思考过程,已经确定下来使用贪心了,那么开始解题。


为了让气球尽可能的重叠,需要对数组进行排序
那么按照气球起始位置排序,还是按照气球终止位置排序呢?
其实都可以!只不过对应的遍历顺序不同,我就按照气球的起始位置排序了。
既然按照起始位置排序,那么就从前向后遍历气球数组,靠左尽可能让气球重复。
从前向后遍历遇到重叠的气球了怎么办?
如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭
以题目示例: [[10,16],[2,8],[1,6],[7,12]]为例,如图:(方便起见,已经排序)

  • 如果当前气球和上一个气球有重叠(体现在上一个球右边界大于下一个球左边界)则更新当前球的右边界(为两个有边界最小值以便判断下一个球)
  • 如果当前气球和上一个气球没重叠(反之体现在上一个球右边界小于下一个球左边界)则增加一支箭
  • 注意:更新当前球的右边界就是为了判断下一个球,是否可以三连发

image.png
可以看出首先第一组重叠气球,一定是需要一个箭,气球3,的左边界大于了 第一组重叠气球的最小右边界,所以再需要一支箭来射气球3了。
代码如下:

public int findMinArrowShots(int[][] points) {
    // 按左边界排序
    Arrays.sort(points,(a,b) -> Integer.compare(a[0],b[0]));

    int count = 1; // points 不为空至少需要一支箭
    for (int i = 1; i < points.length; i++) {
        // 从第二个气球开始判断
        // 根据当前球左边界与上一个球右边界比较判断。没有重合 - 消耗一支箭
        if (points[i][0] > points[i - 1][1]) count++;
            // 否则则为points[i][0] <= points[i - 1][1] 有重合(上一个球范围覆盖当前气球) - 更新右边界
        else points[i][1] = Math.min(points[i][1],points[i - 1][1]);
    }

    return count;
}
  • 时间复杂度:O(nlog n),因为有一个快速排序
  • 空间复杂度:O(1),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间

可以看出代码并不复杂。

注意事项

注意题目中说的是:满足 xstart ≤ x ≤ xend,则该气球会被引爆。那么说明两个气球挨在一起(右边界刚好等于左边界)不重叠也可以一起射爆,
所以代码中 if (points[i][0] > points[i - 1][1]) 不能是>=

总结

这道题目贪心的思路很简单也很直接,就是重复的一起射了,但本题我认为是有难度的。
就算思路都想好了,模拟射气球的过程,很多同学真的要去模拟了,实时把气球从数组中移走,这么写的话就复杂了。
而且寻找重复的气球,寻找重叠气球最小右边界,其实都有代码技巧。
贪心题目有时候就是这样,看起来很简单,思路很直接,但是一写代码就感觉贼复杂无从下手。
*这里其实是需要代码功底的,那代码功底怎么练?多看多写多总结

学习资料:

860. 柠檬水找零

406. 根据身高重建队列

452. 用最少数量的箭引爆气球