ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python函数编程实战:作用域、递归与算法边界避坑指南

Python函数编程实战:作用域、递归与算法边界避坑指南

简介:本资源为《Python编程实践》第8章练习题及解答,作者陈波、刘慧君,面向正在学习Python基础语法与函数式编程的高校学生及自学者,帮助读者通过动手练习巩固函数、递归、循环与字符串处理等核心知识点。压缩包内共1个PDF文件,约224KB,内容按题号组织,涵盖8-1至8-8的完整题目、答案与思路解析。具体包括:计算整数各位数字之和、递归求f(i)=1/2+2/3+…+i/(i+1)、将整数转换为十六进制/八进制/二进制字符串、计算不特定多个数的乘积、打印前100个回文素数,以及折半查找学生信息等典型练习。每道题均配有可运行的示例代码与算法说明,如取模消位、素数筛选法、递归调用过程等,便于读者对照理解并自行验证。目前已有1935人学习下载,适合需要系统刷题、查漏补缺或准备课程实验的Python初学者。

1. 从一份课后题解说起:为什么第 8 章值得逐题拆

很多人学 Python 卡在函数这一章,不是因为语法看不懂,而是因为「看得懂、写不出」。这份《Python编程实践》第 8 章的练习题及解答,恰好把函数编程里最容易翻车的几个点全踩了一遍:全局变量与局部变量同名时的作用域判定、递归的展开过程、进制转换的取模反序、回文素数的筛选法、折半查找的边界处理,还有分治找假硬币的递归改非递归。它不是什么大厂项目源码,而是一份带着「思路」和「答案」的章节题解,适合两类人:一类是正在自学 Python、想拿题目练手但苦于没有参考解法的后端方向初学者;另一类是想回头补函数式思维、递归与算法基本功的开发者。整份资源围绕 8-1 到 8-9 共九道题展开,每道题都给了代码和思路说明,其中 8-2 的作用域题、8-7 的回文素数、8-9 的递归消除是最有嚼头的三道。下面我不按题号顺序念答案,而是按「能跑起来、能改参数、能排错」的方式,把这份题解拆成可复现的实战笔记。

2. 函数作用域与递归展开:8-1 到 8-4 的执行过程还原

2.1 全局变量与局部变量同名时,Python 到底用哪个

8-2 那道题是整章最容易被低估的。题目给了一段代码,函数func内部定义了x = 77,又用了z = y * 2,而y在函数外定义为 1。执行后输出x+y= 79、x= 1、y= 1。很多人第一眼会以为函数里的x = 77会改掉全局的x,结果打印出来全局x还是 1,于是产生「玄学」感。其实规则很干脆:函数内部对x赋值,Python 就把x视为局部变量,函数内的读写都走局部;函数内没有对y赋值,只读取,于是向上找全局y。函数外的print读的是全局x和全局y,自然不受函数内局部x影响。

把这段逻辑写成可验证的脚本,比死记规则有用:

x = 1 y = 1 def func(): x = 77 # 局部变量,遮蔽全局 x z = y * 2 # y 未在函数内赋值,读取全局 y print("x+y=", x + z) func() print("x=", x) # 全局 x 未被修改 print("y=", y)

运行结果与题解一致。这里的关键参数是「赋值即局部」:只要函数体内出现x = ...,整个函数作用域内x都是局部的,哪怕在赋值前读取也会报UnboundLocalError。想改全局,必须显式写global x。我一般会建议在函数里避免用与全局同名的变量,否则后期排查时非常费眼。

2.2 递归不是绕圈,是把调用栈一层层摊开

8-4 给了一个递归函数func(x),当x <= 1返回 1,否则返回x + func(x-2),调用func(9)得到 25。题解里把展开过程写成了9+7+5+3+1,这就是递归最该被看见的部分。很多人写递归靠感觉,结果边界写错就栈溢出。把展开过程用代码打印出来,能直接看到调用链:

def func(x, depth=0): print(" " * depth + f"func({x})") if x <= 1: return 1 return x + func(x - 2, depth + 1) print("结果:", func(9))

