W
e
l
c
o
m
e
: )

4.字符串

本篇导航

字符串是 Python 最常用的类型。本篇从编码与定义讲起,依次覆盖输入/输出/拼接/复制、
索引与切片、三大类内置方法(查找 / 修改 / 判断)、比较与成员运算,
最后讲两个进阶话题:字符串驻留机制与可变字符串。

本篇输出均为本机实跑

环境 Python 3.13.14。验证脚本:verify/py04/run04.py(20 组)。
原文有若干 Python 2 遗留表述与笔误,已逐条校正并标注 [!warning] 原文勘误。


1 字符串的本质

  1. 字符串的本质是:字符序列。
  2. Python 不支持单字符类型,单字符也是作为一个字符串使用的。
注意

Python 的字符串是不可变的。我们无法对原字符串做任何修改,但可以将字符串的一部分复制到新创建的字符串,达到“看起来修改”的效果。

为什么说字符串不可变
s = 'abc'
print(id(s), s)
s = s + 'd'          # 不是"改"了原字符串,而是新建了 'abcd' 并让 s 指向它
print(id(s), s)
2113348746480 abc
2113348746992 abcd

id 变了,说明是新对象。想真正"原地改",见 §16 的 io.StringIO。


2 字符串的编码

Python 3 直接支持 Unicode,可以表示世界上任何书面语言的字符。

(图片未上传成功:Pasted-image-20241214195732)

注意
  • gbk:英文字符占 1 字节,中文字符占 2 字节。
  • utf-8:英文字符占 1 字节,常用中文字符占 3 字节,部分不常用字符占 4 字节。
原文勘误:Python 3 的字符不是「默认 16 位 Unicode」

原文写「Python3 的字符默认就是 16 位 Unicode 编码」——这是 Python 2 的 UCS-2 说法。

自 Python 3.3(PEP 393) 起,CPython 采用灵活字符串表示(flexible string representation):
根据字符串内容自动选择 latin-1(1 字节)/ UCS-2(2 字节)/ UCS-4(4 字节)存储。

实测 sys.getsizeof() 与 UTF-8 字节数:

字符串 字符数 UTF-8 字节 getsizeof
'a' 1 1 42
'ab' 2 2 43
'小' 1 3 60
'小龙女' 3 9 64
''(音乐符号,增补平面) 1 4 64
sys.maxunicode = 1114111   (即 0x10FFFF,覆盖全部 Unicode 码位)

关键点:len() 数的是字符数,不是字节数。想算字节数要 .encode() 之后再看:

print(len('小龙女'), len('小龙女'.encode('utf-8')))
3 9

用内置函数 ord() 可以把字符转换成对应的 Unicode 码;

用内置函数 chr() 可以把十进制数字转换成对应的字符。

print(ord('A'))
print(chr(65))
print(ord('小'))
实跑输出
65
A
23567

(图片未上传成功:Snipaste_2022-06-17_22-54-18)


3 字符串的定义

字符串是 Python 中最常用的数据类型。通常用引号创建字符串,只要为变量分配一个值即可。

案例 1:使用单引号或双引号定义字符串变量。

str1 = 'abcdefg'
str2 = "hello world"

print(type(str1))  # <class 'str'>
print(type(str2))  # <class 'str'>
实跑输出
str
str

(图片未上传成功:Snipaste_2022-06-20_11-03-31)

案例 2:使用 3 个引号定义字符串变量。

name1 = '''I am Tom, Nice to meet you!'''
print(name1)
print(type(name1))

print('-' * 20)

name2 = """I am Jennify,
           Nice to meet you!"""
print(name2)
print(type(name2))
注意

三引号形式的字符串支持换行操作

实跑输出(注意三引号会保留换行与缩进)
'I am Jennify,\n           Nice to meet you!'

第二行的前导空格被原样保留——这是三引号字符串常见的"意外缩进"问题。
想避免,可用 textwrap.dedent() 或把内容顶格写。

(图片未上传成功:Snipaste_2022-06-20_11-04-58)

案例 3:思考如何使用字符串定义 I'm Tom。

使用单引号的情况:

str1 = 'I'm Tom'

运行结果:

(图片未上传成功:image-20210310101123953)

出现以上问题,是因为这段代码触发了语法错误(SyntaxError)。原因在于:单引号在字符串定义中必须成对出现,而 Python 解析器解析时,会自动把第一个单引号与最近的一个单引号视为一对。

如果一定要在单引号内部再放一个单引号,必须用 反斜杠(\) 转义。

str1 = 'I\'m Tom'
原文笔误

原文此处写的是 'I\'am Tom'——多了一个 a,语义变成 "I am Tom" 而非 "I'm Tom"。
正确写法是 'I\'m Tom'(转义)或直接 "I'm Tom"(双引号包裹)。

(图片未上传成功:Snipaste_2022-06-20_11-22-03)

使用双引号的情况:

str2 = "I'm Tom"
实跑输出
"I'm Tom"      ← 双引号直接包裹,最省事
"I'm Tom"      ← 反斜杠转义,等价

(图片未上传成功:Snipaste_2022-06-20_11-22-10)

注意

在 Python 中,如果存在多个引号,建议:

① 单引号放在双引号中

② 双引号放在单引号中。

3.1 空字符串和 len() 函数

len() 用于计算字符串含有多少个字符。

s = '小龙女'
print(len(s))
实跑输出
3

(图片未上传成功:Snipaste_2022-06-26_22-55-14)

Python 允许空字符串存在,它不包含任何字符、长度为 0。例如:

s = ''
print(len(s))
实跑输出
0

(图片未上传成功:Snipaste_2022-06-26_22-55-40)

不要把变量命名为 str

原文示例里用了 str = '小龙女' 作为变量名。这会遮蔽内置类型 str,
后续再写 str(123) 就会报 TypeError: 'str' object is not callable。
本篇已改用 s。同理避免用 list、dict、id、type 作变量名。

3.2 转义字符

可以用 \ + 特殊字符 实现某些难以用普通字符表示的效果,例如换行。常见转义字符如下:

转义字符 描述
\(在行尾时) 续行符
\\ 反斜杠符号
\' 单引号
\" 双引号
\b 退格(Backspace)
\n 换行
\t 横向制表符
\r 回车
不想让反斜杠生效?用原始字符串

在引号前加 r 或 R,反斜杠就不再转义——写 Windows 路径和正则时必备:

