学Python快两周了,如果你问我在日常写代码时最离不开的数据结构是什么,我的答案一定会是列表。你可能已经接触过变量、字符串和数字,但这些一次只能存一个值,真遇到“一组数据”就难免手忙脚乱。列表就是用来装一组数据的最基础容器,它既能存数字、字符串,也能装其他列表,还能随时增删改查。这一篇,我不打算只给你罗列API,而是把列表的底层逻辑、高频操作、容易踩的坑一次性讲清楚,争取让零基础的你也能直接上手用起来。
这个Day 07的内容,适合刚学完变量和基础语法、准备接触数据结构的朋友。不管是做数据分析、写爬虫脚本,还是以后学算法,列表都是绕不开的第一块基石。我会用大量对比和实际场景来说明,看完你就能理解“为什么列表是Python最常用的数据容器”,也能在写代码时更自信地选择合适的方式。
1. 先搞清楚列表到底是什么
1.1 从生活类比理解列表
想象你有一个购物清单,上面写着“牛奶、鸡蛋、面包”。Python里的列表就是这个购物清单本身——它是一个容器,把多个元素按顺序装在一起。你可以随时往清单上添加新的东西,也可以把某个项目划掉,还可以查看第几项是什么。这个“按顺序存放”的特点很关键,它决定了列表最擅长处理有序数据。
和变量不同,变量就像一个盒子,一个盒子只能装一个东西;列表则像一排贴了标签的储物格,每个格子有自己的编号(索引),你可以通过编号快速找到对应内容。这种设计让列表既能保存数据,又能保留数据的先后关系,所以它才被广泛用于存储一组相关值,比如成绩列表、商品价格列表、用户ID列表。
1.2 列表与元组、字典、集合的关键区别
我经常看到新手把Python的四种基础容器混淆。列表、元组、字典和集合各有偏向,搞清了它们的区别,你就能知道什么时候该选列表。
| 容器类型 | 是否可变 | 是否有序 | 是否允许重复 | 典型用途 |
|---|---|---|---|---|
| 列表 list | 可变 | 有序 | 允许 | 需要频繁增删改的一组数据 |
| 元组 tuple | 不可变 | 有序 | 允许 | 只读数据、函数返回多值 |
| 字典 dict | 可变 | 有序(插入序) | 键不允许重复 | 键值对应关系 |
| 集合 set | 可变 | 无序 | 不允许重复 | 去重、集合运算 |
列表最大的优势是“可变且有序”。你需要修改、添加、删除元素时,直接用列表最顺手。元组更像“固定合同”,一旦创建就不能改,适合那些不应被修改的数据,比如坐标点、配置项。字典则是“查字典”式的映射,给你一个键能快速找到值,但如果你要遍历一堆数据并保持顺序,字典显然不如列表直观。
1.3 什么时候优先选择列表
根据我写爬虫和处理日志的经验,以下场景我几乎不会犹豫,直接用列表:
- 存储一组有序的值,比如“今天前10条热搜标题”
- 需要按索引快速访问,比如“取第三个月的数据”
- 需要循环遍历并对每个元素做相同处理
- 需要保存中间结果,后续再进一步加工
列表并不是万能的,如果你要做大量去重操作,或者需要频繁判断“某个值是否存在”,集合的哈希查找会比列表快得多。但若论通用性和上手速度,列表绝对是第一选择。
2. 列表的创建与基础操作
2.1 创建列表的四种常见方式
创建列表最直接的方法是使用方括号,元素之间用逗号分隔:
fruits = ["苹果", "香蕉", "橙子"] numbers = [1, 2, 3, 4, 5] mixed = [1, "hello", True, 3.14] # 列表可以混合类型第二种方式是用list()构造函数,适合把其他可迭代对象转成列表:
text = "python" chars = list(text) # ['p', 'y', 't', 'h', 'o', 'n'] range_list = list(range(1, 6)) # [1, 2, 3, 4, 5]第三种是用列表推导式快速生成,这个我等会儿专门讲。第四种是创建空列表,empty = [],之后通过append逐步添加。这里有个容易忽略的细节:用list()转字符串时,它会逐字符拆分,而不是把整个字符串当一个元素,这一点在处理文本时经常踩坑。
2.2 索引与切片:正着取,反着取,跳着取
列表的索引从0开始,这是计算机世界的通用习惯。你可以用正索引从前数,也可以使用负索引从尾部数:
fruits = ["苹果", "香蕉", "橙子", "葡萄"] print(fruits[0]) # 苹果 print(fruits[-1]) # 葡萄 print(fruits[-2]) # 橙子切片是列表最重要的特性之一,语法是[start:stop:step],左闭右开,取到 stop 的前一个位置:
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(nums[2:5]) # [2, 3, 4] print(nums[:3]) # [0, 1, 2] print(nums[5:]) # [5, 6, 7, 8, 9] print(nums[::2]) # [0, 2, 4, 6, 8] 步长为2,跳着取 print(nums[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] 倒序我建议新手一定要亲手在交互环境里敲一遍切片,因为stop位置“取不到”这个规则特别容易迷糊。比如nums[2:5]取的是索引2、3、4,很多第一次接触的朋友会疑惑为什么不是5个元素。你只要记住“含头不含尾”,切片的长度就等于stop - start(当步长为1时)。
2.3 添加、删除和修改元素
列表是可变的,这就意味着你可以直接修改某个位置的元素:
nums = [1, 2, 3] nums[1] = 20 # 变成了 [1, 20, 3]添加元素有四个常用方法,它们的区别和使用场景各有不同:
append(item):在末尾追加一个元素,最常用insert(index, item):在指定位置插入一个元素extend(iterable):把一个可迭代对象的元素逐个追加到末尾+运算符:拼接两个列表,生成新列表
删除元素也有多种方式:
nums = [1, 2, 3, 4, 5] nums.remove(3) # 删除第一个值为3的元素,列表变为 [1, 2, 4, 5] last = nums.pop() # 弹出并返回末尾元素,last为5 first = nums.pop(0) # 弹出并返回索引0的元素,列表变为 [2, 4] del nums[0] # 删除索引0处的元素,但不返回它remove和pop是新手最容易混淆的一对:remove按值删除,pop按索引删除且会返回被删除的值。如果列表中有多个相同值,remove只删第一个。删除不存在的值会抛出ValueError,所以删除前最好先用in判断。
2.4 列表拼接、重复和成员判断
两个列表可以直接拼接,但要注意它产生的是一个新列表,不会修改原列表:
a = [1, 2] b = [3, 4] c = a + b # [1, 2, 3, 4]重复操作使用乘号:
zeros = [0] * 5 # [0, 0, 0, 0, 0]判断某个值是否存在于列表中,用in关键字:
if "香蕉" in fruits: print("有香蕉")这里隐藏着一个性能问题:in在列表中会从头到尾逐个比较,时间复杂度是O(n);如果列表非常长且频繁做成员判断,换成集合(set)会快很多。我在处理几十万条日志时,就吃过这个亏,后来用集合代替列表,速度提升了近百倍。
3. 列表的常用方法与排序
3.1 查找元素:index、count 与 in
index(value)返回元素第一次出现的索引,如果找不到就抛出ValueError。count(value)统计元素出现次数。这两者配合in判断,能覆盖绝大多数查找需求:
alist = [10, 20, 30, 20, 40] print(alist.index(20)) # 输出1 print(alist.count(20)) # 输出2 print(30 in alist) # True使用index之前先检查元素是否存在,是比较稳妥的写法,避免程序因为抛异常而中断。如果你要找第2次出现的元素位置,可以指定起始范围:
second_index = alist.index(20, 2) # 从索引2开始找,得到3这个技巧在日志分析里很实用,比如想知道某个事件第二次发生在哪里。
3.2 排序:sort 方法与 sorted 函数
排序是列表的高频操作,我在清洗数据时几乎天天用。Python提供了两种方式:
alist.sort():原地排序,直接修改原列表,返回Nonesorted(alist):返回一个新的已排序列表,原列表不变
scores = [88, 72, 95, 60] scores.sort() # scores变为 [60, 72, 88, 95] new_scores = sorted(scores, reverse=True) # [95, 88, 72, 60]排序默认按升序,如果需要降序,两种方式都可以传reverse=True。对于字符串列表,默认按字母顺序排序;如果想按照字符串长度排序,可以用key参数:
words = ["python", "go", "java", "c"] words.sort(key=len) # 按长度升序,得到 ['c', 'go', 'java', 'python']key参数特别强大,它可以接收任意函数,比如按元组的第二个元素排序:
pairs = [(1, "a"), (3, "c"), (2, "b")] pairs.sort(key=lambda x: x[1]) # 按字母部分排序3.3 列表推导式:一行代码生成列表
列表推导式是Python中极具特色的语法,它能用一行表达式代替多行循环,既简洁又高效。基本形式是[表达式 for 变量 in 可迭代对象 if 条件]:
squares = [x ** 2 for x in range(10)] # [0, 1, 4, 9, ..., 81] evens = [x for x in range(20) if x % 2 == 0] # 偶数列表 pairs = [(x, y) for x in range(3) for y in range(3)] # 嵌套循环我建议新手先在脑中把推导式拆成普通循环,理解清楚之后再看简洁写法。比如squares对应的普通写法是:
squares = [] for x in range(10): squares.append(x ** 2)列表推导式的性能通常会比手动append更快,因为它在底层做了优化,少了一些方法调用的开销。不过,并不是所有场景都适合推导式,如果循环体逻辑很复杂,超过了三四行,我还是建议老老实实写普通循环,毕竟代码可读性也是项目维护中的硬指标。
3.4 多维列表与矩阵
列表里还能装列表,这种嵌套结构可以表示表格、矩阵等二维数据。最简单的二维列表长这样:
matrix = [ [1, 2, 3], [4, 5, 6], [7, 8, 9] ] print(matrix[1][2]) # 第2行第3列,输出6遍历二维列表一般用双重循环:
for row in matrix: for item in row: print(item, end=" ") print()用列表推导式生成二维列表时,有一个经典陷阱:如果你用[[0] * 3] * 3的方式创建,会得到一个“看起来”正常的矩阵,但里层三个列表其实是同一个对象,修改一个会同时影响另外两行。正确做法是:
matrix = [[0] * 3 for _ in range(3)]这种引用共享的问题在初学者中非常常见,我在4.2小节会详细展开解释。
4. 列表的底层原理与性能陷阱
4.1 可变对象与引用的关系
列表是可变的,这句话背后藏着一个重要的内存模型。当我们写a = [1, 2, 3]; b = a时,a和b指向的是同一个列表对象,而不是复制了一份数据。你可以验证:
a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4]很多新手在这里翻车,以为b = a是复制列表,结果修改了b发现a也变了。要真正复制列表,需要用切片a[:]、list(a)或者copy.copy(a)。理解引用的概念,是掌握Python可变对象的关键。你可以把变量名想象成标签,多个标签可以贴在同一个人身上,撕开其中一个标签并不会改变这个人。
4.2 浅拷贝与深拷贝:复制列表的正确姿势
复制列表时,如果列表里装的全是数字、字符串这种不可变对象,那么list(a)或a[:]就够用了。但如果列表里嵌套了子列表,问题就来了:
original = [[1, 2], [3, 4]] shallow = original[:] shallow[0].append(99) print(original) # [[1, 2, 99], [3, 4]]为什么原列表也变了?因为[:]做的只是浅拷贝——它创建了一个新列表,但新列表里的元素(子列表)仍然指向原来的子列表对象。要完全独立,需要使用深拷贝:
import copy deep = copy.deepcopy(original) deep[0].append(999) print(original) # 不受影响深拷贝会递归复制所有嵌套对象,代价是更慢、更耗内存。我的原则是:能用浅拷贝就不用深拷贝,只有数据嵌套层级较多且需要完全独立时,才使用deepcopy。
4.3 修改列表时遍历的坑
边遍历边修改列表,是一个经典雷区。我举个例子,你想删除列表中的所有偶数:
nums = [1, 2, 3, 4, 5, 6] for num in nums: if num % 2 == 0: nums.remove(num) print(nums) # 结果是 [1, 3, 5]?不是,实际是 [1, 3, 5] ?不对!实际运行会得到[1, 3, 5],看起来正确,但如果换成[1, 2, 4, 5, 6]这样的数据,结果就错了。因为remove会让列表元素前移,导致循环跳过下一个元素。正确的做法是创建一个新列表,或使用列表推导式:
nums = [1, 2, 3, 4, 5, 6] nums = [num for num in nums if num % 2 != 0]如果你一定要在原列表上修改,可以倒序遍历,这样删除当前元素不会影响前面尚未遍历到的部分:
nums = [1, 2, 3, 4, 5, 6] for i in range(len(nums) - 1, -1, -1): if nums[i] % 2 == 0: del nums[i]这个坑我在处理Excel数据时踩过,当时因为边遍历边删除,导致统计结果出现偏差,排查了很久才发现是列表元素索引变化引起的。记住一点:遍历时不要改变序列的长度,如果必须改,就倒着改或者用新列表。
4.4 列表性能的取舍:append 与 insert
虽然列表功能强大,但它的底层实现是“动态数组”,这决定了某些操作的性能差异巨大。往末尾追加append非常快,平均时间复杂度是O(1);但往头部或中间插入insert(0, x)、删除头部元素pop(0),需要移动后面所有元素,时间复杂度是O(n)。如果你要在头部频繁操作,更合适的是collections.deque,它专门优化了两端的插入和删除。
from collections import deque d = deque([1, 2, 3]) d.appendleft(0) # 头部追加,O(1) d.popleft() # 头部删除,O(1)还有一点,创建超长列表时要预估内存。列表会预先分配一些额外容量,避免每次追加都重新分配内存,所以当你有海量数据频繁追加时,预先调用nums = []或者尽量使用列表推导式,性能都不错。如果数据量极大并且需要数值运算,numpy数组在内存占用和计算速度上会远胜普通列表,但那是后面章节的范畴,这里先不展开。
5. 列表的常见问题与排查技巧
5.1 索引越界报错
当你访问不存在的索引时,Python会抛出IndexError: list index out of range。比如:
nums = [1, 2, 3] print(nums[3]) # IndexError负索引nums[-1]不会越界,因为它是从尾部数的。要避免越界,可以在访问前判断索引是否在len(nums)范围之内。对付不确定长度的列表,切片反而是更安全的取法——切片越界不会报错,只会返回能取到的部分:
print(nums[1:100]) # [2, 3],不会报错我写爬虫时经常遇到网页结构变化导致列表长度不对的情况,此时用切片式提取数据,能比直接取索引稳健得多。如果确实需要按索引定位,先检查len(list)再取值,是一个职业习惯。
5.2 可变默认参数:列表当默认参数的坑
给函数定义def add_item(item, target=[])时,target这个默认列表只会被创建一次,而不是每次调用都创建一个新列表。这会导致所有调用共享同一个列表:
def add_item(item, target=[]): target.append(item) return target print(add_item(1)) # [1] print(add_item(2)) # [1, 2],而不是 [2]正确写法是使用None作为默认值,在函数内部创建新列表:
def add_item(item, target=None): if target is None: target = [] target.append(item) return target这个坑几乎是面试中的常客,也经常出现在实际项目的代码审查中。牢记:可变对象(列表、字典、集合)不能直接作为函数默认参数。
5.3 extend、append 和 += 的区别
append把整个元素作为一个整体加入,如果你把一个列表append到另一个列表,结果是嵌套列表:
a = [1, 2] a.append([3, 4]) print(a) # [1, 2, [3, 4]]extend则是把可迭代对象里的元素逐个展开加入:
a = [1, 2] a.extend([3, 4]) print(a) # [1, 2, 3, 4]+=在列表上的效果等同于extend,但它会原地修改列表(如果右侧是列表)。这点和某些语言不同,Python的a = a + [3, 4]其实会创建新列表再赋值,而a += [3, 4]是原地扩展。如果你在循环中反复使用a = a + ...,性能会变差,因为每次都在创建新对象;建议直接使用extend或+=。
我之前在写一个聚合数据的函数时,就因为在循环里频繁用result = result + new_list,导致处理一万行数据时慢了将近十倍,改成result.extend(new_list)之后立刻流畅多了。
5.4 快速提取列表元素的几个小技巧
- 使用
enumerate同时获取索引和值:
for idx, val in enumerate(["a", "b", "c"]): print(idx, val)- 使用
zip并行遍历多个列表:
names = ["小明", "小红"] scores = [88, 92] for name, score in zip(names, scores): print(name, score)- 使用
max、min、sum直接对列表聚合:
data = [3, 5, 7] print(max(data)) # 7 print(min(data)) # 3 print(sum(data)) # 15这些技巧能极大简化代码,避免再用计数器去手动维护索引。遇到列表处理,先想想有没有内置函数或方法能直接用,往往比自己去写循环更高效、更不容易出错。
6. 实操案例:用列表解决真实问题
6.1 统计一段文本中单词出现的次数
假设你有一段文本,想要统计每个单词的出现次数。常规做法是用字典,但我们要先用列表做预处理:
text = "the quick brown fox jumps over the lazy dog the dog" words = text.lower().split() word_counts = {} for word in words: word_counts[word] = word_counts.get(word, 0) + 1 # 如果想按出现次数从高到低排序,可以用列表+排序 items = list(word_counts.items()) items.sort(key=lambda x: x[1], reverse=True) for word, count in items: print(word, count)这里两次用到列表:split()方法返回的就是列表,items()转列表后可以排序。整个流程清晰地展示了列表如何作为中间容器串联起数据处理流程。
6.2 列表去重并保持原顺序
利用列表 + 集合的组合,可以在保留顺序的前提下高效去重:
data = [3, 1, 2, 3, 2, 4, 1] seen = set() result = [] for item in data: if item not in seen: seen.add(item) result.append(item) print(result) # [3, 1, 2, 4]如果直接使用list(set(data)),结果顺序不固定,因为集合是无序的。在需要保持原有顺序的业务场景里,比如处理用户操作日志,上述方法是标准做法。关键点在于seen集合负责去重判断(O(1)查找),列表负责保存最终顺序。
6.3 用列表实现一个简易堆栈
堆栈是后进先出的数据结构,非常适合用列表模拟:
stack = [] stack.append(1) stack.append(2) stack.append(3) top = stack.pop() # 3 print(stack) # [1, 2]这就够了,因为列表的append和pop()都是在尾部操作,效率很高。实际中,在实现括号匹配、浏览器回退等算法时,列表就是现成的堆栈。如果你需要队列(先进先出),可以使用deque,或者用列表配合pop(0),但性能会差一些。
6.4 基于列表的分页切块
在做数据处理时,经常需要把大列表切成固定大小的小块。用列表推导式加切片就能搞定:
data = list(range(1, 11)) # [1..10] chunk_size = 3 chunks = [data[i:i + chunk_size] for i in range(0, len(data), chunk_size)] print(chunks) # [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]注意最后一组只有[10],切片并不会越界报错,这正好符合需求。这个技巧在处理批量接口请求、批量写入数据库时非常常用,能优雅地把长列表分装成小批次。
6.5 从CSV行数据中筛选符合条件的记录
假设你从CSV文件读取了一些行,每行是一个列表,现在要筛选出年龄大于30的记录:
rows = [ ["小明", 25, "北京"], ["小红", 35, "上海"], ["小刚", 28, "广州"] ] filtered = [row for row in rows if row[1] > 30] print(filtered) # [["小红", 35, "上海"]]这里列表推导式配合索引访问,一行代码完成了筛选。实际项目中,csv.reader返回的每一行就是一个列表,所以这个模式在数据处理脚本里出现的频率极高。如果你对“列表里装列表”的结构还不够熟悉,建议多写几个类似的推导式,很快就会融会贯通。
7. 写在最后的一些个人经验
学完列表基础,你会发现Python中好多操作都围绕“可迭代对象”展开,而列表是最直观的那种可迭代对象。我个人在实际操作中的体会是:与其死记硬背方法名,不如把列表类比成“可变的超市货架”,增删改查都对应货架上的动作。遇到一时不确定的操作,直接开一个交互环境,随手造一个列表试一遍,比翻文档更管用。
还有一个很实用的小技巧:调试列表操作时,多用print或者调试器观察列表的“中间状态”,特别是在循环和切片组合使用的时候。很多隐蔽的bug都是因为对列表引用和切片范围的理解不够透彻,打印中间结果能快速定位是哪里发生了变化。
列表只是Python数据容器的起点。学会了它,后面学元组、字典、集合都会轻松很多,因为它们都遵循相似的可迭代协议,而且经常互相转换。接下来的Day 08,我会继续围绕数据容器展开,讲讲字典和集合如何跟列表配合使用,到时候你会发现,列表几乎是无处不在的。你只需要把这一篇里的代码亲自敲一遍,把那些坑记在心里,就已经超过很多只囤课不练习的人了。