交替重复对是可以“重叠”统计

这道题到底在做什么

这道题表面上看像“字符串题”,但本质上是两步:

第一步,把一个字符矩阵按“列优先、从上到下、从左到右”读出来,拼成一个长字符串。
第二步,把这个长字符串里“夹在两个字母数字之间的连续符号”替换成一个空格。

所以你做题时,不要一上来就盯着正则。应该先把题目拆成两个动作:

  1. 先读出隐藏字符串
  2. 再按规则整理字符串

这是这题最关键的思考方式。


先把题目翻译成人话

题目给你一个由很多行字符串组成的“字符矩阵”。

比如样例是:

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 列

这就是这题的核心。


用样例手动走一遍

原矩阵可以看成:

行号内容
0Tsi
1h%x
2i #
3sM
4$a
5#t%
6ir!

现在按列读。

第 0 列

取每一行的第 0 个字符:

  • T
  • h
  • i
  • s
  • $
  • #
  • i

拼起来:

This$#i

第 1 列

取每一行的第 1 个字符:

  • s
  • %
  • M
  • a
  • t
  • r

拼起来:

s% Matr

第 2 列

取每一行的第 2 个字符:

  • i
  • x
  • #
  • %
  • !

拼起来:

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)]

这里的意思是:

  • 第一行输入两个整数 nm
  • 后面读入 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. 读取 nm

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-Z
  • a-z
  • 0-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

请你先手动写出:

  1. 按列读出来的原始字符串是什么
  2. 替换规则处理之后的最终字符串是什么

提示

先分三列读:

  • 第 0 列是什么
  • 第 1 列是什么
  • 第 2 列是什么

拼成完整字符串后,再观察:

哪些非字母数字字符,是刚好夹在两个字母数字之间的。

为什么这条正则要用 (?<=...)(?=...)

在上一题里,我们用到的正则是:

(?<=[A-Za-z0-9])[^A-Za-z0-9]+(?=[A-Za-z0-9])

很多初学者看到这里最困惑的地方,不是中间那一段 [^A-Za-z0-9]+,而是左右两边这两个写法:

(?<=...)
(?=...)

看起来它们好像也参与了匹配,但又好像没有真正“拿走”字符。这个感觉其实是对的。

这一节就专门讲清楚:

  1. 它们到底是干什么的
  2. 为什么它们只检查左右,不把左右字符也一起匹配进去
  3. 如果不用它们,会发生什么问题

先说结论:它们是“只检查、不吞字符”的条件

你可以先把它们记成一句最直白的话:

  • (?<=...):检查左边是不是满足某个条件
  • (?=...):检查右边是不是满足某个条件

重点在“检查”两个字。

它们不是来取字符的,而是来判断“这个位置周围对不对”。

所以这两个东西虽然写在正则里面,但它们本身不占用匹配内容,不会把字符包含进最终替换范围里。


先看中间真正要替换的部分是谁

在这条正则里:

(?<=[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

左右的 ab 不会消失。


例 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

还是:

@@

请你先自己判断原因。

提示

想清楚两件事:

  1. 哪一部分是真正“吃字符”的
  2. 左右两边那两个括号是不是只在检查条件

题目 2

为什么下面这个写法不适合这道题:

[A-Za-z0-9][^A-Za-z0-9]+[A-Za-z0-9]

提示

试着把字符串 A@@B 代进去,想想它真正匹配到的整段是什么。然后再想:re.sub() 会替换掉哪一部分。

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