
这道题到底在做什么
这道题表面上看像“字符串题”,但本质上是两步:
第一步,把一个字符矩阵按“列优先、从上到下、从左到右”读出来,拼成一个长字符串。
第二步,把这个长字符串里“夹在两个字母数字之间的连续符号”替换成一个空格。
所以你做题时,不要一上来就盯着正则。应该先把题目拆成两个动作:
- 先读出隐藏字符串
- 再按规则整理字符串
这是这题最关键的思考方式。
先把题目翻译成人话
题目给你一个由很多行字符串组成的“字符矩阵”。
比如样例是:
7 3
Tsi
h%x
i #
sM
$a
#t%
ir!
它不是让你按平常那样一行一行读,而是:
- 先读第 0 列,从上到下
- 再读第 1 列,从上到下
- 再读第 2 列,从上到下
也就是按“列”读,不是按“行”读。
第一步怎么思考:如何把矩阵按列读出来
平常读法
平常我们更熟悉的是按行读:
for row in range(n):
for col in range(m):
...
这表示:
- 先固定第 0 行,把这一行读完
- 再读第 1 行
- 再读第 2 行
这叫“按行读”。
这题需要的读法
但这题要求按列读,所以顺序要反过来:
for col in range(m):
for row in range(n):
...
这表示:
- 先固定第 0 列,把这一列从上到下读完
- 再读第 1 列
- 再读第 2 列
这就是这题的核心。
用样例手动走一遍
原矩阵可以看成:
| 行号 | 内容 |
|---|---|
| 0 | Tsi |
| 1 | h%x |
| 2 | i # |
| 3 | sM |
| 4 | $a |
| 5 | #t% |
| 6 | ir! |
现在按列读。
第 0 列
取每一行的第 0 个字符:
This$#i
拼起来:
This$#i
第 1 列
取每一行的第 1 个字符:
s%Matr
拼起来:
s% Matr
第 2 列
取每一行的第 2 个字符:
ix#%!
拼起来:
ix# %!
三列连起来
最终得到:
This$#is% Matrix# %!
注意,这还不是最后答案。
第二步怎么思考:哪些东西要替换成空格
题目说:
如果两个字母数字字符之间夹着一些符号或空格,那么这些符号和空格要被替换成一个空格。
比如:
This$#is
中间 $# 两边是:
- 左边
s,是字母数字 - 右边
i,是字母数字
所以 $# 要被替换成一个空格,变成:
This is
再看:
Matrix# %!
这里 # % 在 x 和 %、! 附近要仔细看。最后真正需要替换的是那些“夹在两个字母数字之间”的连续非字母数字字符。
处理后结果是:
This is Matrix# %!
这里最后的 # %! 没被全部删掉,是因为它们后面不再满足“两边都是字母数字字符”的条件。
这题为什么适合用正则
因为第二步的规则本质上是在找一种“模式”:
“左边是字母数字”
“中间是一串不是字母数字的字符”
“右边还是字母数字”
这正是正则表达式擅长做的事。
也就是说,你先把整串读出来,再一次性替换掉满足这种模式的部分。
基本语法和做法
第一步:输入矩阵
n, m = map(int, input().split())
matrix = [input() for _ in range(n)]
这里的意思是:
- 第一行输入两个整数
n和m - 后面读入
n行字符串 - 每一行字符串长度都是
m
第二步:按列读取
decoded = ""
for col in range(m):
for row in range(n):
decoded += matrix[row][col]
这里的 matrix[row][col] 表示:
- 第
row行 - 第
col列 - 这个位置上的字符
第三步:用正则替换
import re
result = re.sub(r'(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])', ' ', decoded)
这个正则不用一下子全记住,但你要知道它在找什么。
这条正则到底是什么意思
(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])
我们拆开看。
1. (?<=[A-Za-z0-9])
意思是:
当前位置左边,必须紧挨着一个字母或数字。
它只是“检查左边”,自己不吃掉字符。
2. [^A-Za-z0-9]+
意思是:
匹配一段连续的“不是字母也不是数字”的字符。
这里可能包括:
- 空格
#%$!- 其他符号
+ 表示一个或多个。
3. (?=[A-Za-z0-9])
意思是:
当前位置右边,必须紧挨着一个字母或数字。
它也是只检查,不吃掉字符。
合起来的意思
整条规则的意思就是:
找到一段连续的非字母数字字符,并且它左边紧挨着字母数字,右边也紧挨着字母数字。
找到后,把这一整段替换成一个空格 ' '。
最小可运行代码
这是最直接、最适合初学者理解的写法:
import re
n, m = map(int, input().split())
matrix = [input() for _ in range(n)]
decoded = ""
for col in range(m):
for row in range(n):
decoded += matrix[row][col]
result = re.sub(r'(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])', ' ', decoded)
print(result)
从思路到代码
这题真正应该这样想。
第一步,先不要想正则
先问自己:
“题目让我按什么顺序读字符?”
答案是:
- 不是按行
- 而是按列
所以你脑子里先出现的是:
for col in range(m):
for row in range(n):
而不是先去想 re.sub()。
第二步,把读出来的字符拼成一个完整字符串
因为题目的替换规则,是对“整个解码后的字符串”做处理,不是对某一行单独处理。
所以你需要先得到:
decoded
而不是边读边判断。
第三步,再对完整字符串做“清洗”
题目说的是:
- 两个字母数字之间
- 如果夹着一串符号或空格
- 就把这一串变成一个空格
这说明第二步是“模式替换”,所以最自然就是 re.sub()。
执行过程完整走一遍
程序执行顺序是这样的:
1. 读取 n 和 m
n, m = map(int, input().split())
2. 读取矩阵每一行
matrix = [input() for _ in range(n)]
此时 matrix 是一个列表,里面每个元素都是一行字符串。
比如:
[
"Tsi",
"h%x",
"i #",
"sM ",
"$a ",
"#t%",
"ir!"
]
3. 双重循环按列取字符
for col in range(m):
for row in range(n):
decoded += matrix[row][col]
先取第 0 列全部,再取第 1 列全部,再取第 2 列全部。
4. 正则替换
result = re.sub(..., ' ', decoded)
把满足规则的那一段连续符号替换成一个空格。
5. 输出结果
print(result)
这题里最容易错的地方
1. 把矩阵按行读了,而不是按列读
很多人会写成:
for row in range(n):
for col in range(m):
这就错了,因为题目要的是“列优先”。
2. 以为所有符号都要删掉
不是所有符号都删。
只有“夹在两个字母数字之间”的那一段连续符号才替换成空格。
比如末尾的某些符号不一定动。
3. 用 \w 代替字母数字时不够严谨
有些人会写:
(?<=\w)\W+(?=\w)
这在很多场景也能过,但要注意:
\w不只是字母和数字- 它通常还包括下划线
_
而题目明确说 alphanumeric 只有:
A-Za-z0-9
所以更严谨的写法是:
[A-Za-z0-9]
4. 没有先 import re
正则相关函数在 re 模块里,不导入就不能用:
import re
还能不能不用正则
可以,但会更绕。
因为你得自己判断:
- 当前字符是不是字母数字
- 一段符号的左右两边是不是字母数字
- 要不要加空格
- 加几个空格
这会写出很多判断逻辑。
而题目还特别提示了“不需要 if 条件”,其实就是在暗示你:用正则最合适。
所以这题的重点不是“炫技”,而是学会识别:
“什么时候适合先拼字符串,再做模式替换。”
本节小结
这题的核心不是正则本身,而是先把问题拆开:
第一步,按列把字符读出来。
第二步,把“夹在两个字母数字之间的连续符号”替换成一个空格。
你可以把整题记成一句话:
先按列解码,再用正则清洗。
对应代码结构就是:
import re
n, m = map(int, input().split())
matrix = [input() for _ in range(n)]
decoded = ""
for col in range(m):
for row in range(n):
decoded += matrix[row][col]
print(re.sub(r'(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])', ' ', decoded))
练习
你可以自己做一个小练习,先不要看答案。
题目
假设输入是:
3 3
a$1
b#2
c 3
请你先手动写出:
- 按列读出来的原始字符串是什么
- 替换规则处理之后的最终字符串是什么
提示
先分三列读:
- 第 0 列是什么
- 第 1 列是什么
- 第 2 列是什么
拼成完整字符串后,再观察:
哪些非字母数字字符,是刚好夹在两个字母数字之间的。
为什么这条正则要用 (?<=...) 和 (?=...)
在上一题里,我们用到的正则是:
(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])
很多初学者看到这里最困惑的地方,不是中间那一段 [^A-Za-z0-9]+,而是左右两边这两个写法:
(?<=...)
(?=...)
看起来它们好像也参与了匹配,但又好像没有真正“拿走”字符。这个感觉其实是对的。
这一节就专门讲清楚:
- 它们到底是干什么的
- 为什么它们只检查左右,不把左右字符也一起匹配进去
- 如果不用它们,会发生什么问题
先说结论:它们是“只检查、不吞字符”的条件
你可以先把它们记成一句最直白的话:
(?<=...):检查左边是不是满足某个条件(?=...):检查右边是不是满足某个条件
重点在“检查”两个字。
它们不是来取字符的,而是来判断“这个位置周围对不对”。
所以这两个东西虽然写在正则里面,但它们本身不占用匹配内容,不会把字符包含进最终替换范围里。
先看中间真正要替换的部分是谁
在这条正则里:
(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])
真正要匹配、要替换的核心,其实是中间这段:
[^A-Za-z0-9]+
它表示:
“连续的一段非字母数字字符”。
比如:
$#
# %
@@@
这些都可能被它匹配到。
但是题目并不是说“所有符号都替换”,而是说:
只有当这段符号左边是字母数字、右边也是字母数字时,才替换成一个空格。
所以左右两边就需要加“条件判断”。
这时 (?<=...) 和 (?=...) 就派上用场了。
(?<=...) 是什么:向左看一眼
最直白理解
(?<=[A-Za-z0-9])
意思是:
“当前位置左边,紧挨着的那个字符,必须是字母或数字。”
注意,这句话里说的是“左边是”,不是“把左边这个字符也抓进来”。
它只是看一眼左边合不合格。
可以这样想象
假设字符串是:
This$#is
当正则扫描到 $ 这里时,它要判断这段 $# 能不能作为替换目标。
它会先看 $ 左边的字符是谁。
左边是:
s
s 是字母,符合 [A-Za-z0-9],所以左边条件通过。
但是这个 s 只是被“检查到了”,并没有被匹配进去。
(?=...) 是什么:向右看一眼
最直白理解
(?=[A-Za-z0-9])
意思是:
“当前位置右边,紧挨着的那个字符,必须是字母或数字。”
同样,它也只是检查右边,不把右边字符包含进匹配结果。
接着刚才的例子
还是:
This$#is
中间 $# 这段匹配完以后,正则还要检查这段后面紧挨着的是不是字母数字。
后面是:
i
i 是字母,所以右边条件也通过。
于是整段 $# 符合要求,可以被替换成一个空格。
为什么说它们“不吞字符”
这是最关键的一点。
正则里有两类东西,你可以先这样区分:
第一类:真正匹配字符的
比如:
a
\d
[A-Z]
[^A-Za-z0-9]+
这些都会真正去匹配字符串中的某些字符,匹配到的内容会进入“本次匹配结果”。
第二类:只判断位置条件的
比如:
^
$
\b
(?=...)
(?<=...)
这些不负责拿字符,只负责判断“这里是不是一个合适的位置”。
所以 (?<=...) 和 (?=...) 有一个很重要的名字,叫:
零宽断言
你现在不用死记这个术语,但要理解“零宽”这三个字:
- 它有作用
- 但它本身不占字符宽度
- 它不会把字符吃进去
“宽度”你可以暂时理解成“实际占了几个字符的位置”。
它占 0 个字符,所以叫零宽。
用一个更形象的比喻理解
你可以把整个匹配过程想成“抓取中间那段符号”。
而 (?<=...) 和 (?=...) 像两个门卫:
- 左边门卫检查:你前面是不是字母数字
- 右边门卫检查:你后面是不是字母数字
只有两个门卫都点头,中间这一段符号才能被放进去匹配结果里。
但是门卫自己不会跟着一起被抓进去。
所以最后替换的,只有中间那段符号,不包括左右两边的字母数字。
直接看一个完整例子
看这个字符串:
A$#B
我们用这条正则:
(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])
来匹配。
第一步:看中间谁可能匹配
中间 $# 符合:
[^A-Za-z0-9]+
因为它们都不是字母数字,而且是连续的一段。
第二步:检查左边
$ 左边是 A,是字母数字,左边通过。
第三步:检查右边
# 右边是 B,是字母数字,右边通过。
最终匹配结果
最终被匹配到的是:
$#
不是:
A$#B
这就是“只检查左右,但不把左右字符一起匹配进去”。
如果它们也把左右字符算进去,会出什么问题
如果左右字符也被匹配进去,那么替换时就会把原本应该保留的字母数字一起替换掉。
比如还是:
A$#B
如果整个 A$#B 都被匹配,然后替换成一个空格,那结果就变成:
这显然不对。
我们真正想要的是:
A B
也就是:
- 保留左边的
A - 保留右边的
B - 只把中间
$#换成一个空格
所以这里必须让左右字符“参与条件判断”,但“不参与替换内容”。
这正是 (?<=...) 和 (?=...) 最适合的地方。
为什么不能直接把左右写进普通匹配里
很多初学者会自然想到,能不能写成这种样子:
[A-Za-z0-9][^A-Za-z0-9]+[A-Za-z0-9]
这个写法表面上看也像是:
“左字母数字 + 中间符号 + 右字母数字”
但它和前面的断言写法,本质上不一样。
因为这个写法会把三部分都真正匹配进去。
也就是说,匹配结果是:
A$#B
而不是单独的:
$#
那你如果做替换:
re.sub(r'[A-Za-z0-9][^A-Za-z0-9]+[A-Za-z0-9]', ' ', s)
就会把左右字母数字也替换掉。
这不符合题意。
对比一下,两种写法到底差在哪里
写法一:普通匹配
[A-Za-z0-9][^A-Za-z0-9]+[A-Za-z0-9]
含义是:
- 左边字母数字也要匹配
- 中间符号也要匹配
- 右边字母数字也要匹配
最终整段都进入匹配结果。
写法二:断言 + 普通匹配
(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])
含义是:
- 左边字母数字只负责检查
- 中间符号才是真正要匹配的内容
- 右边字母数字也只负责检查
最终只有中间符号进入匹配结果。
所以第二种才是这题真正需要的。
你可以把它理解成“锚定中间,不吞两边”
这句话很适合记忆:
用前后断言,是为了锚定中间那一段的上下文,但不吞掉上下文字符。
这里的“上下文”就是:
- 左边是什么
- 右边是什么
而我们真正关心、真正要改掉的,是中间那段符号。
再看几个小例子
例 1
字符串:
a#$b
匹配到的是:
#$
替换后:
a b
左右的 a 和 b 不会消失。
例 2
字符串:
#$ab
前面的 #$ 左边没有字母数字,所以不匹配。
也就是说,不能只看中间是符号,还得看左右条件。
例 3
字符串:
ab#$
后面的 #$ 右边没有字母数字,所以也不匹配。
例 4
字符串:
ab#$cd
这里 #$ 左右都是字母数字,所以会匹配并替换成空格:
ab cd
这和“位置”有什么关系
你可以把正则匹配想成这样:
它不是只会盯着字符看,它也会盯着“字符之间的位置”看。
比如字符串:
abc
其实可以想象成有很多位置:
|a|b|c|
(?<=...) 和 (?=...) 经常就是在这些“位置”上做判断:
- 这个位置左边是什么
- 这个位置右边是什么
所以它们才不需要真的把字符拿进来,它们只是在某个位置上检查周围环境。
这个角度一旦理解了,你就会更容易明白“为什么断言不吞字符”。
在这道题里,替换时到底替换了什么
还是那条代码:
import re
result = re.sub(r'(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])', ' ', decoded)
re.sub() 会把“匹配到的内容”替换成空格。
而这条正则里,真正匹配到的内容只有:
[^A-Za-z0-9]+
左右两个断言只是提供匹配条件。
所以最后被换掉的只是中间那段符号。
这就是为什么结果刚好符合题意。
初学者最容易混淆的点
第一种误解:我都写进正则里了,为什么它不算匹配内容
因为正则里面不是所有部分都代表“拿字符”。
有些是“拿字符”,有些是“判断条件”。
(?<=...) 和 (?=...) 就属于后者。
第二种误解:既然它检查了左边右边,为什么替换时不把左右也一起改掉
因为替换函数只替换“真正匹配到的部分”。
断言只是附加条件,不属于最终匹配文本。
第三种误解:那它到底有没有作用
当然有作用,而且作用很大。
如果没有这两个断言,正则就只会找到“任意连续符号”,那题目要求就不准确了。
它们的作用不是“提供字符”,而是“限制匹配范围”。
本节小结
这一节最重要的不是背术语,而是抓住下面这句话:
(?<=...) 和 (?=...) 是在检查中间这段字符的左右环境,而不是把左右字符一起拿来替换。
所以在这道题里:
(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])
可以理解成:
- 左边先检查:前面得是字母数字
- 中间再匹配:抓一段连续符号
- 右边再检查:后面也得是字母数字
最终替换掉的,只有中间这段连续符号。
你把它记成一句更短的话也可以:
前后断言只看两边,中间表达式才是真正被替换的内容。
练习
你可以自己先想,不要急着看答案。
题目 1
字符串是:
A@@B
用这条正则:
(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])
匹配到的到底是:
A@@B
还是:
@@
请你先自己判断原因。
提示
想清楚两件事:
- 哪一部分是真正“吃字符”的
- 左右两边那两个括号是不是只在检查条件
题目 2
为什么下面这个写法不适合这道题:
[A-Za-z0-9][^A-Za-z0-9]+[A-Za-z0-9]
提示
试着把字符串 A@@B 代进去,想想它真正匹配到的整段是什么。然后再想:re.sub() 会替换掉哪一部分。



