9-貪心演算法

心静无忧發表於2024-10-04

參考:程式碼隨想錄

題目分類大綱如下:

圖片

貪心演算法理論基礎

什麼是貪心?

貪心的本質是選擇每一階段的區域性最優,從而達到全域性最優。

貪心的套路(什麼時候用貪心)

貪心演算法並沒有固定的套路,說白了就是常識性推導加上舉反例。靠自己手動模擬,如果模擬可行,就可以試一試貪心策略,如果不可行,可能需要動態規劃。

如何驗證可不可以用貪心演算法呢?最好用的策略就是舉反例,如果想不到反例,那麼就試一試貪心吧。

貪心一般解題步驟

貪心演算法一般分為如下四步:

  • 將問題分解為若干個子問題

  • 找出適合的貪心策略

  • 求解每一個子問題的最優解

  • 將區域性最優解堆疊成全域性最優解

其實這個分的有點細了,真正做題的時候很難分出這麼詳細的解題步驟,可能就是因為貪心的題目往往還和其他方面的知識混在一起。

基礎題目

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)

那麼本題採用了兩次貪心的策略:

  • 一次是從左到右遍歷,只比較右邊孩子評分比左邊大的情況。

  • 一次是從右到左遍歷,只比較左邊孩子評分比右邊大的情況。

這樣從區域性最優推出了全域性最優,即:相鄰的孩子中,評分高的孩子獲得更多的糖果。

其他題目列表

參考:

  • 02. 貪心演算法題目-演算法通關手冊(LeetCode)

  • 【題單】貪心演算法(基本貪心策略/反悔/區間/字典序/數學/思維/構造)-靈茶山艾府

相關文章