Python列表、元组与序列操作:从一组数据开始

前面几篇我们一直在处理单个值——一个数字、一个名字、一个分数。但现实生活中,数据几乎总是"成组"出现的。全班 50 个人的成绩,一个月 30 天的温度记录,一个购物车的商品列表——你不可能给每个数据都单独起一个变量名。你需要容器

Python 里最基础、最常用的容器就是列表。它的好搭档元组虽然简单,但在特定场景下不可或缺。这篇把这俩兄弟彻底讲清楚——从创建到增删改查,从切片到排序,从什么时候用列表到为什么需要元组。

列表是什么

列表是一组有序的、可变的数据集合。用方括号 [] 表示,元素之间用逗号分隔:

scores = [85, 92, 78, 95, 88]
names = ["小明", "小红", "小刚", "小美"]
mixed = [1, "hello", 3.14, True, None]  # Python 列表可以混合不同类型

"有序"意味着每个元素有固定的位置——第一个、第二个、第 N 个,不会自己乱跑。"可变"意味着你可以随时增删改里面的元素——这和马上要讲的元组截然相反。

创建列表

# 直接写
fruits = ["苹果", "香蕉", "橙子"]

# 用 list() 从其他序列转换
chars = list("Python")      # ['P', 'y', 't', 'h', 'o', 'n']
numbers = list(range(5))     # [0, 1, 2, 3, 4]

# 空列表
empty = []

访问元素:索引和负数索引

scores = [85, 92, 78, 95, 88]

print(scores[0])     # 85   第一个,索引从 0 开始
print(scores[1])     # 92   第二个
print(scores[-1])    # 88   最后一个
print(scores[-2])    # 95   倒数第二个

# 越界访问会报错
# print(scores[100])   # IndexError: list index out of range

负数索引是 Python 的特色——你不用算"长度减一",直接 -1 就是最后一个。这个细节在日常编码中省了无数行 len(x) - 1

列表长度和成员检查

scores = [85, 92, 78]
print(len(scores))         # 3

print(92 in scores)        # True
print(60 in scores)        # False
print(60 not in scores)    # True

列表的增删改查

增加元素

fruits = ["苹果", "香蕉"]

# 末尾追加(最常用)
fruits.append("橙子")
print(fruits)  # ['苹果', '香蕉', '橙子']

# 插入到指定位置
fruits.insert(1, "葡萄")    # 在索引 1 处插入
print(fruits)  # ['苹果', '葡萄', '香蕉', '橙子']

# 合并另一个列表
fruits.extend(["西瓜", "草莓"])
print(fruits)  # ['苹果', '葡萄', '香蕉', '橙子', '西瓜', '草莓']

# 注意 append vs extend 的区别
nums = [1, 2, 3]
nums.append([4, 5])    # [1, 2, 3, [4, 5]]  ——把整个列表当成一个元素加进去
nums = [1, 2, 3]
nums.extend([4, 5])    # [1, 2, 3, 4, 5]  ——把列表中的元素逐个加进去

删除元素

fruits = ["苹果", "香蕉", "橙子", "葡萄", "西瓜"]

# 按值删除(只删第一个匹配的)
fruits.remove("香蕉")
print(fruits)  # ['苹果', '橙子', '葡萄', '西瓜']

# 按位置删除
deleted = fruits.pop(1)     # 删除索引 1 的元素并返回它
print(deleted)               # '橙子'
print(fruits)                # ['苹果', '葡萄', '西瓜']

fruits.pop()                 # 不传参数删除最后一个
print(fruits)                # ['苹果', '葡萄']

# 按位置删除(不返回)
del fruits[0]
print(fruits)                # ['葡萄']

# 清空整个列表
fruits.clear()
print(fruits)                # []

remove() 根据值来删,pop() 根据位置来删。如果你知道位置用 pop()(更快),只知道值用 remove()

修改元素

