☰
Python列表全面指南:从索引切片到排序去重与复制陷阱
2026/10/5 4:41:35 网站建设 项目流程

如果你刚开始学 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 = 2,lst = [1, 3, 4] last = lst.pop() # last = 4,lst = [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 的写法是用enumerate:

for i, fruit in enumerate(fruits): print(i, fruit)

enumerate返回一个包含下标和值的可迭代对象,一个循环里同时拿到索引和元素,代码也干净很多。它还有一个可选参数start,可以从指定数字开始计数:

for i, fruit in enumerate(fruits, start=1): 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(key=lambda x: x[1]) print(students) # [('王五', 78), ('张三', 85), ('李四', 92)] # 按成绩从高到低 students.sort(key=lambda x: x[1], reverse=True)

key参数接收一个函数,这个函数输入列表里的元素,返回用于排序的“排序键”。排序时 Python 会拿这个“排序键”进行比较。你甚至可以按字符串长度排序、按日期的年月份排序,逻辑都是一样的。

再进阶一点,如果你要对排序键做“先按第一个字段排,再按第二个字段排”,可以构造一个元组作为 key:

items = [("apple", 2), ("banana", 1), ("cherry", 2), ("date", 1)] items.sort(key=lambda x: (x[1], x[0])) # 先按数字升序,数字相同时按字母升序

这个手法在处理带多个维度的数据时非常实用。

5.3 反转列表的两种做法

反转列表也有两个入口:

lst = [1, 2, 3, 4] lst.reverse() # 原地反转 reversed_lst = lst[::-1] # 返回新列表

lst.reverse()修改的是原列表,返回None;lst[::-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.deepcopy:

import 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找不到会抛异常。他跑了几次错误案例之后,后面写代码就稳了很多。列表是基础,但它值得你花一下午去“折腾”,折腾明白了,后续写任何数据处理代码都会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询