biweekly contest 152。

題目

https://leetcode.com/problems/longest-common-prefix-of-k-strings-after-removal/description/

解法

先考慮不刪除 words[i] 的做法。

我們會枚舉每個 w 的前綴 pre,並且統計 pre 的出現次數。
只要 pre 出現滿足 k 次,那麼 len(pre) 就是答案的候選之一。
最後找出候選長度中的最大值。


再來考慮刪除 words[i]。

先枚舉要刪除的 words[i],刪除其所有前綴後,再找候選中的最大值。
但 w 的 pre 刪除後,出現次數可能會低於 k 次,這時必須從答案候選中刪除

維護答案候選長度的資料結構需要有效率的新增刪除,還有求最大值
故選用 sorted list。
並且為了對應沒有 k 共通前綴的情形,在 sorted list 內先加入一個 0 當作哨兵。


最後還有一個問題。

測資保證 sum(len(words[i])) <= 10^5。
但在最差情況下只有一個長度 N = 10^5 的,同樣有需要枚舉 N 個子陣列,每次 O(N)。
O(N^2) 高達 10^5,理論上是會超時的 (但不知道為啥 python 能過)。

我們還需要優化子陣列的枚舉方式,例如字典樹或是 rolling hash,將每次枚舉的複雜度降低到 O(1)。
此處選用 rolling hash。

時間複雜度 O(L log L),其中 L = sum(len(words[i]))。
空間複雜度 O(L)。

from sortedcontainers import SortedList as SL

MOD = 10 ** 9 + 7
base = 87


class Solution:
    def longestCommonPrefix(self, words: List[str], k: int) -> List[int]:
        d = Counter()
        sl = SL([0])

        def add(w):
            ps = 0
            for i, c in enumerate(w):
                ps = (ps * base + ord(c)) % MOD
                # pre = w[:i+1]
                d[ps] += 1
                if d[ps] == k:
                    sl.add(i + 1)

        def remove(w):
            ps = 0
            for i, c in enumerate(w):
                ps = (ps * base + ord(c)) % MOD
                # pre = w[:i+1]
                if d[ps] == k:
                    sl.remove(i + 1)
                d[ps] -= 1

        for w in words:
            add(w)

        ans = []
        for w in words:
            remove(w)
            ans.append(sl[-1])
            add(w)

        return ans

字典樹做法。

from sortedcontainers import SortedList as SL


class Solution:
    def longestCommonPrefix(self, words: List[str], k: int) -> List[int]:
        trie = Trie(k)
        for w in words:
            trie.add(w)

        ans = []
        for w in words:
            trie.remove(w)
            ans.append(trie.sl[-1])
            trie.add(w)

        return ans


class TrieNode:
    def __init__(self) -> None:
        self.child = defaultdict(TrieNode)
        self.cnt = 0


class Trie:
    def __init__(self, k):
        self.root = TrieNode()
        self.k = k
        self.sl = SL([0])

    def add(self, w):
        curr = self.root
        for i, c in enumerate(w):
            curr = curr.child[c]
            curr.cnt += 1
            # pre = w[:i+1]
            if curr.cnt == self.k:
                self.sl.add(i + 1)

    def remove(self, w):
        curr = self.root
        for i, c in enumerate(w):
            curr = curr.child[c]
            # pre = w[:i+1]
            if curr.cnt == self.k:
                self.sl.remove(i + 1)
            curr.cnt -= 1

最後補充個靈神的做法
這種方法看起來簡潔,背後思考量可不少。
賽後自己練習,賽中趕時間可能還是用其他解法更快速。


擁有共通前綴的字串,在根據字典序排序後都會聚集在一起。例如:

有序的字串陣列 ss 內容如下:
ss[0] = “aa”
ss[1] = “aab”
ss[2] = “aac”
ss[3] = “aad”

