跳转至

第 70 章 字符串处理

本章配套 9 道例题,见 §例题。 前置:字符串、复杂度与Python性能

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

核心只有三句话:

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

1 三种表示:str、bytes、list

表示 元素类型 可变 单元素内存 典型来源
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。

但切片仍然给 bytes:b[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 其实也有 split、strip、find、replace、startswith、 count、join 等绝大多数方法,竞赛需要的基本都有。


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

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

场景一:循环拼接

# ❌ 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")

见 输入输出处理。

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 算法构造分隔串用的正是这一招(回文), 比 '#' + '#'.join(s) + '#' 快好几倍。


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 是转大写。 见 位运算。

字符计数:三种写法

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)

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 算法,见 字符串匹配 KMP
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 函数,列表推导反而更快 (见 复杂度与 Python 性能)。


5 竞赛常用操作速查

想做的事 写法
判回文 s == s[::-1](\(O(n)\),C 层,见 回文)
找子串位置 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 手写反而慢。


6 bytes vs str:决策表

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

混用时的三条规则:

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

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


7 例题

题号 题名 难度 题解
BISHI10 小红的字符串修改 简单 题解
BISHI123 环形字符串跃迁 中等 题解
BM22 比较版本号 中等 题解
BM74 数字字符串转化成IP地址 中等 题解
BM83 字符串变形 简单 题解
BM84 最长公共前缀 简单 题解
BM85 验证IP地址 中等 题解
BM88 判断是否为回文字符串 入门 题解
BM91 反转字符串 入门 题解

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

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

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

算法:枚举 \(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. a 和 z 相邻(字母表环形),代价是 \(\min(d, 26-d)\), 不能直接用 abs(ord(x) - ord(y))。样例 2(zzzzzz → xyzabc)就是在测这个: 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}\),问最终位置。 题面见 原题。

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

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

部分 属于 难点
求 nxt[i](一次跃迁到哪) 本章:环形字符串 + 滑动窗口 环形边界、"最远的 0"
从 nxt 求 \(k\) 次跃迁的结果 倍增 / 函数图 \(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 + s:s[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 倍。倍增版已经能过,就不必上这个。 函数图的结构分析见 并查集 的 BISHI98。


面试题单里的七道字符串题

这七道题几乎全部落在 §5 那张速查表上——在 Python 里,字符串题的第一反应 应该是「标准库里有没有现成的」,而不是「怎么手写循环」。

一行就能做完的三道。 反转字符串是 s[::-1]; 判断是否为回文字符串是 s == s[::-1]; 最长公共前缀可以用 os.path.commonprefix, 但更稳的写法是取最短串逐位比对,或者利用「排序后只需比首尾两串」这一性质—— 排完序后,字典序最小与最大的两个串的公共前缀就是全体的公共前缀。

格式解析类的三道,坑都在题面细节里,不在算法上。 字符串变形要求反转单词顺序并翻转每个字母的大小写, s.swapcase() 加 " ".join(reversed(s.split(" "))) 即可; 注意必须用 split(" ") 而不是 split()——后者会把连续空格吞成一个, 而这题要求保留原有的空格数量。 验证IP地址要分辨 IPv4、IPv6 与非法三种情况, 规则琐碎(IPv4 每段 \(0\)–\(255\) 且不允许前导零,IPv6 每段 1–4 位十六进制), 适合逐条写判断而不是拼一个大正则——正则写错了很难调。 数字字符串转化成IP地址是它的反面:给一串数字,枚举三个分割点还原出全部合法 IP。 分割点只有 \(\binom{n-1}{3}\) 种,直接三重循环即可, 剪枝条件是每段长度 1–3 且数值不超过 255 且无前导零。

比较版本号的要点是不能按字符串比也不能整串转数字: 按点分段之后逐段转成整数再比,段数不等时短的一方补零 (1.0.0 与 1 是同一个版本)。段内数值可能极大, 但 Python 的整数没有上限,见 高精度与大整数,这一层不用特殊处理。

8 本章速查

要点 结论
竞赛默认表示 bytes(sys.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 字母计数 定长 list 比 Counter 快 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(' ') 出现空串