天天看點

LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序

作者:彭旭銳

大家好,我是小彭。

上周末是 LeetCode 第 338 場周賽,你參加了嗎?這場周賽覆寫的知識點很多,第四題稱得上是近期幾場周賽的天花闆。

目錄

2599. K 件物品的最大和(Easy)

  • 貪心、模拟 O(1)

2600. 質數減法運算(Medium)

  • 題解一:暴力枚舉 + 二分查找 O(U\sqrt{U} + nlgU)
  • 題解二:Eratosthenes 埃氏篩 + 二分查找 O(UlgU + nlgU)
  • 題解三:Euler 歐氏線性篩 + 二分查找 O(U + nlgU)

2601. 使數組元素全部相等的最少操作次數

  • 字首和 + 二分查找 O(nlgn + qlgn)

2602. 收集樹中金币(Hard)

  • 拓撲排序 + 模拟 O(n)
LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序
LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序

2599. K 件物品的最大和(Easy)

題目位址

https://leetcode.cn/problems/k-items-with-the-maximum-sum/description/

題目描述

袋子中裝有一些物品,每個物品上都标記着數字 1 、0 或 -1 。

給你四個非負整數 numOnes 、numZeros 、numNegOnes 和 k 。

袋子最初包含:

  • numOnes 件标記為 1 的物品。
  • numZeroes 件标記為 0 的物品。
  • numNegOnes 件标記為 1 的物品。

現計劃從這些物品中恰好選出 k 件物品。傳回所有可行方案中,物品上所标記數字之和的最大值。

LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序

題解(貪心 + 模拟)

簡單模拟題,優先選擇 1,其次 0,最後 -1。

class Solution {
    fun kItemsWithMaximumSum(numOnes: Int, numZeros: Int, numNegOnes: Int, k: Int): Int {
        var x = k
        // 1
        val oneCnt = Math.min(numOnes, x)
        x -= oneCnt
        if (x == 0) return oneCnt
        // 0
        x -= Math.min(numZeros, x)
        if (x == 0) return oneCnt
        // -1
        return oneCnt - x
    }
}
           

複雜度分析:

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

2600. 質數減法運算(Medium)

題目位址

https://leetcode.cn/problems/prime-subtraction-operation/description/

題目描述

給你一個下标從 0 開始的整數數組 nums ,數組長度為 n 。

你可以執行無限次下述運算:

  • 選擇一個之前未選過的下标 i ,并選擇一個 嚴格小于 nums[i] 的質數 p ,從 nums[i] 中減去 p 。

如果你能通過上述運算使得 nums 成為嚴格遞增數組,則傳回 true ;否則傳回 false 。

嚴格遞增數組 中的每個元素都嚴格大于其前面的元素。

LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序

預備知識

這道題如果熟悉質數篩就是簡單二分查找問題。

1、質數定義:

  • 質數 / 素數:隻能被 1 和本身整除的數,例如 3,5,7;
  • 合數:除了能被 1 和本身整除外,還能被其他數整除的數。也可以了解為由多個不為 1 的質數相乘組成的數,例如 4 = 2 _ 2,6 = 2 _ 3。
  • 1 既不是質數也不是合數。

2、判斷 x 是否為質數:

可以枚舉 [2,n-1] 的所有數,檢查是否是 x 的因數,時間複雜度是 O(n)。事實上并不需要枚舉到 n-1:以 n = 17 為例,假設從 2 枚舉到 4 都沒有發現因子,我們發現 5 也沒必要檢查。

可以用反證法證明:如果 17 能夠被 5 整除,那麼一定存在 17 能夠被 17/5 的另一個數整除。而由于 17/5 < 5 與前面驗證過 [2,4] 不存在因子的前提沖突。是以 5 不可能是因子。

由此得出,如果存在整除關系 n/x = y,我們隻需要檢查 x 和 y 之間的較小值。所有的較小值最大不會超過 n 的平方根。是以我們可以縮小檢查範圍,隻檢查 [1,�(�)],時間複雜度是 �(�)

