
如果你刚开始学 Python不管你是跟着视频敲代码、买本教材从头啃还是像我一样为了处理工作数据硬着头皮开干你接触到的第一种“能存很多数据”的结构几乎都是列表List。我最初写 Python 脚本管理一批订单状态、按条件筛选超时记录、把几个文件里的数据合并输出回头一看折腾来折腾去核心操作全都在跟列表打交道。后来陆续带过一些刚入门的新人发现他们不是不会写代码而是对列表的基础概念和常用 API 理解得模模糊糊结果一会儿报IndexError: list index out of range一会儿发现列表“偷偷变掉”就是不知道问题出在哪。列表这个知识点在几乎每本教材里都只占一章但在真实项目里它的分量远不止一节课。你可以把它理解成一个可以随时增删改查的“数据收纳盒”每一项都有顺序、有统一的编号里面装什么类型的数据都行。而且它是 Python 后续几乎所有数据处理场景的基石——你写爬虫要处理列表做数据分析要从列表或类似结构开始写接口要返回列表做哈希表、栈、队列这些数据结构更是整天跟列表打交道。这篇文章我会完全站在实际使用的角度把列表的创建、索引切片、增删改查、遍历、排序去重、复制陷阱以及它和元组、集合、字典这几个兄弟结构之间怎么选型一次性讲透。如果你想学的不是死记硬背而是能直接用在代码里的理解方式这篇文章应该正好对味。1. 为什么Python的列表和别的语言里的数组不是一回事1.1 一个变量装一堆数据列表解决的真实问题在学 Python 之前如果你只写过 C 或者 Java多半已经习惯了一件事一个变量就是一个值。比如score1 90 score2 85 score3 78可是当数据量变成上百条呢难道每个成绩都要单独给一个变量名显然不现实。你需要一个结构能把一堆数据“打包”在一起统一管理能整体传参能循环处理能按序号取某一项。列表就是干这个的scores [90, 85, 78, 92, 66] students [张三, 李四, 王五, 赵六, 钱七]这个变化看着简单但它带来的能力提升是巨大的。你可以用for循环批量操作全部数据可以用len(scores)知道一共有多少条可以用scores[0]精确取第一个成绩也可以在程序运行过程中动态往里面塞新数据。数据处理这件事从“一个数据一个变量”变成了“一批数据一个容器”效率差别不是一点点。1.2 动态数组、异构元素与“存放引用”的本质很多人会把 Python 的列表直接理解成其他语言里的数组但实际差别很大。C 或 Java 里的普通数组长度一旦定义就固定了想多放一个元素得自己写扩容逻辑而且数组元素类型通常要求统一int 数组就不能塞字符串。Python 的list在底层实现是动态数组往里面追加元素时它会自动扩容、自动搬数据你根本不需要关心容量问题。更舒服的是列表允许混装不同类型比如[1, hello, 3.14, True, None]这在 Python 里完全合法因为在 C 层面列表存的不是对象本身而是对象的引用你可以简单理解成“指向对象的指针”。这个特性对新手上手极其友好写脚本的时候不用先把所有类型转成一致再操作。但它也带来两个需要你心里有数的点列表自动扩容意味着当数据量快速增长时底层可能发生“重新申请内存 把旧数据拷过去”的操作虽然不是每次都触发但批量插入大量数据时会有性能开销。因为存的是引用所以“复制列表”这件事比想象中复杂如果你直接b a那b和a指向的是同一个列表改一个等于改两个。这一点我在第 6 章专门展开讲因为它是新手翻车重灾区。2. 创建列表和取数据索引与切片里最容易踩的坑2.1 创建列表的四种常见方式列表的创建方式看着简单但不同方式适用的场景差别很大。第一种方括号字面量也是最直观的方式empty_list [] numbers [1, 2, 3, 4, 5] mixed [1, python, 3.14, None, [1, 2]]第二种用list()函数把其他可迭代对象转成列表。比如字符串转字符列表、元组转列表、range 对象转列表chars list(hello) # [h, e, l, l, o] tuple_to_list list((1, 2, 3)) # [1, 2, 3] nums list(range(1, 10)) # [1, 2, 3, 4, 5, 6, 7, 8, 9]第三种用列表推导式直接生成。这个在“遍历与列表推导式”那一章会详细讲但先剧透一句如果你要生成一个符合某种规律的新列表优先用推导式别用 for 循环一遍遍 append。squares [x * x for x in range(10)]第四种也是新手容易忽略的split()字符串分割结果本身就是列表这是处理文本时的高频操作row 张三,85,北京.split(,) # [张三, 85, 北京]2.2 负数索引与左闭右开的切片规则创建列表之后接下来就是“怎么把数据取出来”。先说索引这个大多数人知道list[0]取第一个元素序号从 0 开始。但 Python 还有个非常好用的特性——负数索引list[-1]是最后一个元素list[-2]是倒数第二个。这个特性在取最后一个、倒数几个元素时非常好用不用先算长度再减一。data [10, 20, 30, 40, 50] data[0] # 10 data[-1] # 50 data[-2] # 40再说切片格式是list[start:stop:step]这是 Python 里一个非常精悍但容易出错的功能。核心规则有三条起始位置包含结束位置不包含左闭右开步长可以省略默认是 1三个参数都可以省略data[1:3] # [20, 30]注意索引 3 的元素 40 不包含 data[:2] # [10, 20]从头开始取 data[2:] # [30, 40, 50]一直取到最后 data[::2] # [10, 30, 50]每隔一个取一个 data[::-1] # [50, 40, 30, 20, 10]逆序[::-1]这种写法是反转列表最优雅的方式比先reverse()再取要干净很多而且不影响原列表。2.3 切片越界不报错索引越界报错——这个区别必须记住很多人在热搜里搜IndexError: list index out of range基本都是索引越界问题。索引和切片在越界行为上有一个巨大的、必须记住的差异用索引取值越界直接抛异常IndexError用切片取值越界不报错能取到多少就返回多少data [10, 20, 30] print(data[5]) # IndexError: list index out of range print(data[1:99]) # [20, 30]不报错这个设计其实很方便。比如你想取“从第二个到结尾”的元素直接写data[1:]就可以不用管列表到底多长。反过来也提醒你当你遇到IndexError优先排查的是“直接用索引取值的代码”尤其是data[i]出现在循环里时一定要确认i的范围。3. 列表的增删改查每个方法背后的取舍3.1 append、extend、insert三个添加方法的区别往列表里加元素最常用的是append它把参数当作一个整体加到列表末尾lst [1, 2, 3] lst.append(4) # lst [1, 2, 3, 4]但如果你写lst.append([5, 6])得到的就是[1, 2, 3, 4, [5, 6]]——注意整个列表被当成一个元素塞进去了。很多人在这里翻车其实就是没分清append和extend。extend的作用是把一个可迭代对象里的每个元素分别扩展进列表lst [1, 2, 3] lst.extend([4, 5, 6]) # lst [1, 2, 3, 4, 5, 6] lst2 [1, 2, 3] lst2.extend(ab) # lst2 [1, 2, 3, a, b]一句话记住append是“加一个整体”extend是“把多个元素逐个并入”。insert则可以指定位置插入lst [1, 2, 3] lst.insert(0, 100) # lst [100, 1, 2, 3]不过说实话insert因为涉及插入点后面的元素整体挪动在大列表里频繁使用性能并不好。如果只是想在头部反复插入数据我建议用collections.deque这个话题后面有机会再展开。3.2 remove、pop、del删除方式与返回值的差别删除元素的方法有好几个刚入门时很容易搞混。它们最关键的区别在于是按值删还是按索引删以及删完之后要不要拿到这个值。remove(值)按值删除第一个匹配到的元素不需要索引也没返回值。pop(索引)按索引删除并且会返回被删掉的元素不写索引时默认删最后一个。del语句按索引或切片删除不返回值也可以直接删掉整个变量。clear()清空所有元素。lst [1, 2, 3, 2, 4] lst.remove(2) # 删除第一个 2得到 [1, 3, 2, 4] lst [1, 2, 3, 4] value lst.pop(1) # value 2lst [1, 3, 4] last lst.pop() # last 4lst [1, 3] lst [1, 2, 3] del lst[0] # lst [2, 3] del lst[0:2] # 切片删除 lst [1, 2] lst.clear() # lst []有几个容易踩的细节值得单独提一下remove删除不存在的值会抛ValueError所以调用前最好先确认元素在不在列表里。pop传入越界索引同样会抛IndexError。如果你要“删除所有匹配的某个值”remove一次只删一个得循环删但循环里删元素又有坑这个稍后专门讲。3.3 修改元素索引赋值与切片替换修改列表里的某个元素最直接的是索引赋值lst [10, 20, 30] lst[0] 99 # lst [99, 20, 30]如果一次想改一段可以用切片替换lst [10, 20, 30, 40] lst[1:3] [200, 300] # lst [10, 200, 300, 40]这里有个有意思的细节切片替换时右侧的列表长度可以和原切片长度不一样。比如lst[1:3] [1, 2, 3, 4]结果列表会变长。这个特性在日常数据处理时很实用但也说明切片赋值不是简单的“映射”而是先把切片位置挖空再把右侧元素依次塞进去。理解了这一点你就能预测各种奇奇怪怪的替换结果而不至于一脸懵。3.4 循环过程中删元素是坑用切片副本解决“边遍历边删除”是新手最容易想当然写错的一段代码。比如想删除一个列表中所有小于 0 的数lst [-1, 3, -2, 5, -7, 9] for x in lst: if x 0: lst.remove(x) # 得到的并不是 [-1, 3, -2, 5, -7, 9] 过滤后的结果 print(lst) # 我实际跑出来是 [3, 5, -7, 9]为什么因为你一边遍历一边修改列表的长度迭代器内部的下标会在删除元素后发生错位。remove(-1)之后原来下标 1 的元素3变成了新下标 0而 for 循环继续往下走可能就跳过了原本该检查的元素。这个 bug 很隐蔽也不是必现所以更难查。一个简单又可靠的方案是遍历原列表的副本在副本上遍历在原列表上删除lst [-1, 3, -2, 5, -7, 9] for x in lst[:]: # 注意遍历的是切片副本 if x 0: lst.remove(x)或者干脆用列表推导式生成新列表一次搞定lst [x for x in lst if x 0]我个人的习惯是能生成新列表就不用原地删除代码更清晰也少了很多跟踪索引的麻烦。4. 遍历与列表推导式从能跑到写得漂亮4.1 for循环遍历与enumerate的用途遍历列表是最高频的操作。最基本的写法自然是fruits [apple, banana, cherry] for fruit in fruits: print(fruit)如果你还需要知道当前元素的下标第一反应可能是for i in range(len(fruits)): print(i, fruits[i])这当然没错但更 Pythonic 的写法是用enumeratefor i, fruit in enumerate(fruits): print(i, fruit)enumerate返回一个包含下标和值的可迭代对象一个循环里同时拿到索引和元素代码也干净很多。它还有一个可选参数start可以从指定数字开始计数for i, fruit in enumerate(fruits, start1): print(i, fruit) # 1 apple, 2 banana, ...别小看start在很多业务场景里你要给用户展示序号“从 1 开始”而不是“从 0 开始”这个参数就能让你少写一个加一操作。4.2 列表推导式的写法与过滤列表推导式是 Python 里非常优雅的一项能力本质是“用一个表达式生成一个新列表”。基本语法是[expression for item in iterable if condition]举例生成 0 到 9 的平方squares [x * x for x in range(10)] # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]过滤出 1 到 100 里的奇数odds [x for x in range(1, 101) if x % 2 1]把字符串统一转大写words [hello, world] upper_words [w.upper() for w in words] # [HELLO, WORLD]说实话列表推导式等价的 for 循环写成result [] for x in range(10): result.append(x * x)两种写法功能完全一样但推导式少了几行可读性也更高。更重要的是在 CPython 里列表推导式是经过专门优化的跑起来通常比等价的 for 循环 append 要快一些虽然差距在几千条数据时感觉不到但在几万、几十万条数据时就会体现出优势。4.3 推导式的性能优势和可读性边界不过凡事都有边界。列表推导式一旦嵌套多层比如“列表里套列表再来三个 if 和两个 for”代码就会变得非常难读# 这种代码我看一遍要停下来缓一缓 matrix [[x * y for y in range(1, 6)] for x in range(1, 6) if x % 2 0]遇到这种情况我建议拆分步骤先算出matrix再用普通 for 循环处理逻辑或者用函数封装。可读性远比“少写两行”重要。你写的代码首先是给人看的其次才是给机器跑的。推导式好但别贪。5. 排序、反转与去重三个高频实战场景5.1 sort()和sorted()原地修改还是返回新的排序是日常开发里绕不开的需求。Python 里有两个排序入口很多人刚学的时候搞不清到底该用哪个list.sort()原地排序直接修改原列表返回None。sorted(list)返回一个新的已排序列表原列表不变。nums [3, 1, 4, 1, 5] nums.sort() print(nums) # [1, 1, 3, 4, 5] nums2 [3, 1, 4, 1, 5] sorted_nums sorted(nums2) print(nums2) # [3, 1, 4, 1, 5]原列表不变 print(sorted_nums) # [1, 1, 3, 4, 5]选哪一个我的经验是如果你不需要保留原列表就直接sort()少占用一份内存如果需要保留原顺序或者你要排序的结果只是中间变量就用sorted()。还有个非常隐蔽的坑list.sort()返回None如果你写成new_list nums.sort()那new_list就是None而不是排序后的列表。这个 bug 我在不少新人的代码里见过而且很难发现因为程序不会报错。5.2 key参数与lambda表达式按指定规则排序默认情况下数字按大小排序字符串按字典序排序。但真实业务里经常要按“对象的某个字段”排序。这时候就要用key参数students [(张三, 85), (李四, 92), (王五, 78)] # 按成绩排序 students.sort(keylambda x: x[1]) print(students) # [(王五, 78), (张三, 85), (李四, 92)] # 按成绩从高到低 students.sort(keylambda x: x[1], reverseTrue)key参数接收一个函数这个函数输入列表里的元素返回用于排序的“排序键”。排序时 Python 会拿这个“排序键”进行比较。你甚至可以按字符串长度排序、按日期的年月份排序逻辑都是一样的。再进阶一点如果你要对排序键做“先按第一个字段排再按第二个字段排”可以构造一个元组作为 keyitems [(apple, 2), (banana, 1), (cherry, 2), (date, 1)] items.sort(keylambda x: (x[1], x[0])) # 先按数字升序数字相同时按字母升序这个手法在处理带多个维度的数据时非常实用。5.3 反转列表的两种做法反转列表也有两个入口lst [1, 2, 3, 4] lst.reverse() # 原地反转 reversed_lst lst[::-1] # 返回新列表lst.reverse()修改的是原列表返回Nonelst[::-1]返回一个反转后的新列表原列表不变。如果你只是想在循环里临时倒序遍历其实还有更省内存的方式for x in reversed(lst): print(x)reversed(lst)返回一个迭代器不会真的再复制一份列表内存友好。这三种反转方式各有用途别只会一种。5.4 列表去重的三种实现方式对比去重也是高频需求。方法很多但实用程度和坑各不相同。方法一用set最简单但会打乱顺序lst [3, 1, 3, 2, 1, 4] new_lst list(set(lst)) # 结果是乱序的比如 [1, 2, 3, 4] 还是 [3, 1, 4, 2] 都不确定如果你不关心顺序这是最优解一行搞定而且很快。方法二用循环保持原始顺序lst [3, 1, 3, 2, 1, 4] seen set() result [] for x in lst: if x not in seen: seen.add(x) result.append(x) # result [3, 1, 2, 4]顺序保留这里seen集合保证查找快速同时用result列表保留顺序。两个容器分工明确。方法三用dict.fromkeys()这也是一种很简洁的保持顺序去重写法因为 Python 3.7 之后字典天然有序lst [3, 1, 3, 2, 1, 4] result list(dict.fromkeys(lst)) # [3, 1, 2, 4]我个人更推方法一和方法二。方法一适合不在乎顺序的场景方法二适合需要保持原顺序的场景。dict.fromkeys()写法虽然短但新手读到fromkeys时往往不理解在干什么可读性不如方法二。6. 列表复制的大坑赋值、浅拷贝、深拷贝6.1 等号赋值到底复制了什么我见过太多人在这里翻车。先看一段代码a [1, 2, 3] b a b.append(4) print(a) # [1, 2, 3, 4] 为什么a也变了因为在 Python 里变量是“引用”b a只是让b指向了a指向的那个列表对象。内存里只有一个列表a和b是它的两个名字。所以你通过b修改列表等价于修改了a看到的同一个对象。如果你想要一份“单独的数据副本”必须显式地去复制而不是赋值。最简单的b a[:] # 切片复制 b a.copy() # copy 方法这两个都会创建新列表对象让a和b互不影响。6.2 copy与deepcopy嵌套列表的翻车现场但你以为copy()就万事大吉了再看一个例子a [[1, 2], [3, 4]] b a.copy() b[0].append(99) print(a) # [[1, 2, 99], [3, 4]]a还是变了。为什么因为copy()是浅拷贝它只复制了外层列表外层列表里的元素还是指向原来的内层列表对象。这就好比你把一整盒卡片复制了一份但盒子里装的每张卡片还是原来的那几张你拿着复制盒去改某张卡片原盒里的那张同样被改了。如果你要彻底复制一个包含嵌套结构的多层列表必须用copy.deepcopyimport copy a [[1, 2], [3, 4]] b copy.deepcopy(a) b[0].append(99) print(a) # [[1, 2], [3, 4]]不受影响deepcopy会递归地复制内部所有对象相当于把整棵树都复制了一份。代价是速度慢、内存开销大所以用的时候也要权衡。6.3 什么时候用什么复制方式简单归纳一下我的使用习惯一维列表包含字符串、数字、布尔等不可变对象用a[:]或a.copy()就够。二维或嵌套列表内部还有可变对象用copy.deepcopy(a)。你确定要两个变量指向同一个列表、目的是共享更新用b a但必须心里清楚这不是复制。这个知识点在写函数返回值、缓存数据、初始化多个相似结构时特别容易踩雷。每次你写出b a之后都应该下意识问一句我这是要“同一个对象”还是“一份新数据”7. 列表与元组、集合、字典选型对比与建议7.1 四个内置容器的核心区别Python 内置了列表list、元组tuple、集合set、字典dict四种容器很多新手看到四种选择就懵了。其实它们的核心区别可以归到三个维度是否有序、是否可变、能否存储重复元素。容器是否有序是否可变是否允许重复典型用途列表 list有序可变允许保持顺序的一组数据元组 tuple有序不可变允许固定不可变的数据组合集合 set无序可变不允许去重、成员判断字典 dict有序插入序可变键不允许重复键值映射注意Python 3.7 之后字典会保持键的插入顺序这在绝大多数场景下可以当“有序键值对”用集合则仍然是无序的迭代顺序不保证稳定。7.2 不同业务场景的选型建议具体到项目里我的选择思路大概是这样的数据有明确先后顺序、可能要按序号取第几个选列表比如文章列表、订单流水、排行榜。数据一旦创建就不该被随意改动比如坐标点(x, y)、数据库连接配置选元组它能防手滑还能作为字典的键。要做去重、求交集并集差集、频繁判断某个值在不在集合里选集合它的成员判断时间复杂度是 O(1)比列表的 O(n) 快得多。要根据某个键快速查值比如用户名查用户信息、订单号查订单详情选字典按键查找 O(1)。另外很多人容易忽略“列表里频繁按值查找”的性能问题。如果你有一个几千、几万条数据的列表又需要反复判断某元素是否存在那么把列表转成集合再做成员判断速度会快一个数量级id_list list_a id_set set(id_list) if user_id in id_set: # O(1) ...这个优化在数据量小的时候看不出区别数据量一大就会很明显。但也别忘了转 set 会去重如果重复数据对你后续逻辑有影响那就需要先想清楚。最后再分享一个我在实战里的习惯学列表的时候我一直建议身边的新人做一件事把所有列表常用方法的基础案例写成一个小脚本自己跑一遍故意把参数传错、故意看看越界报什么错再故意把append换成extend试一次。我的经验是光看文档十遍不如自己跑通三个“错误案例”。因为很多报错信息和怪异行为只有真正触发一次你才不会在项目里遇到时慌。比如我之前带的一个实习生在处理数据时连续遇到IndexError、ValueError就是因为不知道pop越界会抛异常、remove找不到会抛异常。他跑了几次错误案例之后后面写代码就稳了很多。列表是基础但它值得你花一下午去“折腾”折腾明白了后续写任何数据处理代码都会顺手很多。