【算法】【学习笔记】双指针、滑动窗口、滑动哈希
双指针
双指针是一种十分常用的算法方法,一般有滑动窗口、快慢指针等使用方式。
在下文就根据题目详细解释下。
❔题目
1. 环形链表
解题思路
是否有环是十分经典的快慢指针题目,就像龟兔赛跑一样。
只需要一个步长为1的slow指针,和一个步长为2的fast指针即可解决。
- 只要fast能走到终点,说明无环;
- 假如fast和slow相遇,说明有环。
证明:
假设有环,进入环后,fast相对于slow每轮靠近1个节点,
因此一定能够追上。
实现
function hasCycle(head: ListNode | null): boolean {
let slow = head, fast = head;
while(fast !== null && fast.next !== null){
slow = slow!.next; // TS类型需要非空断言,算法上slow一定存在
fast = fast.next.next;
if(slow === fast){
return true;
}
}
return false;
};
public class Solution {
public bool HasCycle(ListNode head) {
var slow = head;
var fast = head;
while(fast != null && fast.next != null){
slow = slow.next;
fast = fast.next.next;
if(slow == fast){
return true;
}
}
return false;
}
}
class Solution {
public:
bool hasCycle(ListNode *head) {
auto slow = head, fast = head;
while(fast != NULL && fast->next != NULL){
slow = slow->next;
fast = fast->next->next;
if(slow == fast){
return true;
}
}
return false;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
无环时快指针遍历 \(N\) 个节点;有环时快指针追上慢指针最多多走一圈(环的长度),总步数依然在 \(O(N)\) 级别。 - 空间复杂度: \(O(1)\)
仅使用了 slow 和 fast 两个节点指针,占用常数级别额外空间。
2. 最接近的三数之和
解题思路
一开始思考是否可以用滑动窗口,不过此题不需要连续,所以不可以用滑动窗口。
这时候可以用排序+双指针。
当定好第一个数 \(x\) 时,问题就转变为了 \(l+r\) 要尽可能接近 \(target-x\)。
首先,进行排序,排序的目的就是利用单调性,使双指针移动具有方向性。
其中,\(l\) 为 \(x\) 往后的第一个数,\(r\) 为数组的最后一个数:
- 当 \(l+r+x < target\) ,说明数值需要增大,由于已经排序了,即 \(l\) 往右移即可;
- 当 \(l+r+x > target\) ,说明数值需要缩小,由于已经排序了,即 \(r\) 往左移即可;
- 当 \(l+r+x = target\) ,说明已经是最优解,返回即可。
实现
function threeSumClosest(nums: number[], target: number): number {
// 先排序了,定好一个数字,然后双指针找最近target的
nums.sort((a,b)=>a-b);
let t = nums[0] + nums[1] + nums[2];
const n = nums.length;
for(let i = 0; i < n-2; i++){
const x = nums[i];
// 当当前数与上次数一样,效果不会有变化的,跳过
if (i > 0 && x === nums[i - 1]) continue;
let l = i+1, r = n-1;
while(l < r){
const cur = nums[l] + nums[r] + nums[i];
if(cur === target){
return target;
}
if(Math.abs(target - t) > Math.abs(target - cur)){
t = cur;
}
if(cur < target){
l++;
}else{
r--;
}
}
}
return t;
};
public class Solution {
public int ThreeSumClosest(int[] nums, int target) {
Array.Sort(nums);
int n = nums.Length;
if(n < 3) return -1;
int res = nums[0]+nums[1]+nums[2];
for(int i = 0; i < n-2; i++){
int num = nums[i];
if(i > 0 && num == nums[i-1]) continue;
int l = i + 1, r = n - 1;
while(l < r){
int cur = nums[l] + nums[r] + num;
if(cur == target){
return target;
}
if(Math.Abs(target-cur) < Math.Abs(target-res)){
res = cur;
}
if(cur < target){
l++;
}else{
r--;
}
}
}
return res;
}
}
class Solution {
public:
int threeSumClosest(vector<int>& nums, int target) {
int n = nums.size();
if(n < 3) return -1;
sort(nums.begin(), nums.end());
int res = nums[0]+nums[1]+nums[2];
for(int i = 0; i < n-2; i++){
int num = nums[i];
if (i > 0 && num == nums[i - 1]) continue;
int l = i+1, r = n-1;
while(l < r){
int cur = nums[l] + nums[r] + num;
if(cur == target){
return target;
}
if(abs(target-cur)<abs(target-res)){
res = cur;
}
if(cur < target){
l++;
}else{
r--;
}
}
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N^2)\)
数组排序需要 \(O(N \log N)\),外层循环 \(O(N)\) 配合内层双指针 \(O(N)\) 的总耗时为 \(O(N^2)\)。综合时间复杂度为 \(O(N \log N) + O(N^2) = O(N^2)\)。 - 空间复杂度: \(O(1)\)
没有使用额外的非常数级空间,只是使用了左右指针。
3. 神奇字符串
解题思路
神奇字符串是一个无限序列,题目只要求它的前 n 个字符。
因此我们只需要模拟生成过程直到长度达到 n。
可以看出1和2都至多只能连续出现2次。
我们可以使用一边构造神奇字符串,一边对1计数来获得答案。
构造的方法可以先构造出默认的最初状态s='122',然后每次插入的数字num为1和2的轮换——即插入1后,下一次插入2:
- 假如读取到的 s[i] 为 2,说明当前数字 num 需要连续插入 2 次;
- 假如读取到的 s[i] 为 1,说明当前数字 num 需要连续插入 1 次。
实现
function magicalString(n: number): number {
// 由此可以知道,1和2的连续范围也只在[1,2]
if(n <= 0) return 0;
if(n <= 3) return 1;
// 初始化种子字符串和指针
let res = 1;
const s = [1,2,2];
let i = 2;
let num = 1; // 下一个要填的数字,交替出现,填了1就变2,填了2就变1。
while(s.length < n){
const count = s[i]; // 决定当前数字要加几次
for(let j = 0; j < count && s.length < n; j++){
s.push(num);
if(num === 1){
res++;
}
}
num = 3 - num; // 2变1,1变2
i++; // 检查下一个
}
return res;
};
public class Solution {
public int MagicalString(int n) {
if(n <= 0) return 0;
if(n <= 3) return 1;
List<int> s = [1,2,2];
int res = 1;
int i = 2;
int num = 1;
while(s.Count < n){
int count = s[i++];
for(int j = 0; j < count && s.Count < n; j++){
s.Add(num);
if(num == 1){
res++;
}
}
num = 3 - num;
}
return res;
}
}
class Solution {
public:
int magicalString(int n) {
if(n <= 0) return 0;
if(n <= 3) return 1;
vector<int> s = {1,2,2};
int res = 1, i = 2, num = 1;
while(s.size() < n){
int count = s[i++];
for(int j = 0; j < count && s.size() < n; j++){
s.push_back(num);
if(num == 1){
res++;
}
}
num = 3 - num;
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
每个字符最多被生成一次,因此生成前 n 个字符需要 O(n) 时间。 - 空间复杂度: \(O(N)\)
需要一个长度为n的数组存储神奇字符串。
滑动窗口
滑动窗口有个解法,求最长时:
- 右指针扩张窗口
- 更新窗口状态
- 判断窗口是否合法
- 不合法时移动左指针
- 记录最大答案
❔题目
1. 存在重复元素II
解题思路
题目目标:需要两个索引对应的值相等,并且索引差不超过k。
也就是需要维护一个索引距离不超过 k 的滑动窗口,窗口内最多保存 k 个历史元素,进行搜索相同的值,有就返回true。
但是直接搜索,时间复杂度会提升到 \(O(N^2)\)。
这时候只用使用哈希集合辅助,把维护两个指针变成维护一个大小最大为 \(k\) 的滑动窗口(用 Set 储存),遍历数组,每次先检查 nums[i] 是否已经在 Set 中:
- 如果在,说明最近 \(k\) 个元素内有重复,直接返回 true。
- 将当前元素 nums[i] 加入 Set。
- 如果 Set 的元素数量超过了 \(k\),说明最左边的元素已经滑出了大小为 \(k\) 的范围,将其从 Set 中移除(移除 nums[i - k])。
即
窗口维护:
- 左边界自动移动(删除过期元素)
- 右边界遍历数组(加入新元素)
窗口状态:
- Set 保存窗口内出现过的数字。
当加入元素违反条件时:
- 说明满足答案。
实现
function containsNearbyDuplicate(nums: number[], k: number): boolean {
if(k === 0) return false;
const n = nums.length;
const s = new Set<number>();
for(let i = 0; i < n; i++){
if(i > k){
s.delete(nums[i-k-1]);
}
if(s.has(nums[i])){
return true;
}
s.add(nums[i]);
}
return false;
};
public class Solution {
public bool ContainsNearbyDuplicate(int[] nums, int k) {
int n = nums.Length;
var set = new HashSet<int>();
for(int i = 0;i<n ;i++)
{
if(i>k) set.Remove(nums[i-k-1]);
if(!set.Add(nums[i])) return true;
}
return false;
}
}
class Solution {
public:
bool containsNearbyDuplicate(vector<int>& nums, int k) {
std::unordered_set<int> set;
for(int i = 0; i < nums.size(); i++){
if(i > k) set.erase(nums[i-k-1]);
if(!set.insert(nums[i]).second) return true;
}
return false;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
只用一次循环。 - 空间复杂度: \(O(min(N,k))\)
集合中最多保留 \(k\) 个元素。
2. 包含所有三种字符的子字符串数目
解题思路
一开始想的是前缀和,但好像无法很好判断非0起点的子字符串。
然后用滑动窗口,想着前面的子字符串假如满足,后面的全满足,但是却不能很好的做出结果。
所以只能反过来,只查看右边界之前,有多少个满足条件的子字符串。
可以用一个滑动窗口 [left, right] 维护当前区间内的字符数量:
- 移动右指针 right,增加对应字符的计数。
- 当窗口内满足 a, b, c 数量均 \(> 0\) 时:
- 说明当前 [left, right] 是一个合法窗口。
- 因为 [left, right] 已经包含了所有字符,那么右移 left 之前,以 0, 1, ..., left 为起点、right 为终点的子串全都合法。
- 不断右移 left 并扣减计数,直到窗口不再满足条件。
- 此时这轮贡献的合法子串数量刚好为 left。
可不能又计算左边界为起点的,又计算右边界为起点的,会乱套的,要控制变量法!
实现
function numberOfSubstrings(s: string): number {
// 一定要abc都出现的,那么abc出现的串,后面的全都算
const n = s.length;
if(n < 3) return 0;
let res = 0;
const cnt = [0, 0, 0]; // 计数
let l = 0;
for(let r = 0; r < n; r++){
cnt[s.charCodeAt(r)-97]++;
// 当窗口内abc的计数都大于0,说明子字符串满足条件
while(cnt[0] > 0 && cnt[1] > 0 && cnt[2] > 0){
cnt[s.charCodeAt(l++)-97]--;
}
// 此时,left的数值正好等于以right为结尾时合法的起点个数
res += l;
}
return res;
};
public class Solution {
public int NumberOfSubstrings(string s) {
var cnt = new int[3] {0, 0, 0};
int n = s.Length;
int l = 0;
int res = 0;
for(int r = 0; r < n; r++){
cnt[s[r]-'a']++;
while(cnt[0] > 0 && cnt[1] > 0 && cnt[2] > 0){
cnt[s[l++]-'a']--;
}
res += l;
}
return res;
}
}
class Solution {
public:
int numberOfSubstrings(string s) {
// 需要a、b、c至少各一个
size_t n = s.size();
if(n < 3){
return 0;
}
int res = 0;
vector<int> cnt(3, 0);
// 双指针
int l = 0;
for(int r = 0; r < n; r++){
cnt[s[r] - 'a']++; // 新字符进窗口
// 满足abc窗口时,说明l~r这里都是合法的
while(cnt[0] > 0 && cnt[1] > 0 && cnt[2] > 0){
// 缩小窗口,将左边缩小
cnt[s[l++] - 'a']--;
}
res += l;
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
左边界和右边界都仅每次执行单次移动,在右边界移动时,左边界没有被重置。 - 空间复杂度: \(O(1)\)
只需要一个常数级的数组存储abc的计数。
3. 替换后的最长重复字符
解题思路
一开始,我想着问题应该可以转变为窗口里可以最多有k个不同的字母来进行滑动窗口实现。
但是答案不对,才注意到这个转变是错误的,因为这样子没办法确认最长的位置,而且比如,AAABBAAB的时候,假如k=2,按我的想法,那就会变成整个字符串长度了。
正确应该是
对于任意一个窗口 [l, r]:
- 窗口总长度为:r - l + 1;
- 假设窗口内出现次数最多的那个字符的数量是 maxCnt,将该字符作为最终保留字符;
- 那么把剩下所有非保留字符全都替换掉所需的最少替换次数就是:\[\text{需要替换的字符数} = (r - l + 1) - \text{maxCnt} \]
因此,窗口合法的充要条件是:
$$(r - l + 1) - \text{maxCnt} \le k$$
只要超出 \(k\),就需要收缩左边界 l。
实现
function characterReplacement(s: string, k: number): number {
// 就是窗口里可以最多有k个不同的字母?!
const n = s.length;
let l = 0;
let cnt = Array(26).fill(0);
const an = 'A'.charCodeAt(0);
let maxCnt = 0;
let res = 0;
for(let r = 0; r < n; r++){
const cur = s.charCodeAt(r)-an
cnt[cur]++;
// 更新当前窗口最多的字母数
maxCnt = Math.max(maxCnt, cnt[cur]);
// 当(窗口长度-主要字母频次)> k ,说明更换次数不够,收缩左边界
while((r - l + 1) - maxCnt > k){
const temp = s.charCodeAt(l++)-an;
cnt[temp]--;
// 此时不用更新最大值,因为在收缩,不会有更大频次的字母的
}
res = Math.max(res, r-l+1);
}
return res;
};
public class Solution {
public int CharacterReplacement(string s, int k) {
int n = s.Length;
int[] cnt = new int[26];
int l = 0, res = 0, maxCnt = 0;
for(int r = 0; r < n; r++){
int cur = s[r] - 'A';
cnt[cur]++;
// 更新最大频次字母数
maxCnt = Math.Max(maxCnt, cnt[cur]);
// 当需要替换的字母数超过k,收缩左边界
while((r - l + 1) - maxCnt > k){
cnt[s[l++] - 'A']--;
}
res = Math.Max(res, r - l + 1);
}
return res;
}
}
class Solution {
public:
int characterReplacement(string s, int k) {
int n = s.size();
int l = 0, maxCnt = 0, res = 0;
vector<int> cnt(26, 0);
for(int r = 0; r < n; r++){
int cur = s[r] - 'A';
cnt[cur]++;
maxCnt = max(maxCnt, cnt[cur]);
while((r - l + 1) - maxCnt > k){
cnt[s[l++] - 'A']--;
}
res = max(res, r - l + 1);
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
r 从0到N-1移动一次,l 虽然在while中移动,但最多也移动N次,因此总移动次数为2N,时间复杂度O(N)。 - 空间复杂度: \(O(1)\)
需要一个长度为26的数组存储字母的频次。
滑动哈希
❔题目
1. 最短回文串
解题思路
题目要求只能在字符串前面添加字符。
因此,要使添加的字符最少,就需要找到 s 的最长回文前缀:
此时只需要将剩余后缀反转,并拼接到原字符串前面即可。
因此问题转化为:
如何快速判断每个前缀是否为回文?
对于前缀 s[0..i],如果它是回文,那么它正着和反着应该完全相同。
因此维护两个滚动哈希:
和
base 通常选择一个大于字符集大小的质数,例如 31、131。
实际使用时还需要配合取模,以控制数值范围。
当正向哈希和反向哈希值一致时,可认为当前前缀是回文。
则获得最长回文,并把剩下的反向拼到前面,即可获得最短回文串。
实现
function shortestPalindrome(s: string): string {
// 即找最长回文前缀
const n = s.length;
if(n <= 1) return s;
// 在数字后面加n,代表这是BigInt
const B = 131n;
const M = 1000000007n;
let forward = 0n;
let reverse = 0n;
let power = 1n; // 保存 B^i % M
let bestLen = 0; // 最长回文前缀的长度
for (let i = 0; i < n; i++) {
const charCode = BigInt(s.charCodeAt(i));
// 1. 更新正向哈希:之前的值整体左移一位(乘 B),加上新字符
forward = (forward * B + charCode) % M;
// 2. 更新反向哈希:新字符赋予更高的权重 B^i,加到原哈希上
reverse = (reverse + charCode * power) % M;
// 3. 权重迭代:power = B^(i+1)
power = (power * B) % M;
// 4. 正反哈希相等,说明 s[0..i] 是回文串
if (forward === reverse) {
bestLen = i + 1;
}
}
// 剩下不是回文前缀的部分 s[bestLen..n-1],翻转后拼到最前面
const nonPalindromeSuffix = s.slice(bestLen);
const prefixToAdd = nonPalindromeSuffix.split('').reverse().join('');
return prefixToAdd + s;
};
public class Solution {
public string ShortestPalindrome(string s) {
int n = s.Length;
if(n <= 1) return s;
const long BASE = 131;
const long MOD = 1_000_000_007;
long forward = 0;
long reverse = 0;
long power = 1;
long bestEnd = 0;
for(int i = 0; i < n; i++){
int cur = s[i]-'a';
forward = (forward * BASE + cur) % MOD;
reverse = (reverse + cur * power) % MOD;
power = power * BASE % MOD;
if(forward == reverse){
bestEnd = i;
}
}
// 1. 预分配容量(非回文后缀长度 + 原串长度),防止内部动态扩容
int remLen = (int)(n - (bestEnd + 1));
StringBuilder sb = new StringBuilder(n + remLen);
// 2. 倒序把非回文后缀 append 进来
for (int i = n - 1; i > bestEnd; i--) {
sb.Append(s[i]);
}
// 3. 把原字符串 s append 进来
sb.Append(s);
return sb.ToString();
}
}
class Solution {
public:
string shortestPalindrome(string s) {
int n = s.size();
if(n <= 1) return s;
long long BASE = 131;
long long MOD = 1000000007LL;
long long leftHash = 0, rightHash = 0, power = 1, bestLen = 0;
for(int i = 0; i < n; i++){
long long cur = s[i] - 'a';
leftHash = (leftHash * BASE + cur) % MOD;
rightHash = (cur * power + rightHash) % MOD ;
power = (BASE * power) % MOD;
if(leftHash == rightHash){
bestLen = i;
}
}
string_view sv = s;
string_view plus = sv.substr(bestLen+1);
return string(plus.rbegin(), plus.rend()) + s;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
循环一次计算哈希值即可。 - 空间复杂度: \(O(1)\)
- 滚动哈希本身只维护常数个变量。
- 如果计算最终返回字符串所占空间,则为 \(O(N)\)。
2. 最长快乐前缀
解题思路
在上一题,为了判断回文,我们对同一个前缀构造正向和反向 Hash。
而这一题,我们只需要分别取字符串的前缀和后缀,进行判断是否相等。
所以,可以将上题的公式,改为hash1 是从左到右构造字符串前缀的 Hash,hash2 是从右到左构造字符串后缀的 Hash。
由于两者采用相同的幂次规则,因此可以直接比较。
和
其中,hash1为顺序构造,hash2为倒序构造,此时则代表着前缀哈希和后缀哈希,
寻找最长前缀缀哈希和后缀哈希值一致的时刻即可。
实现
function longestPrefix(s: string): string {
// 嘛,简单,就是找前面和后面一样的
const n = s.length;
if(n <= 1) return '';
const BASE = 131;
const MOD = 1000000007;
let hl = 0, hr = 0, p = 1, bestLen = 0;
// 因为不可以取自身,所以边界为[0,n-2]
for(let i = 0; i < n-1; i++){
const cur = s.charCodeAt(i);
const back = s.charCodeAt(n-1-i);
hl = (hl * BASE + cur) % MOD;
hr = (hr + back * p) % MOD;
p = (p * BASE) % MOD;
if(hl === hr){
bestLen = i+1;
}
}
return s.slice(0, bestLen);
};
public class Solution {
public string LongestPrefix(string s) {
int n = s.Length;
if(n <= 1) return "";
long BASE = 131;
long MOD = 1000000007;
long hl = 0, hr = 0, p = 1;
int bestLen = 0;
for(int i = 0; i < n-1; i++){
int cur = s[i];
int back = s[n-i-1];
hl = (hl * BASE + cur) % MOD;
hr = (hr + back * p) % MOD;
p = (BASE * p) % MOD;
if(hl == hr){
bestLen = i+1;
}
}
return s[..bestLen];
}
}
class Solution {
public:
string longestPrefix(string s) {
int n = s.size();
if(n <= 1) return "";
const long long BASE = 131;
const long long MOD = 1000000007LL;
long long hl = 0, hr = 0, p = 1;
int bestLen = 0;
for(int i = 0; i < n-1; i++){
int cur = s[i];
int back = s[n-i-1];
hl = (hl * BASE + cur) % MOD;
hr = (hr + back * p) % MOD;
p = (p * BASE) % MOD;
if(hl == hr){
bestLen = i+1;
}
}
return s.substr(0, bestLen);
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
循环一次计算哈希值即可。 - 空间复杂度: \(O(1)\)
- 滚动哈希本身只维护常数个变量。
- 如果计算最终返回字符串所占空间,则为 \(O(N)\)。
3. 构造字符串的总得分和
解题思路
这题需要计算每个后缀与整个字符串的最长公共前缀(LCP)。
虽然可以使用滚动哈希进行二分判断,但存在哈希碰撞问题,解决起来太麻烦了,不如用 Z 算法,更简单便捷,可以在 \(O(N)\) 时间内确定性地求出所有后缀与前缀的最长匹配长度,因此更适合这道题。
Z 算法维护一个当前匹配到最右侧的区间 [l,r]:
- 如果 i > r,当前位置不在已知匹配区间内,只能从 i 开始逐个比较;
- 如果 i <= r,可以利用对称位置 i-l 已经计算出的 z[i-l] 复用结果:
z[i] = min(z[i-l], r-i+1)为什么使用min:z[i-l] 可能超出当前 [l,r] 已知范围。
- 如果复用的结果已经到达 r,再继续向后比较并扩展;
- 如果新的匹配区间右端点超过 r,更新 [l,r]。
\(z[i]\) 的含义:字符串 \(s\) 从 \(i\) 开始的后缀 \(s[i..n-1]\) 与 \(s\) 本身的最长公共前缀(LCP)长度。
虽然代码中存在 while 循环,但每次成功扩展都会使 r 向右移动,而 r 最多移动到 n-1,因此所有扩展操作总计为 \(O(N)\)。
Z 算法维护一个当前已经匹配到最右侧的区间 [l,r]。当 i 落在这个区间内时,可以利用之前计算好的 z[i-l] 直接复用部分结果,而不需要从头比较;如果复用结果已经到达区间右边界,再继续向后扩展。
实现
function sumScores(s: string): number {
const n = s.length;
const z = new Array(n).fill(0);
// z[0] 代表全串与自身匹配,最长公共前缀长度就是 n
z[0] = n;
// l 和 r 维护当前匹配到最右边的匹配窗口 [l, r]
let l = 0, r = 0;
let totalScore = n; // 先加上 z[0]
for (let i = 1; i < n; i++) {
// 1. 如果 i 在当前匹配窗口 [l, r] 内,利用对称性复用之前计算好的 z 值
if (i <= r) {
z[i] = Math.min(r - i + 1, z[i - l]);
}
// 2. 没有时,暴力尝试扩展匹配
while (i + z[i] < n && s[z[i]] === s[i + z[i]]) {
// 没有超界并且字符相等时
z[i]++;
}
// 3. 如果扩展后的右边界超过了 r,更新窗口 [l, r]
// i + z[i] - 1:这次匹配能触及到的最右侧字符的下标(终点)
if (i + z[i] - 1 > r) {
l = i;
r = i + z[i] - 1;
}
totalScore += z[i];
}
return totalScore;
}
public class Solution {
public long SumScores(string s) {
int n = s.Length;
int[] z = new int[n];
z[0] = n;
long res = n;
int l = 0, r = 0;
for(int i = 1; i < n; i++){
if(i <= r){
z[i] = Math.Min(z[i-l], r - i + 1);
}
while(i + z[i] < n && s[z[i]] == s[i + z[i]]){
z[i]++;
}
if(i + z[i] - 1 > r){
l = i;
r = i + z[i] - 1;
}
res += z[i];
}
return res;
}
}
class Solution {
public:
long long sumScores(string s) {
int n = s.size();
vector<int> z = vector<int>(n, 0);
z[0] = n;
long long res = n;
int l = 0, r = 0;
for(int i = 1; i < n; i++){
if(i <= r){
z[i] = min(z[i-l], r - i + 1);
}
while(i + z[i] < n && s[z[i]] == s[i+z[i]]){
z[i]++;
}
if(i + z[i] - 1 > r){
l = i;
r = i + z[i] - 1;
}
res += z[i];
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(N)\)
while 的扩展不会反复做大量无效比较,因为每次成功扩展都会让当前 Z-box 的右边界 r 向右移动;而 r 最多从 0 移动到 n-1。 - 空间复杂度: \(O(N)\)
需要一个长度为n的z数组。
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号