
学 Python 的第七天我想专门写一篇关于列表的笔记。列表是 Python 里最常用的数据容器几乎你写的每一段程序都会跟它打交道不管是最简单的保存几个名字还是后面做数据分析、写爬虫、处理接口返回的 JSON都离不开它。这篇内容适合刚装好 Python、还没写过几行代码的纯新手也适合已经会写for循环但对列表理解得不够扎实的同学。我会把创建、增删改查、排序、复制、推导式这些日常高频操作拆开讲重点讲“为什么这样做”以及背后的效率问题最后用一个完整的小案例把知识点串起来方便你照着敲一遍。1. 先别写代码搞明白“数据容器”到底在装什么1.1 列表解决的是“多个数据怎么存”的问题先忘掉电脑想一下日常生活。你要去超市买很多东西总不能给每一样东西都单独起个代号比如apple 苹果、milk 牛奶、bread 面包然后后面每次用到这些东西都去翻变量名那程序会乱成一锅粥。正常人的做法是拿一张购物清单把所有要买的东西按顺序写在上面一张纸就搞定。列表在整个 Python 世界里干的就是这件事它帮你把多个数据按顺序收在一个变量里。你可以把它看成一口可以随时往里面放东西的箱子放进去的数据都有编号你想拿第几个就能拿第几个。这种“能装很多东西的变量”在编程里就叫容器而列表是容器家族里最顺手的一种。这里还要强调一下顺序。列表里的元素不是乱放的你放进去的顺序会被牢牢记住后面对它做操作也都是基于这个顺序展开的。这一点非常关键因为它意味着你可以用“排在第几位”来定位和修改数据也因为这个顺序列表才能支持后面要讲的切片、排序、反转等操作。1.2 列表、元组、集合、字典为什么要先学列表Python 的容器不止列表一个元组、集合、字典也都是容器。很多人刚学的时候会混乱我先用一张表把它们的核心区别摆出来你心里就有底了。容器类型是否有序是否可变是否允许重复怎么取数据列表 list有序可变允许按索引或者切片元组 tuple有序不可变允许按索引或切片集合 set无序可变不允许没有索引只能遍历或判断存在字典 dict键值对有序可变键不允许重复通过键取值从这张表可以看到列表的定位非常特殊它既能保持元素顺序又能修改还允许重复上手门槛最低。元组可以理解成“不可修改的列表”集合擅长去重和快速查找字典擅长“按名字找值”但都属于后续进阶内容。作为第一天接触容器的你先把列表用熟后面的很多概念都会围绕它展开。1.3 列表里装的到底是什么对象的引用有一个不少初学者容易误解的点列表里装的真的是数据本身吗严格来说Python 里的变量保存的是对象的内存地址列表这种容器保存的也是对象的引用而不是把对象的实体复制一份放进来。这事用生活类比来解释就非常清楚。你写通讯录时联系人那一列并不是把每个人从家里搬过来装进本子里你只是记了他们的住址。调用联系人就是用住址找人。Python 的列表对元素也是这个逻辑它保存的是“指向真实数据的位置”。正因为存的是引用列表才能同时装下各种不同的类型。比如下面这个列表mixed [1, Python, 3.14, [1, 2], {name: 张三}]数字、字符串、小数、列表、字典全可以塞进同一个容器里。很多从 C 语言过来的人第一次见到这种写法会觉得不习惯因为在 C 语言里数组要求同一类型。但 Python 是动态语言列表它是一种通用的顺序容器不做这种类型限制这个特性让写代码变得特别灵活但也提醒我们要留意列表元素到底是不是你想要的结构完整。2. 创建列表和索引取值这几个细节最容易被忽略2.1 四种最常见的创建方式创建列表最简单的方式是直接写方括号元素之间用逗号隔开fruits [苹果, 香蕉, 梨] empty_list [] # 空列表 numbers [1, 2, 3]第二种方式是使用内置的list()函数可以把其他可迭代对象转成列表characters list(hello) print(characters) # [h, e, l, l, o] nums list(range(5)) print(nums) # [0, 1, 2, 3, 4]第三种方式是使用字符串的split()方法切分字符串这个方法在实际开发中非常常用。比如从配置或 CSV 文件里读出来的是一行字符串想把它变成列表就可以这样做s 苹果,香蕉,梨 fruits s.split(,) print(fruits) # [苹果, 香蕉, 梨]第四种方式是列表推导式它属于比较进阶的创建方式我会在后面单独开一节讲。现在你只要知道列表推导式可以在一行里生成新的列表比如生成 0 到 9 的平方数。2.2 索引从 0 开始还可以用负数从尾部取列表里的每个元素都有编号这个编号叫索引。Python 的索引从 0 开始也就是说第一个元素的下标是 0不是 1。这是新手最容易产生挫败感的地方其实没有太多道理语言就是这么规定下来的你习惯就好fruits [苹果, 香蕉, 梨] print(fruits[0]) # 苹果 print(fruits[1]) # 香蕉 print(fruits[2]) # 梨索引还有一个很实用的特性可以用负数。-1表示最后一个元素-2表示倒数第二个依次类推。写业务代码时经常需要快速拿到最后一条记录这时候负数索引就是神器print(fruits[-1]) # 梨 print(fruits[-2]) # 香蕉在使用索引时最常出现的报错是IndexError: list index out of range也就是越界了。我自己的习惯是写代码前先心里算一下列表的长度特别是在用循环访问列表时宁可在代码前面加一个if not lst:判断空列表也不要直接lst[0]避免因空列表直接崩溃。2.3 切片切出来的是一个全新的列表切片的语法是list[start:end:step]它能从列表里取出一段子列表。需要注意两点结束索引是不包含的也就是“左闭右开”切片返回的是一个新列表并不会影响原来的列表nums [0, 1, 2, 3, 4, 5] sub nums[1:4] print(sub) # [1, 2, 3] 注意索引 4 对应的 4 不包含在内 print(nums[:3]) # [0, 1, 2] 从开头切 print(nums[3:]) # [3, 4, 5] 一直切到末尾 print(nums[:]) # 整列表复制但也是浅拷贝后面会单独讲step 控制取元素的步长不写默认为 1。步长还可以是负数负数意味着从右往左取。[::-1]这个写法特别出名因为它可以快速实现列表反转nums [1, 2, 3] print(nums[::-1]) # [3, 2, 1]在这里提醒一句切片不会因为索引超出范围而报错。比如长度为 5 的列表你去切[3:100]它只会把能取到的部分给你不会抛异常。这个特性和普通索引完全不同写算法时可以利用它来简化边界条件。2.4 嵌套列表列表里可以再装列表列表的元素可以是任何东西当然也包括另一个列表。这种结构叫嵌套列表非常适合用来表示二维表格、矩阵、日历、成绩表等场景。matrix [ [1, 2, 3], [4, 5, 6], [7, 8, 9] ] print(matrix[0]) # [1, 2, 3] print(matrix[0][1]) # 2matrix[0]先取到第一行[1, 2, 3]再对它做[1]操作就取到了第二列的值 2。嵌套再深些就是三维结构但写法都是一样的一层一层用索引往下取。初学者在做这种操作时建议先把每一步打印出来看不要直接想一层嵌套的结果等熟悉了之后再跳步思考。3. 增删改查四件套用的时候要清楚背后的成本3.1 append 和 extend一个是放整块一个是拆开放往列表里加元素最常用的是append()它会在列表末尾追加一个元素。注意它追加的是“一个”元素哪怕你传进去一个列表它也只把这个列表当成一个元素放进去lst [1, 2, 3] lst.append([4, 5]) print(lst) # [1, 2, 3, [4, 5]] 长度是 4不是 5如果你想把另一个列表里的所有元素都拆开加进去应该用extend()lst [1, 2, 3] lst.extend([4, 5]) print(lst) # [1, 2, 3, 4, 5]需要解释一下效率问题。append往末尾追加元素绝大多数时候是非常快的因为列表在内存里有预留的空间不够了会自动扩容。这种操作的时间级别可以看作是 O(1)也就是几乎和列表多长没关系。而insert(0, x)这种在开头插入的写法由于插入后后面所有元素都要往后挪代价是 O(n)列表越长越慢。所以当你需要频繁在开头插入数据时别盯着insert(0, x)硬用。如果数据规模很大可以考虑先用append收集最后统一反转一次或者直接使用collections.deque这是后续内容今天先记住这个思路。3.2 insert指定位置插入但要留意位置合法性insert(pos, element)可以把元素放在指定索引位置。比如在索引 1 的位置插入一个值lst [10, 20, 30] lst.insert(1, 15) print(lst) # [10, 15, 20, 30]位置也是可以超界的。索引传得过大元素会直接被放到列表末尾传得过小会被放到开头。Python 这样做有它的好处但新手往往会被这种“宽容”搞迷糊建议不要依赖这个特性尽量先确认索引是否合理。如果你需要经常在开头插入性能问题请参考上面一条那是真实工程里非常常见的性能瓶颈。3.3 删除remove、pop、del、clear 各有归宿删除操作的写法很多很容易混乱我按使用场景给你归纳一下。按值删除用remove()lst [3, 1, 4, 1, 5] lst.remove(1) print(lst) # [3, 4, 1, 5]只删掉第一个出现的 1remove()背后做的事情是遍历列表找到第一个匹配的元素然后把后面的元素往前挪。所以它是 O(n) 的列表越长越慢。如果值根本不存在会抛出ValueError最好先判断一下in或者用try...except包裹。按索引删除用pop()或dellst [3, 1, 4, 1, 5] last lst.pop() # 弹出最后一个 print(last, lst) # 5 [3, 1, 4, 1] second lst.pop(1) # 弹出索引 1 的元素 print(second, lst) # 1 [3, 4, 1]pop()末尾弹出是 O(1) 的特别适合用来实现栈结构但pop(0)需要把后面的元素全部往前挪就是 O(n)。如果你看到有人在大列表里反复调用pop(0)性能就会很糟糕。del是语句可以按索引删也可以切片删lst [1, 2, 3, 4, 5] del lst[0] print(lst) # [2, 3, 4, 5] del lst[1:3] print(lst) # [2, 5]清空列表用clear()等价于del lst[:]lst [1, 2, 3] lst.clear() print(lst) # []我把几个删除方式放到一张表里方便对照方法/语句按什么删除是否返回值常见复杂度remove(值)按值删除第一个无O(n)pop()按索引删除末尾返回被删值O(1)pop(索引)按索引删除指定位置返回被删值O(n)del lst[索引]按索引删除无O(n)clear()清空全部无O(n)3.4 查找判断in、index、count判断一个元素在不在列表里最直接的就是用infruits [苹果, 香蕉, 梨] print(苹果 in fruits) # True print(葡萄 in fruits) # False想知道某个元素在哪个位置用index()fruits [苹果, 香蕉, 梨] pos fruits.index(香蕉) print(pos) # 1index()找到的是第一个匹配项的位置如果找不到会抛ValueError。想统计某元素出现多少次用count()nums [1, 2, 2, 2, 3] print(nums.count(2)) # 3这里给个实用经验in和index本质都是顺序查找时间复杂度都是 O(n)。如果你只是判断一个元素是否存在并且这个列表特别长、需要频繁查询把它转成集合set(lst)往往能大幅提升速度因为集合底层是哈希表查询接近 O(1)。但要注意转换本身也有开销而且集合会丢失顺序和重复项所以要在理解清楚需求后再做优化别盲目转换。4. 排序、反转与高级排序sort 和 sorted 别再用混了4.1 sort 和 sorted 核心区别原地修改还是新建列表这是我在培训初学者时见得最多、最容易写错的点。list.sort()是列表自带的方法它会对列表进行原地排序也就是说排序直接修改了原列表不会返回一个新列表。它返回值是None所以千万别写lst lst.sort()那样会把列表变成None后面再用就报错nums [3, 1, 2] new_nums nums.sort() print(nums) # [1, 2, 3] print(new_nums) # None这就是个坑sorted()是 Python 内置函数它接收任意可迭代对象返回一个新的排好序的列表原列表不会动nums [3, 1, 2] new_nums sorted(nums) print(nums) # [3, 1, 2] print(new_nums) # [1, 2, 3]如果你的程序后面还需要用原列表建议直接用sorted()避免因为原数据被改掉而出现诡异的问题。如果你确定要对这个列表本身排序且内存有限那sort()原地排更省内存。4.2 反转reverse 方法、[::-1]、reversed()怎么选反转也分原地和非原地。列表的.reverse()方法会原地反转返回None。如果你想得到一个新列表可以用lst[::-1]切片或者用内置函数reversed()生成一个迭代器再用list()转回列表nums [1, 2, 3] nums.reverse() print(nums) # [3, 2, 1] nums [1, 2, 3] print(nums[::-1]) # [3, 2, 1] 新列表 print(list(reversed(nums))) # [3, 2, 1]在排序时想从大到小不用先排完再反转直接给sort()或sorted()传reverseTruenums [3, 1, 2] nums.sort(reverseTrue) print(nums) # [3, 2, 1] nums [3, 1, 2] print(sorted(nums, reverseTrue)) # [3, 2, 1]4.3 key 函数按长度、按字符串、按某个规则排序很多时候列表元素不是简单的数字比如你想按字符串的长度排或者按对象的某个属性排这时候就要用到key参数。key接收一个函数列表里的每个元素都会先经过这个函数“提取”出一个数值然后根据这个数值排序。words [python, java, c, rust] words.sort(keylen) print(words) # [c, rust, java, python]按字符串本身的字母顺序排时如果不希望大写字母干扰大小写比较可以用str.lower做 keynames [Alice, bob, Carol] print(sorted(names, keystr.lower)) # [Alice, bob, Carol]这里多解释一句为什么要用 key 而不是自定义比较函数。Python 3 里的list.sort()和sorted()只支持key已经不支持很早以前那种需要反复比较两个元素的方式了。因为 key 函数对每个元素只调用一次整体效率高很多所以写的时候也尽量用简单的 key别在 key 里嵌套特别复杂的计算。排序过程中key被调用的次数是 O(n)而排序本身是 O(n log n)。4.4 稳定排序与多关键字先按哪个排再按哪个排Python 的排序是稳定排序这意味着当两个元素的 key 值相同时它们在原列表里的先后顺序会保留下来。这个特性非常有用可以用来实现多级排序。最常见的做法是“分步排序”先按次要条件排再按主要条件排因为稳定排序会保留之前的顺序。举个例子现在有一批学生成绩格式是(姓名, 年龄)我想先按年龄从小到大排再按名字第一个字母排students [(张三, 20), (李四, 19), (王五, 20)] # 先按名字排再按年龄排 students.sort(keylambda s: s[0]) students.sort(keylambda s: s[1]) print(students) # [(李四, 19), (张三, 20), (王五, 20)]也可以一口气用一个 key 元组完成多关键字排序。元组比较时会先比较第一个元素相同再比较第二个以此类推students [(张三, 20), (李四, 19), (王五, 20)] students.sort(keylambda s: (s[1], s[0])) print(students) # [(李四, 19), (张三, 20), (王五, 20)]如果希望某个字段降序可以在元组里取负数比如(-s[1], s[0])就是先按s[1]降序再按s[0]升序。数值可以直接取负字符串则不适合这种方式可以结合reverse或分步排序。5. 复制列表时浅拷贝是新手最容易踩中的坑5.1 赋值不是复制lst2 lst1改的是同一份数据最容易犯的错误就是认为lst2 lst1能把列表复制一份。实际上这个写法只是让lst2指向了和lst1完全相同的列表对象内存里还是同一个列表并没有新建。lst1 [1, 2, 3] lst2 lst1 lst2.append(4) print(lst1) # [1, 2, 3, 4] print(lst2) # [1, 2, 3, 4]看到没你改了lst2lst1也被改掉了。用生活化的话来说你并没有把它抄一遍只是多拿了一份住址两个名字指向同一所房子。想要真正复制一份数据需要用到下面几种浅拷贝方法。5.2 三种浅拷贝方式copy()、切片、list()浅拷贝的意思是把最外层列表复制一份但里面的元素仍然是原来的引用。对一维列表来说如果元素都是不可变对象整数、字符串浅拷贝已经够用了lst1 [1, 2, 3] lst2 lst1.copy() lst2.append(4) print(lst1) # [1, 2, 3] print(lst2) # [1, 2, 3, 4]除了.copy()方法还有两种常见写法lst2 lst1[:] # 切片复制同样是最外层新列表 lst2 list(lst1) # 用 list() 构造函数这三种方式本质都是浅拷贝都能让一维不可变元素的列表实现“大部分独立”。但如果列表里装的是可变对象比如嵌套列表或字典浅拷贝就不安全了问题会在嵌套层暴露出来。5.3 嵌套列表必须用 deepcopy 才不会互相影响看这个例子lst1 [[1, 2], [3, 4]] lst2 lst1.copy() lst2[0].append(99) print(lst1) # [[1, 2, 99], [3, 4]] print(lst2) # [[1, 2, 99], [3, 4]]我明明用了copy()怎么改lst2里的小列表lst1也跟着变了原因就是浅拷贝只复制了外层大列表大列表里保存的还是指向内层小列表的引用。复制出来的lst2[0]和原来的lst1[0]其实指向同一个列表。解决办法是使用copy模块的deepcopy()它会递归地把所有层都复制一遍复制出来的是一个完全独立的列表import copy lst1 [[1, 2], [3, 4]] lst2 copy.deepcopy(lst1) lst2[0].append(99) print(lst1) # [[1, 2], [3, 4]] print(lst2) # [[1, 2, 99], [3, 4]]我的建议是只要列表里出现了嵌套的可变结构要么直接使用deepcopy要么确保你已经完全清楚浅拷贝的边界在哪里。否则项目越大因为共享引用导致的“神奇修改”就越难排查这类 bug 非常隐蔽等到上线之后再发现会非常痛苦。6. 列表推导式一行搞定循环但别为了短而写6.1 从循环到一行推导式是怎么来的普通写法是定义一个空列表然后循环往里面追加squares [] for x in range(5): squares.append(x * x) print(squares) # [0, 1, 4, 9, 16]写成列表推导式就是一行squares [x * x for x in range(5)] print(squares) # [0, 1, 4, 9, 16]推导式的结构可以理解为[表达式 for 变量 in 可迭代对象]。前面的x * x是新列表里每个元素长什么样后面的for x in range(5)决定从哪来。和上面的循环相比推导式更简洁也更省事日常代码里大量应用。我第一次流畅使用它之后写小工具的效率提升很明显。6.2 给推导式加条件只保留想要的元素如果循环里面还有if判断也可以放进推导式。举个例子从 0 到 9 里只取出偶数nums [x for x in range(10) if x % 2 0] print(nums) # [0, 2, 4, 6, 8]结构变成[表达式 for 变量 in 可迭代对象 if 条件]。这个条件在for的后面、在表达式后面千万别把它写到表达式前面去这是一个很小但很容易写错的点。如果想在表达式里做判断也可以使用三元表达式但务必注意可读性。比如把数字转成“正数”或“负数”标签nums [-2, 0, 3, -1] labels [正数 if x 0 else 非正数 for x in nums] print(labels) # [非正数, 非正数, 正数, 非正数]6.3 嵌套推导式先读 for再读后面的 for推导式里还可以再写 for用来处理嵌套列表。例如把二维矩阵展平普通写法要两重循环matrix [[1, 2], [3, 4], [5, 6]] flat [] for row in matrix: for x in row: flat.append(x) print(flat) # [1, 2, 3, 4, 5, 6]写成推导式是flat [x for row in matrix for x in row] print(flat) # [1, 2, 3, 4, 5, 6]这里容易犯迷糊的地方是 for 的顺序。你可以这样记忆推导式里的for顺序和普通嵌套循环的书写顺序完全一致外层循环写前面内层循环写后面最前面还是表达式。别看到for多就害怕把每个 for 指代的变量理清楚一次性记牢固以后就不会乱了。6.4 什么时候别用推导式推导式虽然香但有一个边界不要为了“短”而牺牲可读性。我见过一些很夸张的写法推导式里套了三层 for还带条件和三元表达式读起来像在解谜语。如果你自己写的时候都要先在草稿纸上演算一遍那读你代码的人一定会很痛苦。我的原则是能用一个 for 带一个条件解决的用推导式超过两个 for或者逻辑里带了明显副作用比如在推导式里调用print、修改外部状态那就老老实实写普通循环。代码首先是给人看的压缩到极致从来不是目的。7. 用列表解决一个真实问题统计一段文本的词语长度分布7.1 需求拆解把今天学的都用上学了这么多最好能串起来用一次。我选一个非常常见的场景给你一段文本统计里面每个单词的长度按长度从短到长排列输出每个长度出现了多少次。拆开来看这个任务需要用到字符串拆分成单词列表用列表推导式生成长度列表用set或者列表去重得到长度种类用列表的sort或sorted排序用count()统计次数。整个过程不需要很复杂的数据结构一个列表从头用到尾恰好能把今天讲的很多知识点串起来。7.2 完整代码从输入到输出假设我们的文本是这样一段话text Python list is a very useful data container去掉大小写影响用空格拆分成单词列表先输出原列表words text.split() print(words) # [Python, list, is, a, very, useful, data, container]用列表推导式把每个单词的长度算出来lengths [len(w) for w in words] print(lengths) # [6, 4, 2, 1, 4, 6, 4, 9]找出有哪些不同的长度并排序unique_lengths sorted(set(lengths)) print(unique_lengths) # [1, 2, 4, 6, 9]对每个长度统计它在lengths里出现了几次for length in unique_lengths: print(f长度为 {length} 的单词有 {lengths.count(length)} 个)输出结果就是长度为 1 的单词有 1 个 长度为 2 的单词有 1 个 长度为 4 的单词有 3 个 长度为 6 的单词有 2 个 长度为 9 的单词有 1 个如果想拿到“长度分布最多的是哪个长度”可以用max加 key或者直接在刚才循环里记录最大次数。这些已经超出今天的范围但你可以自己试试。7.3 我在做这类统计时注意到的小细节第一split()默认按空白字符切分并且会自动处理连续多个空格的情况比手动写split( )更稳。但如果文本里有换行、多个空格混在一起用默认split()是最省心的。第二中英文混合文本不能直接用空格拆分需要用jieba这类分词库才能按词切分当前的代码只适合英文场景。第三如果文本特别长比如几十万字lengths.count(length)这样反复遍历效率不高更好的做法是先用collections.Counter统计但那是另一种数据容器的故事了。今天先用列表把整个思路跑通比一上来就追求高效更重要。我在实际带新人的时候发现第 7 天这个节点特别关键。列表的很多操作看起来简单但是只有自己亲手把每个方法的返回值、原列表是否变化、复杂度大概是多少都验证一遍后面学循环、函数、字典时才不会被这些小坑绊住。建议你今晚把这篇里的代码逐段敲一遍尤其是sort返回None、浅拷贝嵌套列表这两个坑多踩一次印象就会深一分。明天我们要开始聊循环到时候你会发现几乎每个循环都在“揉”列表这种东西。