大家好,我是小彭。
上週是 LeetCode 第 333 場周賽,你參加了嗎?這場周賽質量很高,但難度標得不對,我真的會謝。算法解題思維需要長時間鍛鍊,加入我們一起刷題吧~
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()
}
}
複雜度分析:
- 時間複雜度:
- 空間複雜度:
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”,並相應地加上 或減去 ,終止條件爲剩下最後一個 “1” 時,必然減去 。
事實上,我們發現在每一輪決策中並不需要枚舉所有選擇,只需要從最低位的 “1” 開始消除,最終總能得到最優解。這是因爲最低位受到的約束最小,低位的加法會影響高位併產生連續的 111,有可能使結果更優,而高位的加減對低位沒有影響,不會對結果產生更優解。
所以我們的算法是:獲取當前數字最低位的 ,嘗試加上 或減去 並將問題轉換爲規模更小的數,直到剩下的數正好是 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
}
}
複雜度分析:
- 時間複雜度: 其中 是所有測試用例合併的狀態數,每個狀態的時間複雜度是 。如果以單個測試用例分析複雜度,則時間複雜度是 , 是 int 的位數。
- 空間複雜度: 散列表空間。
題解二(貪心 + 統計 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
}
}
複雜度分析:
- 時間複雜度: 其中 是數字中 1 的個數,單次統計位 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)
}
}
複雜度分析:
- 時間複雜度:
- 空間複雜度:
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、當子集中存在平方數時,該子集一定不是解。 例如子集中存在
4
或9
或 25 時,子集的乘積一定存在平方因子; -
2、當子集中任意兩個數存在相同的質因子時,該子集一定不是解。 例如子集中存在
6
和2
,這兩個數都存在相同的質因子“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()
}
複雜度分析:
- 時間複雜度: 其中 是 數組的長度, 是質數的個數(m ≤ 10)。
- 空間複雜度: 數組空間與預處理的二進制掩碼數組。
題解二(計數優化)
題解一還有優化空間。
在外層循環中,我們枚舉的是物品維度,如果同一個物品中存在多個時,會存在重複計算。因此,我們可以預處理物品列表,統計不同物品的出現次數。舉例說明:
- 在物品列表
[3, 3, 5]
中物品[3]
出現了兩次,而這兩個物品3
都可以和物品[5]
組成目標子集,總個數 = [3] 出現次數 * 其他子集個數; - 物品 1 較特殊,在物品列表
[1, 1, 5]
中,物品[1]
可以與物品[5]
組成目標子集,同時任意個數的[1, 1]
也可以[5]
組成目標子集,總個數 = * 其他子集個數;
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()
}
}
複雜度分析:
- 時間複雜度: 其中 是 數組的長度, 是質數的個數(m ≤ 10), 是輸入數據範圍內非平方因子數的個數
- 空間複雜度: 數組空間與預處理的二進制掩碼數組。
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)
}
}
複雜度分析:
- 時間複雜度: 構造與驗證都是 級別。
- 空間複雜度: 不考慮結果字符串,只使用了常數級別變量。