# Python-LeetCode 581 第17招 String (I)
字串是LeetCode常見的題型,從演算法解題的角度來看,字串類似於字元的陣列,所以也可以看成是數列,事實上有許多字串的題目其實跟數列的題目解法是一樣的,它只是出題的一種資料的形式而已。從字串與數列的差異角度來看,字串的成員(字元)通常是一個比較有限的集合,而數列的成員則範圍通常較大。字串的成員稱為alphabet,LeetCode字串題的alphabet通常是限小寫字母,最多也不過就ASCII的範圍。在學術研究上,DNA序列的alphabet只有4個字母,蛋白質序列則是20個字母。alphabet是一個constant size這一點可以看成是字串問題解題上特殊的特性。此外,字串題目中比數列更常會用到字典(hash table)。
本單元中,我們把字串的題目大略區分為以下類別,對於各類別舉出一些題目來說明使用的技巧:
1. 以字串為資料的數列問題。字串只是一種出題的方式,所用的方法,除字串基本操作外,與數列問題並無不同。
2. 字串解析與處理。簡易的語法分析或者要從字串中找出某些結構
3. String matching
4. Palindrome
5. Trie
6. Rolling hash
其中String matching與Palindrome的問題並非僅限於字串,也可以定義在數列上,只是通常放在字串的題目中。而包括Trie 與 Rolling hash則是比較適合small alphabet的資料結構與算法。
註:本單元因為長度較長,所以拆成上下兩個檔案。
## 以字串為資料的數列問題
這一類題目其實就跟數列題一樣,只是輸入與處理時採取字串形式,所以需要一些字串的基本操作。還是可能是難題,因為需要搭配算法,例如排序、Sliding window、DP、Greedy等。因為那些算法已在其他單元中陳述,本單元中僅舉出一些例子。
[(題目連結) 49. Group Anagrams (Medium)](https://leetcode.com/problems/group-anagrams/)
給你些字串,請將屬於相同anagram的字串歸成一組。兩個字串如果使用到的字母相同而排列不同稱為anagram。
這是歸類的問題,同一類我們用一個代表成員來表示的話,我們只要對每一個字串找出他所屬類別的代表成員即可。代表成員可以用它的字母排序後所得的字串來表示,要特別留意Python如何將一個字串內的字母排序。
以下是範例程式。我們先建立一個字典,用defaultdict(list)比較方便,不必特別判斷key值是否存在。接著歷遍每一個字串$s$,計算$s$排序後的字串可以用
''.join(sorted(s))
join是一個很好用的字串函數,語法是
string.join(iterable)
得到的結果是將iterable中每一個成員(必須是字串),以前面的string當做中介,予以串接後得到一個字串。例如,我們要將字串"qwerty"任兩個字母之間加入一個'-',就可以用
'-'.join('qwerty')
因為Python的字串是immutable,所以有時候我們會將字串轉換成list,處理完畢後要轉換回字串,就像本例中,我們不能直接排序字串的字母,所以sorted(s)得到的是sorted(list(s)),也就是是一個list,然後用空字串當做中介來串接,就是
''.join(sorted(s))。
在算出排序後的代表成員後我們就將$s$歸入該組。最後我們要的是每一組一個list,這個可以直接取得dict的values()就能直接轉換。
時間複雜度$O(nL\log L)$,$n$是字串個數,$L$是字串長度。
```python=
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
d = defaultdict(list)
for s in strs:
d[''.join(sorted(s))].append(s)
return d.values()
```
[(題目連結) 1061. Lexicographically Smallest Equivalent String (Medium)](https://leetcode.com/problems/lexicographically-smallest-equivalent-string/)
輸入給你兩個相同長度的字串s1與s2,這兩個字串定義了某些字母的等價關係,亦即$s1[i]$與$s2[i]$兩個字母是等價關係,在此等價關係下,請找出另外一個字串baseStr的最小等價字串。最小的意思是lexicographically smallest。
我們要找出所有等價類的最小代表字母,然後將baseStr的字母一一以該字母等價類的代字母替換就可以了。等價類是disjoint set,所以可以用類似union and find的手法來做。事實上,因為字母只有26個,有很多方式都可以達到需求。
我們以unin and find的概念來做此題,同一個等價類以最小的字母當root,將來對每一個字母找到root即可。以下是範例程式。我們先宣告一個字典$p$用來對應parent,寫一個找到root的函數ifind()。一開始,我們對s1與s2的每一對字母,將它們所屬的類別合併成一類,合併時,第10行先找到各自的root r1與r2,然後將大的併給小的,也就是讓字母順序比較小的當root。
計算答案時,只要將baseStr的每一個字母都找到他的root,最後用join串成字串。
時間複雜度方面,因為字母只有26個,所以是linear time。事實上,因為union發生的機會很少(最多26次),我們對ifind()有做路徑壓縮的狀況下,ifind所花的時間amortize來看就只有一層。
註:對union and find不熟悉的讀者可以參考[Python-LeetCode 581 第13招 Union-and-Find and Minimum spanning tree](/Q-iGBxm0SP69wEztHQnl7A)
```python=
class Solution:
# disjoint set; equivalent class
def smallestEquivalentString(self, s1: str, s2: str, baseStr: str) -> str:
p = defaultdict(str)
def ifind(s):
if not p[s]: return s
p[s] = ifind(p[s])
return p[s]
for c1,c2 in zip(s1,s2):
r1 = ifind(c1); r2 = ifind(c2)
if r1<r2: p[r2] = r1
elif r2<r1: p[r1] = r2
t = "".join([ifind(c) for c in baseStr])
return t
```
[(題目連結) 2391. Minimum Amount of Time to Collect Garbage (Medium)](https://leetcode.com/problems/minimum-amount-of-time-to-collect-garbage/)
三種回收垃圾分別以M, P, G表示,每一間房子各有若干回收垃圾,第 i 戶以字串garbage[i]表示,例如'GGM'就表示有兩單位的G與一單位的M。有三台資源回收車,每台回收車只收一種垃圾,而且一單位的垃圾需要一單位的時間。一開始三台車都在第0間房子,第 $i$ 間房子移動到第 $i+1$ 間房子需要的時間是$travel[i]$,而且車子必須根據房子編號依序移動,任何時間只有一台車能夠動作。請問總共需要多少時間可以把所有垃圾收集完畢。
仔細考慮這個問題,總需求時間是垃圾的總量加上移動的時間,垃圾的總量很容易計算,剩下的就是車輛的移動時間,由於每台車只能依照房子編號依序移動,所以唯一影響時間的因素就是:每一種垃圾最後出現的房子編號。例如M最後如果出現在編號5,則收M的那台車就必須花費從$0, 1, 2,...,$移動到5的時間。從0移動到 $i$ 的時間也就是$travel[\ ]$的prefix sum。
以下是範例程式。我們先計算移動時間的prefix sum,放在$ptime$。接著用一個字典 $last$ 來記每一種垃圾最後出現的房子編號,歷遍每一戶的垃圾,將數量加入 $total$,將所枚舉PMG三個字元,如果有在此戶中出現,就改寫 $last$ 的值,歷遍後,留下來的就是最後出現的編號,最後輸出答案。
時間複雜度$O(n+m)$,$n$是戶數,$m$是所有garbage字串總長度。
```python=
class Solution:
# find the last i with PGM respectively
def garbageCollection(self, garbage: List[str], travel: List[int]) -> int:
ptime = [0]
for t in travel:
ptime.append(ptime[-1]+t)
total = 0
last = {'P':0, 'M':0, 'G':0 }
for i,s in enumerate(garbage):
total += len(s)
for ch in last:
if ch in s: last[ch] = i
#
return total+sum(ptime[i] for i in last.values())
```
接著舉一個字串DP的例子。
[(題目連結) 87. Scramble String (Hard)](https://leetcode.com/problems/scramble-string/)
定義一個字串的以下遞迴操作:
* 如果長度為1,結束;
* 任意選擇一個位置將字串切成左右兩非空字串;
* 選擇交換或不交換左右兩字串;
* 對左右兩字串遞迴進行此操作。
輸入給你兩個長度相同的字串 $s1$ 與 $s2$,請問是否可以將 $s1$ 進行以上操作得到 $s2$。
遞迴定義的東西就遞迴來做,但加上memoization的DP來避免重複檢查。令$dp(l1,r1,l2,r2)$代表$s1[l1:r1]$是否可以變成$s2[l2:r2]$。以下是範例程式。
我們先檢查兩字串內的字母是否相同,這個可以用排序後的字串來檢查(第7行),其次,如果長度不超過3一定是可以轉變(第8行)。否則,我們就檢查在每一個位置切開的可能(第9行),對每一個切開的位置考慮交換與不交換兩種可能(第10 ~ 13行)。
時間複雜度$O(n^4\log n)$,$n$是字串長度,表面上dp有四個參數,但每次進入時兩邊長度必然是相等,所以只有$O(n^3)$種狀態,每種狀態花了$O(n\log n)$的時間。第7行如果不用sort來比較,複雜度可以少一個log,但字串長度其實很短,沒有什麼差別。
```python=
class Solution:
def isScramble(self, s1: str, s2: str) -> bool:
n = len(s1)
@lru_cache(maxsize=None)
def dp(l1,r1,l2,r2):
s,t = s1[l1:r1], s2[l2:r2]
if sorted(s)!=sorted(t): return False
if len(s) <=3: return True
for i in range(1,len(s)):
if dp(l1,l1+i,l2,l2+i) and dp(l1+i,r1,l2+i,r2):
return True
if dp(l1,l1+i,r2-i,r2) and dp(l1+i,r1,l2,r2-i):
return True
return False
#main
ans = dp(0,n,0,n)
return ans
```
接著看一個Sliding window與counter的例子。
[(題目連結) 438. Find All Anagrams in a String (Medium)](https://leetcode.com/problems/find-all-anagrams-in-a-string/)
輸入給你兩個字串 $s$ 與 $p$,請找出 $s$ 所有的substring是 $p$ 的anagram,也就是該子字串是 $p$ 的一個排列(使用字母種類與數量相同)。
很明顯的sliding window的題目,維護好window中屬於p中所需字母的數量,若window大小與 $p$ 的長度一致且滿足所有字母需求,就是一個答案。以下範例程式是使用Counter來當計數器,迴圈不變性為:視窗左端字母在視窗內出現的次數不多於 $p$ 中的數量。每次移動右端一格,調整左端,如果需求數量剛好滿足且視窗長度與 $p$ 的長度一致,就是一個答案。
時間複雜度$O(n+m)$,其中$m,n$分別是兩字串的長度。
```python=
class Solution:
def findAnagrams(self, s: str, p: str) -> List[int]:
pcnt = Counter(p) # goal
scnt = Counter() # current
idx = hit = 0
pos = []
for i,ch in enumerate(s): # move right
scnt[ch] += 1
if scnt[ch] <= pcnt[ch]:
hit += 1
while idx <= i and scnt[s[idx]] > pcnt[s[idx]]:
scnt[s[idx]] -= 1 # move left
idx += 1
if hit == len(p) and idx+len(p)-1 == i:
pos.append(idx) # record ans
# end for
return pos
```
因為本題計數器的大小不超過26,所以固定維護視窗大小與 $p$ 的長度一致,然後直接把計數器拿來比較也是一個方法,但稍微慢一點。
```python=
class Solution:
def findAnagrams(self, s: str, p: str) -> List[int]:
pos = []
m,n = len(s), len(p)
if m < n: return pos
pcnt = Counter(p)
curr = Counter(s[:n-1])
for i in range(n-1,m):
curr[s[i]] += 1
if curr == pcnt: pos.append(i-n+1)
curr[s[i-n+1]] -= 1
return pos
```
接著看一個搜尋的例子。
[(題目連結) 472. Concatenated Words (Hard)](https://leetcode.com/problems/concatenated-words/description/)
輸入給你一些字串 $words$,請找出其中哪些字串可以由其他較短的字串串接而成,較短的字串可以重複使用(請看原題範例)。字串數量不超過$10^4$,每個字串長度不超過30,所有字串長度總合不超過$10^5$。
題目乍看之下好像不知如何下手,字串的個數又很多,但注意到字串的長度很短,這使得枚舉搜尋成為一個可能的解法。
為了避免搜尋到自己,我們將字串根據長度分類,由短到長納入一個集合 $D$ 中,每次檢查一個字串 $s$ 是否可被串接而成時,$D$ 中的字串都是比較短的字串。在檢查字串 $s$ 時,我們嘗試 $s$ 的所有prefix $s[:i]$,如果$s[:i]$在 $D$ 中,我們就搜尋後半段 $s[i:]$ 是否可以被串接而成。
此外,為了避免重複搜尋,我們採取memoization,將搜尋失敗的字串記錄在一個 set 中。
以下是範例程式。初始時,先將所有字串根據長度放入 $W[i]$ 中,$D$ 初始只包含長度為1的字串。第17行開始,由短而長每一回合檢查一種長度的字串,搜尋檢查 $W[i]$ 中的每個字串,完畢後將 $W[i]$ 加入 $D$,第18行的 $fail$ 是紀錄搜尋失敗的字串,要留意變更 $D$ 之後必須重置 $fail$。
搜尋的函數在第8 ~ 15行,先檢查若 $s$ 在 $D$ 中則是True;在 $fail$ 中則是False。若都不是,對 $s$ 的每一個prefix,如果prefix在 $D$ 中且遞迴搜尋後半段也是True的話,則 $s$ 就是可以成功由較短字串串接而成。如果所有前綴都無法成功,則回傳False。
在沒有memoization的情況下,worst case time complexity粗估可能達到$O(n2^L)$,其中$n,L$分別是字串的數量與長度,因為搜尋中的迴圈導致$T(L)=T(L-1)+T(L-2)+\ldots +T(1)$,其中$T(L)$表示長度$L$的字串所需的搜尋時間。
在有memoization的協助下,失敗的搜尋字串不會超過$O(nL)$次,因為每個字串只會有$L$個suffix,更精確一點說,失敗的搜尋字串不會超過$O(M)$,其中$M$是所有字串長度總和,本題保證$M\le 10^5$。每次搜尋需要該字串長度平方的時間,所以總共的時間是$O(ML^2)$。
```python=
class Solution:
def findAllConcatenatedWordsInADict(self, words: List[str]) -> List[str]:
W = [set() for i in range(31)] # words of length i
for word in words:
W[len(word)].add(word)
ans = []
D = W[1] # containing only str shorter than current
def search(s): # if s is composed of shorter str
if s in D: return True # terminal case
if s in fail: return False # memoization
for i in range(1,len(s)): # try each possible prefix
if s[:i] in D and search(s[i:]):
return True
fail.add(s)
return False
#main
for i in range(2,31): # from short to long
fail = set() # reset memoization
for s in W[i]:
if search(s): ans.append(s)
D |= W[i]
return ans
```
## 字串解析與處理
[(題目連結) 10. Regular Expression Matching (Hard)](https://leetcode.com/problems/regular-expression-matching/)
輸入給你兩個字串 $s$ 與 $p$,其中 $s$ 只包含小寫字母,$p$ 除了小寫字母還可能有'.'與'\*'。其中
* '.' 可以代表任意一個字母。
* '\*' 代表他前方字母可以重複0次或任意多次。
請問在此規則下,$p$ 字串可否表達成 $s$。例如,若$p$="b.a\*w",則$p$可以表達的字串是b後面接任意一個字母,再接任意數量的a,再接一個w,例如 bqw, 或 bsaw 或 bbaaaaw....等等。本題中$p$ 與 $s$ 的長度皆不超過20。
我們可以用兩個字串的suffix來做DP,令$dp(i,j)$為$s[i:]$與$p[j:]$兩個字串能否相等,以下是top-down memoization的範例程式。以$memo[i,j]$來記錄算過的$dp(i,j)$值,初始條件是$memo[m,n]=True$,此時兩者皆是空字串。遞迴關係式為:
* 若$p[j+1]$ != '\*',則必須當前的字母配對成功且$d(i+1,j+1)$也是真,$dp(i,j)$才是真;
* 否則,$p[j+1]$ == '\*',有兩個情形$dp(i,j)$為真:$dp(i,j+2)$為真,也就是星號表示0個;或者當前字母配對且$dp(i+1,j)$為真,也就是星號先給出一個與$s[i]$配對。
其中當前字母配對必須考慮 '.' 的情形,此外,要留意邊界。
時間複雜度為$O(mn)$,其中$m,n$是兩個字串的長度,狀態最多只有$mn$種。
```python=
class Solution:
def isMatch(self, s: str, p: str) -> bool:
m,n = len(s),len(p)
memo = {}
for i in range(m): memo[i,n]=False
memo[m,n] = True
def dp(i,j):
if (i,j) in memo: return memo[i,j]
match = (i<m) and (s[i]==p[j] or p[j]=='.')
if j==n-1 or p[j+1]!='*':
res = match and dp(i+1,j+1)
else: # p=x*
res = dp(i,j+2) or (match and dp(i+1,j))
memo[i,j] = res
return res
#main
return dp(0,0)
```
[(題目連結) 65. Valid Number (Hard)](https://leetcode.com/problems/valid-number/)
很標準的Grammar的parser題目,題目中定義一個合法數字的文法規則,輸入一個字串,判斷它是否是合於文法定義的數字。
這一類的題目只要照著定義寫判斷即可,很多類似題目中會包含遞迴的定義,但本題沒有,只要須依些字串處理的技巧即可。以下是範例程式,題目定義有decimal,我們就寫一個函數判斷是否是decimal;題目有一個定義integer,我們也寫一個函數判斷是否是integer。總之,照著定義寫就好了。
```python=
class Solution:
def isNumber(self, s: str) -> bool:
def decimal(s):
if len(s)==0: return False
if s[0]=='+' or s[0]=='-':
s = s[1:]
if not '.' in s or len(s)==1:
return False
i = 0
while i<len(s) and '0'<=s[i]<='9':
i += 1
if i==len(s) or s[i]!='.':
return False
i += 1
while i<len(s) and '0'<=s[i]<='9':
i += 1
if i<len(s): return False
return True
def integer(s):
if len(s)==0: return False
if s[0]=='+' or s[0]=='-':
s = s[1:]
if len(s)==0: return False
for c in s:
if '0'>c or c>'9': return False
return True
# main
if 'e' in s:
i = s.index('e')
elif 'E' in s:
i = s.index('E')
else:
i = -1
if i>=0:
if not decimal(s[:i]) and not integer(s[:i]):
return False
if not integer(s[i+1:]):
return False
else:
if not decimal(s) and not integer(s):
return False
return True
```
[(題目連結) 224. Basic Calculator (Hard)](https://leetcode.com/problems/basic-calculator/)
輸入一個字串,表示一個由數字與加減法以及括號組成的一個計算式,請計算出其結果,其中,減號可以表示減法或負號。
這題是很標準的資料結構教科書中Expression evaluation的題目,當然計算式經過簡化。題目特別要求不可以用內建函數做,否則Python的eval()就直接搞定了。
這一類的題目是很實際的問題,因為compiler處理程式中的表示式就是要處理這個問題,而且複雜的多。教科書標準做法就是先轉Postfix form,然後利用堆疊做evaluation,以下是範例程式,其中我們把減法與負號用兩個符號來分別表示。如果對於Expression evaluation不熟悉可以查一下書或者網路上的教學,例如 [這一篇](https://www.geeksforgeeks.org/expression-evaluation/) 或 [另一篇](https://cp-algorithms.com/string/expression_parsing.html)。
```python=
class Solution:
def calculate(self, s: str) -> int:
s = s.replace(' ','')
s += ')'
n = len(s)
# to postfix
stk = ['('] # dummy
post = []
i = 0
while i < n:
if s[i] == '(':
stk.append('(')
i = i+1
continue
if '0' <= s[i] <='9':
j = i+1
while j<n and '0'<=s[j]<='9':
j += 1
post.append(int(s[i:j]))
i = j
continue
# otherwise
while stk[-1] != '(':
post.append(stk.pop())
if s[i] == ')':
stk.pop()
elif s[i] == '+':
stk.append('+')
elif s[i] == '-': # neg or minus
if i==0 or s[i-1]=='(':
stk.append('n') # negative
else:
stk.append('-') #minus
#end if
i += 1
#end while
#post += stk[::-1]
# evaluation
stk = []
for token in post:
if token == 'n':
stk[-1] = -stk[-1]
elif token == '+':
p = stk.pop()
stk[-1] += p
elif token == '-':
p = stk.pop()
stk[-1] -= p
else: # operand
stk.append(token)
return stk[-1]
```
## String matching
String matching是一個很重要而基本的問題,是指在一個字串 $s$ 中尋找另外一個字串 $pattern$,通常 $s$ 可能很長,而 $pattern$ 較短。搜尋的要求可能是找到第一個或者是所有出現的位置。這也就是很多文書軟體或搜尋引擎的基本文字搜尋功能。
如果用最基本的方法來找,我們對於每一個可能的起始位置進行比對,假設$n=len(s)$; $m=len(pattern)$。以下是枚舉比對。
```python
for i in range(n-m+1):
if s[i:i+m] == pattern:
print(i)
```
這樣的做法worst case時間複雜度是$O(mn)$,或者更精確的說$O(n(n-m+1))$。事實上,在一般情形下,這個方法表現的並不壞,因為對於大多數的字串來說,兩個字串的比對只要碰到第一個不符的字元就會結束比對,所以
s[i:i+m] == pattern
這行指令花的時間平均來講是很小的。但worst case確實存在,當 $s$ 是個週期重複字串時,就會發生比對很花時間的狀況,極端情形例如
s = "a"\*n
pattern = "a"\*(m-1)+'b'
這個問題那麼重要,當然也發展出了很多有效率的演算法,著名的例如KMP與Boyer-Moore演算法,這些演算法可以達到線性時間。那麼,要不要學呢? 能學當然很好,LeetCode中的題目大多數都有其他解法,看起來是因為LeetCode的目標並非多學特殊演算法,而著重在演算法精神的運用。
很多程式語言中都有提供字串中找字串的函數,而且**很多語言已經內建了線性時間的搜尋函數**,例如Python的string.find()以及C的strstr()。那麼,應該不必學了吧?可是也未必,出題者還是有辦法出類似觀念的題目,再者,學習著名算法也可以學習該算法中的精神。
先看一題簡單的字串搜尋裸題。
[(題目連結) 28. Find the Index of the First Occurrence in a String (Easy)](https://leetcode.com/problems/find-the-index-of-the-first-occurrence-in-a-string/)
題目很簡單,就是在一個字串中找另外一個字串出現的位置。雖然題目內字串的名字是**在稻草堆裡面找針**,但是本題並不難。
用Python一行指令就可以了,以下是範例程式,此外我們在下面還是列出了用直接手法而不使用string.find()函數來做,其實也是非常簡單的。
時間複雜度方面,利用find()在測資很大的時候是$O(m+n)$,而自己寫是$O(mn)$,由於測資很小,並非複雜度好的跑得快。
```python=
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
return haystack.find(needle)
# trival method
n=len(haystack); m=len(needle)
for i in range(n-m+1):
if haystack[i:i+m]==needle:
return i
return -1
```
接著我們來看一題需要string matching的應用題。
[(題目連結) 1764. Form Array by Concatenating Subarrays of Another Array (Medium)](https://leetcode.com/problems/form-array-by-concatenating-subarrays-of-another-array/)
輸入給你 $n$ 個數列組成的陣列$groups$,另外再給一個數列$nums$,請問你可否在$nums$中找出$n$個disjoint的subarray,他們依序就是$groups$中的$n$個數列。
在大方向上,我們先找$groups[0]$在$nums$中出現的位置,如果有多個位置,顯然應該要greedy,找第一個出現的只會好不會壞,然後依序往下(不可重疊)找出$groups$中的所有數列,如果成功,答案就是True,否則,過程中失敗,答案就是False。
剩下問題是如何在一個數列中找另外一個數列了。
數列中找數列與字串中找字串基本上是一樣的,可以用類似前一題的trivial algorithm。但這裡我們介紹一個有趣的方法來利用字串的find(),那就是我們把數列轉換成字串。請看以下範例程式。
一開始就先把$nums$轉成字串,用str將數字轉換為字串後,再以join將它們合併,彼此之間加上一個句點,以便分隔,否則無法區分$[12,34]$與$[123,4]$。接著歷遍$groups$,每一個數列也都依相同方法轉換為字串,留意到我們頭尾加上一個空字串,這是為了防止數列只有一個元素時,如果沒有間隔符號可能會找到一個大數字中的一部分。另外一個要注意的是find()第一個參數是要找的對象,第二個參數是開始尋找的位置(省略的話預設為0)。所以我們可以從前一個之後的地方開始找,程式第4, 7與9行就是控制搜尋起始位置。第9行的減1是不要跳掉最後一個句點。
時間複雜度是linear time,但轉換字串需要花一些時間(也是linear time)。
```python=
class Solution:
def canChoose(self, groups: List[List[int]], nums: List[int]) -> bool:
s = '.'.join(['']+[str(i) for i in nums]+[''])
p = 0
for g in groups:
gs = '.'.join(['']+[str(i) for i in g]+[''])
i = s.find(gs,p)
if i==-1: return False
p = i+len(gs)-1 # not excluding last period
return True
```
下一題也是利用string.find()來完成的題目。
[(題目連結) 1910. Remove All Occurrences of a Substring (Medium)](https://leetcode.com/problems/remove-all-occurrences-of-a-substring/)
題目很簡單,給兩個字串$s$與$part$,持續找出$s$中第一個出現$part$的地方,將它移除,直到沒有$part$出現為止。
是個模擬題,解法也很簡單,只要會用find與刪除字串中一部分的方法就行了。以下是範例程式。留意字串是immutable,刪除時另外構建就可以了。
```python=
class Solution:
def removeOccurrences(self, s: str, part: str) -> str:
k = len(part)
while True:
i = s.find(part)
if i<0: return s
s = s[:i]+s[i+k:]
#
```
下面這一題很有意思。
[(題目連結) 1392. Longest Happy Prefix (Hard)](https://leetcode.com/problems/longest-happy-prefix/)
題目是說給你一個字串$s$,要找出(除了$s$自己之外)最長的一段前綴(prefix),該前綴也是$s$的後綴(suffix),也就是說,若$s$的長度為$n$,要找出最大的$k<n$,滿足$s[:k]==s[-k:]$。
直接的方法是由大到小或由小到大嘗試$k$值,例如
```python=
for k in range(n-1,-1,-1):
if s[:k]==s[-k:]: return s[:k]
```
但可能花費$O(n^2)$的時間。
為什麼會說這個題目很有趣呢?因為它要算的就是KMP演算法中的精神所在。不熟悉的讀者可以先看看KMP的教學文件,網路上可以找到很多,例如 [CP-Algorithm-KMP](https://cp-algorithms.com/string/prefix-function.html)這一篇寫得就很清楚容易了解。簡單來說,KMP在字串$s$中搜尋字串$p$時,會先計算$p$的一個**prefix function** (多數文獻中稱為failure function),對於每一個位置$i$,這個prefix function就是最大的$k$,滿足長度為$k$的前綴等於以$i$結尾的後綴($p[:k]==p[i-k+1:i+1]$)。而KMP的算法精神在於
1. prefix function可以很有效率的被計算出來,是一種DP的方法。
2. 在$s$中搜尋$p$時,利用$p$的prefix function,當比對失敗時,可以直接跳到下一個比對位置而非重頭開始比對,減少重複比對。
以下是範例程式,其中的$pi$就是prefix function,詳細內容請參考上述文件。時間複雜度是線性。
如同前面提到的,不使用KMP也可以做這題,方法是用Rolling hash,後面章節中會在說明方法。
```python=
class Solution:
# it is indeed the KMP failure function, aka prefix
# pi[i]=j is longest prefix = suffix, s[:j] = s[i-j+1:i+1]
def longestPrefix(self, s: str) -> str:
n = len(s)
pi = [0]*n
j = 0
for i in range(1,n):
if s[i] == s[j]:
j += 1
pi[i] = j
continue
# otherwise
while j>0 and s[j]!=s[i]:
j = pi[j-1]
j += (s[j]==s[i])
pi[i] = j
#
return s[:pi[-1]]
```
**End of String (上)**