跳转至

第 70 章 字符串处理

配套例题:BISHI10 小红的字符串修改、BISHI123 环形字符串跃迁 来源:S1 菜鸟教程 Python3 字符串;S2 stl/string.cpp 前置04-字符串21-复杂度与Python性能

04 章 讲的是「Python 的字符串有什么」, 这一章讲的是「算法竞赛里该用哪个」。

核心只有三句话:

  1. str 不可变——所有「逐字符修改」的写法都是 \(O(n^2)\)
  2. bytes 更快——索引出来是 int,比较、比较、再比较都省一层对象;
  3. 能不循环就不循环——join / map / 切片赋值把循环压到 C 层。

70.1 三种表示:strbyteslist

表示 元素类型 可变 单元素内存 典型来源
str 单字符 str 1–4 字节(紧凑存储) input().decode()
bytes int(0–255) 1 字节 sys.stdin.buffer.read().split()
bytearray int 1 字节 bytearray(b"...")
list[str] 单字符 str 8 字节指针 list(s)
list[int] int 8 字节指针 [ord(c) for c in s]

最容易被忽略的一条

s = "abc"
s[0]            # 'a'  —— 一个长度 1 的 str 对象

b = b"abc"
b[0]            # 97   —— 一个 int!
b[0:1]          # b'a' —— 切片才是 bytes

⚠️ bytes 上索引得到的是整数,不是字符。 所以判断 b[i] == '#' 永远是 False,必须写 b[i] == ord('#')(或 b[i] == 35)。 这是从 str 切到 bytes 时最高频的 bug。

切片仍然给 bytesb[i:i+1] == b'#' 也对,只是比整数比较慢。

竞赛里的默认选择

import sys

data = sys.stdin.buffer.read().split()      # 每个 token 是 bytes
s = data[0]                                 # 直接用 bytes,不 decode

decode 的三个理由

理由 说明
省掉一次 \(O(n)\) 的解码
省内存 bytes 一个字符 1 字节
比较更快 b[i] == b[j]整数比较s[i] == s[j] 是对象比较

什么时候必须 decode:需要用 str 专有方法(如格式化输出、str.title())时。 但注意 bytes 其实也有 splitstripfindreplacestartswithcountjoin 等绝大多数方法,竞赛需要的基本都有


70.2 str 不可变导致的 \(O(n^2)\) 陷阱

这是 Python 字符串最大的坑,04 章 已经讲过,这里补充四种典型场景和它们的正确写法

场景一:循环拼接

# ❌ O(n^2):每次 += 都新建一个字符串,复制全部已有内容
s = ""
for x in a:
    s += str(x)

# ✅ O(n)
s = "".join(map(str, a))

\(n = 10^5\) 时,前者约 2 秒,后者约 0.01 秒——200 倍差距

场景二:逐字符修改

# ❌ 这行根本不合法:TypeError: 'str' object does not support item assignment
s[i] = 'x'

# ✅ 写法 A:转成 list,改完再 join
t = list(s)
t[i] = 'x'
s = "".join(t)

# ✅ 写法 B(更快):用 bytearray,全程整数
t = bytearray(s, "ascii")       # 或者输入本来就是 bytes: bytearray(b)
t[i] = ord('x')
s = t.decode()

# ✅ 写法 C:只改一处时用切片拼接(O(n) 但常数极小)
s = s[:i] + 'x' + s[i + 1:]
写法 单次修改 \(k\) 次修改 备注
A:list + join \(O(n)\) \(O(n + k)\) 通用,可读性好
B:bytearray \(O(1)\) \(O(n + k)\) 最快,且能切片赋值
C:切片拼接 \(O(n)\) \(O(nk)\) 只适合改一次

场景三:反复取前缀/后缀

# ❌ O(n^2):每次切片都复制
for i in range(n):
    pre = s[:i]
    process(pre)

# ✅ 用哈希/前缀数组代替「真的切出来」
#    见 36-哈希与字符串哈希

切片是复制,不是视图。这一点和 C++ 的 string_view、Go 的 slice 都不一样。 s[l:r] 的代价是 \(O(r-l)\),在循环里切大片就是平方级。 需要「不复制地引用子串」时,用 (l, r) 下标对 + 哈希。

场景四:构造答案串

# ❌
ans = ""
for i in range(n):
    ans += "YES\n" if ok[i] else "NO\n"
print(ans)

# ✅ 攒进 list,最后一次 join + 一次 write
out = []
push = out.append
for i in range(n):
    push("YES" if ok[i] else "NO")