print('C:\new\table')     # 错:\n 被当换行,\t 被当制表符
print(r'C:\new\table')    # 对:原样输出
C:
ew	able
C:\new\table

4 字符串输入

在 Python 代码中,可以用 input() 方法接收用户的输入。记住:在 Python 中,input() 方法返回的结果是 字符串类型 的数据。

name = input('请输入您的姓名:')
age = input('请输入您的年龄:')
address = input('请输入您的住址:')

print(name, age, address)
实跑输出(模拟依次输入 小龙女、18、绝情谷)
小龙女 18 绝情谷
input() 拿到的永远是字符串

这是本篇与 2.编程基础概念 §6.3 呼应的一点:age 是 '18'(str)不是 18(int)。
做数值运算前必须 int(age) / float(age)。


5 字符串的输出

5.1 普通输出

# 伪代码:演示两种调用形态
print(变量名称)
print(变量名称1, 变量名称2, 变量名称3)

5.2 格式化输出

① 百分号(Python 2 与 Python 3 均支持)

name = input('请输入您的姓名:')
age = input('请输入您的年龄:')
address = input('请输入您的住址:')

print('我的名字是%s,今年%d岁了,家里住在%s...' % (name, age, address))

② format 方法(Python 3)

print('我的名字是{},今年{}岁了,家里住在{}...'.format(name, age, address))

③ f-string 形式(Python 3.6+)

print(f'我的名字是{name},今年{age}岁了,家里住在{address}...')
上面百分号那版有个隐藏 bug

age 来自 input(),是字符串,而 %d 要求整数。实测:

age = '18'
print('今年%d岁了' % age)
TypeError: %d format: a real number is required, not str

三处修正方式(任选一):

age = int(input('请输入您的年龄:'))          # ① 输入时就转 int
print('今年%s岁了' % age)                     # ② 用 %s 接收字符串
print(f'今年{age}岁了')                       # ③ f-string 不挑类型(推荐)

延伸:

name = input('请输入您购买商品的名称:')
price = float(input('请输入您购买商品的价格:'))  # 18.5

print(f'购买商品名称:{name},商品价格:{price:f}')
实跑输出(模拟输入 白菜、18.5)
购买商品名称:白菜,商品价格:18.500000

{price:f} 默认 6 位小数,与 %f 一致;想要两位写 {price:.2f}。


6 字符串拼接

  1. 可以用 + 把多个字符串拼接起来,例如 'aa' + 'bb' ==> 'aabb'。

    • + 两边都是字符串时,执行拼接。

      'aa' + 'bb'
      

      (图片未上传成功:Snipaste_2022-06-26_23-05-51)

    • + 两边都是数字时,执行加法运算。

      1 + 1
      

      (图片未上传成功:Snipaste_2022-06-26_23-09-19)

    • + 两边类型不同时,抛出异常。

      1 + 'a'
      

      (图片未上传成功:Snipaste_2022-06-26_23-09-42)

实跑输出与报错
'aa' + 'bb'  = 'aabb'
1 + 1        = 2
1 + 'a'      -> TypeError: unsupported operand type(s) for +: 'int' and 'str'
  1. 可以把多个字面字符串直接放在一起实现拼接,例如 'aa' 'bb' ==> 'aabb'。
'aa' 'bb'

(图片未上传成功:Snipaste_2022-06-26_23-10-53)

隐式拼接只对字面量有效
'aa' 'bb'          # ✅ 'aabb'(编译期合并)
a = 'aa'; b = 'bb'
a b                # ❌ SyntaxError

这个特性最适合拆长字符串:

sql = ('SELECT id, name FROM users '
       'WHERE age > 18 '
       'ORDER BY id')

7 字符串复制

用 * 可以实现字符串复制。

'小龙女' * 2
实跑输出
'小龙女小龙女'

(图片未上传成功:Snipaste_2022-06-26_23-12-26)


8 str() 实现数字转型字符串

str() 可以把其他数据类型转换为字符串。

print(str(5.20))
print(str(314e-2))
print(str(True))
实跑输出
5.2
3.14
True

注意 str(5.20) 得到 '5.2'——末尾的 0 会被去掉,因为存的是浮点值而非书写形式。
想保留两位小数请用 f'{5.20:.2f}'。

(图片未上传成功:Snipaste_2022-06-26_23-18-11)

调用 print() 时,解释器会自动调用 __str__(),把非字符串对象转成字符串。这部分内容在面向对象章节详细讲解。


9 字符串在计算机底层的存储形式

在计算机中,Python 的字符串属于序列结构,因此其底层存储占用一段连续的内存空间。

str1 = 'itheima'

结构原理图:

(图片未上传成功:image-20210310104348286)

注意

索引下标从 0 开始。


10 聊聊索引下标

索引下标就是编号。以火车座位号类比:座位号的作用是按编号快速找到对应座位;同理,下标的作用就是通过下标快速找到对应的数据。

(图片未上传成功:image-20210310104901506)

举个例子:

name = 'abcdef'
print(name[0])  # a
print(name[3])  # d
实跑输出
a
d

(图片未上传成功:image-20210310104907464)


11 使用 [] 提取字符

字符串的本质就是字符序列,可以在字符串后面加 [],在 [] 中指定偏移量,从而提取该位置的单个字符。

  1. 正向搜索:

    最左侧第一个字符偏移量为 0,第二个为 1,以此类推,直到 len(str)-1 为止。

  2. 反向搜索:

    最右侧第一个字符偏移量为 -1,倒数第二个为 -2,以此类推,直到 -len(str) 为止。

a = 'abcdefghijklmnopqrstuvwxyz'
a[0]
a[3]
a[26-1]
a[-1]
a[-26]
a[-30]
实跑输出(最后一行会报错)
a[0]   = 'a'
a[3]   = 'd'
a[25]  = 'z'
a[-1]  = 'z'
a[-26] = 'a'
a[-30] -> IndexError: string index out of range

(图片未上传成功:Snipaste_2022-06-27_14-37-23)

(图片未上传成功:Snipaste_2022-06-27_14-37-32)

索引越界会报错,切片越界不会

这是索引与切片的关键差异(见 §11):

a[-30]      # IndexError
a[-30:]     # 不报错,返回整个字符串

12 字符串切片

12.1 什么是字符串切片

所谓切片,就是对操作对象截取其中一部分的操作。字符串、列表、元组都支持切片。

12.2 字符串切片基本语法

