⭐️ 本文已收錄到 AndroidFamily,技術和職場問題,請關注公眾號 [彭旭銳] 和 BaguTree Pro 知識星球提問。
學習資料結構與演演算法的關鍵在於掌握問題背後的演演算法思維框架,你的思考越抽象,它能覆蓋的問題域就越廣,理解難度也更復雜。在這個專欄裡,小彭與你分享每場 LeetCode 周賽的解題報告,一起體會上分之旅。
本文是 LeetCode 上分之旅系列的第 42 篇文章,往期回顧請移步到文章末尾~
T1. 距離原點最遠的點(Easy)
T2. 找出美麗陣列的最小和(Medium)
T3. 使子序列的和等於目標的最少操作次數(Hard)
T4. 在傳球遊戲中最大化函數值(Hard)
https://leetcode.cn/problems/furthest-point-from-origin/
根據題意 「_」 既可以作為 「L」 也可以作為 「R」。容易想到,為了使得終點距離原點更遠,當所有 「_」 僅作為 「L」 或 「R」 對結果的貢獻是最優的,此時問題的結果就取決於 「L」 和 「R」 的差絕對值。
class Solution {
fun furthestDistanceFromOrigin(moves: String): Int {
return moves.count{ it == '_' } + abs(moves.count{ it == 'L' } - moves.count{ it == 'R' })
}
}
一次遍歷:
class Solution {
fun furthestDistanceFromOrigin(moves: String): Int {
var cntL = 0
var cntR = 0
for (e in moves) {
when (e) {
'L' -> {
cntL ++
cntR --
}
'R' -> {
cntL --
cntR ++
}
else -> {
cntL ++
cntR ++
}
}
}
return max(abs(cntL), abs(cntR))
}
}
複雜度分析:
https://leetcode.cn/problems/find-the-minimum-possible-sum-of-a-beautiful-array/
這道題與上週周賽 359 T2 2829. k-avoiding 陣列的最小總和 相比,除了資料範圍之外是完全相同的,有點離譜。
從 $1$ 開始從小到大列舉,如果當前元素 $cur$ 與已選列表不衝突,則加入結果中。為了驗證是否衝突,我們使用雜湊表在 $O(1)$ 時間複雜度判斷。
class Solution {
fun minimumPossibleSum(n: Int, k: Int): Long {
val set = HashSet<Int>()
var sum = 0L
var cur = 1
repeat(n) {
while (!set.isEmpty() && set.contains(k - cur)) cur++
sum += cur
set.add(cur)
cur++
}
return sum
}
}
複雜度分析:
這道題還可以繼續挖掘數學規律,我們發現當我們從 $1$ 開始從小到大列舉時,每選擇一個數的同時必然會使得另一個數 $k - x$ 不可選。例如:
可以發現,最終選擇的元素被分為兩部分:
我們令 $m = min(k / 2, n)$,使用求和公式可以 $O(1)$ 求出兩部分的總和:
class Solution {
fun minimumPossibleSum(n: Int, k: Int): Long {
val m = 1L * Math.min(n, k / 2)
return m * (m + 1) / 2 + (n - m) * (2 * k + n - m - 1) / 2
}
}
複雜度分析:
https://leetcode.cn/problems/minimum-operations-to-form-subsequence-with-target-sum/
這道題的考點不復雜,難點在模擬問題挺考驗編碼功底的。
# 二進位制位
nums: _ _ _ 1 _ _ _ _
target: _ _ _ _ _ 1 _ _
以範例 1 nums = [1,2,8], target = 7
與範例 2 nums = [1,32,1,2], target = 12
為例,我們將統計 $nums$ 中不同 $2$ 的冪的出現次數:
# 二進位制位
nums: _ _ _ _ 1 _ 1 1
target: _ _ _ _ _ 1 1 1
# 二進位制位
nums: _ _ 1 _ _ _ 1 2 # 1 出現 2 次
target: _ _ _ _ 1 1 _ _
那麼當我們從右向左列舉二進位制位 $k$ 時,如果「$nums$ 中小於等於 $2^k$ 的元素和」$≥$ 「$target$ 中低於等於 $k$ 位的值」,那麼對於僅考慮 $[0, k]$ 位上的子問題是有解的。否則,我們需要找到 $nums$ 中最近大於 $2^k$ 的最近陣列做拆分:
# 只考慮低 2 位,可以構造
nums: _ _ _ _ 1 _ | 1 1
target: _ _ _ _ _ 1 | 1 1
# 只考慮低 3 位,無法構造,需要找到最近的 「1」 做拆分
nums: _ _ _ _ 1 | _ 1 1
target: _ _ _ _ _ | 1 1 1
# 只考慮低 3 位,無法構造,需要找到最近的 「1」 做拆分
nums: _ _ 1 _ _ | _ 1 2
target: _ _ _ _ 1 | 1 _ _
# 只考慮低 6 位,可以構造
nums: _ _ | 1 _ _ _ 1 2
target: _ _ | _ _ 1 1 _ _
組合以上技巧:
思路參考靈神的題解。
注意一個容易 WA 的地方,在開頭特判的地方,由於元素和可能會溢位 $Int$ 上界,所以我們需要轉換為在 $Long$ 上的求和。
class Solution {
fun minOperations(nums: List<Int>, target: Int): Int {
if (nums.fold(0L) { it, acc -> it + acc } < target) return -1
// if (nums.sum() < target) return -1 // 溢位
// 計數
val cnts = IntArray(32)
for (num in nums) {
var i = 0
var x = num
while (x > 1) {
x = x shr 1
i += 1
}
cnts[i]++
}
var ret = 0
var i = 0
var sum = 0L
while(sum < target) {
// 累加低位的 nums
sum += (cnts[i]) shl i
// println("i=$i, sum=$sum")
// 低 i 位掩碼
val mask = (1 shl (i + 1)) - 1
// 構造子問題
if (sum < target and mask) {
var j = i + 1
while (cnts[j] == 0) { // 基於開頭的特判,此處一定有解
j++
}
// 拆分
ret += j - i
i = j
} else {
i += 1
}
}
return ret
}
}
複雜度分析:
在計數的部分,我們可以使用雜湊表模擬,複雜度相同。
class Solution {
fun minOperations(nums: List<Int>, target: Int): Int {
if (nums.fold(0L) { it, acc -> it + acc } < target) return -1
// if (nums.sum() < target) return -1 // 溢位
// 計數
val cnts = HashMap<Int, Int>()
for (num in nums) {
cnts[num] = cnts.getOrDefault(num, 0) + 1
}
var ret = 0
var i = 0
var sum = 0L
while(sum < target) {
// 累加低位的 nums
sum += (cnts[1 shl i] ?: 0) shl i
// println("i=$i, sum=$sum")
// 低 i 位掩碼
val mask = (1 shl (i + 1)) - 1
// 構造子問題
if (sum < target and mask) {
var j = i + 1
while (!cnts.containsKey(1 shl j)) { // 基於開頭的特判,此處一定有解
j++
}
// 拆分
ret += j - i
i = j
} else {
i += 1
}
}
return ret
}
}
複雜度分析:
思路參考雪景式的題解,前兩種寫法是在從小到大列舉「選哪個」,我們也可以列舉「不選哪個」。
class Solution {
fun minOperations(nums: MutableList<Int>, target: Int): Int {
var sum = nums.fold(0L) { it, acc -> it + acc }
if (sum < target) return -1
// 排序
nums.sortDescending()
// 從大到小列舉
var ret = 0
var left = target
while (sum > left) {
val x = nums.removeFirst()
if (sum - x >= left){
sum -= x
} else if (x <= left) {
sum -= x
left -= x
} else {
ret += 1
nums.add(0, x / 2)
nums.add(0, x / 2)
}
// println("ret=$ret, sum=$sum, left=$left, x=$x, nums=${nums.joinToString()}")
}
return ret
}
}
複雜度分析:
https://leetcode.cn/problems/maximize-value-of-function-in-a-ball-passing-game/
從近期周賽的趨勢看,出題人似乎有意想把 LeetCode 往偏競賽的題目引導。
這道題如果知道樹上倍增演演算法,其實比第三題還簡單一些。
class Solution {
fun getMaxFunctionValue(receiver: List<Int>, k: Long): Long {
val n = receiver.size
val m = 64 - k.countLeadingZeroBits()
// 預處理
// dp[i][j] 表示 i 傳球 2^j 次後的節點
val dp = Array(n) { IntArray(m) }
// dp[i][j] 表示 i 傳球 2^j 次的路徑和
val sum = Array(n) { LongArray(m) }
for (i in 0 until n) {
dp[i][0] = receiver[i]
sum[i][0] = receiver[i].toLong()
}
for (j in 1 until m) {
for (i in 0 until n) { // 這道題沒有根節點,不需要考慮 child == -1 的情況
val child = dp[i][j - 1]
// 從 i 條 2^{j-1} 次,再跳 2^{j-1}
dp[i][j] = dp[child][j - 1]
sum[i][j] = sum[i][j - 1] + sum[child][j - 1]
}
}
// 列舉方案
var ret = 0L
for (node in 0 until n) {
var i = node
var x = k
var s = node.toLong() // 起點的貢獻
while (x != 0L) {
val j = x.countTrailingZeroBits()
s += sum[i][j]
i = dp[i][j]
x = x and (x - 1)
}
ret = max(ret, s)
}
return ret
}
}
複雜度分析:
另外,這道題還有基於「內向基環樹」的 $O(n)$ 解法。
推薦閱讀
LeetCode 上分之旅系列往期回顧:
⭐️ 永遠相信美好的事情即將發生,歡迎加入小彭的 Android 交流社群~