stringr 字符串处理:正则匹配与文本清洗完全指南
问卷的开放题、文献库导出的作者字段、仪器软件吐出的文件名——这些数据进到 R 里都是字符串,而且几乎总带着不一致:全角半角混用、多余空格、单位写在数值后面。stringr 是 tidyverse 里的字符串工具箱,把 base R 那套命名不一、参数顺序各异的函数(grep()、grepl()、sub()、gsub()、regexpr())统一成了 str_ 前缀的一套接口:字符串向量永远是第一个参数,所以能直接塞进管道。
检测:str_detect
Section titled “检测:str_detect”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() 还能设置 multiline、dotall 等开关。如果只是按字面量匹配、完全不想要正则语义,用 fixed()——它既更快,也免去转义 (、. 这些元字符的麻烦。
提取:str_extract
Section titled “提取:str_extract”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
Section titled “替换:str_replace 与 str_replace_all”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 hpstr_pad() 常用于生成带前导零的编号(样本号 S001、S002),Excel 里丢掉的前导零就靠它补回来。str_glue() 存在 stringr 1.5 及以上版本。
正则表达式速查
Section titled “正则表达式速查”别指望背下全部语法,下面这些覆盖了日常八成场景:
| 写法 | 含义 |
|---|---|
\\d / \\D |
一个数字 / 一个非数字 |
\\s / \\S |
一个空白字符(空格、制表、换行)/ 非空白 |
\\w / \\W |
字母、数字或下划线 / 取反 |
[abc] / [^abc] |
字符集合中的任意一个 / 集合之外 |
. |
任意一个字符(要匹配真正的点得写 \\.) |
^ / $ |
字符串开头 / 结尾 |
* / + / ? |
0 次以上 / 1 次以上 / 0 或 1 次 |
{2,4} |
重复 2 到 4 次 |
(...) |
捕获组,可在替换串里用 \\1 引用 |
| `a | b` |
R 的字符串里反斜杠本身要转义,所以正则的 \d 在代码里写成 "\\d"。这个双反斜杠是中文用户最容易漏的地方。
完整示例:解析车型名称
Section titled “完整示例:解析车型名称”把检测、提取、筛选接起来,从车名里拆出厂商和排量数字:
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 2402 Merc 230 22.8 2303 Merc 280 19.2 2804 Merc 280C 17.8 2805 Merc 450SL 17.3 4506 Merc 450SE 16.4 4507 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 讲的是当同一套清洗逻辑要作用在几十个文件上时,怎么用函数式编程把它组织起来。