其中 lcp(ss[0..3]) == lcp(ss[0], ss[3]) = size。
以反證法證明:

  • 設 lcp(ss[0..3]) > size:
    愈多字串參與計算,lcp 只可能更小。
    若前者大於 size,則後者也會大於 size。
    與 lcp(ss[0], ss[3]) = size 的前提矛盾,假設不成立。

  • 設 lcp(ss[0..3]) < size:
    ss[0] 和 ss[3] 有共通前綴 “aa”。
    若 lcp(ss[0..3]) 小於 2,則 ss[1..2] 至少有一個前綴不為 “aa” 的字串。
    已排序的前提矛盾,假設不成立。

因此對若干個字串求 lcp,只需將其排序,然後求第一個最後一個字串的 lcp。


先不考慮刪除。
如何在在 words 中求至少 k 個子字串的 lcp?
將 words 排序,得到排序後的字串陣列 ss。
枚舉所有長度 k 的子陣列 ss[i..i+k-1],求 lcp(ss[i], ss[i+k-1]),並更新答案。


再來考慮刪除一個字串的情形。
設最長 lcp 長度為 mx,出自於子陣列 ss[mx_i..mx_i+k-1]。
第二長的 lcp 長度 為 mx2。

分類討論刪除字串 r 的情形:

  • 若 r 不屬於 ss[mx_i..mx_i+k-1],不影響,答案為 mx。
  • 若 r 屬於 ss[mx_i..mx_i+k-1],則 mx 不可用,答案變為 mx2。

舉例:

ss = [“aa”,”aa”,”b”,”cc”,”cc”], k = 2
mx = 2, mx_i = 0 (“aa”)
mx2 = 2 (“cc”)

若刪除 r = “aa”,因 “aa” 屬於 ss[mx_i..mx_i+k-1],區間內不足 k 個字串,故答案變成 mx2 = 2 (“cc”)。
若刪除 r = “b” 或 “cc”,兩者都不屬於 ss[mx_i..mx_i+k-1],不影響答案,依然為 mx = 2 (“aa”)。


但上述範例中,mx 與 mx2 所屬子陣列沒有交集。如果兩者有交集呢?
例如:

ss = [“aa”,”aab”,”ab”], k = 2
mx = 2, mx_i = 0 (“aa”)
mx2 = 1 (“a”)

刪除 r = “aab”,同時屬於 mx 和 mx2 的子陣列。 mx1 與 mx2 有交集,代表 mx2 是 mx 的前綴 (“a” 是 “aa” 的前綴)。
換句話說,[mx_i..mx_i-1] 中的任意字串都是 “aa” 開頭,所以隨便挑一個都滿足 mx2 的前綴 “a”。
所以答案依然是 mx2。

再舉另外一個例子,這次 mx 比 mx2 出現更晚:

ss = [“a”,”ab”,”abc”], k = 2
mx = 2, mx_i = 1 (“ab”)
mx2 = 1 (“a”)

刪除 r = “ab”,同時屬於 mx 和 mx2 的子陣列。
兩者有交集,故 mx2 是 mx 的前綴 (“a” 是 “ab” 的前綴)。
刪除 “ab” 後,依然可以由 “abc” 與 “a” 滿足 mx2。
答案是 mx2。


按照此方法找出 mx, mx_i 以及 mx2。
屬於 ss[mx_i..mx_i+k-1] 中的索引字串答案為 mx2;否則為 mx。
之後遍歷 words 查表即可。

時間複雜度 O(N log N)。
空間複雜度 O(N)。

def lcp(s, t):
    if len(s) > len(t):
        s, t = t, s
    for i, c in enumerate(s):
        if c != t[i]:
            return i
    return len(s)


class Solution:
    def longestCommonPrefix(self, words: List[str], k: int) -> List[int]:
        N = len(words)

        if N-1 < k:
            return [0] * N

        ss = sorted(words)
        mx = mx2 = -1
        mx_i = -1
        for i in range(N-k+1):
            sz = lcp(ss[i], ss[i+k-1])
            if sz > mx:
                mx2 = mx
                mx, mx_i = sz, i
            elif sz > mx2:
                mx2 = sz
                
        mp = {}
        for i, w in enumerate(ss):
            if mx_i <= i <= mx_i + k-1:
                mp[w] = mx2
            else:
                mp[w] = mx

        return [mp[w] for w in words]