Greedy

1. 反悔贪心

贪心本质上是不可能反悔的,这和贪心算法的定义是矛盾的:贪心每次都选取当前的最优解,所以一旦反悔了,被反悔的那次选择就不叫贪心了。

但有时候因为题目的特殊性质,在明确了一个贪心算法之后,我们使用数据结构记录下每次的贪心选择,在之后做出贪心选择时查询记录,如果不满足“全局贪心”的结论,就置换贪心选择,称为反悔贪心。

1.1. LC 1642 可以到达的最远建筑

贪心的思路是:对于小的高度差用砖块,对于大的高度差用梯子。这是一个很直观的结论,用反证法也能轻易证明。这里出于直观就不写严谨的数学证明了。

假设存在一个策略,其中存在至少一次在使用梯子和使用砖块时,前者对应的高度差小于后者。形如(b,x)和(l,y),其中x>y。那么我们交换在这两次中使用梯子和使用砖块的时机,也即(b,y)和(l,x),既然y个砖块能满足后者的高度差,那么对于x>y显然能满足。而梯子本身是对任意高度差都适用的,因此对于前者,交换后仍满足。

因此对于小的高度差用砖块,对于大的高度差用梯子,一定不比反过来差。但反之不亦然。则贪心思路正确。

这里所谓的“全局贪心”,就是对于经历过的所有高度差,小的用砖大的用梯。但是线搜的时候,我们可不知道当前的高度差属于“小”还是“大”。因此需要全局记录这些高度差,以便后续反悔。

思路是,记录所有使用梯子时的高度差,当使用砖块时,可以查询梯子对应的高度差看有没有更小的选择,如果有则置换梯子和砖块的使用。

这里的trick是,上来无论是什么高度差,都用梯子。然后把这些高度差加入一个小根堆中,作为可以用来置换的选择。当梯子用完之后,需要使用砖块时,我们比较当前小根堆的堆顶和本轮高度差,如果堆顶更小,说明之前的某次使用梯子的场景其实应该使用砖块。pop出堆顶,尝试使用砖块踏过那一次的高度差。如果成功,则还要将本轮的高度差置入堆(因为堆本身维护的是梯子对应的高度差)。

import java.util.PriorityQueue;

class Solution {
    public int furthestBuilding(int[] heights, int bricks, int ladders) {
        PriorityQueue<Integer> pq = new PriorityQueue<>(Integer::compare);
        int diff,ans;
        ans = 0;
        for(int i=1;i<heights.length;i++){
            diff = heights[i] - heights[i-1];
            if(diff<=0){
                ans++;
            }else{
                // 先用梯子,砖块不够用了开始置换
                if(ladders>0){
                    ladders--;
                    pq.offer(diff);
                    ans++;
                }else{
                    // 置换,检查堆顶高度差<当前高度差,不然没有任何置换的必要
                    if(pq.isEmpty() || pq.peek()>= diff){
                        if(bricks>=diff){
                            bricks-=diff;
                            ans++;
                        }else{
                            return ans;
                        }
                    }else{
                        if(bricks < pq.peek()){
                            return ans;
                        }
                        bricks -= pq.poll();
                        // diff 对应了梯子
                        pq.offer(diff);
                        ans++;
                    }
                }
            }
        }
        return heights.length-1;
    }
}

1.2. LC 3049 标记所有下标的最早秒数Ⅱ

历史最难的题。灵神估分3100。

  1. 首先这题和3048肯定都是能用二分的,因为答案具有单调性

  2. 灵神问了一个问题:在以下三种操作中,哪个最“紧急”?

    1. 减一
    2. 置零(能置数肯定是选0的)
    3. 标记

    在这个题中,是置零最紧急。因为置零的位置是根据changeIndices来决定的,而另外两个都是你想选哪里就选哪里,只要在总时长用掉时/前能把所有的都标记上就可以了。而置零是过了这个村没这个店了。

    和3048一样,需要选择倒着遍历操作数组changeIndices。为什么不能正着,举个例子。如果正着遍历到一个可以置零的操作位置,那么是选择置零还是不置零。如果置零了,就需要抢占后续一个位置去标记,导致后续的某个可以置零的位置无法操作了,就只能慢慢减一,导致超时。

  3. 下一个问题:倒着遍历遇到能置零的位置的话,要置零吗?如果在比较靠后的位置置零,那么可能就没时间标记了。如果一个位置上需要的慢慢减一的操作次数太多的话,如果把置零和标记的机会让给后面的其他位置,就会导致这个位置超时。所以怎么平衡置零与否是这题的关键。

  4. 那么什么情况下置零呢?这个就用到反悔贪心。首先,如果这个位置上的操作次数≤1,那么就无所谓置零还是减1了,因为对于1来说二者都一样,对于0来说不需要操作。而在这之外,我们就选择在能够置零的所有机会中选择时间点最早的一个置零,这能给后面尽可能多的让出标记的时间(这就导致3中说的,后面位置抢占置零和标记的机会,前面操作次数大的可能就超时了),这个时候我们就要维护我们贪心的记录,当发现到某个位置没有剩余的时间了,那么就要反悔。

  5. 具体怎么反悔呢?很直观的是,可以选择当前贪心记录中减一操作次数最小的,让它让出自己的置零和标记机会,转而变成减一并标记。

  6. 在具体实现上,采用cnt记录能够用来给减一和标记操作的剩余用时。如果置零了,也要消耗cnt中的1个单位,因为要标记一次,并且逐渐减一并标记的总用时要减掉这些时间。如果反悔了,那么就让出了两次机会,cnt+=2;最终如果cnt≥剩余的逐渐减一并标记的剩余时间。那么就成功了。

