1. 实验整体设计与思路拆解
1.1 为什么循环结构是Python入门的分水岭
我带了这么多届学生的Python实验课,几乎每次讲到循环结构这一章,班级里就会明显分出两个梯队:一部分人从这里开始真正“会写程序了”,另一部分人则在这里开始觉得“好像听不懂了”。原因很简单——顺序结构其实不需要你思考,从上到下读代码就行;分支结构也只是多了一条路的选择;但循环结构是第一个强迫你用“机器思维”去思考的结构:你得真正理解什么是重复、什么是终止条件、什么在变化。
这个实验七道题,从斐波那契数列到投资年限计算,表面上看是把循环的多种应用场景走了一遍,实际上它是在训练你三件事:循环变量的设计、循环边界的控制、循环体的累积逻辑。这三件事看起来简单,但每道题都在用不同的方式考你。比如斐波那契数列考的是“滚动更新”多个变量的能力;单词句子统计考的是“状态切换”的判断思路;密码判断考的是“标志位”的用法;自然常数e考的是“累加器”的写法;双倍余额递减法考的是“保留值迭代”的思想;销售统计考的是二维累积的嵌套逻辑;投资年限计算考的则是最经典的“循环变量与业务条件耦合”的场景。
如果你能把这个实验从头到尾独立完成,而且不是靠背代码,是真的自己想明白每一行为什么这么写,那你的循环结构就算是真正过关了。我下面把每一关的关键思路和我在实际批改作业时发现的高频问题都拆开讲一遍,代码全部用Python 3来写。
1.2 七道题之间的递进关系
先说个整体印象:这个实验的题目顺序是精心设计过的,不是随意排列的。
前三道题(斐波那契、单词句子统计、密码判断)属于“单层循环就能解决”的基础题,它们主要考察你对循环控制流本身的掌握,尤其是循环内部的条件分支怎么和循环变量配合。这三道题做对了,说明你已经掌握了循环的基本操作。
第四题(自然常数e)稍微升级了一点,它要求你在循环里做累加,而且是“未知次数”的累加——你需要根据精度条件来决定什么时候结束循环,这就是while循环的真正用武之地。for循环通常是“我知道要循环多少次”,while循环是“我不知道要循环多少次,但我知道什么时候该停”,这是一次重要的思维切换。
第五题和第七题(双倍余额递减法、投资年限计算)是“逼近类”问题——你需要循环迭代,直到某个值达到条件(折旧后的账面价值降到预计净残值以下,或者投资资产达到目标金额)。这类问题在真实业务中极其常见。
第六题(销售统计)则是把循环和列表、字符串处理结合起来,需要你处理的是“一组数据”而不是单个变量,思路又不一样。
理解了这个递进关系,你做题的时候就不会觉得每道题都是孤立的。我建议你做每一题之前先问自己三个问题:这个循环要重复做什么事情?重复多少次或者什么时候停下来?每次循环有哪些变量在变化?这三个问题想清楚了,代码基本上就能写出来了。
2. 第1关:斐波那契数列——循环变量的滚动更新
2.1 题目要求与核心思路
斐波那契数列是算法入门的经典问题了:数列的前两项通常是1和1(有的版本是0和1),从第三项开始,每一项都等于前两项之和,即 F(n) = F(n-1) + F(n-2)。题目一般会让你输入一个整数n,输出前n项或者第n项。
我第一次教这个题的时候,发现很多同学的第一反应是去查“斐波那契数列的公式”,试图用一个简单的数学表达式直接算出来。这其实是思维方向错了——这个题目放在循环结构实验里,考察的就是你用循环去“递推”这个数列的能力,不是让你套公式。而且斐波那契数列的通项公式里带有根号和幂运算,在计算机里用浮点数计算反而容易产生精度误差,远不如循环递推来得干净利落。
递推的核心思路是这样的:维护两个变量,分别表示“当前项的前一项”和“当前项”,每次循环,让这两个变量都向前滚动一次。
2.2 参考实现与逐行讲解
n = int(input("请输入需要打印的斐波那契数列项数:")) a, b = 1, 1 if n == 1: print(a) else: for i in range(n): if i == 0: print(a, end=" ") elif i == 1: print(b, end=" ") else: c = a + b print(c, end=" ") a, b = b, c这段代码的逻辑很直白:a和b初始化为1和1,代表数列的前两项。循环从第0项开始遍历,前两项分别原样输出,从第三项(i等于2)开始,c等于a加b,然后输出c,同时把a更新为原来的b,把b更新为新的c,这样就保证了下一轮循环时,a和b仍然分别是“前一项”和“当前项”。
Python里a, b = b, c这种写法是同时赋值,右边的值先全部计算完,再统一赋给左边的变量,所以不需要临时变量。这个写法看起来只是语法糖,但它背后体现的其实是“滚动更新”的思想——三个变量像传送带一样,每次循环都往前挪一个位置。
2.3 常见问题:换行输出和负数的坑
很多同学在输出斐波那契数列时喜欢一个数一行,比如用普通的print(c),但题目如果要求“一行输出”或者“空格分隔”,就需要在print函数里加上end=" "参数,把默认的换行符换成空格。忘记加这个参数是这道题最高频的丢分点。
另一个容易踩的坑是:输入n为负数或者0的时候怎么办?严格来说,斐波那契数列只对正整数有意义。但如果你不主动处理,for range(负数) 根本不会执行循环体,程序直接结束,用户看到的是“没有输出”的诡异现象。我在实验课上要求学生必须做输入合法性判断,至少有这样一个保护:
if n <= 0: print("请输入正整数")2.4 扩展:不只是输出数列,还能求第n项
如果题目改一下,只让你输出第n项(比如输入10,输出55),代码反而更简洁:
n = int(input()) a, b = 1, 1 for i in range(2, n): a, b = b, a + b print(b)注意这里range从2开始,因为前两项已经由初始值占住了。当n等于1时,循环体一次都不执行,直接输出b(也就是1),这其实是正确的结果。这个小细节特别能体现你对边界条件的把握——很多同学在一开始给a和b赋值1、1之后,总觉得还需要特别判断n等于1的情况,但其实range的边界已经天然处理好了。
这个“滚动更新”的思路在后续很多算法里都会用到,比如字符串轮转、数组移位、甚至一些状态机的问题。学斐波那契不只是学一个数列,更重要的是学会“用有限个变量去跟踪无限递推的过程”。
3. 第2关:统计英文语句中的单词数和句子数——状态切换与边界处理
3.1 题目难点在于定义“单词”和“句子”
这个题表面上看起来不难:给你一段英文,统计里面有多少个单词、多少句话。但真正动手写的时候,很多同学会卡住,因为“什么是单词”这个定义本身就值得商榷。
举几个例子:"Hello,world!"里面的逗号算不算分隔符?“don't”里面的撇号算不算单词的一部分?“ ”(连续多个空格)怎么处理?题目一般会给定简化规则,最常见的规定是:单词由连续的英文字母组成,单词之间以空格或其他非字母字符分隔;句子以句号(.)、问号(?)或感叹号(!)结尾。
如果你不做任何约定,直接数空格个数来统计单词数,遇到"Hello, world"这种句子时,逗号后面有空格,空格数等于1,但单词数是2,逻辑上没问题。可一旦遇到"Hello,world"——逗号后面没有空格——空格数是0,但你显然应该把它算作两个单词。所以数空格这种方法本质上是有缺陷的。
3.2 状态切换法:用布尔变量记录“是否处在单词中”
正确的做法不是数分隔符,而是扫描每个字符,判断字符类型,用状态记录当前是否正处于一个单词内部。我给出一个比较稳的实现:
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 if text and not text[-1].isalpha(): sentence_count = sentence_count else: sentence_count = sentence_count print("单词数:", word_count) print("句子数:", sentence_count)这段代码的核心逻辑在循环体里:
- 遇到字母字符(
ch.isalpha()为True),说明当前处于某个单词之中。如果上一个字符不是字母(in_word为False),说明这个字母是一个新单词的开头,单词数加1,然后把in_word置为True。 - 遇到非字母字符,说明单词结束了,把in_word置为False。
- 如果这个非字母字符是句号、问号或感叹号,句子数加1。
这种“状态切换”的思路非常经典。in_word这个布尔变量相当于一个状态标志,它记录的是“当前位置是否处在一个单词内部”。你不需要去数空格,也不需要处理连续空格的问题——因为连续空格只会让in_word从True变False再变False,但不会触发单词计数。
3.3 边界情况:末尾标点、空字符串和中文引号
在实际测试中,有几个边界情况一定要专门测试:
第一,字符串末尾的标点。如果文本以句号结束,那么最后一个字符是句号,ch.isalpha()为False,所以in_word在循环结束前去被置为False,单词数不会多算,句子数正常加1。这没问题。
第二,空字符串。直接输入空字符串,循环体一次都不执行,word_count和sentence_count都是0,输出0和0,逻辑上没有bug,但如果你在输出之前想加上“没有任何内容”的提示,需要手动判断。
第三,引号和撇号。英文单词中的撇号,比如"don't",在Python里用isalpha()判断时,撇号不是字母,所以会被当作单词分隔符,"don't"会被计为两个单词(don和t)。这在严格意义上是有争议的。如果题目要求忽略撇号,你就需要在判断非字母字符时,单独排除撇号的情况:
elif ch == "'": continue第四,字符串中的数字。isalpha()对数字返回False,所以数字会被当作分隔符,"abc123def"会被统计为两个单词。这在多数场景下是符合预期的。
第五,没有细节到“小数点和单词相连”的情况。比如"OpenAI's GPT-4."这样的字符串,会统计出OpenAI(s会被当成一个单词)、GPT、4(数字作为分隔符)等,结果可能和人的直觉有出入。这时候就要看题目是否对符号做了更细的规定。我在实际批改中会给学生强调:这个题没有必要追求“完全智能”,只要严格按题目给定的规则实现,测试点全过即可。
3.4 用另一种思路:正则表达式
如果你已经学过了正则表达式,这个题可以写得更短:
import re text = input("请输入一段英文语句:") words = re.findall(r"[A-Za-z]+", text) sentences = re.findall(r"[.!?]", text) print("单词数:", len(words)) print("句子数:", len(sentences))re.findall会把所有匹配的子串收集成一个列表,len()数个数即可。[A-Za-z]+表示“连续的英文字母”,[.!?]匹配任意一个句子结束标点符号。这种写法更接近“按规则切片”的思路,代码简洁很多,不过它依赖正则表达式的基础,如果老师还没讲到,用之前要先了解一下它的语法,不然看不懂自己写的代码就尴尬了。
4. 第3关:密码判断——标志位与多重条件
4.1 题目要求拆解
第3关通常是这样:用户输入一个密码字符串,程序判断它是否符合强度要求。最常见的规则是:长度不少于8个字符,必须同时包含大写字母、小写字母和数字(有些版本还会要求包含特殊字符)。符合条件输出“密码合格”,否则输出“密码不合格”。
这道题放在循环结构这一章里,其实有点“四两拨千斤”的意思。它虽然考察的是循环遍历字符串中每个字符,然后分类判断,但真正考查的核心是:如何同时维护多个独立条件的满足状态。很多同学会犯一个很典型的错误——试图用一个if-elif-else链来处理所有条件,结果写出来要么逻辑混乱,要么判断不出同时满足多类字符的情况。
4.2 推荐实现:四个布尔标志位
password = input("请输入密码:") has_upper = False has_lower = False has_digit = False length_ok = len(password) >= 8 for ch in password: if ch.isupper(): has_upper = True elif ch.islower(): has_lower = True elif ch.isdigit(): has_digit = True if has_upper and has_lower and has_digit and length_ok: print("密码合格") else: print("密码不合格")核心就是四个标志位:has_upper、has_lower、has_digit分别记录是否出现过至少一个大写字母、小写字母和数字;length_ok记录长度是否达标。遍历完整个字符串之后,四个标志位做逻辑“与”运算,全True才合格。
4.3 不要用elif的陷阱
我发现很多初学者会这样写:
for ch in password: if ch.isupper(): has_upper = True if ch.islower(): has_lower = True if ch.isdigit(): has_digit = True这里用三个独立的if其实没问题。但有同学会写成elif,也就是:
if ch.isupper(): has_upper = True elif ch.islower(): has_lower = True elif ch.isdigit(): has_digit = True这个写法在逻辑上是错的:因为一个字符不可能同时是大写字母、小写字母和数字,用elif虽然对单个字符来说结果一样,但如果遇到一个字符同时满足两个条件(比如某些特殊字符在Unicode里既被归类为数字又……这种情况不常见,但理论上存在),elif链会漏掉后面的判断。更重要的是,这种写法很容易让初学者形成错误的思维定势。用独立的if语句,语义是“分别检查三种情况”,用elif链,语义是“三种情况只能命中一种”,这不符合这里的业务逻辑——你在一个密码里需要同时存在三种字符,而不是某个字符同时是三种类型。
4.4 更Pythonic的写法:any() + 生成器
如果你已经对Python的生成器和内置函数比较熟悉,可以这样写:
password = input("请输入密码:") is_strong = ( len(password) >= 8 and any(ch.isupper() for ch in password) and any(ch.islower() for ch in password) and any(ch.isdigit() for ch in password) ) print("密码合格" if is_strong else "密码不合格")any()接收一个可迭代对象,只要其中有一个元素为真就返回True。这里any(ch.isupper() for ch in password)等价于“密码中至少存在一个大写字母”。这个写法的好处是代码更简洁,少写了三个布尔变量;坏处是初学者可能会觉得不好理解,尤其any()搭配生成器的组合需要一定的Python功力。如果实验报告要求“用循环结构实现”,建议还是老老实实写标志位版本,并且在注释里说明思路。
4.5 扩展:给登录流程加密码判断
这一关如果玩得深入一点,可以把循环知识和后续的登录流程串联起来。很多同学在做完这一关后,觉得自己已经会密码判断了,但遇到“用户输入密码,错误则重新输入,最多尝试3次”这种需求时,又不知道该怎么做了。这就是while循环的天然应用场景:
password = "abc123ABC" for attempt in range(3): user_input = input("请输入密码:") if user_input == correct_password: print("登录成功") break else: print("密码错误,还剩", 2 - attempt, "次机会") else: print("多次尝试失败,账户已锁定")把for循环和else搭配起来,如果for循环自然结束(也就是说从未break),则执行else分支。这在“尝试N次”的场景下特别方便,比用while加计数器更直观。
5. 第4关:通过数列求自然常数e——浮点数累积与精度控制
5.1 数学原理与题目预期
自然常数e的经典定义为:
e = 1/0! + 1/1! + 1/2! + 1/3! + ...
也就是说,e等于从0开始的阶乘倒数的无穷级数之和。0!按数学定义等于1,1!等于1,2!等于2,3!等于6,4!等于24……所以前几项加起来:1 + 1 + 0.5 + 0.1666... + 0.0416... ≈ 2.7083...,随着项数增加,越来越接近e的真实值 2.718281828...
这个题目在设计上其实没有特别大的难度,但它有一个很关键的数学点:每一项的分母是阶乘。如果你在循环里每一项都重新算一次阶乘,效率就很低。正确做法是在前一项的基础上乘一个数,得到下一项。
5.2 参考实现:迭代计算每一项的倒数
import math precision = 1e-10 e = 1.0 term = 1.0 n = 0 while True: n += 1 term /= n e += term if term < precision: break print("计算得到的e值:", e) print("math库中的e值:", math.e) print("迭代次数:", n)这个实现的精髓在term /= n:term初始为1,代表1/0!。第一次循环term变为1/1=1,加到e上得到2.0;第二次循环term变为1/2=0.5,加到e上得到2.5;第三次term变为0.5/3=0.1666...,加到e上……每一次都在上一项的基础上除以一个递增的整数n,就得到了1/n!。这样写不仅代码简洁,而且计算速度快,根本不需要每次重头算阶乘。
5.3 循环结束条件的选择
题目一般会问你“循环到什么时候停止”,最常见的做法有两种:
第一种:迭代固定次数。比如要求计算前100项的和。这时候直接用for循环:
e = 1.0 term = 1.0 for n in range(1, 101): term /= n e += term print(e)这种做法简单可靠,但如果项数取得不够多,累积误差会比较大。100项的时候,term已经是1/100!,这个值小到可以忽略不计,所以用100次迭代基本能保证双精度浮点下的极限精度。
第二种:根据精度动态停止。像上面代码里的if term < precision: break,每次项小于设定阈值(比如1e-10)就停止。这种做法更“科学”,因为float类型的精度大约在1e-16量级,把阈值设成1e-10时,得到的e已经和math.e在12位有效数字内完全一致了。
5.4 浮点数的精度警告
这里必须提醒一个容易踩的坑:不要用 while e != math.e 这种条件。浮点数在计算机里是近似存储的,两个浮点数永远不会“完全相等”。你应该用“差的绝对值小于某个阈值”来判断:
if abs(e - math.e) < 1e-8: print("精度已达要求")另外,如果你的程序一开始就把e初始化为1.0而不是0.0,记住是因为级数的第一项1/0!等于1,这不难,但很多同学容易在n的初始值上搞错。你可以把n从0开始,也可以从1开始,关键是保证term每次除以的n和它实际表示的分母阶数是对应的。我通常建议n从1开始,第一步计算1/1!,最不容易出错。
5.5 实验报告的优化:输出小数位数
如果题目要求保留若干位小数,用格式化输出:
print("e的值约为:{:.10f}".format(e)){:.10f}表示输出浮点数的10位小数。这里注意,{:.10f}做的是四舍五入,不是截断。如果你需要截断而不是四舍五入,可以用int(e * 10**10) / 10**10来实现,不过大多数情况下四舍五入就够了。
6. 第5关:双倍余额递减法计算固定资产折旧——迭代逼近与边界判断
6.1 业务背景:这到底是什么算法
双倍余额递减法(Double Declining Balance Method)是会计学里常用的一种固定资产加速折旧方法。它的核心思想是:在固定资产使用寿命内,每期折旧额等于该期期初账面净值乘以一个固定的折旧率,这个折旧率是“直线法”折旧率的两倍。
举个例子:一台设备原值100000元,预计使用5年,预计净残值5000元。直线法的年折旧率是1/5=20%,双倍余额递减法的年折旧率就是40%。第一年折旧额 = 100000 × 40% = 40000元,账面净值降到60000元;第二年折旧额 = 60000 × 40% = 24000元,账面净值降到36000元;以此类推。
双倍余额递减法有个特殊的处理规则:在固定资产使用的最后两年,要用“账面净值减去预计净残值后的余额”在剩余年限内平均摊销。这是因为加速折旧在前几年折得很快,如果不改成直线法,会导致最后账面净值低于甚至低于净残值。具体要求是:当某一年按双倍余额递减法计算的折旧额小于用“剩余账面净值-净残值后按剩余年限平均分摊”的金额时,从该年起改用直线法计提折旧。简化版本的实验题目通常是:每年计算折旧,但当账面净值减少到小于或等于预计净残值时,最后几年就改为平均摊销。
6.2 核心公式与循环退出条件
你不需要完全掌握会计学的所有细节,但必须理解下面这几个核心变量:
- 固定资产原值:cost
- 预计净残值:salvage
- 预计使用年限:life(以年为单位)
- 年折旧率:rate = 2 / life
每年折旧额 = 当前账面净值 × rate,但要注意不能超过某个上限,这个上限是:如果某一年按双倍余额递减法算出的折旧额,会比把剩余价值(当前账面净值 - 净残值)在剩余年限内平均分摊的数值还要小,那么从这一年开始,就改用平均分摊的方式来计提折旧。
在实际的实验题里,常见的简化规则是:后两年改为平均摊销,也就是说,前life-2年用双倍余额递减法,最后两年把剩下的账面净值减去残值后平均分摊。
6.3 参考实现:按完整年限循环
cost = float(input("请输入固定资产原值:")) salvage = float(input("请输入预计净残值:")) life = int(input("请输入预计使用年限:")) rate = 2 / life book_value = cost total_depreciation = 0 for year in range(1, life + 1): if year > life - 2: # 最后两年改为平均摊销 annual_depreciation = (book_value - salvage) / (life - year + 1) else: annual_depreciation = book_value * rate # 防止过度折旧导致账面净值低于净残值 if annual_depreciation > (book_value - salvage) / (life - year + 1): annual_depreciation = (book_value - salvage) / (life - year + 1) book_value -= annual_depreciation total_depreciation += annual_depreciation print(f"第{year}年折旧额:{annual_depreciation:.2f},账面净值:{book_value:.2f}") print(f"累计折旧额:{total_depreciation:.2f}") print(f"最终账面净值:{book_value:.2f}") print(f"应保留的净残值:{salvage:.2f}")这段代码做了几件事:
第一行到第三行从用户输入读取原值、残值和年限,然后计算年折旧率rate = 2 / life。
循环里做了年份判断:前life-2年按双倍余额递减法计算折旧额,但每次都要检查一个条件——如果今年按双倍余额递减法算出来的折旧额,比“把剩余账面净值减残值后按剩余年限平均分摊”的金额还要大,那就说明再按加速折旧会导致账面净值掉到净残值以下,这时候就直接改用给定的剩余年限平均摊销。最后两年无论什么情况都改为平均摊销。
6.4 为什么必须加“防止过度折旧”的判断
这个细节是这个实验的精华所在。如果不加这个判断,只按固定公式每年乘以rate,你自己跑一遍就能发现:第五年折旧额 = 36000 × 40% = 14400元,折旧后账面净值 = 21600元,这已经大于净残值5000元,看似没问题,但继续用双倍余额递减法的话,第六年折旧额 = 21600 × 40% = 8640元,折旧后账面净值 = 12960元;第七年折旧额 = 12960 × 40% = 5184元,净值 = 7776元,还要继续折旧……最终会把账面净值折成趋近于0,或者低于净残值,这就违反了固定资产折旧的基本原则:账面净值不能低于预计净残值。
所以这里必须有条件判断:当某年按加速法计算的折旧额大于剩余价值平均分摊的金额时,改用平均摊销。业务逻辑就是:折旧可以快,但不能快到最后亏本。
6.5 常见错误:混淆“账面原值”和“账面净值”
我批改这类实验作业时,经常看到学生把折旧基准搞错。折旧是按“账面净值”来算的,不是按“账面原值”来算的。也就是说,第一年按原值100000算折旧,第二年就只按60000算折旧了,第三年按36000算。很多同学写出来的代码每年都用固定的cost去乘rate,算出来的折旧额一模一样,这显然是错的——双倍余额递减法之所以叫“余额递减”,就是因为它的计算基数是逐年递减的余额。
另外,输出格式要求保留两位小数时,记得用{:.2f}格式化,不格式化的话,浮点数可能出现很长的一串小数尾巴,影响阅读。
7. 第6关:销售统计——二维数据累积
7.1 题目实际考察点
第6关通常会给出一组销售记录,比如“sales = [{'商品': 'A', '数量': 3, '单价': 10}, ...]”或者更简单地用列表嵌套,让你统计总销售额、各商品销量排行等。这道题考察的是“把循环用于聚合计算”,本质上是一个二维数据的累积问题。
如果你已经学过了字典,这道题会轻松很多;如果还没有学到,用列表也能做,只是麻烦一点。
7.2 一个简单版本:统计总销售额
假设销售数据是以列表形式给出的每笔交易,每笔交易是一个元组(商品名, 数量, 单价):
sales = [ ("苹果", 3, 5.0), ("香蕉", 2, 3.5), ("苹果", 1, 5.0), ("橙子", 5, 4.0), ("香蕉", 4, 3.5), ] total_sales = 0 item_sales = {} for item, quantity, price in sales: amount = quantity * price total_sales += amount if item in item_sales: item_sales[item] += amount else: item_sales[item] = amount print("总销售额:", total_sales) for item, amount in item_sales.items(): print(f"商品{item}的销售额:{amount:.2f}")这里的关键是充分使用Python的序列解包特性:for item, quantity, price in sales直接遍历列表,同时解包出每一个元素里的三个字段。这个写法的好处是你不需要用索引sales[i][0]去取字段,代码可读性高很多,而且不易写错。
后面统计每个商品的销售额时,用一个字典做“按key累积”的操作。这个模式在真实的数据处理非常常用:遇到一个数据项,先查字典里有没有这个key,有就更新,没有就新增。这个“先查再更新”的模式用官方语法有一个更简洁的替代方案:
from collections import defaultdict item_sales = defaultdict(float) for item, quantity, price in sales: item_sales[item] += quantity * pricedefaultdict会在访问不存在的key时自动创建一个默认值,通常是0.0,所以item_sales[item] += amount第一次执行时,会自动创建key为item、值为0.0的条目,然后加上amount。这比手动判断key是否存在要干净得多。
7.3 计算销售数量合计、折扣后金额等
有些题目不只是让你算销售额,还会加一些额外条件,比如“数量大于等于10时打九折”。这时候直接在循环体里加一个条件判断即可:
for item, quantity, price in sales: amount = quantity * price if quantity >= 10: amount *= 0.9 total_sales += amount如果题目还要求统计“每种商品的销量合计”“平均单价”等,可以把每个商品的销售数量和销售金额分别用两个字典累积。注意这里有个容易混的点:平均单价不等于简单平均,而是总销售额除以总销量,也就是加权平均。这个业务概念如果理解错了,统计结果会差很多。
7.4 让结果更清晰的格式化输出
如果销售数据很大,你可能想对结果排序展示。可以用sorted()函数按销售额降序排列:
sorted_items = sorted(item_sales.items(), key=lambda x: x[1], reverse=True)item_sales.items()返回的是(商品名, 销售额)的键值对列表,key=lambda x: x[1]表示按第二个元素(销售额)排序,reverse=True表示降序。
Lambda表达式是Python里比较有特色的语法,很多刚开始学的同学会觉得陌生。你可以粗暴地把它理解成一个“临时的小函数”,在这里就是这个临时函数接收一个键值对,返回它的第二个元素作为排序依据。不想用lambda也可以直接写operator.itemgetter(1)达到相同效果,但那需要先import operator模块,反而多了个概念。
8. 第7关:投资年限计算——while循环与条件退出
8.1 题目业务逻辑
第7关是典型的财务计算问题:你有一笔本金,存入银行或投资某个年化收益率的项目,每年复利结算一次,问需要多少年才能达到目标金额。题目给出本金P、年化收益率r(百分比或小数形式)、目标金额T,要求计算需要的年数。
数学公式是:期末金额 = 本金 × (1 + 年化收益率)^年数。你需要找到最小的整数年数n,使得 P × (1+r)^n ≥ T。
这个问题用数学公式直接解当然也可以(两边取对数),但实验题目显然想让你用循环来模拟“逐年滚雪球”的过程。用循环模拟的好处是更直观,而且不需要对数运算。
8.2 参考实现
principal = float(input("请输入本金(元):")) annual_rate = float(input("请输入年化收益率(%,例如5表示5%):")) / 100 target = float(input("请输入目标金额(元):")) current = principal years = 0 while current < target: current *= (1 + annual_rate) years += 1 print("需要", years, "年才能达到目标金额") print("届时金额为:", current)这段代码的核心是while循环:只要当前金额还没达到目标,就继续滚一年。每次循环,当前金额乘以(1+年利率),年数加1。退出循环后,years就是达到目标所需的最少年数。
8.3 为什么用while而不是for
这个题用for循环写会别扭,因为你事先不知道要循环多少次。你可能用几年达到目标?这取决于收益率和目标倍率的关系。收益率5%的情况下,要翻倍需要大约14到15年(因为1.05的14次方约等于1.98,15次方约2.08)。但如果是3%的收益率,翻倍需要差不多24年。不同的输入参数的迭代次数差别很大,这正是while循环的用武之地——条件式循环,退出时机由计算过程动态决定。
8.4 防止死循环:while的经典风险
写while循环最怕的就是死循环。这个题目里如果用户输入的收益率是0或者负数,永远也不会达到目标金额,循环会一直跑下去。所以必须有输入校验:
if annual_rate <= 0: print("年化收益率必须大于0") exit()另一个隐蔽的问题是:如果你把current更新语句放错了位置或漏写了,也会导致死循环。比如忘了加years += 1这一行,年份不增长,但金额在增长——这个循环最后可能还是会退出(因为你没靠years做条件),但输出结果中years永远停留在一个值上,逻辑就错了。或者你把current *= (1 + annual_rate)这行写成current = principal * (1 + annual_rate),那么current每次循环都被重置为本金乘以年利率,永远不会增长,dead loop直接卡死。
所以写while循环之前,先问自己三个问题:循环条件是什么?循环体里哪些变量的变化会影响这个条件?退出时结果是正确的吗?想清楚了再写,基本不会翻车。
8.5 进一步扩展:定投场景和不连续收益
这道题的进阶版可能是“定投”:每年年初追加一笔固定金额,问多少年能到目标。这其实只是把本金变动从单一变量变成了“每年都增加”:
current = principal years = 0 annual_contribution = float(input("请输入每年追加投资金额:")) while current < target: current *= (1 + annual_rate) current += annual_contribution years += 1注意这题的边界条件会变成:如果年化收益率为0,但是每年有定投,也是会达到目标的;如果收益率和定投都为0,就死循环。所以校验逻辑需要更全面。
如果在真实场景中收益率不是固定值,而是随年份变化的数组,你还可以用for循环配合索引来模拟。这个扩展思路,大家可以自己去尝试。
9. 常见问题与排查技巧实录
9.1 问题速查表
我在批改和辅导中总结了这份速查表,覆盖这个实验几乎所有典型报错:
| 典型症状 | 可能原因 | 排查思路与解决方式 |
|---|---|---|
| 程序没有输出或输出为空 | for循环的range范围写错了,循环体未执行 | 检查range起点和终点;检查是否有退出函数提前结束了程序 |
| 输出的斐波那契数列缺少数值 | 循环边界少算了一次 | 用n=3手动推演一遍循环过程 |
| 单词数统计比预期少1 | 最后一个单词后没有标点,也没有空格,被忽略了 | 循环结束后检查in_word状态,若为True则补一次计数 |
| 密码判断永远输出“不合格” | 条件全部都用了and,但某个标志位始终为False | 在循环里加print调试标志位的值 |
| e的计算结果与math.e相差大 | 循环次数太少或精度阈值设置太大 | 把阈值改小为1e-10或增加迭代次数 |
| 折旧计算最后出现负的账面净值 | 前几年折旧过猛,没有做“平均摊销”切换 | 加入剩余价值除以剩余年限的判断逻辑 |
| 投资年数计算永远运行不止 | 年利率输入为0或负数,或本金更新语句写错 | 增加输入校验;确认current更新语句在循环体内 |
| 销售统计的字典KeyError | 没有判断商品名称是否已存在 | 使用defaultdict(float)或先if判断再更新 |
9.2 调试利器:print大法和手动推演
很多同学写循环出bug的时候,第一反应是“我代码肯定没问题”,然后在那边干瞪眼。其实最高效的调试方式一直被低估了——print大法。在循环体里临时加一行print("当前i:", i, "当前值:", current),一运行你就知道循环到底跑了多少次、每次变量怎么变的,问题基本一眼就能看出来。
我经常会在实验课上跟学生说:当你觉得循环逻辑怎么也想不通的时候,就用n等于3或者5这样的小参数,在自己的草稿纸上把每一次循环的变量变化写出来。这是最笨但也是最有效的方法。程序是机器的思维,但你推演一遍就是人的思维,两边一对齐,bug就出来了。
9.3 代码风格与命名建议
最后说一点代码规范。很多同学写循环爱用i、j、k这种无意义命名的循环变量,在纯计数场景下没问题,但在这个实验里,很多循环变量是有业务含义的,建议用有意义的命名,比如year、n、attempt、index。原因很简单:过两周你再打开自己的实验报告,看到for i in range(life),你会想不起来i代表什么;看到for year in range(1, life+1),一目了然。
Python官方的PEP 8风格指南要求变量名用下划线分隔小写单词,比如book_value而不是bookValue。虽然实验课一般不会因为命名扣分,但养成好的习惯对后续写大规模代码非常重要。
另外,每一题的代码开头建议加两三行注释,写明这个程序的功能和关键变量含义。注释不是写给老师看的,是写给一周后的自己看的。写注释的过程也是在强迫你自己梳理逻辑,很多bug在写注释的过程中就被发现了,这招我屡试不爽。
9.4 循环嵌套和break/continue的误区
这个实验的题目基本都用不到嵌套循环,但第六题如果在更复杂的数据结构上操作,可能就会用到嵌套。一旦循环嵌套,要注意break和continue只能作用于它所在的那一层循环,break只能跳出当前的for或while,不能跳出外层循环。这个知识点主要是防止你在后续项目里踩坑,这里是打个预防针。
continue的作用是跳过本次循环的剩余部分,直接进入下一次循环。在密码判断和单词统计这两道题里,如果你用continue来处理特殊字符,务必确认continue放在循环体的末尾位置。如果continue放在循环体中间,后面的代码会被直接跳过,很容易漏掉一些标志位的更新。
10. 一些值得记录的实操心得
10.1 从第1关到第7关,循环的“三个层次”
做完整个实验之后,如果你回头再看,会发现这七道题其实在训练三个递进的层次:第一层是计数循环(斐波那契、密码判断),你知道循环次数,用for;第二层是条件循环(e的计算、投资年限),你不知道循环次数,但知道退出条件,用while;第三层是结构映射(销售统计、折旧计算),循环不再只是重复某段动作,而是在对一组数据进行转换和累积,这时候你的思路要从“我循环了多少次”转换到“我每次循环处理了什么数据”。
能把这个层次想明白,你就不会出现在应该用while的地方用for、应该用for的地方用while的困惑了。
10.2 我对这个实验的定位:通往“会写代码”的分水岭
作为带过很多届学生的老博主,我基于个人实验经验,确实觉得这个循环结构实验是所有基础实验中最重要的一次,甚至比后续的函数、列表、字典等章节更关键。原因很简单:循环是第一个真正要求你“像计算机一样思考”的点,是你从“抄代码”到“写代码”的分水岭。
如果你在完成这个实验时感觉还有点吃力,不用慌,这是正常的。我自己当年学循环结构时,也曾因为一个while循环死循环到不得不按下Ctrl+C强制中断程序。关键在于你要多写、多调试、多推演,不要满足于看懂了别人的代码——看懂和能自己写出来是两回事。你可以试着今天把每一道题都独立重写一遍,不看任何参考资料,写不出来就回到草稿纸上推演,推演明白了再写,这个过程本身就是最有效的学习。
最后送大家一个小建议:做完这七道题之后,去网上找几道“循环结构练习题”或者“经典算法100例”,比如水仙花数、素数判断、最大公约数、猴子吃桃这些问题,用循环实现一遍。等你能够不假思索地写出这些经典题目的循环解法的时候,循环结构这一章你就算真正吃透了。