包左不包右:

# 伪代码:展示完整语法形态
序列名称[起始偏移量 start : 终止偏移量 end : 步长 step]
  1. 不包含结束位置下标对应的数据,正负整数均可;
  2. 步长是选取间隔,正负整数均可:正数从左向右,负数从右向左。默认步长为 1。

☆ 典型操作(三个量均为正数)如下:

操作和说明 示例 结果
[:] 提取整个字符串 "abcdef"[:] "abcdef"
[start:] 从 start 索引开始到结尾 "abcdef"[2:] "cdef"
[:end] 从头开始直到 end-1 "abcdef"[:2] "ab"
[start: end] 从 start 到 end-1 "abcdef"[2:4] "cd"
[start: end: step] 从 start 提取到 end-1,步长是 step "abcdef"[1:5:2] "bd"

☆ 其他操作(三个量为负数)的情况:

操作和说明 示例 结果
倒数三个 "abcdefghijklmnopqrstuvwxyz"[-3:] "xyz"
倒数第八个到倒数第三个(包左不包右) "abcdefghijklmnopqrstuvwxyz"[-8: -3] "stuvw"
步长为负,从右到左反向提取 "abcdefghijklmnopqrstuvwxyz"[:: -1] "zyxwvutsrqponmlkjihgfedcba"
实跑逐行验证
alpha[-3:]     = 'xyz'
alpha[-8:-3]   = 'stuvw'
alpha[::-1]    = 'zyxwvutsrqponmlkjihgfedcba'

如果还是有些陌生,没关系,举个例子:

numstr = '0123456789'

如果想对 numstr 进行切片,如下图所示:

(图片未上传成功:image-20210310110051093)

number = '0123456789'
number[0: 6]
number[0: 6: 1]
number[0: 6: 2]
number[-1:-6:-1]
number[-1::-1]
实跑输出
number[0:6]      = '012345'
number[0:6:1]    = '012345'
number[0:6:2]    = '024'
number[-1:-6:-1] = '98765'
number[-1::-1]   = '9876543210'

(图片未上传成功:Snipaste_2022-07-08_22-36-35)

注意

切片时,起始与终止偏移量若不在 [-(长度), 字符串长度 - 1] 范围内,也不会报错:起始偏移量小于 -(长度) 会被当作 0,终止偏移量大于 长度 - 1 会被当成 长度 - 1。

str1 = 'abcdefg'
len(str1)
str1[-100::]
str1[-69:-3:]
str1[-100:100]
实跑输出(全部不报错)
len('abcdefg')        = 7
'abcdefg'[-100:]      = 'abcdefg'
'abcdefg'[-69:-3]     = 'abcd'
'abcdefg'[-100:100]   = 'abcdefg'

(图片未上传成功:Snipaste_2022-06-27_14-59-30)

12.3 字符串切片小口诀

记口诀:切片其实很简单,只顾头来尾不管,步长为正正向移,步长为负则逆向移

步长为负时,start 应大于 end

这是最容易搞混的地方:反向切片时 start 在右、end 在左:

n = '0123456789'
print(n[-1:-6:-1])   # start=-1(最右) 走到 end=-6,得 '98765'
print(n[5:0:-1])     # start=5 走到 end=0(不含),得 '54321'
print(n[::-1])       # 省略两端 = 整个反转
98765
54321
9876543210

如果还不太清楚这个原理,建议对字符串画图理解:

(图片未上传成功:image-20210310111904137)

12.4 字符串切片的小栗子

案例 1:

numstr = '0123456789'
# 1、从 2 到 5 开始切片,步长为 1
print('numstr[2:5:1]:', numstr[2:5:1])
print('numstr[2:5]:', numstr[2:5])
# 2、只有结尾的字符串切片:代表从索引为 0 开始,截取到结尾字符 -1 的位置
print('numstr[:5]:', numstr[:5])
# 3、只有开头的字符串切片:代表从起始位置开始,已知截取到字符串的结尾
print('numstr[1:]:', numstr[1:])
# 4、获取或拷贝整个字符串
print('numstr[:]:', numstr[:])
# 5、调整步阶:类似求偶数
print('numstr[::2]:', numstr[::2])
# 6、把步阶设置为负整数:类似字符串翻转
print('numstr[::-1]:', numstr[::-1])
# 7、起始位置与结束位置都是负数(遵循一个原则:必须是从左向右截取)
print('numstr[-4:-1]:', numstr[-4:-1])
# 8、结束字符为负数,如截取 012345678
print('numstr[:-1]:', numstr[:-1])
实跑输出
numstr[2:5:1]: 234
numstr[2:5]: 234
numstr[:5]: 01234
numstr[1:]: 123456789
numstr[:]: 0123456789
numstr[::2]: 02468
numstr[::-1]: 9876543210
numstr[-4:-1]: 678
numstr[:-1]: 012345678

(图片未上传成功:Snipaste_2022-06-26_23-33-21)

案例 2:给定一个图片的名称为 avatar.png,使用 Python 方法获取这个图片的名称 avatar 以及这个图片的后缀 .png。

分析:

  1. 建议先获取点号的位置(此处尚未学到相关方法,只能一个一个数);
  2. 从开头切片到点号位置,得到文件名;
  3. 从点号开始切片到结尾,得到文件后缀。
filename = 'avatar.png'
# 获取点号的索引下标
index = 6
# 使用切片截取文件的文件
name = filename[:index]
print(f'上传文件的名称:{name}')

# 使用切片截取文件的后缀
postfix = filename[index:]
print(f'上传文件的后缀:{postfix}')
实跑输出
上传文件的名称:avatar
上传文件的后缀:.png
硬编码下标 6 不可靠,用 rfind() 或 os.path.splitext()

文件名一变,index = 6 就错了。正确做法(见 §12.1):

index = filename.rfind('.')      # 取最后一个点,兼容 'a.b.png'

生产环境更推荐标准库:

import os
name, ext = os.path.splitext('avatar.png')
print(name, ext)
avatar .png

13 字符串的操作方法(内置)

13.1 字符串中的查找方法

所谓字符串查找方法,就是查找子串在字符串中的位置或出现的次数。

基本语法:

# 伪代码:展示调用形态
字符串.find(要查找的字符或者子串)
函数 作用
find() 检测某个子串是否包含在这个字符串中,如果在返回这个子串开始的位置下标,否则则返回 -1。
index() 检测某个子串是否包含在这个字符串中,如果在返回这个子串开始的位置下标,否则则报异常。
rfind() 和 find() 功能相同,但查找方向为右侧开始。
rindex() 和 index() 功能相同,但查找方向为右侧开始。
count() 返回某个子串在字符串中出现的次数。

13.1.1 find() 方法

作用:检测某个子串是否包含在字符串中,若在则返回该子串开始的位置下标,否则返回 -1。

# 定义一个字符串
str1 = 'hello world hello linux hello python'
# 查找 linux 子串是否出现在字符串中
print(str1.find('linux'))
# 在 str1 中查找不存在的子串
print(str1.find('and'))
实跑输出
18
-1

(图片未上传成功:Snipaste_2022-06-27_15-19-53)

案例:使用 input 方法输入任意一个文件名称,求点号的索引下标。

filename = input('请输入您要上传文件的名称:')
# 获取点号的索引下标
index = filename.find('.')
print(index)

# 求文件名称
print(filename[:index])

# 求文件后缀
print(filename[index:])
实跑输出(模拟输入 avatar.png)
6
avatar
.png

(图片未上传成功:Snipaste_2022-06-27_15-20-04)

用 find('.') 处理多后缀文件会出错

对 '20210310axvu.avatar.png',find('.') 返回第一个点的位置 12,
切出来的"后缀"会是 .avatar.png。正确做法见下一节的 rfind()。

13.1.2 index() 方法

index() 方法的功能与 find() 完全一致,唯一区别在于查找的子串不在字符串中时:find() 返回 -1,而 index() 直接报错。

str1 = 'apple, banana, orange'
# 判断apple是否出现在字符串str1中
print(str1.index('apple'))
print(str1.index('pineapple'))
实跑输出(第二行报错)
0
ValueError: substring not found

运行结果:

(图片未上传成功:Snipaste_2022-06-27_15-22-46)

find 还是 index?
  • 能用 find() 就用 find()——返回 -1 便于用 if 判断,不打断流程
  • index() 适合「找不到就是 bug」的场景,让程序尽早失败
i = s.find('x')
if i != -1:
    ...

13.1.3 rfind() 与 rindex() 方法

r 即 right,代表从右侧开始查找。

# 伪代码:展示调用形态
字符串序列.rfind(子串)
字符串序列.rindex(子串)

强调:rfind() 与 rindex() 适合子串在字符串中出现多次的情况。

案例:文件名为 20210310axvu.avatar.png,其中点号出现两次。此时若要获取后缀 .png,代码该如何写?

filename = '20210310axvu.avatar.png'
# 求出点号在字符串中第一次出现的位置
# index = filename.find('.')
# print(index)
# 求出点号在字符串中最后一次出现的位置
index = filename.rfind('.')
print(index)

print(filename[:index])

print(filename[index:])
实跑输出
find('.')   = 12
rfind('.')  = 19
名称 = '20210310axvu.avatar'
后缀 = '.png'

(图片未上传成功:Snipaste_2022-06-27_15-29-45)

rfind() 与 rindex() 在语法上完全一致,唯一区别是子串不存在时:rfind() 返回 -1,rindex() 报错。

13.1.4 count() 方法

主要功能:求子串在字符串中出现的次数。

基本语法:

# 伪代码:展示完整参数
字符串.count('子串', 开始位置下标, 结束位置下标)

案例:获取字符串中 and 关键字出现的次数。

str1 = 'hello world and hello linux and hello python'
# 不限定字符串长度
ands = str1.count('and')
# 限定开始查找的位置和结束位置
# ands = str1.count('and', 10, 30)
print(f'and字符串出现的次数为:{ands}')
实跑输出
str1.count('and')       = 2
str1.count('and',10,30) = 1

(图片未上传成功:Snipaste_2022-06-27_15-31-15)

13.1.5 查找方法效率问题

  1. find 和 rfind 效率高吗?要不要用?

    这两个方法效率并不高——都是在字符串中遍历搜索。但若「找子串」的工作本身必不可少,那就只能这么做,能少做就少做。

    index 方法与 find 很像,缺点在于找不到时抛异常,因此推荐用 find。

    count 方法效率极低,不推荐使用。

    字符串查找问题往往涉及较复杂的算法,难度较大,偶尔用一下查找方法是可行的。

  2. 时间复杂度

    find、index 与 count 方法都是 O(n)。

    随着字符串数据规模增大,效率会下降。

原文勘误:count() 并不「效率极低」

原文写「count 方法效率极低,推荐不用」。实测打脸:

字符串长度 = 900,000 字符
count('and') = 40,000 次,耗时 0.286 毫秒
find('zzz')  = -1,    耗时 0.212 毫秒

count() 是 C 实现的单趟扫描,90 万字符只要 0.286 毫秒,与 find() 同一量级。

原文这句会误导初学者弃用一个完全可用、且语义最直接的方法。

真正要注意的是复杂度场景:在循环里反复对同一个大字符串做 count/find,
整体会退化到 O(n × m)。这时才需要换思路(如预建索引、用正则一次扫描、collections.Counter)。

结论:单次 count() 尽管用;避免在热点循环里反复调用。

13.2 字符串的修改方法

所谓修改字符串,就是通过函数(方法)的形式修改字符串中的数据。

函数 作用
replace() 返回替换后的字符串
split() 返回切割后的列表序列
capitalize() 首字母大写
title() 所有单词首字母大写
upper() 与 lower() 返回全部大写或小写的字符串
swapcase() 产生新的字符串,新字符串所有字母大小写转换
lstrip()、rstrip() 与 strip() 去除左边、右边以及两边的空白字符
ljust()、rjust() 与 center() 返回原字符串左对齐、右对齐以及居中对齐
这些方法都不改变原字符串

因为字符串不可变,所有"修改"方法都是返回新字符串,原串不变:

s = 'hello'
s.upper()        # 返回 'HELLO',但 s 还是 'hello'
print(s)         # hello
s = s.upper()    # 必须重新赋值
print(s)         # HELLO

13.2.1 replace() 方法

基本语法:

# 伪代码:展示完整参数
字符串.replace(要替换的内容, 替换后的内容, [替换的次数])

案例:编写一个字符串,然后把字符串中的 linux 替换为 python。