scores = [85, 92, 78]
scores[1] = 95           # 直接赋值修改
print(scores)             # [85, 95, 78]

# 修改一段
scores[0:2] = [100, 100]  # 把索引 0 和 1 一起改了
print(scores)             # [100, 100, 78]

查找元素

scores = [85, 92, 78, 92, 88]

print(scores.index(92))       # 1 ——第一个 92 的索引
print(scores.count(92))       # 2 ——92 出现了两次
print(scores.index(92, 2))    # 3 ——从索引 2 开始找 92

在这里插入图片描述

图7-1 列表基本操作:增(append/insert/extend)、删(remove/pop/del)、改(索引赋值)、查(index/count/in)四类操作对比。

切片:Python 最强大的数据选取工具

如果说列表里有一个知识点必须花时间彻底搞懂,那就是切片。切片让你用一行代码取出列表的任何子集。

nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# 基本语法:list[start:stop:step]
# start:起始索引(包含)
# stop:结束索引(不包含)——和 range 一样的左闭右开
# step:步长

print(nums[2:6])       # [2, 3, 4, 5]   索引 2 到 5
print(nums[:4])        # [0, 1, 2, 3]   从开头到索引 3
print(nums[6:])        # [6, 7, 8, 9]   从索引 6 到末尾
print(nums[:])         # [0, 1, ..., 9]  复制整个列表
print(nums[::2])       # [0, 2, 4, 6, 8]  每 2 个取 1 个
print(nums[::-1])      # [9, 8, 7, ..., 0]  反转列表!

切片语法看着简单,但几个关键点要记住:

start 省略:从头开始。stop 省略:一直取到末尾。step 为负数:从右往左取。

# 取最后三个
print(nums[-3:])       # [7, 8, 9]

# 取除了前两个和最后两个之外的所有
print(nums[2:-2])      # [2, 3, 4, 5, 6, 7]

# 反转字符串(字符串也支持切片!)
print("Python"[::-1])  # "nohtyP"

切片不只用于取数据,还能用来批量修改数据:

nums = [0, 1, 2, 3, 4, 5]
nums[1:4] = [10, 20, 30]    # 替换一段
print(nums)                  # [0, 10, 20, 30, 4, 5]

nums[1:4] = []               # 删除一段
print(nums)                  # [0, 4, 5]

nums[1:1] = [100, 200]       # 在索引 1 处插入
print(nums)                  # [0, 100, 200, 4, 5]

切片是你以后每天都要用的工具。初学时多写几遍 nums[::-1] 这种操作,熟练之后你会发现它比你想象中还要强大。

列表排序与反转

scores = [85, 92, 78, 95, 88]

# 返回新排序列表,原列表不变
sorted_scores = sorted(scores)
print(sorted_scores)   # [78, 85, 88, 92, 95]
print(scores)           # [85, 92, 78, 95, 88]  没变

# 原地排序,直接修改原列表
scores.sort()
print(scores)           # [78, 85, 88, 92, 95]

# 降序
scores.sort(reverse=True)
print(scores)           # [95, 92, 88, 85, 78]

# 按自定义规则排序(按字符串长度)
words = ["Python", "Go", "JavaScript", "C", "Rust"]
words.sort(key=len)
print(words)            # ['C', 'Go', 'Rust', 'Python', 'JavaScript']

# 反转(不是排序,只是颠倒顺序)
scores.reverse()
print(scores)           # [78, 85, 88, 92, 95]

sorted().sort() 的区别是一个经典面试题:前者返回新列表不修改原列表,后者原地修改返回 None。什么时候用哪个?如果原列表还需要保留就用 sorted(),如果可以丢弃原列表就用 .sort()(更省内存)。

列表的复制——浅拷贝的陷阱

# 你以为在复制,其实没有
a = [1, 2, 3]
b = a              # b 和 a 指向同一个列表对象
b.append(4)
print(a)           # [1, 2, 3, 4]  ——a 也跟着变了!

