Python 实战:如何将多层嵌套字典优雅地“压平”

在日常的 Python 开发中,你是否经常遇到这样令人头秃的数据结构?从 API 返回的 JSON 响应、复杂的配置文件,或者是数据库中的嵌套记录,它们往往包含着层层叠叠的字典。这种“套娃”式的结构虽然直观地展示了数据的层级关系,但在进行数据处理、分析或将其转换为表格格式(如 DataFrame)时,却显得格外笨重。

试想一下,如果你需要直接通过键名来访问一个深层的值,你不得不写成一连串的繁琐索引:data[‘user‘][‘profile‘][‘address‘][‘city‘]。这不仅难以阅读,稍有不慎还可能因为某层不存在而抛出异常。

在这篇文章中,我们将深入探讨一个极其实用的数据处理技巧:如何将复杂的嵌套字典扁平化为单层字典。我们将一起探索几种不同的实现策略,从利用栈的后进先出(LIFO)特性,到利用队列的先进先出(FIFO)机制,再到通过递归函数来实现优雅的代码结构。我们不仅要让代码“跑通”,还要深入理解它背后的逻辑,看看哪种方式最适合你的实际应用场景。

准备好让你的数据处理更加高效了吗?让我们开始吧。

什么是字典扁平化?

首先,让我们明确一下我们的目标。所谓“扁平化”,就是将一个多层的嵌套结构,通过某种规则(通常是连接父键和子键),转换成一个只有一层深度的字典。

举个例子:

假设我们手头有一个如下的嵌套字典 a

a = {
    ‘a‘: 1,
    ‘b‘: {
        ‘x‘: 2,
        ‘y‘: {
            ‘z‘: 3
        }
    },
    ‘c‘: {
        ‘m‘: 4
    }
}

我们的任务是将其展平,使得最终的结果不再包含字典作为值,所有的键都通过下划线连接起来,代表它们原本的层级路径。最终,我们期望得到的输出是:

# 目标输出
# {‘a‘: 1, ‘b_x‘: 2, ‘b_y_z‘: 3, ‘c_m‘: 4}

这样,原本需要通过 INLINECODEf9b0dc31 访问的值,现在可以直接通过 INLINECODE14eee950 轻松获取。是不是很方便?下面让我们来看看具体的实现方案。

方法一:利用栈(Stack)进行迭代

第一种方法,我们采用迭代的方式,利用这种数据结构来辅助我们完成任务。栈的特点是“后进先出”(LIFO),这就好比我们在叠盘子,最后放上去的盘子会被最先拿走。

#### 核心思路

  • 初始化:创建一个空字典 INLINECODEde35898a 用于存储结果,以及一个栈 INLINECODE493620c9。栈中存储的元素是元组,格式为 (当前待处理的字典, 父级键名前缀)。初始时,栈里只有原始字典和一个空字符串。
  • 循环处理:只要栈不为空,我们就从栈顶弹出一个元素进行处理。
  • 构建新键:遍历当前弹出的字典。对于每一个键值对,我们检查是否存在父级前缀。如果存在,就用下划线 INLINECODEab5a18b8 将父前缀和当前键拼接成 INLINECODE4466b7e0;否则,直接使用当前键。
  • 判断类型

* 如果值是一个字典,说明还需要继续往里挖掘。我们将 (当前值, new_key) 作为一个新的元组压入栈中,等待后续处理。

* 如果值不是字典,说明已经到了叶子节点,我们直接将 INLINECODE26ef2e7e 和这个值存入结果字典 INLINECODE759ea121 中。

#### 代码实现

# 原始嵌套字典
a = {
    ‘a‘: 1,
    ‘b‘: {‘x‘: 2, ‘y‘: {‘z‘: 3}},
    ‘c‘: {‘m‘: 4}
}

# 初始化结果字典和栈
f = {}
stack = [(a, ‘‘)]  # 戟中存放元组:(字典对象, 父键前缀)