str1 = 'hello linux and hello linux'
# 把字符串中所有 linux 字符替换为 python
print(str1.replace('linux', 'python'))
# 把字符串中的第一个 linux 进行替换为 python
print(str1.replace('linux', 'python', 1))
# 把 and 字符串替换为 &&
print(str1.replace('and', '&&'))
实跑输出
hello python and hello python
hello python and hello linux
hello linux && hello linux

(图片未上传成功:Snipaste_2022-06-27_16-16-45)

实际工作中,replace 主要用于实现关键字替换或过滤功能,例如 北京 ==> BJ、论坛关键字过滤、人民币 ==> 软妹币。

13.2.2 split() 方法

作用:对字符串进行切割操作,返回一个 list() 列表类型的数据。

str1 = 'apple-banana-orange'
print(str1.split('-'))
实跑输出
['apple', 'banana', 'orange']

(图片未上传成功:Snipaste_2022-06-27_16-18-12)

13.2.3 capitalize() 方法

作用:把字符串的首字母大写,其他字符全部小写。

str1 = 'i love Linux and Java'
print(str1.capitalize())
实跑输出
I love linux and java

⚠ 注意 Linux 变成了 linux、Java 变成了 java——
capitalize() 会把除首字母外的所有字母强制小写,不只是"首字母大写"。

(图片未上传成功:Snipaste_2022-06-27_16-20-18)

13.2.4 title() 方法

作用:把字符串中的所有单词的首字母大写,组成大驼峰。

str1 = 'myName'
# 把str1变成首字母大写字符串
print(str1.capitalize())

str2 = 'student_manager'
# 把str2变成大驼峰
print(str2.title().replace('_', ''))
实跑输出
Myname
StudentManager

拆解:'student_manager'.title() → 'Student_Manager' → .replace('_','') → 'StudentManager'

(图片未上传成功:Snipaste_2022-06-27_16-22-05)

title() 的分词规则很"朴素"

它按非字母字符切词,不认语义:

print("they're here".title())     # "They'Re Here"  ← 不是 They're Here
print('a1b2c3'.title())           # 'A1B2C3'

处理英文人名、标题时建议用 str.capwords()(来自 string 模块)或自行分词。

13.2.5 upper() 与 lower() 方法

upper():把字符串全部转换为大写形式。

lower():把字符串全部转换为小写形式。

# 用户名以及密码验证案例
username = input('请输入您的账号:')
password = input('请输入您的密码:')

# 把 username 和 password 全部转换为大写或小写
print(username.lower())
print(password.upper())
实跑输出(模拟输入 Admin、abc123)
admin
ABC123

(图片未上传成功:Snipaste_2022-06-27_16-27-51)

(图片未上传成功:Snipaste_2022-06-27_16-25-38)

13.2.6 swapcase() 方法

作用:产生一个新字符串,其中所有字母的大小写都被转换。

str1 = 'I love Python and Linux'

print('转换前:', str1)
print('转换后:', str1.swapcase())
实跑输出
转换前: I love Python and Linux
转换后: i LOVE pYTHON AND lINUX

(图片未上传成功:Snipaste_2022-06-27_19-27-01)

13.2.7 lstrip()、rstrip() 与 strip()

strip() 的主要作用:删除字符串两边的空白字符(如空格)。

lstrip() 即 left + strip,作用:只删除字符串左边的空白字符。

rstrip() 作用:只删除字符串右边的空白字符。

# 用户名验证案例
username = input('请输入您的账号:')

# 去除username两边的空白字符
print(len(username))
print(username.strip())
print(len(username.strip()))
实跑输出(模拟输入 admin ,前后各 3 个空格)
11
admin
5
'   admin   '.strip()   -> 'admin'
'   admin   '.lstrip()  -> 'admin   '
'   admin   '.rstrip()  -> '   admin'

(图片未上传成功:Snipaste_2022-06-27_16-31-52)

strip() 可指定要删除的字符集

不带参数删空白;带参数则删除该字符串中出现的所有字符(不是删子串):

print('***hi***'.strip('*'))      # 'hi'
print('abcba'.strip('ab'))        # 'c'  ← 删两端所有 a 和 b
print('www.example.com'.strip('w.moc'))  # 'example'

⚠ 别误以为 strip('.png') 是删后缀——它会删两端所有 ./p/n/g 字符。

13.2.8 ljust()、rjust()、center()

作用:返回原字符串左对齐、右对齐以及居中对齐。

基本语法:

# 伪代码:展示完整参数
字符串序列.ljust(长度, [填充字符])

未指定填充字符时,默认用空格填充。

案例:定义一个字符串,要求返回长度为 10 个字符,不足部分用 . 填充。

str1 = 'python'
# 左对齐
print(str1.ljust(10, '.'))
# 右对齐
print(str1.rjust(10, '#'))
# 居中对齐
print(str1.center(10))
print(str1.center(10, '@'))
实跑输出
python....
####python
  python  
@@python@@

(图片未上传成功:Snipaste_2022-06-27_16-35-49)

原串比指定宽度长时不会被截断
print('python'.ljust(3, '.'))     # 'python'(原样返回)

13.2.9 split() 分割和 join() 合并

split() 可以基于指定分隔符把字符串分隔成多个子字符串(存入列表)。若不指定分隔符,则默认使用空白字符(换行符 / 空格 / 制表符)。

a = "to be or not to be"
print(a.split())        # ['to', 'be', 'or', 'not', 'to', 'be']
print(a.split('be'))    # ['to ', ' or not to ', '']
实跑输出
['to', 'be', 'or', 'not', 'to', 'be']
['to ', ' or not to ', '']

注意 split('be') 结果的首元素带尾空格、末元素是空串——分隔符在两端会产生空串。

(图片未上传成功:Snipaste_2022-06-27_17-03-04)

join() 的作用与 split() 刚好相反:把一系列子字符串连接起来。

a = ['sxt', 'sxt100', 'sxt200']
print('*'.join(a))
实跑输出
sxt*sxt100*sxt200

(图片未上传成功:Snipaste_2022-06-27_17-08-03)

拼接字符串要点

用字符串拼接符 + 会生成新的字符串对象,因此不推荐用 + 拼接字符串。推荐使用 join 函数,因为 join 函数在拼接字符串之前会计算所有字符串的长度,然后逐一拷贝,仅新建一次对象。

测试 + 与 join() 的效率差异:

import time

a = ['sxt', 'sxt100', 'sxt200']
'*'.join(a)

time01 = time.time()   # 起始时刻
a = ""
for i in range(1000000):
    a += "sxt"
