LeetCode 周賽 333,你管這叫 Medium 難度?

2023-02-21 15:00:08

本文已收錄到 AndroidFamily,技術和職場問題,請關注公眾號 [彭旭銳] 提問。

大家好,我是小彭。

上週是 LeetCode 第 333 場周賽,你參加了嗎?這場周賽質量很高,但難度標得不對,我真的會謝。演演算法解題思維需要長時間鍛鍊,加入我們一起刷題吧~


小彭的 Android 交流群 02 群已經建立啦,公眾號回覆 「加群」 加入我們~


2570. 合併兩個二維陣列 - 求和法(Easy)

題目地址

https://leetcode.cn/problems/merge-two-2d-arrays-by-summing-values/

題目描述

給你兩個 二維 整數陣列 nums1 和 nums2.

  • nums1[i] = [idi, vali] 表示編號為 idi 的數位對應的值等於 vali 。
  • nums2[i] = [idi, vali] 表示編號為 idi 的數位對應的值等於 vali 。

每個陣列都包含 互不相同 的 id ,並按 id 以 遞增 順序排列。

請你將兩個陣列合併為一個按 id 以遞增順序排列的陣列,並符合下述條件:

  • 只有在兩個陣列中至少出現過一次的 id 才能包含在結果陣列內。
  • 每個 id 在結果陣列中 只能出現一次 ,並且其對應的值等於兩個陣列中該 id 所對應的值求和。如果某個陣列中不存在該 id ,則認為其對應的值等於 0 。

返回結果陣列。返回的陣列需要按 id 以遞增順序排列。

題解

簡單模擬題,使用雙指標合併陣列即可。

class Solution {
    fun mergeArrays(nums1: Array<IntArray>, nums2: Array<IntArray>): Array<IntArray> {
        val n = nums1.size
        val m = nums2.size
        val result = LinkedList<IntArray>()
        var index1 = 0
        var index2 = 0
        while (index1 < n && index2 < m) {
            val e1 = nums1[index1]
            val e2 = nums2[index2]
            if (e1[0] == e2[0]) {
                result.add(intArrayOf(e1[0], e1[1] + e2[1]))
                index1++
                index2++
            } else if (e1[0] < e2[0]) {
                result.add(e1)
                index1++
            } else {
                result.add(e2)
                index2++
            }
        }
        while (index1 < n) {
            result.add(nums1[index1++])
        }
        while (index2 < m) {
            result.add(nums2[index2++])
        }
        return result.toTypedArray()
    }
}

複雜度分析:

  • 時間複雜度:$O(n + m)$
  • 空間複雜度:$O(1)$

2571. 將整數減少到零需要的最少運算元(Medium)

題目地址

https://leetcode.cn/problems/minimum-operations-to-reduce-an-integer-to-0/

題目描述

給你一個正整數 n ,你可以執行下述操作 任意 次:

  • n 加上或減去 2 的某個 

返回使 n 等於 0 需要執行的 最少 運算元。

如果 x == 2i 且其中 i >= 0 ,則數位 x 是 2 的冪。

題解一(貪心 + 記憶化遞迴)

這道題在競賽時的標籤是 Easy,實際上應該是 Medium,收錄到題庫後官方也改成 Medium了。

題目明顯是決策模型,首先要想到回溯、貪心、動態規劃等思路。

如果用暴力回溯如何解決呢?顯然,在每一輪決策中,我們可以選擇數位的二進位制表示中任意一個 「1」,並相應地加上 $2^k$ 或減去 $2^k$,終止條件為剩下最後一個 「1」 時,必然減去 $2^k$。

事實上,我們發現在每一輪決策中並不需要列舉所有選擇,只需要從最低位的 「1」 開始消除,最終總能得到最優解。這是因為最低位受到的約束最小,低位的加法會影響高位併產生連續的 111,有可能使結果更優,而高位的加減對低位沒有影響,不會對結果產生更優解。

所以我們的演演算法是:獲取當前數位最低位的 $1= 2^k$,嘗試加上 $2^k$ 或減去 $2^k$ 並將問題轉換為規模更小的數,直到剩下的數正好是 2 的冪結束。遞迴過程中會存在重複狀態,所以需要加上記憶化剪枝。

class Solution {
    // 備忘錄
    private val memo = HashMap<Int, Int>()

    fun minOperations(n: Int): Int {
        // n 是 2 的冪
        if (n and (n - 1) == 0) return 1
        if (memo.containsKey(n)) return memo[n]!!
        // 最低位 1
        val lowbit = n and -n
        val result = 1 + Math.min(minOperations(n + lowbit), minOperations(n - lowbit))
        memo[n] = result
        return result
    }
}

