MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python元组与列表的差异比较

2021-03-197.1k 阅读

一、数据结构基础认知

在深入探讨 Python 中列表(List)和元组(Tuple)的差异之前,有必要先对它们的基本概念进行清晰的认知。

1.1 列表

列表是 Python 中最常用的数据结构之一,它是一种有序的可变序列。这意味着可以在列表创建后对其进行修改,例如添加、删除或修改元素。列表使用方括号 [] 来表示,其中的元素之间用逗号分隔。

以下是一个简单的列表示例:

my_list = [1, 'apple', 3.14]

在这个例子中,my_list 是一个包含了整数、字符串和浮点数的列表。由于列表是有序的,所以可以通过索引来访问列表中的元素。索引从 0 开始,例如 my_list[0] 返回 1,my_list[1] 返回 'apple'

1.2 元组

元组也是一种有序的序列,但与列表不同的是,元组是不可变的。一旦元组被创建,就不能再修改其内容。元组使用圆括号 () 来表示,元素同样用逗号分隔。

以下是一个元组示例:

my_tuple = (1, 'apple', 3.14)

和列表类似,元组中的元素也可以通过索引访问,my_tuple[0] 返回 1,my_tuple[1] 返回 'apple'

二、内存结构差异

2.1 列表的内存结构

列表在内存中的存储方式较为灵活,因为它是可变的。列表对象本身在内存中有一个固定的头部,存储着列表的元数据,如列表的长度等信息。而列表中的元素则存储在一个动态分配的内存区域,每个元素的内存地址是独立的。

当向列表中添加元素时,如果当前分配的内存空间不足,Python 会重新分配一块更大的内存空间,将原有的元素复制到新的空间,并把新元素添加进去。这种动态的内存分配机制使得列表在使用上非常灵活,但也带来了一定的性能开销。

下面通过一段代码来展示列表内存地址的变化:

my_list = []
print(id(my_list))
my_list.append(1)
print(id(my_list))

在这个示例中,id() 函数用于获取对象的内存地址。可以看到,当向空列表中添加一个元素后,列表的内存地址发生了变化,这表明列表进行了内存的重新分配。

2.2 元组的内存结构

元组的内存结构相对简单且固定。元组对象同样有一个头部存储元数据,包括元组的长度等信息。而元组中的元素在内存中是连续存储的,它们的内存地址是相邻的。

由于元组是不可变的,一旦创建,其内存布局就固定下来,不会再发生变化。这使得元组在内存使用上更加高效,特别是在存储大量数据且数据不需要修改的情况下。

同样通过代码展示元组内存地址的特性:

my_tuple = ()
print(id(my_tuple))
my_new_tuple = (1,)
print(id(my_new_tuple))

这里可以看到,创建不同的元组,它们的内存地址是不同的,并且元组一旦创建,其内存地址不会因为元素的增减而改变(因为元组不支持元素的增减操作)。

三、操作特性差异

3.1 元素修改

3.1.1 列表的元素修改

列表的可变性使得对其元素的修改非常方便。可以直接通过索引来修改列表中的元素。例如:

my_list = [1, 2, 3]
my_list[1] = 20
print(my_list)

上述代码将列表 my_list 中索引为 1 的元素从 2 修改为 20,运行结果为 [1, 20, 3]

此外,列表还提供了许多方法来修改自身,如 append() 方法用于在列表末尾添加元素,insert() 方法用于在指定位置插入元素,remove() 方法用于删除指定元素等。

my_list = [1, 2, 3]
my_list.append(4)
print(my_list)
my_list.insert(1, 1.5)
print(my_list)
my_list.remove(3)
print(my_list)

运行上述代码,首先 append(4) 将 4 添加到列表末尾,结果为 [1, 2, 3, 4]insert(1, 1.5) 在索引 1 的位置插入 1.5,结果为 [1, 1.5, 2, 3, 4]remove(3) 删除元素 3,最终结果为 [1, 1.5, 2, 4]

3.1.2 元组的元素修改

由于元组的不可变性,直接修改元组中的元素是不被允许的。如果尝试这样做,会引发 TypeError 错误。例如:

my_tuple = (1, 2, 3)
# 以下代码会报错
my_tuple[1] = 20

运行上述代码,会得到类似于 TypeError: 'tuple' object does not support item assignment 的错误信息。

虽然元组本身不能直接修改元素,但可以通过创建新的元组来实现类似修改的效果。例如:

