
本文介绍一种高效、向量化的方法,利用字符串切片与横向求和,在 polars 中实现子串重叠匹配计数(如 `"aa"` 在 `"aaaaa"` 中应计为 4 次),弥补 `str.count_matches` 仅支持非重叠匹配的局限。
在 Polars 中,str.count_matches() 是统计子串出现频次的常用方法,但它基于正则引擎实现,默认匹配非重叠、贪心的连续片段。例如,对字符串 "aaaaa" 查找 "aa",它会匹配位置 0–1 和 2–3,跳过 1–2 和 3–4,最终返回 2 —— 这不符合重叠计数需求(正确结果应为 4)。
要真正支持重叠匹配,核心思路是:将原字符串按长度为 len(sub) 的滑动窗口切分为所有可能的连续子串,再对每个子串判断是否等于目标子串,最后汇总真值数量。由于 Polars 原生不提供滑动窗口计数函数,我们可通过 str.slice() 构造一系列偏移切片,再用 count_matches("...")(此时每个切片长度恰好等于子串长,等价于精确相等判断)完成逐窗口检测。
以下为完整实现方案:
import polars as pl
df = pl.DataFrame({"foo": ["aaaaa", "aabaa", "aaaab"]})
pattern = "aa"
pattern_len = len(pattern)
# 步骤 1:计算最长字符串长度,确定最大切片起始索引(n - pattern_len + 1)
max_len = df.select(pl.col("foo").str.len_chars().max()).item()
if max_len < pattern_len:
# 若所有字符串都短于 pattern,直接返回全 0
result = df.with_columns(pl.lit(0).alias("count"))
else:
# 步骤 2:生成所有可能的起始位置(0 到 max_len - pattern_len)
slices = [
pl.col("foo").str.slice(i, pattern_len).alias(f"_{i}")
for i in range(max_len - pattern_len + 1)
]
# 步骤 3:对每个切片列执行 count_matches(因长度固定,等效于 == pattern)
# 使用 sum_horizontal 对布尔匹配结果横向求和
result = df.with_columns(
pl.sum_horizontal([s.str.count_matches(pattern) for s in slices]).alias("count")
)
print(result)输出:
shape: (3, 2) ┌───────┬───────┐ │ foo ┆ count │ │ --- ┆ --- │ │ str ┆ u32 │ ╞═══════╪═══════╡ │ aaaaa ┆ 4 │ │ aabaa ┆ 2 │ │ aaaab ┆ 3 │ └───────┴───────┘
✅ 关键优势:
- 完全向量化:无 Python 循环或 apply(),充分利用 Polars 的底层优化;
- 内存可控:切片列数由最长字符串决定,对常规文本长度(如 ≤1000 字符)开销极小;
- 通用性强:只需修改 pattern 变量即可适配任意长度子串(包括单字符、多字符、甚至 Unicode 组合)。
⚠️ 注意事项:
- 若数据中存在空字符串或 null 值,str.slice() 会自动返回 null,而 count_matches() 在 null 上返回 null,最终 sum_horizontal 会传播 null。如需健壮处理,建议前置填充或过滤:
.with_columns(pl.col("foo").fill_null("").alias("foo")) - 当 pattern 长度为 0 或超长时,应增加校验逻辑,避免无效切片;
- 此方法本质是“模拟滑动窗口”,时间复杂度为 O(n × m)(n 为行数,m 为平均字符串长度),对超长文本(如 MB 级日志字段)需评估性能,必要时可结合 str.lengths() 提前过滤过短行。
综上,该方案以清晰的声明式表达、零外部依赖和优异的 Polars 兼容性,成为统计重叠子串频次的首选实践。










