跳到正文

stringr 字符串处理:正则匹配与文本清洗完全指南

问卷的开放题、文献库导出的作者字段、仪器软件吐出的文件名——这些数据进到 R 里都是字符串,而且几乎总带着不一致:全角半角混用、多余空格、单位写在数值后面。stringr 是 tidyverse 里的字符串工具箱,把 base R 那套命名不一、参数顺序各异的函数(grep()grepl()sub()gsub()regexpr())统一成了 str_ 前缀的一套接口:字符串向量永远是第一个参数,所以能直接塞进管道。

str_detect() 返回逻辑向量,用法和 grepl() 一样,但参数顺序友好得多。拿 mtcars 的车名当例子——它就是一组真实的、格式不太统一的字符串:

library(tidyverse)
cars <- rownames(mtcars)
str_detect(cars, "^Merc") |> sum()
str_subset(cars, "^F")
[1] 7
[1] "Fiat 128" "Fiat X1-9" "Ford Pantera L" "Ferrari Dino"

^Merc 里的 ^ 表示行首,所以 Merc 240D 会被匹配,而中间出现 Merc 的名字不会。sum() 作用在逻辑向量上就是数 TRUE 的个数。str_subset() 是「检测 + 取子集」的快捷写法,等价于 cars[str_detect(cars, "^F")],但要短得多。同族的还有 str_which(),返回的是位置下标,需要按位置取别的列时用它。

默认区分大小写。要忽略大小写,用 regex() 包一层:

str_detect(cars, regex("merc", ignore_case = TRUE)) |> sum()