import java.util.Arrays;
import java.util.PriorityQueue;

class Solution {
    public int earliestSecondToMarkIndices(int[] nums, int[] changeIndices) {
        int n = nums.length;
        int m = changeIndices.length;

        if(n>m){
            return -1;
        }

        long least = n; // 标记和减一需要的总操作次数
        for (int num : nums) {
            least += num;
        }

        int[] firstT = new int[n];
        Arrays.fill(firstT,-1);
        for (int i = m-1; i >= 0; i--) {
            firstT[changeIndices[i]-1] = i;
        }

        int l,r,mid,ans;
        ans = -1;
        l = n;
        r = m+1;

        while(l<r){
            mid = ((r-l)>>>1)+l;
            if(check(nums,changeIndices,firstT,least,mid)){
                ans = mid;
                r = mid;
            }else{
                l = mid+1;
            }
        }

        return ans;
    }

    private boolean check(int[] nums,int[] changeIndices,int[] firstT,long least,int mid){
        int cnt = 0; // 留给逐渐减一及其对应标记的总时间

        PriorityQueue<Integer> pq = new PriorityQueue<>();
        for(int i=mid-1;i>=0;i--){
            int j = changeIndices[i]-1; // 哪一个位置可以置0
            int v = nums[j]; // 这个位置如果要逐渐减一要用掉多少操作次数

            if(v<=1 || i!=firstT[j]){
                // 只需要操作至多一次 或者 不是把把nums[j]置0的所有机会中最靠前的
                cnt++;
                continue;
            }

            // 需要反悔
            if(cnt==0){
                if(pq.isEmpty() || v<=pq.peek()) {
                    cnt++; // 当前的这个逐渐减一比 反悔堆堆顶的逐渐减一要快,就没必要反悔
                    continue;
                }

                least += pq.poll()+1;// 反悔
                cnt += 2; // 反悔的置0和标记的时间就解放出来了
            }
            least -= v+1; // 逐渐减一和标记的总时间减掉
            cnt--; // 用直接置零和标记替代 注意本次是置零 cnt--的减一是标记
            pq.offer(v);
        }

        return cnt>=least;
    }

}

这里举个例子好了:

nums = [3,2,3] 
changeIndices = [1,3,2,2,2,2,3]

鉴于答案就是6,所以我们玩的极限一点,就看前6个操作单位好了。

也就是:

nums = [3,2,3]
changeIndices = [1,3,2,2,2,2]
  1. i=6,非最早

  2. i=5,非最早

  3. i=4,非最早

  4. i=3,最早,且操作次数>1

  5. i=2,最早,且操作次数>1

  6. i=1,最早,且操作次数>1

  7. i=6 cnt=1 pq=null

  8. i=5 cnt=2 pq=null

  9. i=4 cnt=3 pq=null

  10. i=3 cnt=2 pq=[2](这个2是nums[ changeIndices[3] ],不是changeIndices[3])

  11. i=2 cnt=1 pq=[2,3]

  12. i=1 cnt=0 pq=[2,3,3]

最后需要逐渐减1并标记的等于3+2+3+3-(3+2+3+3)=0,cnt≥0。于是成功。

这个例子还是没出现反悔的情况。有机会可以。

1.3. LC 2813 子序列最大优雅度

好久没更刷题记录噜。

这题反悔贪心+哈希表。先逆序排序,然后把前k个累积profit求和,并且维护一个以category为key的哈希表。

随后开始反悔,继续从后面n-k个的第一个遍历。

  1. 如果其category已经存在,则他前面的前辈都比他大,没必要反悔。
  2. 如果其category不存在,反悔,寻找前辈中最小的非单独的profit,踢走。注意一定要非单独!假设你找到的前辈中最小的是v,但是对应的category里就只有他一个,你把他踢了本质上distinct_category没有增加,反而还降profit。