sys.stdout.write("\n".join(out) + "\n")

20-输入输出处理

bytearray 的切片赋值:让循环消失

bytearray 支持带步长的切片赋值,这是构造字符串时的杀手锏:

n = 5
s = b"abcde"

t = bytearray(b'#' * (2 * n + 1))     # '#a#b#c#d#e#'
t[1::2] = s                           # ★ 一次 C 层拷贝,把 s 铺到奇数位

# 埃氏筛也是同一个技巧(21 章):
# is_p[i*i::i] = bytearray(len(range(i*i, n+1, i)))

Manacher 算法构造分隔串用的正是这一招(72-回文), 比 '#' + '#'.join(s) + '#' 快好几倍。


70.3 字符与 ASCII:ord / chr

ord('a')        # 97
ord('A')        # 65
ord('0')        # 48
chr(97)         # 'a'

必须记住的三个数字'0' = 48,'A' = 65,'a' = 97。

字符 → 下标的标准写法

# str 输入
idx = ord(c) - 97                 # 小写字母 -> 0..25
idx = ord(c) - 65                 # 大写字母 -> 0..25
idx = ord(c) - 48                 # 数字字符 -> 0..9

# bytes 输入(b[i] 已经是 int,省掉 ord 调用)
idx = b[i] - 97
场景 写法
26 个字母计数 cnt = [0]*26; for ch in b: cnt[ch-97] += 1
大小写混合(52 种) ch - 65 if ch < 97 else ch - 97 + 26,或直接用 ch 当下标开 128 大小的数组
判是否字母 b'a' <= ch <= b'z'… ❌ 错,ch 是 int:写 97 <= ch <= 122
大小写转换 ch ^ 32(ASCII 里大小写只差第 5 位!)
字母表环形距离 min(d, 26 - d),其中 d = abs(x - y)

ch ^ 32 这个技巧'a' = 0b1100001,'A' = 0b1000001,只差 0b100000 = 32。 所以异或 32 就是翻转大小写| 32 是转小写,& ~32 是转大写。 见 46-位运算

字符计数:三种写法

from collections import Counter

# 写法一:Counter(最短,C 层循环)
c = Counter(s)

# 写法二:定长数组(值域已知时最快,省哈希)
cnt = [0] * 26
for ch in b:                      # b 是 bytes
    cnt[ch - 97] += 1

# 写法三:bytes.count(只查少数几个字符时最快,纯 C)
k = s.count('a')
需求 选哪个
要全部 26 个字母的频次 定长数组(比 Counter 快 2–3 倍)
字符集大/未知(Unicode) Counter
只关心一两个字符 s.count(x),纯 C 循环
要「出现次数最多的 k 个」 Counter(s).most_common(k)

70.4 分割与拼接的性能

