不知道你有没有过这种经历:刚把Python基础语法过了一遍,函数、列表、循环都认识,可真拿到题目,脑子却一片空白。如果有人让我推荐一份Python入门必练的题目清单,我大概率会把这四个题放进去:汉诺塔问题、组成任意金额、约瑟夫环、密码检查。它们看起来就是普普通通的课后习题,但练过和没练过,写代码的思路完全是两个层次。
这四个题分别打中了四个不同的要害:汉诺塔练递归的“信任感”、金额组合练“暴力到动态规划”的思维演进、约瑟夫环练“模拟与数学建模”的取舍、密码检查练“需求拆解与边界处理”。说实话,我在带新人时经常发现,很多人递归卡在汉诺塔,动态规划卡在硬币找零,列表删除卡在约瑟夫环,字符串处理卡在密码校验。这些问题不是孤立的练习,而是编程里最通用的一批思维模型。这篇文章我不打算把四个题简单罗列一遍代码,而是把我实际写、实际讲、实际调试这些题时看到的坑和心得一起写出来,希望能帮你把每个题吃透。
1. 汉诺塔:递归里藏着"把大问题变小"的万能钥匙
1.1 先别急着写代码,把规则翻译成递归
汉诺塔的规则大家应该都听过:有三根柱子,姑且叫A、B、C,A柱上有N个盘子,从下往上由大到小叠着。一次只能动一个盘子,而且任何时候大盘子不能压在小盘子上,目标是把所有盘子从A挪到C。
很多新手一听规则就懵:“我怎么知道第一步动哪个盘子?”这就是被“过程”绑架了。递归恰恰要求你反过来思考:我先把最底下那个大盘子上面的N-1个盘子整体移走,让大盘子露出来,再把大盘子移到目标柱,最后把N-1个盘子整体移到大盘子上。把这一步想明白,剩下的事情就是“递归自己去做”。
写成伪代码就是:
- 第一步:把A上面的N-1个盘子,借助C,整体移到B。
- 第二步:把A上的第N个盘子直接移到C。
- 第三步:把B上的N-1个盘子,借助A,整体移到C。
你看,整个过程被我描述成了“搬走一摞”“挪一个”“再搬回来一摞”。至于“如何搬走一摞”,那是尺寸更小的汉诺塔问题,递归函数会自己调用自己去解决。你可以把它理解为:递归不在乎“一摞盘子”内部是怎么搬的,它只负责告诉下一层“目标是什么、辅助柱是哪根”。这种“假设我已经会解决规模小一号的问题”的思维方式,是理解汉诺塔乃至所有递归问题的关键。
1.2 汉诺塔的Python实现与参数陷阱
先给出一段可以直接跑起来看的代码:
def hanoi(n, source, target, auxiliary): if n == 1: print(f"{source} -> {target}") return hanoi(n - 1, source, auxiliary, target) print(f"{source} -> {target}") hanoi(n - 1, auxiliary, target, source) hanoi(3, "A", "C", "B")运行结果就是3层汉诺塔的完整移动轨迹。我不止一次看到新手写出功能正确但参数完全传反的版本,比如把hanoi(n-1, source, target, auxiliary)写成hanoi(n-1, source, target, ...)。这里有个很实用的记忆方法:调用递归时,你要问自己“这一层的目标是把盘子从哪搬到哪,临时借用哪根柱子”。第一层递归的目标是把N-1个盘子从source搬到auxiliary,所以target参数位置要填auxiliary;等它搬完了,真正的target柱才空出来放最大盘;第三层递归再把剩下的盘子从auxiliary搬到target。每次递归调用,三根柱子扮演的角色都在轮换,这也是汉诺塔代码最容易出错的地方。
另一个容易被忽略的点是终止条件。n == 1时直接打印“从当前位置到目标位置”并return,其实可以这样理解:只有一个盘子时你不需要借助任何辅助柱,直接搬过去就完了。如果把这个条件写成n == 0也行,但那样print就要放到条件外面,逻辑上绕了一圈,反而容易把参数搞混。我个人的建议是坚持用n == 1作为终止条件,代码和思路完全对齐,也不容易出错。
1.3 实测体会:递归调试与大盘数时的"物理极限"
汉诺塔题目做对只是第一步,最好再亲手验证一下移动次数。N个盘子的最少移动次数是2^N - 1,为什么?因为每层递归都要把N-1个盘子搬两次,外加最大盘移动一次,得到递推式T(N) = 2*T(N-1) + 1,解出来就是2^N - 1。我在调试时习惯在print前面加个全局计数器,每移动一次计数加1,最后和2^N - 1对比,能瞬间发现逻辑漏洞。
还有一个很实在的体验:不要轻易跑N=64。2的64次方减1等于18446744073709551615次移动,就算每秒打印1000行,也要几十亿年才能跑完。很多初学者把N设到30甚至更大,结果控制台刷屏卡死,还以为程序写错了。想验证代码正确性,N=3或N=4就足够了。想体验递归深度,N=100的时候Python会直接报RecursionError,因为递归深度默认限制在1000左右。这不是代码问题,而是调用栈的物理极限,碰到这种情况心里要有数。
2. 组成任意金额:暴力枚举到动态规划的三层进化
2.1 题目到底在问什么
“组成任意金额”这个描述其实是个简化说法。最常见的版本是:假设有面值为1分、2分、5分的硬币无限量供应,给定一个金额N,问有多少种不同的组合方式可以凑出N,或者把所有组合打印出来。我见过不少人拿到题目第一反应是“这有什么难的,三层循环穷举就完了”,但真写起来会发现两个问题:一是“组合”不关心顺序,1+2和2+1算同一种方案;二是N稍微大一点,暴力循环的范围就很难控制。
先明确题意再说代码。如果需要的是“方案数”,那问题本质是组合计数;如果需要的是“所有方案”,那问题就变成了回溯枚举。这两个需求代码结构完全不同,先弄清楚再动手,能省一半返工时间。下面我从最暴力的方案一直写到动态规划,每层都说明它在解决什么问题。
2.2 三层循环:最直观但最容易蒙圈的做法
如果你只考虑固定面值1、2、5,三层循环是最容易上手的方案:
def count_ways_naive(n): count = 0 for a in range(n + 1): # 1分硬币的数量 for b in range(n // 2 + 1): # 2分硬币的数量 for c in range(n // 5 + 1): # 5分硬币的数量 if a + 2 * b + 5 * c == n: count += 1 return count这种写法的毛病很明显:当N变大时,循环次数会膨胀得很快。更重要的是,循环变量a没必要从0遍历到n,因为一旦2*b + 5*c确定,a的值只能是n - 2*b - 5*c,而且必须非负。所以更聪明的写法是只枚举b和c,再检查剩余部分能不能用1分硬币补齐:
def count_ways_better(n): count = 0 for b in range(n // 2 + 1): for c in range((n - 2 * b) // 5 + 1): count += 1 return count为什么这样改?因为只要b和c都确定了,剩下的金额n - 2*b - 5*c一定用1分硬币来填,而且正好对应唯一一种组合。这样两层循环就把问题解决了,而且天然去重——你根本不存在枚举a的机会,也就不可能出现“先选2分再选1分”和“先选1分再选2分”的重复方案。
2.3 回溯法:面向任意面值列表的通用解法
问题来了:如果硬币面值不是固定的1、2、5,而是用户输入的一个列表,比如[1, 5, 10, 25],那上面的两层循环写法就完全失效了。这时候需要用回溯法,把“选择硬币”的过程抽象成沿着列表往后搜索。
def coin_combinations(coins, target): coins = sorted(coins) res = [] def dfs(start, remaining, path): if remaining == 0: res.append(path[:]) return for i in range(start, len(coins)): if coins[i] > remaining: break path.append(coins[i]) dfs(i, remaining - coins[i], path) path.pop() dfs(0, target, []) return res这里有一个关键去重设计:dfs(i, ...)而不是dfs(0, ...)。意思是下一次选择只能从当前硬币或其后面的面值开始,不允许回头选更小的面值。这样一来,方案“1, 1, 2”会被保留,而“1, 2, 1”和“2, 1, 1”会被拦掉,因为你在选了2之后,不会再回到1分硬币。这个“参数i随着递归逐渐变大”的技巧,是回溯法处理组合问题时最通用的去重手段,后面做排列、子集、组合总和类的题目都会用到。
coins[i] > remaining时直接break而不是continue,也值得提一下:因为我们已经对硬币排序了,当前面值的硬币超过剩余金额,后面更大的面值必然也超过,没必要继续循环。这个小优化在大金额、多面值的场景下能明显减少递归分支。
2.4 动态规划:把"方案数"变成状态转移
如果只是想要方案数,动态规划比回溯舒服得多,而且性能远胜。思路是定义一个数组dp,dp[x]表示凑出金额x的方案数。初始dp[0] = 1,凑出0元只有一种方案,就是一种硬币都不选。然后每引入一种面值的硬币,就更新一遍所有可能的金额。
def count_ways_dp(coins, n): dp = [0] * (n + 1) dp[0] = 1 for coin in coins: for amount in range(coin, n + 1): dp[amount] += dp[amount - coin] return dp[n]这个代码里最容易出错的地方是两层循环的先后顺序。for coin in coins在外面、for amount in range(coin, n+1)在里面,算出的是“组合数”;如果把这两个循环交换,算出的是“排列数”。为什么要这样?因为把coin放到外层循环,相当于每次只考虑“当前这一种面值加进来后”的状态更新,后面出现的面值不会回头和前面的面值重新组合出新的顺序,自然就过滤掉了“先选大后选小”和“先选小后选大”的重复。我见过不少人面试时在这里栽跟头,代码看着都一样,结果全都错在循环顺序上。
你可以用一个小例子验证:硬币[1, 2],金额3。组合方案的答案是2种,就是1+1+1和1+2。但如果把循环写成内coin外amount,dp[3]会算成3种,因为“2+1”和“1+2”被当成了两个不同的排列。这种捕捉错误的习惯,建议刚入门时就养成:写完动态规划,永远先用个小例子手算验证一遍。
3. 约瑟夫环:报到5退圈,坑比你想得多
3.1 用列表模拟:思路直白,性能有隐患
约瑟夫环问题是这样的:N个人围成一圈,编号从1到N,从1号开始报数,报到5的人退出圈子,然后从下一个继续报数,反复循环,直到只剩一个人,求最后幸存者的编号。题面里那句“报到5的人退圈,求最后留下来的是几号”就是这个意思。
最直白的做法是用列表模拟整个圈子:
def josephus_sim(n, k=5): people = list(range(1, n + 1)) idx = 0 while len(people) > 1: idx = (idx + k - 1) % len(people) people.pop(idx) return people[0]这段代码里最关键的(idx + k - 1) % len(people)其实值得掰开讲。当前指针idx指向的是“本轮第一个报数的人”,报到k的人应该从当前指针开始往后数k个人。因为当前这个人已经算报了1,所以要再往前移k-1步,才轮到处在第k位的人。取模运算符%用来处理“绕圈”:当指针移到列表末尾时,自动回到开头。
我见过新手在这个指针表达式上反复试错,一会儿idx + k,一会儿idx + k - 1,一会儿不加括号,结果边界情况一塌糊涂。最有效的排查方法是打印每一步的删除过程。比如n=5, k=5,手动推一遍:第一轮1到5报数,5号出圈;第二轮从1号开始,重新报1,数字2报2,3报3,4报4,1报5,所以1号出圈;第三轮从2开始,2报1、3报2、4报3、2报4、3报5,3号出圈;第四轮剩2号和4号,从4号开始报1、2报2、4报3、2报4、4报5,4号出圈,最后留下2号。跑一遍模拟代码,如果输出也是2,你的指针逻辑就对了。
3.2 数学解法:约瑟夫环的递推公式
列表模拟虽然好理解,但每次pop操作都是O(n)级别的删除,整体复杂度是O(n²)。当N达到十万、百万级别时,模拟会慢得让人抓狂。这时候就该上约瑟夫环的数学递推了。
先摆出结论:如果从0开始给这N个人编号,那么最后幸存者在“只剩1人”这个规模下的位置一定是0。然后反向递推:当规模从i-1扩展到i时,幸存者的新位置等于(上一轮的幸存位置 + k) % i。写成代码就是:
def josephus_math(n, k=5): pos = 0 for i in range(2, n + 1): pos = (pos + k) % i return pos + 1我知道这个公式看起来像从天而降,但它的推导逻辑其实很朴素。你想象一下:上一轮有i-1个人时,最后幸存者在那个小圈子里的相对位置是pos。现在圈子扩展到i个人,相当于在这个大圈子里从某个起始点重新开始报数,而大圈子的“报数起点”相对小圈子整体向右偏移了k个位置(因为每报数一轮就会淘汰一个人,起点会前进k步)。所以新位置就是(pos + k) % i。这个递推从i=2一路算到i=n,过程中完全不用真的删除任何元素,时间复杂度直接降到O(n)。
3.3 易错点与测试样例
约瑟夫环的代码虽然短,坑却不少。第一个坑是编号从0还是从1开始。递推公式用的是0基编号,所以最后返回时要pos + 1换回人类习惯的1基编号。如果你全程用1基编号硬套公式,取模的结果会在边界上错一位,非常隐蔽。第二个坑是k=1的情况。k=1时每报数1次就淘汰一个人,过程变成了“从头到尾按顺序淘汰”,最后留下的永远是从第1个开始数到最后的最后一个人。用公式算,pos每次变成(pos + 1) % i,最后结果就是n。手动推一遍就不怕写错。第三个坑是n=1,这时循环根本不会执行,直接返回pos + 1 = 1,逻辑上也没问题。
我还建议你把模拟解法和数学解法写在一起,用n=7, k=3这类小数据交叉验证。7个人、报数3退圈,手算答案是4号,两个代码都能输出4。一旦发现结果不一致,优先怀疑指针表达式和递推公式里的取模对象。我甚至见过有人把% len(people)写成% n,结果n固定不变,索引直接越界报错。这类低级错误靠测试样例是最容易抓出来的。
4. 密码检查:一句"检查密码"背后的完整需求清单
4.1 需求拆解:规则定清楚,代码才写得明白
“对用户设置的密码进行检查”是典型的题目描述简短、实际需求模糊的练习题。你和别人写的代码之所以不一样,往往不是因为谁更懂Python,而是因为对“检查”的理解不同。我在写之前会先把检查规则拆成一条条可验证的标准,比如:
- 长度至少8位。
- 至少包含一个大写字母、一个小写字母、一个数字。
- 至少包含一个特殊字符(非字母数字)。
- 不能包含连续3个及以上相同字符。
- 不能是常见弱密码(如123456、password)。
拆完需求你会发现,检查密码本质上不是“写一个if”,而是“写一套规则引擎”。你有没有把规则列清楚,直接决定代码质量。有人只写一个if len < 8就交差,有人能写出返回具体错误列表的版本,这就是差距所在。
4.2 用正则实现密码规则校验
Python的re模块是干这个活的最佳工具。下面这段代码我建议直接抄下来,然后改成自己的规则:
import re def check_password(password): errors = [] if len(password) < 8: errors.append("密码长度至少需要8位") if not re.search(r'[A-Z]', password): errors.append("密码中必须包含大写字母") if not re.search(r'[a-z]', password): errors.append("密码中必须包含小写字母") if not re.search(r'\d', password): errors.append("密码中必须包含数字") if not re.search(r'[^A-Za-z0-9]', password): errors.append("密码中必须包含特殊字符") if re.search(r'(.)\1{2,}', password): errors.append("密码中不能出现连续3个相同字符") weak_list = ["123456", "password", "qwerty", "admin"] for weak in weak_list: if weak in password.lower(): errors.append("密码过于常见,容易被猜中") break return errors password = "Abc12345!" result = check_password(password) if result: print("密码不合格:") for error in result: print("-", error) else: print("密码合格")这段代码里最值得我多讲两句的是正则表达式。re.search在字符串任意位置查找匹配,而re.match只从字符串开头匹配。检查“是否包含大写字母”用search才正确,用match会让你在“abcDef”这类密码上误判。r'[^A-Za-z0-9]'的意思是“匹配任何一个既不是字母也不是数字的字符”,这里的^放在方括号里表示“取反”,和放在方括号外表示“行首”是两回事。初学者看到^在正则里一会儿表示开头一会儿表示排除,很容易晕,多写几次就记住了。(.)\1{2,}里(.)匹配任意一个字符并捕获它,\1引用这个捕获的内容,{2,}表示至少重复2次,连起来就是“同一个字符连续出现至少3次”。如果不加(.)直接写.\1{2,},正则引擎会搞不清要重复的是哪个字符,所以这组括号不能省。
4.3 要命的边界情况:空字符串、Unicode、超长密码
很多人写密码检查只盯着正常情况,被隐藏的边界炸得措手不及。第一个是空字符串。len("") < 8会报“长度不足”,但后续所有re.search都会正常返回结果,不会报错,所以空密码会落在一堆错误列表里,这是能接受的。怕的是有人在re.search之前直接用password[0]或者password[-1]取字符,那才是真正的崩溃点。第二个是Unicode字符。比如密码“密码123Ab!”里包含中文,中文字符既不属于[A-Za-z0-9],所以会被特殊字符规则命中,这其实符合很多系统的预期,但也意味着“特殊字符”这个定义对中文密码来说可能过于宽泛。如果你不想让中文被当成特殊字符,正则要写成r'[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]'这种显式特殊字符集合。第三个是超长密码。假设用户粘贴了一段几万字的文章当密码,你的正则引擎会怎么表现?绝大多数情况下没问题,re.search在超长字符串上性能也还行,但如果你写了类似(.)\1{100,}这种夸张的重复量词,正则引擎的匹配耗时可能明显上升。我建议实际项目里限制密码最长128位,这样既挡住极端输入,又给后面的哈希存储留出性能余地。
4.4 真实项目里的延伸思考
练习题里的密码检查,和真实项目里的密码强度校验,差别还是蛮大的。真实系统不会只靠前端或单机校验就放行,而是会在后端再校验一遍,并且把规则提示清楚返回给用户。真正的生产环境更不能用本文这种明文校验结果做最终判定,而是要把密码加盐哈希后再存储,比如用bcrypt或argon2这类专门算法,绝不能在数据库里存明文密码。不过这不代表这个练习题不值钱,它练的是“把模糊需求拆成清晰规则再用代码落地”的能力,练的是对字符串和正则的熟练度,练的是空值、边界、性能这些意识。有了这些底子,后面接触PEP 8风格、函数拆分、单元测试,才会顺理成章。
就说这个密码检查,我实际写的时候还会建议你顺手把它改造成一个带文档字符串的函数,让调用者能直接看到返回的是错误列表而不是简单的True/False。一个函数如果只返回True或False,使用者只知道密码不行,却不知道哪里不行;返回错误列表,网页后端就能直接把错误逐条显示给用户。这种“返回值设计”的基本功,恰恰是从这种小练习里练出来的。
这几天把这四个题反复写了几遍,我最大的感受是:好的练习题不是让你背答案,而是逼着你从“会语法”走向“会思考”。汉诺塔逼你相信递归的直觉,金额组合逼你在暴力解和动态规划之间找到平衡,约瑟夫环逼你把一个过程抽象成递推公式,密码检查逼你把模糊的描述翻译成明确的规则。这四种思维用到任何项目里都不过时。如果你正处在“语法都会、做题就废”的阶段,不用贪多,就这四个题,一题写三遍,每一遍试着换一种解法,感受会非常不一样。