my_tuple = (1, 2, 3)
new_tuple = my_tuple[:1] + (20,) + my_tuple[2:]
print(new_tuple)

这里通过切片操作 my_tuple[:1] 获取元组的前部分,(20,) 创建一个新的包含 20 的元组,my_tuple[2:] 获取元组的后部分,然后通过 + 运算符将它们连接起来,得到一个新的元组 (1, 20, 3)

3.2 元素删除

3.2.1 列表的元素删除

列表删除元素的方式有多种。除了前面提到的 remove() 方法,还可以使用 pop() 方法删除指定位置的元素并返回该元素。如果不指定位置,pop() 方法默认删除并返回列表的最后一个元素。另外,还可以使用 del 语句删除指定索引的元素。

my_list = [1, 2, 3, 4]
# 使用 pop() 删除指定位置元素
popped = my_list.pop(2)
print(my_list)
print(popped)
# 使用 pop() 删除最后一个元素
last_popped = my_list.pop()
print(my_list)
print(last_popped)
# 使用 del 删除指定索引元素
del my_list[0]
print(my_list)

在上述代码中,首先 pop(2) 删除索引为 2 的元素 3 并返回 3,列表变为 [1, 2, 4];接着 pop() 删除最后一个元素 4 并返回 4,列表变为 [1, 2];最后 del my_list[0] 删除索引为 0 的元素 1,列表变为 [2]

3.2.2 元组的元素删除

元组不能直接删除其中的元素,因为它是不可变的。同样,如果尝试使用 del 语句删除元组中的某个元素,会引发 TypeError 错误。

my_tuple = (1, 2, 3)
# 以下代码会报错
del my_tuple[1]

运行上述代码,会得到 TypeError: 'tuple' object doesn't support item deletion 的错误。

但是,可以使用 del 语句删除整个元组对象。例如:

my_tuple = (1, 2, 3)
del my_tuple
# 此时再访问 my_tuple 会报错,因为它已被删除
print(my_tuple)

运行上述代码,在 del my_tuple 之后再访问 my_tuple 会得到 NameError: name'my_tuple' is not defined 的错误,表明元组对象已被成功删除。

3.3 元素添加

3.3.1 列表的元素添加

列表添加元素有多种方法,前面已经介绍过 append()insert() 方法。除此之外,还可以使用 extend() 方法将一个可迭代对象(如列表、元组等)的元素添加到列表末尾。

my_list = [1, 2]
new_list = [3, 4]
my_list.extend(new_list)
print(my_list)

在上述代码中,my_list.extend(new_list)new_list 中的元素 3 和 4 添加到 my_list 的末尾,最终 my_list 变为 [1, 2, 3, 4]

3.3.2 元组的元素添加

元组本身不支持直接添加元素的操作,因为其不可变性。但可以通过连接两个元组来达到类似添加元素的效果。例如:

my_tuple = (1, 2)
new_tuple = (3, 4)
combined_tuple = my_tuple + new_tuple
print(combined_tuple)

这里通过 + 运算符将 my_tuplenew_tuple 连接起来,得到一个新的元组 (1, 2, 3, 4)

四、性能差异

4.1 创建性能

4.1.1 列表的创建性能

列表在创建时,由于其动态内存分配的特性,需要额外的操作来初始化内存空间。当创建一个包含大量元素的列表时,可能需要多次内存分配和复制操作,这会导致创建过程相对较慢。

以下代码用于测试创建列表的性能:

import timeit

setup = 'nums = range(10000)'
stmt_list = '[num for num in nums]'
list_time = timeit.timeit(stmt=stmt_list, setup=setup, number=1000)
print(f'创建列表的时间: {list_time} 秒')

在上述代码中,使用 timeit 模块来测量创建包含 10000 个元素的列表的时间,重复 1000 次取平均值。

4.1.2 元组的创建性能

元组在创建时,由于其内存布局固定,元素连续存储,不需要动态分配内存空间。因此,创建元组的性能通常比列表要好,特别是在创建包含大量元素的序列时。

同样使用 timeit 模块测试元组的创建性能:

import timeit

setup = 'nums = range(10000)'
stmt_tuple = 'tuple(num for num in nums)'
tuple_time = timeit.timeit(stmt=stmt_tuple, setup=setup, number=1000)
print(f'创建元组的时间: {tuple_time} 秒')

