Python字符串全解析:从不可变序列到切片、格式化与性能优化
字符串是Python里存在感最高的一种数据类型——不管你是在写爬虫、做数据分析还是刷算法题几乎每一行代码都在跟字符串打交道。但很多初学Python的朋友在实际处理字符串时总会遇到这类问题想截取一段却弄反了边界用和is做比较时偶尔“翻车”在循环里用拼接字符串结果速度卡到怀疑人生。今天这一讲我们就把字符串的知识点全部过一遍从底层原理讲到常用操作和踩坑经验不做照本宣科只讲实际写代码最用得上的那部分。如果你是一个刚接触Python的初学者或者写过几段脚本但总被字符串细节绊倒这篇内容能帮你省下不少翻文档的时间。1. 字符串的本质不可变序列先搞懂这三件事1.1 字符串不是“字符数组”也没有结束符很多从C语言或者C转过来的朋友一开始会下意识把Python字符串理解成“字符数组”。实际上Python的字符串是一个不可变的有序Unicode字符序列。这意味着每个字符都对应一个Unicode码点len()统计的是字符个数而不是字节个数。举个例子s Python中文 print(len(s)) # 7而不是按字节算出的10这里Python有6个字符中文有2个字符一共7个。但在C语言里你可能会用strlen按字节数去量还得担心末尾的\0结束符。Python完全不需要这一套\0在Python里就是一个普普通通的字符它参与长度计算也参与切片和查找。s a\0b print(len(s)) # 3 print(s[1]) # 这个位置是空字符但仍是一个合法字符我刚接触Python时因为习惯C的string总会在处理网络包时去找“结束符”结果发现Python的字符串就是一个连续序列自己把自己绕晕了。记住Python字符串没有C语言的\0终止概念它内部会记录长度你不需要关心“到底哪里算结束”。也因为这个原因你不能像操作list那样直接str[0] x来修改字符串。字符串不可变这是它的核心设计之一。所谓“修改字符串”实际上都是创建了一个新的字符串对象并把变量重新指向新对象。1.2 索引和切片半开区间是最容易踩的坑字符串支持索引下标从0开始也支持负数索引-1代表最后一个字符。切片是Python里特别顺手的功能但新手最容易在边界上犯迷糊。切片写法是[start:stop:step]关键规则是包含start不包含stop。这个“不包含stop”很多人一开始记不住。s Python基础 print(s[:2]) # Py取前两个字符 print(s[2:5]) # tho注意下标5对应的字符‘n’不会被取到 print(s[::-1]) # 础基nohtyP最经典的逆序写法截取前两位用s[:2]这个简洁得不能再简洁了。如果你要逆序输出直接[::-1]Python内置了这种“看不懂但很实用”的写法。还要记住索引和切片在越界时的差别。直接用下标访问越界会抛IndexError但切片越界不会报错只会返回尽可能多的部分s abc print(s[5]) # IndexError: string index out of range print(s[5:10]) # 空字符串不报错所以你在不确定边界的时候优先用切片而不是一下一下去取下标可以少写很多判断。比如我想取“从第3个字符到末尾”s[2:]就完事了人工算末尾下标反而容易出错。1.3 不可变为什么是好事以及“更改”字符串的正确方式字符串不可变指的是对象本身不可变不是“变量不能重新赋值”。当你写s hello s world你并没有修改原来那个hello只是把s这个标签贴到了另一个字符串对象上。你可以用id()验证s hello print(id(s)) # 某个地址 s hello! # 原对象可能还在内存里s已经指向新对象有同学会问“那我想把某个位置的字母改掉怎么办”答案是要么切片重组要么先转成列表再改改完再join回来。s hello lst list(s) # [h, e, l, l, o] lst[0] H s .join(lst) # Hello不可变带来的好处很多字符串可以安全地作为字典的键、可以放进集合里因为它的哈希值不会变多个变量共享同一个字符串的时候不会因为某个变量的操作影响其他变量线程安全也天然有保障。实际业务中我们常常需要拼接或者替换但这些都是生成新字符串不会破坏原数据所以调试起来也格外省心。2. 常用操作查找、判断、改、转这些方法要刻进脑子2.1 子串查找与类型判断in、find、isalnum怎么组合用判断一个字符串里有没有某个子串最直观的就是in和not ins Hello, Python print(Python in s) # True print(python in s) # False大小写敏感如果想拿到子串的位置用find()找不到时返回-1。用index()也能找但找不到时会抛ValueError所以在不确定子串是否存在时我一般用find()或者先if xx in s再index()。startswith()和endswith()在判断文件类型、URL协议时特别方便它们还支持传入元组一次判断多个前缀url https://example.com print(url.startswith((http://, https://))) # True再说一个常见需求判断字符串是否只由字母和数字组成。很多从Java转过来的朋友习惯去遍历字符挨个判断但Python里一个isalnum()就够了s1 abc123 s2 abc_123 print(s1.isalnum()) # True字母或数字都算 print(s2.isalnum()) # False下划线不算与之相关的还有isdigit()只判断数字字符比如123为True-123为False负号不是数字。isalpha()只判断字母中文也会被算作字母因为Unicode里中文是Letter类型。isdecimal()更严格只判断十进制数字字符。写注册用户名校验的时候我习惯这样写if not (username.isalnum() and 3 len(username) 16): print(用户名必须由字母和数字组成长度3~16位)2.2 大小写转换、替换、分割和去空格大小写转换是字符串处理里最常见的操作。upper()全大写lower()全小写capitalize()首字母大写其余小写title()每个单词首字母大写swapcase()大小写互换。s python tutorial print(s.upper()) # PYTHON TUTORIAL print(s.capitalize()) # Python tutorial print(s.title()) # Python Tutorial替换用replace()它的第三个参数可以控制替换次数s a-b-c-d print(s.replace(-, ,, 2)) # a,b,c-d只替换前两个分割字符串是高频操作。split()默认按任意空白字符切并自动过滤空串也可以指定分隔符还能用maxsplit限制切几次。类似C#里Split成数组Python里split()直接返回列表line apple,banana,orange fruits line.split(,) # [apple, banana, orange]如果要从右往左先切一刀用rsplit()在处理文件路径这类字符串时很有用。比如path /home/user/README.md head, tail path.rsplit(/, 1) # head/home/user, tailREADME.md去掉字符串首尾的空格、换行、制表符用strip()只要去左边用lstrip()去右边用rstrip()。这三个方法默认去掉空白字符可以传入参数去掉指定字符s --hello-- print(s.strip()) # --hello-- print(s.strip(- )) # hello左右两边的-和空格都去掉2.3 字符串转数字int()、float()和异常处理不能少从数据文件或者接口里拿到数字时它们往往是字符串比如123、3.14。转整数用int()转浮点数用float()x int(123) # 123 y float(3.14) # 3.14 z float(0.5) # 0.5但几点要特别注意第一int()默认能处理首尾空格和正负号但是不能处理字符串内部的空格也不能处理小数。int(1 2)会报错int(3.14)也会报错。第二int()可以指定进制。int(ff, 16)得到255int(1010, 2)得到10。这里只识别合法的进制数字如果你直接int(ff)Python会默认十进制解析然后抛ValueError。第三如果字符串本身不是合法数字比如12a调用int()会直接抛ValueError。所以在处理不可信输入时一定要用try/except包住def safe_int(s, default0): try: return int(s.strip()) except ValueError: return default这个函数在读取配置文件、解析Excel导入数据时几乎每天都会用到。对比一下SQL Server里的CAST和CONVERTPython的转换更严格宁可让你报错也不悄悄给你一个错误的值这点其实是好事。反过来数字转字符串最省事的就是str()n 42 s str(n) # 42 pi 3.14159 s fvalue{pi:.2f} # value3.14如果你要保留小数点后指定位数、补零对齐请直接跳到下一节格式化输出。3. 格式化输出从%到f-string现在我只推荐后者3.1 三种格式化方式对比别再用老掉牙的写法早期的Python支持%格式化类似于C语言的printf风格name Tom age 18 print(I am %s, and Im %d years old. % (name, age))这种方式写起来容易眼花尤其是占位符多的时候。后来Python推出了str.format()支持位置参数、关键字参数和格式说明print(I am {}, and Im {} years old..format(name, age)) print(I am {n}, and Im {a} years old..format(nname, aage))format()比%清晰很多但还是要写一对花括号多变量时仍然不够直观。从Python 3.6开始强烈推荐使用f-string也就是在字符串前面加f或F然后在花括号里直接写变量或表达式print(fI am {name}, and Im {age} years old.) print(f计算结果: {2 * 3 1}) # 括号里还能写表达式f-string除了赋值方便还能直接调用方法s fuser: {name.upper()}我在实际代码里几乎全部用f-string只有在需要动态构造模板、模板是运行期字符串的时候才会考虑str.format()。3.2 对齐、补零、精度控制模板字符串的进阶玩法f-string的格式说明符很强大。格式是{变量:填充对齐宽度.精度类型}。n 3.14159 print(f{n:.2f}) # 3.14 print(f{n:10.2f}) # 保留两位小数总宽度10默认右对齐左边补空格 print(f{n:010.2f}) # 10位宽右对齐左边用0填充对齐符号左对齐右对齐^居中。填充字符放在对齐符号前面s test print(f[{s:*^15}]) # [*****test*****]这个技巧在打印表格、生成日志、拼报文时非常好用。比如我要输出一个带两位小数的价格并左对齐补零可以这样写price 12.5 print(f{price:08.2f}) # 00012.50平时说的“模板字符串”其实指的就是这种带占位符的文本结构。除了f-stringPython还有一个string.Template类允许用$name占位适合模板内容需要动态生成或多语言翻译的场景from string import Template t Template(Hello, $name!) print(t.substitute(nameworld)) # Hello, world!如果你是做GUI的可能接触过Qt里的QString::arg或者double转字符串时的QString::number思路其实是类似的都是“把变量填充进模板”。Python里的f-string更直观少了很多类型转换的麻烦。4. 排序与比较基础字符串操作里最容易被忽略的两块4.1 用比较内容用is比较身份千万别混很多语言里字符串是引用类型比较是否相等要用专门的方法。Python里直接用就是比较内容a hello b .join([h, e, l, l, o]) print(a b) # True内容完全一样 print(a is b) # False两个不同的对象为什么这里is是False因为join创建了一个新字符串。如果你直接写b hello由于编译期的字符串驻留机制a is b有可能为True但这属于实现细节不应该依赖它。记住一个原则比较字符串相等永远用不要用is。在判断用户输入是否等于某个关键字时用就是安全的。字符串还能直接比较大小因为Python会从第一个字符开始按Unicode码点逐字符比较。这就是“字典序”print(apple banana) # True因为a b print(abc abd) # True print(A a) # True因为A的码点是65a是97因此我们可以直接对字符串列表排序words [banana, apple, Cherry, date] words.sort() print(words) # [Cherry, apple, banana, date]注意默认排序是区分大小写的大写字母排在小写前面。如果希望不区分大小写用keystr.lowerwords.sort(keystr.lower) print(words) # [apple, banana, Cherry, date]sorted()返回一个新列表list.sort()原地排序。需要保留原数据时一定记得用sorted()。4.2 实战字符计数和从字符串构建“邻接矩阵”掌握了字符串基础之后我们来看两个经常出现的小练习题。第一个是统计字符串中每个字符出现次数。用字典遍历s python字符串 counter {} for ch in s: counter[ch] counter.get(ch, 0) 1 print(counter)也可以用collections.Counter一行搞定from collections import Counter print(Counter(s))很多所谓的“李白打酒”类的算法题本质上就是需要你熟练地遍历字符串、分割字符串、转换类型、维护计数器。字符串基础打牢之后这类题目刷起来会顺手很多。第二个是构建邻接矩阵。假设你拿到一个文本文件里面每行是“起点 终点 权重”比如a b 3 a c 5 b c 2想把它转成Python里的邻接矩阵可以先初始化一个二维列表再按空格分割每行字符串最后把权重转成整数n 3 nodes [a, b, c] index {node: i for i, node in enumerate(nodes)} matrix [[0] * n for _ in range(n)] lines [a b 3, a c 5, b c 2] for line in lines: start, end, weight line.split() matrix[index[start]][index[end]] int(weight) print(matrix) # [[0, 3, 5], # [0, 0, 2], # [0, 0, 0]]这里用到了split()切分、索引映射、类型转换三个字符串基本功。很多实际数据处理场景比如读取CSV、日志文件、配置文件操作套路都是差不多的。5. 常见问题与排查技巧实录5.1 循环里拼字符串速度会慢到让你怀疑人生字符串不可变所以每执行一次Python都要创建一个新字符串并复制旧内容。在循环里用拼接大量字符串时间复杂度是O(n²)数据一多就会突然卡死。# 不推荐循环里用 s for i in range(10000): s str(i)上面这段代码数据量到几万就能明显感到变慢。推荐做法是把字符串片段先放进列表最后用.join(list)一次性合并parts [] for i in range(10000): parts.append(str(i)) s .join(parts)join()会预先计算总长度只做一次内存分配性能好得多。如果只是简单拼接少量字符串完全没问题不要过度优化。5.2 读文件乱码、报UnicodeDecodeError多半是编码问题Python 3的字符串是Unicode但读写文件的时候要指定编码。很多新手默认打开文件什么都不写一旦文件是GBK编码在UTF-8环境下直接报UnicodeDecodeErrorwith open(data.txt, r, encodingutf-8) as f: content f.read()如果你不确定文件编码回退用errorsreplace可以避免程序崩溃但会替换掉无法识别的字节。在解析老系统导出文件时经常遇到GBK编码可以这样with open(data.txt, r, encodinggbk, errorsreplace) as f: content f.read()在网络传输或读取二进制时你也可能需要手动encode()和decode()。比如把字符串编码为UTF-8字节s 中文 data s.encode(utf-8) # bytes s2 data.decode(utf-8) # 还原成字符串如果decode时用的编码不对就会出现乱码。遇到乱码时先搞清楚源文件的编码格式再从utf-8、gbk、gb2312这些常见编码里逐个尝试别急着改文件。5.3 字符串里写引号、换行、路径转义和原生字符串帮你省事定义一个包含引号的字符串有几种办法。如果字符串内部是单引号可以用双引号包起来内部是双引号可以用单引号包起来s1 He said hello s2 He said hello如果既有单引号又有双引号或者想表达换行、制表符就用转义s 第一行\n第二行\t带缩进在Windows文件路径里反斜杠容易造成转义混乱比如C:\Users\new会被当成\n和换行符处理。这种情况用原始字符串也就是在引号前加rpath rC:\Users\new正则表达式也推荐用原始字符串这样\d、\.这些写法不用写双重反斜杠。三引号字符串可以跨行非常适合保存多行文本、模板、说明文档sql SELECT id, name, age FROM users WHERE age 18 注意三引号会原样保留换行和缩进不需要额外加\n。5.4 从脏数据里清洗出结构化字段实际项目里拿到的数据往往不干净有前后空格、冒号中英文混用、多余换行。处理这种数据常规套路就是strip()、replace()加上split()。比如我要从下面这种文本里提取用户名和年龄name: 张三 ; age: 25可以这样处理raw name: 张三 ; age: 25 parts raw.split(;) user {} for part in parts: key, value part.split(:) user[key.strip()] value.strip() print(user) # {name: 张三, age: 25}如果要提取的数字纯数字最后再转一次类型。这类清洗操作在爬虫、Excel导入、日志分析里能占一半的工作量。掌握这些字符串基础方法比去背一堆高阶框架更管用。最后再分享一个小技巧如果你需要对字符串做批量字符替换比如把所有小写元音转成大写用str.maketrans和translate会比连续replace高效得多而且代码更简洁s hello world table str.maketrans(aeiou, AEIOU) print(s.translate(table)) # hEllO wOrld字符串的基础就这些看起来琐碎但每一项都会在后面的正则、文件处理、数据处理里反复用到。把上面的方法都亲手敲一遍再遇到字符串相关的问题你会发现自己明显比之前沉着得多。