
今天来聊聊Python里一个绕不开的基础话题——数据序列。不管是刚开始学Python的学生还是为了爬虫、数据分析、办公自动化而自学Python的朋友基本都会从列表、元组、字典、集合这几个概念开始。你可以把数据序列理解为Python中“装数据”的容器它决定了你的数据怎么存、怎么取、怎么改也决定了代码是清晰好维护还是又乱又容易出bug。这篇文章会把围绕数据序列的核心知识点捋一遍并结合我实际开发中遇到的坑给出一些经验适合准备系统学习Python的新手也适合学了一阵子但总被各种细节卡住的人。1. 数据序列在Python里的角色为什么它是地基1.1 先分清四类“装数据”的容器写Python代码说白了就是在和数据打交道而数据序列就是装数据的盒子。Python里最常用的数据序列主要有五类字符串str、列表list、元组tuple、字典dict、集合set。严格来说字符串、列表、元组才是文档意义上真正的序列因为它们有序、可索引字典和集合是无序容器但因为日常学习和开发中总是一起讨论习惯上也会被放到“数据序列”这个大话题里。可以用一个表格直观地对比类型是否可变是否有序是否可重复典型用途字符串 str不可变有序允许文本、单字符序列列表 list可变有序允许动态数据集合元组 tuple不可变有序允许只读数据、函数返回字典 dict可变插入有序3.7键不允许重复键值映射、配置项集合 set可变无序不允许重复去重、集合运算看这张表的时候重点不是背下每一条而是建立第一印象不同容器适合不同的场景。比如你有一个购物车商品可能随时增减那就用列表比如你有一份全国城市编码和城市名的对应关系那就用字典比如你只需要判断一批数字中有哪些不重复的就用集合。“有序”和“无序”的区别平时不显眼但真正调试代码的时候就会感受到。列表和元组可以通过索引找到位置字典虽然3.7以后有插入顺序但它查找数据靠的是键不是下标集合更是完全不保证顺序所以不能用下标去取集合里的元素。选哪个容器不是随便拍脑袋而是取决于你的数据是什么形态、你要怎么用它。这个判断本身就是编程能力的体现。1.2 可变与不可变到底影响什么可变和不可变是整个数据序列里最基础也最容易让人踩坑的一个点。可变意思是这个对象创建之后它的内容可以被原地修改比如列表可以 append、remove不可变意思是对象一旦创建内容就不能改变比如字符串、元组。要修改一个不可变对象Python 会先新创建一个对象然后把新的内容填进去再让变量指向新对象。这个区别的影响非常深远。举个例子a [1, 2, 3] b a b.append(4) print(a) # [1, 2, 3, 4]一个列表被另一个变量“赋值”过去并没有拷贝一份数据而是两个变量指向同一个列表对象。因为列表是可变对象b 这边添加元素a 那边的内容就跟着变了。元组就不会有这个问题因为元组本身不允许修改c (1, 2, 3) d c # d (4,) 实际上会生成一个新元组c 本身不变理解了可变性后面很多“诡异现象”就豁然开朗了。比如为什么字典的键不能用列表因为字典靠键的哈希值定位存储位置如果键的内容可变哈希值就会变数据就会找不到所以键必须是不可变类型比如字符串、数字、元组。提示学习数据序列时不用死背结论最好多写几行代码看看id()值的变化。id()相当于对象在内存中的门牌号可变对象原地修改时地址不变不可变对象每次变更后地址基本都会变。2. 序列的基本功索引、切片与步长2.1 正向索引与负向索引序列最大的优势就是可以按位置取数据这个基本功如果打不牢后面的所有操作都会别扭。Python 的索引从 0 开始这一点和很多编程语言一致但 Python 还有一个特别方便的设计负索引。-1表示最后一个元素-2表示倒数第二个依次往前推。s python print(s[0]) # p print(s[-1]) # n新手最容易犯的错是把索引从 1 开始数。比如想取一个长度为 10 的列表的最后一个元素有人会写lst[10]然后直接 IndexError 报错。正确写法是lst[9]或lst[-1]。负索引能省不少事尤其是不确定序列长度的时候。实际开发里负索引用得最频繁的场景是读取日志或配置文件的最后一行数据。数据源可能很长你往往只关心最近一条记录data[-1]一行就解决了不用先len(data)再减一。2.2 切片操作的三个参数start:stop:step切片是序列操作里最常用的功能格式是sequence[start:stop:step]。它按左闭右开的规则取值包括 start 位置不包括 stop 位置。step 控制步长默认是 1。举几个例子就很清楚nums [10, 20, 30, 40, 50] print(nums[1:3]) # [20, 30] print(nums[:3]) # [10, 20, 30] print(nums[::2]) # [10, 30, 50] print(nums[::-1]) # [50, 40, 30, 20, 10]这里的[::-1]是反转序列的经典写法不仅适用于列表也适用于字符串。切片返回的是一个新对象不会修改原序列这一点在拷贝数据时非常有用。为什么要设计成左闭右开主要原因是这样能够直接看出切片长度[2:5]一眼就知道有三个元素另外两个切片能够无缝拼接比如[0:2]和[2:5]正好覆盖[0:5]中间不会重叠也不会留空。2.3 切片技巧速查表我整理了一份日常用得比较多的切片写法可以直接收藏需求写法结果以 nums[10, 20, 30, 40, 50] 为例取前3个nums[:3][10, 20, 30]取后2个nums[-2:][40, 50]取所有奇数位从0开始计数nums[1::2][20, 40]反转nums[::-1][50, 40, 30, 20, 10]每隔2个取一个nums[::3][10, 40]记住两个关键点stop 位置取不到step 为负数时从右往左走。很多看起来很绕的题其实就是这五个案例的组合。3. 列表与元组最常用的两个序列3.1 列表的增删改查列表是开发里最灵活的容器因为它可增可删可排序可翻转。增的常用方法有append、extend、insert。append把整个参数当作一个元素加到末尾extend是遍历参数里的每一项逐个拼接到末尾。区别很典型a [1, 2] a.append([3, 4]) # a - [1, 2, [3, 4]] b [1, 2] b.extend([3, 4]) # b - [1, 2, 3, 4]删除的常用方法有remove、pop、del。remove按值删除第一个匹配项pop按下标删除并返回被删的值del是语句可以按索引删除也可以直接删整个变量。如果你是“既不关心返回值又不知道下标”的场景remove最方便。查找的话in运算符是最简单的存在性判断index方法可以查出某个值的下标count可以统计出现次数。平时写业务代码if x in lst的使用频率非常高。改的话更简单直接按下标赋值就行lst[0] new。这个能力列表独有元组可不行。3.2 方法返回值陷阱sort、append、reverse这里有一个很多新手都中过招的细节sort、append、reverse这类方法都是原地修改对象返回的是None。也就是说a [3, 1, 2] b a.sort() # 错误理解b 是排序后的列表 print(b) # None而不是[1, 2, 3]。sort 要想拿新列表得用内置函数sorted()它返回一个新的排序列表原列表不变。我帮一个同事复盘过类似代码他写了一个函数最后一句是return inventory.append(sword)这个函数永远返回 None但数据确实加进去了。这种 bug 不容易被发现因为代码不报错只是结果不是想要的样子。排查这类问题最好的方法是单独写一行调用方法再查看原对象。提示如果你需要“链式写法”比如一边处理一边排序尽量不要依赖原地方法。可以用new_list sorted(old_list)或者用推导式生成新列表后再继续操作这样思路更清晰也不容易污染原始数据。3.3 元组的价值与使用场景元组看起来像“不可修改的列表”但它的存在不是为了让你写起来更别扭而是有几个实打实的价值。第一元组可以当作字典的键。字典的键要求可哈希列表不行但元组可以只要元组内部不包含可变元素它就可以稳定地作为键。比如用坐标(x, y)作为地图数据的键比拼字符串更直观。第二函数返回多个值时本质上返回的也是元组。return x, y会给你一个(x, y)接收时用a, b func()解包这是 Python 最常见的写法。第三元组更省心也更安全。如果某个数据集合定义好之后就不应该被修改比如星期几、一年十二个月的名称用元组能在语义上直接表明“这是固定配置”防止后续代码误改。weekdays (Mon, Tue, Wed, Thu, Fri)另外元组支持解包、切片、拼接大部分列表的操作它都能用。只是不能增删改。解包这个能力反而是很多同学容易忽略的point (10, 20) x, y point这种写法在遍历字典、处理坐标、交换变量时都有用处。你甚至可以写出a, b b, a这样优雅地交换两个变量的代码本质上用到的就是元组的打包和解包机制。4. 字典与集合无序容器的高效用法4.1 字典的构造与常用操作字典是键值对的数据结构适合做“根据一个键快速找到对应值”的事。比如用户ID对应昵称、商品编号对应价格、城市名对应邮编这种映射关系用字典最顺手。创建一个字典很简单user {name: 张三, age: 25}读取的时候直接user[name]就行但要注意如果键不存在user[age]会抛KeyError。更稳妥的写法是user.get(age)键不存在时返回 None也可以传入默认值user.get(age, 0)。新增或修改键直接赋值user[city] 北京。批量更新用updateuser.update({age: 26, score: 90})遍历字典时推荐用for k, v in user.items()一次拿到键和值不要在循环里反复user[k]可读性差还慢一点点。有一点要说明Python 3.7 之后字典按插入顺序保存但这不是字典的核心特性。真实开发里不要依赖“字典有顺序”去写业务逻辑更不要把字典当成列表用。顺序只是实现细节键值查找才是它的本职工作。4.2 集合去重与集合运算集合最大的特点是不允许重复元素这种特性天然适合做两件事去重和集合运算。去重最简单data [1, 2, 2, 3, 3, 3] unique set(data) # {1, 2, 3}集合运算在业务里也经常出现。比如你有访问过A页面的用户列表有访问过B页面的用户列表想知道同时访问过两个页面的用户直接求交集a_users {u1, u2, u3} b_users {u2, u4} print(a_users b_users) # {u2}同理|表示并集-表示差集。用in判断一个元素是否在集合里也比在列表中查找快很多因为集合底层是哈希结构。提示集合要求元素可哈希所以列表、字典不能放在集合里但字符串、数字、元组可以。如果你需要对一批复杂对象去重可以考虑把对象转换成元组再放进集合。4.3 从元组/列表到字典的转换技巧有些数据从文件或接口读出来是一对一对的元组列表比如[(name, 张三), (age, 25)]想变成字典的话不用写循环直接dict()就能转pairs [(name, 张三), (age, 25)] d dict(pairs) # {name: 张三, age: 25}如果你想合并两个列表成一个字典可以用zipkeys [name, age, city] values [张三, 25, 北京] d dict(zip(keys, values))zip是把多个序列按位置一一配对的内置函数很多同学第一次看到会觉得很神奇其实它的本质就是把“多个序列”转成“多个元组”。反过来的操作同样常见用list(d.items())把字典转成元组列表方便排序、序列化或者写进文件。5. 深坑预警赋值、浅拷贝与深拷贝5.1 直接赋值导致的数据共用在Python里写b a并不会复制一份数据而是让b和a指向同一个对象。对可变序列来说这就是一个常见的坑。original [1, 2, [3, 4]] backup original backup.append(5) print(original) # [1, 2, [3, 4], 5]你以为自己备份了一份数据结果原数据跟着变了。实际开发里比如你从公共配置里取了一个列表想临时加工一下不小心直接赋值就会污染全局配置。解决办法是先搞清楚自己的需求是想得到一个新的列表还是只想多一个变量名。想得到新列表就要用拷贝。5.2 浅拷贝与深拷贝的区别切片original[:]、list(original)、original.copy()都属于浅拷贝。浅拷贝的意思是新列表本身独立了但列表里的元素如果是可变对象比如子列表它们仍然共享。来看这个例子a [1, [2, 3]] b a.copy() b.append(4) print(a) # [1, [2, 3]] 最外层的追加不影响 a b[1].append(5) print(a) # [1, [2, 3, 5]] 子列表被影响了为什么会这样因为浅拷贝只复制一层外面的列表是新容器但里面的[2, 3]依然是同一个对象。要连内层都复制必须用深拷贝import copy a [1, [2, 3]] b copy.deepcopy(a) b[1].append(5) print(a) # [1, [2, 3]] print(b) # [1, [2, 3, 5]]用不用深拷贝得看你的数据是几层结构。普通一维列表浅拷贝够了嵌套结构且要彻底隔离就得深拷贝。copy.deepcopy能递归复制所有层代价是多花一点时间和内存。5.3 日常编码中避免该坑的建议我自己的习惯是凡是把一个列表/字典赋值给另一个变量并且后续可能有一方会被修改就先停下来想一下这个变量是“引用”还是“拷贝”如果是拷贝用.copy()如果有嵌套结构用copy.deepcopy()如果只是想传给函数做只读处理可以不拷贝但函数内部要注意不要用会原地修改的方法。另外可以多用id()验证a [1, 2] b a print(id(a), id(b)) # 相同 c a.copy() print(id(a), id(c)) # 不同这个技巧在学习阶段非常好用能帮你在头脑里建立起“对象”和“变量名”的关系模型。6. 进阶玩法推导式与生成器6.1 列表推导式一行生成新列表列表推导式是Python里非常符合直觉的语法它可以把“创建新列表”和“循环处理”合在一条语句里。基本格式是[表达式 for 变量 in 可迭代对象]后面还可以加if过滤条件。举例提取一列数字里的偶数并平方。普通写法result [] for x in range(10): if x % 2 0: result.append(x ** 2)用列表推导式result [x ** 2 for x in range(10) if x % 2 0]写成一行之后可读性并不差反而把“数据从哪里来、过滤条件是什么、结果是什么”集中在一处非常清晰。列表推导式并不是炫技而是Python社区通行的高效写法源码里经常见到。推导式里可以嵌套比如[f{k}-{v} for k, v in user.items()]这种场景很常见。不过一旦嵌套两层以上可读性就会下降我建议最多嵌套一层再复杂就乖乖写普通 for 循环吧。6.2 字典推导式与集合推导式同样的语法也适用于字典和集合。字典推导式的典型场景是从一个列表构造键值映射names [张三, 李四, 王五] name_length {name: len(name) for name in names} # {张三: 2, 李四: 2, 王五: 2}集合推导式则常用在去重或规范化数据时texts [python, Python, PYTHON, java] unique_lower {t.lower() for t in texts} # {python, java}看到这里你会发现推导式的本质就是“把 for 循环产生的每一项按照表达式加工再放进对应类型的容器里”。理解了这一点三种推导式可以一起记住不用分别背语法。6.3 生成器表达式处理大数据的关键生成器表达式长得和列表推导式很像区别在于外层是圆括号squares (x ** 2 for x in range(1000000))它不会一次性生成一个巨大的列表而是“惰性求值”等你要一个值才给你算一个。这在处理海量数据时非常有用比如读取一个几百万行的日志文件如果用列表推导式把每行都存下来内存可能直接爆掉用生成器表达式则可以一行一行地处理。# 假设 file 是一个很大的文件对象 data_count sum(len(line.split()) for line in file)上面这行代码统计文件中总词数过程中不会创建巨大的中间列表内存开销很小。需要注意的是生成器表达式是“一次性”的遍历完之后就不能再用了想再遍历只能再创建一个。这是一个和列表很不一样的地方。提示判断自己该用列表推导式还是生成器表达式我一般看数据量。几十上百个元素用列表推导式方便直观数万以上或者你不确定有多大就用生成器表达式。6.4 嵌套推导式避免地狱嵌套推导式虽然能写但很容易写出“半小时后连自己都看不懂”的代码。比如二维列表展开matrix [[1, 2], [3, 4]] flat [x for row in matrix for x in row]这一行能看懂吗能但前提是你已经熟悉了它的结构。它的执行顺序其实是先外层 for再内层 for。写成直观形式就是flat [] for row in matrix: for x in row: flat.append(x)当你看到别人写的嵌套推导式时按照“外层for在左、内层for在右”的规则去还原就行了。但我个人建议线性的、只要一层的推导式随便用超过两层就果断拆分或者拆成普通循环加中间变量。代码是给人读的自己舒服、同事也舒服才最重要。7. 常见问题与排查技巧实录7.1 明明改的是副本原数据也变了这个坑在第5节详细讲过这里再总结一下排查思路看到“副本改了原数据跟着变”首先要判断自己是不是在做浅拷贝。如果数据结构里包含列表、字典这类可变元素浅拷贝只隔离了最外层内层还是共享的。解决方案是copy.deepcopy()或者在设计数据时就尽量避免深层次的嵌套结构。嵌套越深拷贝带来的心智负担越大。7.2 列表去重后顺序乱了用set(list)去重确实快但集合是无序的结果顺序往往和原始列表不一样。如果你的业务需要保持原顺序可以用一个常见技巧data [3, 1, 2, 3, 2, 1] unique list(dict.fromkeys(data)) # [3, 1, 2]原理是字典的键不允许重复fromkeys会根据传入的序列创建键重复的键自动忽略而 Python 3.7 的字典又保持插入顺序所以最终list(...)出来的结果既去重又保持了原始顺序。7.3 字符串拼接太慢很多初学者喜欢在循环里用拼接字符串。这种做法在数据量小时没问题数据量大时性能会非常差。因为字符串是不可变对象每次都会生成一个新的字符串对象循环几次就创建了几次对象时间开销接近 O(n²)。更优的写法是先把片段放进列表最后用join一次拼起来parts [] for word in words: parts.append(f{word}) result .join(parts)join是线性复杂度几十万次拼接也能轻松跑完。类似也是同样的问题要尽量在循环外累积到列表再统一合并。7.4 循环里删列表元素跑着跑着就漏了这是很多人在做数据清洗时会遇到的经典问题。直接在 for 循环里remove元素索引会动态变化容易跳过某些项lst [1, 2, 3, 4, 5] for x in lst: if x % 2 0: lst.remove(x)这段代码想删掉所有偶数但实际会漏删。原因是在遍历过程中删除了元素后续元素会向前移动循环的索引却不跟着减于是跳过了元素。推荐做法有两种一种是倒序遍历从后往前删就不会影响前面元素的索引另一种更简单直接用列表推导式重建一个新列表lst [x for x in lst if x % 2 ! 0]列表推导式本质是生成新列表原列表不变再赋回去干净又安全。7.5 字典按键或值排序字典本身无序。Python 3.7 虽然有插入顺序但你要按某项规则排序时还是要借助sorted转成可排序的结构。按键排序d {banana: 3, apple: 2, pear: 1} sorted_keys sorted(d)想拿排好序的键值对sorted_items sorted(d.items(), keylambda item: item[0])按值排序只需要把 key 换成item[1]sorted_items sorted(d.items(), keylambda item: item[1], reverseTrue)这里的关键是sorted返回的是新列表不会动原字典。排序后的结果如果还想要字典可以用dict(sorted_items)转回去不过大多数时候你需要的恰恰是一个有序的键列表或者键值对列表直接用它更方便。我自己在带新人或者复习基础的时候最喜欢用“数据序列”这一块来检验自己对Python的理解程度。表面上看它只是几个内置类型和一堆方法实际上一旦你把可变性、切片、拷贝、推导式这些点吃透写爬虫、做数据分析、处理接口数据时就能少走很多弯路。具体到实践上我特别推荐一个练法找一个自己感兴趣的小数据集比如一份CSV日志、一个JSON接口返回分别用列表、元组、字典、集合把它存一遍再思考每种存法适合做什么操作。这个过程会逼着你理解每种数据结构的特性比光看文档印象深得多。另外一个建议是遇到不认识的容器方法先在交互式环境里试一遍打印一下返回值再决定怎么用。你踩过的坑越多后面写代码就越稳。希望这篇围绕数据序列的整理能帮你把这些基础概念真正串起来后面学习更进阶的内容时少一些坎。