time02 = time.time()   # 终止时刻
print("运算时间:" + str(time02 - time01))

time03 = time.time()   # 起始时刻
li = []
for i in range(1000000):
    li.append("sxt")
a = "".join(li)
time04 = time.time()   # 终止时刻
print("运算时间:" + str(time04 - time03))

(图片未上传成功:Snipaste_2022-06-27_17-13-55)

实跑验证:原文这个结论完全正确(本机 N=500,000)
N = 500,000
+=  循环拼接 :  51232.7 ms
join 拼接    :     35.8 ms
倍数         : 1430.73x  (join 快约 1430 倍)
结果一致     : True

差距极其悬殊,在大循环中务必用 join。

补充说明:CPython 对 s += x 有一项原地扩展优化(当 s 引用计数为 1、且是可增长的 unicode 对象时,
会尝试就地 resize 而不新建对象)。但从实测看,这项优化并不能抹平数量级差距——
每次拼接仍有边界检查、长度计算与可能的内存重分配,累积起来远超 join 的一次性分配。

这个倍数不是固定值,会随规模急剧放大

上面 1430x 只是 N=500,000 这一次的实测值。换规模重测:

N += 循环 join 倍数
200,000 1249.2 ms 12.7 ms 98x
500,000 51232.7 ms 35.8 ms 1431x

原因在于复杂度不同:

  • += 循环:整体约 O(n²)(N 增到 2.5 倍,耗时增到约 41 倍)
  • join:整体 O(n)(N 增到 2.5 倍,耗时增到约 2.8 倍)

所以正确的理解不是"join 固定快 1430 倍",而是规模越大差距越夸张。
别把单一 N 下的倍数当常数记。

实践建议
  • 循环里累积字符串 → 先放 list,最后 ''.join(list)
  • 少量固定片段(2~3 个)→ 用 f-string,可读性最好,无需 micro-optimize
  • 大量文本拼接 → 考虑 io.StringIO 或直接写文件

13.3 字符串的判断方法

所谓判断,就是判断真假,返回结果是布尔型数据:True 或 False。

函数 作用
startswith() 检查字符串是否以指定子串开头,是则返回 True,否则返回 False。若设置开始和结束位置下标,则在指定范围内检查。
endswith() 检查字符串是否以指定子串结尾,是则返回 True,否则返回 False。若设置开始和结束位置下标,则在指定范围内检查。
isalpha() 若字符串所有字符(至少有一个)都是字母,返回 True,否则返回 False。
isdigit() 若字符串只包含数字,返回 True,否则返回 False。
isalnum() Python isalnum() 方法检测字符串是否由字母和数字组成:若所有字符(至少有一个)都是字母或数字,返回 True,否则返回 False。
isspace() 若字符串中只包含空白,返回 True,否则返回 False。

13.3.1 startswith()

# 伪代码:展示完整参数
字符串.startswith('子串', [开始位置下标, [结束位置下标]])

作用:检查字符串是否以指定子串开头,是则返回 True,否则返回 False。若设置开始和结束位置下标,则在指定范围内检查。

str1 = 'python program'
str2 = 'I love Python and Linux'

print(str1.startswith('python'))
print(str2.startswith('Python', 7, -1))
实跑输出
True
True

(图片未上传成功:Snipaste_2022-06-27_16-41-30)

startswith() 支持传元组
print('avatar.png'.endswith(('.png', '.jpg', '.gif')))   # True

比原文 if a.endswith('.png') or a.endswith('.jpg') or ... 简洁得多。

13.3.2 endswith()

# 伪代码:展示完整参数
字符串.endswith('子串', [开始位置下标, [结束位置下标]])

作用:检查字符串是否以指定子串结尾,是则返回 True,否则返回 False。若设置开始和结束位置下标,则在指定范围内检查。

str2 = 'avatar.png'
print(str2.endswith('.png'))

if str2.endswith('.png') or str2.endswith('.jpg') or str2.endswith('.gif'):
    print('是一张图片格式的图片')
else:
    print('您上传的文件格式异常')
实跑输出
True
是一张图片格式的图片

(图片未上传成功:Snipaste_2022-06-27_16-43-02)

13.3.3 isalpha()

作用:若字符串只包含字母则返回 True,否则返回 False。

str1 = 'admin'
str2 = 'admin123'

print(str1.isalpha())  # True
print(str2.isalpha())  # False
实跑输出
True
False

(图片未上传成功:Snipaste_2022-06-27_16-48-06)

isalpha() 认的是 Unicode 字母,不只 ASCII
print('中文'.isalpha())    # True
print('ΑΒΓ'.isalpha())    # True(希腊字母)

想只认 a-z/A-Z,用 s.isascii() and s.isalpha()(Python 3.7+ 有 isascii())。

13.3.4 isdigit()

作用:若字符串只包含数字则返回 True,否则返回 False。

password = input('请输入您的银行卡密码:')

if len(password) == 6 and password.isdigit():
    print('输入密码成功,正在验证...')
else:
    print('密码输入错误,请重新输入')
实跑输出(模拟输入 123456)
输入密码成功,正在验证...

(图片未上传成功:image-20220627164957513)

(图片未上传成功:Snipaste_2022-06-27_16-50-14)

isdigit() 的三个坑(原文未提)

实测:

输入 isdigit() isnumeric() isdecimal()
'123' True True True
'½'(分数) False True False
'²'(上标 2) True True False
'123'(全角) True True True
'1.5' False False False
'-1' False False False

关键:'²'.isdigit() 是 True!直接拿 isdigit() 校验纯数字密码有隐患:

'²²²²²²'.isdigit()        # True,但这显然不是 6 位密码
int('²²²²²²')             # ValueError

校验 ASCII 纯数字:用 s.isdecimal() and s.isascii(),或 s.isdigit() and s.isascii()。

13.3.5 isalnum()

作用:Python isalnum() 方法检测字符串是否只由字母和数字组成。若所有字符都是字母或数字,返回 True,否则返回 False。

username = input('请输入的您的用户名(只能为字母+数字形式):')

if username.isalnum():
    print('合理的用户名,正在录入系统...')
else:
    print('输入的用户名有误,请重新输入...')
实跑输出(模拟输入 admin123)
合理的用户名,正在录入系统...

(图片未上传成功:Snipaste_2022-06-27_16-51-25)

(图片未上传成功:Snipaste_2022-06-27_16-51-38)

isalnum() 也认 Unicode