3、計算所有小于 n 的質數,有三種算法:

  • 暴力枚舉: 枚舉每個數,判斷它是不是質數,整體時間複雜度是 �(��)
  • Eratosthenes 埃氏篩: 如果 x 不是質數,則從 x*x 開始将成倍的數字标記為非質數,整體時間複雜度是 O(UlgU);
  • Euler 歐氏線性篩: 标記 x 與 “小于等于 x 的最小質因子的質數” 的乘積為非質數,保證每個合數隻被标記最小的質因子标記。

題解一(暴力枚舉質數 + 二分查找)

為了獲得嚴格遞增數組,顯然數組的末位元素的限制是最弱的,甚至是沒有限制的。是以我們選擇從後往前處理,最後一個數不需要處理。

顯然如果靠後的元素盡可能大,那麼靠前的元素越容易滿足條件。是以,我們可以找到貪心思路:從後往前處理,每處理一個數字時,我們盡可能減去滿足題目要求的最小質數,減緩數字變小的速度,給靠前的數字留出空間。

容易發現,“滿足題目要求的最小質數” 存在單調性可以用二分查找解決。是以我們的題解分為 2 部分:

  • 1、預處理題目資料範圍内的所有質數,即小于 1000 的質數清單,可以用預置知識中的兩種;
  • 2、使用二分查找尋找 “滿足題目要求的最小質數”。
class Solution {

    companion object {
        // 1000 以内的質數清單
        private val primes = getPrimes(1000)

        // 暴力求質數
        private fun getPrimes(max: Int): IntArray {
            val primes = LinkedList<Int>()
            for (num in 2..max) {
                if (isPrime(num)) primes.add(num)
            }
            return primes.toIntArray()
        }

        // 質數判斷
        private fun isPrime(num: Int): Boolean {
            var x = 2
            while (x * x <= num) {
                if (num % x == 0) return false
                x++
            }
            return true
        }
    }

    fun primeSubOperation(nums: IntArray): Boolean {
        for (index in nums.size - 2 downTo 0) {
            if (nums[index] < nums[index + 1]) continue
            // 二分查找:尋找嚴格小于 nums[index] 且減去後形成遞增的最小質數
            var left = 0
            var right = primes.size - 1
            while (left < right) {
                val mid = (left + right) ushr 1
                if (primes[mid] >= nums[index] || nums[index] - primes[mid] < nums[index + 1]) {
                    right = mid
                } else {
                    left = mid + 1
                }
            }
            if (primes[left] >= nums[index] || nums[index] - primes[left] >= nums[index + 1]) return false
            nums[index] -= primes[left]
        }
        return true
    }
}
           

複雜度分析:

  • 時間複雜度:O(U·\sqrt{U} + nlgU) 其中 n 是 nums 數組的長度,U 是輸入資料範圍,U 為常數 1000;
  • 空間複雜度:O(1) 忽略預處理質數空間,僅使用常數級别空間。

題解二(Eratosthenes 埃氏篩 + 二分查找)

計算質數的部分可以用經典埃氏篩。

篩法求質數的思路是:如果 x 是質數,那麼 x 的整數倍 2x、3x 一定不是質數。我們設 isPrime[i] 表示 i 是否為質數,從小開始周遊,如果 i 是質數,則同時将所有倍數标記為合數。事實上,我們不必從 x 開始标記,而是直接從 x _ x 開始标記,因為 x _ 2, x * 3 已經在之前被标記過,會重複标記。

LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序
class Solution {

    companion object {
        // 1000 以内的質數清單
        private val primes = getPrimes(1000)

        // 埃氏篩求質數
        private fun getPrimes(max: Int): IntArray {
            val primes = LinkedList<Int>()
            val isPrime = BooleanArray(max + 1) { true }
            for (num in 2..max) {
                // 檢查是否為質數,這裡不需要調用 isPrime() 函數判斷是否質數,因為它沒被小于它的數标記過,那麼一定不是合數
                if (!isPrime[num]) continue
                primes.add(num)
                // 标記
                var x = num * num
                while (x <= max) {
                    isPrime[x] = false
                    x += num
                }
            }
            return primes.toIntArray()
        }
    }

