1. 项目概述与背景
最近在整理资料时,翻到了2019年腾讯编程营的练习题集。这套题目在当时是面向有一定编程基础,特别是Python初学者的夏令营活动设计的,涵盖了从基础语法到简单算法、数据处理等多个维度的练习。虽然时间过去几年,但编程的核心逻辑和解决问题的思路是相通的。我发现很多朋友在自学Python时,常常苦于找不到合适的、有梯度的练习题来巩固知识,或者即使找到了题目,面对五花八门的“参考答案”也感到困惑,不知道哪种解法更优、更“Pythonic”。因此,我决定花些时间,把这套练习题的下半部分拿出来,结合我这些年的开发经验,不仅给出答案,更重要的是拆解每一道题背后的考察点、解题思路的演变过程,以及不同解法之间的优劣对比。我希望这份“解答”更像是一份“解题笔记”或“思路复盘”,能帮助正在爬坡的Python学习者,在理解“怎么做”的同时,更明白“为什么这么做”,以及“有没有更好的做法”。
这套练习题的下半部分,难度相较于上半部分有所提升,开始涉及一些经典的算法思想(如递归、动态规划的雏形)、对数据结构的灵活运用(列表、字典、集合),以及一些实际编程中常见的场景模拟。对于已经掌握了if-else、for循环、函数定义等基础语法的朋友来说,这是一个非常好的进阶练习场。接下来,我将挑选其中最具代表性的几道题目,进行深度剖析。
2. 核心题目解析与思路拆解
2.1 题目一:字符串模式匹配与统计
原题描述(简述):给定一个长字符串text和一个短字符串pattern,要求统计pattern在text中出现的所有位置(索引从0开始),并返回一个列表。例如,text = “ababababc”, pattern = “aba”,则应返回[0, 2, 4]。
这道题看似简单,直接使用str.find()或str.index()循环查找即可,但它实际上是一个引子,引导我们思考更高效的字符串匹配算法。
2.1.1 基础解法与陷阱分析
最直观的解法是使用滑动窗口:
def find_pattern_naive(text, pattern): positions = [] len_text, len_pat = len(text), len(pattern) # 滑动窗口的起始位置 i 只需遍历到 len_text - len_pat for i in range(len_text - len_pat + 1): if text[i:i+len_pat] == pattern: positions.append(i) return positions这个解法的时间复杂度是 O((n-m+1)*m),其中 n 是text长度,m 是pattern长度。在pattern较短时完全可行,也是面试中快速写出的基础答案。但这里有一个新手极易忽略的边界陷阱:range(len_text - len_pat + 1)中的+1。如果pattern长度大于text,len_text - len_pat为负数,range()接收负数参数会直接返回空迭代器,逻辑上是对的(不可能找到)。但更严谨的写法是先判断 iflen_pat > len_text: return [],这样意图更清晰。
注意:在 Python 中,
text[i:i+len_pat]当i+len_pat超过字符串长度时,会自动截取到末尾,不会报错。这虽然方便,但在这个算法里,我们通过循环范围控制,避免了无意义的切片,是更优的。
2.1.2 进阶思考:KMP算法引介
当题目追问“是否有更优解”或text和pattern规模很大时,就需要引入经典的 KMP (Knuth-Morris-Pratt) 算法。它的核心是利用匹配失败时的信息,通过一个“部分匹配表”(Next数组)来避免主串指针的回退,将时间复杂度降到 O(n+m)。
对于初学者,理解 KMP 的关键在于明白“最长相同前后缀”的概念。我们不必在解答中实现完整的 KMP(除非题目明确要求),但可以指出这种更优算法的存在,并说明其思想:
“实际上,对于大规模字符串匹配,有更高效的算法如 KMP。它的精髓在于,当某次匹配失败时,pattern串本身的信息(哪些前缀和后缀是相同的)可以告诉我们,下一次可以直接将pattern滑动多长的距离,而无需回头重新比较text中已经检查过的字符。虽然实现起来稍复杂,但这是算法学习路上一个重要的里程碑。”
在编程营的练习题层面,掌握基础滑动窗口解法并注意边界条件,已经能够拿到满分。但作为学习笔记,点出更广阔的技术视野,是非常有价值的。
2.2 题目二:列表去重与顺序保持
原题描述:给定一个可能包含重复元素的列表,要求去除重复元素,并保持元素在原列表中首次出现的相对顺序。例如,输入[3, 2, 1, 2, 4, 3, 1],输出[3, 2, 1, 4]。
这道题完美考察了对 Python 数据结构特性和时间复杂度的理解。
2.2.1 常见误区与低效解法
新手可能会想到用一个新列表,遍历原列表,如果元素不在新列表中,则追加:
def remove_duplicates(lst): new_lst = [] for item in lst: if item not in new_lst: # 这里每次 `in` 操作都是 O(k), k 为新列表当前长度 new_lst.append(item) return new_lst这个方法虽然能保持顺序,但效率很低。因为if item not in new_lst本质是线性查找,整个算法的时间复杂度是 O(n²)。
2.2.2 高效解法:利用集合(Set)进行成员检测
优化的关键是快速判断一个元素是否已经出现过。Python 的set基于哈希表,其in操作的平均时间复杂度是 O(1)。我们可以利用一个辅助集合来记录已经遇到过的元素:
def remove_duplicates_efficient(lst): seen = set() result = [] for item in lst: if item not in seen: seen.add(item) result.append(item) return result这个算法的时间复杂度是 O(n),因为每次查找和插入set的操作平均都是常数时间。空间复杂度是 O(n),用于存储集合和结果列表。
2.2.3 更“Pythonic”的写法与原理
对于熟悉 Python 的朋友,可能会想到用dict.fromkeys或者利用 Python 3.7+ 中字典保持插入顺序的特性:
# 方法一:利用字典键的唯一性和顺序性 (Python 3.7+ 保证) def remove_duplicates_dict(lst): return list(dict.fromkeys(lst)) # 方法二:使用 collections.OrderedDict (兼容更早版本) from collections import OrderedDict def remove_duplicates_ordereddict(lst): return list(OrderedDict.fromkeys(lst))dict.fromkeys(lst)会以lst中的元素为键,创建一个字典,重复的键自然被去重,并且从 Python 3.7 开始,字典正式保证了插入顺序。这行代码非常简洁,其内在原理和我们上面“集合+列表”的方法异曲同工,可读性极高,是生产环境中常用的写法。
实操心得:在面试或快速原型开发中,
list(dict.fromkeys(lst))是首选,因为它简洁且高效。但在向初学者讲解时,务必先揭示set辅助查找的原理,这是理解算法效率的关键。同时要指出,如果列表元素是不可哈希的类型(如列表、字典),那么上述所有方法都会报错,这是由set和dict的底层实现决定的。
2.3 题目三:模拟简单缓存(LRU 最近最少使用)机制
原题描述:设计一个简易的缓存系统,缓存容量为capacity。提供put(key, value)和get(key)方法。当缓存满时,put操作需要淘汰最久未被访问的键值对(LRU策略)。要求get和put操作的时间复杂度尽可能低。
这道题已经触及了数据结构设计的核心,是面试中的高频题。它综合考察了哈希表(字典)和双向链表(或 OrderedDict)的应用。
2.3.1 问题抽象与数据结构选型
核心需求:
- 快速访问:通过
key快速找到对应的value。这指向哈希表(Python 字典),O(1) 时间复杂度。 - 维护顺序:需要知道哪些数据是“最近使用过”的,哪些是“最久未使用”的。这需要一种有序的数据结构,并且要支持在任意位置快速插入和删除。数组(列表)删除非末尾元素是 O(n),不合适。单向链表删除指定节点需要知道前驱节点,不方便。
因此,经典解决方案是:哈希表 + 双向链表。
- 哈希表:
{key: ListNode},实现 O(1) 的查找。 - 双向链表:节点按访问时间排序,头节点是最近访问的,尾节点是最久未访问的。链表支持在任意位置 O(1) 时间插入和删除节点(前提是已获得该节点的引用)。
2.3.2 详细设计与 Python 实现
我们先定义双向链表节点:
class DLinkedNode: def __init__(self, key=0, value=0): self.key = key self.value = value self.prev = None self.next = None然后实现 LRU 缓存类:
class LRUCache: def __init__(self, capacity: int): self.capacity = capacity self.size = 0 self.cache = {} # 哈希表,映射 key -> Node # 使用伪头部和伪尾部节点,简化边界条件判断 self.head = DLinkedNode() self.tail = DLinkedNode() self.head.next = self.tail self.tail.prev = self.head def _add_node_to_head(self, node: DLinkedNode): """将节点添加到伪头部之后(链表头部)""" node.prev = self.head node.next = self.head.next self.head.next.prev = node self.head.next = node def _remove_node(self, node: DLinkedNode): """从链表中移除指定节点""" node.prev.next = node.next node.next.prev = node.prev def _move_node_to_head(self, node: DLinkedNode): """将某个已存在的节点移动到链表头部""" self._remove_node(node) self._add_node_to_head(node) def _pop_tail(self) -> DLinkedNode: """弹出并返回链表尾部的节点(最久未使用)""" node = self.tail.prev self._remove_node(node) return node def get(self, key: int) -> int: if key not in self.cache: return -1 # 题目通常要求未找到返回 -1 node = self.cache[key] # 访问了该节点,需将其移动到头部 self._move_node_to_head(node) return node.value def put(self, key: int, value: int) -> None: if key in self.cache: # key 已存在,更新值,并移动到头部 node = self.cache[key] node.value = value self._move_node_to_head(node) else: # key 不存在,创建新节点 new_node = DLinkedNode(key, value) self.cache[key] = new_node self._add_node_to_head(new_node) self.size += 1 # 如果超出容量,移除尾部节点 if self.size > self.capacity: tail_node = self._pop_tail() del self.cache[tail_node.key] # 从哈希表中也删除 self.size -= 12.3.3 利用collections.OrderedDict的取巧实现
在 Python 中,collections.OrderedDict本身就是一个维护插入顺序的字典,并且提供了move_to_end(key, last=True)方法将某个键移动到末尾(last=True)或开头(last=False)。我们可以将链表尾部视为最久未使用,那么get和put更新时,就把键移到开头;容量满时,弹出最后一个键。
from collections import OrderedDict class LRUCacheUsingOrderedDict: def __init__(self, capacity: int): self.capacity = capacity self.cache = OrderedDict() def get(self, key: int) -> int: if key not in self.cache: return -1 # 访问后,移动到末尾(表示最近使用) self.cache.move_to_end(key) return self.cache[key] def put(self, key: int, value: int) -> None: if key in self.cache: # 更新值,并移动到末尾 self.cache.move_to_end(key) self.cache[key] = value # 检查容量 if len(self.cache) > self.capacity: # popitem(last=False) 移除并返回第一个插入的键值对(最久未使用) self.cache.popitem(last=False)注意事项:
OrderedDict的实现通常也是基于双向链表,所以其move_to_end和popitem操作也是 O(1)。这种写法极其简洁,是 Python 解决此类问题的“作弊器”。但在面试中,面试官很可能要求你实现底层结构,以考察你对数据结构的掌握程度。所以,理解“哈希表+双向链表”的原理是根本。
3. 题目四:递归与分治思想的应用——计算x的n次幂
原题描述:实现函数pow(x, n),计算x的n次幂。要求效率尽可能高。
这道题是递归和分治算法的经典入门题。暴力解法是循环n次连乘,时间复杂度 O(n)。但利用分治思想,可以优化到 O(log n)。
3.1 思路演化:从暴力到分治
最直接的想法:
def pow_naive(x, n): result = 1 for _ in range(n): result *= x return result如果n很大(比如上亿),这个循环将非常慢。
我们观察到:x^n = x^(n/2) * x^(n/2)(当 n 为偶数)。这样,我们可以把一个大问题(n次方)分解成两个规模减半的子问题(n/2次方),子问题的结果可以复用。这就是分治。
3.2 递归分治实现与细节处理
递归实现需要考虑以下几点:
- 终止条件:
n == 0时,返回 1(任何数的0次幂为1)。 - 负数次幂:
x^(-n) = 1 / (x^n)。 - 奇偶性:
- 如果
n是偶数:pow(x, n) = pow(x, n//2) ** 2 - 如果
n是奇数:pow(x, n) = x * pow(x, n//2) ** 2(注意,n//2在 Python 中是向下取整)
- 如果
def pow_recursive(x: float, n: int) -> float: # 处理 n 为负数的情况 if n < 0: x = 1 / x n = -n # 递归辅助函数 def helper(x, n): if n == 0: return 1 half = helper(x, n // 2) # 计算子问题 if n % 2 == 0: return half * half else: return x * half * half return helper(x, n)3.3 迭代实现与位运算优化
递归实现有函数调用开销,我们可以用迭代方式重写,并利用位运算判断奇偶性和进行除以2的操作,效率更高。
思路是:将指数n用二进制表示。例如x^13 = x^(1101)_2 = x^(8) * x^(4) * x^(1)。我们在迭代中,让x不断自乘(x = x * x),相当于计算x^1, x^2, x^4, x^8...。同时,我们检查n的二进制位,如果某位是1,就把当前的x乘到结果中。
def pow_iterative(x: float, n: int) -> float: if n < 0: x = 1 / x n = -n result = 1 current_product = x while n > 0: # 如果当前二进制位为1,则乘入结果 if n & 1: # n % 2 == 1 result *= current_product # current_product 自乘,相当于计算 x^(2^k) current_product *= current_product # n 右移一位,相当于 n //= 2 n >>= 1 return result这个迭代版本的时间复杂度是 O(log n),空间复杂度是 O(1),是最优的解法之一。
常见问题:为什么
current_product初始值是x,而不是1?因为current_product代表的是x^(2^0)即x^1。在第一次循环时,如果n的最低位是1,result就应该乘以x^1。
4. 题目五:利用栈处理表达式求值(简化版)
原题描述:给定一个字符串表达式,包含数字、+、-、*、/和空格,实现一个基本计算器来计算其值。表达式中的运算都是整数运算,/为整数除法(向零取整)。这是一个简化版,假设输入表达式总是有效的。
这道题是栈数据结构的典型应用,考察对运算符优先级和计算顺序的理解。
4.1 核心思路:双栈法
我们可以使用两个栈:
- 操作数栈 (num_stack):存放待计算的数字。
- 运算符栈 (op_stack):存放运算符。
算法流程(调度场算法思想的简化):
- 遍历表达式字符串。
- 遇到数字,解析完整的数字并入操作数栈。
- 遇到运算符:
- 如果当前运算符优先级小于或等于运算符栈顶运算符的优先级,则先进行栈顶运算符的运算(从操作数栈弹出两个数,从运算符栈弹出一个运算符,计算后将结果压回操作数栈),然后再将当前运算符入栈。这保证了
*和/在+和-之前计算。 - 否则,直接入栈。
- 如果当前运算符优先级小于或等于运算符栈顶运算符的优先级,则先进行栈顶运算符的运算(从操作数栈弹出两个数,从运算符栈弹出一个运算符,计算后将结果压回操作数栈),然后再将当前运算符入栈。这保证了
- 遍历结束后,依次弹出运算符栈中的运算符进行计算,直到运算符栈为空。
- 操作数栈中剩下的最后一个数就是结果。
4.2 优先级处理与代码实现
我们需要一个函数来定义运算符的优先级。
def calculate(s: str) -> int: def precedence(op): if op in ('+', '-'): return 1 if op in ('*', '/'): return 2 return 0 def apply_operation(a, b, op): if op == '+': return a + b if op == '-': return a - b if op == '*': return a * b if op == '/': # 向零取整的整数除法 return int(a / b) # 使用 int() 而非 //,因为 // 是向下取整 num_stack = [] op_stack = [] i = 0 n = len(s) while i < n: if s[i] == ' ': i += 1 continue elif s[i].isdigit(): # 解析完整数字 num = 0 while i < n and s[i].isdigit(): num = num * 10 + int(s[i]) i += 1 num_stack.append(num) # 注意这里已经 i 指向了数字后的字符,循环末尾不 i+=1 continue else: # 是运算符 while op_stack and precedence(op_stack[-1]) >= precedence(s[i]): b = num_stack.pop() a = num_stack.pop() op = op_stack.pop() num_stack.append(apply_operation(a, b, op)) op_stack.append(s[i]) i += 1 # 处理剩余的运算符 while op_stack: b = num_stack.pop() a = num_stack.pop() op = op_stack.pop() num_stack.append(apply_operation(a, b, op)) return num_stack[0]4.3 处理负数与括号的扩展思考
原题是简化版。更完整的计算器还需要处理:
- 负数:例如
“-1+2”。可以在解析时,如果遇到-号,且前面不是数字也不是右括号),则认为是负号,将下一个数字解析为负数入栈。 - 括号:括号会改变运算顺序。遇到左括号
(直接入运算符栈;遇到右括号),则不断弹出运算符栈进行计算,直到遇到左括号(为止。
实操心得:表达式求值的关键在于延迟计算。栈帮助我们保存了暂时不能计算的操作数和运算符,直到遇到优先级更低的运算符或表达式结束时,才进行之前的计算。在编写这类代码时,要特别注意索引
i的移动和continue的使用,确保完整解析数字。调试时,可以打印出每一步两个栈的状态,这对理解算法流程非常有帮助。
5. 常见问题与调试技巧实录
在解决这些练习题的过程中,尤其是自己动手实现时,肯定会遇到各种“坑”。下面我总结几个高频问题和调试技巧。
5.1 关于递归的深度与栈溢出
问题:在实现递归分治(如pow函数)或深度优先搜索时,如果递归层数过深(例如n很大),Python 可能会抛出RecursionError: maximum recursion depth exceeded。
排查与解决:
- 检查终止条件:确保递归函数一定有终止条件,并且每次递归调用都向终止条件靠近。
- 尾递归优化:Python 默认不支持尾递归优化。对于可以写成尾递归形式的函数,可以尝试手动改写成迭代形式,这是最根本的解决方法。例如
pow的迭代版本。 - 修改递归深度限制(不推荐作为常规手段):可以通过
sys.setrecursionlimit(limit)提高限制,但这只是权宜之计,可能掩盖程序逻辑问题,并消耗大量内存。
5.2 列表修改与迭代的陷阱
问题:在遍历列表的同时,对其进行增删操作,可能导致意想不到的结果或RuntimeError。
# 错误示例:想删除列表中所有的偶数 lst = [1, 2, 3, 4, 5, 6] for i, num in enumerate(lst): if num % 2 == 0: lst.pop(i) # 这会改变列表长度和索引,导致后续迭代出错或漏删解决:
- 创建新列表:最安全的方法是使用列表推导式创建新列表。
lst = [1, 2, 3, 4, 5, 6] lst = [num for num in lst if num % 2 != 0] # [1, 3, 5] - 反向遍历:如果必须在原列表上操作,可以反向遍历,这样删除元素不会影响未遍历部分的索引。
lst = [1, 2, 3, 4, 5, 6] for i in range(len(lst)-1, -1, -1): if lst[i] % 2 == 0: lst.pop(i)
5.3 字典键的存在性判断
问题:在 LRU 缓存或类似场景中,直接if dict[key]来判断键是否存在,如果键不存在且其对应的值为假值(如0,[],False),则会误判。
解决:始终使用key in dict或dict.get(key)方法。
my_dict = {'a': 0, 'b': []} # 错误 if my_dict.get('a'): # 条件为 False,因为 my_dict['a'] == 0 print('a exists') # 正确 if 'a' in my_dict: # 条件为 True print('a exists') value = my_dict.get('c', 'default') # 安全地获取,不存在则返回'default'5.4 浮点数比较的精度问题
问题:在涉及浮点数计算(如pow(x, n)中的x为浮点数)或比较时,直接使用==可能因为精度问题得到错误结果。
解决:判断两个浮点数是否“足够接近”,而不是完全相等。
# 错误 if result == expected: ... # 正确 if abs(result - expected) < 1e-9: # 设置一个极小的误差容忍度 ...5.5 调试利器:print与pdb
对于算法题,最直接的调试方法就是在关键步骤插入print语句,打印变量的状态(如栈的内容、循环索引、中间结果)。
对于更复杂的逻辑,可以学习使用 Python 内置的调试器pdb。
- 在代码中插入
import pdb; pdb.set_trace(),程序运行到此处会进入交互式调试环境。 - 常用命令:
n(执行下一行),s(进入函数),c(继续运行),p 变量名(打印变量),l(查看当前代码上下文)。
5.6 单元测试意识
养成为自己写的函数编写简单测试用例的习惯。这能快速验证基本功能是否正确,并在修改代码后快速回归测试。
def test_pow(): assert pow_iterative(2, 10) == 1024 assert pow_iterative(2, -2) == 0.25 assert pow_iterative(0, 10) == 0 assert pow_iterative(5, 0) == 1 print("All tests passed!") test_pow()解决编程练习题,最终目的不是“做出这一道题”,而是通过这道题掌握一类问题的解决方法,并锻炼将思路转化为严谨、高效代码的能力。希望这份针对腾讯编程营部分练习题的深度解析,能为你提供除了答案之外,更多关于“如何思考”和“如何实现得更好”的启发。编程之路,道阻且长,行则将至。多练、多思、多总结,共勉。