# 正确的复制方式
a = [1, 2, 3]
b = a.copy()        # 方式一:.copy() 方法
c = a[:]            # 方式二:切片
d = list(a)         # 方式三:list() 构造

如果你在函数里处理列表参数,不确信外部代码是否还依赖原列表,就复制一份再操作。避免"莫名其妙的数据变化"这个令人头秃的 bug 来源。

对于嵌套列表(列表里还有列表),.copy() 只能复制外层——内层的子列表还是共享的。这种情况叫浅拷贝。如果需要对嵌套列表做完全独立的复制,需要 import copy 然后 copy.deepcopy()。这部分在第 9 篇(字符串处理)和第 12 篇(文件操作)的实战中会再次碰到。

列表推导式:一行代码完成循环+处理+筛选

# 传统写法:创建一个平方数列表
squares = []
for i in range(1, 11):
    squares.append(i ** 2)

# 列表推导式
squares = [i ** 2 for i in range(1, 11)]
print(squares)  # [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

# 加筛选条件:只要偶数的平方
even_squares = [i ** 2 for i in range(1, 11) if i % 2 == 0]
print(even_squares)  # [4, 16, 36, 64, 100]

# 从现有列表推导
scores = [85, 92, 78, 95, 88]
levels = ["A" if s >= 90 else "B" if s >= 80 else "C" for s in scores]
print(levels)  # ['B', 'A', 'C', 'A', 'B']

列表推导式的结构是 [表达式 for 变量 in 序列 if 条件]。它本质上是一个压缩的 for 循环——但执行更快、代码更短、读起来更接近自然语言。

推导式有一个边界:不要写得太复杂。如果超过一行或者嵌套超过两层,老老实实写普通 for 循环——代码是给人读的,不是炫技用的。

在这里插入图片描述

图7-2 列表切片操作:start/stop/step 三参数的位置含义,以及负数索引从右向左计数的规则。

元组:不可变的列表

元组和列表几乎一模一样——有序、可索引、可切片——但有一个致命区别:元组不可变,创建后不能修改

# 创建元组(用小括号)
point = (3, 5)
person = ("小明", 20, "北京")
single = (42,)         # 只有一个元素时,后面的逗号不可省略!
not_a_tuple = (42)     # 这不是元组,这是加了括号的数字 42

# 也可以用 tuple()
grades = tuple([85, 92, 78])
# 元组的操作(和列表一样,但不修改原元组)
point = (3, 5)
print(point[0])        # 3
print(point[1])        # 5
print(len(point))      # 2
print(3 in point)      # True

# 但以下操作会报错
# point[0] = 10        # TypeError: 'tuple' object does not support item assignment
# point.append(7)      # AttributeError: 'tuple' object has no attribute 'append'

元组解包——Python 传递返回值的秘诀

# 同时赋多个值
x, y = (3, 5)
print(x, y)    # 3 5

# 更常见的:元组括号可以省略
x, y = 3, 5    # 等价于上面

# 交换两个值就是元组解包的应用
a, b = b, a    # 右边 (b, a) 是一个元组,然后解包赋值给左边

# 函数返回多值其实就是返回元组
def get_coordinates():
    return 116.4, 39.9   # 返回的是元组 (116.4, 39.9)

lng, lat = get_coordinates()   # 解包
print(lng, lat)  # 116.4 39.9

什么时候用元组而不是列表

一个简单的原则:如果你需要一组数据在程序运行期间保持不变,用元组

具体场景:

  • 坐标、RGB 颜色值、日期(三个固定成分,不会改)
  • 函数返回多个相关值(让返回结构不可变,避免调用者误改)
  • 字典的键(列表不能做字典键,元组可以——因为元组不可变,可哈希)
  • 常量配置数据(星期名称、月份列表等)
# 元组可以做字典的键
locations = {
    (116.4, 39.9): "北京",
    (121.5, 31.2): "上海",
    (113.3, 23.1): "广州",
}