这里测量创建包含 10000 个元素的元组的时间,重复 1000 次取平均值。通常情况下,tuple_time 会小于 list_time,表明元组的创建性能更优。

4.2 访问性能

4.2.1 列表的访问性能

列表通过索引访问元素时,由于元素在内存中地址不连续,需要通过列表头部的元数据来计算元素的内存地址,这会带来一定的开销。不过,由于列表的使用非常广泛,Python 对其进行了优化,在大多数情况下,这种开销并不明显。

以下代码用于测试列表的访问性能:

import timeit

my_list = list(range(10000))
stmt_list_access = 'for i in range(len(my_list)): my_list[i]'
list_access_time = timeit.timeit(stmt=stmt_list_access, number=1000)
print(f'列表访问时间: {list_access_time} 秒')

在上述代码中,通过循环访问列表中的每个元素,并使用 timeit 模块测量这个过程的时间,重复 1000 次取平均值。

4.2.2 元组的访问性能

元组中的元素在内存中是连续存储的,通过索引访问元素时,计算元素内存地址的过程相对简单,因此元组的访问性能略优于列表。

同样使用 timeit 模块测试元组的访问性能:

import timeit

my_tuple = tuple(range(10000))
stmt_tuple_access = 'for i in range(len(my_tuple)): my_tuple[i]'
tuple_access_time = timeit.timeit(stmt=stmt_tuple_access, number=1000)
print(f'元组访问时间: {tuple_access_time} 秒')

这里通过循环访问元组中的每个元素,并测量这个过程的时间,重复 1000 次取平均值。通常情况下,tuple_access_time 会小于 list_access_time,表明元组的访问性能更优。

4.3 修改性能

4.3.1 列表的修改性能

列表的修改操作,如添加、删除、修改元素等,由于涉及到动态内存分配和可能的元素复制,性能开销较大。特别是当列表元素较多时,每次修改都可能导致内存的重新分配,从而影响性能。

以下代码测试列表添加元素的性能:

import timeit

my_list = []
stmt_list_append = 'for i in range(10000): my_list.append(i)'
list_append_time = timeit.timeit(stmt=stmt_list_append, number=1000)
print(f'列表添加元素时间: {list_append_time} 秒')

在上述代码中,通过循环向空列表中添加 10000 个元素,并使用 timeit 模块测量这个过程的时间,重复 1000 次取平均值。

4.3.2 元组的修改性能

元组由于不可变,不存在直接修改元素的操作,也就不存在修改性能的问题。如果要实现类似修改的效果,通过创建新元组的方式,由于涉及到内存的重新分配和元素复制,性能开销较大,且这种操作本质上与列表的修改操作类似,只是实现方式不同。

五、使用场景差异

5.1 列表的使用场景

5.1.1 数据频繁变动的场景

当数据需要频繁地添加、删除或修改时,列表是一个很好的选择。例如,在实现一个动态的任务队列时,任务可能随时被添加或完成后被删除,列表的可变性使得它非常适合这种场景。

task_queue = []
def add_task(task):
    task_queue.append(task)
def complete_task():
    if task_queue:
        return task_queue.pop(0)
    return None
add_task('任务1')
add_task('任务2')
completed_task = complete_task()
print(completed_task)

在上述代码中,task_queue 是一个列表,add_task 函数用于向任务队列中添加任务,complete_task 函数用于从队列中取出并删除第一个任务,很好地体现了列表在数据频繁变动场景下的应用。

5.1.2 存储异构数据且顺序重要的场景

列表可以存储不同类型的数据,并且元素的顺序是有意义的。在处理一些需要按照特定顺序存储多种类型数据的情况时,列表非常适用。比如,在记录一个学生的信息,包括学号(整数)、姓名(字符串)、成绩(浮点数)等,并且需要按照一定顺序存储时:

student_info = [1001, '张三', 85.5]

这里使用列表可以方便地存储和管理这些异构数据,并且通过索引可以方便地访问特定的信息,如 student_info[0] 获取学号,student_info[1] 获取姓名等。

5.2 元组的使用场景

5.2.1 数据不可变且需要保护的场景

当数据一旦确定就不应该被修改,并且需要防止意外修改时,元组是首选。例如,在定义一个表示坐标点的对象时,坐标一旦确定就不应该随意更改,使用元组可以保证数据的安全性。

point = (10, 20)

这里的 point 元组表示一个二维坐标点,由于元组的不可变性,不会出现不小心修改坐标值的情况。