while stack:
    # 弹出栈顶元素:c为当前字典,p为父键前缀
    c, p = stack.pop()
    
    for k, v in c.items():
        # 拼接新键名:如果父前缀p不为空,则用‘_‘连接;否则直接使用k
        new_key = f"{p}_{k}" if p else k
        
        if isinstance(v, dict):
            # 如果值仍然是字典,将其作为待处理项压回栈中
            stack.append((v, new_key))
        else:
            # 如果值不是字典,将其存入结果字典
            f[new_key] = v

# 输出最终结果
print(f)

输出结果:

{‘a‘: 1, ‘c_m‘: 4, ‘b_x‘: 2, ‘b_y_z‘: 3}

#### 深度解析

你可能注意到了,输出的顺序与原始字典中的顺序并不完全一致。这是由栈的“后进先出”特性决定的。当我们处理键 INLINECODEcc52df67 时,发现它是一个字典,于是我们将 INLINECODE3d15b477 压入栈中。紧接着我们处理键 INLINECODE319c5df0,发现它也是字典,又将其压入栈中。在下一轮循环中,INLINECODE9b4ffe65 相关的元组在栈顶,所以它会被优先弹出处理,导致 INLINECODE73fda3d3 出现在 INLINECODE152ebd5e 之前。

这种非深度优先(DFS)的遍历方式在处理超深层级嵌套时非常稳健,因为它不受 Python 递归深度限制的影响,无论你的字典嵌套多少层,循环都能稳稳当当地跑完。

方法二:利用队列进行迭代

接下来,让我们看看第二种方法。这次我们使用队列。与栈不同,队列遵循“先进先出”(FIFO)的原则,这就好比我们在排队买票,先来的人先买。

#### 核心思路

这种方法与栈方法在逻辑上非常相似,唯一的区别在于数据进出容器的顺序。

  • 初始化:我们使用 Python 标准库 INLINECODE79fa05f3 中的 INLINECODE61e480a9(双端队列)来初始化一个队列,初始状态同样包含原始字典和空前缀。
  • 入队与出队:我们从队列的左侧(INLINECODE880de0e9)取出待处理的元组,处理逻辑与栈方法完全一致。如果遇到嵌套字典,我们将新的元组添加到队列的右侧(INLINECODEe97a8d24)。

#### 代码实现

from collections import deque

a = {
    ‘a‘: 1,
    ‘b‘: {‘x‘: 2, ‘y‘: {‘z‘: 3}},
    ‘c‘: {‘m‘: 4}
}

f = {}
queue = deque([(a, ‘‘)])  # 初始化队列

while queue:
    # 从队列左侧取出元素
    c, p = queue.popleft()
    
    for k, v in c.items():
        new_key = f"{p}_{k}" if p else k
        
        if isinstance(v, dict):
            # 将嵌套字典加入队列右侧
            queue.append((v, new_key))
        else:
            f[new_key] = v 

print(f)

输出结果:

{‘a‘: 1, ‘b_x‘: 2, ‘c_m‘: 4, ‘b_y_z‘: 3}

#### 深度解析

细心的你一定发现了,这次的输出顺序看起来更“自然”一些(虽然 Python 3.7+ 字典本身就有序,但这里的遍历顺序体现了广度优先搜索 BFS 的特点)。我们先处理完第一层的所有直接子项,当遇到 INLINECODE638c28a9 和 INLINECODE01c7d472 时,将它们放入队列,随后依次处理它们的子项。这种方式更适合于层级较宽、我们希望按层级顺序逐步处理数据的场景。

方法三:递归——代码更简洁的方案

如果你喜欢代码简洁、优雅,或者你的字典嵌套层级非常深且你不想手动管理栈或队列的状态,那么递归是你的不二之选。递归函数会自己调用自己,直到触底反弹,完美契合嵌套字典的层级结构。