    fun primeSubOperation(nums: IntArray): Boolean {
        val n = nums.size
        if (n <= 1) return true
        for (index in n - 2 downTo 0) {
            if (nums[index] < nums[index + 1]) continue
            // 二分查找
            var left = 0
            var right = primes.size - 1
            while (left < right) {
                val mid = (left + right) ushr 1
                if (primes[mid] >= nums[index] || nums[index] - primes[mid] < nums[index + 1]) {
                    right = mid
                } else {
                    left = mid + 1
                }
            }
            if (primes[left] >= nums[index] || nums[index] - primes[left] >= nums[index + 1]) return false
            nums[index] -= primes[left]
        }
        return true
    }
}
           

複雜度分析:

  • 時間複雜度:O(U·lgU + nlgU) 其中 n 是 nums 數組的長度,U 是輸入資料範圍,U 為常數 1000;
  • 空間複雜度:O(1) 忽略預處理質數空間,僅使用常數級别空間。

題解三(Euler 歐氏線性篩 + 二分查找)

盡管我們從 x * x 開始标記來減少重複标記,但 Eratosthenes 篩選法還是會重複标記一個合數。舉個例子,400 這個數不僅會被 2 标記一遍,還會被 5 标記,這就導緻了大量的重複計算。

為了避免重複标記,我們使用歐氏篩,它與埃氏篩不同的是:

  • 标記過程不再僅對質數 prime 标記,而是對每個數 x 标記;
  • 不再标記所有 x* x 的整數倍,而是标記 x 與 “小于等于 x 的最小質因子的質數” 的乘積,保證每個合數隻被标記最小的質因子标記。
class Solution {

    companion object {
        // 1000 以内的質數清單
        private val primes = getPrimes(1000)

        // 線性篩求質數
        private fun getPrimes(max: Int): IntArray {
            val primes = LinkedList<Int>()
            val isPrime = BooleanArray(max + 1) { true }
            for (num in 2..max) {
                // 檢查是否為質數,這裡不需要調用 isPrime() 函數判斷是否質數,因為它沒被小于它的數标記過,那麼一定不是合數
                if (isPrime[num]) {
                    primes.add(num)
                }
                // 标記
                for (prime in primes) {
                    if (num * prime > max) break
                    isPrime[num * prime] = false
                    if (num % prime == 0) break
                }
            }
            return primes.toIntArray()
        }
    }

    fun primeSubOperation(nums: IntArray): Boolean {
        val n = nums.size
        if (n <= 1) return true
        for (index in n - 2 downTo 0) {
            if (nums[index] < nums[index + 1]) continue
            // 二分查找
            var left = 0
            var right = primes.size - 1
            while (left < right) {
                val mid = (left + right) ushr 1
                if (primes[mid] >= nums[index] || nums[index] - primes[mid] < nums[index + 1]) {
                    right = mid
                } else {
                    left = mid + 1
                }
            }
            if (primes[left] >= nums[index] || nums[index] - primes[left] >= nums[index + 1]) return false
            nums[index] -= primes[left]
        }
        return true
    }
}
           

複雜度分析:

  • 時間複雜度:O(U + nlgU) 其中 n 是 nums 數組的長度,U 是輸入資料範圍,U 為常數 1000;
  • 空間複雜度:O(1) 忽略預處理質數空間,僅使用常數級别空間。

相似題目:

  • 204. 計數質數
  • 2523. 範圍内最接近的兩個質數

2601. 使數組元素全部相等的最少操作次數(Medium)

題目位址

https://leetcode.cn/problems/minimum-operations-to-make-all-array-elements-equal

題目描述

給你一個正整數數組 nums 。

同時給你一個長度為 m 的整數數組 queries 。第 i 個查詢中,你需要将 nums 中所有元素變成 queries[i] 。你可以執行以下操作 任意 次:

  • 将數組裡一個元素 增大 或者 減小 1 。

請你傳回一個長度為 m 的數組 **answer ,其中 **answer[i]是将 nums 中所有元素變成 queries[i] 的 最少 操作次數。