可能会疑惑反悔遍历是不是需要判停/跳过。实际上不需要。因为这个东西要做就一直做下去,两个原因:

  1. 首先你不知道一直反悔下去有没有可能更大
  2. 其次,你不可能跳过某个元素不反悔。注意我们逆序排序了,所以如果你要跳过前面某个元素不反悔,更没有理由用后面的元素反悔,因为同样对distinct_category增1,但是后面的元素的profit更小。
from typing import List
import heapq

class Solution:
    def findMaximumElegance(self, items: List[List[int]], k: int) -> int:
        m = {}
        n = len(items)

        res = 0

        items = sorted(items,key = lambda tup:tup[0])
        # print(items)
        for i in range(k):
            index = n-1-i
            cat = items[index][1]
            profit = items[index][0]
            if cat not in m:
                m[cat] = [profit]
            else:
                heapq.heappush(m[cat],profit)
            
            res += profit
        
        # print(m)
        res += pow(len(m),2)
        if len(m)==k:
            return res

        ans = res
        for i in range(n-k-1,-1,-1):
            cat = items[i][1]
            profit = items[i][0]

            if cat in m:
                continue
            else:
                min_k = -1
                min_v = 1_000_000_000+1
                for k,v in m.items():
                    # print(v)
                    if len(v)==1:
                        continue
                    if min_v > v[0]:
                        min_k = k
                        min_v = v[0]
                
                if min_k!=-1:
                    ele = heapq.heappop(m[min_k])
                    m[cat] = [profit]
                    res += 2*len(m)-1-ele+profit
                    ans = max(ans,res)
        
        return ans

这里维护和的时候,每次反悔加上2*distinct_category-1-ele+profit

  1. ele是被反悔的,profit是反悔的元素的profit。这个是profit的置换
  2. 对于distinct_category,假设原先是c,现在是c+1,那么(c+1)^2-c^2 = 2c+1 = 2(c+1)-1

复杂度:空间O(k)没啥好说的,时间O(n)的遍历里套了一个O(k)的遍历以及O(logk)的小根堆维护,总体O(n(k+logk)) = O(n*max(k,logk)) = O(nk)

To Counter That…

这样写有点繁,其实数据结构没必要哈希表套小根堆,可以直接哈希集合加列表维护前k大。

  1. 当新增一个category,不在列表中维护
  2. 当category已经存在,再push到列表中

这样本来1个的在列表中变0个,2个的变1个…不用担心把自己单独一个category的前辈踢走了。

class Solution:
    def findMaximumElegance(self, items: List[List[int]], k: int) -> int:
        items.sort(key=lambda x: -x[0])
        tot = 0
        vis = set()
        dup = []
        for p, c in items[:k]:
            tot += p
            if c not in vis:
                vis.add(c)
            else:
                dup.append(p)
        ans = tot + len(vis) ** 2
        for p, c in items[k:]:
            if c in vis or not dup:
                continue
            vis.add(c)
            tot += p - dup.pop()
            ans = max(ans, tot + len(vis) ** 2)
        return ans

复杂度:空间O(k),时间O(n),不用维护小根堆和遍历找最小(因为append根据逆序尾插尾出的,每次出最小非单独),遥遥领先

2. 树上贪心

2.1. 双周赛120 T3 LC 2973 树中每个节点放置的金币数量

事后诸葛亮的说法是,我被这题吓到了,因为这把打烂掉了。T3一直卡着出不来,信心直接没了。其实这题补的时候发现难度不高(就是对java选手不友好)。

对于有正有负的长度≥3的数组,从中选三个节点,乘积最大,从数学上来说只可能:

  1. 两个最小的负数,一个最大的正数
  2. 三个最大的正数

这样只要判断5个数就可以了,其他数压根不关心,所以这题排序的话耗时没那么严重,因为最多排序排5个元素。

思路上来说:

  1. 深搜子树,子树返回其本身以及其子树的节点列表。当返回数目大于5,挑两个最小的和三个最大的组成列表返回。不用理会是正是负,因为我们挑的是最大值。
  2. 拿到5个数,两种组合加上一个兜底的0,更新结果数组
  3. 返回结果数组即可。

无向树深搜防环的trick是,深搜时加一个前面的父节点,邻接表判到父节点就直接跳过防入环。由于树确定了整棵树的根后,不可能有节点拥有两个父节点(这会出现环,很简单的证明,不证了),所以判单一父节点足够防环了。

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

class Solution {

    static long[] ans;
    static int[] c;
    public long[] placedCoins(int[][] edges, int[] cost) {
        int n = cost.length;
        c = cost;
        // 建图 邻接表
        List<Integer>[] g = new ArrayList[n];
        Arrays.setAll(g,e->new ArrayList<Integer>());
        for (int[] edge : edges) {
            g[edge[0]].add(edge[1]);
            g[edge[1]].add(edge[0]);
        }
        ans = new long[n];
        Arrays.fill(ans,1);
        dfs(g,0,-1);
        return ans;
    }