输出会逐层缩进显示func(9)到func(1),最后回传求和。参数x每次减 2,所以递归深度约为x/2,对 9 来说只有 5 层,不会触发递归上限。但如果把步长改成 1 且不设边界,就会无限递归。这里要提醒的是:递归函数的第一个if必须是「收敛条件」,且要保证每次递归都朝这个条件靠近。

2.3 各位数字之和:取模与整除的循环写法

8-3 要求计算一个整数的各位数字之和。题解给的循环写法是:

def caculate(temp): ans = 0 while temp: ans += temp % 10 # 取出最低位 temp //= 10 # 消去最低位 return ans a = int(input("请输入一个整数:")) print(caculate(a))

逻辑说明:temp % 10拿到个位,累加到ans;temp //= 10把个位丢掉,直到temp变成 0 退出循环。参数上要注意负数:如果输入-123,-123 % 10在 Python 里是 7 而不是 -3,因为 Python 的取模向负无穷取整。所以这份题解默认处理非负整数。要兼容负数,得先取绝对值或单独处理符号位。这是新手很容易忽略的边界。

2.4 分数序列求和:循环比递归更稳

8-4 的另一问是计算f(i) = 1/2 + 2/3 + ... + i/(i+1)。题解用while循环:

def caculate(n): x = 1 ans = 0 while x <= n: ans += x / (x + 1) x += 1 return ans value = int(input("请输入正整数n:")) print(caculate(value))

这里x从 1 走到n,每次累加x/(x+1)。参数n决定项数,n越大结果越接近n - ln(n)量级,但题解不要求精度分析,只要求正确累加。注意x / (x+1)在 Python 3 里是浮点除法,不会截断;如果写成x // (x+1)就全是 0,这是血泪经验级别的低级错误。

3. 进制转换与不定参数:8-5、8-6 的字符串构造细节

3.1 十进制转十六、八、二进制:取模反序的统一套路

8-5 要求写三个函数,分别把整数转成十六进制、八进制、二进制字符串。题解的核心套路完全一致:不断取模得到当前最低位,整除降位,最后把收集到的字符反序。十六进制多了一个映射表,因为 10 到 15 要变成 A 到 F。