操作 复杂度 是否 C 层 备注
s.split() \(O(n)\) 按任意空白切,顺带去掉换行
s.split(',') \(O(n)\) 指定分隔符
s.splitlines() \(O(n)\) 按行切
"".join(list_of_str) \(O(总长)\) 拼接的唯一正确写法
s + t $O( s +
s * k $O(k s )$
s.replace(a, b) \(O(n)\)
s.find(t) / t in s 平均近 \(O(n)\) 内部是 Crochemore-Perrin 算法,见 71 章
s[::-1] \(O(n)\) 反转
s.strip() \(O(n)\)

split() vs split(' '):一个经典坑

"a  b".split()        # ['a', 'b']       —— 连续空白算一个分隔符,且忽略首尾
"a  b".split(' ')     # ['a', '', 'b']   —— 空串也算一段!
"a\n".split()         # ['a']            —— 换行也被当空白处理
"a\n".split(' ')      # ['a\n']          —— 换行留在里面了!

竞赛里一律用无参数的 split(),除非分隔符真的是逗号之类的特定字符。 无参版还顺手帮你去掉了 \r(Windows 换行), 这在牛客上是实打实的救命细节。

join 的正确姿势

# ❌ 会 TypeError:join 只接受字符串序列
" ".join([1, 2, 3])

# ✅
" ".join(map(str, [1, 2, 3]))

# ✅ bytes 的 join(元素必须是 bytes)
b" ".join([b"a", b"b"])

map(str, ...)[str(x) for x in ...],因为 str 是 C 函数, map 的循环在 C 层。但如果里面是自己写的 Python 函数,列表推导反而更快 (见 21 章)。


70.5 竞赛常用操作速查

想做的事 写法
判回文 s == s[::-1]\(O(n)\),C 层,见 72 章
找子串位置 s.find(t),找不到返回 \(-1\)s.index(t) 找不到抛异常
找全部出现位置 while (i := s.find(t, i)) >= 0: ...; i += 1
判前缀/后缀 s.startswith(t) / s.endswith(t),可传元组
统计不重叠出现次数 s.count(t)不含重叠"aaa".count("aa") == 1
去重保序 "".join(dict.fromkeys(s))
排序字符 "".join(sorted(s))——判字母异位词的标准手法
循环移位 s[k:] + s[:k]
\(t\)\(s\) 的循环移位 len(s) == len(t) and t in s + s非常好用
大小写无关比较 s.casefold() == t.casefold()
补齐到固定长度 s.zfill(n)(补 0)、s.rjust(n, '0')f"{x:05d}"
反转每个单词 " ".join(w[::-1] for w in s.split())
字符映射替换 s.translate(str.maketrans("abc", "xyz"))C 层,比循环快很多

s + s 判循环移位是个高频技巧: \(t\)\(s\) 的循环移位 \(\iff\) \(t\)\(s+s\) 的子串(且长度相等)。 在 Python 里 in 是 C 实现,一行搞定;用 KMP 手写反而慢。


70.6 bytes vs str:决策表

判据 bytes str
数据量 \(\ge 10^6\) 字符
需要逐字符比较(KMP、Manacher、Trie) 明显更快
需要原地修改 ✅ 用 bytearray ❌ 不可变
只用整串操作(findcountin 都行 都行
需要格式化输出
有非 ASCII 字符
要当 dict 的键 都行 都行

混用时的三条规则

b = s.encode()          # str -> bytes
s = b.decode()          # bytes -> str
b"abc" == "abc"         # ❌ False!bytes 和 str 永远不相等

⚠️ bytesstr 直接比较永远是 False,而且不报错。 这是最阴险的 bug:读进来的是 bytes,你拿 "YES" 去比, 永远走不进那个分支,程序照跑不误,只是答案全错。 写代码时保持全程一种类型,只在输出时转一次。


70.7 例题

BISHI10 小红的字符串修改(简单)

\(|s|, |t| \le 10^3\),都由小写字母构成,\(|s| \le |t|\)。 每次可以把 \(s\) 的一个字母换成字母表中相邻的字母(az 也相邻)。 求最少替换多少次,使 \(s\) 成为 \(t\)连续子串。 题面见 BISHI10 原题(牛客)。 题解见 solutions/BISHI10.py(已用官方样例验证)。

04 章 从「字符串基础」的角度讲过这题,这里从性能角度再看一遍。

算法:枚举 \(s\)\(t\) 中的起始对齐位置(至多 \(|t|-|s|+1 \le 10^3\) 个), 每个位置逐字符累加代价。字母表是环形的,所以单字符代价是

\[\text{cost}(x, y) = \min\big(|x-y|,\ 26-|x-y|\big)\]

总量 \(10^3 \times 10^3 = 10^6\) 次基本运算。纯 Python 双重循环大约 1 秒, 时限「其他语言 2 秒」——能过但不宽裕。 把内层循环压到 C 层就稳了:

import sys
from operator import add

data = sys.stdin.buffer.read().split()
s = data[0].decode()
t = data[1].decode()
n, m = len(s), len(t)

# 扁平化的 26*26 代价表:cost[i * 26 + j] = 字母 i 变到 j 的最少次数
cost = [min(abs(i - j), 26 - abs(i - j)) for i in range(26) for j in range(26)]

base = [(ord(c) - 97) * 26 for c in s]     # s 每一位在代价表中的「行首偏移」
tc = [ord(c) - 97 for c in t]              # t 每一位的字母编号

# 枚举对齐起点;map(add, ...) 把「行首 + 列号」的下标计算放到 C 层,
# cost.__getitem__ 做查表,sum 做累加——内层一个 Python 字节码都没有
best = min(sum(map(cost.__getitem__, map(add, base, tc[off:off + n])))
           for off in range(m - n + 1))
print(best)

这段代码值得逐层拆开看——它是「让循环消失」的教科书示范:

做的事 在哪跑
tc[off:off+n] 取出 \(t\) 的对齐窗口 C
map(add, base, ...) 逐位算出 行首 + 列号 = 一维下标 C
map(cost.__getitem__, ...) 逐个查代价表 C
sum(...) 累加 C
min(... for off in ...) 只有这一层是 Python 循环,\(10^3\) Python

三个坑

  1. az 相邻(字母表环形),代价是 \(\min(d, 26-d)\), 不能直接用 abs(ord(x) - ord(y))。样例 2(zzzzzzxyzabc)就是在测这个: z→x 是 2,z→y 是 1,z→z 是 0,z→a 是 1,z→b 是 2,z→c 是 3, 合计 \(2+1+0+1+2+3 = 9\) ✓;
  2. 这里的「子串」是连续子串(题面说的是「从开头和结尾各删若干字符」), 不是子序列,所以只需枚举起点做定长对齐;
  3. 代价表扁平成一维cost[i*26+j])而不是二维 cost[i][j], 这样 __getitem__ 只查一次,配合 map(add, ...) 就能整条流水线走 C 层。

本题的通用套路:「两个序列逐位比较并求和」的循环, 在 Python 里可以用 sum(map(f, A, B)) 整个下沉到 C 层。 如果 f 是查表,就用 table.__getitem__f

BISHI123 环形字符串跃迁(中等)

长度 \(n \le 10^5\)01 环形字符串 \(S\)\(S_n\) 的下一个是 \(S_1\))。 光标在 \(S_i\) 时的跃迁规则:若 \(S_i\) 后方 \(m\) 个字符(不含 \(S_i\))中存在 0, 就跳到其中最远的那个 0;否则跳到 \(S_{i+1}\)\(q \le 10^5\) 次询问,每次给起点 \(t\) 和跃迁次数 \(k \le 10^{18}\),问最终位置。 题面见 BISHI123 原题(牛客)

