⭐️ 本文已收錄到 AndroidFamily,技術和職場問題,請關注公眾號 [彭旭銳] 和 BaguTree Pro 知識星球提問。
學習資料結構與演算法的關鍵在於掌握問題背後的演算法思維框架,你的思考越抽象,它能覆蓋的問題域就越廣,理解難度也更復雜。在這個專欄裡,小彭與你分享每場 LeetCode 周賽的解題報告,一起體會上分之旅。
本文是 LeetCode 上分之旅系列的第 47 篇文章,往期回顧請移步到文章末尾\~
LeetCode 周賽 364
T1. 最大二進位制奇數(Easy)
- 標籤:貪心
T2. 美麗塔 I(Medium)
- 標籤:列舉、前字尾分解、單調棧
T3. 美麗塔 II(Medium)
- 標籤:列舉、前字尾分解、單調棧
T4. 統計樹中的合法路徑數目(Hard)
- 標籤:DFS、質數
T1. 最大二進位制奇數(Easy)
https://leetcode.cn/problems/maximum-odd-binary-number/description/
題解(模擬)
簡單模擬題,先計算 $1$ 的個數,將其中一個 $1$ 置於最低位,其它 $1$ 置於最高位:
class Solution {
fun maximumOddBinaryNumber(s: String): String {
val cnt = s.count { it == '1' }
return StringBuilder().apply {
repeat(cnt - 1) {
append("1")
}
repeat(s.length - cnt) {
append("0")
}
append("1")
}.toString()
}
}
class Solution:
def maximumOddBinaryNumber(self, s: str) -> str:
n, cnt = len(s), s.count("1")
return "1" * (cnt - 1) + "0" * (n - cnt) + "1"
class Solution {
public:
string maximumOddBinaryNumber(string s) {
int n = s.length();
int cnt = 0;
for (auto& e : s) {
if (e == '1') cnt++;
}
string ret;
for (int i = 0; i < cnt - 1; i++) {
ret.push_back('1');
}
for (int i = 0; i < n - cnt; i++) {
ret.push_back('0');
}
ret.push_back('1');
return ret;
}
};
複雜度分析:
- 時間複雜度:$O(n)$ 線性遍歷;
- 空間複雜度:$O(1)$ 不考慮結果字串。
T2. 美麗塔 I(Medium)
https://leetcode.cn/problems/beautiful-towers-i/description/
同 T3. 美麗塔 I
T3. 美麗塔 II(Medium)
https://leetcode.cn/problems/beautiful-towers-ii/description/
問題分析
初步分析:
- 問題目標: 構造滿足條件的方案,使得陣列呈現山狀陣列,返回元素和;
- 方案條件: 從陣列的最大值向左側為遞減,向右側也為遞減。
思考實現:
- 在 T2. 美麗塔 I(Medium) 中的資料量只有 $1000$,我們可以列舉以每個點作為山峰(陣列最大值)的方案,從山頂依次向兩側遞減,使得當前位置不高於前一個位置,整體的時間複雜度是 $O(n^2)$;
- 在 T3. 美麗塔 II(Medium) 中資料量有 $10^5$,我們需要思考低於平方時間複雜度的方法。
思考最佳化:
以示例 [6,5,3,9,2,7]
為例,我們觀察以 $3$ 和 $9$ 作為山頂的兩個方案:
以 3 作為山頂:
3 3 |3 3| 2 2
以 9 作為山頂
3 3 |3 9| 2 2
可以發現:以 $3$ 作為山頂的左側與以 $9$ 為山頂的右側在兩個方案之間是可以複用的,至此發現解決方法:我們可以分別預處理出以每個節點作為山頂的字首和字尾的和:
- $pre[i]$ 表示以 $maxHeights[i]$ 作為山頂時左側段的字首和;
- $suf[i]$ 表示以 $maxHeights[i]$ 作為山頂時右側段的字尾和。
那麼,最佳方案就是 $pre[i] + suf[i] - maxHeight[i]$ 的最大值。 現在,最後的問題是如何以均攤 $O(1)$ 的時間複雜度計算出每個元素前字尾的和?
思考遞推關係:
繼續以示例 [6,5,3,9,2,7]
為例:
- 以 $6$ 為山頂,字首為 $[6]$
- 以 $5$ 為山頂,需要保證左側元素不大於 $5$,因此找到 $6$ 並修改為 $5$,字首為 $[5, 5]$
- 以 $3$ 為山頂,需要保證左側元素不大於 $3$,因此找到兩個 $5$ 並修改為 $3$,字首為 $[3, 3, 3]$
- 以 $9$ 為山頂,需要保證左側元素不大於 $9$,不需要修改,字首為 $[3, 3, 3, 9]$
- 以 $2$ 為山頂,需要保證左側元素不大於 $2$,修改後為 $[2, 2, 2, 2, 2]$
- 以 $7$ 為山頂,需要保證左側元素不大於 $7$,不需要修改,字首為 $[2, 2, 2, 2, 2, 7]$
提高抽象程度:
觀察以上步驟,問題的關鍵在於修改操作:由於陣列是遞增的,因此修改的步驟就是在「尋找小於等於當前元素 $x$ 的上一個元素」,再將中間的元素削減為 $x$。「尋找上一個更小元素」,這是單調棧的典型場景。
題解一(列舉)
列舉以每個元素作為山頂的方案:
class Solution {
fun maximumSumOfHeights(maxHeights: List<Int>): Long {
val n = maxHeights.size
var ret = 0L
for (i in maxHeights.indices) {
var curSum = maxHeights[i].toLong()
var pre = maxHeights[i]
for (j in i - 1 downTo 0) {
pre = min(pre, maxHeights[j])
curSum += pre
}
pre = maxHeights[i]
for (j in i + 1 ..< n) {
pre = min(pre, maxHeights[j])
curSum += pre
}
ret = max(ret, curSum)
}
return ret
}
}
class Solution:
def maximumSumOfHeights(self, maxHeights: List[int]) -> int:
n, ret = len(maxHeights), 0
for i in range(n):
curSum = maxHeights[i]
pre = maxHeights[i]
for j in range(i + 1, n):
pre = min(pre, maxHeights[j])
curSum += pre
pre = maxHeights[i]
for j in range(i - 1, -1, -1):
pre = min(pre, maxHeights[j])
curSum += pre
ret = max(ret, curSum)
return ret
class Solution {
public:
long long maximumSumOfHeights(vector<int>& maxHeights) {
int n = maxHeights.size();
long long ret = 0;
for (int i = 0; i < n; i++) {
long long curSum = maxHeights[i];
int pre = maxHeights[i];
for (int j = i + 1; j < n; j++) {
pre = min(pre, maxHeights[j]);
curSum += pre;
}
pre = maxHeights[i];
for (int j = i - 1; j >= 0; j--) {
pre = min(pre, maxHeights[j]);
curSum += pre;
}
ret = max(ret, curSum);
}
return ret;
}
};
複雜度分析:
- 時間複雜度:$O(n^2)$ 每個方案的時間複雜度是 $O(n)$,一共有 $n$ 種方案;
- 空間複雜度:$O(1)$ 僅使用常量級別空間。
題解二(前字尾分解 + 單調棧)
使用單點棧維護前字尾陣列,為了便於邊界計算,我們構造長為 $n + 1$ 的陣列。以示例 [6,5,3,9,2,7]
為例:
0, 5, 6, 10, 4, 5
13, 8, 6, 2, 1, 0
class Solution {
fun maximumSumOfHeights(maxHeights: List<Int>): Long {
val n = maxHeights.size
val suf = LongArray(n + 1)
val pre = LongArray(n + 1)
// 單調棧求字首
val stack = java.util.ArrayDeque<Int>()
for (i in 0 until n) {
// 彈出棧頂
while (!stack.isEmpty() && maxHeights[stack.peek()] > maxHeights[i]) {
stack.pop()
}
val j = if (stack.isEmpty()) -1 else stack.peek()
pre[i + 1] = pre[j + 1] + 1L * (i - j) * maxHeights[i]
stack.push(i)
}
// 單調棧求字尾
stack.clear()
for (i in n - 1 downTo 0) {
// 彈出棧頂
while (!stack.isEmpty() && maxHeights[stack.peek()] > maxHeights[i]) {
stack.pop()
}
val j = if (stack.isEmpty()) n else stack.peek()
suf[i] = suf[j] + 1L * (j - i) * maxHeights[i]
stack.push(i)
}
// 合併
var ret = 0L
for (i in 0 until n) {
ret = max(ret, pre[i + 1] + suf[i] - maxHeights[i])
}
return ret
}
}
class Solution:
def maximumSumOfHeights(self, maxHeights: List[int]) -> int:
n = len(maxHeights)
suf = [0] * (n + 1)
pre = [0] * (n + 1)
stack = []
# 單調棧求字首
for i in range(n):
# 彈出棧頂
while stack and maxHeights[stack[-1]] > maxHeights[i]:
stack.pop()
j = stack[-1] if stack else -1
pre[i + 1] = pre[j + 1] + (i - j) * maxHeights[i]
stack.append(i)
# 單調棧求字尾
stack = []
for i in range(n - 1, -1, -1):
# 彈出棧頂
while stack and maxHeights[stack[-1]] > maxHeights[i]:
stack.pop()
j = stack[-1] if stack else n
suf[i] = suf[j] + (j - i) * maxHeights[i]
stack.append(i)
# 合併
ret = 0
for i in range(n):
ret = max(ret, pre[i + 1] + suf[i] - maxHeights[i])
return ret
class Solution {
public:
long long maximumSumOfHeights(vector<int>& maxHeights) {
int n = maxHeights.size();
vector<long long> suf(n + 1, 0);
vector<long long> pre(n + 1, 0);
stack<int> st;
// 單調棧求字首
for (int i = 0; i < n; i++) {
// 彈出棧頂
while (!st.empty() && maxHeights[st.top()] > maxHeights[i]) {
st.pop();
}
int j = st.empty() ? -1 : st.top();
pre[i + 1] = pre[j + 1] + 1LL * (i - j) * maxHeights[i];
st.push(i);
}
// 單調棧求字尾
while (!st.empty()) st.pop();
for (int i = n - 1; i >= 0; i--) {
// 彈出棧頂
while (!st.empty() && maxHeights[st.top()] > maxHeights[i]) {
st.pop();
}
int j = st.empty() ? n : st.top();
suf[i] = suf[j] + 1LL * (j - i) * maxHeights[i];
st.push(i);
}
// 合併
long long ret = 0;
for (int i = 0; i < n; i++) {
ret = max(ret, pre[i + 1] + suf[i] - maxHeights[i]);
}
return ret;
}
};
複雜度分析:
- 時間複雜度:$O(n)$ 在一側的計算中,每個元素最多如何和出棧 $1$ 次;
- 空間複雜度:$O(n)$ 前字尾陣列空間。
T4. 統計樹中的合法路徑數目(Hard)
https://leetcode.cn/problems/count-valid-paths-in-a-tree/description/
這道題似乎比 T3 還簡單一些。
問題分析
初步分析:
- 問題目標: 尋找滿足條件的方案數;
- 問題條件: 路徑 $[a, b]$ 上質數的數目有且僅有 $1$;
- 問題要素: 路徑和 - 表示路徑上質數的數目。
思考實現:
子問題: 對於以根節點 x 的原問題,可以分為 3 種情況:
- 左子樹可以構造的方案數
- 右子樹可以構造的方案數
- 如果根節點為質數:「從根到子樹節點的路徑和為 $0$ 的數目」與「從根到其它子樹節點的路徑和為 $0$ 的數目」的乘積(乘法原理)
題解(DFS)
構造 DFS 函式,子樹的 DFS 返回值為兩個值:
- $cnt0$:到子樹節點和為 $0$ 的路徑數;
- $cnt1$:到子樹節點和為 $1$ 的路徑數;
返回結果時:
- 如果根節點為質數,那麼只能與 $cnt0$ 個路徑和為 $1$ 的路徑;
- 如果根節點為非質數,那麼 $cnt0$ 個路徑可以組成和為 $0$ 的路徑,同理 $cnt1$ 個路徑可以組成和為 $1$ 的路徑。
在子樹的計算過程中還會構造結果:
由於題目說明 $[a, b]$ 與 $[b, a]$ 是相同路徑,我們可以記錄當前子樹左側已經計算過的 $cnt0$ 和 $cnt1$ 的累加和,再與當前子樹的 $cnt0$ 與 $cnt1$ 做乘法:
$ret += cnt0 * cnt[1] + cnt1 * cnt[0]$
class Solution {
companion object {
val U = 100000
val primes = LinkedList<Int>()
val isPrime = BooleanArray(U + 1) { true }
init {
isPrime[1] = false
for (i in 2 .. U) {
if (isPrime[i]) primes.add(i)
for (e in primes) {
if (i * e > U) break
isPrime[i * e] = false
if (i % e == 0) break
}
}
}
}
fun countPaths(n: Int, edges: Array<IntArray>): Long {
val graph = Array(n + 1) { LinkedList<Int>() }
for ((from, to) in edges) {
graph[from].add(to)
graph[to].add(from)
}
var ret = 0L
// return 0 和 1 的數量
fun dfs(i: Int, pre: Int): IntArray {
// 終止條件
var cnt = IntArray(2)
if (isPrime[i]) {
cnt[1] = 1
} else {
cnt[0] = 1
}
// 遞迴
for (to in graph[i]) {
if (to == pre) continue // 返祖邊
val (cnt0, cnt1) = dfs(to, i)
// 記錄方案
ret += cnt0 * cnt[1] + cnt1 * cnt[0]
// 記錄影響
if (isPrime[i]) {
cnt[1] += cnt0
} else {
cnt[0] += cnt0
cnt[1] += cnt1
}
}
return cnt
}
dfs(1, -1) // 隨機選擇根節點
return ret
}
}
U = 100000
primes = deque()
isPrime = [True] * (U + 1)
isPrime[1] = False
for i in range(2, U + 1):
if isPrime[i]: primes.append(i)
for e in primes:
if i * e > U: break
isPrime[i * e] = False
if i % e == 0: break
class Solution:
def countPaths(self, n, edges):
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
ret = 0
def dfs(i, pre):
nonlocal ret # 修改外部變數
cnt = [0, 0]
# 終止條件
if isPrime[i]:
cnt[1] = 1
else:
cnt[0] = 1
for to in graph[i]:
if to == pre: continue # 返祖邊
cnt0, cnt1 = dfs(to, i)
# 記錄方案
ret += cnt0 * cnt[1] + cnt1 * cnt[0]
# 記錄影響
if isPrime[i]:
cnt[1] += cnt0
else:
cnt[0] += cnt0
cnt[1] += cnt1
return cnt
dfs(1, -1) # 隨機選擇根節點
return ret
const int U = 100000;
list<int> primes;
bool isPrime[U + 1];
bool inited = false;
void init() {
if (inited) return;
inited = true;
memset(isPrime, true, sizeof(isPrime));
isPrime[1] = false;
for (int i = 2; i <= U; ++i) {
if (isPrime[i]) primes.push_back(i);
for (auto e : primes) {
if (i * e > U) break;
isPrime[i * e] = false;
if (i % e == 0) break;
}
}
}
class Solution {
public:
long long countPaths(int n, vector<vector<int>>& edges) {
init();
vector<list<int>> graph(n + 1);
for (const auto& edge : edges) {
int from = edge[0];
int to = edge[1];
graph[from].push_back(to);
graph[to].push_back(from);
}
long long ret = 0;
// return 0 和 1 的數量
function<vector<int>(int, int)> dfs = [&](int i, int pre) -> vector<int> {
// 終止條件
vector<int> cnt(2, 0);
if (isPrime[i]) {
cnt[1] = 1;
} else {
cnt[0] = 1;
}
// 遞迴
for (auto to : graph[i]) {
if (to == pre) continue; // 返祖邊
vector<int> subCnt = dfs(to, i);
int cnt0 = subCnt[0];
int cnt1 = subCnt[1];
// 記錄方案
ret += cnt0 * cnt[1] + cnt1 * cnt[0];
// 記錄影響
if (isPrime[i]) {
cnt[1] += cnt0;
} else {
cnt[0] += cnt0;
cnt[1] += cnt1;
}
}
return cnt;
};
dfs(1, -1); // 隨機選擇根節點
return ret;
}
};
複雜度分析:
- 時間複雜度:預處理時間為 $O(U)$,建圖時間 和 DFS 時間為 $O(n)$;
- 空間複雜度:預處理空間為 $O(U)$,模擬空間為 $O(n)$。
列舉質數
列舉法:列舉 $[2, n]$ ,判斷它是不是質數,整體時間複雜度是 $O(n\sqrt{n})$
// 暴力求質數
fun getPrimes(max: Int): IntArray {
val primes = LinkedList<Int>()
for (num in 2..max) {
if (isPrime(num)) primes.add(num)
}
return primes.toIntArray()
}
// 質數判斷
fun isPrime(num: Int): Boolean {
var x = 2
while (x * x <= num) {
if (num % x == 0) return false
x++
}
return true
}
Eratosthenes 埃氏篩:如果 $x$ 是質數,那麼 $x$ 的整數倍 $2x$、$3x$ 一定不是質數。我們設
isPrime[i]
表示 $i$ 是否為質數。從小開始遍歷,如果 $i$ 是質數,則同時將所有倍數標記為合數,整體時間複雜度是 $O(nlgn)$為什麼要從 $x^2$, $2x^2$ 開始標記,而不是 $2x$, $3x$ 開始標記,因為 $2x$, $3x$ 已經被小於 $x$ 的質數標記過。
// 埃氏篩求質數
val primes = LinkedList<Int>()
val isPrime = BooleanArray(U + 1) { true }
for (i in 2..U) {
// 檢查是否為質數,這裡不需要呼叫 isPrime() 函式判斷是否質數,因為它沒被小於它的數標記過,那麼一定不是合數
if (!isPrime[i]) continue
primes.add(i)
// 標記
var x = i * i
while (x <= U) {
isPrime[x] = false
x += i
}
}
Euler 歐氏線性篩:儘管我們從 $x^2$ 開始標記來減少重複標記,但埃氏篩還是會重複標記合數。為了避免重複標記,標記 $x$ 與 “小於等於 $x$ 的最小質因子的質數” 的乘積為合數,保證每個合數只被標記最小的質因子標記,整體時間複雜度是 $O(n)$
// 線性篩求質數
val primes = LinkedList<Int>()
val isPrime = BooleanArray(U + 1) { true }
for (i in 2..U) {
// 檢查是否為質數,這裡不需要呼叫 isPrime() 函式判斷是否質數,因為它沒被小於它的數標記過,那麼一定不是合數
if (isPrime[i]) {
primes.add(i)
}
// 標記
for (e in primes) {
if (i * e > U) break
isPrime[i * e] = false
if (i % e == 0) break
}
}
推薦閱讀
LeetCode 上分之旅系列往期回顧:
- LeetCode 單週賽第 363 場 · 經典二分答案與質因數分解
- LeetCode 單週賽第 361 場 · 同餘字首和問題與經典倍增 LCA 演算法
- LeetCode 雙週賽第 113 場 · 精妙的 O(lgn) 掃描演算法與樹上 DP 問題
- LeetCode 雙週賽第 112 場 · 電腦科學本質上是數學嗎?
⭐️ 永遠相信美好的事情即將發生,歡迎加入小彭的 Android 交流社群\~