def My_Hexadecimal(temp): ans = "" Map = {0:"0",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7", 8:"8",9:"9",10:"A",11:"B",12:"C",13:"D",14:"E",15:"F"} while temp: ans += Map[temp % 16] temp //= 16 return ans[::-1] def My_Octonary(temp): ans = "" while temp: ans += str(temp % 8) temp //= 8 return ans[::-1] def My_Binary(temp): ans = "" while temp: ans += str(temp % 2) temp //= 2 return ans[::-1] value = int(input("请输入一个整数:")) print("十六进制:0x", My_Hexadecimal(value)) print("八进制:0o", My_Octonary(value)) print("二进制:0b", My_Binary(value))

逻辑说明:以十六进制为例,temp % 16得到当前最低位的数值,查Map转成字符追加到ans尾部;temp //= 16去掉这一位。循环结束时ans是低位在前,所以用ans[::-1]反序。参数上,Map的键必须覆盖 0 到 15,少一个就会KeyError。另外这段代码对value = 0会返回空字符串,因为while temp直接不进入。要修的话,在函数开头加if temp == 0: return "0"。这是题解没覆盖但实际使用一定会遇到的坑。

3.2 不定参数的乘积:*temp 收集与空参数陷阱

8-6 要求计算一个或不特定多个数的乘积,题解用*temp:

def caculate(*temp): ans = 1 for x in temp: ans *= x return ans print(caculate(1, 2, 3, 4, 5))

*temp把调用时传入的所有位置参数打包成一个元组,函数体内遍历相乘。初始值设为 1 而不是 0,否则乘积永远是 0。参数说明:如果调用caculate()不传任何数,temp是空元组,循环不执行,返回 1。数学上空乘积定义为 1,所以这个行为是合理的。但如果业务上要求至少传一个数,就得在函数里加if not temp: raise ValueError(...)。另外,如果传入的是列表而不是展开的参数,比如caculate([1,2,3]),temp会变成([1,2,3],),乘的时候列表不能和整数相乘,会报TypeError。要支持列表,得用caculate(*[1,2,3])展开。

3.3 三个转换函数的复用与边界统一

把三个进制转换函数放在一起看,会发现它们只有「进制数」和「映射方式」不同。可以抽成一个通用函数:

def to_base(temp, base): if temp == 0: return "0" digits = "0123456789ABCDEF" ans = "" while temp: ans += digits[temp % base] temp //= base return ans[::-1]

这样十六进制调to_base(value, 16),八进制调to_base(value, 8),二进制调to_base(value, 2)。参数base支持 2 到 16,超过 16 需要扩展digits。这种改法在题解基础上做了工程化收敛,减少重复代码,也顺手修了 0 的边界。

4. 回文素数筛选与折半查找:8-7、8-8 的算法落地

4.1 回文素数:先筛素数再判回文,顺序不能反

8-7 要求打印前 100 个回文素数,每行 10 个,判断素数和反序都用函数实现。题解用的是素数筛选法:先筛出 2 到 100000 的所有素数,再逐个判断是否回文,凑够 100 个就停。

def Prime(): prime = [] count = {} for i in range(2, 100000): temp = i if temp not in count: prime.append(temp) while temp <= 100000: count[temp] = 1 temp += i return prime def judge(temp): value_str = str(temp) Len = len(value_str) for i in range(Len // 2): if value_str[i] != value_str[Len - 1 - i]: return False return True prime = Prime() ans = [] count = 0 for i in prime: if judge(i): ans.append(i) count += 1 if count >= 100: break cnt = 0 for x in ans: print(x, end=" ") cnt += 1 if cnt % 10 == 0: print("")

逻辑说明:Prime()用了一个字典count做标记,i没被标记过就是素数,加入prime,然后把i的所有倍数标记掉。这是埃拉托斯特尼筛法的变体。judge()把数字转成字符串,首尾向中间比较,只要有一对不等就返回False。参数上,筛的上限 100000 是题解写死的,因为前 100 个回文素数不会超过这个范围。如果上限调小,可能凑不够 100 个;调大则浪费内存。实际跑下来,前 100 个回文素数里最大的是 94049,所以 100000 够用。

4.2 折半查找:边界条件比算法本身更容易错

8-8 的折半查找题给了一个学生列表,每个子列表是[学号, 姓名],学号已递增排序,要求用折半查找找指定学号。题解的写法是:

def Binary_Find(Stu): bottom = 0 top = len(Stu) - 1 while bottom <= top: mid = (bottom + top) // 2 if num == int(Stu[mid][0]): return "已找到" elif num > int(Stu[mid][0]): bottom = mid + 1 elif num < int(Stu[mid][0]): top = mid - 1 return "没有找到" num = int(input("请输入要查找的学生学号:")) Stu = [[str(2018 + i), str(i + 20)] for i in range(21)] print(Binary_Find(Stu))

逻辑说明:bottom和top维护当前查找区间,mid取中点。相等就返回;目标比中点大就把bottom推到mid+1;目标比中点小就把top拉到mid-1。循环条件是bottom <= top,等号不能丢,否则区间只剩一个元素时会漏查。参数上,Stu的学号必须是可比较的整数,题解里用int(Stu[mid][0])转换。如果学号带字母,比如'20189X',int()会直接报ValueError。这是题解数据示例和代码之间的一个不一致点,实际使用时要么保证学号纯数字,要么改成字符串比较。

4.3 折半查找的比较次数与 logN 验证

折半查找最坏情况下比较次数是log2(N)量级。对 21 个学生,最多比较 5 次。可以在函数里加一个计数器验证:

def Binary_Find(Stu, target): bottom, top, steps = 0, len(Stu) - 1, 0 while bottom <= top: steps += 1 mid = (bottom + top) // 2 if target == int(Stu[mid][0]): return "已找到", steps elif target > int(Stu[mid][0]): bottom = mid + 1 else: top = mid - 1 return "没有找到", steps

把steps返回出来,就能直观看到每次查找用了几次比较。参数target是待查学号。这个改动不影响原逻辑,但能让「logN」从口号变成可观测的数字。

5. 分治找假硬币:8-9 递归改非递归的避坑记录

5.1 递归版与迭代版的差异点

8-9 要求把分治找假硬币的递归程序改成非递归,功能不变。题解给的迭代版核心是:

def findFalseCoin(coins, start, n): bottom = start num = n while num: mid = num // 2 ans_left = sum(coins[bottom:bottom + mid]) ans_right = sum(coins[bottom + mid:bottom + mid + mid]) if ans_left < ans_right: pass elif ans_left > ans_right: bottom = bottom + mid elif mid * 2 < num: return bottom + num - 1 else: return None num = mid if num == 1: return bottom coins = [100, 100, 100, 100, 100, 100, 100, 100, 99] r = findFalseCoin(coins, 0, len(coins)) print(('假硬币:%d' % r) if r != None else '没有发现假硬币')

逻辑说明:每次把当前范围的硬币分成左右两半,mid = num // 2。左边轻说明假币在左边,bottom不动,num缩到mid;右边轻说明假币在右边,bottom右移mid,num缩到mid;两边相等时,如果总数是奇数,多出来的那一枚就是假币,返回bottom + num - 1;如果是偶数,说明没有假币,返回None。当num缩到 1 时,直接返回bottom。

5.2 递归改迭代时最容易翻车的三个点

第一个坑是num的更新时机。递归版里每层调用传入新的n,迭代版必须在循环末尾手动num = mid,否则num不变,死循环。第二个坑是bottom的偏移。当假币在右半部分时,bottom要加上mid,而不是加上mid + 1,因为右半部分的起点就是bottom + mid。第三个坑是奇数个硬币时多出来的那一枚。mid * 2 < num说明当前num是奇数,多出的那一枚在末尾,索引是bottom + num - 1。这三个点任何一个写错,结果都会偏。

5.3 用测试用例验证迭代版与递归版等价

要确认改对了,最直接的办法是拿多组硬币数据同时跑递归版和迭代版,比对返回值。常见做法是构造 3、5、7、9 枚硬币的列表,分别把假币放在不同位置,观察两边输出是否一致。参数上,真硬币重量用 100,假硬币用 99,这样左边轻就是假币在左。如果假币比真币重,所有比较方向都要反过来,题解默认假币更轻。这个假设不写清楚,换一组数据就会翻车。

6. 把题解变成自己的验证脚本:几个能直接抄的检查习惯

这份题解最大的价值不是答案本身,而是每道题背后的「思路」段落。但思路是文字,代码是代码,中间隔着一层「我以为我懂了」。我的习惯是每抄完一道题,就给它补一个最小验证脚本,把边界值、异常输入、返回值都跑一遍。比如进制转换补value = 0,不定参数补caculate(),折半查找补不存在的学号,找假硬币补没有假币的情况。下面这个表格是我整理的第 8 章各题最该补测的边界:

题号功能最该补测的边界预期行为
8-2作用域函数内赋值同名变量全局变量不变
8-3各位数字之和输入 0、负数0 返回 0,负数需额外处理
8-4分数序列求和n=1、n 较大累加正确,不截断
8-5进制转换value=0应返回 "0" 而非空串
8-6不定参数乘积不传参数返回 1
8-7回文素数前 100 个是否够最大不超过 100000
8-8折半查找目标不存在、区间剩 1 个返回未找到,不漏查
8-9找假硬币无假币、假币在末尾返回 None 或正确索引

另一个习惯是给递归函数加深度打印。递归改迭代之所以容易错,是因为递归的调用栈是隐式的,改完以后「当前处理的是哪一段」变得不直观。在迭代版里加一行print(bottom, num),每次循环打印当前范围,跑一遍就能看出范围收缩是否符合预期。这个办法比盯着代码想要快得多。

还有一个习惯是保留题解原代码,另存一份改过的版本,不要在原文件上直接改。题解里的代码有些是教学简化版,比如 8-5 没处理 0,8-8 的学号转换假设纯数字。直接改容易把原本能跑的示例改坏,分开存就能随时对比。从那以后我每次拆这类课后题解,都强制走一遍「原样跑通、补边界、加打印、存副本」四步,少一步后面都要还债。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进