
带过几次学生的循环结构实验课之后我越来越觉得这七道题不是随便拼凑的。斐波那契数列、单词句子统计、密码校验、自然常数e、固定资产折旧、销售统计、投资年限看起来七零八落实际上是把循环结构的几种典型用法挨个过了一遍递推、遍历、状态判断、累加逼近、迭代求解、聚合统计。这些场景在后面写爬虫、做数据处理、写自动化脚本时全都能遇到。这篇就把每道关卡的解题思路、完整代码和容易踩的坑从头梳理一遍当作一份能直接抄作业的实验总结。1. 第1关“斐波那契数列”循环背后的递推思想为什么值得反复练1.1 递推式代码的三种正确写法斐波那契数列的定义很简单第一项和第二项都是1从第三项开始每一项等于前两项之和。这个递推关系天然适合用循环来表达因为循环本身就是“重复执行同一套操作每次把状态往前推一步”。最常见的写法是用两个变量交替更新n int(input()) if n 2: print(1) else: a, b 1, 1 for i in range(3, n 1): a, b b, a b print(b)这里的关键是a, b b, a b这行。Python会先计算右边的表达式再把结果同时赋值给左边的变量所以不会出现“先改了a导致后面计算ab出错”的问题。很多新手写成a b b a b这种写法是错的因为第一行执行完后a已经等于b了第二行的a b实际变成了b b整个递推就断掉了。如果想把计算过程和结果集都保留下来可以用列表n int(input()) fib [1, 1] for i in range(2, n): fib.append(fib[i-1] fib[i-2]) print(fib[n-1])这是更贴近“表格递推”的思路每一轮把新计算出的值追加到末尾下一轮直接从列表里取前两个数。好处是调试时能打印整个数列坏处是当n很大时会浪费额外内存。竞速或者内存受限的场景用第一种双变量写法更合适。1.2 边界情况与运行效率这道题的隐藏考点其实是边界值。很多同学输入n1或者n2时代码直接报错或者输出0就是因为没有处理前三项的特殊情况。if n 2: print(1)这行判断建议一开始就写上不要等到测试用例报错才补。另一个容易踩的坑是range的范围。当n3时range(3, 4)只循环一次用a1、b1算出了第3项2当n4时range(3, 5)循环两次算出第4项3。这个逻辑是对的但前提是循环从3开始。如果从1开始就会多算两项最终结果不对。建议在本地把n1到5的输出都测一遍确认递推是从第三项开始推进的。从效率角度说斐波那契数列用简单的for循环时间复杂度是O(n)这已经足够应付实验和日常使用。递归写法虽然代码更短但如果不加缓存计算第40项就会卡顿很久因为重复计算量是指数级的。循环结构在这里的真正价值就是用O(n)的代价把递归的指数级重复计算全部省掉。理解了这一点才算真正理解了这道题放在循环实验第一关的原因。2. 第2关“语句统计”从字符流里数出单词和句子本质是状态机2.1 用“是否在单词内”这个标志位代替复杂判断统计一段英文语句中的单词数和句子数常见的错误思路是先按空格切分再用split()统计单词。这种写法遇到连续多个空格时统计会乱遇到“hello,world”这种逗号紧贴单词的情况还是会乱。题目考察的是逐字符扫描而不是简单的字符串切分。核心思路是维护一个布尔变量in_word表示当前扫描位置是否处在一个单词内部。初始为False遇到字母时如果之前不在单词内说明发现了一个新单词单词数加1然后标记为True遇到非字母时标记为False。句子数则单独判断遇到.、!、?时加1。text input() word_count 0 sentence_count 0 in_word False for ch in text: if ch.isalpha(): if not in_word: word_count 1 in_word True else: in_word False if ch in .!?: sentence_count 1 print(单词数:, word_count) print(句子数:, sentence_count)isalpha()方法在字符是字母时返回True这里把数字当作分隔符处理也就意味着“abc123def”会被统计成两个单词。如果题目把数字也当作单词内容需要把判断条件改成ch.isalnum()。实验里一般以字母单词为准但建议提交前先确认题目描述。2.2 边界情况连续空格、缩写词、省略号连续空格是这个统计方法天然免疫的。比如“hello world”扫描到第一个空格时in_word变成False后面再扫到多个空格时因为不在单词内不会重复计数。只有再次遇到字母时才开始一次新的单词计数。真正容易出问题的是标点符号紧贴单词的情况比如“hello,world”或“good morning!”。“good”后的空格会把in_word置为False接着扫描“morning”时单词数加1扫描到!时句子数加1这些都是正确行为。缩写词会干扰句子统计。比如“e.g.”这个缩写里包含两个点按上面的代码会统计成两个句子。实验题一般不会出这么刁钻的用例但如果你在是做真实文本处理就需要注意省略号“...”不应该算三个句子小数点和缩写词里的句号也不该触发句子计数。想处理好这个问题需要往前多看几个字符做上下文判断已经超出了这个实验的范畴知道有这回事就行。做练习时会发现in_word这种状态标记的思路在后续开发里非常常见——读配置文件时判断当前是否在某个区块内、解析日志时判断是否在多行输出中都是同一个套路。这个标志位的本质是让循环在“逐字符处理”的视角下还能记住“当前处在什么上下文”相当于给循环加了一个短期记忆。3. 第3关“密码判断”标志位循环遍历把多条件判断拆干净3.1 逐个条件独立标记再汇总密码判断的常见需求是长度不小于8位至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。很多人一上来就想用一个复杂的正则表达式解决但在循环结构这个章节实验想考察的是用循环遍历字符串逐个字符判断并记录标志位。pwd input() if len(pwd) 8: print(密码长度不足8位) else: has_upper False has_lower False has_digit False has_special False special_chars !#$%^*()_-[]{}|;:,.?/~ for ch in pwd: if ch.isupper(): has_upper True elif ch.islower(): has_lower True elif ch.isdigit(): has_digit True elif ch in special_chars: has_special True if has_upper and has_lower and has_digit and has_special: print(密码符合要求) else: print(密码不符合要求)四个标志位初始都是False循环里只要遇到对应类型的字符就置为True。循环结束后统一判断而不是在循环里一边判断一边打印结果。这样设计的好处是逻辑清晰循环负责扫描和标记循环外的if负责判断汇总各管一段。而且无论密码里包含多少个字符循环始终只遍历一遍不会出现提前退出导致漏判。3.2 用any()和生成器表达式简化判断如果觉得四个标志位写起来啰嗦Python里可以用内置函数any()配合生成器表达式把整段遍历压缩成几行pwd input() has_upper any(ch.isupper() for ch in pwd) has_lower any(ch.islower() for ch in pwd) has_digit any(ch.isdigit() for ch in pwd) has_special any(ch in !#$%^*()_-[]{}|;:,.?/~ for ch in pwd) if len(pwd) 8 and has_upper and has_lower and has_digit and has_special: print(密码符合要求) else: print(密码不符合要求)any()遇到第一个为True的结果就会返回后面的字符不再继续扫描所以这段代码实际上的遍历次数不一定是完整的n次。虽然函数式写法看起来简洁但它的执行逻辑对初学者来说更抽象建议先在笔记本上把标志位写法完整推导一遍再考虑用any()简化。实验报告里两种写哪个都行关键是能讲清楚any()是“短路求值”不是把每个字符都判断完再汇总。另外提一个真实场景中的补充真正校验密码强度时一般不会只给“通过/不通过”的二元结论而是把“缺少哪种字符”也反馈给用户。这时候标志位版本就比any()版本更好扩展因为可以在最终判断时直接指出not has_upper对应“缺少大写字母”。这算是一个进阶思路可以在实验总结里写进去。4. 第4关“自然常数e”级数累加对精度控制的典型示范4.1 e的级数公式与循环累加器自然常数e可以用无穷级数表示e 1 1/1! 1/2! 1/3! …。这个级数收敛非常快求和到第10项左右就能和真值逼近到小数点后好几位。这道题的循环结构就是把“阶乘”、“累加”、“精度控制”三件事融合在一起。最朴素的思路是每轮循环单独计算i的阶乘再累加。但阶乘本身也是递推关系i! (i-1)! × i完全可以在循环里同步更新省掉内层再写一个阶乘循环e 1.0 factorial 1 i 1 while True: factorial * i term 1 / factorial if term 1e-6: break e term i 1 print(fe ≈ {e:.6f})初看这个循环可能有点绕factorial初始为1也就是0!的值进入循环后先乘i此时factorial变成i!计算当前项1 / factorial判断这一项的绝对值是否小于阈值如果足够小说明后续项对总和的贡献已经可以忽略break跳出循环。这样保证最后累加进去的项都大于等于1e-6低于这个精度的项直接忽略。4.2 求和精度的三种控制方式这类“无限级数求近似值”的问题有三种常见的终止条件。第一种是本题风格规定某一项的值小于某个阈值比如1e-6这时认为后续项全部可以忽略第二种是规定累加到的项数比如算前20项适合当级数收敛速度已知时使用第三种是规定相邻两次累加结果的差值小于某个精度时停止这种更常用于迭代算法中判断是否收敛。实际运行中这个循环大约执行到第9轮就会退出因为9! 3628801/9! ≈ 2.76e-6还大于1e-6到第10轮时10! 36288001/10! ≈ 2.76e-7已经小于1e-6。所以循环总共会累加到第9项就break。运行结束后可以和Python内置的math.e对比一下import math print(f循环计算: {e:.10f}) print(fmath.e: {math.e:.10f})两者在小数点后第6位左右是一致的。注意这里e的浮点精度受限于Python浮点数本身的表达方式如果要求更高精度需要用decimal.Decimal来存储但这道实验题在1e-6这个精度下用默认浮点就足够了。这个“累加一项判断一项再决定是否继续”的结构是后面学习泰勒展开、牛顿迭代、蒙特卡洛模拟等现象的雏形。很多看似高深的数值算法骨架就是一个类似的循环只是在循环体里做了更复杂的计算。5. 第5关“双倍余额递减法”财务折旧公式如何一步步翻译成可迭代的循环5.1 折旧公式与循环变量的设计双倍余额递减法DDB是固定资产折旧里比较特殊的一种算法。它不把资产原值平均分摊到每一年而是先按一个固定的折旧率对“当前账面净值”进行加速折旧最后两年再转为直线法把剩余净值平均摊销完。年折旧率 2 ÷ 预计使用年限这个“双倍”就体现在折旧率是直线法的两倍。写成循环结构每轮循环对应每一年循环内维护一个“当前账面净值”的变量cost float(input()) # 资产原值 salvage float(input()) # 残值 life int(input()) # 预计使用年限 rate 2 / life book_value cost for year in range(1, life 1): if year life - 2: depreciation book_value * rate book_value - depreciation print(f第{year}年折旧额: {depreciation:.2f}, 账面净值: {book_value:.2f}) else: depreciation (book_value - salvage) / 2 book_value - depreciation print(f第{year}年折旧额: {depreciation:.2f}, 账面净值: {book_value:.2f})关键点在于year life - 2这个判断前life-2年按双倍余额递减法计提最后两年按直线法平均摊销。以原值10万、残值1万、使用年限5年为例折旧率是40%前三年的折旧额分别是4万、2.4万、1.44万第四年和第五年则把剩余账面净值减去残值后的差额对半分。如果不做这个最后的转换到第五年结束时账面净值会永远降不到残值这就是双倍余额递减法需要在最后两年切换成直线法的原因。5.2 为什么最后两年必须转直线法“为什么最后两年要转直线法而不是继续乘折旧率”这是很多人的疑问。原因其实很简单折旧率按固定比例套在越来越小的账面净值上每期折旧额会一直变小理论上永远不会归零。但实务上固定资产折旧必须在一个固定年限内提完所以会计规则规定当双倍余额递减法下某年折旧额小于直线法折旧额或者当已到折旧年限末期时转为直线法。这道实验题直接规定最后两年转直线法属于最简化的版本。实际题目中还有一种变体固定折旧年限只有1年。此时life - 2是负数year life - 2永远不成立整个循环都会走直线法分支。如果原值10万、残值1万、年限1年第一年就把9万全部提完逻辑上是对的。但要注意循环中最后一次折旧额计算时如果book_value - salvage出现负数通常表示残值设置不合理比如残值大于原值。这种测试用例虽然实验题里一般不会出现但你自己做练习时可以加一个判断提醒输入错误。从代码层面看这道题真正想考的其实是“循环变量year同时作为业务时间轴”这种用法。循环的次数和业务年份一一对应循环体里用if把业务规则翻译成代码。后面做账务系统、贷款计算器这类项目时这种按时间轴滚动计算并输出每年结果的模式会反复出现。第7关“投资年限计算”其实也是这个模式只是终止条件从“达到年限”换成了“达到目标金额”。6. 第6、7关“销售统计与投资年限”for聚合与while逼近的实战对照6.1 销售统计for循环天然适配数据聚合场景销售统计这类题目一般会给定一组销售数据可能是每笔金额的列表也可能是“员工-销售额”的键值对要求计算总销售额、平均销售额、最高最低销售额或者找出超过业绩目标的人。这类需求的核心是“遍历数据一遍同时更新多个汇总变量”。假设实验输入是多行“姓名 销售额”统计所有记录的总额与均值n int(input()) total 0 max_sale -1 max_name sales [] for i in range(n): name, amount input().split() amount float(amount) total amount sales.append((name, amount)) if amount max_sale: max_sale amount max_name name average total / n print(f总销售额: {total:.2f}) print(f平均销售额: {average:.2f}) print(f最高销售额: {max_sale:.2f}, 销售员: {max_name}) # 低于平均值的销售员 below [name for name, amount in sales if amount average] print(低于平均销售额的销售员:, .join(below) if below else 无)这里值得注意的点是total、max_sale、max_name都是在循环过程中一次性更新的。max_sale初始化为-1比自己定义一个“第一个销售额”要省事因为销售金额非负-1一定比第一次循环里的任何值都小。如果想处理负销售额的极端情况则应该初始化为负无穷或取第一个元素这一点可以在代码注释里说明。超过目标人数、每季度汇总、按月份分组这类扩展需求本质都是在循环体里加几个分支判断。掌握“一次遍历同时维护多个聚合变量”的思路之后什么都会很顺手。如果你学过pandas会发现DataFrame.describe()做的事情本质上就是这些聚合逻辑的高度封装但底层思路完全一致。6.2 投资年限计算while循环解决“不知道循环多少次”的问题如果题目改成“本金10万年收益率7%计算多少年后资产翻倍到20万”用for循环就不太合适了因为无法提前确定需要循环多少次。这种“不断循环直到某个条件满足”的场景正好是while循环的主场。principal float(input()) # 本金 target float(input()) # 目标金额 rate float(input()) # 年利率如0.07 year 0 current principal while current target: current * (1 rate) year 1 print(f需要{year}年)每次循环当前资产按(1 rate)的倍率增长年份计数器加1。当当前资产不再小于目标金额时循环退出此时year就是答案。边界条件是“恰好一年后刚好达标”的情况进入循环前current target为真循环内更新一次后正好等于目标退出循环时输出1完全正确。如果用复利公式和对数化简理论上可以直接算出年限是log(target/principal) / log(1rate)。但这种数学解法在实验里往往不可用因为题目明确要求用循环结构实现。更重要的是循环结构天然能处理更复杂的规则——比如每年年末额外追加投入、利率会变化、有手续费等条件追加规则后代码还是一样能跑这是单纯的数学公式很难做到的。6.3 两道题放在一起的启发for与while的选择标准把第6关和第7关放在一起对比你能很清楚地看到一个判断标准如果能确定要重复多少次用for循环比如销售数据有n条记录就循环n次如果只知道跳出条件、不知道具体次数用while循环比如投资滚到目标金额之前说不准要几年。这是很多人在刚学循环时纠结的问题。实际上Python的for循环是“遍历一个可迭代对象”while循环是“条件为真就重复执行”。换成人话你知道有n条数据要处理用for你不知道n是多少但知道“没做成就继续做”用while。后面学爬虫时会发现翻页爬取直到没有下一页用的就是while数据清洗时遍历DataFrame的每一行用的是for。这两道表面完全不同的题放在实验最后很大程度就是为了逼你区分这两个场景。就我个人经验来说把第6关和第7关写成实验总结时适合把“聚合统计思路”和“迭代逼近思路”分开来描述。一个是横向遍历所有数据一个是纵向沿着时间轴推进一个关心的是“总共、平均、最大最小”一个关心的是“第几年会达到什么状态”。两者底层都是循环但思维方式差异很大。实验做完之后可以试着把第6关的销售数据和第7关的投资计算合并——比如“每年销售额按7%增长第几年能达到目标”——基本上就能把这两个思维融到一起了。