9-贪心算法

参考:代码随想录

题目分类大纲如下:

图片

贪心算法理论基础

什么是贪心?

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

贪心的套路(什么时候用贪心)

贪心算法并没有固定的套路,说白了就是常识性推导加上举反例。靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。

如何验证可不可以用贪心算法呢?最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧。

贪心一般解题步骤

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

  • 将问题分解为若干个子问题

  • 找出适合的贪心策略

  • 求解每一个子问题的最优解

  • 将局部最优解堆叠成全局最优解

其实这个分的有点细了,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起。

基础题目

1、455.分发饼干

参考:LeetCode-455. 分发饼干

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。

对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是满足尽可能多的孩子,并输出这个最大数值。

示例 1:

输入: g = [1,2,3], s = [1,1]
输出: 1
解释: 
你有三个孩子和两块小饼干,3 个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是 1,你只能让胃口值是 1 的孩子满足。
所以你应该输出 1。

示例 2:

输入: g = [1,2], s = [1,2,3]
输出: 2
解释: 
你有两个孩子和三块小饼干,2 个孩子的胃口值分别是 1,2。
你拥有的饼干数量和尺寸都足以让所有孩子满足。
所以你应该输出 2。

提示:

  • 1 <= g.length <= 3 * 10^4

  • 0 <= s.length <= 3 * 10^4

  • 1 <= g[i], s[j] <= 2^31 - 1

贪心-优先小饼干

为了尽可能满足最多数量的孩子,从贪心的角度考虑,应该按照孩子的胃口从小到大的顺序依次满足每个孩子,且对于每个孩子,应该选择可以满足这个孩子的胃口且尺寸最小的饼干

这里的局部最优就是小饼干喂给胃口小的,充分利用小饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。

  • 可以尝试使用贪心策略,先将饼干数组和小孩数组排序。

  • 然后从前向后遍历饼干数组,用小饼干优先满足胃口小的,并统计满足小孩数量。

java

import java.util.Arrays;
public class LeetCode_455 {
    public static void main(String[] args) {
        int[] g = {1,2};
        int[] s = {1,2,3};
        System.out.println(findContentChildren(g, s));
    }


    public static int findContentChildren(int[] g, int[] s) {
        int res = 0;
        int n = g.length;
        Arrays.sort(g);
        Arrays.sort(s);
        // 遍历饼干,优先使用小的饼干喂饱小胃口的学生
        for (int i : s) {
            if (res < n && i >= g[res]) {
                res++;
            }
        }
        return res;
    }
}

python

def findContentChildren(g: list, s: list) -> int:
    g.sort()
    s.sort()
    res = 0
    for i in range(len(s)):
        if res <len(g) and s[i] >= g[res]:  #小饼干先喂饱小胃口
            res += 1
    return res

贪心-优先大饼干

为了了满足更多的小孩,就不要造成饼干尺寸的浪费。大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。

这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。

  • 可以尝试使用贪心策略,先将饼干数组和小孩数组排序。

  • 然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。

如图:

图片
这个例子可以看出饼干9只有喂给胃口为7的小孩,这样才是整体最优解,并想不出反例,那么就可以撸代码了。

java

import java.util.Arrays;
public class LeetCode_455_2_greedy {
    public static void main(String[] args) {
        int[] g = {1, 2};
        int[] s = {1, 2, 3};
        System.out.println(findContentChildren(g, s));
    }


    public static int findContentChildren(int[] g, int[] s) {
        Arrays.sort(g);
        Arrays.sort(s);
        int index = s.length - 1;
        int res = 0;
        int n = g.length;
        // 大尺寸饼干优先满足大胃口的学生
        for (int i = n - 1; i >= 0; i--) {
            if (index >= 0 && g[i] <= s[index]) {
                index--;
                res++;
            }
        }
        return res;
    }
}

python

def findContentChildren(g: list, s: list) -> int:
    g.sort()
    s.sort()
    start, count = len(s) - 1, 0
    for index in range(len(g) - 1, -1, -1): # 先喂饱大胃口
        if start >= 0 and g[index] <= s[start]: 
            start -= 1
            count += 1
    return count

2、860.柠檬水找零

参考:LeetCode-860. 柠檬水找零

在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。

每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。

注意,一开始你手头没有任何零钱。

如果你能给每位顾客正确找零,返回 true ,否则返回 false 。

给你一个整数数组 bills ,其中 bills[i] 是第 i 位顾客付的账。如果你能给每位顾客正确找零,返回 true ,否则返回 false 。

示例 1:

输入:bills = [5,5,5,10,20]
输出:true
解释:
前 3 位顾客那里,我们按顺序收取 3 张 5 美元的钞票。
第 4 位顾客那里,我们收取一张 10 美元的钞票,并返还 5 美元。
第 5 位顾客那里,我们找还一张 10 美元的钞票和一张 5 美元的钞票。
由于所有客户都得到了正确的找零,所以我们输出 true。

提示:

  • 1 <= bills.length <= 10^5

  • bills[i] 不是 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,完成本次找零。

  • 全局最优:完成全部账单的找零。

局部最优可以推出全局最优,并找不出反例,那么就试试贪心算法!

java

public class LemonadeChange {
    public static void main(String[] args) {
        int[] bills = {5, 5, 5, 10, 20};
        System.out.println(lemonadeChange(bills));
    }
    
    public static boolean lemonadeChange(int[] bills) {
        int five = 0, ten = 0;
        for (int bill : bills) {
            if (bill == 5) {
                five++;
            } else if (bill == 10) {
                if (five == 0) {
                    return false;
                }
                five--;
                ten++;
            } else {
                // 给20找零优先用10块的
                if (five > 0 && ten > 0) {
                    five--;
                    ten--;
                } else if (five >= 3) {
                    five -= 3;
                } else {
                    return false;
                }
            }
        }
        return true;
    }
}

复杂度分析

  • 时间复杂度:O(N),其中 N 是 bills 的长度。

  • 空间复杂度:O(1)。

python

def lemonadeChange(bills: list) -> bool:
    # 记录每种币种个数
    five, ten, twenty = 0, 0, 0
    # 遍历
    for bill in bills:
        if bill == 5:  # 5美元
            five += 1
        elif bill == 10:  # 10美元
            if five < 1: return False
            five -= 1
            ten += 1
        else:  # 20美元
            # 20美元优先使用10美元兑换
            if ten > 0 and five > 0:
                ten -= 1
                five -= 1
                twenty += 1
            # 没有10美元采用3个5美元兑换
            elif five > 2:
                five -= 3
                twenty += 1
            else:
                return False
    return True

3、561.数组拆分

参考:LeetCode-561. 数组拆分

给定长度为 2n 的整数数组 nums ,你的任务是将这些数分成 n 对, 例如 (a1, b1), (a2, b2), ..., (an, bn) ,使得从 1 到 n 的 min(ai, bi) 总和最大。

返回该 最大总和 。

示例 1:

输入:nums = [1,4,3,2]
输出:4
解释:所有可能的分法(忽略元素顺序)为:
1. (1, 4), (2, 3) -> min(1, 4) + min(2, 3) = 1 + 2 = 3
2. (1, 3), (2, 4) -> min(1, 3) + min(2, 4) = 1 + 2 = 3
3. (1, 2), (3, 4) -> min(1, 2) + min(3, 4) = 1 + 3 = 4
所以最大总和为 4

示例 2:

输入:nums = [6,2,6,5,1,2]
输出:9
解释:最优的分法为 (2, 1), (2, 5), (6, 6). min(2, 1) + min(2, 5) + min(6, 6) = 1 + 2 + 6 = 9

提示:

  • 1 <= n <= 10^4

  • nums.length == 2 * n

  • -10^4 <= nums[i] <= 10^4

排序+贪心

要想每对数最小值的总和最大,就得使每对数的最小值尽可能大。只有让较大的数与较大的数一起组合,较小的数与较小的数一起结合,才能才能使总和最大。

  • 对 nums 进行排序。

  • 将相邻两个元素的最小值进行相加,即得到结果

import java.util.Arrays;
public class LeetCode_561_greedy {
    public static void main(String[] args) {
        int[] nums = {1, 4, 3, 2};
        System.out.println(arrayPairSum(nums));
    }