注意,每次查詢後,數組變回最開始的值。

LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序

題解(字首和 + 二分查找)

一道題很明顯有字首和問題,難點也正是如何把原問題轉換為字首和問題。 如果用暴力解法,我們隻需要計算每個元素到 queries[i] 的絕對值之和,單詞查詢操作的時間複雜度是 O(n),我們不考慮。

為了優化時間複雜度,我們分析資料的特征:

以 nums = [3,1,6,8], query = 5 為例,小于 5 的 3 和 1 需要增大才能變為 5,大于 5 的 6 和 8 需要減小才能變為 5。是以我們嘗試将數組分為兩部分 [3,1, | 6,8],需要執行加法的次數為 [+2,+4, | -1,-3]。

然而,我們不需要累加這 n 個內插補點的絕對值,而是使用字首和在 O(1) 時間内快速計算。如圖所示,小于 5 的部分可以用 “小于 5 的數字個數 _ 5” - “小于 5 的數字之和” 獲得,大于 5 的部分可以用 “大于 5 的數字之和” - “大于 5 的數字個數 _ 5” 計算:

LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序

而 “小于 5 的數字之和” 與 “大于 5 的數字之和” 是明顯的區間求和,可以用字首和數組在 O(1) 時間複雜度解決。至此,我們成功将原問題轉換為字首和問題。

那麼,剩下的問題是如何将數組拆分為兩部分?

我們發現對于單次查詢來說,我們可以使用快速選擇算法在 O(lgn) 時間内找到。但是題目不僅隻有一次,是以我們可以先對整個數組排序,再用二分查找找到枚舉的分割點。

最後一個細節,由于數組的輸入範圍很大,是以字首和數組要定義為 long 數組類型。

class Solution {
    fun minOperations(nums: IntArray, queries: IntArray): List<Long> {
        val n = nums.size
        // 排序
        nums.sort()
        // 字首和
        val preSums = LongArray(n + 1)
        for (index in nums.indices) {
            preSums[index + 1] = nums[index] + preSums[index]
        }
        val ret = LinkedList<Long>()
        for (target in queries) {
            // 二分查找尋找大于等于 target 的第一個元素
            var left = 0
            var right = n - 1
            while (left < right) {
                val mid = (left + right) ushr 1
                if (nums[mid] < target) {
                    left = mid + 1
                } else {
                    right = mid
                }
            }
            val index = if (nums[left] >= target) left - 1 else left
            val leftSum = 1L * (index + 1) * target - preSums[index + 1]
            val rightSum = preSums[n] - preSums[index + 1] - 1L * (n - 1 - index) * target
            ret.add(leftSum + rightSum)
        }
        return ret
    }
}
           

複雜度分析:

  • 時間複雜度:O(nlgn + qlgn) 其中 n 是 nums 數組的長度,q 是 queries 數組的長度。總共會執行 q 次查詢操作,每次查詢操作的時間複雜度是 O(lgn);
  • 空間複雜度:O(n) 字首和數組空間。

近期周賽字首和問題:

  • 周賽 336. 統計美麗子數組數目(Medium)

2602. 收集樹中金币(Hard)

題目位址

https://leetcode.cn/problems/collect-coins-in-a-tree/

題目描述

給你一個 n 個節點的無向無根樹,節點編号從 0 到 n - 1 。給你整數 n 和一個長度為 n - 1 的二維整數數組 edges ,其中 edges[i] = [ai, bi] 表示樹中節點 ai 和 bi 之間有一條邊。再給你一個長度為 n 的數組 coins ,其中 coins[i] 可能為 0 也可能為 1 ,1 表示節點 i 處有一個金币。

一開始,你需要選擇樹中任意一個節點出發。你可以執行下述操作任意次:

  • 收集距離目前節點距離為 2 以内的所有金币,或者
  • 移動到樹中一個相鄰節點。

你需要收集樹中所有的金币,并且回到出發節點,請你傳回最少經過的邊數。

如果你多次經過一條邊,每一次經過都會給答案加一。

LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序
LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序

預備知識

  • 拓撲排序:

