【算法】【学习笔记】双指针、滑动窗口、滑动哈希

双指针

双指针是一种十分常用的算法方法,一般有滑动窗口、快慢指针等使用方式。

在下文就根据题目详细解释下。

❔题目

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. 右指针扩张窗口
  2. 更新窗口状态
  3. 判断窗口是否合法
  4. 不合法时移动左指针
  5. 记录最大答案

❔题目

1. 存在重复元素II

【点击跳转】存在重复元素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 = [最长回文前缀][剩余后缀] \]

此时只需要将剩余后缀反转,并拼接到原字符串前面即可。

因此问题转化为:

如何快速判断每个前缀是否为回文?

对于前缀 s[0..i],如果它是回文,那么它正着和反着应该完全相同。

因此维护两个滚动哈希:

\[\begin{align*} 正向hash1 &= s[0] \times base^i + s[1] \times base^{i-1}... \\ &= oldHash1 \times base+cur \end{align*} \]

\[ \begin{cases} power = base^{当前长度} \\ \\ \begin{align*} 反向hash2 &= s[i] \times base^{i} + s[i-1] \times base^{i-1}... \\ &= oldHash2 + cur \times power \end{align*} \end{cases} \]

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。

由于两者采用相同的幂次规则,因此可以直接比较。

\[\begin{align*} hash1 &= s[0] \times base^i + s[1] \times base^{i-1}... \\ &= oldHash1 \times base+cur \end{align*} \]

\[ \begin{cases} power = base^{当前长度} \\ \\ \begin{align*} hash2 &= s[n-1] \times base^0 + s[n-2] \times base^1... \\ &= oldHash2 + cur \times power \end{align*} \end{cases} \]

其中,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 辅助整理。

posted @ 2026-08-07 22:57  SEHOD  阅读(10)  评论(0)    收藏  举报