#### 核心思路

  • 定义辅助函数:我们需要一个内部函数(比如叫 flatten),它接受当前字典和父级键前缀作为参数。
  • 终止条件:虽然没有显式的终止条件(因为没有显式 return),但隐式终止是“遍历完所有非字典键值对”。
  • 递归逻辑:遍历字典的键值对。如果值是字典,则以新的键名前缀递归调用该函数;如果不是,则更新外部作用域的结果字典。

#### 代码实现

def flatten_dict(nested_dict):
    # 用于存储最终结果的字典
    flat_dict = {}
    
    # 定义内部递归函数
    def _flatten(current_dict, parent_key=‘‘):
        for k, v in current_dict.items():
            # 构造新的键名
            new_key = f"{parent_key}_{k}" if parent_key else k
            
            if isinstance(v, dict):
                # 如果是字典,递归调用自身
                _flatten(v, new_key)
            else:
                # 如果不是字典,存入结果
                flat_dict[new_key] = v
    
    # 启动递归
    _flatten(nested_dict)
    return flat_dict

# 测试数据
a = {‘a‘: 1, ‘b‘: {‘x‘: 2, ‘y‘: {‘z‘: 3}}, ‘c‘: {‘m‘: 4}}
print(flatten_dict(a))

输出结果:

{‘a‘: 1, ‘b_x‘: 2, ‘b_y_z‘: 3, ‘c_m‘: 4}

#### 优缺点分析

递归方法的代码结构非常清晰,几乎可以直接映射到我们的逻辑思维上。然而,在 Python 中,递归深度是有限制的(默认为 1000)。如果你的嵌套字典层级超过 1000 层,Python 会抛出 RecursionError。但在绝大多数业务场景(如处理 API 返回的 JSON 或配置文件)中,嵌套层级远达不到这个极限,因此递归通常是最受欢迎的选择。

实战应用场景

你可能会问,学会这个技巧到底有什么用?让我给你列举几个真实的场景:

  • 生成 Pandas DataFrame:Pandas 的 DataFrame 非常强大,但它喜欢扁平的数据。如果你直接把嵌套字典扔给 DataFrame,它会生成包含 Series 对象的列,非常难以操作。先将其扁平化,再转为 DataFrame,数据清洗效率会提升一个档次。
  • 构建 URL 查询参数:在发送 HTTP 请求时,我们有时需要将复杂的过滤条件转换为 URL 参数。扁平化的字典可以很容易地被编码成 key1.subkey=value 的形式。
  • 日志分析:从日志系统(如 ELK)中导出的数据往往包含嵌套的 JSON 字段。为了方便在 Excel 或 BI 工具中分析,我们需要将这些嵌套字段“拍平”。

进阶技巧与最佳实践

虽然上面的代码已经能解决大部分问题,但在实际工程中,我们还需要考虑更多细节。下面我将提供几个增强版的代码片段,帮助你处理更复杂的情况。

#### 1. 自定义分隔符

目前的示例都使用下划线 INLINECODE345c54c7 作为分隔符。但在实际中,你可能希望使用 INLINECODE85430474 (点号,类似 JSONPath) 或者 INLINECODE084a3406 (类似路径)。我们可以轻松地修改函数,增加一个 INLINECODE6cf4ad43 参数。

def flatten_with_separator(nested_dict, sep=‘_‘):
    items = {}
    
    def _flatten(current_dict, parent_key=‘‘):
        for k, v in current_dict.items():
            new_key = f"{parent_key}{sep}{k}" if parent_key else k
            
            if isinstance(v, dict):
                _flatten(v, new_key)
            else:
                items[new_key] = v
                
    _flatten(nested_dict)
    return items

# 使用点号分隔
a = {‘a‘: 1, ‘b‘: {‘x‘: 2}}
print(flatten_with_separator(a, sep=‘.‘))
# 输出: {‘a‘: 1, ‘b.x‘: 2}

#### 2. 处理列表和元组

现实世界的数据不仅仅是字典。你可能会遇到值是列表的情况,比如 INLINECODE070ae8b4。对于这种情况,我们可以选择将列表展开为索引,例如 INLINECODE101e8a44 和 user_roles_1。让我们升级一下我们的递归函数来处理这个需求。

