LeetCode 3518. Smallest Palindromic Rearrangement II
weekly contest 445。
以前總是抱怨 python 卡常數打比賽沒優勢,原來其實在暴力排列組合有巨大優勢,只是我不敢用。
本周兩場真的在大數上吃了不少甜頭。
題目
https://leetcode.com/problems/smallest-palindromic-rearrangement-ii/
解法
和前一題一樣,回文串只需要處理其中一半。
問題簡化成左半邊字串 half 第 k 小的不同的排列。
設 half 長度為 sz,則全排列有 sz! 種。
但題目要求的是不同的排列,所以對於每種元素都要去重。
若對於 c 總共出現 v 次,則要除以 v! 種重複排列。
若去重後的總排列數 tot 不足 k,沒有答案,直接回傳空字串。
接著從小到到枚舉要填的元素。
設當前填第 i 位,剩餘排列數有 rem_ways 種。
如果填了字元 c,則分子會少乘一個 (sz-i),分母會多乘一個 cnt[c]。
代表由 c 開頭的共有 ways = rem_ways * cnt[c] / (sz-i) 種排列。
如果 k <= ways,則代表第 k 小的選法包含在這組內。答案填入 c,更新 c 的剩餘數量與剩餘排列數。
否則 k > ways 代表 k 不屬於這組。直接從 k 中排除掉 ways 個更小的排法。
最後填完把左半邊翻轉,並加上中心元素即可。
也不知道是測資太弱,還是出題者允許大數運算,我換了 go 也是可以過。
至於複雜度就很尷尬。
至多 D = 26 種字元,有 N 個位置,每次試填需枚舉 D 個字元。
如果把大數運算看做 O(1),那硬要說整體是 O(N * D) 好像也不能說不對。
f = cache(factorial)
class Solution:
def smallestPalindrome(self, s: str, k: int) -> str:
N = len(s)
half = s[:N//2]
mid = "" if N % 2 == 0 else s[N//2]
sz = len(half)
tot = f(sz)
cnt = Counter(half)
for v in cnt.values():
tot //= f(v)
# 排列數不足
if tot < k:
return ""
rem_ways = tot
ans = []
for i in range(sz):
for c in ascii_lowercase:
if cnt[c] == 0:
continue
ways = rem_ways*cnt[c]//(sz-i)
if k <= ways:
ans.append(c)
rem_ways = ways
cnt[c] -= 1
break
k -= ways
pre = "".join(ans)
return pre + mid + pre[::-1]
那如果不用大數,有什麼辦法不溢位又能算出正確的排法?
當初在做 3470 題,只要排列數超過 MXK 就直接停止,後面的不需要繼續算,反正已經知道答案要填什麼。
但是這種不盡相異物排列扣除各元素重複排法,必須要先算完分子才能算分母,可能中途就溢位了。
“aabbc” 的排法
5! / (2!2!1!)
= 30 種排法
改從組合的角度來看,往空格中逐次填入不同元素:
”#####” 最初 5 個空格
在 5 格中選 2 格填 a。剩 3 格 在 3 格中選 2 格填 b。剩 1 格
在 1 格中選 1 格填 c。剩 0 格
共有 comb(5,2) * comb(3,2) * comb(1,1)
= 30 種選法
兩種方式其實是等價的。
如此一來,每次試填只需要算 D 個組合數相乘,只要一達到 MXK 就馬上中止。
注意:下述 k 是指組合數取 k 個物品,與題目給定的第 k 小排法無關。
MXK 才是第 k 小排法的上限。
對於組合數來說,comb(n,k) 等價於 comb(n,n-k)。
取 k = min(k,n-k),保證 k <= n-k。
但是組合數同樣也是要先算分子,然後才扣掉分母。
comb(n,k) = n! / (k!(n-k)!)
例如:
comb(5,2) = 5! / (2!3!)
階乘展開後
分子 = 5 4 3 2 1
分母 = 2 1 3 2 1
剛好分子和分母的後 n-k 項可以消掉,實際上剩下 k 項:
分子 = 5 4 分母 = 2 1
簡單來說,因為每 x 個數會出現一個 x 的倍數,若以分子遞減、分母遞增的順序計算,可以保證乘積一定能被分母整除。
5 4 3 有 3 個數,其中 5 是 1 的倍數、4 是 2 的倍數、3 是 3 的倍數
所以 (5/1) * (4/2) * (3/3) 肯定能整除
又因為先前取了 k = min(k, n-k),保證不會出現配對的分子小於分母的情形,所以乘積肯定是遞增的,在計算途中乘積達 MXK 可以安全中止。
並且因為每次乘上的 (分子/分母) 不為 1,只需要 O(log MXK) 次計算,而不需跑滿 O(k) 次循環。
共需要 N 次試填,每次試填要算 D 次組合數,每次組合數複雜度 log MXK。
時間複雜度 O(N * D * log MXK)。
空間複雜度 O(N + D)。
def get_comb(n, k):
k = min(k, n-k)
res = 1
for i in range(1, k+1):
res *= n-i+1 # from n to n-k+1
res //= i # from 1 to k
if res >= MXK:
return MXK
return res
def get_ways(cnt):
ways = 1
space = sum(cnt.values())
for v in cnt.values():
ways *= get_comb(space, v)
space -= v
if ways >= MXK:
return MXK
return ways
class Solution:
def smallestPalindrome(self, s: str, k: int) -> str:
N = len(s)
half = s[:N//2]
mid = "" if N % 2 == 0 else s[N//2]
sz = len(half)
cnt = Counter(half)
# 排列數不足
if get_ways(cnt) < k:
return ""
ans = []
for _ in range(sz):
for c in ascii_lowercase:
if cnt[c] == 0:
continue
cnt[c] -= 1
ways = get_ways(cnt)
if k <= ways:
ans.append(c)
break
cnt[c] += 1
k -= ways
pre = "".join(ans)
return pre + mid + pre[::-1]