目录
- [LeetCode 424. 替换后的最长重复字符|不定长滑动窗口:如何判断窗口是否合法?](#LeetCode 424. 替换后的最长重复字符|不定长滑动窗口:如何判断窗口是否合法?)
-
- 一、先确定思路:什么样的窗口才算合法?
- 二、怎么计算最少需要替换的字符数量?
- 三、怎么知道哪个字符出现得最多?
- 四、什么时候需要缩小窗口?
- 五、什么时候更新最大长度?
- 六、完整代码
- 七、代码中的几个细节
-
- [1. 为什么用 freq.get()?](#1. 为什么用 freq.get()?)
- [2. 为什么这里不需要删除次数为 0 的字符?](#2. 为什么这里不需要删除次数为 0 的字符?)
- [3. 为什么 max_freq 不能只在收缩前计算?](#3. 为什么 max_freq 不能只在收缩前计算?)
- 八、复杂度分析
- 九、最后复盘
LeetCode 424. 替换后的最长重复字符|不定长滑动窗口:如何判断窗口是否合法?
题目链接:424. 替换后的最长重复字符
PS:自己弄了个算法可视化网站,可以看看代码是怎么一步步运行的:算法过程可视化
上一篇做了 3. 无重复字符的最长子串,我们通过滑动窗口,找到了不包含重复字符的最长子串。
这次的 424 看起来有点相反:我们希望窗口里的字符尽可能相同。
不过题目允许我们最多替换 k 个字符。
这就多了一个问题:窗口里即使有不同字符,也不代表它不合法。
那么,我们该怎么判断当前窗口能不能通过最多 k 次替换,变成所有字符都相同的字符串?
一、先确定思路:什么样的窗口才算合法?
题目给定一个字符串 s 和整数 k,我们最多可以替换 k 个字符,要求找到替换后包含相同字符的最长子串长度。
例如:
python
s = "AABABBA"
k = 1
其中一个满足条件的子串是:
text
A A B A
↓
A A A A
只需要把 B 替换成 A,就能得到长度为 4 的重复字符子串。
所以答案是 4。
因为题目要求的是连续子串,而且长度不固定,我们可以继续使用不定长滑动窗口。
设置两个指针:
left:窗口左边界。right:窗口右边界。
但和上一道题不同,这次不能简单地通过「有没有重复字符」来判断窗口是否合法。
因为窗口中允许存在不同字符,只要能够在 k 次以内把它们替换掉就行。
所以我们首先需要解决:当前窗口最少需要替换多少个字符?
二、怎么计算最少需要替换的字符数量?
假设当前窗口是:
text
[A, A, B, A, C]
其中:
text
A:3 次
B:1 次
C:1 次
如果想让窗口中的字符全部相同,我们应该保留哪一种字符?
显然是出现次数最多的 A。
因为保留的字符越多,需要替换的字符就越少。
对于这个窗口:
text
窗口长度 = 5
最多的字符 A = 3
需要替换 = 5 - 3 = 2
所以我们可以得到这个公式:
最少替换次数 = 当前窗口长度 - 窗口内字符的最高出现次数。
用变量表示:
python
window_len = right - left + 1
max_freq = max(freq.values())
那么:
python
window_len - max_freq
就是让当前窗口中的字符全部相同,至少需要替换的次数。
接下来,只需要和 k 比较即可。
什么时候合法?
如果:
python
window_len - max_freq <= k
说明需要替换的字符数量没有超过 k,窗口合法。
如果:
python
window_len - max_freq > k
说明需要替换的字符太多,当前窗口不合法。
这里要注意:题目要求的是最多替换 k 次,而不是必须替换 k 次。
例如:
text
窗口:[A, A, A, B]
窗口长度 = 4
max_freq = 3
k = 2
只需要替换一个 B,就能得到 "AAAA"。
虽然没有用完两次替换机会,但窗口仍然合法。
所以判断条件是 <= k,而不是 == k。
三、怎么知道哪个字符出现得最多?
现在我们已经知道,需要通过最高字符频率来判断窗口是否合法。
但问题来了:窗口一直在变化,我们怎么知道当前哪个字符出现得最多?
这里可以使用字典 freq,记录窗口中每种字符出现的次数。
例如:
python
freq = {
'A': 3,
'B': 1,
'C': 1
}
每次右指针加入一个字符,就把它的出现次数加 1:
python
freq[s[right]] = freq.get(s[right], 0) + 1
🚩 那么,怎么从字典中找出最大的次数?
Python 提供了 max():
python
max_freq = max(freq.values())
其中:
freq.values():获取字典中所有字符的出现次数。max():找出这些次数中的最大值。
例如:
python
freq = {'A': 3, 'B': 1, 'C': 1}
max(freq.values()) # 3
这样我们就知道了,当前窗口中最多的字符出现了 3 次。
不过,窗口不只是会扩大,还会缩小。
当左边的字符被移出去时,字典中的频率也会变化。
因此,如果要准确判断当前窗口是否合法,就需要使用当前窗口的真实最高频率。
这一点我们在后面的代码注意事项中再具体说明。
四、什么时候需要缩小窗口?
前面已经确定了窗口的合法条件:
python
(right - left + 1) - max(freq.values()) <= k
既然我们要找最长子串,就应该尽可能扩大窗口。
每次让 right 向右移动,加入一个新字符。
如果加入之后,窗口仍然合法,就可以保留当前窗口,继续寻找更长的答案。
但如果需要替换的字符数量超过了 k 呢?
例如:
python
s = "AABABBA"
k = 1
当前窗口:
text
[A, A, B, A, B]
L R
其中:
text
A:3 次
B:2 次
窗口长度 = 5
需要替换 = 5 - 3 = 2
因为 2 > k,所以窗口不合法。
既然右指针继续扩大窗口已经让替换次数超出了限制,我们就尝试移动左指针,把左边的字符移出去。
移出第一个 A:
text
[A, A, B, A, B]
L R
当前窗口:[A, B, A, B]
现在:
text
A:2 次
B:2 次
窗口长度 = 4
需要替换 = 4 - 2 = 2
还是超过了 k = 1。
所以还需要继续收缩。
再移出一个 A:
text
[A, A, B, A, B]
L R
当前窗口:[B, A, B]
此时:
text
A:1 次
B:2 次
窗口长度 = 3
需要替换 = 3 - 2 = 1
现在只需要替换一个字符,窗口终于恢复合法。
所以这里不能只收缩一次,而是需要:
只要当前窗口需要替换的字符数量超过 k,就不断移动 left,直到窗口重新合法。
对应的判断就是:
python
while (right - left + 1) - max(freq.values()) > k:
这里使用 while,而不是 if,因为收缩一次不一定能够恢复合法。
五、什么时候更新最大长度?
现在,我们已经知道了整个窗口的变化过程:
- 右指针负责加入新字符,尝试扩大窗口。
- 如果需要替换的字符超过
k,就移动左指针缩小窗口。 - 直到窗口重新满足最多替换
k次的要求。
那么,什么时候更新最大长度?
和上一道 LeetCode 3 类似,我们应该在当前窗口合法时更新答案。
因为我们要找的是最长合法窗口,而不是最长的任意窗口。
可以分成两种情况:
- 加入新字符后,窗口仍然合法,不需要收缩,直接更新最大长度。
- 加入新字符后,窗口不合法,需要先收缩,恢复合法以后再更新最大长度。
所以,我们可以统一把更新答案的操作放在收缩循环后面:
python
max_len = max(max_len, right - left + 1)
这样,无论这一轮是否进入 while,执行到这里时,窗口都已经合法。
而且,窗口长度可以直接通过两个指针计算:
python
right - left + 1
不需要再额外维护一个变量记录长度。
到这里,整个思路就已经完整了:
text
用 left、right 维护窗口
↓
用 freq 记录窗口中各字符的出现次数
↓
right 向右移动,加入新字符
↓
计算窗口最少需要替换的次数
↓
需要替换的次数 > k?
│
├── 是 → 不断移动 left
│ ↓
│ 直到窗口恢复合法
│ ↓
└── 否 ──────────┘
↓
更新最大长度
↓
继续移动 right
接下来就可以按照这个思路写代码了。
六、完整代码
python
class Solution:
def characterReplacement(self, s: str, k: int) -> int:
left = 0
right = 0
freq = {}
max_len = 0
while right < len(s):
# 加入右边的新字符
freq[s[right]] = freq.get(s[right], 0) + 1
# 替换次数超过 k,就不断缩小窗口
while (right - left + 1) - max(freq.values()) > k:
freq[s[left]] -= 1
left += 1
# 当前窗口合法,更新最大长度
max_len = max(max_len, right - left + 1)
right += 1
return max_len
七、代码中的几个细节
1. 为什么用 freq.get()?
python
freq[s[right]] = freq.get(s[right], 0) + 1
这里的 get() 是字典的方法。
它会先尝试获取对应字符的出现次数:
- 如果字符已经存在,就返回原来的次数。
- 如果字符不存在,就返回默认值
0。
然后再加 1。
例如:
python
freq = {'A': 2}
freq.get('A', 0) # 2
freq.get('B', 0) # 0
这样就不需要每次都先判断字符是否存在于字典中。
2. 为什么这里不需要删除次数为 0 的字符?
收缩窗口时:
python
freq[s[left]] -= 1
表示左边的字符被移出了窗口,对应的出现次数减 1。
即使某个字符的次数变成 0,这里也不需要删除它。
因为我们使用的是:
python
max(freq.values())
只关心窗口中最高的字符频率 ,次数为 0 的键不会影响最大值。
但上一道 LeetCode 3 使用的是
len(freq)来统计不同字符的数量。即使某个字符的次数为0,它仍然会被len(freq)计算进去,所以需要删除。
因此,是否删除次数为 0 的键,取决于后续如何使用字典。
3. 为什么 max_freq 不能只在收缩前计算?
这里有一个比较容易忽略的细节。
假设我们这样写:
python
max_freq = max(freq.values())
while (right - left + 1) - max_freq > k:
freq[s[left]] -= 1
left += 1
看起来没有问题,但注意:max_freq 是进入 while 之前计算出来的值。
当左指针移动时:
python
freq[s[left]] -= 1
字典中的字符频率已经发生变化。
但 max_freq 不会跟着自动更新。
例如:
text
窗口:[A, A, B]
k = 0
A:2 次
B:1 次
max_freq = 2
窗口长度为 3,需要替换 1 个字符,超过了 k,所以开始收缩。
移出第一个 A 后:
text
窗口:[A, B]
A:1 次
B:1 次
现在真实的最高频率已经变成 1。
但之前保存的 max_freq 仍然是 2。
如果继续用旧值判断:
text
窗口长度 - max_freq
= 2 - 2
= 0
程序就会认为不需要继续收缩。
可实际上 "AB" 仍然不合法,因为 k = 0,我们不能替换任何字符。
所以,如果我们的思路是:窗口不合法 → 不断收缩 → 直到恢复合法
那么就应该在每次判断时重新获取当前窗口的最高频率:
python
while (right - left + 1) - max(freq.values()) > k:
这样,即使收缩过程中字符频率发生变化,下一次判断也会使用更新后的频率。
补充: LeetCode 424 还有一种常见的优化写法,会维护一个只增不减的
max_freq。这种写法允许当前窗口暂时不合法,但仍然可以正确求出历史最长长度。不过,它的正确性依赖额外的推导,并不等同于「每次收缩后窗口一定合法」。这里先采用实时计算最高频率的方法,让代码和前面的思路保持一致,也更容易理解。
八、复杂度分析
-
时间复杂度:
O(n)虽然代码中使用了嵌套的
while循环,但两个指针都只向右移动,不会回退。right:每个字符最多加入窗口一次,总共移动n次。left:每个字符最多移出窗口一次,总共最多移动n次。max(freq.values()):每次最多检查 26 种大写字母的出现次数,因此可以视为O(1)。
所以整体时间复杂度为
O(n),而不是O(n²)。 -
空间复杂度:
O(1)额外空间主要来自字典
freq。- 题目中的字符串只包含大写英文字母。
- 因此字典最多记录 26 种字符。
left、right、max_len等变量只占用常数空间。
所以整体辅助空间复杂度为
O(1)。
九、最后复盘
这道题和上一道 LeetCode 3 都是在寻找最长合法窗口,但它们对「合法」的定义完全不同。
| 3. 无重复字符的最长子串 | 424. 替换后的最长重复字符 | |
|---|---|---|
| 目标 | 找最长无重复子串 | 找最长可替换成相同字符的子串 |
| 合法条件 | 所有字符都不重复 | 需要替换的字符数 <= k |
| 收缩条件 | 出现重复字符 | 需要替换的字符数 > k |
| 收缩目的 | 消除重复 | 减少需要替换的字符数量 |
| 更新答案 | 窗口合法后 | 窗口合法后 |
这次\的思路,其实就是先把题目中的要求转化成一个能够计算的条件:
text
最多替换 k 个字符
↓
尽量保留出现次数最多的字符
↓
需要替换的次数 = 窗口长度 - 最高字符频率
↓
判断是否超过 k
有了这个条件,我们才能进一步确定窗口什么时候扩大、什么时候收缩。
所以做滑动窗口题时,我觉得可以先问、三个问题:
- 什么样的窗口才算合法?
- 什么时候需要扩大缩小窗口?
- 什么时候可以用当前窗口更新答案?
把这三个问题想清楚,再去写代码,整个过程就会顺畅很多。