給定一個包含 n 個節點的有向圖 G,我們給出它的節點編号的一種排列,如果滿足: 對于圖 G 中的任意一條有向邊 (u,v),u 在排列中都出現在 v 的前面。 那麼稱該排列是圖 G 的「拓撲排序」。

  • 拓撲排序的實作思路:

拓撲排序的正常實作是 Kahn 拓撲排序算法,基于 BFS 搜尋和貪心思想:每次從圖中删除沒有前驅的節點(入度為 0),并修改它指向的節點的入度,直到 BFS 隊列為空結束。

題解(拓撲排序 + 模拟)

  • 觀察示例 1:從節點 2 出發,收集節點 0 處的金币,移動到節點 3 ,收集節點 5 處的金币,然後移動回節點 2。
  • 觀察示例 2:從節點 0 出發,收集節點 4 和 3 處的金币,移動到節點 2 處,收集節點 7 處的金币,移動回節點 0。

結合題目規則(收集距離目前節點距離為 2 以内的所有金币)和這 2 個示例分析: 最優路徑必然不需要觸達圖的邊緣,而隻需要在圖的核心部分來回試探 (如示例 1 的節點 2 和節點 3,示例 2 的節點 0 和節點 2)。

  • 1、通路無金币的子樹沒有意義,我們将整個子樹剪枝;
  • 2、葉子節點和距離葉子節點距離為 1 的節點都沒有必要通路,我們将這些點剪枝;

剩下的點就是必須經過的核心點。再結合題目規則(你需要收集樹中所有的金币,并且回到出發節點),且題目保證輸入資料是合法的樹,是以答案正好就是剩下部分邊的數目 * 2。

LeetCode 周賽 338,埃氏篩 / 歐氏線性篩 / 字首和 / 二分查找 / 拓撲排序
class Solution {
    fun collectTheCoins(coins: IntArray, edges: Array<IntArray>): Int {
        val n = coins.size
        // 入度表
        val inDegrees = IntArray(n)
        // 領接表
        val graph = HashMap<Int, MutableList<Int>>()
        for (edge in edges) {
            graph.getOrPut(edge[0]) { LinkedList<Int>() }.add(edge[1])
            graph.getOrPut(edge[1]) { LinkedList<Int>() }.add(edge[0])
            inDegrees[edge[0]]++
            inDegrees[edge[1]]++
        }
        // 剩餘的邊
        var left_edge = edges.size // n - 1
        // 1、拓撲排序剪枝無金币子樹
        val queue = LinkedList<Int>()
        for (node in 0 until n) {
            // 題目是無向圖,是以葉子結點的入度也是 1
            if (inDegrees[node] == 1 && coins[node] == 0) {
                queue.offer(node)
            }
        }
        while (!queue.isEmpty()) {
            // 删除葉子結點
            val node = queue.poll()
            left_edge -= 1
            // 修改相鄰節點
            for (edge in graph[node]!!) {
                if (--inDegrees[edge] == 1 && coins[edge] == 0) queue.offer(edge)
            }
        }
        // 2、拓撲排序剪枝與葉子結點距離不大于 2 的節點(裁剪 2 層)
        // 葉子節點
        for (node in 0 until n) {
            if (inDegrees[node] == 1 && coins[node] == 1) {
                queue.offer(node)
            }
        }
        for (node in queue) {
            // 2.1 删除葉子結點
            left_edge -= 1
            // 2.2 删除到葉子結點距離為 1 的節點
            for (edge in graph[node]!!) {
                if (--inDegrees[edge] == 1) left_edge -= 1
            }
        }
        // println(inDegrees.joinToString())
        // coins=[0,0],edges=[[0,1]] 會減去所有節點導緻出現負數
        return Math.max(left_edge * 2, 0)
    }
}
           

複雜度分析:

  • 時間複雜度:O(n) 其中 n 是 coins 數組的長度;
  • 空間複雜度:O(n)。

相似題目:

  • 207. 課程表
  • 2050. 并行課程 III

有用請支援,你們的支援是我持續分享有價值内容的動力。