複雜度分析:

  • 時間複雜度:$O(C)$ 其中 $C$ 是所有測試用例合併的狀態數,每個狀態的時間複雜度是 $O(1)$。如果以單個測試用例分析複雜度,則時間複雜度是 $O(c)$,$c$ 是 int 的位數。
  • 空間複雜度:$O(C)$ 雜湊表空間。

題解二(貪心 + 統計 1 的個數)

我們發現: 當執行某個操作後,使得二進位制中 1 的個數更少的方案最終總的操作次數一定更低。

例如:當最低位 1 是連續的多個 111 時,採用加法可以一次性消除多個 「1」,否則減法固定消除單個 「1」 更優。

  • 1011, 1101:加法後 = 1011, 1110,減法後:1011, 1100(減法更優)
  • 1011, 1111:加法後 = 1100, 0000,減法後:1011, 1110(加法更優)

因此我們的演演算法是:在每一步選擇中直接以試錯的方式做貪心選擇,先比較操作後結果中二進位制中 1 的個數,再選擇更優的操作。

class Solution {
    fun minOperations(n: Int): Int {
        var num = n
        var operateCount = 0
        while (num != 0) {
            // 最低位 1
            val lowbit = num and -num
            // 直接判斷
            if (Integer.bitCount(num + lowbit) <= Integer.bitCount(num - lowbit)) {
                num += lowbit
            } else {
                num -= lowbit
            }
            operateCount++
        }
        return operateCount
    }
}

複雜度分析:

  • 時間複雜度:$O(mlgm)$ 其中 $m$ 是數位中 1 的個數,單次統計位 1 的操作時間複雜度是 $O(lgm)$。
  • 空間複雜度:$O(1)$ 只使用常數級別空間。

題解三(位運算優化)

思路參考:靈茶山艾府的題解

繼續題解二的思路,連續多個 1 的最優解是先加上 lowbit 再減去 lowbit,那麼最多需要操作兩次,而單個 1 的最優解是直接減去 lowbit,最多隻要操作一次。

我們發現:

// 連續 1 的情況:
n        = 0011, 1111
3n       = 1011, 1101
n xor 3n = 1000, 0010 // 正好得到 2 個 1

// 單個 1 的情況:
n        = 0100
3n       = 1100
n xor 3n = 1000 // 正好得到 1 個 1

因此答案就是 n xor 3n 中 1 的個數。

class Solution {
    fun minOperations(n: Int): Int {
        return Integer.bitCount(n xor 3 * n)
    }
}

複雜度分析:

  • 時間複雜度:$O(1)$
  • 空間複雜度:$O(1)$

2572. 無平方子集計數(Medium)

題目地址

https://leetcode.cn/problems/count-the-number-of-square-free-subsets/

題目描述

給你一個正整數陣列 nums 。

如果陣列 nums 的子集中的元素乘積是一個 無平方因子數 ,則認為該子集是一個 無平方 子集。

無平方因子數 是無法被除 1 之外任何平方數整除的數位。

返回陣列 nums 中 無平方 且 非空 的子集數目。因為答案可能很大,返回對 109 + 7 取餘的結果。

nums 的 非空子集 是可以由刪除 nums 中一些元素(可以不刪除,但不能全部刪除)得到的一個陣列。如果構成兩個子集時選擇刪除的下標不同,則認為這兩個子集不同。

預備知識

  • 質數 / 素數:只能被 1 和本身整除的數,例如 3,5,7;
  • 合數:除了能被 1 和本身整除外,還能被其他數整除的數。也可以理解為由多個不為 1 的質數相乘組成的數,例如 4 = 2 * 2,6 = 2 * 3。
  • 1 既不是質數也不是合數。
  • 質因數分解:將合數分解為多個質數相乘的形式,其中的質數就是合數的質因子。例如 10 包含質因子 2 和 5,12 包含質因子 2 和 3。
  • 狀態壓縮:用一個維度(通常是二進位制數)表示所有物品存在或不存在的狀態。

題解一(狀態壓縮 + 01 揹包)

這道題的標籤是 Medium,但實際上是 Hard。

題目的核心是求 「乘積是無平方因子數的子集」 數目,顯然有:

  • 1、當子集中存在平方數時,該子集一定不是解。 例如子集中存在 49 或 25 時,子集的乘積一定存在平方因子;
  • 2、當子集中任意兩個數存在相同的質因子時,該子集一定不是解。 例如子集中存在 62,這兩個數都存在相同的質因子 「2」,因此它們的乘積一定存在平方因子。
  • 3、我們觀察到本題的輸入資料範圍只有 [1, 30],30 以內的質數只有 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 總共 10 個數, 所以我們可以預先對 2 ~ 30 的數位進行質因數分解,並且使用二進位制掩碼 Mask 記錄數位是否包含某個質因子。 例如:
    • 00, 0000, 0011:表示存在質因子 2 和 3
    • 11, 1111, 1111:表示存在所有質因子(只是舉例,本題不存在)