# 列表不能做字典的键
# d = {[1, 2]: "value"}  # TypeError: unhashable type: 'list'

命名元组:给元组的每个位置起名字

普通元组的缺点是只能通过索引访问——point[0]person[1] 这样的代码读起来不够清晰。collections.namedtuple 解决了这个问题:

from collections import namedtuple

# 定义一个"学生"命名元组
Student = namedtuple("Student", ["name", "age", "score"])

s1 = Student("小明", 20, 85)
print(s1.name)     # 小明  ——可以按名字访问了!
print(s1.age)      # 20
print(s1[0])       # 小明  ——按索引访问也可以

s2 = Student("小红", 21, 92)
print(f"{s2.name} 的成绩是 {s2.score} 分")

namedtuple 结合了元组的不可变性和字典的可读性——它占的内存比字典小,但读起来和对象一样清晰。在很多项目中,它被用来代替简单的数据类。

序列的通用操作

列表和元组都是"序列"——Python 里有序容器的统称。所有序列都支持以下操作:

# 适用于所有序列的通用操作
seq = [1, 2, 3, 4, 5]    # 也可以是元组或字符串

len(seq)        # 长度
min(seq)        # 最小值
max(seq)        # 最大值
sum(seq)        # 求和(只适用于数字)
seq.count(3)    # 统计出现次数
seq.index(3)    # 查找位置
3 in seq        # 成员检查
s1 + s2         # 拼接两个序列
s * 3           # 重复序列
s[i]            # 索引访问
s[i:j]          # 切片

这些通用操作让你在不同类型的序列之间切换时不需要改变太多代码。你在列表上用的 len() 和切片,在元组和字符串上一样能用。

在这里插入图片描述

图7-3 列表 vs 元组对比:可变性、语法、性能、使用场景四个维度的对比,帮助你做出正确的选择。

实战:用列表和元组写几个实用程序

程序一:成绩分析器

def analyze_scores():
    scores = []
    print("请输入学生成绩(输入 q 结束):")
    while (val := input("> ").strip()) != "q":
        try:
            s = float(val)
            if 0 <= s <= 100:
                scores.append(s)
            else:
                print("范围 0~100,已忽略")
        except ValueError:
            print("无效数字,已忽略")
    
    if not scores:
        return print("无数据")
    
    count = len(scores)
    avg = sum(scores) / count
    sorted_scores = sorted(scores)
    
    print(f"\n人数:{count}")
    print(f"平均:{avg:.1f}")
    print(f"最高:{sorted_scores[-1]}")
    print(f"最低:{sorted_scores[0]}")
    
    # 中位数
    mid = count // 2
    if count % 2 == 0:
        median = (sorted_scores[mid-1] + sorted_scores[mid]) / 2
    else:
        median = sorted_scores[mid]
    print(f"中位数:{median:.1f}")
    
    # 分数段分布
    ranges = [(90, "A"), (80, "B"), (70, "C"), (60, "D"), (0, "F")]
    for threshold, label in ranges:
        n = sum(1 for s in scores if s >= threshold and 
                (threshold == 0 or s < threshold + 10))
        print(f"  {label} ({threshold}+): {'▓' * n} {n}人")

analyze_scores()

程序二:简易待办事项

def todo_list():
    todos = []
    
    while True:
        print(f"\n===== 待办事项 ({len(todos)} 项) =====")
        for i, task in enumerate(todos, 1):
            status = "✓" if task["done"] else "○"
            print(f"  {i}. [{status}] {task['title']}")
        
        print("\n1.添加 2.完成 3.删除 0.退出")
        choice = input("> ").strip()
        
        if choice == "1":
            title = input("事项:").strip()
            if title:
                todos.append({"title": title, "done": False})
        elif choice == "2":
            idx = int(input("完成第几项?")) - 1
            if 0 <= idx < len(todos):
                todos[idx]["done"] = True
        elif choice == "3":
            idx = int(input("删除第几项?")) - 1
            if 0 <= idx < len(todos):
                removed = todos.pop(idx)
                print(f"已删除:{removed['title']}")
        elif choice == "0":
            break

