双指针、滑动窗口与字符串
本文件维护第一章第四类题目的题解与面试追问。本类重点不是背指针模板,而是说明指针为何可以单向移动,以及哈希表如何减少窗口内的重复查找。
字节核心题单
| 顺序 | 题目 | 字节频次 | 数据版本 | 其他统计覆盖 |
|---|---|---|---|---|
| 1 | LC 3 无重复字符的最长子串 | 174 | 08 更新 | 3/4 |
| 2 | LC 15 三数之和 | 44 | 08 更新 | 4/4 |
| 3 | LC 42 接雨水 | 38 | 08 更新 | 4/4 |
| 4 | LC 88 合并两个有序数组 | 29 | 04 基线 | 2/4 |
| 5 | LC 209 长度最小的子数组 | 12 | 04 基线 | 2/4 |
| 6 | LC 239 滑动窗口最大值 | 9 | 04 基线 | 4/4 |
其他来源新增题
| 题目 | 发现来源 |
|---|---|
| LC 8 字符串转换整数(atoi) | 美团 |
| LC 11 盛最多水的容器 | 华为 |
| LC 14 最长公共前缀 | 美团 |
| LC 16 最接近的三数之和 | 美团 |
| LC 43 字符串相乘 | 美团、腾讯 |
| LC 76 最小覆盖子串 | 腾讯、Hot 100 综合榜 |
| LC 283 移动零 | Hot 100 综合榜 |
| LC 438 找到字符串中所有字母异位词 | Hot 100 综合榜 |
| LC 468 验证 IP 地址 | 美团 |
题解与追问重点
- LC 3 比较集合维护窗口与“最近出现位置”哈希表,解释左指针为什么只能前进,以及哈希表如何让它直接跳转。
- LC 15 必须给出排序加双指针和枚举加哈希表两种解法,重点比较去重难度、空间成本和面试推荐写法。
- LC 15 与第五类的 LC 1 组成专题:哈希表用于快速找补数,双指针用于在有序区间中缩小搜索范围。
- LC 42 比较前后缀最大值、双指针和单调栈,说明每种解法真正累加水量的时机。
- 滑动窗口题统一说明窗口含义、扩张条件、收缩条件和答案更新时机。
- 字符串解析题必须列出空串、前导零、符号、非法字符、溢出和分隔符边界。
LC 3 无重复字符的最长子串
考频与考点:字节 174 次,是本章最高频题。考查可变滑动窗口、哈希集合与最近位置哈希表。
写代码前确认:答案是子串长度,子串必须连续;字符集是否只含 ASCII 不影响字典写法;空串返回 0。
思路/解法一:集合维护合法窗口。窗口 [left, right] 始终无重复。若新字符已在集合中,持续移除左端字符并右移 left,直到可加入新字符。每个字符最多进出集合一次。
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
window = set()
left = answer = 0
for right, char in enumerate(s):
while char in window:
window.remove(s[left])
left += 1
window.add(char)
answer = max(answer, right - left + 1)
return answer
思路/解法二:最近位置哈希表直接跳转。记录每个字符最近一次出现的下标。新字符上次出现在当前窗口内时,left 可直接跳到该位置后一格;必须取 max,因为左边界只能前进,不能被窗口外的旧位置拉回。
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
last_index = {}
left = answer = 0
for right, char in enumerate(s):
if char in last_index:
left = max(left, last_index[char] + 1)
last_index[char] = right
answer = max(answer, right - left + 1)
return answer
复杂度:两种方法时间均为 O(n)、空间均为 O(min(n, 字符集大小))。最近位置法减少了逐个收缩窗口的操作,表达更直接。
边界/易错点:"abba" 可检查左边界是否错误回退;更新最近位置和答案的先后顺序要与窗口定义一致。
面试追问:若每个字符最多出现 k 次,窗口状态如何维护?如何返回最长子串本身?输入是字符流且不能保存全部字符串时怎么办?
LC 15 三数之和
考频与考点:字节 44 次,所有统计源均覆盖。考查排序、双指针、哈希补数和结果去重,是 LC 1“两数之和”的自然扩展。
写代码前确认:返回数值三元组而非下标;三元组内使用三个不同位置;答案不能重复,输出顺序不限。
思路/解法一:排序 + 双指针。排序后枚举第一个数 nums[i],剩余区间转化为目标值为 -nums[i] 的两数之和。和偏小则左指针右移,和偏大则右指针左移,这是有序性保证的单调排除。三层去重缺一不可:固定数与前一个相同时跳过;找到答案后分别跳过左侧重复值和右侧重复值;最后同时收缩两端。若 nums[i] > 0,之后不可能得到零,可提前结束。
from typing import List
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
nums.sort()
answer = []
for i in range(len(nums) - 2):
if nums[i] > 0:
break
if i > 0 and nums[i] == nums[i - 1]:
continue
left, right = i + 1, len(nums) - 1
while left < right:
total = nums[i] + nums[left] + nums[right]
if total < 0:
left += 1
elif total > 0:
right -= 1
else:
answer.append([nums[i], nums[left], nums[right]])
left_value, right_value = nums[left], nums[right]
while left < right and nums[left] == left_value:
left += 1
while left < right and nums[right] == right_value:
right -= 1
return answer
思路/解法二:枚举 + 哈希表。固定一个位置后,从其右侧扫描第二个数;哈希集合保存已经扫描过的值,以平均 O(1) 查找补数。与 LC 1 的一次扫描哈希完全同构,但三数之和还要对三元组归一化并用集合去重,因此空间和去重成本更高,面试中通常优先排序双指针。
from typing import List
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
triples = set()
for i in range(len(nums) - 2):
seen = set()
for j in range(i + 1, len(nums)):
complement = -nums[i] - nums[j]
if complement in seen:
triples.add(tuple(sorted((nums[i], nums[j], complement))))
seen.add(nums[j])
return [list(triple) for triple in triples]
复杂度:排序双指针时间 O(n^2),排序额外空间取决于实现;哈希法平均时间 O(n^2),除答案外额外空间 O(n)。
边界/易错点:长度不足三、全为零、大量重复值;找到答案后只移动一侧会重复输出。排序会修改输入,若不允许应先复制。
面试追问:为何双指针必须依赖有序性?如何推广到目标值不为零、四数之和或 kSum?若要求返回原下标,应如何设计去重?
LC 42 接雨水
考频与考点:字节 38 次,所有统计源均覆盖。考查按列计算、边界最大值、双指针不变量和单调栈。
写代码前确认:每格宽度为 1,高度非负;长度小于三必然无法蓄水。
思路/解法一:前后缀最大值。第 i 列水量由其左侧最高柱与右侧最高柱中较矮者决定:min(left_max[i], right_max[i]) - height[i]。该方法最直观,适合先推导正确公式。
from typing import List
class Solution:
def trap(self, height: List[int]) -> int:
n = len(height)
if n < 3:
return 0
left_max = [0] * n
right_max = [0] * n
left_max[0], right_max[-1] = height[0], height[-1]
for i in range(1, n):
left_max[i] = max(left_max[i - 1], height[i])
for i in range(n - 2, -1, -1):
right_max[i] = max(right_max[i + 1], height[i])
return sum(min(left_max[i], right_max[i]) - height[i] for i in range(n))
思路/解法二:双指针。维护两端迄今最高值。若 left_max <= right_max,左侧当前列的短板已经确定为 left_max,未来右侧再高也不会改变它,因此可立即结算并右移左指针;反之结算右侧。空间降为常数。
from typing import List
class Solution:
def trap(self, height: List[int]) -> int:
left, right = 0, len(height) - 1
left_max = right_max = water = 0
while left <= right:
if left_max <= right_max:
left_max = max(left_max, height[left])
water += left_max - height[left]
left += 1
else:
right_max = max(right_max, height[right])
water += right_max - height[right]
right -= 1
return water
思路/解法三:单调栈。栈保存高度单调不增的柱下标。当前柱更高时,弹出的柱是凹槽底部;新栈顶与当前柱形成左右边界,一次计算一层横向水量。它更适合追问“每个凹槽如何形成”。
from typing import List
class Solution:
def trap(self, height: List[int]) -> int:
stack, water = [], 0
for right, current_height in enumerate(height):
while stack and current_height > height[stack[-1]]:
bottom = stack.pop()
if not stack:
break
left = stack[-1]
width = right - left - 1
bounded_height = min(height[left], current_height) - height[bottom]
water += width * bounded_height
stack.append(right)
return water
复杂度:三种方法时间均为 O(n);前后缀空间 O(n),双指针空间 O(1),单调栈空间 O(n)。
边界/易错点:单调序列、全等高度、多个相同边界;双指针比较的是已知边界最大值,而非简单比较当前两根柱后随意结算。
面试追问:三种算法分别按列还是按层累加?为什么单调栈在弹出后若为空不能计算?二维接雨水为何需要最小堆?
LC 88 合并两个有序数组
考频与考点:字节 29 次。考查逆向双指针和原地覆盖安全性。
写代码前确认:nums1 尾部有 n 个占位空间;有效元素分别为前 m 个和前 n 个;要求修改 nums1。
思路/解法:若从前向后写,nums1 中尚未比较的元素可能被覆盖。从两数组有效区末尾比较,把较大值写入 nums1 最末空位,写指针不断左移。最后只需补齐 nums2 剩余部分;若 nums1 有剩余,它们已在正确位置。
from typing import List
class Solution:
def merge(self, nums1: List[int], m: int, nums2: List[int], n: int) -> None:
first, second, write = m - 1, n - 1, m + n - 1
while second >= 0:
if first >= 0 and nums1[first] > nums2[second]:
nums1[write] = nums1[first]
first -= 1
else:
nums1[write] = nums2[second]
second -= 1
write -= 1
复杂度:时间 O(m + n);额外空间 O(1)。
边界/易错点:m = 0、n = 0、重复值;循环条件以 second >= 0 为准即可。
面试追问:若 nums1 没有尾部空间怎么办?如何合并两个有序流?为何正向合并需要额外数组?
LC 209 长度最小的子数组
考频与考点:字节 12 次。考查正数数组上的可变滑动窗口。
写代码前确认:原题元素均为正数,这是窗口和具有单调性的关键;不存在答案时返回 0。
思路/解法:窗口 [left, right] 表示当前连续子数组。右端加入新数使总和增大;当总和达到目标时,持续右移左边界寻找以当前右端结尾的最短合法窗口,并在删除左端前更新答案。若允许负数,该单调性消失,应考虑前缀和加单调队列。
from typing import List
class Solution:
def minSubArrayLen(self, target: int, nums: List[int]) -> int:
left = window_sum = 0
answer = len(nums) + 1
for right, value in enumerate(nums):
window_sum += value
while window_sum >= target:
answer = min(answer, right - left + 1)
window_sum -= nums[left]
left += 1
return 0 if answer == len(nums) + 1 else answer
复杂度:时间 O(n),每个元素最多进入和离开窗口一次;额外空间 O(1)。
边界/易错点:单元素已达标、总和不足;答案要在收缩前更新,否则会漏掉合法窗口。
面试追问:如何用前缀和加二分做到 O(n log n)?包含负数时为什么普通滑窗错误,怎样改成单调队列?
LC 239 滑动窗口最大值
考频与考点:字节 9 次,所有统计源均覆盖。考查单调队列、过期下标和摊还复杂度。
写代码前确认:窗口大小 k 合法;返回每个完整窗口的最大值。
思路/解法:双端队列保存下标,并保证对应值从队首到队尾单调不增。加入新元素前,先移除队尾所有不大于它的元素,因为它们更小且更早过期,不可能再成为最大值;再移除队首已离开窗口的下标。队首始终是当前窗口最大值。
from collections import deque
from typing import List
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
queue = deque()
answer = []
for right, value in enumerate(nums):
while queue and nums[queue[-1]] <= value:
queue.pop()
queue.append(right)
left = right - k + 1
if queue[0] < left:
queue.popleft()
if left >= 0:
answer.append(nums[queue[0]])
return answer
复杂度:时间 O(n),每个下标至多入队、出队各一次;队列空间 O(k)。
边界/易错点:队列必须存下标才能判断过期;k = 1 时答案等于原数组;相等元素是否弹出不影响正确性,但弹出可保持队列更短。
面试追问:如何同时维护窗口最小值?为什么堆解法通常是 O(n log n)?单调队列的摊还 O(1) 如何证明?
LC 8 字符串转换整数(atoi)
考频与考点:美团新增。考查顺序解析、状态边界和 32 位整数溢出。
写代码前确认:只跳过前导空格;符号至多一个;遇到首个非数字后立即停止;无有效数字返回 0;结果限制在 [-2^31, 2^31 - 1]。
思路/解法:依次处理前导空格、可选符号和连续数字。累加新数字前用 (limit - digit) // 10 判断是否将溢出,从而不依赖语言的大整数能力。limit 根据符号分别取 2^31 - 1 或 2^31。
class Solution:
def myAtoi(self, s: str) -> int:
index, n = 0, len(s)
while index < n and s[index] == " ":
index += 1
sign = 1
if index < n and s[index] in "+-":
sign = -1 if s[index] == "-" else 1
index += 1
limit = 2**31 if sign == -1 else 2**31 - 1
value = 0
while index < n and "0" <= s[index] <= "9":
digit = ord(s[index]) - ord("0")
if value > (limit - digit) // 10:
return sign * limit
value = value * 10 + digit
index += 1
return sign * value
复杂度:时间 O(n);额外空间 O(1)。
边界/易错点:空串、只有符号、前导零、符号后空格、数字后字母、正负边界;不要把中间空格当作可跳过字符。
面试追问:如何用有限状态机表达?若解析任意进制或 64 位整数,需要调整哪些状态和边界?
LC 11 盛最多水的容器
考频与考点:华为新增。考查相向双指针及“移动短板”的正确性证明。
写代码前确认:两条竖线与横轴构成容器,面积为距离乘较短高度;不能倾斜容器。
思路/解法:从最宽区间开始计算面积。面积受较短边限制;若移动较长边,宽度减小而短板不变,面积不可能变大,因此只能移动较短边,寻找更高的短板。每一步都安全排除一批不可能更优的组合。
from typing import List
class Solution:
def maxArea(self, height: List[int]) -> int:
left, right = 0, len(height) - 1
answer = 0
while left < right:
answer = max(answer, (right - left) * min(height[left], height[right]))
if height[left] <= height[right]:
left += 1
else:
right -= 1
return answer
复杂度:时间 O(n);额外空间 O(1)。
边界/易错点:面积宽度是下标差;两边等高时移动任意一边都不会漏掉更优解。
面试追问:如何严格证明移动长边无收益?与 LC 42 的双指针不变量有何区别?
LC 14 最长公共前缀
考频与考点:美团新增。考查字符串逐列比较和输入边界。
写代码前确认:空数组和包含空串时均返回空字符串;比较区分大小写。
思路/解法:把第一个字符串作为基准,逐列检查其余字符串。任一字符串长度不足或当前位置字符不同,当前下标之前就是答案。也可不断缩短候选前缀,但可能反复创建字符串。
from typing import List
class Solution:
def longestCommonPrefix(self, strs: List[str]) -> str:
if not strs:
return ""
for index, char in enumerate(strs[0]):
for position in range(1, len(strs)):
text = strs[position]
if index == len(text) or text[index] != char:
return strs[0][:index]
return strs[0]
复杂度:时间 O(S),S 为实际检查的字符总数上界;除切片结果外空间 O(1)。
边界/易错点:不要访问短字符串越界;只有一个字符串时应返回它本身。
面试追问:大量字符串如何用 Trie?字符串分布在多台机器时如何归并公共前缀?
LC 16 最接近的三数之和
考频与考点:美团新增。考查排序双指针与最优差值维护。
写代码前确认:题目保证至少三个数且答案唯一;返回三数之和而非差值或下标。
思路/解法:排序后枚举第一个数,在其右侧使用双指针。每次先根据绝对差更新当前最佳和;和小于目标时只能右移左指针使其增大,和大于目标时只能左移右指针使其减小,恰好相等可立即返回。
from typing import List
class Solution:
def threeSumClosest(self, nums: List[int], target: int) -> int:
nums.sort()
best = nums[0] + nums[1] + nums[2]
for i in range(len(nums) - 2):
left, right = i + 1, len(nums) - 1
while left < right:
total = nums[i] + nums[left] + nums[right]
if abs(total - target) < abs(best - target):
best = total
if total < target:
left += 1
elif total > target:
right -= 1
else:
return target
return best
复杂度:时间 O(n^2);排序额外空间取决于实现。
边界/易错点:最佳值应初始化为真实三数之和,不能随意设为 0;若题目不保证答案唯一,要约定差值相同时的选择。
面试追问:如何做最接近目标的两数之和?如何通过边界和进行剪枝?与 LC 15 的去重要求有何不同?
LC 43 字符串相乘
考频与考点:美团、腾讯新增。考查竖式乘法、进位与字符串大整数模拟。
写代码前确认:输入只含数字且无多余前导零;不能直接转换为大整数;任一输入为 "0" 时返回 "0"。
思路/解法:长度为 m、n 的数相乘结果最多 m+n 位。反向枚举两个数字,乘积累加到结果数组位置 i+j+1,个位留在该处,进位累加到 i+j。由于从低位向高位处理,累加进位可被后续计算继续归并。
class Solution:
def multiply(self, num1: str, num2: str) -> str:
if num1 == "0" or num2 == "0":
return "0"
result = [0] * (len(num1) + len(num2))
for i in range(len(num1) - 1, -1, -1):
for j in range(len(num2) - 1, -1, -1):
product = (ord(num1[i]) - 48) * (ord(num2[j]) - 48)
total = product + result[i + j + 1]
result[i + j + 1] = total % 10
result[i + j] += total // 10
first = 0
while first < len(result) - 1 and result[first] == 0:
first += 1
return "".join(str(digit) for digit in result[first:])
复杂度:时间 O(mn);结果数组空间 O(m+n)。
边界/易错点:下标对应关系是 i+j 和 i+j+1;结果前导零要移除,但不能把零乘积变成空串。
面试追问:如何实现字符串加法?超长数字如何用更大的进制分块?Karatsuba 乘法何时值得使用?
LC 76 最小覆盖子串
考频与考点:腾讯、Hot 100 新增。考查带重复需求的可变窗口和哈希计数。
写代码前确认:覆盖必须满足 t 中每个字符的频次而非仅包含字符种类;若不存在返回空串;大小写敏感。
思路/解法:need 保存尚缺的字符频次,missing 保存尚缺字符总数。右端字符若仍被需要,则 missing 减一;无论是否多余都将其需求减一。覆盖完成后持续收缩左端,并在收缩前更新最短答案;移出字符后若其需求变为正数,说明窗口重新缺字符。
from collections import Counter
class Solution:
def minWindow(self, s: str, t: str) -> str:
if not s or not t:
return ""
need = Counter(t)
missing = len(t)
left = 0
best_start, best_length = 0, len(s) + 1
for right, char in enumerate(s):
if need[char] > 0:
missing -= 1
need[char] -= 1
while missing == 0:
length = right - left + 1
if length < best_length:
best_start, best_length = left, length
left_char = s[left]
need[left_char] += 1
if need[left_char] > 0:
missing += 1
left += 1
if best_length == len(s) + 1:
return ""
return s[best_start:best_start + best_length]
复杂度:时间 O(|s| + |t|);哈希表空间 O(|字符集|)。
边界/易错点:t 含重复字符、t 比 s 长、刚好覆盖;多余字符的需求允许为负数。
面试追问:如何返回所有同长度最小窗口?若字符流只能向前读取,如何保存候选窗口?与 LC 438 的固定窗口有何联系?
LC 283 移动零
考频与考点:Hot 100 新增。考查同向双指针、原地稳定移动。
写代码前确认:保持非零元素相对顺序;必须原地操作,不需要返回数组。
思路/解法:write 指向下一个非零元素应放的位置。read 扫描数组,遇到非零就与 write 位置交换并推进 write。已扫描区间中,前 write 个元素始终是按原顺序收集的全部非零元素,其后均可视为待处理区域。
from typing import List
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
write = 0
for read in range(len(nums)):
if nums[read] != 0:
nums[write], nums[read] = nums[read], nums[write]
write += 1
复杂度:时间 O(n);额外空间 O(1)。
边界/易错点:全零、无零、只有一个元素;交换法在 read == write 时会自交换但不影响正确性。
面试追问:如何减少写操作次数?如何把满足任意谓词的元素稳定移到末尾?若不要求稳定可怎样做?
LC 438 找到字符串中所有字母异位词
考频与考点:Hot 100 新增。考查固定长度滑动窗口与频次哈希。
写代码前确认:异位词必须长度相同且字符频次一致;返回起始下标;原题字符为小写字母。
思路/解法:窗口长度固定为 len(p)。沿用 LC 76 的含义:need 表示当前窗口还缺多少字符,missing 表示缺失总数。加入右端后,若窗口过长就移出左端并恢复需求;长度刚好且 missing == 0 时记录起点。
from collections import Counter
from typing import List
class Solution:
def findAnagrams(self, s: str, p: str) -> List[int]:
if len(p) > len(s):
return []
need = Counter(p)
missing = len(p)
left = 0
answer = []
for right, char in enumerate(s):
if need[char] > 0:
missing -= 1
need[char] -= 1
if right - left + 1 > len(p):
left_char = s[left]
need[left_char] += 1
if need[left_char] > 0:
missing += 1
left += 1
if right - left + 1 == len(p) and missing == 0:
answer.append(left)
return answer
复杂度:时间 O(|s| + |p|);哈希表空间 O(|字符集|)。
边界/易错点:p 比 s 长、重复字符、相邻答案;移出字符时先恢复 need,再判断是否重新产生缺失。
面试追问:小写字母场景如何用长度 26 的数组优化?如何判断两个字符串是否异位?与最小覆盖子串的收缩条件有何区别?
LC 468 验证 IP 地址
考频与考点:美团新增。考查严格格式解析、分隔符和字符范围校验。
写代码前确认:IPv4 恰有四段,IPv6 恰有八段;本题不接受 IPv6 压缩写法 ::、前后分隔符或混合格式。
思路/解法:先按出现的分隔符决定候选类型。IPv4 每段必须是 1 至 3 个十进制数字,除单个 0 外不能有前导零,数值不超过 255。IPv6 每段必须是 1 至 4 个十六进制字符。先校验字符再转换,避免 int 接受题目不允许的符号或空白。
class Solution:
def validIPAddress(self, queryIP: str) -> str:
if "." in queryIP and ":" not in queryIP:
parts = queryIP.split(".")
if len(parts) != 4:
return "Neither"
for part in parts:
if not 1 <= len(part) <= 3:
return "Neither"
if not all("0" <= char <= "9" for char in part):
return "Neither"
if len(part) > 1 and part[0] == "0":
return "Neither"
if int(part) > 255:
return "Neither"
return "IPv4"
if ":" in queryIP and "." not in queryIP:
parts = queryIP.split(":")
hexadecimal = set("0123456789abcdefABCDEF")
if len(parts) != 8:
return "Neither"
for part in parts:
if not 1 <= len(part) <= 4 or any(char not in hexadecimal for char in part):
return "Neither"
return "IPv6"
return "Neither"
复杂度:时间 O(n);切分后的辅助空间 O(n),IP 长度有固定上限时可视为常数。
边界/易错点:空段、尾部分隔符、IPv4 前导零、正负号、IPv6 非法字母、同时含点和冒号。
面试追问:如何支持 IPv6 的 :: 压缩和 IPv4 映射地址?不用 split 如何写状态机?