刷题系列文章目录(python版)
第一章 字符串
第二章 数组
第三章 链表
第四章 堆,栈 与 队列
第五章 树
第六章 哈希与映射
第七章 排序与检索
第八章 动态规划
第九章 图论
第十章 位运算
刷题一字符串
一、字符串是什么?
字符串是一个由字符构成的数组。
一个重要的知识点–子串
二、字符串的基本操作
字符串操作比其他数据类型更复杂(例如比较、连接操作)
1.比较操作(“==” )
对于C++、Python我们可以使用 ==
来比较两个字符串。
但是对于Java,我们可能无法使用 ==
来比较两个字符串。当我们使用 ==
时,它实际上会比较这两个对象是否是同一个对象。
2.连接操作
对于不同的编程语言中,字符串可能是可变的,也可能是不可变的。不可变意味着一旦字符串被初始化,你就无法改变它的内容。
在C ++中,字符串是可变的。 也就是说,你可以像在数组中那样修改字符串。
在Java、Python中,字符串是不可变的。
针对 Java 中出现的此问题,我们提供了以下解决方案:
- 如果你确实希望你的字符串是可变的,则可以使用
toCharArray
将其转换为字符数组。 - 如果你经常必须连接字符串,最好使用一些其他的数据结构,如
StringBuilder
。
三、字符串的经典题目
1. 最长公共前缀
编写一个函数来查找字符串数组中的最长公共前缀。如果不存在公共前缀,返回空字符串 “”。
方法一:横向扫描
L
C
P
(
S
1
…
S
n
)
\ LCP(S1…Sn)
LCP(S1…Sn) 表示字符串
S
1
.
.
.
S
n
S_1...S_n
S1...Sn的最长公共前缀
结论:
L
C
P
(
S
1
…
S
n
)
=
L
C
P
(
L
C
P
(
L
C
P
(
S
1
,
S
2
)
,
S
3
)
,
…
S
n
)
LCP(S1…Sn)=LCP(LCP(LCP(S1,S2 ),S3),…Sn)
LCP(S1…Sn)=LCP(LCP(LCP(S1,S2),S3),…Sn)
依次遍历字符串数组中的每个字符串,对于每个遍历到的字符串,更新最长公共前缀,当遍历完所有的字符串以后,即可得到字符串数组中的最长公共前缀。
如果在尚未遍历完所有的字符串时,最长公共前缀已经是空串,则最长公共前缀一定是空串,因此不需要继续遍历剩下的字符串,直接返回空串即可。
class Solution:
def longestCommonPrefix(self, strs: List[str]) -> str:
if not strs:return ""
n = len(strs)
preStr = strs[0]
for i in range(1,n):
preStr = self.LCP(preStr,strs[i])
if not preStr:
break
return preStr
def LCP(self,str1,str2):
length ,index = min(len(str1),len(str2)),0
while index < length and str1[index] == str2[index]:
index += 1
return str1[:index]
时间复杂度:O(mn)
空间复杂度:O(1)
方法一加强版(纵向)
class Solution:
def longestCommonPrefix(self, strs: List[str]) -> str:
if not strs:return ""
length, n = len(strs[0]), len(strs)
for i in range(length):
c = strs[0][i]
if any(i == len(strs[j]) or strs[j][i] != c for j in range(1, n)):
return strs[0][:i]
return strs[0]
方法二:二分查找
显然,最长公共前缀的长度不会超过字符串数组中的最短字符串的长度。用 minLength 表示字符串数组中的最短字符串的长度,则可以在 [0,minLength] 的范围内通过二分查找得到最长公共前缀的长度。每次取查找范围的中间值mid,判断每个字符串的长度为mid 的前缀是否相同,如果相同则最长公共前缀的长度一定大于或等于 mid,如果不相同则最长公共前缀的长度一定小于 mid,通过上述方式将查找范围缩小一半,直到得到最长公共前缀的长度。
class Solution:
def longestCommonPrefix(self, strs: List[str]) -> str:
def isCommonPrefix(index):
n = len(strs)
str0 = strs[0][:index]
return all(strs[i][:index] == str0 for i in range(1,n))
if not strs:return ""
left ,right = 0 , min(len(str_) for str_ in strs)
while left < right:
mid = left + (right - left + 1)//2
if isCommonPrefix(mid):
left = mid
else:
right = mid - 1
return strs[0][:left]
注:讲一下为什么这样写mid = left + (right - left + 1)//2
一、因为mid = (left+right)//2 会导致超时
二、为什么二分法是 mid = (right - left + 1) // 2 + left,而分治法又是mid = (start + end) // 2,到底什么时候该用哪个,傻傻分不清楚,这里发现可以利用4,5来枚举用。假设left=4,right=5, mid=4,因为left=mid=4,所以无限循环
方法三:字典序
把最长的和最短的比较有多少前缀相同。yyds
class Solution:
def longestCommonPrefix(self, s: List[str]) -> str:
if not s:return ""
s.sort()
n = len(s)
a = s[0]
b = s[n-1]
res = ""
for i in range(len(a)):
if i < len(b) and a[i] == b[i]:
res += a[i]
else:
break
return res
2. 最长回文子串
给你一个字符串 s,找到 s 中最长的回文子串。
方法一:中心扩展法(回文基本解法)
从某个元素开始,左右两边探索是否符合回文数并返回回文数的长度,比较并保存最长起始坐标和长度;
注意:回文数奇偶情况
时间复杂度:O(N^2) 空间复杂度: O(1)
class Solution:
def longestPalindrome(self, s: str) -> str:
if not s :return ""
n = len(s)
left,right = 0,0
maxStart = 0
maxLen = 0
length = 1
for i in range(n):
left ,right = i - 1, i + 1
while left >=0 and s[left] == s[i]:
left -= 1
length += 1
while right < n and s[right] == s[i]:
right += 1
length += 1
while right < n and left >= 0 and s[left] == s[right]:
right += 1
left -= 1
length += 2
if length > maxLen:
maxLen = length
maxStart = left
length = 1
return s[maxStart + 1:maxStart + maxLen + 1]
方法二、动态规划
中心扩散的方法,其实做了很多重复计算。动态规划就是为了减少重复计算的问题。作用和工程中用 redis 做缓存有异曲同工之妙。
dp[left][right] 定义:字符串从left到right是回文子串
初始状态,left=right 时,此时 dp[left][right]=true。
状态转移方程: dp[left][right] =s[left] == s[right] and dp[left+1][right-1]
**边界条件:
如果s[left]!=s[right],那么字符串从left到right是不可能构成子串的,直接跳过即可。
如果s.charAt(left)==s.charAt(right),字符串从left到right能不能构成回文子串还需要进一步判断
- 如果
left == right
,也就是说只有一个字符,我们认为他是回文子串。即dp[left][right] = true(left == right)
- 如果
right-left<=2
,类似于"aa",或者"aba",我们认为他是回文子串。即dp[left][right]=true(right-left<=2)
- 如果
right-left>2
,我们只需要判断dp[left+1][right-1]是否是回文子串,才能确定dp[left][right]是否为true还是false。即dp[left][right] = dp[left+1][right-1]
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
dp = [[0]*n for _ in range(n+1)]
maxStart = 0
maxLen = 1
for right in range(1,n):
for left in range(right):
if s[left] == s[right] and( right - left <= 2 or left == right or dp[left +1 ][right - 1]):
dp[left][right] = True
if maxLen < right - left + 1:
maxLen = right -left + 1
maxStart = left
return s[maxStart:maxStart + maxLen ]
**
方法三:Manacher 算法
思路与算法
在中心扩展算法的过程中,我们能够得出每个位置的臂长。那么当我们要得出以下一个位置 i 的臂长时,能不能利用之前得到的信息呢?
答案是肯定的。具体来说,如果位置 j 的臂长为 length,并且有 j + length > i,如下图所示:
当在位置 i 开始进行中心拓展时,我们可以先找到 i 关于 j 的对称点 2 * j - i。那么如果点 2 * j - i 的臂长等于 n,我们就可以知道,点 i 的臂长至少为 min(j + length - i, n)。那么我们就可以直接跳过 i 到 i + min(j + length - i, n) 这部分,从 i + min(j + length - i, n) + 1 开始拓展。
将奇偶数的情况统一起来:我们向字符串的头尾以及每两个字符中间添加一个特殊字符 #,比如字符串 aaba 处理后会变成 #a#a#b#a#。那么原先长度为偶数的回文字符串 aa 会变成长度为奇数的回文字符串 #a#a#,而长度为奇数的回文字符串 aba 会变成长度仍然为奇数的回文字符串 #a#b#a#,我们就不需要再考虑长度为偶数的回文字符串了。
class Solution:
def expand(self, s, left, right):
while left >= 0 and right < len(s) and s[left] == s[right]:
left -= 1
right += 1
return (right - left - 2) // 2
def longestPalindrome(self, s: str) -> str:
end, start = -1, 0
s = '#' + '#'.join(list(s)) + '#'
arm_len = []
right = -1
j = -1
for i in range(len(s)):
if right >= i:
i_sym = 2 * j - i
min_arm_len = min(arm_len[i_sym], right - i)
cur_arm_len = self.expand(s, i - min_arm_len, i + min_arm_len)
else:
cur_arm_len = self.expand(s, i, i)
arm_len.append(cur_arm_len)
if i + cur_arm_len > right:
j = i
right = i + cur_arm_len
if 2 * cur_arm_len + 1 > end - start:
start = i - cur_arm_len
end = i + cur_arm_len
return s[start+1:end+1:2]
3. 翻转字符串里的单词
给你一个字符串 s ,逐个翻转字符串中的所有 单词 。
单词 是由非空格字符组成的字符串。s 中使用至少一个空格将字符串中的 单词 分隔开。
请你返回一个翻转 s 中单词顺序并用单个空格相连的字符串。
方法一:直接调用API
字符串的操作
split(拆分),reverse(翻转)和 join(连接)
- 使用 split 将字符串按空格分割成字符串数组;
- 使用 reverse 将字符串数组进行反转;
- 使用 join方法将字符串数组拼成一个字符串。
时间复杂度:O(n),其中 n 为输入字符串的长度。
空间复杂度:O(n),用来存储字符串分割之后的结果。
class Solution:
def reverseWords(self, s: str) -> str:
return " ".join(reversed(s.split()))
方法二:
对于字符串不可变的语言,首先得把字符串转化成其他可变的数据结构,同时还需要在转化的过程中去除空格。
对于字符串可变的语言,就不需要再额外开辟空间了,直接在字符串上原地实现。在这种情况下,反转字符和去除空格可以一起完成。
时间复杂度:O(n),其中 n 为输入字符串的长度。
空间复杂度:Java 和 Python 的方法需要 O(n) 的空间来存储字符串,而 C++ 方法只需要 O(1)的额外空间来存放若干变量。
class Solution:
def trim_spaces(self, s: str) -> list:
left, right = 0, len(s) - 1
# 去掉字符串开头的空白字符
while left <= right and s[left] == ' ':
left += 1
# 去掉字符串末尾的空白字符
while left <= right and s[right] == ' ':
right -= 1
# 将字符串间多余的空白字符去除
output = []
while left <= right:
if s[left] != ' ':
output.append(s[left])
elif output[-1] != ' ':
output.append(s[left])
left += 1
return output
def reverse(self, l: list, left: int, right: int) -> None:
while left < right:
l[left], l[right] = l[right], l[left]
left, right = left + 1, right - 1
def reverse_each_word(self, l: list) -> None:
n = len(l)
start = end = 0
while start < n:
# 循环至单词的末尾
while end < n and l[end] != ' ':
end += 1
# 翻转单词
self.reverse(l, start, end - 1)
# 更新start,去找下一个单词
start = end + 1
end += 1
def reverseWords(self, s: str) -> str:
l = self.trim_spaces(s)
# 翻转字符串
self.reverse(l, 0, len(l) - 1)
# 翻转每个单词
self.reverse_each_word(l)
return ''.join(l)
方法三:双端队列
由于双端队列支持从队列头部插入的方法,因此我们可以沿着字符串一个一个单词处理,然后将单词压入队列的头部,再将队列转成字符串即可。
时间复杂度:O(n),其中 n 为输入字符串的长度。
空间复杂度:O(n),双端队列存储单词需要 O(n) 的空间。
class Solution:
def reverseWords(self, s: str) -> str:
left, right = 0, len(s) - 1
# 去掉字符串开头的空白字符
while left <= right and s[left] == ' ':
left += 1
# 去掉字符串末尾的空白字符
while left <= right and s[right] == ' ':
right -= 1
d, word = collections.deque(), []
# 将单词 push 到队列的头部
while left <= right:
if s[left] == ' ' and word:
d.appendleft(''.join(word))
word = []
elif s[left] != ' ':
word.append(s[left])
left += 1
d.appendleft(''.join(word))
return ' '.join(d)
4. 实现 strStr()
给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串出现的第一个位置(下标从 0 开始)。如果不存在,则返回 -1 。
Knuth-Morris-Pratt 算法:
对于长度为 m 的字符串 s,其前缀函数表示 s 的子串 s[0:i] 的最长的相等的真前缀与真后缀的长度。其中真前缀与真后缀的定义为不等于自身的的前缀与后缀。
如何求解前缀函数:我们可以一边读入字符串,一边求解当前读入位的前缀函数。
1. π ( i ) ≤ π ( i − 1 ) + 1 1.π(i)≤π(i−1)+1 1.π(i)≤π(i−1)+1
- 依据 π ( i ) π(i) π(i)定义得: s [ 0 : π ( i ) − 1 ] = s [ i − π ( i ) + 1 : i ] s[0:π(i)−1]=s[i−π(i)+1:i] s[0:π(i)−1]=s[i−π(i)+1:i]。
- 将两区间的右端点同时左移,可得: s [ 0 : π ( i ) − 2 ] = s [ i − π ( i ) + 1 : i − 1 ] s[0:π(i)−2]=s[i−π(i)+1:i−1] s[0:π(i)−2]=s[i−π(i)+1:i−1] 依据 π ( i − 1 ) π(i−1) π(i−1)定义得: π ( i − 1 ) ≥ π ( i ) − 1 π(i−1)≥π(i)−1 π(i−1)≥π(i)−1即 π ( i ) ≤ π ( i − 1 ) + 1 π(i)≤π(i−1)+1 π(i)≤π(i−1)+1
2.如果
s
[
i
]
=
s
[
π
(
i
−
1
)
]
s[i]=s[π(i−1)]
s[i]=s[π(i−1)],那么
π
(
i
)
=
π
(
i
−
1
)
+
1
π(i)=π(i−1)+1
π(i)=π(i−1)+1。
依据
π
(
i
−
1
)
π(i−1)
π(i−1) 定义得:
s
[
0
:
π
(
i
−
1
)
−
1
]
=
s
[
i
−
π
(
i
−
1
)
:
i
−
1
]
s[0:π(i−1)−1]=s[i−π(i−1):i−1]
s[0:π(i−1)−1]=s[i−π(i−1):i−1]
因为
s
[
π
(
i
−
1
)
]
=
s
[
i
]
s[π(i−1)]=s[i]
s[π(i−1)]=s[i],可得
s
[
0
:
π
(
i
−
1
)
]
=
s
[
i
−
π
(
i
−
1
)
:
i
]
s[0:π(i−1)]=s[i−π(i−1):i]
s[0:π(i−1)]=s[i−π(i−1):i]
依据
π
(
i
)
π(i)
π(i) 定义得:
π
(
i
)
≥
π
(
i
−
1
)
+
1
π(i)≥π(i−1)+1
π(i)≥π(i−1)+1,结合第一个性质可得
π
(
i
)
=
π
(
i
−
1
)
+
1
。
π(i)=π(i−1)+1。
π(i)=π(i−1)+1。
构造next数组
我们定义一个函数getNext来构建next数组,函数参数为指向next数组的指针,和一个字符串。 代码如下:
构造next数组其实就是计算模式串s,前缀表的过程。 主要有如下三步:
- 初始化 :定义两个指针i和j,j指向前缀终止位置(严格来说是终止位置减一的位置),i指向后缀终止位置(与j同理)。然后还要对next数组进行初始化赋值,如下:
void getNext(int* next, const string& s)
- 处理前后缀不相同的情况 :因为j初始化为-1,那么i就从1开始,进行s[i] 与 s[j+1]的比较。所以遍历模式串s的循环下标i 要从 1开始,代码如下:
for(int i = 1; i < s.size(); i++) {
如果 s[i] 与 s[j+1]不相同,也就是遇到 前后缀末尾不相同的情况,就要向前回溯。next[j]就是记录着j(包括j)之前的子串的相同前后缀的长度。那么 s[i] 与 s[j+1] 不相同,就要找 j+1前一个元素在next数组里的值(就是next[j])。所以,处理前后缀不相同的情况代码如下:
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回溯
}
- 处理前后缀相同的情况:如果s[i] 与 s[j + 1] 相同,那么就同时向后移动i 和j 说明找到了相同的前后缀,同时还要将j(前缀的长度)赋给next[i], 因为next[i]要记录相同前后缀的长度。
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j;
void getNext(int* next, const string& s){
int j = -1;
next[0] = j;
for(int i = 1; i < s.size(); i++) { // 注意i从1开始
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回溯
}
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
使用next数组来做匹配
因为next数组里记录的起始位置为-1。 i就从0开始,j初始值为-1,遍历文本串,代码如下:
for (int i = 0; i < s.size(); i++)
接下来就是 s[i] 与 t[j + 1] (因为j从-1开始的) 经行比较。如果 s[i] 与 t[j + 1] 不相同,j就要从next数组里寻找下一个匹配的位置。
while(j >= 0 && s[i] != t[j + 1]) {
j = next[j];
}
如果 s[i] 与 t[j + 1] 相同,那么i 和 j 同时向后移动, 代码如下:
if (s[i] == t[j + 1]) {
j++; // i的增加在for循环里
}
如果j指向了模式串t的末尾,那么就说明模式串t完全匹配文本串s里的某个子串了。
if (j == (t.size() - 1) ) {
return (i - t.size() + 1);
}
那么使用next数组,用模式串匹配文本串的整体代码如下:
int j = -1; // 因为next数组里记录的起始位置为-1
for (int i = 0; i < s.size(); i++) { // 注意i就从0开始
while(j >= 0 && s[i] != t[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
if (s[i] == t[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (t.size() - 1) ) { // 文本串s里出现了模式串t
return (i - t.size() + 1);
}
}
总体代码实现
class Solution {
public:
void getNext(int* next, const string& s) {
int j = -1;
next[0] = j;
for(int i = 1; i < s.size(); i++) { // 注意i从1开始
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回溯
}
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
int strStr(string haystack, string needle) {
if (needle.size() == 0) {
return 0;
}
int next[needle.size()];
getNext(next, needle);
int j = -1; // // 因为next数组里记录的起始位置为-1
for (int i = 0; i < haystack.size(); i++) { // 注意i就从0开始
while(j >= 0 && haystack[i] != needle[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
if (haystack[i] == needle[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (needle.size() - 1) ) { // 文本串s里出现了模式串t
return (i - needle.size() + 1);
}
}
return -1;
}
};
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
if not needle:return 0
j = -1
Sn ,Fn = len(needle),len(haystack)
next = self.getNext(needle,Sn)
for i in range(Fn):
while j >= 0 and haystack[i] != needle[j+1]:
j = next[j]
if haystack[i] == needle[j+1]:
j += 1
if j == Sn - 1:
return i - Sn + 1
return -1
def getNext(self,str,n):
length = -1
next = [" " for i in range(n)]
next[0] = length
for i in range(1,n):
while length >= 0 and str[i] != str[length+1]:
length = next[length]
if str[i] == str[length+1]:
length += 1
next[i] = length
return next
5. 字符串匹配算法:KMP
Knuth–Morris–Pratt(KMP)算法是一种改进的字符串匹配算法,它的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。它的时间复杂度是 O(m+n)。
小插曲:构造 next 数组
构造方法为:next[i] 对应的下标,为 P[0…i - 1] 的最长公共前缀后缀的长度,令 P[0] = -1。 具体解释如下
前缀:它的前缀包括:a, ab, abc, abcb,不包括本身;
后缀:它的后缀包括:bcba, cba, ba, a,不包括本身;
最长公共前缀后缀:abcba 的前缀和后缀中只有 a 是公共部分,字符串 a 的长度为 1。
所以,我们将 P[0…i - 1] 的最长公共前后缀的长度作为 next[i] 的下标,就得到了 next 数组。(next数组中存的是P中该从几号开始)
KMP 主算法参考代码:
int match (char* P, char* S){ // KMP 算法
int* next = buildNext(P); // 构造 next 表
int m = (int) strlen (S), i = 0; // 文本串指针
int n = (int) strlen(P), j = 0; //模式串指针
while (j < n && i < m) // 自左向右逐个比对字符
if (0 > j || S[i] == P[j]) // 若匹配,或 P 已移除最左侧
{i++; j++;} // 则转到下一字符
else
j = next[j]; // 模式串右移(注意:文本串不用回退)
delete [] next; // 释放 next 表
return i - j;
}
构造 next 表参考代码:
int* buildNext(char* P) { // 构造模式串 P 的 next 表
size_t m = strlen(P), j = 0; // “主”串指针
int* N = new int[m]; // next 表
int t = N[0] = -1; // 模式串指针
while (j < m - 1)
if ( 0 > t || P[j] == P[t]){ // 匹配
j++; t++;
N[j] = t; // 此句可改进为 N[j] = (P[j] != P[t] ? t : N[t]);
}else // 失配
t = N[t];
return N;
}
总结
解题手法:
一、双指针:
- 通常,我们只需要一个指针进行迭代,即从数组中的第一个元素开始,最后一个元素结束。然而,有时我们会使用两个指针进行迭代。使用双指针技巧,其思想是分别将两个指针分别指向数组的开头及末尾,然后将其指向的元素进行交换,再将指针向中间移动一步,继续交换,直到这两个指针相遇。
- 有时,我们可以使用两个不同步的指针来解决问题,即快慢指针。与情景一不同的是,两个指针的运动方向是相同的,而非相反。
经典问题:给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
使用双指针的典型场景之一
是你想要从两端向中间迭代数组。
这时你可以使用双指针技巧:一个指针从头部开始,而另一个指针从尾部开始。这种技巧经常在排序数组中使用。
另一种非常常见的情况:
同时有一个慢指针和一个快指针。
解决这类问题的关键是:确定两个指针的移动策略。
与前一个场景类似,你有时可能需要在使用双指针技巧之前对数组进行排序,也可能需要运用贪心法则来决定你的运动策略。