🔗 🔴 1255. Maximum Score Words Formed by Letters 1882

tags: Weekly Contest 162 回溯(Backtracking) 剪枝(Pruning) 子集型回溯 位運算(Bit Manipulation) 狀態壓縮 雜湊表(Hash Table)

題意

給定一個字串陣列 wordswords 表示單字列表、一個字元陣列 lettersletters 表示可使用的字母(可能會有重複字母)、和一個長度為 2626 的整數陣列 scorescore 表示每個字母的分數,其中字母 皆為小寫英文字母

返回在給定的單字列表中,選擇其任何有效子集合,所能獲得的最大分數。每個單字 words[i]words[i] 只能使用一次,每個字母 letters[i]letters[i] 也只能使用一次。

限制

  • 1words.length141 \leq words.length \leq 14
  • 1words[i].length151 \leq words[i].length \leq 15
  • 1letters.length1001 \leq letters.length \leq 100

思路

注意題目的數據範圍,1n=words.length141 \leq n = words.length \leq 14 ,枚舉所有可能的子集的時間複雜度為 O(2n)O(2^n),因此可以使用回溯法或直接枚舉所有可能的子集解決。

方法一:回溯(Backtracking) + 剪枝(Pruning)

建立一個雜湊表 cntcnt 來保存 lettersletters 中每個字母的數量,即每個字母的可用次數。此外,為方便起見,將每個字母的分數也用雜湊表 scoresscores 保存、或是直接以字元的 ASCII 碼減去 a'a' 作為索引。

使用 curcur 保存當前子集中的分數,ansans 保存最大分數,在遞迴時分別考慮每個單字 words[i]words[i] 選或不選兩種情況:

  • 若不選 words[i]words[i] ,則直接遞迴到下一個單字。
  • 若選 words[i]words[i] ,則先在 cntcnt 中減去 words[i]words[i] 的字母數量,若不能選 words[i]words[i] 則直接恢復,若能選則是先遞迴後恢復。
    • 若發生不能選 words[i]words[i] 的情況,則顯然選擇 words[i]words[i] 和之後的單字都是不合法的,因此不用考慮這些情況,此作法即為剪枝。

遞迴終點為 i=ni = n 時,即所有單字都考慮過了,此時更新答案。在遍歷完所有路徑後,最後返回 ansans 即可。

複雜度分析

  • 時間複雜度 O(m+L2n)O(m + L \cdot 2^n) ,其中 nnwordswords 的長度,mmlettersletters 的長度,LLwordswords 中最長單字的長度。
  • 空間複雜度 O(n+Σ)O(n + \Sigma) ,其中 Σ\Sigma 為字元集大小,本題中為 2626
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution:
def maxScoreWords(self, words: List[str], letters: List[str], score: List[int]) -> int:
n = len(words)
cnt = Counter(letters) # 每個字母可使用的次數
scores = {chr(i + 97): score[i] for i in range(26)} # 每個字母的分數
ans, cur = 0, 0
def dfs(i: int) -> None:
nonlocal ans, cur
if i == n:
ans = max(ans, cur) # 更新答案
return
dfs(i + 1) # 不選 words[i]
# 先減去 words[i] 的字母數量,若不能選 words[i] 則直接恢復,若能選則是遞迴後恢復
for ch in words[i]:
cnt[ch] -= 1
cur += scores[ch]
if all(cnt[ch] >= 0 for ch in cnt): # 判斷是否可以選 words[i]
dfs(i + 1) # 選 words[i]
for ch in words[i]:
cnt[ch] += 1
cur -= scores[ch]
dfs(0)
return ans
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
int maxScoreWords(vector<string>& words, vector<char>& letters, vector<int>& score) {
int n = words.size();
vector<int> cnt(26);
for (char ch : letters) cnt[ch - 'a']++;
int ans = 0;
function<void(int, int)> dfs = [&](int i, int cur) {
if (i == n) {
ans = max(ans, cur);
return;
}
dfs(i + 1, cur); // 不選 words[i]
for (char ch : words[i]) cnt[ch - 'a']--;
bool flag = true;
for (char ch : words[i]) {
if (cnt[ch - 'a'] < 0) {
flag = false;
break;
}
cur += score[ch - 'a'];
}
if (flag) dfs(i + 1, cur); // 選 words[i]
for (char ch : words[i]) cnt[ch - 'a']++;
};
dfs(0, 0);
return ans;
}
};

