咱们直接进入正题。这一章是Python基础系列里我自己最看重的一章,因为容器数据类型(list、tuple、dict、set)几乎会出现在你往后写的每一段代码里。不管是写爬虫存数据、做数据分析清洗字段,还是写自动化脚本处理文件列表,翻来覆去就是在操作这几种容器。可以说,把这一章吃透了,你才算是真正迈进了Python编程的门槛。
这一章会从一个整体视角把这四种容器放在一起讲清楚,包括它们各自适合什么场景、增删改查怎么操作、内存和性能上有什么差异,最后用一个综合案例把四种容器串起来用。内容对零基础读者足够友好,不需要任何前置知识,装好Python环境就能跟着敲。如果你还没装Python,可以去官网下个3.10以上的版本,安装时记得勾选“Add Python to PATH”,这一步能省掉后面配置环境变量的不少麻烦。编辑器用VS Code或者PyCharm社区版都行,各有各的顺手之处,这里就不展开说了。
1. 容器类型整体设计与思路拆解
1.1 为什么把list、tuple、dict、set放在一章讲
很多新手学Python的时候容易陷入一个误区:今天学列表,明天学元组,后天学字典,学完就忘,遇到实际问题根本不知道该用哪个。这一章把四种容器放一起,目的就是从一开始就帮你建立“选型”的思维模式。
其实这四种容器可以分成两大家族:序列型(list和tuple)和映射型(dict和set)。序列型强调的是“顺序”和“位置”,就像食堂打饭排队,每个人都有固定的位置,你可以按位置找到第几个人。映射型强调的是“对应关系”和“唯一性”,就像查字典,你通过拼音找到对应的汉字,或者像超市会员卡系统,卡号是唯一的,对应一个会员信息。
从底层实现来看,list和tuple都基于数组实现,区别在于list是可变的,tuple一旦创建就不能修改。dict基于哈希表实现,set本质上是没有value的dict,只关心key是否存在。理解了这个底层差异,你就能明白为什么dict和set的查找速度那么快(O(1)时间复杂度),而list的查找需要逐个遍历(O(n)时间复杂度)。
1.2 四种容器的核心差异对照
先给出一张全局对比表,后面所有内容都是围绕这张表展开的。
| 容器类型 | 是否有序 | 是否可变 | 是否允许重复 | 典型使用场景 |
|---|---|---|---|---|
| list(列表) | 有序 | 可变 | 允许 | 购物车、待办事项、任意需要按顺序存储的集合 |
| tuple(元组) | 有序 | 不可变 | 允许 | 函数返回多值、坐标点、配置信息、不可变的数据记录 |
| dict(字典) | 有序(Python 3.7+) | 可变 | key不允许重复 | 存储键值对应关系、JSON数据解析结果、配置参数 |
| set(集合) | 无序 | 可变(元素必须可哈希) | 不允许重复 | 去重、集合运算(交集、并集、差集) |
表格列出的只是最典型的场景,实际使用中经常交叉。举个例子,一个班级的考试成绩单,你可以用dict存储,key是学号,value是成绩;但如果需要按名次排序,就得转成list再操作。没有万能的容器,只有适合当前场景的容器。
1.3 可变与不可变的深层影响
这四个容器最容易被忽略的区别,就是可变性和不可变性。我之前带过不少学员,第一次接触tuple的时候都会问:“既然tuple和list这么像,为什么不直接用list,非要搞一个不能改的tuple出来?”
这个问题问得非常好。tuple的存在意义在于“安全”和“效率”两个层面。
安全层面:当你需要把一个数据传给其他人使用,又不希望这个数据被意外修改,tuple是最合适的选择。比如一个地理坐标点(纬度,经度),或者一个RGB颜色值(255, 255, 255),这些数据在逻辑上就不应该被修改。用tuple声明,代码的意图就非常明确——“这个数据是固定的”。
效率层面:tuple因为不可变,底层的存储结构更加紧凑,遍历和访问速度比list略快。虽然差距在数据量小的时候感觉不出来,但在大规模数据处理和函数参数传递的场景下,这个差异是真实存在的。而且tuple可以作为dict的key,list则不行,因为dict要求key必须是可哈希的,list可变会导致哈希值不稳定。
2. 核心类型逐个击破:list和tuple的实操要点
2.1 列表(list)的创建、增删改查与切片操作
先说说最常用的list。创建列表的写法非常简单,用方括号[]包起来,元素之间用逗号分隔:
empty_list = [] mixed_list = [1, "hello", 3.14, True, [1, 2, 3]] # 列表可以嵌套 range_list = list(range(10)) # 用range生成0到9的列表这里有个新手容易忽略的地方:list里可以放任意类型的元素,包括另一个list。嵌套列表在多维数据处理时特别好用,比如二维表格就可以用“列表的列表”来表示。
增删改查是list的核心操作,我整理成了一张对照表:
| 操作 | 方法/语法 | 示例 | 说明 |
|---|---|---|---|
| 尾部追加 | append(item) | lst.append(4) | 在末尾添加一个元素,原地修改 |
| 合并列表 | extend(iterable) | lst.extend([5, 6]) | 把另一个可迭代对象展开后追加 |
| 指定位置插入 | insert(index, item) | lst.insert(0, "first") | 在index位置插入元素,原来的元素后移 |
| 按值删除 | remove(item) | lst.remove(3) | 删除第一个匹配的值,元素不存在会报错 |
| 按索引删除 | pop(index=-1) | lst.pop() | 弹出并返回指定位置元素,不传参数默认弹出最后一个 |
| 清空列表 | clear() | lst.clear() | 删除所有元素 |
| 修改元素 | lst[index] = new_value | lst[0] = 10 | 直接按索引赋值修改 |
切片是list最强大的功能之一,也是新手最容易踩坑的地方。基本语法是lst[start:stop:step],左闭右开,start包含,stop不包含:
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(nums[2:5]) # 输出 [2, 3, 4],注意不包括索引5的元素 print(nums[:3]) # 从头开始,输出 [0, 1, 2] print(nums[7:]) # 到末尾结束,输出 [7, 8, 9] print(nums[::2]) # 每隔一个取一个,输出 [0, 2, 4, 6, 8] print(nums[::-1]) # 反转列表,输出 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]切片返回的是一个新的list,不会修改原来的list,这在数据处理中特别有用。比如你想拿一批数据的前80%做训练集、后20%做测试集,一句切片就搞定了。
2.2 元组(tuple)的特殊语法与自动解包
tuple的创建写法与list很相似,只不过用的是圆括号:
empty_tuple = () single_tuple = (1,) # 注意这个逗号不能省略! not_a_tuple = (1) # 这其实是整数1,不是元组这里有个非常经典的坑:创建只有一个元素的元组时,必须带上那个逗号。(1)在Python里只是加了括号的数字1,(1,)才是元组。很多新手在这里栽过跟头,我自己也曾经因为这个逗号浪费过几分钟排查问题。
tuple最常见的用途之一就是多值返回和多变量同时赋值:
def get_min_max(nums): return min(nums), max(nums) min_val, max_val = get_min_max([3, 1, 4, 1, 5]) print(min_val, max_val) # 输出 1 5 # 交换两个变量的值 a, b = 10, 20 a, b = b, a print(a, b) # 输出 20 10这种“元组解包”的写法非常Pythonic,写出来的代码简洁明了。在遍历dict的时候,用for key, value in dict.items()也是同样的原理,每次循环取出的元素是一个包含两个元素的tuple,然后自动解包给key和value两个变量。
2.3 列表推导式:让代码简洁一个级别
说到list,就不得不提列表推导式(list comprehension)。这是一种用一行表达式快速生成list的语法,用好了代码极其优雅:
# 初学者的写法:用for循环生成平方数列表 squares = [] for i in range(10): squares.append(i * i) # 熟练者的写法:一行搞定 squares = [i * i for i in range(10)] # 带条件的列表推导式:只取偶数的平方 even_squares = [i * i for i in range(10) if i % 2 == 0] print(even_squares) # 输出 [0, 4, 16, 36, 64]列表推导式的语法结构是:[表达式 for 变量 in 可迭代对象 if 条件],按照“先写表达式,再写循环,最后写条件”的顺序来理解。很多初学者看到这个语法觉得晕,其实拆开来看就是前面for循环的压缩版。
同样的语法还适用于dict和set,分别叫字典推导式和集合推导式:
squared_dict = {x: x*x for x in range(5)} # 字典推导式 even_set = {x for x in range(10) if x % 2 == 0} # 集合推导式推导式用得好,代码量能减少一半,而且可读性更强。不过要注意一点:推导式不是万能的,如果逻辑复杂度超过两层循环加上多条件判断,写普通for循环反而更清晰。代码的可读性永远比“看起来很高端”更重要。
3. 核心类型逐个击破:dict和set的实操要点
3.1 字典(dict)的键值设计、查改删方法与内置函数
dict是Python里最灵活、最常用的容器类型。它的底层是哈希表,每个key通过哈希函数计算出一个存储位置,所以查找速度极快,与容器内有多少元素基本无关。这就是为什么做数据映射关系时,dict是首选。
创建dict的常见方式:
# 直接字面量创建 user = { "name": "张三", "age": 25, "city": "北京" } # 用dict()构造函数,适合key是简单字符串的情况 user2 = dict(name="李四", age=30, city="上海") # 从键值对序列创建 user3 = dict([("name", "王五"), ("age", 35)])访问和修改dict的核心操作:
# 取值 print(user["name"]) # 直接用key取值,key不存在会报KeyError print(user.get("name")) # 用get取值,key不存在返回None,推荐这种方式 print(user.get("email", "未设置")) # 可以指定默认值 # 修改和新增 user["age"] = 26 # key存在则修改值 user["email"] = "zhangsan@example.com" # key不存在则新增键值对 # 删除 removed_value = user.pop("city") # 删除指定key并返回对应的值 del user["age"] # 删除指定key,不返回值 user.popitem() # 删除最后插入的键值对(Python 3.7+)在实际开发中最推荐的取值方式是get(),因为它不会因为key不存在而抛出异常。比如从API返回的JSON数据中取值,你永远无法保证某个字段一定存在,用get("key", 默认值)就可以优雅地解决这个问题。
遍历dict有三种方式,对应三个方法:
for key in user: # 默认遍历key print(key) for value in user.values(): # 遍历value print(value) for key, value in user.items(): # 同时遍历key和value print(key, value)3.2 字典合并与update:多版本写法对比
合并两个dict是日常开发中非常常见的操作。随着Python版本演进,写法的优雅程度也在不断提升:
dict1 = {"a": 1, "b": 2} dict2 = {"b": 3, "c": 4} # Python 3.5+ 的写法:字典解包 merged = {**dict1, **dict2} print(merged) # 输出 {'a': 1, 'b': 3, 'c': 4} # Python 3.9+ 的写法:| 运算符 merged2 = dict1 | dict2 print(merged2) # 输出 {'a': 1, 'b': 3, 'c': 4} # 常用但会修改原字典的写法:update方法 dict1.update(dict2) print(dict1) # 输出 {'a': 1, 'b': 3, 'c': 4}这几种写法有一个共同点:key重复时,后面字典的值会覆盖前面字典的值。{**dict1, **dict2}和dict1 | dict2不会修改原字典,适合需要保留原数据的场景;update()会直接修改调用它的字典,适合原地更新的场景。
如果你用的Python版本在3.9以下,更推荐解包写法{**dict1, **dict2},兼容性更好,语法也更直观。
3.3 集合(set)的去重原理与数学运算
set的底层也是哈希表,只存储key,不存储value。它的两个核心特性——无序和不重复——都来源于这个底层实现。因为key是哈希存储的,顺序自然无从谈起;因为哈希表要求key唯一,重复元素自动被忽略。
创建set的方法:
fruits = {"apple", "banana", "cherry"} # 花括号直接创建 empty_set = set() # 注意:{}创建的是空dict,不是空set numbers = set([1, 2, 2, 3, 3, 3]) # 从list创建,自动去重 print(numbers) # 输出 {1, 2, 3}set最常用的场景就是去重。一行代码就能去掉list中的重复元素:
data = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4] unique_data = list(set(data)) print(unique_data) # 输出可能为 [1, 2, 3, 4],顺序不固定注意这里的输出顺序不固定,因为set本身无序。如果既要去重又要保留原始顺序,需要额外处理:
data = [3, 1, 2, 3, 4, 1, 2, 5] seen = set() result = [] for item in data: if item not in seen: seen.add(item) result.append(item) print(result) # 输出 [3, 1, 2, 4, 5]set的另一个强大功能是集合运算,对应数学中的交集、并集、差集:
a = {1, 2, 3, 4} b = {3, 4, 5, 6} print(a & b) # 交集,输出 {3, 4} print(a | b) # 并集,输出 {1, 2, 3, 4, 5, 6} print(a - b) # 差集(a有b没有),输出 {1, 2} print(a ^ b) # 对称差集,输出 {1, 2, 5, 6}我在实际工作中用set的集合运算处理过很多实际问题。比如统计两个文件里共同的用户ID,把两个文件读出来转成两个set,一取交集就完事,比两层for循环快了不是一点半点。还有做权限管理的时候,判断一个用户是否有某个角色权限,user_roles.intersection(required_roles)一句就能搞定。
4. 四个容器综合实操:搭建一个学员成绩管理系统
4.1 需求分析与数据结构设计
前面讲的都是单个容器的操作,实际开发中容器往往是嵌套组合使用的。这一节我带你做一个相对完整的案例:一个基础版的学员成绩管理系统。
需求很简单:录入学员的学号、姓名、各科成绩,支持查询、统计、排序和去重功能。
数据结构设计如下:
- 用dict存储每个学生的信息:key为学号,value为包含姓名和各科成绩的子dict
- 用list维护所有学生的学号顺序,保证查询结果按录入顺序展示
- 用set记录所有课程名称,方便统计有哪些科目
- 用tuple存储单科成绩的不可变记录(比如最高分和最低分)
# 数据结构示意 students = { "S001": {"name": "张三", "chinese": 85, "math": 92, "english": 78}, "S002": {"name": "李四", "chinese": 90, "math": 85, "english": 95}, }这个设计的核心思路是:把学号作为dict的key,因为学号唯一且需要频繁查找;学生具体信息用value存储,可以灵活增删改。
4.2 完整代码实现与逐段解析
首先实现基本的功能框架:
# 学员成绩管理系统 students = {} # 用学号做主键存储学生信息 course_set = set() # 存储所有课程名称 def add_student(sid, name, scores): """新增学生:sid为学号,scores为课程成绩dict""" if sid in students: print(f"学号{sid}已存在,无法重复添加") return False students[sid] = {"name": name, **scores} course_set.update(scores.keys()) print(f"学生{name}添加成功") return True def query_student(sid): """按学号查询学生信息""" info = students.get(sid) if not info: print(f"找不到学号{sid}") return None print(f"学号:{sid},姓名:{info['name']}") for course, score in info.items(): if course != "name": print(f" {course}:{score}分") return info def calc_average(sid): """计算某位学生的平均分""" info = students.get(sid) if not info: return None scores = [score for key, score in info.items() if key != "name"] return sum(scores) / len(scores) def rank_students(): """按平均分从高到低排序所有学生""" avg_list = [] for sid, info in students.items(): avg = calc_average(sid) avg_list.append((avg, sid, info["name"])) avg_list.sort(reverse=True) print("成绩排名(按平均分):") for rank, (avg, sid, name) in enumerate(avg_list, start=1): print(f"第{rank}名:{name}(学号{sid}),平均分{avg:.1f}")这里有几个值得展开的细节。
第一,course_set.update(scores.keys())这行代码,用set的update方法把所有课程名称加入集合。因为set不重复的特性,即使不同学生选了不同课程,课程名单也不会重复,而且后续想遍历有哪些科目,直接遍历这个set就行。
第二,calc_average函数里用列表推导式筛掉"name"这个key,只保留成绩值,然后用sum和len计算平均分,整个逻辑非常简洁。
第三,rank_students函数用tuple把平均分、学号、姓名捆绑成一组记录,放入list后排序。tuple在这里充当了“不可修改的记录”角色,用sort(reverse=True)倒序排,就能得到从高到低的排名。这就是tuple最常见的实际应用——多字段不可变记录。
再补充一个排名后的展示逻辑,复用tuple解包:
def show_top3(): """显示班级前三名""" avg_list = [] for sid, info in students.items(): avg = calc_average(sid) avg_list.append((avg, sid, info["name"])) avg_list.sort(reverse=True) print("班级前三名:") top3 = avg_list[:3] # 列表切片,取前三个 for rank, (avg, sid, name) in enumerate(top3, start=1): print(f"第{rank}名:{name}({avg:.1f}分)")这里的avg_list[:3]用到了第2部分讲的切片操作,从排序后的list中取出前三名,一行代码完成。回头看,这个黑板上几十行代码的案例,几乎把本章所有知识点都串起来了:list存储、tuple捆绑记录、dict快速查找、set去重、切片取值、列表推导式筛选。
4.3 数据录入与完整验证过程
光看代码不运行等于白学。下面我用具体的录入数据来验证这套系统的运作过程:
# 录入学生成绩 add_student("S001", "张三", {"chinese": 85, "math": 92, "english": 78}) add_student("S002", "李四", {"chinese": 90, "math": 85, "english": 95}) add_student("S003", "王五", {"chinese": 88, "math": 76, "english": 82}) add_student("S004", "赵六", {"chinese": 92, "math": 98, "english": 90}) # 查询某个学生 query_student("S003") # 查看课程集合 print("本班开设课程:", course_set) # 输出成绩排名 rank_students()运行结果如下:
学生张三添加成功 学生李四添加成功 学生王五添加成功 学生赵六添加成功 学号:S003,姓名:王五 chinese:88分 math:76分 english:82分 本班开设课程: {'chinese', 'math', 'english'} 成绩排名(按平均分): 第1名:赵六(学号S004),平均分93.3 第2名:李四(学号S002),平均分90.0 第3名:张三(学号S001),平均分85.0 第4名:王五(学号S003),平均分82.0你注意到没有,course_set打印出来的顺序是随机的,因为set本身就是无序的。如果对输出顺序有要求,可以用sorted(course_set)转成排序后的list再打印。
到这里,一个可用的成绩管理系统核心逻辑就有了。虽然离生产级还差得远(没有文件持久化、没有异常处理、没有GUI),但作为理解四种容器配合使用的案例,这个已经足够完整了。你可以在这个基础上继续扩展:用tuple存储学生详细信息、用set做选课去重、用dict嵌套存成绩表,越用越熟练。
5. 常见问题与排查技巧实录
5.1 新手必踩的坑:误用可变对象、删改与赋值陷阱
容器类型使用中有些问题几乎每个新手都会碰到,我把它们集中整理出来。
坑一:默认参数使用可变对象
def add_item(item, lst=[]): # 这样写有问题! lst.append(item) return lst print(add_item(1)) # 输出 [1] print(add_item(2)) # 输出 [1, 2],你可能会疑惑,第二次调用lst不应该是空的吗?这个问题的根源在于:Python函数的默认参数在函数定义时只创建一次,之后每次调用都复用同一个list对象。正确写法是默认参数设为None,在函数内部创建新列表:
def add_item(item, lst=None): if lst is None: lst = [] lst.append(item) return lst坑二:修改list时遍历会漏元素
nums = [1, 2, 3, 4, 5] for num in nums: if num % 2 == 0: nums.remove(num) print(nums) # 你预期输出 [1, 3, 5],实际输出 [1, 3, 5]?不对,是 [1, 3, 5]还是[1, 3, 5]?这个问题展开说明一下。实际运行会发现结果有时候正确有时候错误,因为remove导致元素索引前移,循环跳过了部分元素。最安全的做法是遍历原list的副本:
nums = [1, 2, 3, 4, 5] for num in nums[:]: # 遍历副本 if num % 2 == 0: nums.remove(num) print(nums) # 稳定输出 [1, 3, 5]坑三:浅拷贝带来的连锁修改
list_a = [1, 2, [3, 4]] list_b = list_a.copy() # 浅拷贝 list_b[2].append(5) print(list_a) # 输出 [1, 2, [3, 4, 5]],list_a也被改了浅拷贝只复制了最外层,内层嵌套的list仍然是同一个对象。对于嵌套容器,需要用copy.deepcopy()做深拷贝。
| 拷贝方式 | 代码 | 嵌套对象的处理 |
|---|---|---|
| 直接赋值 | b = a | b和a指向同一个对象,任何修改互相影响 |
| 浅拷贝 | b = a.copy()或b = list(a) | 外层独立,内层嵌套对象仍共享 |
| 深拷贝 | b = copy.deepcopy(a) | 完全独立的副本,互不影响 |
5.2 面试高频考点:算法复杂度与内存表现
容器类型的算法复杂度通常是面试和进阶学习的重点。我用一张表来总结:
| 操作 | list | tuple | dict | set |
|---|---|---|---|---|
| 按索引访问 | O(1) | O(1) | 不支持 | 不支持 |
| 按值查找 | O(n) | O(n) | O(1) | O(1) |
| 尾部追加 | O(1)均摊 | 不可用 | O(1)均摊 | O(1)均摊 |
| 任意位置插入 | O(n) | 不可用 | 不支持 | 不支持 |
| 删除元素 | O(n) | 不可用 | O(1) | O(1) |
这张表的实际意义在于帮助你做数据结构的选型。举个例子:如果你需要一个频繁按值查找的数据集合,用list实现,数据量达到几万条时性能就会明显下降(每查一次要遍历整个list),换成都用set,查询速度几乎没有变化。
内存占用方面,dict和set由于哈希表的结构,比list和tuple占用更多内存。数据量不大的日常脚本完全不必在意这点差异,但处理百万级数据时就需要权衡:是用list换内存空间,还是用dict/set换查询速度。
5.3 判断容器是否为空的Pythonic写法
新手经常写出类似if len(lst) > 0:这样的代码,虽然功能没错,但不够Pythonic。在Python中,空容器(空list、空tuple、空dict、空set)的布尔值都是False,非空容器都是True,所以直接判断就行:
data = [] if data: # 等价于 if len(data) > 0 print("容器非空") else: print("容器为空")这种写法在Python社区非常普遍,代码读起来很自然,像是在说“如果有数据,就做什么”。
5.4 不要在遍历dict时修改其大小
遍历dict的过程中添加或删除键值对,会直接抛出运行时错误:
user = {"name": "张三", "age": 25, "city": "北京"} for key in user: if key == "age": del user[key] # RuntimeError: dictionary changed size during iteration如果需要过滤dict中的部分键值,正确做法是生成一个新的dict:
user = {"name": "张三", "age": 25, "city": "北京"} filtered_user = {key: value for key, value in user.items() if key != "age"} print(filtered_user) # 输出 {'name': '张三', 'city': '北京'}5.5 实操体会与扩展建议
把我用过这些年Python的经验浓缩成几条建议。
第一,永远优先思考“用什么容器”,再思考“怎么写代码”。数据结构选对了,很多问题迎刃而解。比如要统计一堆单词里哪些出现次数最多,第一反应应该是用dict的key存单词、value存次数,而不是用list存一个二维数组硬查找。
第二,多看Python官方文档中关于容器的章节,里面有很多精致的小技巧。比如collections模块中的defaultdict和Counter,它们在dict的基础上做了很大的功能增强,是标准库中的宝藏。
第三,面对不熟悉的容器操作时,直接在交互式环境里试,比死记硬背效率高得多。Python的python -c命令配合一句脚本,就能快速验证某个api的行为是否符合预期。
容器数据类型的学习不是背API,而是建立一种思维模式。list是排队,tuple是刻在石碑上的承诺,dict是手机联系人,set是开会的签到表。用生活经验去映射编程概念,学起来会有趣得多。
后续如果你想继续深入,可以自己去查查collections.deque(双端队列)、collections.namedtuple(具名元组)和frozenset(不可变集合),它们是这四种容器的重要补充。把今天这些内容练熟了,再去看那些高级容器,一天的时间就能轻松上手。