本文已收錄到 AndroidFamily,技術和職場問題,請關注公眾號 [彭旭銳] 提問。
大家好,我是小彭。
上週是 LeetCode 第 333 場周賽,你參加了嗎?這場周賽質量很高,但難度標得不對,我真的會謝。演演算法解題思維需要長時間鍛鍊,加入我們一起刷題吧~
小彭的 Android 交流群 02 群已經建立啦,公眾號回覆 「加群」 加入我們~
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 以遞增順序排列的陣列,並符合下述條件:
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()
}
}
複雜度分析:
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
}
}
複雜度分析:
我們發現: 當執行某個操作後,使得二進位制中 1 的個數更少的方案最終總的操作次數一定更低。
例如:當最低位 1 是連續的多個 111 時,採用加法可以一次性消除多個 「1」,否則減法固定消除單個 「1」 更優。
因此我們的演演算法是:在每一步選擇中直接以試錯的方式做貪心選擇,先比較操作後結果中二進位制中 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 的最優解是先加上 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)
}
}
複雜度分析:
https://leetcode.cn/problems/count-the-number-of-square-free-subsets/
給你一個正整數陣列 nums
。
如果陣列 nums
的子集中的元素乘積是一個 無平方因子數 ,則認為該子集是一個 無平方 子集。
無平方因子數 是無法被除 1
之外任何平方數整除的數位。
返回陣列 nums
中 無平方 且 非空 的子集數目。因為答案可能很大,返回對 109 + 7
取餘的結果。
nums
的 非空子集 是可以由刪除 nums
中一些元素(可以不刪除,但不能全部刪除)得到的一個陣列。如果構成兩個子集時選擇刪除的下標不同,則認為這兩個子集不同。
這道題的標籤是 Medium,但實際上是 Hard。
題目的核心是求 「乘積是無平方因子數的子集」 數目,顯然有:
4
或 9
或 25 時,子集的乘積一定存在平方因子;6
和 2
,這兩個數都存在相同的質因子 「2」
,因此它們的乘積一定存在平方因子。2, 3, 5, 7, 11, 13, 17, 19, 23, 29
總共 10 個數, 所以我們可以預先對 2 ~ 30 的數位進行質因數分解,並且使用二進位制掩碼 Mask 記錄數位是否包含某個質因子。 例如:
所以,我們的演演算法思路應該是: 從數位列表選擇中若干個數,如果所有質因子的出現次數不超過 1,則可以組成合法的子集, 例如 [3, 5] 中所有質因子最多隻出現 1 次,因此構成一個合法的子集。
「從數位列表選擇中若干個數」, 由此我們發現原問題可以轉換為熟悉揹包問題 —— 計算揹包可以容納的物品組合方案數:
完成問題轉換後,按照熟悉的揹包問題模板解決即可:
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()
}
複雜度分析:
題解一還有優化空間。
在外層迴圈中,我們列舉的是物品維度,如果同一個物品中存在多個時,會存在重複計算。因此,我們可以預處理物品列表,統計不同物品的出現次數。舉例說明:
[3, 3, 5]
中物品 [3]
出現了兩次,而這兩個物品 3
都可以和物品 [5]
組成目標子集,總個數 = [3] 出現次數 * 其他子集個數;[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()
}
}
複雜度分析:
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, 字串末尾]
兩個子串的最長公共字首的長度。根據定義可以得出基本性質:
LCP[i][j]
等於 0
時,位於 str[i]
與 str[j]
的兩個字元一定不相同;反之當 LCP[i][j]
大有 0 時,位於 str[i]
與 str[j]
的兩個字元一定相同。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)
}
}
複雜度分析: