目录

题目描述

1156. 单字符重复子串的最大长度

题意分析

给一个只含小写字母的字符串 text最多做一次交换(把两个位置上的字符互换,也可以不换),求交换后能得到的最长的「所有字符都相同」的子串长度。

先把「一次交换」这个约束翻译成对答案区间的限制。假设最终那段全同字符的子串由字符 ch 组成,占据某个区间。交换只能改动两个位置,其中至多一个落在这个区间内,所以交换前这个区间里最多只能有一个位置不是 ch。反过来说,凡是区间内非 ch 的位置超过一个,一次交换救不回来。这是第一条硬约束。

第二条硬约束更隐蔽,也是本题的真正考点:交换只是把两个已有的字符换位置,不会凭空造出新的字符。所以答案长度绝不可能超过 ch 在整个字符串中出现的总次数。像 aaabaaa 这种串,中间的 b 虽然只有一个、看似换掉就能得到长度 7 的全 a 串,但全串一共只有 6 个 a,那个用来替换 ba 必须从区间内部抽走,最终最长只能是 6。

这两条约束还需要一次可行性确认:当区间内恰有一个非 ch 位置、且区间长度不超过 ch 的总数时,区间外一定还剩至少一个 ch(总数减去区间内的 ch 个数大于等于 1),拿它和区间内那个异类对调即可,所以两条约束一起就是充要的。

字符集只有 26 个小写字母,且字符串长度上限在两万量级,这组数字在提示:可以对每个字母单独跑一遍线性扫描,$26n$ 的代价完全可以接受,不必设计一次扫描同时处理所有字母的复杂结构。

边界:全串同一个字符时答案就是串长;长度为 1 的串答案是 1;某个字母压根没出现时不必为它做任何计算。

解法:枚举目标字符的滑动窗口

核心思路

直接对着原串思考「换哪两个位置」会陷入 $O(n^2)$ 的配对枚举。破局点在于先把答案的目标字符固定下来:如果提前宣布「我要的是一段全 a」,那么「区间内至多一个非 a」就变成了一个标准的、单调的窗口约束——窗口越长,里面的非 a 只会越多,不会越少。有了单调性,就能用左右两个指针一次线性扫描扫出所有极大合法窗口。

目标字符只有 26 种,全部枚举一遍即可,于是整体是 26 次独立的线性扫描。

对固定的目标字符 target,维护窗口 [left, right] 和计数 diff 表示窗口内不等于 target 的字符个数。循环不变量是:每次处理完 right 之后,diff 恒等于区间 [left, right] 内非 target 字符的个数,且 diff <= 1。右指针每前进一格就把新字符计入 diff,一旦 diff 超过 1,就右移 left 并把移出的字符从 diff 里扣掉,直到不变量重新成立。

因为约束只在窗口变长时可能被破坏、在窗口变短时只会更容易满足,左指针永远不需要回退,所以两个指针都单调右移,每个字符至多进出窗口各一次。

最后把第二条硬约束叠上去:合法窗口的长度 right - left + 1 只是「区间形状」允许的上限,还要和 freq[target](该字符全局出现次数)取较小值才是真正可达的长度。答案取所有目标字符、所有合法窗口下这个较小值的最大值。

这里有个容易被忽略的细节:不需要专门去找「窗口外是否还有多余的 target」,因为一旦 len <= freq[target],窗口外剩余的 target 个数至少是 freq[target] - (len - 1) >= 1,可换的字符必然存在;取 min 这一步已经把可行性判断包含进去了。

解题步骤

  • 先统计 26 个字母各自的出现次数存进 freq。为什么必须先统计:freq[target] 是答案的天花板,而且它是全局信息,边扫边算拿不到,只能预处理。
  • 外层枚举目标字符 target025。为什么要枚举:只有先把目标字符钉死,「窗口内最多一个异类」才是一个含义明确、可单调判定的条件;不固定目标字符就无法定义什么叫「异类」。
  • 跳过 freq[target] == 0 的字母。为什么:这个字母根本不在串里,任何窗口对它的贡献都是 min(len, 0) = 0,扫一遍纯属浪费。
  • 每个 target 开始前重置 left = 0diff = 0。为什么:两个变量都描述当前这一轮扫描的窗口状态,跨轮复用会让新一轮从上一轮遗留的位置起步,窗口彻底错位。
  • 右指针推进:text[right] != targetdiff++。为什么只统计异类而不统计同类:约束只压在异类数量上,同类字符个数可以由窗口长度减去 diff 反推,没必要额外维护。
  • 收缩:while (diff > 1),若 text[left] 是异类则 diff--,然后无条件 left++。为什么 left++ 必须写在 if 外面:窗口最左端也可能正好是目标字符,此时它不影响 diff,如果不移动左指针,循环条件永远为真,直接死循环。
  • 统计答案:ans = max(ans, min(right - left + 1, freq[target]))。为什么要取 min:窗口形状允许的长度未必能被实际拥有的字符数量兑现,这一步是题目「交换不创造字符」这条约束的落地。
  • 返回 ans

text = "ababa" 走一遍 target = 'a' 的扫描。先统计得 freq['a'] = 3freq['b'] = 2。初始 left = 0diff = 0ans = 0

right = 0,字符 a:是目标字符,diff 保持 0。窗口 [0, 0] = "a",长度 1,min(1, 3) = 1ans = 1

right = 1,字符 b:异类,diff = 1,未超限不收缩。窗口 [0, 1] = "ab",长度 2,min(2, 3) = 2ans = 2

right = 2,字符 adiff 仍是 1。窗口 [0, 2] = "aba",长度 3,min(3, 3) = 3ans = 3。这一步的含义是:把中间的 b 和串外某个 a 对调就能得到 aaa

right = 3,字符 bdiff = 2,超限,进入收缩。text[0] = 'a' 不是异类,diff 不变,left 变成 1;仍然 diff = 2text[1] = 'b' 是异类,diff 减到 1left 变成 2。窗口 [2, 3] = "ab",长度 2,min(2, 3) = 2ans 保持 3

right = 4,字符 adiff 仍是 1。窗口 [2, 4] = "aba",长度 3,min(3, 3) = 3ans 保持 3

再跑 target = 'b'freq['b'] = 2,任何合法窗口的贡献都被 min 压到最多 2,无法刷新 ans。最终返回 3,即把某个 b 与某个 a 对调后得到的 aaa

再用 text = "aaabaaa" 检验 min 的作用:target = 'a'freq['a'] = 6,整串只有一个 bdiff 始终不超过 1,窗口能一路撑到 [0, 6],长度 7。若不取 min 会输出 7,但全串统共只有 6 个 a,用来顶替 b 的那个 a 只能从窗口内部抽调,抽走之后又空出一个洞。取 min(7, 6) = 6 才是正确答案。

代码实现

class Solution {
    public int maxRepOpt1(String text) {
        int[] freq = new int[26];
        for (int i = 0; i < text.length(); i++) {
            freq[text.charAt(i) - 'a']++;
        }

        int ans = 0;
        for (int target = 0; target < 26; target++) {
            if (freq[target] == 0) {
                continue;
            }

            int left = 0;
            int diff = 0;
            for (int right = 0; right < text.length(); right++) {
                if (text.charAt(right) - 'a' != target) {
                    diff++;
                }

                while (diff > 1) {
                    if (text.charAt(left) - 'a' != target) {
                        diff--;
                    }
                    left++;
                }

                // 一次交换最多填掉一个非目标字符,长度还不能超过目标字符总数。
                int len = right - left + 1;
                ans = Math.max(ans, Math.min(len, freq[target]));
            }
        }

        return ans;
    }
}
func maxRepOpt1(text string) int {
    freq := make([]int, 26)
    for i := 0; i < len(text); i++ {
        freq[text[i]-'a']++
    }

    ans := 0
    for target := 0; target < 26; target++ {
        if freq[target] == 0 {
            continue
        }

        left := 0
        diff := 0
        for right := 0; right < len(text); right++ {
            if int(text[right]-'a') != target {
                diff++
            }

            for diff > 1 {
                if int(text[left]-'a') != target {
                    diff--
                }
                left++
            }

            // 一次交换最多填掉一个非目标字符,长度还不能超过目标字符总数。
            length := right - left + 1
            if length > freq[target] {
                length = freq[target]
            }
            if length > ans {
                ans = length
            }
        }
    }

    return ans
}

复杂度分析

  • 时间复杂度:$O(26n)$,即 $O(n)$。预处理频次是一遍 $O(n)$;外层枚举 26 个目标字符,每轮内层扫描中左右指针都只单调右移、各自至多走过 n 个位置,while 收缩的总步数被 left 的总位移量摊还,所以每轮是 $O(n)$ 而非 $O(n^2)$。
  • 空间复杂度:$O(1)$,只有一个长度固定为 26 的计数数组和几个标量,与输入长度无关;没有用哈希表存下标列表,也没有构造任何新字符串。

