学Python这段时间我反复被问到的一个问题就是“数据类型里我先学哪个”我的答案永远是同一个先啃透列表。哪怕你后面的目标是做数据分析、写爬虫还是搞Web开发列表这个结构几乎会出现在每一段真实代码里。我第一次写爬虫解析HTML时打印出来的数据就是列表套字典、字典再套列表整整花了半天才把数据一层层取干净。那时候我就意识到列表不只是“能存多个东西的容器”它背后涉及的索引、切片、拷贝、迭代这些概念才是Python入门真正要跨过的坎。这篇文章我就把自己在项目里反复用过、踩过的列表知识点整理出来从创建到增删改查从切片到深拷贝适合刚入门的朋友按顺序读也适合有一定基础的人查漏补缺。1. 列表为什么值得上一篇完整的文章1.1 一个容器承载了Python最重要的心智模型在Python里列表list是最基础的可变序列类型。它最大的特点是有序、可变、可以装任意类型。你可以把整数、字符串、布尔值、浮点数、甚至另一个列表、一个字典都塞进同一个列表里这在很多静态语言里是难以想象的。mix_list [1, hello, 3.14, True, [1, 2, 3], {name: Tom}]这段代码能直接运行因为它就是一个合法的列表。也正是这种“什么都能装”的灵活性让列表成了数据交换的临时中转站。我处理日志、清洗Excel数据时经常是先读出一个列表过滤清洗后再传出去。你可以把列表理解成一条可以随时变更内容的传送带数据从A到B周转的过程里列表是最常见的中转容器。理解列表也是在为后续学字典、集合、元组打基础。比如for循环遍历列表和遍历字典的写法差异本质上就来自容器的不同特性。很多初学者觉得切片难、拷贝难其实都是因为最开始没把列表的“引用关系”搞清楚。1.2 什么时候该选元组什么时候该选列表明白列表的特性之后紧接着要回答的一个问题就是“那为什么还需要元组tuple”元组和列表长得几乎一模一样区别是元组不可变。我在项目里的选型原则很简单维度列表 list元组 tuple可变性可增删改创建后不可修改性能相对慢一些更轻量遍历和构造更快语义需要动态变化的数据固定结构如坐标、配置项作为字典键不可以可以要求不可变有一回我写一个工具函数要返回三个固定参数一开始用的是列表后来调用方拿着返回结果去当字典键直接报错“unhashable type: list”。改成元组就结束了。所以如果一段数据你拿到后就不准备改或者本来就应该保持固定结构用元组比用列表更合适。列表和元组的关系说白了就是“建议你别变”和“随时可变”的区别。刚入门时先掌握列表没错但别养成所有数据都往列表里塞的习惯固定结构用元组唯一性数据用集合查表用字典这才是正规军打法。2. 创建列表的几种姿势字面量、range 和 list()2.1 字面量方式默认的创建入口创建列表最直接的方式就是用方括号字面量empty_list [] nums [1, 2, 3, 4, 5] strs [apple, banana, cherry]这种写法可读性最好也是我写得最多的。需要注意一点空列表[]是直接创建一个新的列表对象它和list()效果一样但[]更省事、可读性更好。很多人会踩一个“默认参数共享”的坑写函数时用空列表做默认参数比如def func(items[])。这个写法看起来方便实际上每次调用如果不传参数用的都是同一个列表对象多次调用后数据会累积。def add_item(item, items[]): items.append(item) return items上面的代码跑几轮结果会出乎你的意料。正确做法是用None占位函数内部再创建列表。这是我刚工作时就踩过的坑吃了大亏才记住。2.2 用 range 批量生成数字序列第二个高频创建方式是list(range(...))。range生成的是一个可迭代对象本身不是列表但它可以非常方便地批量生成等差数列。list(range(5)) # [0, 1, 2, 3, 4] list(range(2, 10)) # [2, 3, 4, 5, 6, 7, 8, 9] list(range(0, 20, 2)) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]range(10)之所以得到0到9而不是0到10是因为它和切片规则一样遵循“左闭右开”。这个原则在Python里贯穿始终建议你第一次接触时就把它记牢起点包含终点不包含。2.3 list() 转换的三种常见来源除了用list()转换range我还经常遇到几种场景字符串转列表list(abc)得到[a, b, c]会把字符串拆成单个字符适合做逐字符处理。元组转列表list((1, 2, 3))得到[1, 2, 3]这是为了把固定数据转成可变结构。迭代器或生成器转列表list(x * 2 for x in range(3))得到[0, 2, 4]。不过要记住list()只是“浅层转换”。如果你把一个包含子列表的列表转成另一个列表list()创建的新列表里子列表还是原来的对象。这个细节会在后面拷贝那一节引出大问题。3. 索引与切片正着数、倒着数、跳着取3.1 索引从0开始倒数用负数列表是有序的每个元素都有对应索引。第一个元素索引是0不是1这是个老生常谈但永远有人错的地方。除了正向索引Python还支持负索引nums [10, 20, 30, 40, 50] nums[0] # 10 nums[-1] # 50 nums[-2] # 40-1永远代表最后一个元素-2代表倒数第二个。用负索引读取尾部数据非常方便nums[-1]比nums[len(nums)-1]不但短而且不会因为列表长度改变而出错。我经常在循环里用lst[-1]对比上一轮处理的数据。3.2 切片左闭右开边界怎么记不混淆切片语法是lst[start:stop]取的是从start到stop-1的元素。很多人卡在这里就是记不住到底取不取stop。nums [10, 20, 30, 40, 50] nums[1:3] # [20, 30]索引1和2不包含索引3 nums[:2] # [10, 20]start缺省从0开始 nums[3:] # [40, 50]stop缺省到末尾我的记忆方法是“start 是第一个要的stop 是第一个不要的。” 比如nums[1:3]就是要索引1、2不要索引3。这个规则也让你能自然地衔接两个切片nums[:2] nums[2:]能拼回整个列表就是因为第一个切片不包含索引2第二个切片又从索引2开始正好无缝对接。3.3 步长和反转切片表达式的最实用形态完整切片语法是lst[start:stop:step]step表示隔几个取一个。默认step1。nums [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] nums[::2] # [0, 2, 4, 6, 8]取偶数位 nums[1::2] # [1, 3, 5, 7, 9]取奇数位 nums[::-1] # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]反转[::-1]是我用得最多的反转写法。它不需要写循环也不需要额外开新的变量一步到位。步长为负的时候方向反过来但要格外小心start和stop的配合。比如nums[5:0:-1]是取索引5、4、3、2、1不会取到索引0。这个细节很容易绕晕我的建议是非必要不用负步长做复杂切片凡是能拆成两步做就拆成两步简单代码出错概率低。3.4 切片是浅拷贝不只是视图切片返回的是一个新列表而不是原来列表的一个“视图”。这一点和 NumPy 里的切片行为不一样很多从数值计算转过来的人容易混。original [1, 2, 3, 4] part original[1:3] part[0] 99 print(original) # [1, 2, 3, 4]不受影响这说明part是独立的列表。但是如果列表里装的是可变对象比如子列表、字典那么切片得到的元素仍然指向同一个子对象。也就是说outer [[1, 2], [3, 4]] sub outer[0:1] sub[0][0] 99 print(outer) # [[99, 2], [3, 4]]外层也变了所以“切片是浅拷贝”这句话的含义是顶层独立嵌套共享。这个特性既是便利也是坑后面拷贝那一节我还会展开讲。4. 增删改查列表方法背后的性能差异4.1 追加和扩展append、extend、insert列表最常用的两个加法操作是append和extend。区别很简单append(x)把x作为一个元素添加到末尾。extend(iterable)把可迭代对象里的每个元素分别追加到末尾。a [1, 2] a.append([3, 4]) print(a) # [1, 2, [3, 4]] b [1, 2] b.extend([3, 4]) print(b) # [1, 2, 3, 4]如果你想把两个列表合并用extend而不是append这是初学者最容易出错的点。insert(i, x)则是在指定索引位置插入元素。它的性能需要心里有数在列表头部或接近头部的位置插入需要把后面所有元素都往后挪时间复杂度是 O(n)。所以如果你频繁在列表头部插入列表并不是好选择后面我会说到deque。4.2 删除操作pop、remove、del、clear删除元素有四条路但适用场景完全不同方法作用时间复杂度pop()删除并返回最后一个元素O(1)pop(i)删除并返回索引i处的元素O(n)remove(x)删除第一个值为x的元素找不到则报错O(n)del lst[i]删除指定索引位置的元素不返回值O(n)clear()清空整个列表O(n)pop()不带参数时是最高效的删除方式适合用作“栈”的场景。remove(x)是按值删除不是按索引删除而且它只删除第一个匹配项。如果你的列表里有多个相同的值只删一个你可能需要循环删或者用列表推导式重新生成。值得提醒的是只要删除的是中间位置的元素无论pop(i)、remove还是del lst[i]都需要移动后续元素都是 O(n)。所以批量删除时与其在一个循环里反复删不如一次性用推导式过滤生成新列表代码又短又快。4.3 查询与统计in、index、count查询操作我总结成三个x in lst判断元素是否在列表里返回布尔值。lst.index(x)返回第一个值为x的索引找不到会抛ValueError。lst.count(x)统计x在列表里出现的次数。in和index的时间复杂度都是 O(n)因为列表不是哈希结构只能从头到尾逐个比较。如果你有大量频繁的“存在性判断”需求列表就不是最优解换成set会让查询复杂度降到 O(1)。fruits [apple, banana, cherry] if apple in fruits: print(存在)这段代码虽然简单但是在处理上万条数据时会明显变慢。我的习惯是数据量大且需要反复查成员关系时一开始就维护一个集合而不是每次都in一遍列表。4.4 排序sort 和 sorted 的分工列表排序有两个入口lst.sort()是就地排序直接修改原列表sorted(lst)是返回一个新的排序列表原列表不变。nums [3, 1, 4, 1, 5, 9, 2] sorted_nums sorted(nums) print(nums) # [3, 1, 4, 1, 5, 9, 2] print(sorted_nums) # [1, 1, 2, 3, 4, 5, 9]如果你还需要保留原始顺序用sorted如果数据结构不再需要原顺序用sort更省内存。sorted还可以用在任何可迭代对象上比如字典遍历时按键排序。排序时想自定义规则用key参数比如按字符串长度排words [bb, a, ccc] words.sort(keylen) print(words) # [a, bb, ccc]这个key参数是排序的精髓几乎你能想到的排序规则都可以用它表达比如按字典某个字段排、按绝对值排。Python 的排序算法是 Timsort稳定且性能很好不用自己写排序。5. 遍历与列表推导式从循环到一行代码5.1 直接遍历元素最简单也最常用遍历列表最自然的方式是直接迭代元素for fruit in fruits: print(fruit)这种写法可读性最好。需要留意的是在遍历过程中直接修改列表要非常小心。比如想在循环里删除某些元素边遍历边删会导致索引错乱漏掉元素。nums [1, 2, 3, 4, 5] for num in nums: if num % 2 0: nums.remove(num)这段代码运行完后结果并不是你以为的只保留奇数。因为删除元素后列表长度和索引都变了迭代器会跳过部分元素。正确做法是遍历原列表的副本或者直接用列表推导式生成新列表。5.2 enumerate 同时拿索引和值如果遍历时需要用到索引不要这样写for i in range(len(fruits)): print(i, fruits[i])更 Pythonic 的方式是enumeratefor i, fruit in enumerate(fruits): print(i, fruit)enumerate还可以指定起始值比如从1开始编号for i, fruit in enumerate(fruits, start1): print(i, fruit)这个函数在写带序号输出的脚本、做表格序号、遍历邻接矩阵时都特别好用。比起手动range(len(...))enumerate不容易出错代码也更简洁。5.3 列表推导式过滤变换一段搞定列表推导式是Python里最值得花的十分钟学会的语法。基本结构是new_list [表达式 for 变量 in 可迭代对象 if 条件]比如把数字列表里的偶数平方nums [1, 2, 3, 4, 5, 6] squares [n * n for n in nums if n % 2 0] print(squares) # [4, 16, 36]这段代码如果用普通循环至少要四五行推导式一行就能完成。它的执行速度通常也比手写循环快一些因为底层有专门的优化路径。我还经常用推导式做过滤、类型转换、字符串清洗等操作。嵌套推导式对应嵌套循环例如生成二维列表matrix [[col for col in range(3)] for row in range(3)]但这里必须提醒一句嵌套推导式一旦超过两层可读性会快速下降。我会优先考虑拆成普通循环或写成辅助函数。代码首先是给人读的其次才是给机器跑的。6. 嵌套列表与拷贝陷阱一处改动为何带崩整片数据6.1 嵌套列表的应用场景二维数据与邻接表列表里装列表就是嵌套列表也叫二维列表。比如坐标点、表格数据、邻接矩阵经常是这样的结构table [ [1, 2, 3], [4, 5, 6], [7, 8, 9] ]读取二维列表的元素时用table[row][col]。比如table[1][2]是第2行第3列即6。处理图像像素、Excel数据的时候这种嵌套结构很常见。我在解析CSV文件时也习惯先用二维列表暂存行数据再逐行处理。嵌套列表本身不难真正会让人掉坑的是它的拷贝行为。6.2 浅拷贝和深拷贝需要用 copy 模块的时机Python里的赋值操作b a只是给同一个列表起了一个新名字两个变量指向同一个对象。要拷贝列表有几种不同级别的做法import copy a [1, [2, 3]] b list(a) # 浅拷贝顶层独立嵌套共享 c a[:] # 也是浅拷贝 d copy.copy(a) # 浅拷贝 e copy.deepcopy(a) # 深拷贝全部独立浅拷贝的意思是b是一个新的列表对象所以改b[0]不会影响a[0]但b[1]和a[1]指向同一个子列表对象所以b[1][0] 99会让a也改变。深拷贝则递归复制所有层级的对象生成一个完全独立的副本。要分清什么时候用哪个核心是判断你的数据里有没有嵌套的可变对象以及你改完之后是否希望影响原数据。只存基本类型的一维列表用a[:]就够了有嵌套结构且后续要修改内部元素稳妥起见用deepcopy。6.3 用乘法创建列表最经典的翻车场景我见过最多人翻车的代码是用乘法创建二维列表matrix [[0] * 3] * 3这行代码的本意是生成3行3列全是0的列表但从matrix[0][0] 1改完你会发现每一行的第一个元素都变成了1。原因是[[0] * 3] * 3把同一个行列表对象复制了三份三个外层元素指向同一个内层列表。这就像一张表纸复印了三份但你改的是原本三份复印件全变了。正确写法是用列表推导式matrix [[0] * 3 for _ in range(3)]这一招记住了能省掉很多无谓的排错时间。碰到这种问题第一反应就是用id(matrix[0])和id(matrix[1])对比如果相同基本就是这个原因。7. 实际项目里关于列表的几条性能与选型建议7.1 频繁头部增删时改用 deque列表本身是一个动态数组末尾追加和删除是 O(1)但在头部插入或删除元素需要整体平移是 O(n)。如果你的程序里需要频繁在序列两端操作比如实现队列、来回遍历的缓冲区collections.deque是更好的选择。from collections import deque dq deque([1, 2, 3]) dq.appendleft(0) # 左侧追加 O(1) dq.append(4) # 右侧追加 O(1) dq.popleft() # 左侧弹出 O(1) dq.pop() # 右侧弹出 O(1)我写过一个小型IO缓存模块最初用列表存待推送的消息每天早上处理几万条消息后来发现每次pop(0)都慢得明显。换成了deque的popleft()之后性能问题直接消失。列表适合按顺序追加、随机访问的场景两端操作频繁时尽早切换到deque。7.2 大数量级场景用生成器代替列表如果数据规模很大比如要处理一千万个数字的平方和直接生成一个完整列表会占用大量内存。这种情况应该用生成器表达式total sum(x * x for x in range(10**7))生成器不会一次性把全部数据载入内存而是按需逐个生成元素。列表推导式写法和生成器表达式只差一层括号但内存占用天差地别。big_list [x * x for x in range(10**7)] # 直接占大量内存 big_gen (x * x for x in range(10**7)) # 惰性求值我自己在处理日志流、大文本分词时都坚持这个原则只要数据只需要遍历一次就优先考虑生成器如果后面还需要反复访问、切片、索引才明确转成列表。7.3 我处理列表时的三条默认规则踩了足够多的坑之后我给自己定了几条处理列表的默认规则现在分享给你固定结构用元组动态数据用列表返回多个固定值时用元组需要后续插入、删除、排序时才用列表。遍历时不改原列表需要过滤就用推导式生成新列表不要在循环里边遍历边删。嵌套修改前先问自己“我要不要影响原数据”要就深拷贝不要就避免共享引用不要等到数据被改坏了再排查。这三条规则不一定适合每一个人但它们帮我挡掉了很多线上问题。列表学起来不难难的是在这些细微的场景中做出正确的选择。把上面这些概念逐个跑一遍你对Python数据类型的理解会比只敲几行入门demo深得多。