ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python过滤偶数并计算平方:列表推导式与filter/map性能对比

Python过滤偶数并计算平方:列表推导式与filter/map性能对比 1. 问题拆解过滤偶数与计算平方的真实应用场景1.1 这个示例到底在解决什么问题你可以把过滤偶数并计算平方看成数据处理里最经典的两个动作的组合先做筛选再做变换。筛选是把不符合条件的数据剔除变换是把剩下的数据按规则加工。这两个动作是任何一门编程语言处理数据时都绕不开的基本功Python把它做得很干净。拿一个特别常见的场景举例假设你手上有一批订单金额需要把金额为偶数的订单抽出来再计算这些订单金额的平方作为某种评分指标。又或者你拿到一份传感器读数只需要处理偶数编号的样本并且要把读数做平方放大。再简单一点爱好者做九九乘法表相关的小工具时也经常要用到这类筛选加变换的逻辑。这个示例之所以适合所有Python初学者是因为它不大不小——代码量刚好能展示Python的核心语法特性又不会因为业务逻辑太复杂让人分心。同时它也适合有一定经验的开发者作为函数式编程和列表推导式的重要参考因为一点点就能延伸到generator、map、filter这些高级话题。1.2 用生活化场景类比去理解过滤变换我们可以把整个流程想成一条水果分拣流水线。你有一堆苹果先按重量筛掉太轻的过滤再把合格的苹果贴上价格标签变换。如果用一个程序来描述你需要一个容器装苹果需要一个条件来判断苹果是否合格还需要一个动作来给苹果贴条。对应到Python代码里这三件事分别对应可迭代对象数据源、条件表达式判断是否为偶数、操作表达式计算平方。把这三件事用列表推导式写在一行里就是result [x**2 for x in range(20) if x % 2 0]这行代码读起来很接近自然语言对range(20)里面的每个x先看x是不是偶数是的话就计算x的平方最后把所有平方结果装进一个列表。这种先条件后动作的排列顺序和人的思维方式天然一致这也是列表推导式在Python里如此流行的原因。2. 基础实现列表推导式与函数式方案对比2.1 列表推导式大多数场景下的最优解先把最直接的答案亮出来。假设我们要处理的是0到19这20个整数过滤出偶数再平方代码长这样numbers range(20) result [x**2 for x in numbers if x % 2 0] print(result) # 输出: # [0, 4, 16, 36, 64, 100, 144, 196, 256, 324]逐行拆解一下。range(20)生成0到19的整数序列注意这是惰性对象不会一次性把20个数字全部塞进内存这点在大数据量时很重要。x % 2 0是取模判断如果x除以2的余数是0说明这是一个偶数。x**2是平方运算Python里**是幂运算符x**2就是x的二次方。有一个容易忽略的小知识点range(20)是从0开始的而0是偶数所以0**2等于0也出现在结果列表里。如果你不希望0出现在结果中可以加一个下界result [x**2 for x in range(1, 21) if x % 2 0]这样处理的是1到20之间的偶数结果就从4开始了。这个细节在真实业务里可能影响计算结果有强迫症的开发者不妨多留个心眼。2.2 filter加map的写法及底层逻辑列表推导式虽然简洁但不是说它是唯一解。Python内置的filter和map函数可以组合出同样的效果numbers range(20) result list(map(lambda x: x**2, filter(lambda x: x % 2 0, numbers))) print(result) # 输出: # [0, 4, 16, 36, 64, 100, 144, 196, 256, 324]这段代码的执行过程是先执行最内层的filter(numbers)把偶数挑出来得到一组偶数序列再执行外层的map对每个偶数套用lambda x: x**2计算平方最后用list把map返回的迭代器转成列表。之所以需要list包裹一层是因为filter和map返回的都是迭代器迭代器是惰性求值的必须消费它才能拿到全部结果。如果你直接print(map(...))看到的只会是类似map object at 0x7f...的一串地址信息。在Python 3中尤其要注意这一点Python 2时代filter返回的是列表到了Python 3改成返回迭代器很多人从老版本迁移代码时踩过这个坑。2.3 两种写法如何选型很多新手会纠结到底学列表推导式还是学filter加map我个人的使用经验是绝大多数业务代码用列表推导式。原因有三点第一列表推导式的执行速度通常更快原因在于它的循环在C层实现而filter加map涉及多次Python函数调用函数调用是有开销的第二列表推导式的可读性更高因为它把筛什么、变什么写在同一个表达式里而filter加map需要从内到外读顺序上是反的第三列表推导式可以很方便地扩展条件比如说想同时过滤偶数和大于10的数直接加一个if条件就行。但filter和map并不是没有用武之地。如果你做的是函数式编程风格的项目或者需要把函数作为参数传递、组合使用那filter和map就是你需要的工具。而且当你需要复用一个过滤逻辑时可以把filter的条件函数提取成具名函数def is_even(x): return x % 2 0 result list(map(lambda x: x**2, filter(is_even, numbers)))这样is_even可以被其他代码复用代码结构更干净。要不要用filter和map核心判断标准是你和你的同事能不能一眼看懂。如果过两个月回来看代码要花三分钟才能反应出它在干什么那不管多酷炫都该改掉。3. 实战延伸从玩具代码到真实数据处理3.1 读取文件中的数据并完成过滤计算range(20)这种写法适合演示语法但真实工作里数据通常来自文件、数据库或者接口。拿最常见的场景来举例有一个data.txt文件每行一个整数我们要读取文件内容、过滤偶数、算平方再写到另一个文件里。with open(data.txt, r, encodingutf-8) as f: raw_lines f.readlines() numbers [] for line in raw_lines: line line.strip() if line: # 跳过空行 numbers.append(int(line)) squared_evens [x**2 for x in numbers if x % 2 0] with open(result.txt, w, encodingutf-8) as f: for value in squared_evens: f.write(f{value}\n)这段代码看起来比纯列表推导式复杂但每个步骤都是必要的。strip()用来去掉每行末尾的换行符和首尾空白字符if line跳过空行防止空字符串被int()转换时报错int(line)把字符串转成整数。这里有一个非常隐蔽的坑如果文件里有一行写的是12.5或者有一个空文件直接int(line)会因为无法解析而抛出ValueError。真实场景中脏数据防不胜防我会把转换部分包成异常处理numbers [] for line in raw_lines: line line.strip() if not line: continue try: numbers.append(int(line)) except ValueError: print(f警告无法解析的行被跳过: {line})3.2 处理不合理数据时的防御性写法除了文本转整数的问题还有一个容易被忽略的情况负数到底算不算偶数从数学定义上说偶数包括负偶数因为负偶数也能被2整除。但是有些业务场景里负数可能是错误数据或者异常值。我见过不少团队对负数数据的处理方式不统一导致同一个功能在不同模块里跑出来的结果不一样。建议在过滤条件里显式加入范围约束这样逻辑一目了然result [x**2 for x in numbers if x 0 and x % 2 0]如果业务逻辑不允许处理负数还可以直接打日志或者抛异常if any(x 0 for x in numbers): raise ValueError(输入数据中包含负数请检查数据源)这一段代码就是生成器表达式的实际应用any函数会逐个检查numbers里的元素一旦发现负数就返回True。注意这里用的是圆括号而不是方括号所以不会生成整个布尔列表数据量大的时候省内存。防御性写法不是让你把代码写得臃肿而是要让程序在异常情况下有明确的、可预期的行为。宁可写得啰嗦一点也比线上数据出问题后查半天强。3.3 生成结果后如何安全落盘写文件这一步也有讲究。f.write(f{value}\n)是逐行写入适合数据量中等的情况。如果数据量很大逐行写入会频繁进行磁盘I/O操作拉低整体效率。可以改成构建一个大的字符串一次性写入output \n.join(str(v) for v in squared_evens) with open(result.txt, w, encodingutf-8) as f: f.write(output)\n.join(...)会把每个平方数用换行符连接成一个大字符串。注意生成器表达式里的str(v)必须写因为join要求所有元素都是字符串类型直接传整数会报TypeError。另一个常见需求是想保留原始数字和计算结果对应关系。比如想知道8原本是多少平方之后是多少那列表里只放平方值就不够了。可以用元组保存原始值和计算值result [(x, x**2) for x in numbers if x % 2 0]输出结果就是[(0, 0), (2, 4), (4, 16), ...]这样的结构每一条记录都有数据血缘查问题的时候能追溯源头。4. 性能与内存数据量上来之后怎么应对4.1 列表推导式、生成器表达式与map的内存对比初学者往往觉得代码能跑就行但数据量一旦涨到百万级性能问题就会暴露出来。还是过滤偶数并计算平方这个例子看看三种写法有什么区别# 写法一列表推导式一次性产出一个列表 squares_list [x**2 for x in range(1_000_000) if x % 2 0] # 写法二生成器表达式迭代时逐个产出 squares_gen (x**2 for x in range(1_000_000) if x % 2 0) # 写法三map filter同样是惰性求值 squares_map map(lambda x: x**2, filter(lambda x: x % 2 0, range(1_000_000)))写法一会占用约500万个元素100万个数里一半是偶数的内存空间。假如每个整数对象占28字节左右加上列表本身的开销内存轻松超过100MB。写法二和写法三不会预先计算所有结果它们只是在需要时逐个产出内存占用几乎可以忽略。那是不是任何场景都用生成器表达式就好了也不全是。如果你后续要反复遍历这批数据比如既要计算总和、又要算平均值还要找最大值生成器只能遍历一次遍历完就空了。此时只能重新生成一遍反而更费事。而列表虽然在内存里占了地方但可以反复访问。4.2 测量耗时与内存的正确姿势说到性能和内存必须用数据说话。推荐用timeit模块做耗时测试import timeit setup numbers list(range(1000000)) stmt_list [x**2 for x in numbers if x % 2 0] stmt_gen list(x**2 for x in numbers if x % 2 0) stmt_map list(map(lambda x: x**2, filter(lambda x: x % 2 0, numbers))) t1 timeit.timeit(stmt_list, setupsetup, number10) t2 timeit.timeit(stmt_gen, setupsetup, number10) t3 timeit.timeit(stmt_map, setupsetup, number10) print(f列表推导式: {t1:.4f}s) print(f生成器转列表: {t2:.4f}s) print(fmapfilter: {t3:.4f}s)实话说我自己的多次测试下来列表推导式通常略微快于生成器表达式转列表而mapfilter这种组合在大量使用lambda时速度最慢原因之前提过函数调用开销和多次迭代开销叠加。但如果把lambda换成内置函数比如map(str, data)这种用法速度优势才会体现出来。真正要关注的不是几毫秒的差异而是内存的成倍增长。数据量百万级时还好到了千万级、亿级时一次性生成列表会让程序直接卡死甚至被系统杀掉。这时候生成器表达式是不二选择。4.3 什么时候需要转向NumPy如果数据量达到百万级以上而且你还需要做大量数值计算那纯Python写法的性能天花板就很明显了。同样的过滤偶数并计算平方用NumPy写是import numpy as np arr np.arange(1_000_000) result arr[arr % 2 0] ** 2NumPy的底层是用C语言实现的数组操作arr % 2 0会在C层完成一次向量化取模运算然后布尔索引在C层筛选元素** 2再在C层做平方。整个过程没有Python循环也没有Python级别的函数调用速度通常比纯Python快几十倍。但要注意是否引入NumPy不能只看数据量还要看你的项目里是否已经在用NumPy生态。如果只是想算一组小数据引入NumPy属于过度设计仅仅为了过滤偶数就装一个几十MB的库不划算。判断标准很简单当纯Python写法跑一次需要几秒钟或者内存占用明显吃紧时再考虑NumPy。5. 新手常见错误与排查实录5.1 filter函数忘写类型转换接触filter函数时最容易犯的错误是直接打印结果numbers range(20) result filter(lambda x: x % 2 0, numbers) print(result)得到的输出是filter object at 0x7f8b1c3b8a90而不是[0, 2, 4, ...]。很多第一次接触迭代器概念的人看到这个输出第一反应是代码写错了但其实filter对象是惰性求值的你需要用list()消费它result list(filter(lambda x: x % 2 0, numbers))同样的问题也出现在map、zip、dict.keys()这些惰性对象上。排查办法很直接先弄明白对象是什么类型用type()函数看再用list()或for循环消费它。5.2 把过滤和变换的先后顺序搞反另一个经典错误是先平方再过滤# 错误写法先算平方再判断是否为偶数 wrong [x**2 for x in range(20) if (x**2) % 2 0]这个写法的数学结果是奇数的平方还是奇数偶数的平方还是偶数所以最终结果确实也是偶数的平方。看起来输出好像差不多但计算量翻倍了因为每个数都先算了平方再进行奇偶判断。不过更微妙的问题出在语义上如果判断条件依赖的是平方后的值那这个写法没错如果判断条件依赖的是原始值这个写法就错了。比如把问题换成过滤出偶数再计算平方加1两种写法结果就完全不同。根本原则是先确认你的筛选条件基于哪个值再决定过滤和变换的顺序。5.3 类型不一致导致的计算异常假设你从CSV文件或Excel读取数据读出来的一列数字在Python里其实是字符串类型。这时候拿来做取模运算会直接抛出TypeErrornumbers [10, 12, 15, 18] result [x**2 for x in numbers if x % 2 0] # TypeError: not all arguments converted during string formatting这个报错信息比较迷惑很多人第一次看到会懵。实际上是因为字符串格式化运算符%和取模运算符%在Python里是同一个符号字符串用%时走的是格式化逻辑比如%s % hello这种用法所以报错说不是所有参数都被转换。解决办法是提前把字符串转成整数numbers [10, 12, 15, 18] result [int(x)**2 for x in numbers if int(x) % 2 0]不过这样int()被调用了两次又丑又慢。更优雅的做法是分两步走numbers [int(x) for x in numbers] result [x**2 for x in numbers if x % 2 0]第一步统一做类型转换让后续计算的类型干净统一这也是一种防御性编程思路。5.4 生成器只能遍历一次的问题用生成器表达式处理大量数据时有个特别容易踩的坑生成器用完之后再去遍历拿到的什么也没有。nums (x for x in range(10)) print(sum(nums)) # 45 print(sum(nums)) # 0因为nums已经被消费完了这在调试时尤其让人困惑。第一次调用sum能算出结果第二次调用却变成0看起来像是程序状态出了问题。实际上这是生成器的正常行为生成器内部保存着迭代状态每次next()调用都会往前推进遍历完就到底了。如果你需要多次遍历同一份数据要么用列表把它保存下来要么用itertools.tee复制出多个生成器。在过滤偶数并计算平方这个例子里如果你既要打印结果又要统计结果的总和把生成器先转成列表是最省心的方式数据量实在太大就考虑换用NumPy这类支持多遍遍历的方案。6. 从这个小例子延伸出去的Python学习路径6.1 从推导式到复杂表达式过滤偶数并计算平方这个例子看似简单但它是一整类问题的代表。掌握了之后可以尝试扩展出很多变体。比如筛选条件换成既是偶数又是3的倍数result [x**2 for x in range(100) if x % 2 0 and x % 3 0]再比如加上if-else的条件变换这是列表推导式里很多人没注意到的语法result [x**2 if x % 2 0 else x**3 for x in range(10)]这个表达式表示如果x是偶数就平方否则就立方。相当于把if-else分支内联到推导式里。注意这个写法和先if过滤再平方是有本质差别的——前者对每个元素都做二选一的变换后者先筛掉一部分元素再对剩余元素做变换。在此基础上可以延伸到字典推导式、集合推导式square_dict {x: x**2 for x in range(10) if x % 2 0} square_set {x**2 for x in range(10) if x % 2 0}字典推导式在需要建立映射关系时非常实用比如把用户ID映射到权限值集合推导式天然去重可以用一行代码从大量数据中提取出唯一的偶数平方值。6.2 结合文件I/O、lambda和内置函数进阶学习者可以把这个小例子放进更完整的场景中。比如把文本读到内存用推导式过滤计算再把结果写回去。更进一步可以把处理逻辑封装成一个函数传入任意数字列表输出过滤平方后的结果def square_evens(numbers): 过滤偶数计算平方返回新列表。 return [x**2 for x in numbers if x % 2 0]这个函数可以配合sorted、min、max这些内置函数一起使用data [5, 3, 8, 2, 10, 7] result square_evens(data) print(result) # [64, 4, 100] print(sorted(result)) # [4, 64, 100] print(sum(result)) # 168把核心逻辑封装成函数既能脱离固定数据源又方便做单元测试这是从写脚本到写程序的重要一步。6.3 与其他热词场景的衔接如果顺着这个示例继续深入学习有几个扩展方向特别值得花时间数据分析方向可以学习如何用pandas做更复杂的数据筛选与列变换DataFrame里的列操作本质也是过滤加变换的模式只是规模更大、API更丰富。可视化方向这个例子可以和matplotlib结合把过滤平方的结果画成柱状图。数据处理能力和可视化能力在数据分析岗位上是配套技能缺一不可。自动化脚本方向尝试把这个逻辑写成命令行工具输入一个文件名程序自动读取数字、过滤计算、把结果写回文件。这样才算真正把代码用起来而不是只停留在交互式环境里跑练习。爬虫方向爬下来的数据往往很杂乱清洗数据时最常用的操作就是类型转换加条件过滤。这个例子里养的防御性思维在数据清洗中尤其重要因为爬虫数据里面什么脏值都有。7. 实操心得与最后的建议每次我给别人讲Python基础语法总会拿这个过滤偶数并计算平方当开场白因为它太能说明Python的设计哲学了。从语言层面看这个例子同时涵盖了循环、条件判断、算术运算、数据结构这四个Python的基石。从思维方式看它展示了声明式编程和函数式编程两种不同思路。从工程实践看它可以延展出性能优化、内存管理、异常处理、代码重构等进阶话题。我的建议是不要停留在把代码运行出来就算完成任务。每次运行代码时多问自己几个问题这个操作的时间复杂度是O(n)还是O(n^2)换一种写法会不会更快如果数据量翻100倍会怎样条件判断放在哪个位置最合理只有带着这些问题去写代码才能从能跑进化到会写。另外一定要养成随手写注释的习惯。这个例子本身很小不写注释也能看懂但封装成函数、扩展到真实业务后没有注释代码的可维护性会直线下降。保持头注释docstring的规范性哪怕只是两行字半年后你回来维护代码时会感激自己。Python的进阶之路没有捷径但像这样把一个小知识点吃透、掰开、揉碎一个点一个点地攻克反而是最扎实的成长方式。这个过滤偶数算平方的小例子就是你迈出的第一步。
返回列表