# todo_list()

动手练习

  1. 列表去重:给定一个包含重复元素的列表,用两种方法去重(方法一:用循环和新列表;方法二:用 set)。比较两种方法的代码量。

  2. 矩阵转置:用嵌套列表表示一个 3×3 矩阵,写代码实现矩阵转置(行列互换)。试试用列表推导式一行完成。

  3. 学生成绩排序:存储一个学生列表,每个学生包含姓名和成绩(用元组 ("小明", 85)),按成绩降序排列输出。

  4. 列表方法探索:在交互模式中 dir([]) 查看列表的所有可用方法,挑选三个你没用过的方法,读它们的 help() 文档,各写一段小代码测试,记录下这三个方法分别做什么。

元组的一个隐藏技巧——让函数返回"有意义的多个值"

很多人学了元组解包之后,只把它用在 x, y = point 这种显而易见的场景。但元组解包真正闪光的地方是在函数返回值的接收上。当你需要从一个函数返回"成功/失败状态"和"实际数据"两个信息时,元组是 Python 社区约定俗成的方式:

def safe_divide(a, b):
    """安全除法:返回 (是否成功, 结果或错误信息)"""
    if b == 0:
        return False, "除数不能为零"
    return True, a / b

# 调用方用元组解包接收
ok, result = safe_divide(10, 2)
if ok:
    print(f"结果:{result}")
else:
    print(f"失败:{result}")  # result 此时是错误信息

ok, result = safe_divide(10, 0)
if not ok:
    print(f"果然失败了:{result}")

这个模式在 Python 标准库和第三方库中随处可见。它比抛出异常更轻量(适合"预期内可能失败"的场景),比返回 None 更清晰(调用者明确知道需要检查成功状态)。Go 语言甚至把这种模式做成了语言级别的特性。Python 虽然更常用异常来处理错误,但在某些场景(解析数据、查数据库、网络请求)中,这种"成功标志 + 数据"的元组模式是非常优雅的。

当你开始写更多函数时,会自然形成一种直觉:单值用 return value,多值用 return a, b, c(本质是元组),可选值用 return value or default。这种对返回值的清晰把控,是写出可维护代码的重要习惯。

  1. 元组实战:定义一个返回多个天气信息的函数(温度、湿度、风速),用元组返回,调用时解包。

列表和元组的底层——为什么会有两种"序列"

很多初学者会问:既然元组就像一个"不能改的列表",为什么不直接用列表然后"不去改它"就行?这个问题其实触及了 Python 设计的一个重要哲学:用类型来表达意图

当你使用元组,你在向读代码的人传递一个明确的信号——“这些数据是一个整体结构,不会变”。函数返回 (116.4, 39.9) 而不是 [116.4, 39.9],就是在说"这是一个坐标,不是一组待扩展的数字"。当你在字典里用元组做键,你依赖的是元组的不可变性保证了这个键永远不会在你不注意的时候被修改。

性能方面,元组的创建比列表快,占内存比列表少。对于一次性创建、只读不写的场景,元组是更好的选择。所以在 Python 社区里有一个不成文的建议:默认用元组,只有当你确实需要修改内容时再用列表。当然实际编码中大多数人都习惯默认用列表——因为方便。两种做法各有道理,重要的是理解它们之间的设计差异,而不是死记规则。


本篇要点回顾

  • 列表是可变的有序容器,用 [],支持增删改查
  • 切片 [start:stop:step] 是 Python 最强大的数据选取工具——左闭右开,负数倒着数
  • .sort() 原地排序,sorted() 返回新列表
  • b = a 不是复制,是两个变量指向同一个列表——复制用 .copy()[:]
  • 元组是不可变的——创建后不能修改,适合表示固定结构的数据
  • 元组解包是 Python 优雅传递多值的基石:x, y = point
  • 所有序列支持通用操作:len()、切片、in、拼接、重复

