做Python开发这些年,我反复跟新人强调一件事:数据结构怎么存,决定了你要怎么取;而“取”这件事,在代码里就是遍历。列表要一个个处理,字典要拿到键和值,文件要逐行读,嵌套的JSON要一层层挖——这些操作全是遍历。很多人遇到具体场景时能写出来,但换个结构就卡壳,本质上缺的不是某个API的记法,而是对“遍历”这个底层逻辑的整体理解。
这篇文章我就想把这套逻辑完整盘一遍。从最基础的列表、元组、字典、集合、字符串,到文件对象和生成器,再到二叉树、嵌套JSON这类复合结构的遍历,把每种思路、每段代码、每个坑都摊开讲。不管你是刚学Python的新手,还是想回过头把基础补扎实的开发者,都能从这里拿走可以直接用的东西。
1. 遍历的本质:先想清楚怎么存,再决定怎么取
1.1 为什么不同数据结构的遍历方式完全不一样
数据结构解决的核心问题就是组织和存储数据,而遍历就是把这些存储的数据按某种顺序“过一遍”。所以遍历方式天然被存储方式制约。数组在内存里是一段连续空间,所以用下标访问最快;链表是节点之间靠指针串起来的,所以只能跟着指针一个个往下走;字典是哈希表,存储顺序和插入顺序不一定一致(Python 3.7之后虽然保持插入顺序,但访问仍然是靠哈希);树是靠父子关系组织的,所以必须用递归或栈/队列来辅助。
这个认知看起来很基础,但我见过不少同学在各种结构里都习惯性地想写“下标循环”,遇到字典和集合就懵了。其实你只需要问自己一个问题:这个结构支持随机访问吗?支持就优先考虑下标或for直接遍历,不支持就老老实实利用它提供的迭代接口。Python把所有能遍历的对象统一成了“可迭代对象”,这才是通用的钥匙。
1.2 可迭代对象与迭代器协议:for循环背后的秘密
Python里的for循环,本质上不是C语言那种“计数器加判断”的语法糖,而是对迭代器协议的一层包装。任何对象只要实现了__iter__()或者__getitem__(),就能被for循环遍历。你可以理解成:for循环是一个懂行的管家,它只管向对象索取下一个元素,至于元素怎么产生、顺序如何,是对象内部的事。
nums = [10, 20, 30] it = iter(nums) # 获取迭代器 print(next(it)) # 10 print(next(it)) # 20 print(next(it)) # 30 # print(next(it)) # 抛出 StopIteration 异常iter()和next()是理解遍历的关键。iter()返回迭代器,next()每次取下一个元素,取完会抛StopIteration。for循环背后其实就是反复调用next()直到捕获这个异常。明白了这点,你就能写自定义可迭代对象,也能理解为什么生成器遍历了一次就不能再遍历——因为它本身就是迭代器,是一次性消耗品。这个区分很重要,后面讲文件遍历和生成器时还会用到。
2. 内置数据结构的遍历实操
2.1 列表与元组:for到底怎么写才合适
列表是我们打交道最多的结构,遍历方式至少有三种,但适用场景完全不同。
data = [3, 1, 4, 1, 5, 9, 2, 6] # 方式一:只拿元素,最常用 for item in data: print(item) # 方式二:需要下标时,用 enumerate for idx, item in enumerate(data): print(idx, item) # 方式三:老式写法,用 range(len()) for i in range(len(data)): print(i, data[i])我建议,只关心元素本身时无脑用方式一;需要知道“这个元素在第几个位置”时用方式二;方式三在Python里几乎没有必要,除非你需要在循环体中修改下标或者处理步长。由于列表是连续存储,用下标随机访问也是O(1)的,所以while + range也不慢,但代码可读性差不少。
元组和列表的遍历基本一样,区别是元组不可变,所以不存在“遍历时修改元素”这种操作。另一个小技巧是元组和列表都可以用解包,这在遍历二维结构时非常方便:
pairs = [(1, 'a'), (2, 'b'), (3, 'c')] for num, char in pairs: print(num, char)这种解包写法不局限于列表,凡是元素本身是容器时都能用。
2.2 字典遍历:键、值、键值对的三种打开方式
字典是所有Python面试里绕不开的数据结构。它和列表最大的区别是:列表你能说“给我第2个元素”,字典必须说“给我键为x的值”。所以字典的遍历天然就有三种角度:键、值、键值对。
user = {"name": "Tom", "age": 18, "city": "Hangzhou"} # 遍历键(默认) for key in user: print(key, user[key]) # 遍历键值对,推荐 for key, value in user.items(): print(key, value) # 只遍历值 for value in user.values(): print(value)新手最常犯的错误是遍历的时候用user[key]去取值,其实items()一步到位,还能直接解包。另一个值得注意的点是:Python 3.7之后字典保持插入顺序,这意味着你遍历字典时能看到元素的“放入顺序”,这在实际做配置解析、日志处理时很实用。不过在3.6及以前的版本里,字典顺序是不可靠的,所以如果有跨版本运行需求,不要依赖字典顺序写核心逻辑。
还有一点,遍历字典时如果要同时根据值筛选,可以直接用推导式:
# 拿所有 age > 18 的键 adult_keys = {k for k, v in user.items() if v >= 18}这种一行搞定的写法,比写四五行for循环干净得多。
2.3 集合与字符串:无序与有序的边界场景
集合的遍历看起来和列表一样简单:
tags = {"python", "data", "struct"} for tag in tags: print(tag)但集合是无序的,遍历顺序完全取决于哈希表的内部布局,你不能假设每次运行顺序一样。我遇到过一个实际问题:测试脚本里用集合保存了一批用例ID,然后遍历去执行,结果执行顺序每次重启都不固定,导致日志对比对不上。解法很简单,需要稳定顺序就用sorted(tags),或者直接用列表。
字符串遍历有两种常见需求:按字符遍历和按下标遍历。按字符直接for就好:
text = "python" for ch in text: print(ch)需要下标时用enumerate。要注意字符串是不可变对象,所以“遍历时修改”是不可行的,只能生成新字符串。对于中文等Unicode字符,for循环遍历得到的是一个个字符,这在处理文本分析时比较直观;但如果你需要按字节处理,得先encode()再遍历。
2.4 文件对象和生成器:让遍历具备内存优势
文件对象也是可迭代的,它最大的价值是逐行读取,避免一次性把整个文件读进内存。对比一下:
# 一次性读入,内存占用大 with open("large.log", "r", encoding="utf-8") as f: lines = f.readlines() for line in lines: process(line) # 逐行遍历,推荐 with open("large.log", "r", encoding="utf-8") as f: for line in f: process(line)第二个写法里,文件对象按需从磁盘读取每一行,处理完一行再读下一行,内存占用基本和文件大小无关。这在处理几个GB的日志文件时是质的区别。
生成器是另一个容易踩坑的点。用yield写一个生成器,或者用生成器推导式,能实现“懒加载”的遍历:
# 生成器:边算边出,不一次性占用内存 square_gen = (x * x for x in range(1000000)) for val in square_gen: pass生成器最大的坑是“只能遍历一次”。如果代码里先for x in gen做了一遍统计,再想for x in gen做第二遍,你会莫名发现循环直接不执行了。这其实就是迭代器已耗尽。正确做法是如果需要多次遍历,要么重新创建生成器,要么提前转成列表。
2.5 while循环:什么时候该用它来遍历
现在很多人一听说for更Pythonic,就把while彻底丢掉了。但实际上,while在处理“不知道什么时候结束”的遍历时仍然有用。比如手动遍历迭代器并在某个条件下提前终止:
it = iter(data) while True: item = next(it, None) if item is None: break if item == target: print("找到目标,结束") break用for循环配合break也能实现同样效果,代码更短,所以while大多数情况不是最优选。我的建议是:常规遍历优先for;如果你需要手动控制迭代器的移动节奏,或者遍历过程中涉及复杂的条件跳转,再用while也不迟。没有绝对好坏,只有适合。
3. 进阶遍历:复合结构、二叉树与组合工具
3.1 嵌套列表和嵌套字典的递归遍历
实际项目里,我们处理的数据很少是单层的。典型的比如一个嵌套字典:
config = { "app": { "name": "demo", "deps": ["flask", "sqlalchemy"], "env": {"debug": True, "port": 8000} } }想把这个结构里的所有字符串值都找出来,用一层for循环根本不够。正确思路是递归:遍历当前层的每一项,如果值是容器就继续深入,否则就处理。
def walk_dict(d): for key, value in d.items(): if isinstance(value, dict): walk_dict(value) elif isinstance(value, list): for item in value: if isinstance(item, dict): walk_dict(item) else: print(key, item) else: print(key, value) walk_dict(config)这段代码的递归核心是:遇到子结构,就把它当作一个新的根结构处理。递归确实不是新手最容易想到的思路,但一旦你意识到“每一层的处理逻辑都一样”,递归就是最自然的解法。要注意的是递归深度,Python默认递归限制大约在1000层,如果数据结构深得离谱,可以考虑改用显式的栈来迭代实现。
显式栈版本的层序/深度遍历代码如下:
stack = [config] while stack: cur = stack.pop() if isinstance(cur, dict): for v in cur.values(): stack.append(v) elif isinstance(cur, list): stack.extend(cur) else: print(cur)这个写法避免了递归深度问题,而且遍历顺序可控。
3.2 二叉树遍历:前序、中序、后序与层序
树是经典的数据结构,在Python里虽然没有内置二叉树,但用类可以很方便地定义:
class TreeNode: def __init__(self, val, left=None, right=None): self.val = val self.left = left self.right = right前序、中序、后序对应的是根节点被访问的时机。用递归写非常简洁:
def preorder(node): if node is None: return print(node.val) # 根 preorder(node.left) # 左 preorder(node.right) # 右 def inorder(node): if node is None: return inorder(node.left) print(node.val) inorder(node.right) def postorder(node): if node is None: return postorder(node.left) postorder(node.right) print(node.val)递归虽然直观,但面试和工程里常常要求用迭代实现,本质是用栈模拟函数调用。以前序遍历为例:
def preorder_iter(root): stack = [root] while stack: node = stack.pop() if node: print(node.val) stack.append(node.right) stack.append(node.left)注意这里先压右子树再压左子树,因为栈是后进先出,我们希望左子树先弹出。
层序遍历(广度优先)则是用队列实现的,这也是热词里反复出现的“层序遍历”:
from collections import deque def level_order(root): if not root: return queue = deque([root]) while queue: node = queue.popleft() print(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right)层序的应用特别多,比如按层打印二叉树(每层一行)、树形菜单的展开、JSON配置的逐层校验等等。核心就是“先进先出”这个队列特性,保证每一层从左到右被处理。
3.3 组合遍历工具:enumerate、zip、sorted
除了for本身,Python还有几个工具能让遍历更优雅。enumerate前面讲过,是用来拿下标的;zip是把多个序列“并排”遍历的利器:
names = ["Alice", "Bob", "Cathy"] scores = [85, 92, 78] for name, score in zip(names, scores): print(f"{name}: {score}")zip最方便的地方是自动在最短序列处停止,不用自己算长度。但它有个隐含行为:如果两个列表长度不一致,多余的元素会被静默丢弃。需要保留全部元素时,用itertools.zip_longest。
sorted可以把任意可迭代对象排好序再遍历,这在处理字典键、集合元素时非常常用:
d = {"banana": 3, "apple": 5, "pear": 1} for key in sorted(d): print(key, d[key])sorted有一个key参数,可以按自定义规则排序,比如按字典的值排序:
for key in sorted(d, key=lambda k: d[k]): print(key, d[key])这一套组合拳打下来,很多“先处理再遍历”的需求就不用再造临时列表了。
4. 遍历过程中最容易踩的坑,我一个个踩给你看
4.1 遍历列表时修改列表:经典翻车现场
假设你想把列表里所有大于3的元素都乘以2:
nums = [1, 2, 3, 4, 5] for i, x in enumerate(nums): if x > 3: nums[i] = x * 2 print(nums) # [1, 2, 3, 8, 10]这个操作是安全的,因为你改的是元素值而不是列表长度。但如果你在循环里删除或添加元素,问题就来了:
nums = [1, 2, 3, 4, 5] for x in nums: if x > 3: nums.remove(x) print(nums) # 结果可能会让你意外遍历时删除元素,可能导致元素跳过。因为for循环底层通过下标访问,删除元素后后续元素会“前移”,但循环的下标计数器不会因此回退。最典型的就是遍历时删掉当前元素后,下一个元素被漏掉了。
安全的做法是遍历副本,或先收集再删除:
# 方式一:遍历副本 for x in nums[:]: if x > 3: nums.remove(x) # 方式二:收集要保留的 nums = [x for x in nums if x <= 3]我个人建议用列表推导式,不仅安全,还快,一行解决。
4.2 遍历字典时删除键:RuntimeError迎面而来
遍历字典时直接修改键是不可行的,字典大小在遍历过程中发生变化会直接抛出RuntimeError: dictionary changed size during iteration。比如:
d = {"a": 1, "b": 2, "c": 3} for k in d: if k == "b": del d[k] # 抛异常正确做法是遍历键的副本:
for k in list(d.keys()): if k == "b": del d[k]或者用字典推导式直接生成新字典:
d = {k: v for k, v in d.items() if k != "b"}两种方式都能达到目的。我倾向第二种,因为语义清晰,而且生成新字典不会影响其他正在引用这个字典的代码。注意,如果原字典被多处引用,del会影响到所有引用者,而生成新字典则不会,这点在写工具函数时要格外留意。
4.3 for循环和while循环的性能与可读性对比
总有同学纠结用for还是while,其实在现代Python实现里,两者的性能差异非常小,真正的差异在于可读性和功能上限。for循环天然面向迭代协议,代码简洁;while循环需要手动管理下标或迭代器,更容易出错。
从性能角度说,for循环遍历列表通常比while略快,因为它把迭代细节下沉到C层面。但如果你用range(len())这种写法,差距就更小了。真正拉开差距的是“每次迭代里做了什么”。我有一个习惯:遍历大数据量时,把能提到循环外面的操作(比如属性查找、函数引用)先提出来:
# 慢方法:每次循环都查一次 get for item in data: result.append(handler.get(item)) # 快方法:把函数引用保存到局部变量 get_item = handler.get for item in data: result.append(get_item(item))这种微观优化在数据量大时有一定帮助,但普通场景下不必过度追求,代码清晰优先。
4.4 遍历过程中浅拷贝与深拷贝带来的隐患
当数据结构里嵌套了可变对象时,遍历要注意拷贝的深浅。比如用列表推导式复制一个二维列表:
matrix = [[1, 2], [3, 4]] copy = [row for row in matrix] # 浅拷贝 copy[0].append(99) print(matrix) # [[1, 2, 99], [3, 4]]原因是copy里的每个row仍然是原列表的引用,修改内层列表会作用到原数据上。如果遍历时想基于原结构生成一份独立的变换结果,记得用copy.deepcopy:
import copy matrix = [[1, 2], [3, 4]] copy = copy.deepcopy(matrix) copy[0].append(99) print(matrix) # [[1, 2], [3, 4]]这个坑在遍历处理配置、嵌套数据时尤其常见。判断依据很简单:你的操作会不会修改内层可变对象?会,就需要深拷贝;不会,浅拷贝就够。
4.5 遍历性能速查表
| 遍历目标 | 推荐方式 | 时间复杂度 | 常见坑 |
|---|---|---|---|
| 列表/元组 | for item in seq | O(n) | 遍历时删除元素 |
| 字典 | for k, v in d.items() | O(n) | 遍历时修改大小抛异常 |
| 集合 | for item in s | O(n) | 顺序不稳定 |
| 字符串 | for ch in s | O(n) | 不可变,不能原地改 |
| 文件 | for line in f | O(行数) | 多次遍历需重新打开 |
| 生成器 | for x in gen | O(n) | 一次性,遍历完即耗尽 |
| 二叉树递归 | 递归遍历 | O(n) | 深度大时递归栈溢出 |
| 二叉树层序 | deque 迭代 | O(n) | 别忘了用 popleft |
这张表是我在实际项目里总结的。遇到遍历问题,先对号入座,选最合适的遍历方式,比硬写循环高效得多。
5. 实战案例:把遍历串起来
5.1 词频统计:一次性用上列表、字典、集合的遍历
这个经典练习题可以检验你对多个数据结构的掌握程度。需求很简单:给定一段英文文本,统计每个单词出现次数,并输出出现次数最多的前5个词。
text = """python is great, python is simple, python is powerful""" words = text.split() word_count = {} for word in words: # 去掉标点,这里简化处理 word = word.strip(",.").lower() word_count[word] = word_count.get(word, 0) + 1 # 按出现次数排序 sorted_items = sorted(word_count.items(), key=lambda item: item[1], reverse=True) top5 = sorted_items[:5] for word, count in top5: print(f"{word}: {count}")这里用到了列表的split得到待遍历序列,字典的get方法做增量统计,items()遍历键值对,sorted配合key按值排序。一个最简单的题就把好多种遍历姿势都覆盖了。如果你给文本加一个大小写分支去重(使用集合),就能把集合的遍历也加进来,一举多得。
5.2 层序遍历处理嵌套JSON:树形结构的真实应用
很多自动化工具的配置文件是嵌套JSON,比如一个组织架构:
org = { "name": "CEO", "subs": [ { "name": "CTO", "subs": [ {"name": "Backend Leader"}, {"name": "Frontend Leader"} ] }, { "name": "CFO", "subs": [] } ] }想按层级打印出这个组织架构,其实就是二叉树的层序遍历扩展到多叉树。用队列实现:
from collections import deque def print_by_level(root): queue = deque([(root, 0)]) current_level = 0 line = [] while queue: node, level = queue.popleft() if level != current_level: print("|".join(line)) line = [] current_level = level line.append(node["name"]) for sub in node.get("subs", []): queue.append((sub, level + 1)) if line: print("|".join(line)) print_by_level(org)输出结果会是按层级分行的,这正是层序遍历的典型价值。如果换成深度优先的递归,也能拿到所有节点,但打印顺序就会是“先到底再回头”,而不是逐层展开。选择哪种方式,取决于你的业务需求。
5.3 遍历时的“只读/写”分离原则
实战里我始终保持一个习惯:遍历时尽量不修改原始容器,而是把结果收集到新列表或新字典中。这个原则能避免掉绝大多数“遍历时修改”的坑。比如:
# 不推荐:在循环里不断 append 到原列表 result = [] for x in data: if condition(x): result.append(x) # 推荐:列表推导式 result = [x for x in data if condition(x)]本质上是一个思路,但推导式的意图更清晰,且不会影响原数据。遇到需要“原数据保持不变,生成一个新版本”的需求时,优先选择生成新容器。这个习惯在你处理共享数据、并发场景时会帮你省下大量调试时间。
6. 我这些年总结出的小经验
最后分享几个个人体会。第一,不管多复杂的遍历场景,先弄清楚“我要的是顺序还是值”,顺序用列表存,按值查找用字典,去重用集合,这个选型对了,遍历自然顺手。第二,遇到嵌套结构,先用递归思路画一画每一层的处理逻辑,再考虑能不能改成迭代栈,避免深递归爆栈。第三,尽量少写while + 下标这个组合,真的没有太多场景需要它。
还有一个小技巧:遍历的时候善用print(变量)来观察每一次迭代的状态,尤其是处理嵌套结构时,用缩进表示层数,调试效率和肉眼理解速度都会提升不少。这个习惯帮我定位过非常多肉眼看不到的问题。
遍历这件事,初看是语法,再看是结构,说到底是对数据组织方式的理解。把基础的数据结构过一遍、把每种遍历方式都写熟悉,后续无论做数据处理、Web开发还是算法题,都会轻松很多。你先把今天这篇文章里的代码都敲一遍,再回来看复杂需求,会觉得豁然开朗。