'中文123'.isalnum() → True。要限制 ASCII 请加 .isascii()。
另外空字符串全部返回 False:''.isalnum()、''.isdigit()、''.isalpha() 都是 False。

13.3.6 isspace()

作用:若字符串中只包含空白,则返回 True,否则返回 False(逆向思维)。

str1 = ' '  # 最少要包含一个空白字符
print(str1.isspace())

username = input('请输入的您的用户名:')
if len(username) == 0 or username.isspace():
    print('您没有输入任何字符...')
else:
    print(f'您的输入的字符{username}')
实跑输出(第一个 print)
True

(图片未上传成功:Snipaste_2022-06-27_16-53-38)

判断「空输入」的更简洁写法
if not username.strip():      # 覆盖 ''、'   '、'\t\n' 等所有空白情况
    print('您没有输入任何字符...')

len(username) == 0 or username.isspace() 与 not username.strip() 等价,后者更简洁。

13.3.7 判断方法效率问题

startswith 与 endswith 本身效率较高、可以使用,因为通常要比对的子串规模较小。但二者的效率会随子串规模变大而降低,时间复杂度为 O(n)(n 为子串长度),因此子串规模较大时要慎用,例如 DNA 序列中的子串匹配问题。


14 字符串比较

可以直接用 == 比较两个字符串是否含有相同字符。

也可以用 is / is not 判断两个对象是否为同一个对象——比较的是对象地址,即 id(obj1) == id(obj2)。

原文笔误:not is 不存在

原文写「我们使用 is / not is」——Python 没有 not is 这个运算符,正确的是 is not:

print('a' is not 'b')     # True
# print('a' not is 'b')   # SyntaxError
不要用 is 比较字符串内容

is 比的是身份(地址),== 比的是值。字符串内容比较一律用 ==:

a = ''.join(['h', 'i'])      # 运行时创建
print(a == 'hi')             # True   ← 值相等
print(a is 'hi')             # False  ← 不是同一对象

只有与单例(None、True、False)比较时才用 is。


15 成员操作符

in / not in 关键字,用于判断某个字符(子字符串)是否存在于字符串中。

实跑输出
'ab' in 'abcde'        = True
'zz' not in 'abcde'    = True
in 是子串判断,不是"逐字符包含"
print('abc' in 'xxabcyy')    # True,连续子串
print('ac'  in 'abc')        # False,不连续不算

判断"字符串中是否含有某集合里的任一字符",用 any():

# 'hello' 的字符是 h/e/l/l/o,'abc' 是 a/b/c,没有交集 → False
print(any(c in 'abc' for c in 'hello'))    # False
print(any(c in 'abc' for c in 'cat'))      # True('c' 和 'a' 都在 'abc' 中)
False
True

⚠ 本条此前误写作 True,已按实测更正为 False。


16 字符串驻留机制

16.1 什么是字符串的驻留机制

字符串驻留:一种在内存中仅保存一份不可变字符串的方法(相同的字符串只保留一份)。
不同的值被存放在字符串的驻留池中,Python 的驻留机制对相同字符串只保留一份拷贝:后续创建相同字符串时不会开辟新空间,而是把已有字符串的地址赋给新变量。

a = 'hello'
b = "hello"
c = """hello"""
print(a, id(a))
print(a, id(b))
print(a, id(c))
实跑输出
id(a) = 2113348746480
id(b) = 2113348746480
id(c) = 2113348746480
a is b = True    a is c = True

(图片未上传成功:Snipaste_2022-06-27_17-54-05)

16.2 原理

  1. 系统维护一个 interned 字典,记录已被驻留的字符串对象。
  2. 字符串对象需要驻留时,先在 interned 中检测是否存在:若存在,引用数 +1;不存在则记录到 interned 中。

16.3 驻留机制的优缺点

  1. 背景:Python 一切皆对象,频繁创建和销毁对象会影响性能。
  2. 优点:需要值相同的字符串时,可直接从字符串池中取用,避免频繁创建和销毁,提升效率、节约内存。
  3. 缺点:创建驻留字符串需要花费更多时间。

16.4 驻留机制的几种情况(交互模式)

这里强调「交互模式」,是因为 PyCharm 等平台对字符串做了优化,本来不符合条件的字符串也会指向同一位置。

  • 字符串长度为 0 或 1;
  • 符合标识符的字符串(只包含字母、数字、下划线);
  • 字符串只在编译时驻留,而非运行时;
  • [-5,256] 之间的整数数字。
关键提醒:脚本模式与交互模式结果不同

原文特意标注「交互模式」是有道理的。本节的判定规则针对 REPL(每行单独编译);
而脚本模式下整个文件是一个编译单元,编译器会做常量折叠,
把文件内重复出现的相同字面量合并——所以即使含 @ 的 'abc@' 也会 is 成立。

实测(本文件以脚本方式运行):

'' is ''           = True
'@' is '@'         = True
'abc' is 'abc'     = True
'abc@' is 'abc@'   = True   ← 交互模式下应为 False
'xyz' is 'xy'+'z'         = True   (编译期常量折叠)
'xyz' is ''.join([...])   = False  (运行时新建,不驻留)

不要依赖驻留行为写代码,只把它当作理解 is / == 差异的背景知识。

案例

  • 字符串长度为 0 时:

    s1 = ''
    s2 = ''
    print(s1 is s2)
    

Jupyter:

(图片未上传成功:Snipaste_2022-06-27_18-13-27)

PyCharm:

(图片未上传成功:file-20260415224600413)

  • 字符串长度为 1 时:

    s1 = '@'
    s2 = '@'
    print(s1 is s2)
    

Jupyter:

(图片未上传成功:Snipaste_2022-06-27_18-13-34)

PyCharm:

(图片未上传成功:file-20260415225007735)

  • 符合标识符的字符串(只包含字母、数字和下划线):

    s1 = 'abc'
    s2 = 'abc'
    print(s1 is s2)
    
    s1 = 'abc@'
    s2 = 'abc@'
    print(s1 is s2)
    

Jupyter:

(图片未上传成功:Snipaste_2022-06-27_18-13-42)

PyCharm:IDE 中做了优化。

(图片未上传成功:file-20260415224811984)

  • 字符串只在编译时进行驻留,而非运行时:

    s1 = 'xyz'
    s2 = 'xy' + 'z'            # 编译时
    s3 = ''.join(['xy', 'z'])  # 运行时
    
    print(s1 is s2)
    print(s1 is s3)
    