    public static int arrayPairSum(int[] nums) {
        // 数组升序排序
        Arrays.sort(nums);


        // 数组两两一组,每一组取第一个值累加
        int n = nums.length;
        int res = 0;
        for (int i = 0; i < n; i += 2) {
            res += nums[i];
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(n×log⁡n)

  • 空间复杂度:O(1)

4、1710.卡车上的最大单元数

参考:LeetCode-1710. 卡车上的最大单元数

请你将一些箱子装在 一辆卡车 上。给你一个二维数组 boxTypes ,其中 boxTypes[i] = [numberOfBoxesi, numberOfUnitsPerBoxi] :

  • numberOfBoxesi 是类型 i 的箱子的数量。

  • numberOfUnitsPerBoxi 是类型 i 每个箱子可以装载的单元数量。

整数 truckSize 表示卡车上可以装载 箱子 的 最大数量 。只要箱子数量不超过 truckSize ,你就可以选择任意箱子装到卡车上。

返回卡车可以装载 单元 的 最大 总数。

示例 1:

输入:boxTypes = [[1,3],[2,2],[3,1]], truckSize = 4
输出:8
解释:箱子的情况如下:
- 1 个第一类的箱子,里面含 3 个单元。
- 2 个第二类的箱子,每个里面含 2 个单元。
- 3 个第三类的箱子,每个里面含 1 个单元。
可以选择第一类和第二类的所有箱子,以及第三类的一个箱子。
单元总数 = (1 * 3) + (2 * 2) + (1 * 1) = 8

示例 2:

输入:boxTypes = [[5,10],[2,5],[4,7],[3,9]], truckSize = 10
输出:91

提示:

  • 1 <= boxTypes.length <= 1000

  • 1 <= numberOfBoxesi, numberOfUnitsPerBoxi <= 1000

  • 1 <= truckSize <= 10^6

贪心

一辆卡车上可以装载箱子的最大数量是固定的(truckSize),那么如果想要使卡车上装载的单元数量最大,就应该优先选取装载单元数量多的箱子。

所以,从贪心算法的角度来考虑,我们应该按照每个箱子可以装载的单元数量对数组 boxTypes

boxTypes 从大到小排序。然后优先选取装载单元数量多的箱子。步骤如下:

  • 对数组 boxTypes 按照每个箱子可以装载的单元数量从大到小排序。使用变量 res 记录卡车可以装载的最大单元数量。

  • 遍历数组 boxTypes,对于当前种类的箱子 box:

    • 如果 truckSize>box[0],说明当前种类箱子可以全部装载。则答案数量加上该种箱子的单元总数,即 box[0]×box[1],并且最大数量 truckSize 减去装载的箱子数。

    • 如果 truckSize≤box[0],说明当前种类箱子只能部分装载。则答案数量加上truckSize×box[1],并跳出循环。

  • 最后返回答案 res

java

import java.util.Arrays;
public class LeetCode_1710_greedy {
    public static void main(String[] args) {
        int[][] boxTypes = {{1, 3}, {2, 2}, {3, 1}};
        int truckSize = 4;
        System.out.println(maximumUnits(boxTypes, truckSize));
    }


    public static int maximumUnits(int[][] boxTypes, int truckSize) {
        // 按照每一类箱子最大可装载单元数降序排序
        Arrays.sort(boxTypes, (boxType1, boxType2) -> (boxType2[1] - boxType1[1]));


        // 从左到右遍历,优先装载单元较大的箱子
        int res = 0;
        for (int[] boxType : boxTypes) {
            if (boxType[0] < truckSize) {
                res += boxType[0] * boxType[1];
                truckSize -= boxType[0];
            } else {
                res += truckSize * boxType[1];
                break;
            }
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 是 boxTypes 的长度。排序需要 O(nlogn) 的时间。

  • 空间复杂度:O(logn),其中 n 是 boxTypes 的长度。排序需要 O(logn) 的递归调用栈空间。

5、1217.玩筹码

参考:LeetCode-1217. 玩筹码

有 n 个筹码。第 i 个筹码的位置是 position[i] 。

我们需要把所有筹码移到同一个位置。在一步中,我们可以将第 i 个筹码的位置从 position[i] 改变为:

  • position[i] + 2 或 position[i] - 2 ,此时 cost = 0

  • position[i] + 1 或 position[i] - 1 ,此时 cost = 1

返回将所有筹码移动到同一位置上所需要的 最小代价 。

示例 1:

图片

输入:position = [1,2,3]
输出:1
解释:第一步:将位置3的筹码移动到位置1,成本为0。
第二步:将位置2的筹码移动到位置1,成本= 1。
总成本是1。

示例 2:
图片

输入:position = [2,2,2,3,3]
输出:2
解释:我们可以把位置3的两个筹码移到位置2。每一步的成本为1。总成本= 2。

示例 3:

输入:position = [1,1000000000]
输出:1

提示:

  • 1 <= position.length <= 100

  • 1 <= position[i] <= 10^9

贪心

题目中移动偶数位长度是不需要代价的,所以奇数位移动到奇数位不需要代价,偶数位移动到偶数位也不需要代价。

我们可以想将所有偶数位都移动到下标为 0 的位置,奇数位都移动到下标为 1 的位置。这样,所有的奇数位、偶数位上的人都到相同或相邻位置了。我们只需要统计一下奇数位和偶数位的数字个数。将少的数移动到多的数上边就是最小代价。

则这道题就可以通过以下步骤求解:

  • 遍历数组,统计数组中奇数个数和偶数个数。

  • 返回奇数个数和偶数个数中较小的数即为答案

java

public class LeetCode_1217_greedy {
    public static void main(String[] args) {
        int[] position = {1, 2, 3};
        System.out.println(minCostToMoveChips(position));
    }


    public static int minCostToMoveChips(int[] position) {
        // 分别统计奇数和偶数索引上的筹码数量(奇数/偶数索引之间相互移动无成本)
        int odd = 0;
        int even = 0;
        for (int index : position) {
            if (index % 2 == 0) {
                even++;
            } else {
                odd++;
            }
        }


        // 将较小的(奇数和/偶数和)移动到较大的上成本最低
        return Math.min(odd, even);
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为数组 position 的长度,只对数组进行了一次遍历。

  • 空间复杂度:O(1),仅使用常数变量。

字符串贪心

6、921.使括号有效的最少添加

参考:LeetCode-921. 使括号有效的最少添加

只有满足下面几点之一,括号字符串才是有效的:

  • 它是一个空字符串,或者

  • 它可以被写成 AB (A 与 B 连接), 其中 A 和 B 都是有效字符串,或者

  • 它可以被写作 (A),其中 A 是有效字符串。

给定一个括号字符串 s ,在每一次操作中,你都可以在字符串的任何位置插入一个括号

  • 例如,如果 s = "()))" ,你可以插入一个开始括号为 "(()))" 或结束括号为 "())))" 。

返回 为使结果字符串 s 有效而必须添加的最少括号数。

示例 1:

输入:s = "())"
输出:1

示例 2:

输入:s = "((("
输出:3

提示:

  • 1 <= s.length <= 1000

  • s 只包含 '(' 和 ')' 字符。

这道题是括号匹配的题目。每个左括号必须对应一个右括号,而且左括号必须在对应的右括号之前。对于括号匹配的题目,常用的做法是使用栈进行匹配,栈具有后进先出的特点,因此可以保证右括号和最近的左括号进行匹配

java

import java.util.ArrayDeque;
import java.util.Deque;
public class LeetCode_921_greedy {
    public static void main(String[] args) {
        String s = "()))((";
        System.out.println(minAddToMakeValid(s));
    }


    public static int minAddToMakeValid(String s) {
        // 利用栈来匹配左右有效括号,统计匹配不到的数量就是需要添加的括号数
        Deque<Character> stack = new ArrayDeque<>();
        int n = s.length();
        for (int i = 0; i < n; i++) {
            if (!stack.isEmpty() && (stack.peek() == '(' && s.charAt(i) == ')')) {
                stack.pop();
                continue;
            }
            stack.push(s.charAt(i));
        }
        // 栈内没有正确匹配的括号数就是需要添加的对应括号数目
        return stack.size();
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串的长度。每个元素最多需要进出栈各一次。

  • 空间复杂度:O(n),栈只需要常数空间。

贪心

这道题可以使用计数代替栈,进行匹配时每次都取距离当前位置最近的括号,就可以确保平衡。从左到右遍历字符串,在遍历过程中维护左括号的个数以及添加次数。

如果遇到左括号,则将左括号的个数加 1。

如果遇到右括号,则需要和前面的左括号进行匹配,具体做法如下:

  • 如果左括号的个数大于 0,则前面有左括号可以匹配,因此将左括号的个数减 1,表示有一个左括号和当前右括号匹配;

  • 如果左括号的个数等于 0,则前面没有左括号可以匹配,需要添加一个左括号才能匹配,因此将添加次数加 1。

遍历结束后,需要检查左括号的个数是否为 0。如果不为 0,则说明还有剩下的左括号没有匹配,对于每个剩下的左括号都需要添加一个右括号才能匹配,此时需要添加的右括号个数为剩下的左括号个数,将需要添加的右括号个数加到添加次数。

无论是哪种添加的情况,都是在遇到括号无法进行匹配的情况下才进行添加,因此上述做法得到的添加次数是最少的。

java

public class LeetCode_921_greedy_2 {
    public static void main(String[] args) {
        String s = "()))((";
        System.out.println(minAddToMakeValid(s));
    }


    public static int minAddToMakeValid(String s) {
        int res = 0;
        int leftCount = 0;
        // 从左向右遍历遇到左括号则统计数量,遇到右括号则使用最近的左括号匹配,没有则需要添加res+1
        for (char ch : s.toCharArray()) {
            if (ch == '(') {
                leftCount++;
            } else {
                if (leftCount > 0) {
                    leftCount--;
                } else {
                    res++;
                }
            }
        }


        // 遍历结束查看左括号数量,左括号数量>0则没有被匹配,需要添加对应数量右括号来匹配
        return res + leftCount;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串的长度。遍历字符串一次。

  • 空间复杂度:O(1)。只需要维护常量的额外空间。

7、1247.交换字符使得字符串相同

参考:LeetCode-1247. 交换字符使得字符串相同

有两个长度相同的字符串 s1 和 s2,且它们其中 只含有 字符 "x" 和 "y",你需要通过「交换字符」的方式使这两个字符串相同。

每次「交换字符」的时候,你都可以在两个字符串中各选一个字符进行交换。

交换只能发生在两个不同的字符串之间,绝对不能发生在同一个字符串内部。也就是说,我们可以交换 s1[i] 和 s2[j],但不能交换 s1[i] 和 s1[j]。

最后,请你返回使 s1 和 s2 相同的最小交换次数,如果没有方法能够使得这两个字符串相同,则返回 -1 。

示例 1:

输入:s1 = "xx", s2 = "yy"
输出:1
解释:
交换 s1[0] 和 s2[1],得到 s1 = "yx",s2 = "yx"。

示例 2:

输入:s1 = "xy", s2 = "yx"
输出:2
解释:
交换 s1[0] 和 s2[0],得到 s1 = "yy",s2 = "xx" 。
交换 s1[0] 和 s2[1],得到 s1 = "xy",s2 = "xy" 。
注意,你不能交换 s1[0] 和 s1[1] 使得 s1 变成 "yx",因为我们只能交换属于两个不同字符串的字符。

示例 3:

输入:s1 = "xx", s2 = "xy"
输出:-1

提示:

  • 1 <= s1.length, s2.length <= 1000

  • s1.length == s2.length

  • s1, s2 只包含 'x' 或 'y'。

贪心

参考:算法通关手册(LeetCode)

  • 如果 s1==s2,则不需要交换。

  • 如果 s1 = "xx",s2 = "yy",则最少需要交换一次,才可以使两个字符串相等。

  • 如果 s1 = "yy",s2 = "xx",则最少需要交换一次,才可以使两个字符串相等。

  • 如果 s1 = "xy",s2 = "yx",则最少需要交换两次,才可以使两个字符串相等。

  • 如果 s1 = "yx",s2 = "xy",则最少需要交换两次,才可以使两个字符串相等。

则可以总结为:

  • "xx" 与 "yy"、"yy" 与 "xx" 只需要交换一次。

  • "xy" 与 "yx"、"yx" 与 "xy" 需要交换两次。

我们把这两种情况分别进行统计。

  • 当遇到 s1[i]==s2[i] 时直接跳过。

  • 当遇到 s1[i] == 'x',s2[i] == 'y' 时,则统计数量到变量 xyCnt 中。

  • 当遇到 s1[i] == 'y',s2[i] == 'y' 时,则统计数量到变量 yxCnt 中。

则最后我们只需要判断 xyCnt 和 yxCnt 的个数即可。(和非连续的元素组成一对进行交换****)

  • 如果 xyCnt+yxCnt 是奇数,则说明最终会有一个位置上的两个字符无法通过交换相匹配。

  • 如果 xyCnt+yxCnt 是偶数,并且 xyCnt 为偶数,则 yxCnt 也为偶数。则优先交换 "xx" 与 "yy"、"yy" 与 "xx"。即每两个 xyCnt 对应一次交换,每两个 yxCnt 对应交换一次,则结果为xyCnt÷2+yxCnt÷2。

  • 如果 xyCnt+yxCnt 是偶数,并且 xyCnt 为奇数,则 yxCnt 也为奇数。则优先交换 "xx" 与 "yy"、"yy" 与 "xx"。即每两个 xyCnt 对应一次交换,每两个 yxCnt 对应交换一次,则结果为 xyCnt÷2+yxCnt÷2。最后还剩一组 "xy" 与 "yx" 或者 "yx" 与 "xy",则再交换一次,则结果为xyCnt÷2+yxCnt÷2+2。

以上结果可以统一写成 xyCnt÷2+yxCnt÷2+xyCnt % 2×2。

java

public class LeetCode_1247_greedy {
    public static void main(String[] args) {
        String s1 = "xx";
        String s2 = "yy";
        System.out.println(minimumSwap(s1, s2));
    }


    public static int minimumSwap(String s1, String s2) {
        int n = s1.length();
        // 从左到右遍历,统计两个字符串的每一个对应元素不相同的形式数量:xy、yx
        int xyCount = 0;
        int yxCount = 0;
        for (int i = 0; i < n; i++) {
            char a = s1.charAt(i);
            char b = s2.charAt(i);
            if (a == 'x' && b == 'y') {
                xyCount++;
            }
            if (a == 'y' && b == 'x') {
                yxCount++;
            }
        }


        // xy、yx的总和为奇数时无法完成交换
        if ((xyCount + yxCount) % 2 == 1) {
            return -1;
        }


        // xy两两之间构成xx和yy只要交换一次,yx两两之间构成yy和xx只要交换一次(和非连续的元素组成一对进行交换)
        // 奇数个xy、yx无法全部配对成xx、yy或者yy、xx,最后分别剩余一个xy和yx需要进行两次交换
        return xyCount / 2 + yxCount / 2 + (xyCount % 2) * 2;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串的长度。需要遍历两个字符串一遍。

  • 空间复杂度:O(1),只需要常数空间。

8、1400.构造 K 个回文字符串

参考:LeetCode-1400. 构造 K 个回文字符串

给你一个字符串 s 和一个整数 k 。请你用 s 字符串中 所有字符 构造 k 个非空 回文串 。

如果你可以用 s 中所有字符构造 k 个回文字符串,那么请你返回 True ,否则返回 False 。

示例 1:

输入:s = "annabelle", k = 2
输出:true
解释:可以用 s 中所有字符构造 2 个回文字符串。
一些可行的构造方案包括:"anna" + "elble","anbna" + "elle","anellena" + "b"

示例 2:

输入:s = "leetcode", k = 3
输出:false
解释:无法用 s 中所有字符构造 3 个回文串。

示例 3:

输入:s = "true", k = 4
输出:true
解释:唯一可行的方案是让 s 中每个字符单独构成一个字符串。

示例 4:

输入:s = "yzyzyzyzyzyzyzy", k = 2
输出:true
解释:你只需要将所有的 z 放在一个字符串中,所有的 y 放在另一个字符串中。那么两个字符串都是回文串。

示例 5:

输入:s = "cr", k = 7
输出:false
解释:我们没有足够的字符去构造 7 个回文串。

提示:

  • 1 <= s.length <= 10^5

  • s 中所有字符都是小写英文字母。

  • 1 <= k <= 10^5

贪心

由于我们需要根据给定的字符串 s 构造出 k 个非空的回文串,那么一种容易想到的步骤是:

  1. 求出字符串 s 最少可以构造的回文串个数 left;

  2. 求出字符串 s 最多可以构造的回文串个数 right;

  3. 找出在 [left,right] 范围内满足要求的那些值,并判断 k 是否在其中。

对于步骤 2 来说,它的答案很简单。我们设字符串 s 的长度为 ∣s∣,那么显然 s 最多可以构造的回文串个数就是 ∣s∣,即 s 中的每一个字符都单独构成一个回文串。

那么我们如何分析步骤 1 呢?我们需要考虑回文串的性质:回文串分为两类,第一类是长度为奇数,回文中心为一个字符,例如 abcba,abacaba 等;第二类是长度为偶数,回文中心为两个相同的字符,例如 abccba,abaccaba 等。我们可以发现,对于第一类回文串,只有一种字符出现了奇数次,其余所有字符都出现了偶数次;而对于第二类回文串,所有字符都出现了偶数次。

因此,如果 s 中有 p 个字符出现了奇数次,q 个字符出现了偶数次,那么 s 最少可以构造的回文串个数就为 p,这是因为每一种出现了奇数次的字符都必须放在不同的回文串中。特别地,如果 p=0,那么最少构造的回文串个数为 1。

通过简单的分析,我们得到了 left 的值为 max(p,1),right 的值为 ∣s∣。那么最后还剩下步骤 1 了,对于 [left,right] 范围内的值,哪些是满足要求的呢?我们当然希望所有的值都是满足要求的,但这可以实现吗?

我们随意地给出一个回文串 ahykhbhkyha,可以发现,如果将回文中心 b 取出,这样我们就可以得到两个回文串 ahykhhkyha 和 b。接下来,我们将回文中心 hh 中取出一个 h,这样就得到了三个回文串 ahykhkyha,h 和 b。以此类推,最终我们可以得到 11 个回文串(即为初始回文串的长度),每一个回文串的长度均为 1。(针对全都是偶数个回文串ahykhhkyha分析过程也是一样)

因此我们就可以断定:对于 [left,right] 范围内的值,它们都是满足要求的:

  • 我们知道 left 是满足要求的;

  • 如果 x 是满足要求的,并且 x != right,那么我们一定可以找到一个回文串的长度大于 1。我们取出该回文串的回文中心(如果是第一类回文串)或者回文中心其中的一个字符(如果是第二类回文串),单独作为一个长度为 1 的回文串。这样我们就得到了 x+1 个回文串,那么 x+1 也是满足要求的。

通过归纳法,我们证明了上述的结论,因此只要 k 在 [left,right] 中,我们就返回 True,否则返回 False。

java

class Solution {
    public boolean canConstruct(String s, int k) {
        // 右边界为字符串的长度
        int right = s.length();
        // 统计每个字符出现的次数
        int[] occ = new int[26];
        for (int i = 0; i < right; ++i) {
            ++occ[s.charAt(i) - 'a'];
        }
        // 左边界为出现奇数次字符的个数
        int left = 0;
        for (int i = 0; i < 26; ++i) {
            if (occ[i] % 2 == 1) {
                ++left;
            }
        }
        // 注意没有出现奇数次的字符的特殊情况
        left = Math.max(left, 1);
        return left <= k && k <= right;
    }
}

复杂度分析

  • 时间复杂度:O(N+∣Σ∣),其中 N 是字符串 s 的长度,Σ 是字符集(即字符串中可能出现的字符种类数),在本题中字符串只会包含小写字母,因此 ∣Σ∣=26。我们需要对字符串 s 进行一次遍历,得到每个字符出现的次数,时间复杂度为 O(N)。在这之后,我们需要遍历每一种字符,统计出现奇数次的字符数量,时间复杂度为 O(∣Σ∣)。

  • 空间复杂度:O(∣Σ∣)。我们需要使用数组或哈希表存储每个字符出现的次数。

数字贪心

9、402.移掉 K 位数字

参考:LeetCode-402. 移掉 K 位数字

给你一个以字符串表示的非负整数 num 和一个整数 k ,移除这个数中的 k 位数字,使得剩下的数字最小。请你以字符串形式返回这个最小的数字。

示例 1 :

输入:num = "1432219", k = 3
输出:"1219"
解释:移除掉三个数字 4, 3, 和 2 形成一个新的最小的数字 1219 。

示例 2 :

输入:num = "10200", k = 1
输出:"200"
解释:移掉首位的 1 剩下的数字为 200. 注意输出不能有任何前导零。

示例 3 :

输入:num = "10", k = 2
输出:"0"
解释:从原数字移除所有的数字,剩余为空就是 0 。

提示:

  • 1 <= k <= num.length <= 10^5

  • num 仅由若干位数字(0 - 9)组成

  • 除了 0 本身之外,num 不含任何前导零

单调栈+贪心

若要使得剩下的数字最小,需要保证靠前的数字尽可能小

「删除一个数字」的贪心策略:

给定一个长度为 n 的数字序列 [D0D1D2D3…Dn−1],从左往右找到第一个位置 i(i>0)使得 Di<Di−1,并删去 Di−1;如果不存在,说明整个数字序列单调不降,删去最后一个数字即可。(从左到右遍历,删除前一个数字比后一个大的数字)

考虑从左往右增量的构造最后的答案。我们可以用一个栈维护当前的答案序列,栈中的元素代表截止到当前位置,删除不超过 k 次个数字后,所能得到的最小整数。根据之前的讨论:在使用 k 个删除次数之前,栈中的序列从栈底到栈顶单调不降。

单调栈中遇到和栈顶相等的元素时,元素入栈不执行栈顶元素出栈操作,即维护单调不减栈,如 "112" k=1,如果第二个 '1' 要入栈时弹出栈顶 '1',结果就不是最小的。

考虑到栈的特点是后进先出,如果通过栈实现,则需要将栈内元素依次弹出然后进行翻转才能得到最小数。为了避免翻转操作,可以使用双端队列代替栈的实现。
java

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedList;
public class LeetCode_402_stack {
    public static void main(String[] args) {
        String num = "1432219";
        int k = 3;
        System.out.println("最小的数字: " + removeKdigits(num, k));
    }


    public static String removeKdigits(String num, int k) {
        int n = num.length();
        Deque<Character> stack = new LinkedList<>();


        // 遍历每个数字入栈,保证栈内元素单调不减
        for (int i = 0; i < n; i++) {
            char digit = num.charAt(i);
            // 栈顶元素>当前元素时,删除栈顶元素可获得较小的结果
            while (!stack.isEmpty() && k > 0 && stack.peekLast() > digit) {
                stack.pollLast();
                k--;
            }
            stack.offerLast(digit);
        }


        // stack内还需要删除k个元素
        for (int i = 0; i < k; i++) {
            stack.pollLast();
        }


        // 处理前导0,有可能有多个前导0
        StringBuilder res = new StringBuilder();
        boolean leadingZero = true;
        while (!stack.isEmpty()) {
            char digit = stack.pollFirst();
            if (leadingZero && '0' == digit) {
                continue;
            }
            leadingZero = false;
            res.append(digit);
        }


        return res.isEmpty() ? "0" : res.toString();
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为字符串的长度。尽管存在嵌套循环,但内部循环最多运行 k 次。由于 0<k≤n,主循环的时间复杂度被限制在 2n 以内。对于主循环之外的逻辑,它们的时间复杂度是 O(n),因此总时间复杂度为 O(n)。

  • 空间复杂度:O(n)。栈存储数字需要线性的空间。

java-使用字符串模拟栈

public class LeetCocd_402_greedy {
    public static void main(String[] args) {
        String num = "1432219";
        int k = 3;
        System.out.println(removeKdigits(num, k));
    }


    public static String removeKdigits(String num, int k) {
        // 遍历数字每个字符入栈,保证数字单调递增,不满足条件的前一个元素出栈
        StringBuilder sb = new StringBuilder();
        for (char ch : num.toCharArray()) {
            // 前一个元素较大时弹出
            while (!sb.isEmpty() && ch < sb.charAt(sb.length() - 1) && k > 0) {
                sb.deleteCharAt(sb.length() - 1);
                k--;
            }
            sb.append(ch);
        }
        // 当前元素已经时单调递增序列,还差k个元素需要从末尾移除
        while (k > 0) {
            sb.deleteCharAt(sb.length() - 1);
            k--;
        }
        // 去掉前导0
        while (!sb.isEmpty() && sb.charAt(0) == '0') {
            sb.deleteCharAt(0);
        }
        return sb.isEmpty() ? "0" : sb.toString();
    }
}

python

def removeKdigits(num, k):
    stack = []
    # 构建单调递增的数字串
    for digit in num:
        while k and stack and digit < stack[-1]:
            stack.pop()
            k -= 1
        stack.append(digit)
    # 如果k>0,删除末尾的k个字符
    final_stack = stack[:-k] if k else stack
    # 抹去前导零
    return "".join(final_stack).lstrip('0') or "0"

10、670.最大交换

参考:LeetCode-670. 最大交换

给定一个非负整数,你至多可以交换一次数字中的任意两位。返回你能得到的最大值。

示例 1 :

输入: 2736
输出: 7236
解释: 交换数字2和数字7。

示例 2 :

输入: 9973
输出: 9973
解释: 不需要交换。

注意:

  1. 给定数字的范围是 [0, 10^8]

排序+贪心

我们期望获得最大的数字和可以经过排序交换任意次得到的最大数字,前面几位一定相同,这样才可能得到通过一次交换获得最大的数字

  • 针对数字的每一个字符进行降序排序得到最大的数字

  • 只能交换一次所以将左侧第一个不在应该在的位置的数字交换到当前位置。

import java.util.Arrays;
public class LeetCode_670_greedy {
    public static void main(String[] args) {
        int num = 99735818;
        System.out.println(maximumSwap(num));
    }


    public static int maximumSwap(int num) {
        // 针对数字的每一个字符进行降序排序得到最大的数字,只能交换一次所以将左侧第一个不在应该在的位置的数字交换到当前位置
        char[] maxNumCharArr = String.valueOf(num).toCharArray();
        Arrays.sort(maxNumCharArr);
        int n = maxNumCharArr.length;
        for (int i = 0; i < n / 2; i++) {
            char tmp = maxNumCharArr[i];
            maxNumCharArr[i] = maxNumCharArr[n - i - 1];
            maxNumCharArr[n - i - 1] = tmp;
        }


        // 同时顺序遍历排序后的数组和原数字的字符,第一个不相同时进行该位置的数字交换
        StringBuilder numStr = new StringBuilder(String.valueOf(num));
        for (int i = 0; i < n; i++) {
            if (numStr.charAt(i) != maxNumCharArr[i]) {
                // 将最后一个出现较大元素位置和当前i位置交换
                char tmp = numStr.charAt(i);
                int index = numStr.lastIndexOf(String.valueOf(maxNumCharArr[i]));
                numStr.replace(i, i + 1, String.valueOf(maxNumCharArr[i]));
                numStr.replace(index, index + 1, String.valueOf(tmp));
                break;
            }
        }
        return Integer.parseInt(String.valueOf(numStr));
    }
}

复杂度分析

  • 时间复杂度:O(log n),n为num数字字符的长度,排序所需的时间为log n;

  • 空间复杂度:O(n),n为num数字字符的长度。

贪心

参考:670. 最大交换 -LeetCode全解

  • 先将数字转为字符串 numCharArr,然后从右往左遍历字符串 numCharArr,用数组或哈希表 rightMaxIndex 记录每个数字右侧的最大数字的位置(可以是数字本身的位置)。

  • 接着从左到右遍历 rightMaxIndex,如果 numCharArr[i]<numCharArr[rightMaxIndex[i]],则进行交换,并退出遍历的过程。

  • 最后将字符串 numCharArr 转为数字,即为答案。

public class LeetCode_670_greedy_2 {
    public static void main(String[] args) {
        int num = 1993;
        System.out.println(maximumSwap(num));
    }


    public static int maximumSwap(int num) {
        char[] numCharArr = String.valueOf(num).toCharArray();
        int n = numCharArr.length;
        // 从右向左遍历,计算每一个数字右边最大的数字(包含它本身)
        int[] rightMaxIndex = new int[n];
        rightMaxIndex[n - 1] = n - 1;
        for (int i = n - 2; i >= 0; i--) {
            // 这里需要取等号,寻找最右边的最后一个最大数字
            if (numCharArr[i] <= numCharArr[rightMaxIndex[i + 1]]) {
                rightMaxIndex[i] = rightMaxIndex[i + 1];
            } else {
                rightMaxIndex[i] = i;
            }
        }


        // 从左向右遍历原字符串数组,遇到第一个数字和其右边最大数字不相同的进行交换
        for (int i = 0; i < n; i++) {
            int j = rightMaxIndex[i];
            if (numCharArr[j] != numCharArr[i]) {
                char tmp = numCharArr[i];
                numCharArr[i] = numCharArr[j];
                numCharArr[j] = tmp;
                // 只进行一次交换
                break;
            }
        }
        return Integer.parseInt(String.valueOf(numCharArr));
    }
}

复杂度分析

  • 时间复杂度:O(log n),n为num数字字符的长度,遍历和交换操作都是常数级别;

  • 空间复杂度:O(n),n为num数字字符的长度。

11、738.单调递增的数字

参考:LeetCode-738. 单调递增的数字

给定一个非负整数 N,找出小于或等于 N 的最大的整数,同时这个整数需要满足其各个位数上的数字是单调递增。(当且仅当每个相邻位数上的数字 x 和 y 满足 x <= y 时,我们称这个整数是单调递增的。)

示例 1:

输入: n = 10
输出: 9

示例 2:

输入: n = 1234
输出: 1234

示例 3:

输入: n = 332
输出: 299

提示:

  • 0 <= n <= 10^9

贪心

例如:98,一旦出现strNum[i - 1] > strNum[i]的情况(非单调递增),首先想让strNum[i - 1]--,然后strNum[i]给为9,这样这个整数就是89,即小于98的最大的单调递增整数。

  • 局部最优:遇到strNum[i - 1] > strNum[i]的情况,让strNum[i - 1]--,然后strNum[i]给为9,可以保证这两位变成最大单调递增整数。

  • 全局最优:得到小于等于N的最大单调递增的整数。

但这里局部最优推出全局最优,还需要其他条件,即遍历顺序,和标记从哪一位开始统一改成9。

此时是从前向后遍历还是从后向前遍历呢?

  • 从前向后遍历的话,遇到strNum[i - 1] > strNum[i]的情况,让strNum[i - 1]减一,但此时如果strNum[i - 1]减一了,可能又小于strNum[i - 2]。

  • 这么说有点抽象,举个例子,数字:332,从前向后遍历的话,那么就把变成了329,此时2又小于了第一位的3了,真正的结果应该是299。

所以从前后向遍历会改变已经遍历过的结果!

那么从后向前遍历,就可以重复利用上次比较得出的结果了,从后向前遍历332的数值变化为:332 -> 329 -> 299

java

import java.util.Arrays;
public class LeetCode_738_greedy {
    public static void main(String[] args) {
        int n = 332;
        System.out.println(monotoneIncreasingDigits(n));
    }


    public static int monotoneIncreasingDigits(int n) {
        char[] digitList = Integer.toString(n).toCharArray();
        int len = digitList.length;


        // 从后向前遍历每个数字
        for (int i = len - 1; i > 0; i--) {
            // 如果前一个数大于当前数字,前一个数字-1,后面的数字全部置为9
            if (digitList[i - 1] > digitList[i]) {
                digitList[i - 1] = (char) ((int) digitList[i - 1] - 1);
                Arrays.fill(digitList, i, len, '9');
            }
        }
        return Integer.parseInt(String.valueOf(digitList));
    }
}

复杂度分析

  • 时间复杂度:O(n),n 为数字长度

  • 空间复杂度:O(n),需要一个字符串,转化为字符串操作更方便

python

def monotoneIncreasingDigits(n: int) -> int:
    # string不可变,转为list
    a = list(str(n))
    
    # 从后向前遍历
    for i in range(len(a) - 1, 0, -1):
        # 前一个数字小于后一个数字
        if int(a[i]) < int(a[i - 1]):
            # 前一个数字减1
            a[i - 1] = str(int(a[i - 1]) - 1)
            # 从i开始之后全部赋值为'9'
            a[i:] = '9' * (len(a) - i)
    return int("".join(a)) 

12、861.翻转矩阵后的得分

参考:LeetCode-861. 翻转矩阵后的得分

给你一个大小为 m x n 的二元矩阵 grid ,矩阵中每个元素的值为 0 或 1 。

一次 移动 是指选择任一行或列,并转换该行或列中的每一个值:将所有 0 都更改为 1,将所有 1 都更改为 0。

在做出任意次数的移动后,将该矩阵的每一行都按照二进制数来解释,矩阵的 得分 就是这些数字的总和。

在执行任意次 移动 后(含 0 次),返回可能的最高分数。

示例 1:

图片

输入:grid = [[0,0,1,1],[1,0,1,0],[1,1,0,0]]
输出:39
解释:0b1111 + 0b1001 + 0b1111 = 15 + 9 + 15 = 39

示例 2:

输入:grid = [[0]]
输出:1

提示:

  • m == grid.length

  • n == grid[i].length

  • 1 <= m, n <= 20

  • grid[i][j] 为 0 或 1

贪心-修改原数组

针对行列进行如下转化,可以使得总和最大:

  • 为了得到最高的分数,矩阵的每一行的最左边的数都必须为 1。为了做到这一点,我们可以翻转那些最左边的数不为 1 的那些行,而其他的行则保持不动。

  • 当将每一行的最左边的数都变为 1 之后,就只能列翻转了。为了使得总得分最大,我们要让每个列中 1 的数目尽可能多。因此,我们扫描除了最左边的列以外的每一列,如果该列 0 的数目多于 1 的数目,就翻转该列,其他的列则保持不变。

按照如上步骤直接修改原数组,以最直观的方式实现代码:

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
public class LeetCode_861_greedy {
    public static void main(String[] args) {
        int[][] grid = {{0, 0, 1, 1}, {1, 0, 1, 0}, {1, 1, 0, 0}};
        System.out.println(matrixScore(grid));
    }


    public static int matrixScore(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        Map<Integer, Integer> convertMap = new HashMap<>();
        convertMap.put(1, 0);
        convertMap.put(0, 1);


        // 遍历矩阵的第一列,为0的元素所在行进行转化
        for (int i = 0; i < m; i++) {
            if (grid[i][0] != 0) {
                continue;
            }
            for (int j = 0; j < n; j++) {
                grid[i][j] = convertMap.get(grid[i][j]);
            }
        }


        // 遍历矩阵的每一列,为0的元素数量超过一半则所在列进行转化
        for (int j = 1; j < n; j++) {
            int colSum = 0;
            for (int i = 0; i < m; i++) {
                colSum += grid[i][j];
            }
            if (2 * colSum >= m) {
                continue;
            }
            for (int i = 0; i < m; i++) {
                grid[i][j] = convertMap.get(grid[i][j]);
            }
        }


        // 按照行计算每个二进制的数值并累加
        int res = 0;
        for (int i = 0; i < m; i++) {
            String rowStr = Arrays.stream(grid[i]).mapToObj(String::valueOf).collect(Collectors.joining());
            res += Integer.parseInt(rowStr, 2);
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(mn),其中 m 为矩阵行数,n 为矩阵列数。

  • 空间复杂度:O(1)。

贪心-不改原数组

针对行列进行如下转化,可以使得总和最大:

  • 为了得到最高的分数,矩阵的每一行的最左边的数都必须为 1。为了做到这一点,我们可以翻转那些最左边的数不为 1 的那些行,而其他的行则保持不动。

  • 当将每一行的最左边的数都变为 1 之后,就只能列翻转了。为了使得总得分最大,我们要让每个列中 1 的数目尽可能多。因此,我们扫描除了最左边的列以外的每一列,如果该列 0 的数目多于 1 的数目,就翻转该列,其他的列则保持不变。

实际编写代码时,我们无需修改原矩阵,而是可以计算每一列对总分数的「贡献」,从而直接计算出最高的分数。假设矩阵共有 m 行 n 列,计算方法如下:

  • 对于最左边的列而言,由于最优情况下,它们的取值都为 1,因此每个元素对分数的贡献都为 2^(n−1),总贡献为 m×2^(n−1)。

  • 对于第 j 列(j>0,此处规定最左边的列是第 0 列)而言,我们统计这一列 0,1 的数量,令其中的最大值为 k,则 k 是列翻转后的 1 的数量,该列的总贡献为 k×2^(n−j−1)。需要注意的是,在统计 0,1 的数量的时候,要考虑最初进行的行反转。

java

public class LeetCode_861_greedy_2 {
    public static void main(String[] args) {
        int[][] grid = {{0, 0, 1, 1}, {1, 0, 1, 0}, {1, 1, 0, 0}};
        System.out.println(matrixScore(grid));
    }


    public static int matrixScore(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;


        // 第一列的元素都是1,直接计算加入总和中
        int res = m * (1 << (n - 1));


        // 矩阵的第一列为0的元素所在行都需要进行转化,转化之后每一列0数量过半的也需要进行转化
        // 可以按照列进行计算,相同列对总数贡献是一样的2^(n-j-1),只要统计每一列的1的个数就能计算出当前列的总
        // 注意要考虑行是否被转化过,其次1的个数少于列元素数的一半时也需要转化


        // 遍历矩阵的每一列
        for (int j = 1; j < n; j++) {
            // 统计当前列进过最终转化后1的个数(行转化)
            int nOne = 0;
            for (int i = 0; i < m; i++) {
                // 判断当前行是否被转化过
                if (grid[i][0] == 1) {
                    nOne += grid[i][j];
                } else {
                    // 如果这一行进行了行反转,则该元素的实际取值为 1 - grid[i][j]
                    nOne += (1 - grid[i][j]);
                }
            }
            // 判断当前列是否需要转化,无论是否转化取较大的值
            nOne = Math.max(nOne, m - nOne);
            // 当前列的总和加入结果中
            res += nOne * (1 << (n - j - 1));
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(mn),其中 m 为矩阵行数,n 为矩阵列数。

  • 空间复杂度:O(1)。

序列贪心

13、1005.K次取反后最大化的数组和

参考:LeetCode-1005. K 次取反后最大化的数组和

给定一个整数数组 A,我们只能用以下方法修改该数组:我们选择某个索引 i 并将 A[i] 替换为 -A[i],然后总共重复这个过程 K 次。(我们可以多次选择同一个索引 i。)

以这种方式修改数组后,返回数组可能的最大和。

示例 1:

输入:nums = [4,2,3], k = 1
输出:5
解释:选择下标 1 ,nums 变为 [4,-2,3] 。

示例 2:

输入:nums = [3,-1,0,2], k = 3
输出:6
解释:选择下标 (1, 2, 2) ,nums 变为 [3,1,0,2] 。

示例 3:

输入:nums = [2,-3,-1,5,-4], k = 2
输出:13
解释:选择下标 (1, 4) ,nums 变为 [2,3,-1,5,4] 。

提示:

  • 1 <= nums.length <= 10^4

  • -100 <= nums[i] <= 100

  • 1 <= k <= 10^4

优先队列

  • 构建优先队列,小的元素在队首;

  • 循环弹出队首最小元素,取反后重新加入队列。k次循环后就是最终的元素集合;

  • 求和。

import java.util.PriorityQueue;
public class LeetCode_1005_1_greedy {
    public static void main(String[] args) {
        int[] nums = {3, -1, 0, 2};
        int k = 3;
        System.out.println(largestSumAfterKNegations(nums, k));
    }


    public static int largestSumAfterKNegations(int[] nums, int k) {
        // 构建优先队列,小的元素在队首
        PriorityQueue<Integer> queue = new PriorityQueue<>();
        for (int num : nums) {
            queue.offer(num);
        }
        // 循环弹出队首最小元素,取反后重新加入队列
        for (int i = 0; i < k; i++) {
            int tmp = -1 * queue.poll();
            queue.offer(tmp);
        }
        // 计算元素和
        int res = 0;
        while(!queue.isEmpty()) {
            res += queue.poll();
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度: O((n+k)logn),构建优先队列的时间复杂度;

  • 空间复杂度: O(n),优先队列存储数组元素所额外使用的空间。

贪心

由于我们希望数组的和尽可能大,因此除非万不得已,我们应当总是修改负数,并且优先修改值最小的负数。因为将负数 -x 修改成 x 会使得数组的和增加 2x,所以这样的贪心操作是最优的。

当给定的 K 小于等于数组中负数的个数时,我们按照上述方法从小到大依次修改每一个负数即可。但如果 K 的值较大,那么我们不得不去修改非负数(即正数或者 0)了。由于修改 0 对数组的和不会有影响,而修改正数会使得数组的和减小,因此:

  • 如果数组中存在 0,那么我们可以对它进行多次修改,直到把剩余的修改次数用完;

  • 如果数组中不存在 0 并且剩余的修改次数是偶数,由于对同一个数修改两次等价于不进行修改,因此我们也可以在不减小数组的和的前提下,把修改次数用完;

  • 如果数组中不存在 0 并且剩余的修改次数是奇数,那么我们必然需要使用单独的一次修改将一个正数变为负数(剩余的修改次数为偶数,就不会减小数组的和)。为了使得数组的和尽可能大,我们就选择那个最小的正数。

需要注意的是,在之前将负数修改为正数的过程中,可能出现了(相较于原始数组中最小的正数)更小的正数,这一点不能忽略。

细节

为了实现上面的算法,我们可以对数组进行排序,首先依次遍历每一个负数(将负数修改为正数),再遍历所有的数(将 0 或最小的正数进行修改)。

java

import java.util.Arrays;
public class LeetCode_1005_1_greedy {
    public static void main(String[] args) {
        int[] nums = {-2, 9, 9, 8, 4};
        int k = 5;
        System.out.println(largestSumAfterKNegations(nums, k));
    }


    public static int largestSumAfterKNegations(int[] nums, int k) {
        Integer[] arr = Arrays.stream(nums).boxed().toArray(Integer[]::new);
        // 根据元素绝对值降序排序
        Arrays.sort(arr, (a, b) -> Math.abs(b) - Math.abs(a));
        // 遍历数组,从左到右将负数取反(k次以内)
        for (int i = 0; i < arr.length && k > 0; i++) {
            if (arr[i] < 0) {
                k--;
                arr[i] = -arr[i];
            }
        }


        // k还有剩余,将最后一个最小的元素进行剩余k次取反
        if (k % 2 == 1) {
            arr[arr.length - 1] *= -1;
        }
        return Arrays.stream(arr).mapToInt(Integer::intValue).sum();
    }
}

复杂度分析

  • 时间复杂度: O(nlogn),数组排序的时间复杂度。

  • 空间复杂度: O(1)

python

def largestSumAfterKNegations(A: list, K: int) -> int:
    # 将A按绝对值从大到小排列
    A = sorted(A, key=abs, reverse=True)
    for i in range(len(A)):
        # 优先将绝对值较大的负数转为正数
        if K > 0 and A[i] < 0:
            A[i] *= -1
            K -= 1
    # 负数已经全部转正,还有K次未用完
    if K > 0:
        A[-1] *= (-1) ** K  # 取A最后一个数只需要写-1
    return sum(A)

14、53.最大子序和

参考:LeetCode-53. 最大子数组和

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例 1:

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

示例 2:

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

示例 3:

输入:nums = [5,4,-1,7,8]
输出:23

提示:

  • 1 <= nums.length <= 10^5

  • -10^4 <= nums[i] <= 10^4

动态规划

用 f(i) 代表以第 i 个数结尾的「连续子数组的最大和」,我们只需要求出每个位置的 f(i),然后返回 f 数组中的最大值即可。那么我们如何求 f(i) 呢?

可以考虑 nums[i] 单独成为一段还是加入 f(i−1) 对应的那一段,这取决于 nums[i] 和 f(i−1)+nums[i] 的大小,我们希望获得一个比较大的,于是可以写出这样的动态规划转移方程:f(i)=max{f(i−1)+nums[i], nums[i]}

public class LeetCode_53_1_dp {
    public static void main(String[] args) {
        int[] nums = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
        System.out.println(maxSubArray(nums));
    }


    public static int maxSubArray(int[] nums) {
        // 以第i个元素为最后一个元素的连续子序列的最大和,dp[i] = max(dp[i-1] + nums[i], nums[i])
        int dp = nums[0];
        int res = dp;
        for (int num : nums) {
            // i元素有两个选择:当前元素加入前面的序列、当前元素单独作为一个序列
            dp = Math.max(dp + num, num);
            res = Math.max(res, dp);
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为 nums 数组的长度。我们只需要遍历一遍数组即可求得答案。

  • 空间复杂度:O(1)。我们只需要常数空间存放若干变量

python

def maxSubArray( nums):
    if len(nums) == 0:
        return 0
    dp = [0] * len(nums)
    dp[0] = nums[0]
    result = dp[0]
    for i in range(1, len(nums)):
        dp[i] = max(dp[ i -1] + nums[i], nums[i])  # 状态转移公式
        result = max(result, dp[i])  # result 保存dp[i]的最大值
    return result

贪心

参考:leetCode 53.最大子数和 图解 + 贪心算法/动态规划+优化

图片
如果 -2 1 在一起,计算起点的时候,一定是从1开始计算,因为负数只会拉低总和,这就是贪心贪的地方!

  • 局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。

  • 全局最优:选取最大“连续和”

局部最优的情况下,并记录最大的“连续和”,可以推出全局最优。

从代码角度上来讲:

遍历nums,从头开始用count累积,如果count一旦加上nums[i]变为负数,那么就应该从nums[i+1]开始从0累积count了,因为已经变为负数的count,只会拖累总和。

这相当于是暴力解法中的不断调整最大子序和区间的起始位置。

那有同学问了,区间终止位置不用调整么? 如何才能得到最大“连续和”呢?

区间的终止位置,其实就是如果count取到最大值了,及时记录下来了

java

public class LeetCode_53_2_greedy {
    public static void main(String[] args) {
        int[] nums = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
        System.out.println(maxSubArray(nums));
    }


    public static int maxSubArray(int[] nums) {
        int res = Integer.MIN_VALUE;
        // 当子序列和<0时,就没必要把下一个元素加进来,和负数相加只会变得更小,直接从下一个元素开始累计
        int curSum = 0;
        for (int num : nums) {
            // 子序列和<0则从下一个元素开始累计
            curSum = curSum < 0 ? num : curSum + num;
            res = Math.max(res, curSum);
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为 nums 数组的长度。我们只需要遍历一遍数组即可求得答案。

  • 空间复杂度:O(1)。

python

def maxSubArray(nums: list) -> int:
    result = -float('inf')
    count = 0
    for i in range(len(nums)):
        count += nums[i]
        # 记录子序列最大和
        if count > result:
            result = count
        # 和为负责重置和,以下一个点为起点继续遍历
        if count <= 0:
            count = 0
    return result

15、406.根据身高重建队列

参考:LeetCode-406. 根据身高重建队列

假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。

请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。

示例 1:

输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
解释:
编号为 0 的人身高为 5 ,没有身高更高或者相同的人排在他前面。
编号为 1 的人身高为 7 ,没有身高更高或者相同的人排在他前面。
编号为 2 的人身高为 5 ,有 2 个身高更高或者相同的人排在他前面,即编号为 0 和 1 的人。
编号为 3 的人身高为 6 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
编号为 4 的人身高为 4 ,有 4 个身高更高或者相同的人排在他前面,即编号为 0、1、2、3 的人。
编号为 5 的人身高为 7 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
因此 [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]] 是重新构造后的队列。

示例 2:

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

提示:

  • 1 <= people.length <= 2000

  • 0 <= hi <= 10^6

  • 0 <= ki < people.length

  • 题目数据确保队列可以被重建

排序+贪心

本题有两个维度,h和k。遇到两个维度权衡的时候,一定要先确定一个维度,再确定另一个维度。如果两个维度一起考虑一定会顾此失彼。

对于本题相信大家困惑的点是先确定k还是先确定h呢,也就是究竟先按h排序呢,还先按照k排序呢?

  • 如果按照k来从小到大排序,排完之后,会发现k的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。

  • 那么按照身高h来排序呢,身高一定是从大到小排(身高相同的话则k小的站前面),让高个子在前面。

此时我们可以确定一个维度了,就是身高,前面的节点一定都比本节点高!

那么只需要按照k为下标重新插入队列就可以了,为什么呢?

以图中{5,2} 为例:

图片
按照身高排序之后,优先按身高高的people的k来插入,后序插入节点也不会影响前面已经插入的节点,最终按照k的规则完成了队列。

所以在按照身高从大到小排序后:

  • 局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性

  • 全局最优:最后都做完插入操作,整个队列满足题目队列属性

回归本题,整个插入过程如下:

排序完的people: [[7,0], [7,1], [6,1], [5,0], [5,2],[4,4]]

插入的过程:

  • 插入[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]]

此时就按照题目的要求完成了重新排列。

java

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class LeetCode_406_greedy {
    public static void main(String[] args) {
        int[][] people = {{7, 0}, {4, 4}, {7, 1}, {5, 0}, {6, 1}, {5, 2}};
        System.out.println(Arrays.deepToString(reconstructQueue(people)));
    }


    public static int[][] reconstructQueue(int[][] people) {
        // 按照身高h降序、序数k升序对数组进行排序
        Arrays.sort(people, (people1, people2) -> {
            if (people1[0] != people2[0]) {
                return people2[0] - people1[0];
            }
            return people1[1] - people2[1];
        });


        // 按照排序后的顺序,将元素插入结果中的索引为k的位置(先插入的数据较大,所以不会受后插入数据影响)
        int n = people.length;
        List<int[]> res = new ArrayList<>(n);
        for (int[] person : people) {
            res.add(person[1], person);
        }
        return res.toArray(new int[n][]);
    }
}

复杂度分析

  • 时间复杂度:O(n^2),其中 n 是数组 people 的长度。我们需要 O(nlogn) 的时间进行排序,随后需要 O(n^2) 的时间遍历每一个人并将他们放入队列中。由于前者在渐近意义下小于后者,因此总时间复杂度为 O(n^2)。

  • 空间复杂度:O(logn)。

python

def reconstructQueue(people: list) -> list:
    # 先按照h维度的身高顺序从高到低排序。确定第一个维度
    # lambda返回的是一个元组:当-x[0](维度h)相同时,再根据x[1](维度k)从小到大排序
    people.sort(key=lambda x: (-x[0], x[1]))
    que = []
    # 根据每个元素的第二个维度k,贪心算法,进行插入
    # people已经排序过了:同一高度时k值小的排前面。
    for p in people:
        que.insert(p[1], p)
    return que

16、376.摆动序列

参考:LeetCode-376. 摆动序列

如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为摆动序列。第一个差(如果存在的话)可能是正数或负数。少于两个元素的序列也是摆动序列。

  • 例如, [1, 7, 4, 9, 2, 5] 是一个 摆动序列 ,因为差值 (6, -3, 5, -7, 3) 是正负交替出现的。

  • 相反,[1, 4, 7, 2, 5] 和 [1, 7, 4, 5, 5] 不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。

子序列 可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。给你一个整数数组 nums ,返回 nums 中作为 摆动序列 的 最长子序列的长度 。

示例 1:

输入:nums = [1,7,4,9,2,5]
输出:6
解释:整个序列均为摆动序列,各元素之间的差值为 (6, -3, 5, -7, 3) 。

示例 2:

输入:nums = [1,17,5,10,13,15,10,5,16,8]
输出:7
解释:这个序列包含几个长度为 7 摆动序列。
其中一个是 [1, 17, 10, 13, 10, 16, 8] ,各元素之间的差值为 (16, -7, 3, -3, 6, -8) 。

示例 3:

输入:nums = [1,2,3,4,5,6,7,8,9]
输出:2

提示:

  • 1 <= nums.length <= 1000

  • 0 <= nums[i] <= 1000

思路

解决本题前,我们先进行一些约定:

  • 某个序列被称为「上升摆动序列」,当且仅当该序列是摆动序列,且最后一个元素呈上升趋势。如序列 [1,3,2,4] 即为「上升摆动序列」。

  • 某个序列被称为「下降摆动序列」,当且仅当该序列是摆动序列,且最后一个元素呈下降趋势。如序列 [4,2,3,1] 即为「下降摆动序列」。

  • 特别地,对于长度为 1 的序列,它既是「上升摆动序列」,也是「下降摆动序列」。

  • 序列中的某个元素被称为「峰」,当且仅当该元素两侧的相邻元素均小于它。如序列 [1,3,2,4] 中,3 就是一个「峰」。

  • 序列中的某个元素被称为「谷」,当且仅当该元素两侧的相邻元素均大于它。如序列 [1,3,2,4] 中,2 就是一个「谷」。

  • 特别地,对于位于序列两端的元素,只有一侧的相邻元素小于或大于它,我们也称其为「峰」或「谷」。如序列 [1,3,2,4] 中,1 也是一个「谷」,4 也是一个「峰」。

  • 因为一段相邻的相同元素中我们最多只能选择其中的一个,所以我们可以忽略相邻的相同元素。现在我们假定序列中任意两个相邻元素都不相同,即要么左侧大于右侧,要么右侧大于左侧。对于序列中既非「峰」也非「谷」的元素,我们称其为「过渡元素」。如序列 [1,2,3,4] 中,2 和 3 都是「过渡元素」。

贪心

只需要统计该序列中「峰」与「谷」的数量即可(注意序列两端的数也是「峰」或「谷」),但需要注意处理相邻的相同元素。

在实际代码中,我们记录当前序列的上升下降趋势。每次加入一个新元素时,用新的上升下降趋势与之前对比,如果出现了「峰」或「谷」,答案加一,并更新当前序列的上升下降趋势

用示例二来举例,如图所示:

图片

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

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

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

这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点。

数组最左面和最右面是最不好统计的。例如序列[2,5],它的峰值数量是2,如果靠统计差值来计算峰值个数就需要考虑数组最左面和最右面的特殊情况。

所以可以针对序列[2,5],可以假设为[2,2,5],这样它就有坡度了即preDiff = 0,如图:

图片
java

public class LeetCode_376_1_greedy {
    public static void main(String[] args) {
        int[] nums = {1, 7, 4, 9, 2, 5};
        System.out.println(wiggleMaxLength(nums));
    }


    public static int wiggleMaxLength(int[] nums) {
        int n = nums.length;
        if (n < 2) {
            return n;
        }


        // 计算局部区间的峰、谷数量,就是最终的摆动序列的最长子序列
        int prediff = nums[1] - nums[0];
        int res = prediff == 0 ? 1 : 2;
        int curdiff;
        for (int i = 2; i < n; i++) {
            curdiff = nums[i] - nums[i-1];
            // 锋、谷交错,和前一个差值正负值相反
            if ((curdiff > 0 && prediff <= 0) || (curdiff < 0 && prediff >= 0)) {
                res++;
                prediff = curdiff;
            }
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是序列的长度。我们只需要遍历该序列一次。

  • 空间复杂度:O(1)。我们只需要常数空间来存放若干变量

python

从后向前遍历:

针对以上情形,result初始为1(默认最右面有一个峰值),此时curDiff > 0 && preDiff <= 0,那么result++(计算了左面的峰值),最后得到的result就是2(峰值个数为2即摆动序列长度为2)

def wiggleMaxLength(nums: list) -> int:
    # 题目里nums长度大于等于1,当长度为1时,其实到不了for循环里去,所以不用考虑nums长度
    preC, curC, res = 0, 0, 1
    for i in range(len(nums) - 1):
        curC = nums[i + 1] - nums[i]
        if curC * preC <= 0 and curC != 0:  # 差值为0时,不算摆动
            res += 1
            # 如果当前差值和上一个差值为一正一负时,才需要用当前差值替代上一个差值
            preC = curC
    return res

动态规划

参考:leetcode(力扣) 376. 摆动序列 (贪心 & 动态规划)

每当我们选择一个元素作为摆动序列的一部分时,这个元素要么是上升的,要么是下降的,这取决于前一个元素的大小。那么列出状态表达式为:

  • up[i] 表示以前 i 个元素中的某一个为结尾的最长的「上升摆动序列」的长度。

  • down[i] 表示以前 i 个元素中的某一个为结尾的最长的「下降摆动序列」的长度。

图片

  • 序列中的某个元素被称为“峰”,当且仅当该元素两侧的相邻元素均小于它。例如序列[1,3,2,4]中的 3 就是一个“峰”

  • 序列中的某个元素被称为“谷”,当且仅当该元素两侧的相邻元素均大于它。例如序列[1,3,2,4]中的2 就是一个“谷”

  • 特别地,对于位于序列两端的元素,只有一侧的相邻元素小于或大于它,也称其为“峰”“谷”。如序列[1,3,2,4]中,1 也是一个“谷”4 是一个“峰”

状态定义:

我们可以定义两个状态:

  • up[i]:表示以 nums[i] 结尾,并且 nums[i] 是上升的摆动序列的最长长度。

  • down[i]:表示以 nums[i] 结尾,并且 nums[i] 是下降的摆动序列的最长长度。

状态转移:

对于每一个 i,我们需要考虑所有 j < i 的情况:

  • 如果 nums[i] > nums[j],那么 nums[i] 可以接在以 nums[j] 结尾的下降序列后面形成一个更长的上升序列,山谷-山峰 ,此时最长长度+1,要么就维持原状也就是 山峰 -山峰,因此 up[i] = max(up[i], down[j] + 1)。

  • 如果 nums[i] < nums[j],那么 nums[i] 可以接在以 nums[j] 结尾的上升序列后面形成一个更长的下降序列,变成 山峰-山谷,此时最长长度+1,要么就是维持原状, 山谷-山谷,因此 down[i] = max(down[i], up[j] + 1)。

初始化时,每个位置 i 的 up[i] 和 down[i] 都至少为1,因为单个数字本身就可以构成一个长度为1的摆动序列。

public class LeetCode_376_2_dp {
    public static void main(String[] args) {
        int[] nums = {1, 7, 4, 9, 2, 5};
        System.out.println(wiggleMaxLength(nums));
    }


    public static int wiggleMaxLength(int[] nums) {
        int n = nums.length;
        if (n < 2) {
            return n;
        }


        // 计算以每个元素结尾的上摆、下摆序列的最长长度
        int[] up = new int[n];
        int[] down = new int[n];
        up[0] = 1;
        down[0] = 1;
        for (int i = 1; i < n; i++) {
            if (nums[i] > nums[i - 1]) {
                up[i] = Math.max(up[i - 1], down[i - 1] + 1);
                down[i] = down[i - 1];
            } else if (nums[i] < nums[i - 1]) {
                down[i] = Math.max(down[i - 1], up[i - 1] + 1);
                up[i] = up[i - 1];
            } else {
                up[i] = up[i - 1];
                down[i] = down[i - 1];
            }
        }
        return Math.max(down[n - 1], up[n - 1]);
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是序列的长度。我们只需要遍历该序列一次。

  • 空间复杂度:O(n),其中 n 是序列的长度。我们需要开辟两个长度为 n 的数组

动态规划-空间优化

和方法一相比,仅需要前一个状态来进行转移,所以我们维护两个变量即可。

public class LeetCode_376_2_dp {
    public static void main(String[] args) {
        int[] nums = {1, 7, 4, 9, 2, 5};
        System.out.println(wiggleMaxLength(nums));
    }


    public static int wiggleMaxLength(int[] nums) {
        int n = nums.length;
        if (n < 2) {
            return n;
        }


        // 计算以每个元素结尾的上摆、下摆序列的最长长度
        int up = 1;
        int down = 1;
        for (int i = 1; i < n; i++) {
            if (nums[i] > nums[i - 1]) {
                up = Math.max(up, down + 1);
            } else if (nums[i] < nums[i - 1]) {
                down = Math.max(down, up + 1);
            }
        }
        return Math.max(down, up);
    }
}

注意到每有一个「峰」到「谷」的下降趋势,down 值才会增加,每有一个「谷」到「峰」的上升趋势,up 值才会增加。且过程中 down 与 up 的差的绝对值值恒不大于 1,即 up≤down+1 且 down≤up+1,于是有 max(up,down+1)=down+1 且 max(up+1,down)=up+1。这样我们可以省去不必要的比较大小的过程。

public class LeetCode_376_2_dp {
    public static void main(String[] args) {
        int[] nums = {1, 7, 4, 9, 2, 5};
        System.out.println(wiggleMaxLength(nums));
    }


    public static int wiggleMaxLength(int[] nums) {
        int n = nums.length;
        if (n < 2) {
            return n;
        }


        // 计算以每个元素结尾的上摆、下摆序列的最长长度
        int up = 1;
        int down = 1;
        for (int i = 1; i < n; i++) {
            if (nums[i] > nums[i - 1]) {
                up = down + 1;
            } else if (nums[i] < nums[i - 1]) {
                down = up + 1;
            }
        }
        return Math.max(down, up);
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是序列的长度。我们只需要遍历该序列一次;

  • 空间复杂度:O(1)。我们只需要常数空间来存放若干变量。

17、1029.两地调度

参考:LeetCode-1029. 两地调度

公司计划面试 2n 人。给你一个数组 costs ,其中 costs[i] = [aCosti, bCosti] 。第 i 人飞往 a 市的费用为 aCosti ,飞往 b 市的费用为 bCosti 。

返回将每个人都飞到 a 、b 中某座城市的最低费用,要求每个城市都有 n 人抵达。

示例 1:

输入:costs = [[10,20],[30,200],[400,50],[30,20]]
输出:110
解释:
第一个人去 a 市,费用为 10。
第二个人去 a 市,费用为 30。
第三个人去 b 市,费用为 50。
第四个人去 b 市,费用为 20。


最低总费用为 10 + 30 + 50 + 20 = 110,每个城市都有一半的人在面试。

示例 2:

输入:costs = [[259,770],[448,54],[926,667],[184,139],[840,118],[577,469]]
输出:1859

示例 3:

输入:costs = [[515,563],[451,713],[537,709],[343,819],[855,779],[457,60],[650,359],[631,42]]
输出:3086

提示:

  • 2 * n == costs.length

  • 2 <= costs.length <= 100

  • costs.length 为偶数

  • 1 <= aCosti, bCosti <= 1000

贪心

我们先假设所有人都去了城市 a。然后令一半的人再去城市 b。现在的问题就变成了,让一半的人改变城市去向,从原本的 a 城市改成 b 城市的最低费用为多少。

已知第 i 个人更换去向的费用为「去城市 b 的费用 - 去城市 a 的费用」。所以我们可以根据「去城市 b 的费用 - 去城市 a 的费用」对数组 costs 进行排序,让前 n 个改变方向去城市 b,后 n 个人去城市 a。

最后统计所有人员的费用,将其返回即可。

可以考虑成所有人都去了a,现在需要把n个人从a调到b,当前都去a地的总花费已经确定,最终目的是使得总花费最小。调去b的n人 花费bi如果比ai更小总花费就能减少bi-ai,所以根据bi-ai的数值进行排序,尽可能小的bi-ai可以使得总花费最小。
java

import java.util.Arrays;
import java.util.Comparator;
public class LeetCode_1029_greedy {
    public static void main(String[] args) {
        int[][] costs = {{259, 770}, {448, 54}, {926, 667}, {184, 139}, {840, 118}, {577, 469}};
        System.out.println(twoCitySchedCost(costs));
    }


    public static int twoCitySchedCost(int[][] costs) {
        int n = costs.length / 2;
        // 假设2n个人先去a,n个人再改签去b,改签费用最少的n个人去b可以使得总花费最小
        // 按照bi-ai进行升序排序,排在前面的是改签去b花费最小的n个人
        Arrays.sort(costs, Comparator.comparingInt(cost -> cost[1] - cost[0]));
        // 前n个人去b,后n个人去a
        int res = 0;
        for (int i = 0; i < n; i++) {
            res += costs[i][1];
            res += costs[i + n][0];
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(NlogN),需要对 price_A - price_B 进行排序。

  • 空间复杂度:O(1)。

区间贪心

18、55.跳跃游戏

参考:LeetCode-55. 跳跃游戏

给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个下标,如果可以,返回 true ;否则,返回 false 。

示例 1:

输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。

示例 2:

输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。

提示:

  • 1 <= nums.length <= 10^4

  • 0 <= nums[i] <= 10^5

贪心

刚看到本题一开始可能想:当前位置元素如果是3,我究竟是跳一步呢,还是两步呢,还是三步呢,究竟跳几步才是最优呢?

其实跳几步无所谓,关键在于可跳的覆盖范围!不一定非要明确一次究竟跳几步,每次取最大的跳跃步数,这个就是可以跳跃的覆盖范围。这个范围内,别管是怎么跳的,反正一定可以跳过来。

那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!

每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。

  • 贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围)

  • 整体最优解:最后得到整体最大覆盖范围,看是否能到终点。

局部最优推出全局最优,找不出反例,试试贪心!如图:

图片

  • i每次移动只能在cover的范围内移动,每移动一个元素,cover得到该元素数值(新的覆盖范围)的补充,让i继续移动下去。

  • 而cover每次只取 max(该元素数值补充后的范围, cover本身范围)。

  • 如果cover大于等于了终点下标,直接return true就可以了。

java

public class LeetCode_55_1_greedy {
    public static void main(String[] args) {
        int[] nums = {3, 2, 1, 0, 4};
        System.out.println(canJump(nums));
    }


    public static boolean canJump(int[] nums) {
        int rightmost = nums[0];
        int n = nums.length;
        for (int i = 1; i < n; i++) {
            if (i <= rightmost) {
                rightmost = Math.max(rightmost, i + nums[i]);
                if (rightmost >= n - 1) {
                    return true;
                }
            }
        }
        return false;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为数组的大小。只需要访问 nums 数组一遍,共 n 个位置。

  • 空间复杂度:O(1),不需要额外的空间开销

python

def canJump(nums: list) -> bool:
    n, rightmost = len(nums), 0
    for i in range(n):
        # 遍历的i必须在最长可到达范围内
        if i <= rightmost:
            rightmost = max(rightmost, i + nums[i])
        if rightmost >= n - 1:
            return True
    return False

19、45.跳跃游戏II

参考:LeetCode-45. 跳跃游戏 II

给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。

每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:

  • 0 <= j <= nums[i]

  • i + j < n

返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]。

示例 1:

输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
     从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。

示例 2:

输入: nums = [2,3,0,1,4]
输出: 2

提示:

  • 1 <= nums.length <= 10^4

  • 0 <= nums[i] <= 1000

  • 题目保证可以到达 nums[n-1]

贪心-反向查找出发位置

目标是到达数组的最后一个位置,因此我们可以考虑最后一步跳跃前所在的位置,该位置通过跳跃能够到达最后一个位置。

如果有多个位置通过跳跃都能够到达最后一个位置,那么我们应该如何进行选择呢?直观上来看,我们可以「贪心」地选择距离最后一个位置最远的那个位置,也就是对应下标最小的那个位置。因此,我们可以从左到右遍历数组,选择第一个满足要求的位置。

找到最后一步跳跃前所在的位置之后,我们继续贪心地寻找倒数第二步跳跃前所在的位置,以此类推,直到找到数组的开始位置。

public class LeetCode_45_1_greedy {
    public static void main(String[] args) {
        int[] nums = {2, 3, 1, 1, 4};
        System.out.println(jump(nums));
    }


    public static int jump(int[] nums) {
        // 从后向前遍历,每次寻找能够达到当前节点的最左边的节点
        int right = nums.length - 1;
        int steps = 0;
        while (right > 0) {
            for (int i = 0; i < right; i++) {
                // 寻找左侧跨度最大的结点
                if (i + nums[i] >= right) {
                    right = i;
                    steps++;
                    break;
                }
            }
        }
        return steps;
    }
}

复杂度分析

  • 时间复杂度:O(n^2),其中 n 是数组长度。有两层嵌套循环,在最坏的情况下,例如数组中的所有元素都是 1,position 需要遍历数组中的每个位置,对于 position 的每个值都有一次循环。

  • 空间复杂度:O(1)。

贪心-正向查找可到达的最大位置

本题要计算最小步数,那么就要想清楚什么时候步数才一定要加一呢?

贪心的思路,局部最优:当前可移动距离尽可能多走,如果还没到终点,步数再加一。整体最优:一步尽可能多走,从而达到最小步数。

思路虽然是这样,但在写代码的时候还不能真的就能跳多远跳远,那样就不知道下一步最远能跳到哪里了。

所以真正解题的时候,要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最小步数!

这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖。

如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点。

如图:

图片
图中覆盖范围的意义在于,只要红色的区域,最多两步一定可以到!(不用管具体怎么跳,反正一定可以跳到)

从图中可以看出来,就是移动下标达到了当前覆盖的最远距离下标时,步数就要加一,来增加覆盖距离。最后的步数就是最少步数。

这里还是有个特殊情况需要考虑,当移动下标达到了当前覆盖的最远距离下标时

  • 如果当前覆盖最远距离下标不是是集合终点,步数就加一,还需要继续走。

  • 如果当前覆盖最远距离下标就是是集合终点,步数不用加一,因为不能再往后走了。

在遍历数组时,我们不访问最后一个元素,这是因为在访问最后一个元素之前,我们的边界一定大于等于最后一个位置,否则就无法跳到最后一个位置了。如果访问最后一个元素,在边界正好为最后一个位置的情况下,我们会增加一次「不必要的跳跃次数」,因此我们不必访问最后一个元素

java

public class LeetCode_45_2_greedy {
    public static void main(String[] args) {
        int[] nums = {2, 3, 1, 1, 4};
        System.out.println(jump(nums));
    }


    public static int jump(int[] nums) {
        int curDistance = 0;
        int nextDistance = 0;
        int res = 0;
        int n = nums.length;
        // 起点开始从左到右遍历,记录当前区间内能够到达的最远位置,如果遍历到最远位置还没到达终点则步数+1
        for (int i = 0; i < n - 1; i++) {
            nextDistance = Math.max(nextDistance, i + nums[i]);
            if (i == curDistance) {
                res++;
                curDistance = nextDistance;
                // 当前最大范围已经覆盖终点
                if (nextDistance >= n - 1) {
                    break;
                }
            }
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是数组长度。

  • 空间复杂度:O(1)。

python

def jump(nums: list) -> int:
    if len(nums) == 1: return 0
    ans = 0
    curDistance = 0
    nextDistance = 0
    for i in range(len(nums)):
        nextDistance = max(i + nums[i], nextDistance)
        # 到达当前覆盖最远距离下标
        if i == curDistance:
            # 未到达终点
            if curDistance != len(nums) - 1:
                ans += 1  # 到达最远距离,步数+1
                curDistance = nextDistance
                # 到达终点
                if nextDistance >= len(nums) - 1: break
    return ans

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

参考:LeetCode-452. 用最少数量的箭引爆气球

在二维空间中有许多球形的气球。对于每个气球,提供的输入是水平方向上,气球直径的开始和结束坐标。由于它是水平的,所以纵坐标并不重要,因此只要知道开始和结束的横坐标就足够了。开始坐标总是小于结束坐标。

一支弓箭可以沿着 x 轴从不同点完全垂直地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足  xstart ≤ x ≤ xend,则该气球会被引爆。可以射出的弓箭的数量没有限制。 弓箭一旦被射出之后,可以无限地前进。我们想找到使得所有气球全部被引爆,所需的弓箭的最小数量。

给你一个数组 points ,其中 points [i] = [xstart,xend] ,返回引爆所有气球所必须射出的最小弓箭数。

示例 1:

输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
解释:对于该样例,x = 6 可以射爆 [2,8],[1,6] 两个气球,以及 x = 11 射爆另外两个气球

示例 2:

输入:points = [[1,2],[3,4],[5,6],[7,8]]
输出:4

示例 3:

输入:points = [[1,2],[2,3],[3,4],[4,5]]
输出:2

示例 4:

输入:points = [[1,2]]
输出:1

示例 5:

输入:points = [[2,3],[2,3]]
输出:1

提示:

  • 1 <= points.length <= 10^5

  • points[i].length == 2

  • -2^31 <= xstart < xend <= 2^31 - 1

排序+贪心

  • 局部最优:当气球出现重叠,一起射,所用弓箭最少。

  • 全局最优:把所有气球射爆所用弓箭最少。

为了让气球尽可能的重叠,需要对数组进行排序。那么按照气球起始位置排序,还是按照气球终止位置排序呢?其实都可以!只不过对应的遍历顺序不同,我就按照气球的起始位置排序了。

既然按照起始位置排序,那么就从前向后遍历气球数组,靠左尽可能让气球重复。从前向后遍历遇到重叠的气球了怎么办?如果气球重叠了,重叠气球中右边边界的最小值之前的区间一定需要一个弓箭。

以题目示例: [[10,16],[2,8],[1,6],[7,12]]为例,如图:(方便起见,已经排序)

图片
可以看出首先第一组重叠气球,一定是需要一个箭,气球3,的左边界大于了 第一组重叠气球的最小右边界,所以再需要一支箭来射气球3了。

java

import java.util.Arrays;
import java.util.Comparator;
public class LeetCode_452_greedy_2 {
    public static void main(String[] args) {
        int[][] points = {{3, 9}, {7, 12}, {3, 8}, {6, 8}, {9, 10}, {2, 9}, {0, 9}, {3, 9}, {0, 6}, {2, 8}};
        System.out.println(findMinArrowShots(points));
    }


    public static int findMinArrowShots(int[][] points) {
        int n = points.length;
        // 根据左侧范围值进行升序排序
        Arrays.sort(points, Comparator.comparingInt(point -> point[0]));


        // 遍历排序后的数组,如果横坐标范围有重叠则进行合并,并使用重叠区域替代当前区域
        int res = 1;
        for (int i = 1; i < n; i++) {
            // 区域重叠
            if (points[i][0] <= points[i - 1][1]) {
                points[i][1] = Math.min(points[i][1], points[i - 1][1]);
            } else {
                // 不重叠
                res++;
            }
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 是数组 points 的长度。排序的时间复杂度为 O(nlogn),对所有气球进行遍历并计算答案的时间复杂度为 O(n),其在渐进意义下小于前者,因此可以忽略。

  • 空间复杂度:O(logn),即为排序需要使用的栈空间

python

def findMinArrowShots(points: list) -> int:
    if len(points) == 0: return 0
    points.sort(key=lambda x: x[0])
    result = 1
    for i in range(1, len(points)):
        if points[i][0] > points[i - 1][1]:  # 气球i和气球i-1不挨着,注意这里不是>=
            result += 1
        else:
            points[i][1] = min(points[i - 1][1], points[i][1])  # 更新重叠气球最小右边界
    return result

21、435.无重叠区间

参考:LeetCode-435. 无重叠区间

给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回 需要移除区间的最小数量,使剩余区间互不重叠

示例 1:

输入: intervals = [[1,2],[2,3],[3,4],[1,3]]
输出: 1
解释: 移除 [1,3] 后,剩下的区间没有重叠。

示例 2:

输入: intervals = [[1,2], [1,2], [1,2]]
输出: 2
解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。

示例 3:

输入: intervals = [[1,2], [2,3] ]
输出: 0
解释: 你不需要移除任何区间,因为它们已经是无重叠的了。

提示:

  • 1 <= intervals.length <= 10^5

  • intervals[i].length == 2

  • -5 * 10^4 <= starti < endi <= 5 * 10^4

左边界排序+贪心

本题和452.用最少数量的箭引爆气球 非常像,弓箭的数量就相当于是非交叉区间的数量,只要把弓箭那道题目代码里射爆气球的判断条件 进行修改(认为[0,1][1,2]不是相邻区间),然后用总区间数减去弓箭数量 就是要移除的区间数量了。

有相同的重叠区域的区间都需要合并成一个****,其余的都必须删除才能保证所有结果区间不重叠,重叠的区域合并成右边界最小的一个区域,和下一个不重叠的区间一定不会重合。

import java.util.Arrays;
public class LeetCode_435_greedy {
    public static void main(String[] args) {
        int[][] intervals = {{1, 2}, {2, 3}, {3, 4}, {1, 3}};
        System.out.println(eraseOverlapIntervals(intervals));
    }


    public static int eraseOverlapIntervals(int[][] intervals) {
        // 和452.射箭类似,计算出最少使用的箭即计算出有多少个不重叠的区域,n-不重叠区域数 为结果(区间左右端点重合不算重叠)
        // 按照区域左侧起点进行升序排序
        Arrays.sort(intervals, (interval1, interval2) -> {
            if (interval1[0] != interval2[0]) {
                return interval1[0] - interval2[0];
            }
            return interval1[1] - interval2[1];
        });


        // 计算不重叠区域数量
        int res = 1;
        int n = intervals.length;
        for (int i = 1; i < n; i++) {
            // 当前区域和前一个区域重叠
            if (intervals[i][0] < intervals[i - 1][1]) {
                intervals[i][1] = Math.min(intervals[i][1], intervals[i - 1][1]);
            } else {
                // 区域不重叠
                res++;
            }
        }
        return n - res;
    }
}

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 是数组 intervals 的长度。排序的时间复杂度为 O(nlogn),对所有区间进行遍历并计算答案的时间复杂度为 O(n),其在渐进意义下小于前者,因此可以忽略。

  • 空间复杂度:O(logn),即为排序需要使用的栈空间。

右边界排序+贪心

  • 按照右边界排序,就要从左向右遍历,因为右边界越小越好,只要右边界越小,留给下一个区间的空间就越大,所以从左向右遍历,优先选右边界小的。

  • 按照右边界排序,从左向右记录非交叉区间的个数。最后用区间总数减去非交叉区间的个数就是需要移除的区间个数了。此时问题就是要求非交叉区间的最大个数。

右边界排序之后:

  • 局部最优:优先选右边界小的区间,所以从左向右遍历,留给下一个区间的空间大一些,从而尽量避免交叉。

  • 全局最优:选取最多的非交叉区间。

局部最优推出全局最优,试试贪心!这里记录非交叉区间的个数还是有技巧的,如图:

图片
区间,1,2,3,4,5,6都按照右边界排好序。

  • 每次取非交叉区间的时候,都是取右边界最小的来做分割点(这样留给下一个区间的空间就越大),所以第一条分割线就是区间1结束的位置。

  • 接下来就是找大于区间1结束位置的区间,是从区间4开始。

  • 区间4结束之后,在找到区间6,所以一共记录非交叉区间的个数是三个。

  • 总共区间个数为6,减去非交叉区间的个数3。移除区间的最小数量就是3。

java

import java.util.Arrays;
import java.util.Comparator;
public class LeetCode_435_greedy_2 {
    public static void main(String[] args) {
        int[][] intervals = {{1, 2}, {2, 3}, {3, 4}, {1, 3}};
        System.out.println(eraseOverlapIntervals(intervals));
    }


    public static int eraseOverlapIntervals(int[][] intervals) {
        // 按照区域右侧终点进行降序排序,重叠区域中选择右端点较小的保留,可以保证留给其他不重叠区域尽可能大的区域位置
        // 按照区域右侧终点进行降序排序
        Arrays.sort(intervals, Comparator.comparingInt(interval -> interval[1]));


        // 计算不重叠区域数量
        int res = 1;
        int n = intervals.length;
        int end = intervals[0][1];
        for (int i = 1; i < n; i++) {
            // 寻找下一个不重叠的区域,重叠区域直接忽略
            if (intervals[i][0] >= end) {
                end = intervals[i][1];
                res++;
            }
        }
        return n - res;
    }
}

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 是区间的数量。我们需要 O(nlogn) 的时间对所有的区间按照右端点进行升序排序,并且需要 O(n) 的时间进行遍历。由于前者在渐进意义下大于后者,因此总时间复杂度为 O(nlogn)。

  • 空间复杂度:O(logn),即为排序需要使用的栈空间。

python

def eraseOverlapIntervals(intervals: list) -> int:
    if len(intervals) == 0: return 0
    # 按照右边界升序排序
    intervals.sort(key=lambda x: x[1])
    count = 1  # 记录非交叉区间的个数
    end = intervals[0][1]  # 记录区间分割点
    for i in range(1, len(intervals)):
        # 查找到不重叠区间
        if end <= intervals[i][0]:
            count += 1
            end = intervals[i][1]
    return len(intervals) - count

22、56.合并区间

参考:LeetCode-56. 合并区间

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。

示例 1:

输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].

示例 2:

输入:intervals = [[1,4],[4,5]]
输出:[[1,5]]
解释:区间 [1,4] 和 [4,5] 可被视为重叠区间。

提示:

  • 1 <= intervals.length <= 10^4

  • intervals[i].length == 2

  • 0 <= starti <= endi <= 10^4

排序+贪心

按照左边界排序,排序之后

  • 局部最优:每次合并都取最大的右边界,这样就可以合并更多的区间了

  • 整体最优:合并所有重叠的区间。

按照左边界从小到大排序之后,如果 intervals[i][0] < intervals[i - 1][1] 即intervals[i]左边界 < intervals[i - 1]右边界,则一定有重复,因为intervals[i]的左边界一定是大于等于intervals[i - 1]的左边界。

即:intervals[i]的左边界在intervals[i - 1]左边界和右边界的范围内,那么一定有重复! 这么说有点抽象,看图:(注意图中区间都是按照左边界排序之后了)

图片
知道如何判断重复之后,剩下的就是合并了,如何去模拟合并区间呢?

其实就是用合并区间后左边界和右边界,作为一个新的区间,加入到result数组里就可以了。如果没有合并就把原区间加入到result数组。

java

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
public class LeetCode_56_greedy {
    public static void main(String[] arg) {
        int[][] intervals = {{1, 3}, {2, 6}, {8, 10}, {15, 18}};
        System.out.println(Arrays.deepToString(merge(intervals)));
    }


    public static int[][] merge(int[][] intervals) {
        // 按照区间的左端点升序排序
        Arrays.sort(intervals, Comparator.comparingInt(interval -> interval[0]));


        // 遍历每个区间,有重叠的区间进行合并
        int n = intervals.length;
        List<int[]> res = new ArrayList<>();
        int left = intervals[0][0];
        int right = intervals[0][1];
        for (int i = 1; i < n; i++) {
            // 和上一个区间有重叠
            if (intervals[i][0] <= right) {
                right = Math.max(right, intervals[i][1]);
            } else {
                // 不重叠,记录结果,从下一个区域开始遍历
                res.add(new int[]{left, right});
                left = intervals[i][0];
                right = intervals[i][1];
            }
        }
        // 最后一个区间加入结果
        res.add(new int[]{left, right});
        return res.toArray(new int[res.size()][]);
    }
}

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 为区间的数量。除去排序的开销,我们只需要一次线性扫描,所以主要的时间开销是排序的 O(nlogn)。

  • 空间复杂度:O(logn),其中 n 为区间的数量。这里计算的是存储答案之外,使用的额外空间。O(logn) 即为排序所需要的空间复杂度。

python

def merge(intervals: list) -> list:
    if len(intervals) == 0: return intervals
    # 按照区间左边界从小到大排序
    intervals.sort(key=lambda x: x[0])
    result = []
    # 第一个区间作为初始值
    result.append(intervals[0])
    
    for i in range(1, len(intervals)):
        # 每次拓展最后一个结果区间
        last = result[-1]
        # 区间重叠,合并区间
        if last[1] >= intervals[i][0]:
            # 不断更新右边界
            result[-1] = [last[0], max(last[1], intervals[i][1])]
        # 区间不重叠,直接加入结果集
        else:
            result.append(intervals[i])
    return result

23、763.划分字母区间

参考:LeetCode-763. 划分字母区间

给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。

注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s 。

返回一个表示每个字符串片段的长度的列表。

示例 1:输入:s = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:
划分结果为 "ababcbaca"、"defegde"、"hijhklij" 。
每个字母最多出现在一个片段中。
像 "ababcbacadefegde", "hijhklij" 这样的划分是错误的,因为划分的片段数较少。 

示例 2:

输入:s = "eccbbbbdec"
输出:[10]

提示:

  • 1 <= s.length <= 500

  • s 仅由小写英文字母组成

排序+贪心

452.用最少数量的箭引爆气球 435.无重叠区间 相同的思路。也是对重叠区域的处理。
统计字符串中所有字符的起始和结束位置,记录这些区间(实际上也就是435.无重叠区间 
题目里的输入),将区间按左边界从小到大排序,找到边界将区间划分成组,互不重叠。找到的边界就是答案。
可以把每个元素的左右区间跨度当做一个区域来处理,所有有重叠的区域合并成一个大区域,左右端点更新为两个区域的最左和最右值。最终计算得到的合并完成后的所有大区域就是最终的结果。具体计算步骤如下:

  • 计算出每个字母的左右区间跨度;

  • 按照区间的左端点进行升序排序;

  • 从左到右遍历每个区间,针对有重叠的区域进行合并,合并后的大区间继续进行合并,直到无法合并之后将本次划分写入结果;

  • 最后一次划分要单独写入结果,因为后续没有不重叠的区域了

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class LeetCode_763_greedy {
    public static void main(String[] args) {
        String s = "ababcbacadefegdehijhklij";
        System.out.println(partitionLabels(s));
    }


    public static List<Integer> partitionLabels(String s) {
        List<Integer> res = new ArrayList<>();
        int n = s.length();
        Map<Character, int[]> letterMap = new HashMap<>();
        // 计算出每个元素的左右区间跨度
        for (int i = 0; i < n; i++) {
            char ch = s.charAt(i);
            letterMap.putIfAbsent(ch, new int[]{i, i});
            letterMap.get(ch)[1] = Math.max(letterMap.get(ch)[1], i);
        }


        // 按照区间的左端点进行升序排序
        List<int[]> arr = new ArrayList<>(letterMap.values().stream().toList());
        arr.sort(Comparator.comparingInt(region -> region[0]));


        int start = arr.getFirst()[0];
        int end = arr.getFirst()[1];
        // 针对有重叠的区域进行合并,合并后的区间就是最终单个划分的区域
        for (int i = 1; i < arr.size(); i++) {
            // 和前一个区域有重合,更新划分的右端点
            if (arr.get(i)[0] < end) {
                end = Math.max(end, arr.get(i)[1]);
            } else {
                res.add(end - start + 1);
                start = arr.get(i)[0];
                end = arr.get(i)[1];
            }
        }
        // 将最后一个结果写入
        res.add(end - start + 1);
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 是字符串中不同字母的个数。排序的时间复杂度为 O(nlogn),对所有区域进行遍历并计算答案的时间复杂度为 O(n),其在渐进意义下小于前者,因此可以忽略。

  • 空间复杂度:O(logn),即为排序需要使用的栈空间

贪心

由于同一个字母只能出现在同一个片段,显然同一个字母的第一次出现的下标位置和最后一次出现的下标位置必须出现在同一个片段。因此需要遍历字符串,得到每个字母最后一次出现的下标位置。

在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。

可以分为如下两步:

  • 统计每一个字符最后出现的位置

  • 从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点

上述做法使用贪心的思想寻找每个片段可能的最小结束下标,因此可以保证每个片段的长度一定是符合要求的最短长度,如果取更短的片段,则一定会出现同一个字母出现在多个片段中的情况。由于每次取的片段都是符合要求的最短的片段,因此得到的片段数也是最多的。
如图:

图片
java

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class LeetCode_763_greedy_2 {
    public static void main(String[] args) {
        String s = "ababcbacadefegdehijhklij";
        System.out.println(partitionLabels(s));
    }


    public static List<Integer> partitionLabels(String s) {
        int n = s.length();
        int[] letterMaxIndex = new int[26];
        // 统计每一个字符最后出现的位置
        for (int i = 0; i < n; i++) {
            letterMaxIndex[s.charAt(i) - 'a'] = i;
        }


        // 遍历范围内每个元素,更新右端点最远的位置
        List<Integer> res = new ArrayList<>();
        int left = 0;
        int right = 0;
        for (int i = 0; i < n; i++) {
            // 计算字符出现的最远边界
            right = Math.max(right, letterMaxIndex[s.charAt(i) - 'a']);
            // 到达当前片段最远边界
            if (right == i) {
                // 保存结果,指针移动到下一片段位置
                res.add(right - left + 1);
                left = i + 1;
                right = left;
            }
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串的长度。需要遍历字符串两次,第一次遍历时记录每个字母最后一次出现的下标位置,第二次遍历时进行字符串的划分。

  • 空间复杂度:O(∣Σ∣),其中 Σ 是字符串中的字符集。这道题中,字符串只包含小写字母,因此 ∣Σ∣=26。

python

def partitionLabels(s: str) -> list:
    # i为字符,hash[i]为字符出现的最后位置
    hash = [0] * 26
    # 统计每一个字符最后出现的位置
    for i in range(len(s)):
        hash[ord(s[i]) - ord('a')] = i
    result = []
    left = 0
    right = 0
    for i in range(len(s)):
        # 找到字符出现的最远边界
        right = max(right, hash[ord(s[i]) - ord('a')])
        # 到达当前片段最远边界
        if i == right:
            # 保存结果,指针移动到下一片段位置
            result.append(right - left + 1)
            left = i + 1
    return result

构造题

24、1605.给定行和列的和求可行矩阵

参考:LeetCode-1605. 给定行和列的和求可行矩阵

给你两个非负整数数组 rowSum 和 colSum ,其中 rowSum[i] 是二维矩阵中第 i 行元素的和, colSum[j] 是第 j 列元素的和。换言之你不知道矩阵里的每个元素,但是你知道每一行和每一列的和。

请找到大小为 rowSum.length x colSum.length 的任意 非负整数 矩阵,且该矩阵满足 rowSum 和 colSum 的要求。

请你返回任意一个满足题目要求的二维矩阵,题目保证存在 至少一个 可行矩阵。

示例 1:

输入:rowSum = [3,8], colSum = [4,7]
输出:[[3,0],
      [1,7]]
解释:
第 0 行:3 + 0 = 3 == rowSum[0]
第 1 行:1 + 7 = 8 == rowSum[1]
第 0 列:3 + 1 = 4 == colSum[0]
第 1 列:0 + 7 = 7 == colSum[1]
行和列的和都满足题目要求,且所有矩阵元素都是非负的。
另一个可行的矩阵为:[[1,2],
                  [3,5]]

示例 2:

输入:rowSum = [5,7,10], colSum = [8,6,8]
输出:[[0,5,0],
      [6,1,0],
      [2,0,8]]

示例 3:

输入:rowSum = [14,9], colSum = [6,9,8]
输出:[[0,9,5],
      [6,0,3]]

示例 4:

输入:rowSum = [1,0], colSum = [1]
输出:[[1],
      [0]]

示例 5:

输入:rowSum = [0], colSum = [0]
输出:[[0]]

提示:

  • 1 <= rowSum.length, colSum.length <= 500

  • 0 <= rowSum[i], colSum[i] <= 10^8

  • sum(rowSum) == sum(colSum)

贪心

参考:1605.给定行和列的和求可行矩阵-LeetCode Wiki

实现步骤:

  • 我们可以先初始化一个 m 行 n 列的答案矩阵 res。

  • 接下来,遍历矩阵的每一个位置 (i,j),将该位置的元素设为 x=min(rowSum[i],colSum[j]),并将 rowSum[i] 和 colSum[j] 分别减去 x。遍历完所有的位置后,我们就可以得到一个满足题目要求的矩阵 res。

以上策略的正确性说明如下:

根据题目的要求,我们知道 rowSum 和 colSum 的和是相等的,那么 rowSum[0] 一定小于等于 ∑j=(0,n−1) colSum[j]。所以,在经过 n 次操作后,一定能够使得 rowSum[0] 为 0,并且保证对任意 j∈[0,n−1],都有 colSum[j]≥0。

因此,我们把原问题缩小为一个 m−1 行和 n 列的子问题,继续进行上述的操作,直到rowSum 和 colSum 中的所有元素都为 0,就可以得到一个满足题目要求的矩阵 res。

本题最重要的是确定构造策略的正确性,下面是具体的分析和思考过程:

  • rowSum 和 colSum 的和是相等的,都等于矩阵所有元素的总和,同时构造一个元素res[i][j]的数据时,rowSum[i] 和colSum[j]的数值是同时减少res[i][j]的。
  • 以第一行的遍历构造每个元素为例:
    rowSum[0]从初始状态到构造行元素的不断减小的过程中,一定一直是小于colSum的所有列的和。每一个元素都是按照min(rowSum[0], colSum[j])来获取,第一行遍历结束之后,rowSum[0]一定为0,行构造一定能完成:
    (1)第一种情况:如果某一个元素直接取到rowSum[0]当前行遍历就结束了
    (2)第二种情况:如果每次min(rowSum[0], colSum[j])取得都是colSum[j],那么当前行遍历结束之后rowSum[0]的值也一定为0,因为每一列的colSum[j]的和一定是大于等于rowSum[0]的,且两者在构造元素过程是同步减小的,所以如果在行元素每次构造都是选择colSum[j]时,colSum[j]和为零、rowSum[0]也为0。
    其他行的构造过程和第一行一样,到最后一行时,行和列的和已经都相同了,按照每一列的最大值取,所有行和列最终和都为0,所以最后一行也一定可以构造成功,每一行构造成功了,所以最终结果一定可以构造出来。
    java
import java.util.Arrays;
public class LeetCode_1605_greedy {
    public static void main(String[] args) {
        int[] rowSum = {5, 7, 10};
        int[] colSum = {8, 6, 8};
        System.out.println(Arrays.deepToString(restoreMatrix(rowSum, colSum)));
    }


    public static int[][] restoreMatrix(int[] rowSum, int[] colSum) {
        int m = rowSum.length;
        int n = colSum.length;
        int[][] res = new int[m][n];


        // 按照行遍历矩阵的每个元素,每个元素res[i][j]取当前行、列可以取到的最大值min(rowSum[i],colSum[j])
        // 逐行遍历一定可以使当前行rowSum=0,因为colSum和>=rowSum[0],每个行元素从min(rowSum, colSum)取得
        for (int i = 0; i < m; i++) {
            // 当前行rowSum为0,提前结束当前行的遍历
            if (rowSum[i] == 0) {
                continue;
            }
            for (int j = 0; j < n; j++) {
                // 当前列colSum为0,提前结束当前列的遍历
                if (colSum[j] == 0) {
                    continue;
                }
                int curMax = Math.min(rowSum[i], colSum[j]);
                res[i][j] = curMax;
                rowSum[i] -= curMax;
                colSum[j] -= curMax;
            }
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(n×m),其中 n 和 m 分别为数组 rowSum 和 colSum 的长度,主要为构造 matrix 结果矩阵的时间开销,填充 matrix 的时间复杂度为 O(n+m);

  • 空间复杂度:O(1),仅使用常量空间。注意返回的结果数组不计入空间开销。

25、122.买卖股票的最佳时机II

参考:LeetCode-122. 买卖股票的最佳时机 II

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: prices = [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。

示例 2:

输入: prices = [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:

输入: prices = [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

动态规划

考虑到「不能同时参与多笔交易」,因此每天交易结束后只可能存在手里有一支股票或者没有股票的状态。

  • 定义状态 dp[i][0] 表示第 i 天交易完后手里没有股票的最大利润,dp[i][1] 表示第 i 天交易完后手里持有一支股票的最大利润(i 从 0 开始)。

  • 考虑 dp[i][0] 的转移方程,如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即 dp[i−1][0],或者前一天结束的时候手里持有一支股票,即 dp[i−1][1],这时候我们要将其卖出,并获得 prices[i] 的收益。因此为了收益最大化,我们列出如下的转移方程:dp[i][0]=max{dp[i−1][0],dp[i−1][1]+prices[i]}

  • 再来考虑 dp[i][1],按照同样的方式考虑转移状态,那么可能的转移状态为前一天已经持有一支股票,即 dp[i−1][1],或者前一天结束时还没有股票,即 dp[i−1][0],这时候我们要将其买入,并减少 prices[i] 的收益。可以列出如下的转移方程:dp[i][1]=max{dp[i−1][1],dp[i−1][0]−prices[i]}

  • 对于初始状态,根据状态定义我们可以知道第 0 天交易结束的时候 dp[0][0]=0,dp[0][1]=−prices[0]。

因此,我们只要从前往后依次计算状态即可。由于全部交易结束后,持有股票的收益一定低于不持有股票的收益,因此这时候 dp[n−1][0] 的收益必然是大于 dp[n−1][1] 的,最后的答案即为 dp[n−1][0]。

java

public class LeetCode_122_2_dp {
    public static void main(String[] args) {
        int[] prices = {7, 1, 5, 3, 6, 4};
        System.out.println(maxProfit(prices));
    }


    public static int maxProfit(int[] prices) {
        int n = prices.length;
        int[][] dp = new int[n][2];
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        for (int i = 1; i < n; i++) {
            // 不持有股票:保持前一天不持有状态、把前一天持有的股票卖掉
            dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
            // 持有股票:保持前一天持有状态、前一天不持有的状态下买入今天的股票
            dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
        }
        return Math.max(dp[n - 1][0], dp[n - 1][1]);
    }
}

python

def maxProfit(prices):
    length = len(prices)
    dp = [[0] * 2 for _ in range(length)]
    dp[0][1] = -prices[0]
    dp[0][0] = 0
    for i in range(1, length):
        # 当天持有股票
        dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i])
        # 当天不持有股票
        dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
    return dp[-1][0]

动态规划-空间优化

注意到上面的状态转移方程中,每一天的状态只与前一天的状态有关,而与更早的状态都无关,因此我们不必存储这些无关的状态,只需要将 dp[i−1][0] 和 dp[i−1][1] 存放在两个变量中,通过它们计算出 dp[i][0] 和 dp[i][1] 并存回对应的变量,以便于第 i+1 天的状态转移即可。

public class LeetCode_122_2_dp {
    public static void main(String[] args) {
        int[] prices = {7, 1, 5, 3, 6, 4};
        System.out.println(maxProfit(prices));
    }


    public static int maxProfit(int[] prices) {
        int n = prices.length;
        int dp0 = 0;
        int dp1 = -prices[0];
        for (int i = 1; i < n; i++) {
            // 不持有股票:保持前一天不持有状态、把前一天持有的股票卖掉
            int new0 = Math.max(dp0, dp1 + prices[i]);
            // 持有股票:保持前一天持有状态、前一天不持有的状态下买入今天的股票
            int new1 = Math.max(dp1, dp0 - prices[i]);
            // 不能直接覆盖dp0,在dp1计算中使用到了dp0
            dp0 = new0;
            dp1 = new1;
        }
        return dp0;
    }
}

python

def maxProfit(prices):
    length = len(prices)
    dp = [[0] * 2 for _ in range(2)]  # 注意这里只开辟了一个2 * 2大小的二维数组
    dp[0][1] = -prices[0]
    dp[0][0] = 0
    for i in range(1, length):
        dp[i % 2][1] = max(dp[(i - 1) % 2][1], dp[(i - 1) % 2][0] - prices[i])
        dp[i % 2][0] = max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] + prices[i])
    return dp[(length - 1) % 2][0]

复杂度分析

  • 时间复杂度:O(n),其中 n 为数组的长度。一共有 2n 个状态,每次状态转移的时间复杂度为 O(1),因此时间复杂度为 O(2n)=O(n)。

  • 空间复杂度:O(n)。我们需要开辟 O(n) 空间存储动态规划中的所有状态。如果使用空间优化,空间复杂度可以优化至 O(1)。

贪心

这道题目可能我们只会想,选一个低的买入,在选个高的卖,在选一个低的买入.....循环反复。

如果想到其实最终利润是可以分解的,那么本题就很容易了!

如何分解呢?

  • 假如第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])。

如图:

图片
图片
从图中可以发现,其实我们需要收集每天的正利润就可以,收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间。

那么只收集正利润就是贪心所贪的地方!

  • 局部最优:收集每天的正利润

  • 全局最优:求得最大利润。

局部最优可以推出全局最优,找不出反例,试一试贪心!

java

public class LeetCode_122_1_greedy {
    public static void main(String[] args) {
        int[] prices = {7, 1, 5, 3, 6, 4};
        System.out.println(maxProfit(prices));
    }


    public static int maxProfit(int[] prices) {
        int res = 0;
        int n = prices.length;
        // 把每一段上升的利润加起来,就是最大利润
        for (int i = 1; i < n; i++) {
            if (prices[i] > prices[i - 1]) {
                res += prices[i] - prices[i - 1];
            }
        }
        return res;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 为数组的长度。我们只需要遍历一次数组即可。

  • 空间复杂度:O(1)。只需要常数空间存放若干变量

python

def maxProfit(prices: list) -> int:
    result = 0
    for i in range(1, len(prices)):
        # 将两天差价为正数的差值加入利润
        result += max(prices[i] - prices[i - 1], 0)
    return result

26、134.加油站

参考:LeetCode-134. 加油站

  • 在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。

  • 你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。

  • 给定两个整数数组 gas 和 cost ,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则 保证 它是 唯一 的。

示例 1:

输入: gas = [1,2,3,4,5], cost = [3,4,5,1,2]
输出: 3
解释:
从 3 号加油站(索引为 3 处)出发,可获得 4 升汽油。此时油箱有 = 0 + 4 = 4 升汽油
开往 4 号加油站,此时油箱有 4 - 1 + 5 = 8 升汽油
开往 0 号加油站,此时油箱有 8 - 2 + 1 = 7 升汽油
开往 1 号加油站,此时油箱有 7 - 3 + 2 = 6 升汽油
开往 2 号加油站,此时油箱有 6 - 4 + 3 = 5 升汽油
开往 3 号加油站,你需要消耗 5 升汽油,正好足够你返回到 3 号加油站。
因此,3 可为起始索引。

示例 2:

输入: gas = [2,3,4], cost = [3,4,3]
输出: -1
解释:
你不能从 0 号或 1 号加油站出发,因为没有足够的汽油可以让你行驶到下一个加油站。
我们从 2 号加油站出发,可以获得 4 升汽油。 此时油箱有 = 0 + 4 = 4 升汽油
开往 0 号加油站,此时油箱有 4 - 3 + 2 = 3 升汽油
开往 1 号加油站,此时油箱有 3 - 3 + 3 = 3 升汽油
你无法返回 2 号加油站,因为返程需要消耗 4 升汽油,但是你的油箱只有 3 升汽油。
因此,无论怎样,你都不可能绕环路行驶一周。

提示:

  • gas.length == n

  • cost.length == n

  • 1 <= n <= 10^5

  • 0 <= gas[i], cost[i] <= 10^4

贪心

参考:LeetCode 134 加油站 全面详细题解

从头到尾遍历每个加油站,并检查以该加油站为起点,最终能否行驶一周。我们可以通过减小被检查的加油站数目,来降低总的时间复杂度。

简单的枚举每个点能否走一圈,当起点 i 到达 起点 j 之后,下一站无法到达 j + 1,那么 [i, j] 之间任意一点 k 都无法到达 j + 1。因为从 i 开始,到达 k,此时剩余油量大于等于0(不加 k 点的油量),但这种情况下都无法到达 j + 1。如果让 k 作为起点,那么意味着此时只有 k 点的油,那么肯定是无法到达 j + 1 的。
从 i 到 k,k 点时的油量 = 前面的剩余油量(>= 0)+ k 点的油量。无法到达 j + 1。
k 作为起点,k 点时的油量 = k 点的油量,显然更不可能到达 j + 1。
所以下次枚举直接从 j + 1 开始即可。
在发现了这一个性质后,算法就很清楚了:我们首先检查第 0 个加油站,并试图判断能否环绕一周;如果不能,就从第一个无法到达的加油站开始继续检查

public class LeetCode_134_1_greedy {
    public static void main(String[] args) {
        int[] gas = {1, 2, 3, 4, 5};
        int[] cost = {3, 4, 5, 1, 2};
        System.out.println(canCompleteCircuit(gas, cost));
    }


    public static int canCompleteCircuit(int[] gas, int[] cost) {
        int n = gas.length;
        // 以每个点为起点
        for (int i = 0; i < n; ) {
            int j;
            int sum = 0;
            // i作为起点模拟走一圈
            for (j = 0; j < n; j++) {
                int index = (i + j) % n;
                // 补油再尝试向前走
                sum += gas[index] - cost[index];
                if (sum < 0) {
                    break;
                }
            }
            // j回到起点完成一圈,则找到结果返回
            if (j == n) {
                return i;
            }
            // sum < 0时表示:从i+j点无法到达下一个节点,所以下一个起点从i+j+1开始
            i = i + j + 1;
        }
        return -1;
    }
}

复杂度分析

  • 时间复杂度:O(N),其中 N 为数组的长度。我们对数组进行了单次遍历。

  • 空间复杂度:O(1)。

27、135.分发糖果

参考:LeetCode-135. 分发糖果

老师想给孩子们分发糖果,有 N 个孩子站成了一条直线,老师会根据每个孩子的表现,预先给他们评分。

你需要按照以下要求,帮助老师给这些孩子分发糖果:

  • 每个孩子至少分配到 1 个糖果。

  • 相邻的孩子中,评分高的孩子必须获得更多的糖果。

那么这样下来,老师至少需要准备多少颗糖果呢?

示例 1:

输入:ratings = [1,0,2]
输出:5
解释:你可以分别给第一个、第二个、第三个孩子分发 2、1、2 颗糖果。

示例 2:

输入:ratings = [1,2,2]
输出:4
解释:你可以分别给第一个、第二个、第三个孩子分发 1、2、1 颗糖果。
     第三个孩子只得到 1 颗糖果,这满足题面中的两个条件。

提示:

  • n == ratings.length

  • 1 <= n <= 2 * 10^4

  • 0 <= ratings[i] <= 2 * 10^4

贪心

可以将「相邻的孩子中,评分高的孩子必须获得更多的糖果」这句话拆分为两个规则,分别处理。

  • 左规则:当ratings[i−1]<ratings[i] 时,i 号学生的糖果数量将比 i - 1 号孩子的糖果数量多。

  • 右规则:当 ratings[i]>ratings[i+1] 时,i 号学生的糖果数量将比 i + 1 号孩子的糖果数量多。

我们遍历该数组两次,处理出每一个学生分别满足左规则或右规则时,最少需要被分得的糖果数量。每个人最终分得的糖果数量即为这两个数量的最大值。实现步骤如下:

  • 左规则:从左到右遍历该数组,假设当前遍历到位置 i,如果有ratings[i−1]<ratings[i] 那么 i 号学生的糖果数量将比 i - 1 号孩子的糖果数量多,我们令 res[i]=res[i−1]+1 即可,否则我们令 res[i]=1。

  • 右规则:从右到左遍历该数组,假设当前遍历到位置 i,如果有ratings[i+1]<ratings[i] 那么 i 号学生的糖果数量将比 i + 1 号孩子的糖果数量多,我们令 res[i]=max(res[i],res[i+1]+1 ),这里使用max考虑了左右两边的情况:res[i]保证了左侧符合条件,res[i+1]+1保证了右侧符合条件。

java

import java.util.Arrays;
public class LeetCode_135_1_greedy {
    public static void main(String[] args) {
        int[] ratings = {1, 3, 2, 2, 1};
        System.out.println(candy(ratings));
    }


    public static int candy(int[] ratings) {
        int n = ratings.length;
        int[] res = new int[n];
        res[0] = 1;
        // 左规则:从左向右遍历,当前元素等级比前一个高时,糖果+1
        for (int i = 1; i < n; i++) {
            if (ratings[i] > ratings[i - 1]) {
                res[i] = res[i - 1] + 1;
            } else {
                res[i] = 1;
            }
        }


        int sum = res[n - 1];
        // 右规则:从右向左遍历,当前元素等级比后一个高时,max(后一个元素的糖果+1, 当前糖果)。和左规则不冲突,修改糖果时考虑了当前糖果(即当前糖果一定大于前一个)
        for (int i = n - 2; i >= 0; i--) {
            if (ratings[i] > ratings[i + 1]) {
                res[i] = Math.max(res[i], res[i + 1] + 1);
            }
            sum += res[i];
        }
        return sum;
    }
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是孩子的数量。我们需要遍历两次数组以分别计算满足左规则或右规则的最少糖果数量。

  • 空间复杂度:O(n),其中 n 是孩子的数量。我们需要保存所有的左规则对应的糖果数量。

python

def candy(ratings: list) -> int:
    candyVec = [1] * len(ratings)
    n = len(ratings)
    
    # 从左向右遍历,比较当前左相邻孩子
    for i in range(1, n):
        # 保证当前孩子比左边分数高时,糖果也比左边多
        if ratings[i] > ratings[i - 1]:
            candyVec[i] = candyVec[i - 1] + 1
            
    # 从右向左遍历,比较当前右相邻孩子
    for j in range(n - 2, -1, -1):
        # 保证当前孩子比右边分数高时,糖果也比右边多
        if ratings[j] > ratings[j + 1]:
            candyVec[j] = max(candyVec[j], candyVec[j + 1] + 1)
    return sum(candyVec)

那么本题采用了两次贪心的策略:

  • 一次是从左到右遍历,只比较右边孩子评分比左边大的情况。

  • 一次是从右到左遍历,只比较左边孩子评分比右边大的情况。

这样从局部最优推出了全局最优,即:相邻的孩子中,评分高的孩子获得更多的糖果。

其他题目列表

参考:

posted @ 2024-10-04 18:30  心静无忧  阅读(23)  评论(0编辑  收藏  举报