ℹ️ 本题的 solutions/ 题解文件尚未编写。下面的代码已由 scripts/verify_docs.py官方样例实测通过,并在本地用暴力对拍 (300 组随机小数据)与 \(n = q = 10^5\) 的极限数据验证过,但未在牛客提交。

这题只有一半是字符串题,值得先把两半拆开:

部分 属于 难点
nxt[i](一次跃迁到哪) 本章:环形字符串 + 滑动窗口 环形边界、"最远的 0"
nxt\(k\) 次跃迁的结果 45-倍增 / 函数图 \(k \le 10^{18}\)

第一半:把环拉直。 处理环形字符串的标准手法是 \(T = S + S\)—— 这样窗口 \([i+1, i+m]\) 就一定落在 \(T\) 内(因为 \(m \le n\)\(i+m \le 2n-1\)), 完全不用写取模的边界判断

「最远的 0」= 窗口内下标最大的 0。所以只要预处理

\[\text{prevzero}[j] = \max\{\,j' \le j \mid T_{j'} = \texttt{0}\,\}\]

一次线性扫描就能得到,然后

# [片段] 核心两行:p 是窗口右端往左找到的第一个 0
p = prevzero[i + m]
nxt[i] = p % n if p > i else (i + 1) % n      # p <= i 说明窗口内没有 0

第二半:\(k \le 10^{18}\) 每个位置只有一个后继,这是一张函数图 (每点出度为 1)。求「第 \(k\) 个后继」的标准做法是倍增: 预处理 up[b][i] = 从 \(i\)\(2^b\) 步到达的位置,然后按 \(k\) 的二进制位逐位跳。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); m = int(data[1]); q = int(data[2])
    s = data[3]                                    # bytes,长度 n
    ZERO = 48                                      # ord('0')

    # ---- 一、字符串预处理:环拉直成 T = s + s,求每个位置左侧最近的 0 ----
    L = 2 * n
    prevz = [-1] * L
    last = -1
    for j in range(L):
        if s[j - n if j >= n else j] == ZERO:      # 不真的拼出 s+s,省一次拷贝
            last = j
        prevz[j] = last

    # ---- 二、一次跃迁:nxt[i] ----
    nxt = [0] * n
    for i in range(n):
        p = prevz[i + m]                           # 窗口 [i+1, i+m] 内最靠右的 0
        if p > i:
            nxt[i] = p - n if p >= n else p        # 手写取模,比 % 快
        else:
            nxt[i] = i + 1 if i + 1 < n else 0     # 窗口全是 1,走一步

    # ---- 三、倍增:up[b][i] = 从 i 跳 2^b 步到哪 ----
    LOG = 60                                       # 2^60 > 1e18
    up = [nxt]
    for _ in range(LOG - 1):
        c = up[-1]
        up.append([c[x] for x in c])               # ★ 列表推导,循环在 C 层

    # ---- 四、逐位回答 ----
    p = 4
    out = []
    for _ in range(q):
        t = int(data[p]) - 1; k = int(data[p + 1]); p += 2
        b = 0
        while k:
            if k & 1:
                t = up[b][t]
            k >>= 1
            b += 1
        out.append(t + 1)
    sys.stdout.write("\n".join(map(str, out)) + "\n")