Jupyter:

(图片未上传成功:Snipaste_2022-06-27_18-13-48)

PyCharm:

(图片未上传成功:file-20260415225222509)

  • [-5,256] 之间的整数数字:

    s1 = -5
    s2 = -5
    print(s1 is s2)
    
    s1 = -6
    s2 = -6
    print(s1 is s2)
    

Jupyter:

(图片未上传成功:Snipaste_2022-06-27_18-13-54)

PyCharm:IDE 中所有整数都会被保存;[-5, 256] 仍底层用数组实现,不在 [-5, 256] 范围内的数则缓存到链表中,不连续分配空间。

(图片未上传成功:file-20260415225332411)

16.5 强制驻留 sys.intern

sys 模块中的 intern 方法可以强制两个字符串指向同一个对象。

import sys

s1 = 'abc%'
s2 = 'abc%'

print(s1 is s2)

s1 = sys.intern(s2)

print(s1 is s2)
实跑输出(脚本模式,两者均为 True)
驻留前 'abc%' is 'abc%' = True
intern 后              = True

(图片未上传成功:Snipaste_2022-06-27_19-15-12)

sys.intern() 什么时候有用

需要在大量字符串上做重复比较时(如编译器符号表、大规模字典键、文本去重),
先 intern() 可以让后续比较退化为指针比较,显著提速:

import sys
a = sys.intern('some very long key')
b = sys.intern('some very long key')
a == b     # intern 后可能只需比地址,不必逐字符

日常小代码里没必要使用。


17 可变字符串

在 Python 中,字符串属于不可变对象,不支持原地修改;若修改其中的值,会自动创建新的字符串对象。但我们确实常有「原地修改」的需求,这时可以使用 io.StringIO 对象或 array 模块。

import io

s = "hello, sxt"
sio = io.StringIO(s)

print(sio)
print(sio.getvalue())
sio.seek(7)
sio.write("g")
print(sio.getvalue())
实跑输出
<_io.StringIO object at 0x...>
hello, sxt
hello, gxt

seek(7) 把写指针移到索引 7('hello, sxt' 中第 8 个字符 s),
write("g") 覆盖该位置,于是得到 'hello, gxt'。

(图片未上传成功:Snipaste_2022-06-27_20-13-17)

StringIO 不是「可变字符串」,而是「可写缓冲区」

更准确地说:io.StringIO 提供的是类文件对象的读写接口,
适合频繁追加/拼接的场景(比 += 快得多),而不是让你「修改某个字符」。

两种典型用法:

# ① 当作可写缓冲,替代 += 循环
import io
buf = io.StringIO()
for i in range(1000):
    buf.write(f"item{i}\n")
text = buf.getvalue()

# ② 当作「文件」传给需要文件对象的 API
import csv
r = csv.reader(io.StringIO("a,b\n1,2\n"))

如果只是想「改某个字符」,最直白的方式仍是切片重组:

s = 'hello, sxt'
s2 = s[:7] + 'g' + s[8:]
print(s2)
hello, gxt

18 术语表(重要英文单词)

18.1 编码与表示

术语 全拼 / 说明 释义
Unicode — 统一码,为世界上所有字符分配唯一码位的标准
UTF-8 Unicode Transformation Format - 8-bit 变长编码,英文 1 字节、常用中文 3 字节、增补字符 4 字节
GBK 汉字内码扩展规范 中文编码,英文 1 字节、中文 2 字节
ASCII American Standard Code for Information Interchange 美国信息交换标准代码,Unicode 的子集
code point — 码位,Unicode 中字符的唯一编号,如 'A' = U+0041
PEP Python Enhancement Proposal Python 增强提案;PEP 393 定义了灵活字符串表示
UCS Universal Character Set 通用字符集;UCS-2(2 字节)/ UCS-4(4 字节)
sys.maxunicode — 当前解释器支持的最大 Unicode 码位,Python 3 为 1114111(0x10FFFF)

18.2 操作与方法

术语 全拼 / 说明 释义
indexing — 索引,用 [] 按下标取单个元素;越界抛 IndexError
slicing — 切片,用 [start:end:step] 取子序列;越界不报错
offset — 偏移量,即下标;支持负数(-1 表示最后一个)
stride / step — 步长,切片的第三个参数;负数表示反向
escape character — 转义字符,以 \ 开头,如 \n、\t、\\
raw string — 原始字符串,前缀 r,反斜杠不转义
implicit concatenation — 隐式拼接,相邻字面量自动合并,如 'aa' 'bb'
immutable — 不可变;字符串创建后内容不可改,"修改"实为新建
intern / interning — 驻留,相同字符串只存一份的机制
constant folding — 常量折叠,编译器在编译期合并相同字面量
sys.intern() — 强制驻留函数
io.StringIO — 内存中的类文件字符串缓冲区,适合频繁追加

18.3 判断类方法

术语 全拼 / 说明 释义
isdecimal() — 是否只含十进制数字(最严格,推荐用于数字校验)
isdigit() — 是否只含数字(含上标 '²',不含分数 '½')
isnumeric() — 是否只含数值字符(最宽,含 '½'、'²'、中文数字)
isalpha() — 是否只含字母(含 Unicode 字母,不只 a-z)
isalnum() — 是否只含字母和数字
isspace() — 是否只含空白字符
isascii() — 是否全部为 ASCII 字符(Python 3.7+)

19 系列导航

01_Python基础
  1. 1.Python 入门
  2. 2.编程基础概念
  3. 3.Python 中的基础数据类型
  4. 4.字符串
  5. 5.内建数据结构
  6. 6.控制语句
  7. 7.函数用法
  8. 8.面向对象编程
  9. 9.异常和错误
  10. 10.文件操作(IO 技术)
  11. 11.模块 (module)
  12. 12.正则表达式 ★★★
  13. 13.并发编程
  14. 14.网络编程
  15. 15.Python操作MySQL
相关主题
  • 序列通用操作(列表/元组也适用切片):Python 中常用的序列、5.内建数据结构
  • 字符串方法速查:split 详解
  • 编码相关的实战:10.文件操作(IO 技术)
  • 正则(字符串高级匹配):12.正则表达式 ★★★、4.Python中的正则表达式

20 参考链接

分类:Python

posted @ 2026-04-11 01:59  挖掘鱼  阅读(46)  评论(0)    收藏  举报