
本文介绍在 polars dataframe 中准确统计字符串中**重叠匹配**子串(如 "aa" 在 "aaaaa" 中出现 4 次)的方法,突破 `str.count_matches` 仅支持非重叠匹配的限制,并提供可扩展、向量化、无需 python 循环的纯表达式解决方案。
在 Polars 中,str.count_matches(pattern) 默认执行非重叠、贪婪式正则匹配,因此对 "aaaaa" 查找 "aa" 仅返回 2(匹配位置 0–1 和 2–3),而无法捕获重叠情形(如位置 1–2、2–3、3–4)。要获得真正的重叠计数(本例应为 4),需将字符串按滑动窗口切片,再对每个子串独立匹配并求和。
核心思路是:对每条字符串,生成所有长度为 len(pattern) 的连续滑动子串(即起始索引从 0 到 len(s) - len(pattern)),然后批量判断每个子串是否等于目标模式,最后横向求和。
以下是以子串 "aa" 为例的完整实现(适配任意固定长度模式):
import polars as pl
df = pl.DataFrame({"foo": ["aaaaa", "aabaa", "aaaab"]})
pattern = "aa"
window_size = len(pattern)
# 步骤 1:获取所有字符串的最大长度,确定最大滑动起始索引
max_len = df.select(pl.col("foo").str.len_chars().max()).item()
if max_len < window_size:
# 若所有字符串均短于模式,直接返回全 0
result = df.with_columns(pl.lit(0).alias("count"))
else:
# 步骤 2:为每个可能的起始位置 i ∈ [0, max_len - window_size] 创建切片列
slice_exprs = [
pl.col("foo").str.slice(i, window_size).alias(f"slice_{i}")
for i in range(max_len - window_size + 1)
]
# 步骤 3:对所有切片列执行精确字符串匹配(非正则,更高效),并横向求和
result = (
df.select(*slice_exprs)
.with_columns(
pl.sum_horizontal([pl.col(f"slice_{i}") == pattern for i in range(max_len - window_size + 1)])
.alias("count")
)
.select("count")
.hstack(df) # 恢复原始列顺序(可选)
)
print(result)输出:
shape: (3, 2) ┌───────┬───────┐ │ foo ┆ count │ │ --- ┆ --- │ │ str ┆ u32 │ ╞═══════╪═══════╡ │ aaaaa ┆ 4 │ │ aabaa ┆ 2 │ │ aaaab ┆ 3 │ └───────┴───────┘
✅ 优势说明:
- 完全向量化:全程使用 Polars 表达式,无 Python 循环或 UDF,性能接近底层 Rust 实现;
- 模式无关:只需修改 pattern 变量即可适配任意固定长度子串(如 "abc"、"11");
- 内存可控:切片列数量由最长字符串决定,对长文本可结合 str.slice 的 lazy 特性进一步优化;
- 语义清晰:== pattern 比正则 count_matches 更精准(避免意外元字符匹配)。
⚠️ 注意事项:
- 该方法适用于固定长度子串;若需支持正则重叠匹配(如 r"a+"),需改用 str.extract + 自定义逻辑,但会损失性能;
- 当 window_size == 1(单字符)时,等价于 str.count_chars(),可直接使用;
- 对超长字符串(如 >10k 字符),预生成大量切片列可能导致内存压力,此时建议分块处理或改用 map_elements(牺牲部分性能换取内存稳定性)。
总结:通过滑动窗口切片 + 向量化等值判断 + 横向聚合,我们绕过了 Polars 原生 API 的非重叠限制,实现了高效、简洁、可维护的重叠子串计数方案——这是 Polars 数据处理中“以空间换向量化”的典型实践。