方法二:狀態壓縮,枚舉所有可能的子集

對於子集型回溯的問題,同樣可以嘗試使用位運算來枚舉所有可能的子集。

枚舉所有可能的子集 ii ,若 i & (1 << j) ,則代表 words[j]words[j] 在當前子集中。在枚舉每個子集時,建立一個雜湊表或長度為 2626 的陣列 tmptmp 來保存當前子集中需要的字母數量,同樣使用 curcur 保存當前子集的分數。

  • 若在添加 words[j]words[j] 所需要的字母數量到 tmptmp 後,出現 tmp[ch]>cnt[ch]tmp[ch] > cnt[ch] 的情況,則這個子集不合法,直接跳過。
  • 若所有 words[j]words[j] 都能選,則更新答案。

Python 中的 for...else 語法可以用來判斷 for 迴圈是否正常結束,若正常結束則執行 else 語句,這樣可以省去一個 flag 變數。

狀態壓縮的作法,相較回溯+剪枝的優缺點如下:

  • 會考慮每個可能的子集,例如若 0000111100001111 不合法,則在回溯中不會考慮 XXXX1111XXXX1111 的情況,但在枚舉所有可能的子集的做法中,還是會將其再考慮一次。
  • 此外,在回溯中,cntcnt 可以被重複利用,而在枚舉所有可能的子集的做法中,需要每次都建立一個新的 tmptmp 來保存當前子集需要的字母數量。
  • 但狀態壓縮的作法在簡潔性上更勝一籌,且在本題的數據範圍下,枚舉所有可能的子集的時間複雜度也是可以接受的。

複雜度分析

  • 時間複雜度 O(m+(S+Σ)2n)O(m + (S + \Sigma) \cdot 2^n) ,其中 nnwordswords 的長度,mmlettersletters 的長度,SSwordswords 中所有單字的總長度,Σ\Sigma 為字元集大小,本題中為 2626
  • 空間複雜度 O(Σ)O(\Sigma)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def maxScoreWords(self, words: List[str], letters: List[str], score: List[int]) -> int:
n = len(words)
cnt = Counter(letters) # 每個字母可使用的次數
scores = {chr(i + 97): score[i] for i in range(26)} # 每個字母的分數
ans = 0
for i in range(1, 1 << n): # 枚舉所有可能的子集
tmp = Counter() # 當前子集需要的字母數量
cur = 0 # 當前子集的分數
for j in range(n):
if i & (1 << j): # words[j] 在當前子集中
for ch in words[j]:
tmp[ch] += 1
cur += scores[ch]
if any(tmp[ch] > cnt[ch] for ch in tmp): # 這個子集不合法。這樣寫是為了不用 flag 變數
break
else: # 若所有 words[j] 都能選,則更新答案
ans = max(ans, cur)
return ans
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
int maxScoreWords(vector<string>& words, vector<char>& letters, vector<int>& score) {
int n = words.size();
vector<int> cnt(26); // 每個字母可使用的次數
for (char ch : letters) cnt[ch - 'a']++;
int ans = 0;
for (int i = 1; i < (1 << n); i++) { // 枚舉所有可能的子集
vector<int> tmp(26); // 當前子集需要的字母數量
int cur = 0; // 當前子集的分數
bool flag = true;
for (int j = 0; j < n && flag; j++) {
if (i & (1 << j)) { // words[j] 在當前子集中
for (char ch : words[j]) {
tmp[ch - 'a']++;
if (tmp[ch - 'a'] > cnt[ch - 'a']) { // 當前子集需要的字母數量超過可用的字母數量,不合法
flag = false;
break;
}
cur += score[ch - 'a'];
}
}
}
if (flag) ans = max(ans, cur); // 若所有 words[j] 都能選,則更新答案
}
return ans;
}
};

寫在最後

Cover photo is generated by @たろたろ, thanks for their work!