5.2.2 作为字典的键

在 Python 中,字典的键必须是不可变类型。元组由于其不可变性,可以作为字典的键,而列表则不能。当需要使用一个有序的集合作为字典的键时,元组就非常有用。例如,在存储不同城市的坐标信息时,可以使用元组作为键:

city_coordinates = {
    ('北京',): (116.4, 39.9),
    ('上海',): (121.4, 31.2)
}

这里使用元组 ('北京',)('上海',) 作为字典 city_coordinates 的键,分别对应相应城市的坐标值。

六、可迭代性与解包

6.1 可迭代性

6.1.1 列表的可迭代性

列表是可迭代对象,这意味着可以使用 for 循环等方式对其进行遍历。在迭代列表时,会按照列表中元素的顺序依次访问每个元素。

my_list = [1, 2, 3]
for num in my_list:
    print(num)

上述代码通过 for 循环遍历列表 my_list,依次打印出列表中的每个元素 1、2、3。

列表还支持许多与迭代相关的操作,如使用 enumerate() 函数同时获取元素和其索引:

my_list = [1, 2, 3]
for index, num in enumerate(my_list):
    print(f'索引 {index}: {num}')

这里 enumerate() 函数返回一个包含索引和元素的迭代器,通过解包可以同时获取索引和元素的值,运行结果为 索引 0: 1索引 1: 2索引 2: 3

6.1.2 元组的可迭代性

元组同样是可迭代对象,与列表类似,可以使用 for 循环等方式进行遍历。

my_tuple = (1, 2, 3)
for num in my_tuple:
    print(num)

上述代码通过 for 循环遍历元组 my_tuple,依次打印出元组中的每个元素 1、2、3。

元组也支持 enumerate() 函数,用法与列表相同:

my_tuple = (1, 2, 3)
for index, num in enumerate(my_tuple):
    print(f'索引 {index}: {num}')

运行结果同样为 索引 0: 1索引 1: 2索引 2: 3

6.2 解包

6.2.1 列表的解包

列表解包是指将列表中的元素分别赋值给多个变量。解包时,变量的数量必须与列表中的元素数量一致(除非使用了星号表达式来处理剩余元素)。

my_list = [1, 2, 3]
a, b, c = my_list
print(a)
print(b)
print(c)

在上述代码中,列表 my_list 中的三个元素分别解包赋值给变量 abc,打印结果分别为 1、2、3。

如果列表中的元素数量较多,可以使用星号表达式来收集剩余的元素:

my_list = [1, 2, 3, 4, 5]
a, b, *rest = my_list
print(a)
print(b)
print(rest)

这里变量 a 被赋值为 1,变量 b 被赋值为 2,rest 是一个包含剩余元素 [3, 4, 5] 的列表。

6.2.2 元组的解包

元组的解包与列表解包类似,将元组中的元素分别赋值给多个变量。

my_tuple = (1, 2, 3)
a, b, c = my_tuple
print(a)
print(b)
print(c)

上述代码将元组 my_tuple 中的三个元素分别解包赋值给变量 abc,打印结果分别为 1、2、3。

同样,元组也支持使用星号表达式来处理剩余元素:

my_tuple = (1, 2, 3, 4, 5)
a, b, *rest = my_tuple
print(a)
print(b)
print(rest)

这里变量 a 被赋值为 1,变量 b 被赋值为 2,rest 是一个包含剩余元素 [3, 4, 5] 的列表,与列表解包的行为一致。

七、总结与最佳实践建议

通过以上对 Python 中列表和元组在数据结构基础、内存结构、操作特性、性能、使用场景以及可迭代性与解包等方面的详细比较,可以看出它们各有特点。

在实际编程中,应根据具体需求来选择使用列表还是元组。如果数据需要频繁变动,或者需要存储异构数据且顺序重要,列表是较好的选择;而当数据一旦确定就不应该被修改,或者需要作为字典的键等场景,元组更为合适。

同时,在考虑性能方面,创建和访问包含大量元素的序列时,元组通常具有更好的性能;而对于频繁修改操作,列表虽然性能开销较大,但它提供了更灵活的操作方式。

在代码编写过程中,遵循这些最佳实践建议,可以使代码更加清晰、高效,避免因为数据结构选择不当而带来的潜在问题。无论是列表还是元组,都是 Python 中强大的数据结构工具,合理运用它们能够极大地提升编程效率和代码质量。