    private List<Integer> dfs(List<Integer>[] g,int node,int fa){
        // 返回给父节点的列表
        List<Integer> children = new ArrayList<>();
        children.add(c[node]);
        for (Integer child : g[node]) {
            if(child!=fa){
                children.addAll(dfs(g,child,node));
            }
        }
        Collections.sort(children);
        int size = children.size();
        if(size >=3){
            ans[node] = Math.max(0,Math.max((long)children.get(0)*children.get(1)*children.get(size -1), (long) children.get(size - 1) *children.get(size -2)*children.get(size -3)));
        }
        List<Integer> ret = new ArrayList<>();
        if(size > 5){
						// 可以List.of顶掉,java这门语言嘛,写起来有爽点,但很多时候笨得出奇
						// 隔壁python3 ret = children[:2] + children[-3:]秒了
            ret.add(children.get(0));
            ret.add(children.get(1));
            ret.add(children.get(size-1));
            ret.add(children.get(size-2));
            ret.add(children.get(size-3));
        }else{
            ret = children;
        }
        return ret;
    }
}

由于边的数量恒为n-1,所以其实是O(n)的复杂度。(排序排了常数长度,等于常数时间不考虑)

这题真的是信心被打没了,其实真的可以尝试做的(懊悔

3. 贪心DP

3.1. LC 1553 吃掉N个橘子的最少天数

这是个好题。贪心我做过,DP我也做过,但是俩合起来的,我第一次做,就这题。

问题:如果现在有7个橘子,怎么吃才最快?

很显然不会傻傻的一个一个吃,吃7次。肯定是想办法吃到2或3的倍数,然后一口气吃一半,或者吃掉三分之二。

但是你说是吃到2的倍数对半砍还是吃到3的倍数砍掉三分之二哪个快,这个不好说。

所以思路就是:每次吃到2或3的倍数,然后按那个值对半砍或者吃掉三分之二,继续递归。

可以记搜优化。

import java.util.HashMap;
import java.util.Map;

class Solution {
    Map<Integer,Integer> memo = new HashMap<>();
    public int minDays(int n) {
        if(n<=1){
            return n;
        }

        Integer cost = memo.get(n);
        if(cost!=null){
            return cost;
        }
        
        int res = Math.min(minDays(n/2)+n%2,minDays(n/3)+n%3)+1;
        memo.put(n,res);
        return res;
    }

}

4. General

4.1. LC 2589 完成所有任务的最少时间

贪心思路:尽可能让任意两个区间重叠的部分变多。

按照结束时间排序,每个任务查询区间内已经使用的点,任务实际的执行时间就是总时间减去已经使用的时间(因为可以并行),再从剩下可用的时间点中从后往前安排时间。

import java.util.Arrays;
import java.util.Comparator;

class Solution {
    public int findMinimumTime(int[][] tasks) {
        Arrays.sort(tasks, Comparator.comparingInt(o -> o[1]));

        boolean[] rec = new boolean[2001];
        Arrays.fill(rec,false);

        if(tasks.length==0){
            return 0;
        }

        int duration = tasks[0][2];
        int back = tasks[0][1];
        while(duration>0){
            rec[back--] = true;
            duration--;
        }

        int ans = tasks[0][2];

        for(int i=1;i<tasks.length;i++){
            int end = tasks[i][1];
            int start = tasks[i][0];
            duration = tasks[i][2];
            int cnt = nnz_range(start, end, rec);
            duration = Math.max(0,duration-cnt);
            ans += duration;
            back = end;

            while(duration>0){
                if(rec[back]){
                    back--;continue;
                }

                rec[back--] = true;
                duration--;
            }

        }

        return ans;
    }

    private int nnz_range(int start,int end,boolean[] rec){
        int res = 0;
        for(int i = start;i<=end;i++){
            if(rec[i]){
                res++;
            }
        }

        return res;
    }
}

4.2. LC 1953 你可以工作的最大周数

唯一能产生影响的是最大值,如果最大值比剩下的所有数之和还大,那么只能剩下的全做完的同时,最大的做等量的周数;否则,可以全部做完。构造做完的方法是:每一轮每个任务做一个阶段,直到只剩下一个任务或全部做完。

class Solution {
    public long numberOfWeeks(int[] milestones) {
        long sum = 0L;
        int max = 0;
        for (int milestone : milestones) {
            sum+=milestone;
            max = Math.max(max,milestone);
        }
        
        return sum>=max*2L?sum:(sum-max)*2+1;
    }
}

  • 7
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值