main()

手动复核样例\(n=8, m=3\)\(S = \texttt{01110100}\),0-indexed \(S = [0,1,1,1,0,1,0,0]\)):

\(i\)(0-idx) 窗口(0-idx) 窗口内的 0 最远的 nxt[i] 1-idx
0 1,2,3 1 \(1 \to 2\)
1 2,3,4 4 4 4 \(2 \to 5\)
3 4,5,6 4,6 6 6 \(4 \to 7\)
6 7,8,9 7,8 8(即 0) 0 \(7 \to 1\)

询问 1:\(1 \to 2 \to 5\),输出 5 ✓ 询问 2:\(4 \to 7 \to 1 \to 2\),输出 2 ✓

Python 现实性评估\(n = q = 10^5\),时限「其他语言 2 秒」):

实测
prevz + nxt 预处理(\(3\times10^5\) 次循环) 约 0.1 s
倍增表 60 层 × \(10^5\) = \(6\times10^6\) 次列表推导 约 0.4 s
\(10^5\) 次询问 × 最多 60 位 约 0.3 s
总计 约 0.8 s

四个坑

  1. 「最远的 0」是下标最大的,不是最小的。读题时很容易看成「最近的」, 两者答案完全不同;
  2. 不要真的拼出 s + ss[j - n if j >= n else j] 一个条件表达式就够了, 省掉一次 \(10^5\) 字节的拷贝(虽然这点开销不大,但这个「虚拟拼接」的思路 在 \(n = 10^6\) 时就重要了);
  3. up.append([c[x] for x in c]) 这一行是整个倍增的性能关键。 写成 for i in range(n): up[b][i] = up[b-1][up[b-1][i]] 要慢 3 倍以上—— 列表推导 + 直接迭代 c 的元素,把 \(6\times10^6\) 次循环压到了 C 层;
  4. \(k\) 可以等于 0(题面写的是 \(0 \le k\)),此时直接输出起点。 上面的 while k: 天然处理了这种情况。

更快的做法:函数图上还有 \(O(n+q)\) 的写法—— 先找出所有环,把挂在环上的树离线 DFS 一遍, 用「根到当前点的路径栈」\(O(1)\) 回答「\(k\) 步以内还在树上」的询问, 超出树高的部分在环上取模。本地实测 0.2 s(比倍增快 4 倍), 但代码长 3 倍。倍增版已经能过,就不必上这个。 函数图的结构分析见 38-并查集 的 BISHI98。


70.8 本章速查

要点 结论
竞赛默认表示 bytessys.stdin.buffer.read().split()
b[i] int,不是字符!要 b[i] == ord('#')
b[i:j] bytes
bytes == str 永远 False 且不报错——最阴险的 bug
str 可变吗 ❌ 不可变,s[i] = 'x' 直接报错
循环拼接 \(O(n^2)\)一律改 "".join(...)
逐字符改 bytearray\(O(1)\) 单点改 + 支持切片赋值)
切片 是复制不是视图\(O(r-l)\);循环里切大片 = 平方级
构造带分隔符的串 bytearray + 切片步长赋值t[1::2] = s
split() vs split(' ') 一律用无参版,顺带处理 \r\n
join 元素必须是 str(或全 bytes),配 map(str, ...)
ASCII '0'=48,'A'=65,'a'=97
翻转大小写 ch ^ 32
26 字母计数 定长 listCounter 快 2–3 倍
只数一个字符 s.count(x)(纯 C)
count 重叠吗 不含重叠"aaa".count("aa") == 1
判回文 s == s[::-1](C 层 \(O(n)\)
判循环移位 len(s)==len(t) and t in s+s
判字母异位词 sorted(s) == sorted(t)
批量字符替换 str.translate + str.maketrans(C 层)
环形字符串 拉直成 \(S+S\),或用条件表达式虚拟拼接
「两序列逐位算再求和」 sum(map(f, A, B)),全程 C 层
症状 → 原因
字符串操作莫名超时
比较永远不相等
TypeError: 'str' object does not support item assignment
ord 比较 bytes 元素报错
多组数据里 split(' ') 出现空串