关键点总结

  • 遇到「最多改动 k 处后求最长同质区间」,先把目标值固定下来,约束才会变成单调条件,滑动窗口才用得上;这是本题从 $O(n^2)$ 配对枚举降到线性的关键转折。
  • 「交换不创造新字符」这条全局守恒约束必须单独用 freq 兜住,它不是窗口内的局部性质,窗口逻辑本身永远发现不了它——面试中能主动说出 aaabaaa 这个反例,基本就说明想透了。
  • min(len, freq[target]) 同时完成了「封顶」和「可行性验证」两件事,因为 len <= freq[target] 已经蕴含了窗口外还剩至少一个目标字符可供交换,无需再写额外判断。
  • 滑动窗口的收缩里,指针推进必须无条件执行,只有计数的增减才受字符判定控制,二者混在同一个 if 里是死循环的常见来源。
  • 字符集大小是常数时,「对每个字符各跑一遍线性扫描」是完全合法的设计,$O(26n)$ 在面试里应当明确说成 $O(n)$ 并解释常数来源,而不是含糊带过。

易错点总结

  • 忘记和 freq[target]min"aaabaaa" 会输出 7,但全串只有 6 个 a,正确答案是 6;这是本题第一大坑。
  • 误用 max(len, freq[target])"ab" 时目标 a 的窗口长度是 2、freq['a'] 是 1,会输出 2,而只有一个 a 时答案只能是 1
  • 只统计现成的最长连续同字符段"ababa" 里最长同字符段长度是 1,会输出 1,正确答案是 3
  • 收缩条件写成 while (diff > 2):等于允许一次交换修好两个位置。"aabbaa" 会得到 min(6, 4) = 4,而实际最优只能是 3
  • left++ 写在 if 内部,只在扣减 diff 时才推进"baa" 中目标为 b 时,right = 2 触发收缩而 text[0] = 'b' 不是异类,diff 不减、left 不动,while 条件恒真,程序死循环。
  • 比较时忘记 - 'a',用字符直接和下标比'a' 的码值是 97,永远不等于下标 0"aaa" 里每个字符都被判成异类,窗口被压到长度 1,输出 1
  • leftdiff 定义在枚举 target 的循环外面:换目标字符时 left 仍停在上一轮结束的位置,新一轮开头会算出 right - left + 1 为负数的窗口,统计彻底失真。
  • 收缩时扣减的是 text[right] 而不是 text[left]diff 与窗口真实内容脱节,窗口只会一路变长。"aaabbaaa" 目标为 a 时窗口能撑到整串长度 8,输出 min(8, 6) = 6,而正确答案是 4
  • 改用「按字符记录下标、合并相邻两段」的写法却漏掉总数封顶"aabaa" 的两段 aa 隔着一个 b,合并后算出 5,但只有 4 个 a,正确答案是 4
  • 认为不交换就不算答案,强制必须换一次"aaaa" 会被误判成需要打散再拼,输出小于 4;题目允许「最多一次」,包括一次都不换。

相似题目

题目 难度 考察点
424. 替换后的最长重复字符 中等 可替换 k 个字符且字符凭空产生,没有本题的总数封顶
1004. 最大连续1的个数 III 中等 二值版本,翻转 k0 而不是交换,同样无需考虑资源守恒
487. 最大连续1的个数 II 中等 k = 1 的二值特例,窗口约束与本题的 diff <= 1 完全同构
1493. 删掉一个元素以后全为 1 的最长子数组 中等 操作是删除而非交换,最终长度要在窗口长度上再减 1
485. 最大连续 1 的个数 简单 不允许任何修改,退化成一次遍历数段落,是本题的基线
3. 无重复字符的最长子串 中等 窗口约束是「无重复」,需要哈希记录字符最近位置而非计数
340. 至多包含 K 个不同字符的最长子串 中等 约束落在「不同字符种类数」上,收缩时要维护每种字符的剩余个数
159. 至多包含两个不同字符的最长子串 中等 340 的 k = 2 特例,可用两个变量替代哈希表
1208. 尽可能使字符串相等 中等 窗口约束是代价总和不超预算,收缩条件从计数变成求和
1151. 最少交换次数来组合所有的 1 中等 同样是交换聚合,但交换次数不限、窗口长度固定,求的是最少操作数