def flatten_advanced(nested_dict, sep=‘_‘):
    items = {}
    
    def _flatten(x, parent_key=‘‘):
        # 如果是字典,按原来的逻辑处理
        if isinstance(x, dict):
            for k, v in x.items():
                new_key = f"{parent_key}{sep}{k}" if parent_key else k
                _flatten(v, new_key)
        # 如果是列表或元组,遍历索引
        elif isinstance(x, (list, tuple)):
            for i, v in enumerate(x):
                new_key = f"{parent_key}{sep}{i}" if parent_key else str(i)
                _flatten(v, new_key)
        else:
            # 既不是字典也不是列表,则是最终值
            items[parent_key] = x
            
    _flatten(nested_dict)
    return items

# 复杂示例
data = {
    ‘user‘: {
        ‘name‘: ‘Alice‘,
        ‘roles‘: [‘admin‘, ‘editor‘],
        ‘scores‘: (90, 85)
    }
}

print(flatten_advanced(data))

输出结果:

{‘user_name‘: ‘Alice‘, ‘user_roles_0‘: ‘admin‘, ‘user_roles_1‘: ‘editor‘, ‘user_scores_0‘: 90, ‘user_scores_1‘: 85}

这个功能非常强大,它可以把极其复杂的混合结构完全拍平,这在处理非结构化数据时简直是救命稻草。

#### 3. 使用生成器节省内存

如果你的字典非常大,一次性构建结果字典可能会消耗大量内存。我们可以利用 Python 的生成器来惰性地生成键值对,这样你可以按需处理或者将其逐条写入数据库/文件,而不是全部加载到内存中。

def flatten_generator(nested_dict, sep=‘_‘):
    def _flatten(x, parent_key=‘‘):
        if isinstance(x, dict):
            for k, v in x.items():
                new_key = f"{parent_key}{sep}{k}" if parent_key else k
                yield from _flatten(v, new_key)
        elif isinstance(x, (list, tuple)):
            for i, v in enumerate(x):
                new_key = f"{parent_key}{sep}{i}" if parent_key else str(i)
                yield from _flatten(v, new_key)
        else:
            yield (parent_key, x)
    
    return _flatten(nested_dict)

# 使用示例:逐条打印而不存储在列表中
a = {‘a‘: 1, ‘b‘: {‘x‘: 2, ‘y‘: {‘z‘: 3}}}
for k, v in flatten_generator(a):
    print(f"Key: {k}, Value: {v}")

性能优化建议

最后,我们来谈谈性能。

  • 拼接字符串:在循环中频繁使用 INLINECODEce0df033 或 INLINECODE1d8bd9b0 会产生大量的临时字符串对象。如果你的性能要求极高(比如处理海量数据),可以考虑使用列表收集路径片段,最后在叶子节点 join 一次,但这通常会增加代码复杂度,对于大多数场景,目前的 f-string 已经足够快且可读性好。
  • 避免重复类型检查isinstance(v, dict) 是 O(1) 操作,非常快,无需过度优化。

总结

在这篇文章中,我们深入探讨了如何在 Python 中将嵌套字典展平。

  • 如果你需要一个稳健且不受递归深度限制的解决方案,栈或队列迭代法是最佳选择。
  • 如果你追求代码的优雅和简洁,且数据结构不是特别极端,递归法无疑是最具 Pythonic 风格的写法。
  • 如果你需要处理列表和元组,或者需要自定义分隔符,参考我们提供的进阶技巧即可。

掌握这些技巧后,你将不再畏惧复杂的 JSON 数据结构,而是能从容地将其转化为任何你需要的格式。希望这篇文章能对你的 Python 编程之旅有所帮助!

下一步,你可以尝试将这些封装成一个独立的工具类或者 Python 库,这样在未来的项目中就可以直接复用了。祝编码愉快!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/42404.html
点赞
0.00 平均评分 (0% 分数) - 0