regex() 还能设置 multilinedotall 等开关。如果只是按字面量匹配、完全不想要正则语义,用 fixed()——它既更快,也免去转义 (. 这些元字符的麻烦。

str_extract() 返回第一个匹配到的片段,匹配不上时返回 NA(不是空字符串,这个区别后面会咬人):

str_extract(cars, "\\d+") |> head(5)
[1] "4" "4" "710" "4" NA

前三辆车名里的数字分别是 RX4 的 4、RX4 的 4、710,第四辆 Hornet 4 Drive 匹配到 4,而 Hornet Sportabout 里没有数字,返回 NA\\d 匹配一个数字,+ 表示「一次或多次」,所以 \\d+ 抓的是连续的一整段数字。

需要一次提取多组信息时用 str_match(),它把每个捕获组单独放一列,返回值是矩阵;str_extract_all() 则返回列表,因为每个元素的匹配个数可能不同。这两个函数的输出结构比 str_extract() 复杂,建议先在小样本上跑通再上全量数据。

str_replace() 只替换第一次匹配,str_replace_all() 替换全部——这个区别和 base R 的 sub() / gsub() 一一对应。取出车名里的厂商部分:

str_replace(cars, "\\s.*$", "") |> head(6)
[1] "Mazda" "Mazda" "Datsun" "Hornet" "Hornet" "Valiant"

\\s 匹配空白字符,.*$ 匹配它之后的所有内容,替换成空字符串就等于删掉。捕获组可以在替换串里用 \\1\\2 引用,例如把 Sepal.Length 这种变量名换成 Length (Sepal) 的格式:str_replace(names(iris), "^(\\w+)\\.(\\w+)$", "\\2 (\\1)")

str_split() 按分隔符切开,返回的是列表——这是新手最常卡住的地方,因为它不能直接赋值成数据框的一列:

str_split(cars[1], " ")
[[1]]
[1] "Mazda" "RX4"

取单个元素用 str_split_i(x, " ", 1),或者加 simplify = TRUE 让它返回矩阵。矩阵确实能直接塞进数据框,但元素个数不一致时它会用空字符串补齐,多出一批空列,所以更推荐 str_split_i()

拼字符串有三条路:str_c() 是向量化的粘贴(paste0() 的 tidyverse 版本),str_pad() 补位对齐,str_glue(){} 插值,读起来最像模板:

str_pad(c(1, 12, 123), width = 4, pad = "0")
car <- "Maserati Bora"
str_glue("{car} 的马力是 {mtcars[car, 'hp']} hp")
[1] "0001" "0012" "0123"
Maserati Bora 的马力是 335 hp

str_pad() 常用于生成带前导零的编号(样本号 S001S002),Excel 里丢掉的前导零就靠它补回来。str_glue() 存在 stringr 1.5 及以上版本。

别指望背下全部语法,下面这些覆盖了日常八成场景:

写法 含义
\\d / \\D 一个数字 / 一个非数字
\\s / \\S 一个空白字符(空格、制表、换行)/ 非空白
\\w / \\W 字母、数字或下划线 / 取反
[abc] / [^abc] 字符集合中的任意一个 / 集合之外
. 任意一个字符(要匹配真正的点得写 \\.
^ / $ 字符串开头 / 结尾
* / + / ? 0 次以上 / 1 次以上 / 0 或 1 次
{2,4} 重复 2 到 4 次
(...) 捕获组,可在替换串里用 \\1 引用
`a b`

R 的字符串里反斜杠本身要转义,所以正则的 \d 在代码里写成 "\\d"。这个双反斜杠是中文用户最容易漏的地方。

把检测、提取、筛选接起来,从车名里拆出厂商和排量数字:

tibble(car = rownames(mtcars), mpg = mtcars$mpg) |>
filter(str_detect(car, "^Merc")) |>
mutate(engine = str_extract(car, "\\d+")) |>
arrange(desc(mpg))
# A tibble: 7 × 3
car mpg engine
<chr> <dbl> <chr>
1 Merc 240D 24.4 240
2 Merc 230 22.8 230
3 Merc 280 19.2 280
4 Merc 280C 17.8 280
5 Merc 450SL 17.3 450
6 Merc 450SE 16.4 450
7 Merc 450SLC 15.2 450

先筛出奔驰的 7 辆车,再把车名里的数字提成 engine 列,最后按油耗降序。注意 engine 是字符型的(<chr>),要参与计算必须 mutate(engine = as.numeric(engine))——提取出来的东西永远是字符串,这一点在 str_extract() 之后要立刻处理掉,否则 mean(engine) 只会得到 NA 和一条警告。

NA 会传播str_detect() 遇到 NA 返回 NA 而不是 FALSE,在 filter() 里这样的行会被丢掉。做筛选前先决定缺失值算「匹配」还是「不匹配」:str_detect(coalesce(x, ""), "pattern") 能把 NA 当成空串处理。

默认区分大小写,而且不会提示你。清洗英文问卷时先统一 str_to_lower() 再匹配,比在每处加 ignore_case 更不容易漏。

元字符要转义。判断字符串里有没有小数点,写 str_detect(x, ".") 会永远返回 TRUE,因为 . 匹配任意字符;正确写法是 "\\."fixed(".")。文献 DOI、化学式、文件名里全是这类字符。

中文按字符计数str_length("数据分析") 返回 4,不是字节数;str_sub() 也按字符取位置,处理中文不会切出乱码。反过来,str_to_upper() 对中文没有任何效果(汉字本来不分大小写),str_sort() 排中文默认按编码顺序而不是拼音顺序,要按拼音排序得先转成因子并显式指定 levels

提取失败返回 NA,不是空字符串。后续如果要 str_c() 拼接,NA 会把整条结果污染成 NA,先用 str_replace_na(x, "")tidyr::replace_na() 补上。

字符串处理往往是数据整理的第一步而不是最后一步。如果你也用 Python,pandas 的 .str 访问器提供了一套几乎一一对应的向量化字符串方法,正则部分则直接用 Python 的 re 模块语法,可以对照 /python/pandas/cleaning 看同一类清洗任务的两种写法。下一节 /r/tidyverse/purrr 讲的是当同一套清洗逻辑要作用在几十个文件上时,怎么用函数式编程把它组织起来。