所以,我們的演演算法思路應該是: 從數位列表選擇中若干個數,如果所有質因子的出現次數不超過 1,則可以組成合法的子集, 例如 [3, 5] 中所有質因子最多隻出現 1 次,因此構成一個合法的子集。

「從數位列表選擇中若干個數」, 由此我們發現原問題可以轉換為熟悉揹包問題 —— 計算揹包可以容納的物品組合方案數:

  • 物品:每一個數位是一個不可分割的物品,我們不可能選擇半個數;
  • 物品體積:每個物品所包含的質因子就是該物品的體積;
  • 揹包容積:揹包容積為 10,即揹包最多隻能包含 10 個質因子;
  • 限制條件:揹包內的數位的質因子不能重複。

完成問題轉換後,按照熟悉的揹包問題模板解決即可:

  • 狀態轉移方程:dp[i][j] = dp[i - 1][j] + dp[i - 1][j xor mask]
class Solution {

		companion object {
        private val MOD = 1000000007
        private val primeList = listOf(2, 3, 5, 7, 11, 13, 17, 19, 23, 29)
        private val masks = IntArray(31).apply {
            for (i in 2..30) {
                for ((j, prime) in primeList.withIndex()) {
                    // 過濾平方因子數
                    if (i % (prime * prime) == 0) {
                        this[i] = -1
                        break
                    }
                    // 記錄質因子
                    if (i % prime == 0) this[i] = this[i] or (1 shl j)
                }
            }
        }
    }

    fun squareFreeSubsets(nums: IntArray): Int {
        // 揹包問題

        // 過濾平方因子數(數位 1 的 mask == 0)
        val numsFiltered = nums.filter { masks[it] >= 0 }
        // 物品總數
        val n = numsFiltered.size
        // 揹包容積 11,1111,1111
        val amount = (1 shl 10) - 1
        // dp[i][j] 表示選擇前 i 個物品且體積為 j 的方案數
        val dp = Array(n + 1) { IntArray(amount + 1) }.apply {
            // 選擇前 i 個物品且體積為 0 的方案為 1
            this[0][0] = 1
        }
        // 列舉物品
        for (i in 1..n) {
            // 物品的質因子屬性
            val mask = masks[numsFiltered[i - 1]]
            // 列舉體積
            for (j in 0..amount) {
                dp[i][j] = dp[i - 1][j]
                // j | mask == j => mask 是 j 的子集
                if (j or mask == j) dp[i][j] += dp[i - 1][j xor mask]
            }
        }
        // 題目不要求揹包裝滿,所以 dp[n - 1][...] 的方案都包含,最後再去掉空集
        return dp[n].sum() - 1
    }
}

考慮大數越界問題:

fun squareFreeSubsets(nums: IntArray): Int {
    // 揹包問題

    // 過濾平方因子數(數位 1 的 mask == 0)
    val numsFiltered = nums.filter { masks[it] >= 0 }
    // 物品總數
    val n = numsFiltered.size
    // 揹包容積 11,1111,1111
    val amount = (1 shl 10) - 1
    // dp[i][j] 表示選擇前 i 個物品且體積為 j 的方案數
    val dp = Array(n + 1) { IntArray(amount + 1) }.apply {
        // 選擇前 i 個物品且體積為 0 的方案為 1
        this[0][0] = 1
    }
    // 列舉物品
    for (i in 1..n) {
        // 物品的質因子屬性
        val mask = masks[numsFiltered[i - 1]]
        // 列舉體積
        for (j in 0..amount) {
            dp[i][j] = dp[i - 1][j]
            // j | mask == j => mask 是 j 的子集
            if (j or mask == j) dp[i][j] = (dp[i][j] + dp[i - 1][j xor mask]) % MOD
        }
    }
    // 題目不要求揹包裝滿,所以 dp[n - 1][...] 的方案都包含,最後再去掉空集
    var sum = 0L
    for (count in dp[n]) {
        sum += count
    }
    return ((sum - 1 + MOD) % MOD).toInt()
}

01 揹包問題可以取消物品維度降低空間複雜度:

