biweekly contest 140。
我搞了半天的 rolling hash 竟然被卡常數,真搞不懂時間限制的標準。
賽後某天不知道是改了測資還是改了時間限制,原本 TLE 的提交變成 AC 了。

題目

輸入兩個字串 s 和 pattern。

若一個字串 x 在修改至多一個字元後等同於字串 y,則稱 x 和 y 幾乎相等

回傳最小的 s 的子字串起始索引,其子字串與 pattern 幾乎相等
若不存在則回傳 -1。

解法

Q3 的時候是求子序列,我們利用了前後綴分解的技巧找到分割點,使得左右兩邊的匹配長度至少等於 N-1。
雖然本題 Q4 是求子字串,但同樣也適用前後綴分解的思維。

設一個子字串 sub = s[i..j] 幾乎相等於 pattern。
那麼兩者肯定具有公用前綴 pref = sub[i..] 還有公共後綴 suff = sub[..j],並有 len(pref) + len(suff) >= N-1。


問題轉換成:找 sub 和 pattern 的公共前綴公共後綴
以前周賽中也碰過不少次,正是 z-function。

但是原始的 z-function 是在字串 s 本身找子字串 s[i..] 的最長公共前綴
此處是要在 s 裡找 pattern,所以需要將兩者串接為 pattern + “#” +s。
其中 “#” 號是只是分隔習慣,不加也可以。

至於後綴,只需把 s 和 pattern 都反轉後,另外套一次 z-function 即可。
注意:z[i] 指的是 text = pattern + “#” + s,取 z[i] 值記得加上 len(pattern) + 1 的偏移量。
注意:z[i] 是從左向右數,所以後綴 z-array 的索引也要反轉,然後再加上偏移量。

時間複雜度 O(M + N)。
空間複雜度 O(M + N)。

class Solution:
    def minStartingIndex(self, s: str, pattern: str) -> int:
        M, N = len(s), len(pattern)
        pre_z = z_function(pattern + "#" + s)
        suf_z = z_function(pattern[::-1] + "#" + s[::-1])

        # check substring s[i..j]
        # where pre[i] + suf[j] >= N-1
        for i in range(M - N + 1):
            j = i + N - 1
            rev_j = M - 1 - j  # reverse j
            pre_i = i + N + 1  # offset len(pattern + "#")
            suf_j = rev_j + N + 1  # offset len(pattern + "#")
            if pre_z[pre_i] + suf_z[suf_j] >= N - 1:
                return i

        return -1


def z_function(s):
    N = len(s)
    z = [0]*N
    L = R = 0
    for i in range(1, N):
        if R < i:  # not covered by previous z-box
            # z[i] = 0
            pass
        else:  # partially or fully covered
            j = i-L
            if j+z[j] < z[L]:  # fully covered
                z[i] = z[j]
            else:
                z[i] = R-i+1

        while i+z[i] < N and s[i+z[i]] == s[z[i]]:  # remaining substring
            z[i] += 1
        if i+z[i]-1 > R:  # R out of prev z-box, update R
            L = i
            R = i+z[i]-1
    return z

修改 pattern 至多一個字元,可能的結果有 pattern 本身,或是任意 pattern[i] 修改成任意字母。
總共 26 * N 約 2e6 種可能。

透過 rolling hash 將所有可能的子字串加入集合中,最後再枚舉 s 中的子字串,第一個找到的就是答案。

時間複雜度 O(M + 26N)。
空間複雜度 O(M + 26N)。


稍微紀錄一下賽時調整參數的心得:
2e6 種子字串要塞進 1e9 的桶子,碰撞率挺高的,當時提交賭了十幾次 (???) 都失敗,只好改用雙雜湊。
但是我的雜湊模板又不支援字元修改,只好當場手刻,有夠累人,更氣的是最後還被卡常數 TLE。
雖然很意外官方後來有放人,雙雜湊版本變成 AC。

其實更簡單的方法應該是直接調整大質數,來個 1e15 的質數,碰撞率就小得多了。
要是再不夠,改成 1e18。
反正尊貴的 python 支持大數,要多大的質數都可以。

class Solution:
    def minStartingIndex(self, s: str, pattern: str) -> int:
        M, N = len(s), len(pattern)

        rh = RollingHash(pattern, MOD)
        powers = rh.p

        # try modify every char
        tot = rh.get(0, N-1)
        hashes = set()
        for i, c1 in enumerate(pattern):
            p = powers[N-1-i]
            for c2 in ascii_lowercase:
                h = (tot - ord(c1) * p + ord(c2) * p) % MOD
                hashes.add(h)

        # find substring
        rh = RollingHash(s, MOD)
        for i in range(M-N+1):
            h = rh.get(i, i+N-1)
            if h in hashes:
                return i

        return -1


MOD = 1_000_000_000_000_003

class RollingHash:
    def __init__(self, s, mod):
        self.mod = mod
        base = 87
        ps = self.ps = [0]
        p = self.p = [1]
        for c in s:
            ps.append((ps[-1] * base + ord(c)) % mod)
            p.append(p[-1] * base % mod)

    def get(self, L, R):
        return (self.ps[R+1] - self.ps[L] * self.p[R-L+1]) % self.mod