下一篇我们进入字典和集合——字典让你用"键"来查"值",集合帮你秒速去重和做集合运算。

理解列表的"可变"——一把双刃剑

列表的可变性是它最大的优势,也是它最大的坑。这里展开讲讲为什么。

一个可变对象被多个变量引用时,通过任何一个引用修改它,其他引用都会"看到"这个变化。这不是 bug,是设计——但因为太反直觉,所以经常被当成 bug:

# 场景:函数里修改了传入的列表
def add_bonus(scores):
    for i in range(len(scores)):
        scores[i] += 5   # 直接修改了原列表!
    return scores

original = [85, 92, 78]
new = add_bonus(original)
print(original)  # [90, 97, 83] ——原列表被改了!
print(new)       # [90, 97, 83]
print(original is new)  # True ——还是同一个对象!

很多初学者以为函数传入的是"参数的副本",实际上传的是"参数的引用"。如果你不想影响原数据,应该在函数内部复制一份:

def add_bonus_safe(scores):
    scores = scores.copy()  # 复制一份,不影响原列表
    for i in range(len(scores)):
        scores[i] += 5
    return scores

这种"可变对象被意外修改"的 bug 非常难排查——因为代码一切正常,没有报错,只是数据不知不觉地变了。排查时你需要在所有可能修改列表的地方打上 print,追踪数据流向。所以,从一开始就养成"不确定是否需要共享时,先复制一份"的习惯,能帮你省下大量 debug 时间。

列表的性能特性——什么操作快、什么操作慢

你对列表做的每个操作,Python 在底层花了不同的代价。了解这些差异有助于你写出更高效的代码。

O(1) 操作——常数时间,数据再多也不怕

  • 按索引访问:scores[5]
  • 末尾追加:scores.append(x)
  • 末尾删除:scores.pop()
  • 获取长度:len(scores)

O(n) 操作——数据越多越慢

  • 按值查找:scores.index(x)x in scores(需要从头到尾逐个对比)
  • 按值删除:scores.remove(x)(先找到位置再删除)
  • 开头插入:scores.insert(0, x)(后面所有元素都要向后移一位)
  • 开头删除:scores.pop(0)(后面所有元素都要向前移一位)

实际编码中的指导原则是:如果需要频繁在头部插入或删除,考虑 collections.deque 而不是列表。如果只是偶尔操作,列表完全够用。了解这些性能特性不是为了让你焦虑每一个操作,而是让你在数据量大、性能出问题时知道从哪排查。

元组不仅仅是"不可变列表"

很多教程把元组简单概括为"不可变的列表",这在技术上是正确的,但忽略了元组更深层的用途。

元组意味着"结构"。当你写 (116.4, 39.9),你的意图是"这是一个由经度和纬度组成的坐标"。第一个位置是经度,第二个位置是纬度——这就是结构。而列表意味着"同质集合"——列表里的每个元素往往是相同类型的,元素的位置不重要(排序后位置就变了)。

元组更快、更省内存。因为元组在创建时就知道永远不会变,Python 可以优化它的内存布局。实测中,创建一百万个元组比创建一百万个列表快约 20%,内存占用少约 15%。对绝大多数程序来说,这个差异可以忽略不计——但当你要存储数十万条记录时,元组的内存优势就体现出来了。

元组让你的代码意图更清晰。当别人读你的代码,看到 colors = ("red", "green", "blue") 时,他们知道"这个颜色值不会变,我可以放心地引用它"。而看到列表时,他们需要警惕"这个数据可能会在后面的代码被修改"。每一个让代码意图更清晰的设计,都在为未来的维护省时间。

更多推荐