fun squareFreeSubsets(nums: IntArray): Int {
    // 揹包問題

    // 物品總數
    val n = nums.size
    // 揹包容積 11,1111,1111
    val amount = (1 shl 10) - 1
    // dp[i][j] 表示選擇前 i 個物品且體積為 j 的方案數
    val dp = IntArray(amount + 1).apply {
        // 選擇前 i 個物品且體積為 0 的方案為 1
        this[0] = 1
    }
    // 列舉物品
    for (i in 1..n) {
        // 物品的質因子屬性
        val mask = masks[nums[i - 1]]
        // 過濾平方因子數
        if (mask < 0) continue
        // 列舉體積(從大到小遍歷)
        for (j in amount downTo 0) {
            // j | mask == j => mask 是 j 的子集
            if (j or mask == j) dp[j] = (dp[j] + dp[j xor mask]) % MOD
        }
    }
    // 題目不要求揹包裝滿,所以 dp[n - 1][...] 的方案都包含,最後再去掉空集
    var sum = 0L
    for (count in dp) {
        sum += count
    }
    return ((sum - 1 + MOD) % MOD).toInt()
}

複雜度分析:

  • 時間複雜度:$O(n^{2m})$ 其中 $n$ 是 $nums$ 陣列的長度,$m$ 是質數的個數(m ≤ 10)。
  • 空間複雜度:$O(2^{2m} + 31)$ $dp$ 陣列空間與預處理的二進位制掩碼陣列。

題解二(計數優化)

題解一還有優化空間。

在外層迴圈中,我們列舉的是物品維度,如果同一個物品中存在多個時,會存在重複計算。因此,我們可以預處理物品列表,統計不同物品的出現次數。舉例說明:

  • 在物品列表 [3, 3, 5] 中物品 [3] 出現了兩次,而這兩個物品 3 都可以和物品 [5] 組成目標子集,總個數 = [3] 出現次數 * 其他子集個數;
  • 物品 1 較特殊,在物品列表 [1, 1, 5] 中,物品 [1] 可以與物品 [5] 組成目標子集,同時任意個數的 [1, 1] 也可以 [5] 組成目標子集,總個數 = $2^{[1] 出現次數}$ * 其他子集個數;
class Solution {

    companion object {
        private val MOD = 1000000007
        private val primeList = listOf(2, 3, 5, 7, 11, 13, 17, 19, 23, 29)
        private val masks = IntArray(31).apply {
            for (i in 2..30) {
                for ((j, prime) in primeList.withIndex()) {
                    // 過濾平方因子數
                    if (i % (prime * prime) == 0) {
                        this[i] = -1
                        break
                    }
                    // 記錄質因子
                    if (i % prime == 0) this[i] = this[i] or (1 shl j)
                }
            }
        }
    }

    fun squareFreeSubsets(nums: IntArray): Int {
        // 元素計數
        var pow1 = 1L
        val cnts = IntArray(31).apply {
            for (element in nums) {
                if (element == 1) pow1 = pow1 * 2 % MOD else this[element]++
            }
        }
        // 物品總數
        val n = nums.size
        // 揹包容積 11,1111,1111
        val amount = (1 shl 10) - 1
        // dp[i][j] 表示選擇前 i 個物品且體積為 j 的方案數
        val dp = LongArray(amount + 1).apply {
            // 選擇前 i 個物品且體積為 0 的方案為 1
            this[0] = 1
        }
        // 列舉去重物品
        for ((num, cnt) in cnts.withIndex()) {
            // 物品的質因子屬性
            val mask = masks[num]
            // 過濾不存在的物品
            if (cnt <= 0) continue
            // 過濾平方因子數和 1 
            if (mask <= 0) continue
            // 列舉體積(從大到小遍歷)
            for (j in amount downTo 0) {
                // j | mask == j => mask 是 j 的子集
                if (j or mask == j) dp[j] = (dp[j] + dp[j xor mask] * cnt) % MOD
            }
        }
        // 題目不要求揹包裝滿,所以 dp[n - 1][...] 的方案都包含,最後再去掉空集
        var sum = 0L
        for (count in dp) {
            sum = (sum + count) % MOD
        }
        // 1 的任意組合可以與其他子集組合
        return ((sum * pow1 - 1 + MOD) % MOD).toInt()
    }
}

複雜度分析:

  • 時間複雜度:$O(n + q^{2m})$ 其中 $n$ 是 $nums$ 陣列的長度,$m$ 是質數的個數(m ≤ 10), $q$ 是輸入資料範圍內非平方因子數的個數$(q ≤ 18)$
  • 空間複雜度:$O(2^{2m} + 31)$ $dp$ 陣列空間與預處理的二進位制掩碼陣列。

2573. 找出對應 LCP 矩陣的字串(Hard)

題目地址

https://leetcode.cn/problems/find-the-string-with-lcp/description/

題目描述

對任一由 n 個小寫英文字母組成的字串 word ,我們可以定義一個 n x n 的矩陣,並滿足:

  • lcp[i][j] 等於子字串 word[i,...,n-1] 和 word[j,...,n-1] 之間的最長公共字首的長度。

給你一個 n x n 的矩陣 lcp 。返回與 lcp 對應的、按字典序最小的字串 word 。如果不存在這樣的字串,則返回空字串。

對於長度相同的兩個字串 a 和 b ,如果在 a 和 b 不同的第一個位置,字串 a 的字母在字母表中出現的順序先於 b 中的對應字母,則認為字串 a 按字典序比字串 b 小。例如,"aabd" 在字典上小於 "aaca" ,因為二者不同的第一位置是第三個字母,而 'b' 先於 'c' 出現。

預備知識

LCP 矩陣的定義是一個字串中的 [i, 字串末尾][j, 字串末尾] 兩個子串的最長公共字首的長度。根據定義可以得出基本性質:

  • 性質 1:當 LCP[i][j] 等於 0 時,位於 str[i]str[j] 的兩個字元一定不相同;反之當 LCP[i][j] 大有 0 時,位於 str[i]str[j] 的兩個字元一定相同。
  • 性質 2:LCP 矩陣的定義可以利用動態規劃推導(與兩個字串的最長公共字首類似):
    • str[i] == str[j] 時,LCP[i][j] = 0(無共同字首)
    • str[i] == str[j] 時,LCP[i][j] = LCP[i + 1][j + 1] + 1

題解(貪心構造 + 動態規劃)

貪心思路: 題目要求輸出滿足 LCP 矩陣的字典序最小的結果,那麼我們應該優先選擇數值最小的字母 ‘a’。

可以用反證法證明:假設 「bcbc」 是滿足條件且字典序最小的結果,那麼我們可以將 ‘b’ 對映為 ‘a’,而 ‘c’ 對映為 ‘b’ 得到 「abab」。由於 LCP 矩陣只考慮公共字首長度而不考慮字母,所以 「abab」 一定符合同一個 LCP 矩陣定義,與假設矛盾。

因此,我們的演演算法思路是:從 s[0] 開始填入 ‘a’,並根據 LCP[0][j] > 0 將所有 s[j] 設定為同一個字元 ‘a’,依此類推。從下一個未填入的位置開始填入 ‘b’,並將所有相同的 LCP[i][j] > 0 的位置填入 ‘b’,直到字串結束或候選字元大於 ‘z’ 結束。

class Solution {
    fun findTheString(lcp: Array<IntArray>): String {
        // 目標字串長度
        val n = lcp.size
        // 1、構造字串
        // 目標字串
        val charArray = CharArray(n) { ' ' }
        // 候選字元序列 'a' -> 'z'
        var candidate = 'a'
        var i = 0
        while (i < n) {
            // 當前位置已經填充
            if (charArray[i] != ' ') {
                i++
                continue
            }
            // 候選字元不夠
            if (candidate > 'z') {
                return ""
            }
            // 填充相同字元的位置,並且使用字典序最小的候選字元
            for (j in i until n) {
                if (lcp[i][j] > 0) charArray[j] = candidate
            }
            // 下一個候選字元
            candidate += 1
            i++
        }
        return String(charArray)
    }
}

使用貪婪演演算法構造出字串後,我們還需要檢查字串是否符合 LCP 矩陣的定義。這是因為構造時只考慮 Lcp[i][j] > 0,但至於具體大於 0 的什麼數並沒有考慮,所以我們還需要驗證的過程:

class Solution {
    fun findTheString(lcp: Array<IntArray>): String {
        // 目標字串長度
        val n = lcp.size
        // 1、構造字串
        ...
        // 2、檢查字串是否符合 LCP(因為構造時只考慮 lcp[i][j] > 0)
        for (i in n - 1 downTo 0) {
            for (j in n - 1 downTo 0) {
                if (charArray[i] == charArray[j]) {
                    if (i == n - 1 || j == n - 1) {
                        if (lcp[i][j] != 1) return ""
                    } else {
                        if (lcp[i][j] != lcp[i + 1][j + 1] + 1) return ""
                    }
                } else {
                    if (lcp[i][j] != 0) return ""
                }
            }
        }
        return String(charArray)
    }
}

複雜度分析:

  • 時間複雜度:$O(n^2)$ 構造與驗證都是 $O(n^2)$ 級別。
  • 空間複雜度:$O(1)$ 不考慮結果字串,只使用了常數級別變數。