配套专栏:Python 全栈修炼之路 第 03 篇《字符串 —— 不只是文本那么简单》
难度分布:⭐ → ⭐⭐ → ⭐⭐ → ⭐⭐⭐ → ⭐⭐⭐ → ⭐⭐⭐⭐
核心覆盖:切片、常用方法、f-string 格式化、正则表达式、回文判断、编码解码、不可变性
题目一:回文判断 ⭐
📌 题目描述
编写函数 is_palindrome(s),判断字符串是否为回文(正读和反读相同)。需忽略大小写和非字母数字字符。
输入:"A man, a plan, a canal: Panama"输出:True解释:去掉非字母数字并转小写后为 "amanaplanacanalpanama",是回文输入:"race a car"输出:False
💡 编程思路
回文判断是字符串入门经典题,核心是清理 + 反转比较:
- 方法一(切片反转):先过滤非字母数字字符并转小写,然后用
s[::-1] 反转,比较是否相等。一行搞定,最 Pythonic。 - 方法二(双指针):左右两个指针从两端向中间移动,跳过非字母数字字符,逐个比较。无需创建新字符串,空间 O(1)。
方法一简洁直观,方法二更节省内存,面试中两种都应掌握。
🖥️ 参考代码
def is_palindrome_slice(s): """方法一:切片反转 —— O(n) 时间,O(n) 空间""" # 过滤:只保留字母和数字,转小写 cleaned = ''.join(ch.lower() for ch in s if ch.isalnum()) return cleaned == cleaned[::-1]def is_palindrome_two_pointer(s): """方法二:双指针 —— O(n) 时间,O(1) 空间""" left, right = 0, len(s) - 1 while left < right: # 跳过非字母数字字符 while left < right and not s[left].isalnum(): left += 1 while left < right and not s[right].isalnum(): right -= 1 # 比较(忽略大小写) if s[left].lower() != s[right].lower(): return False left += 1 right -= 1 return True# 测试if __name__ == "__main__": test_cases = [ ("A man, a plan, a canal: Panama", True), ("race a car", False), ("", True), (" ", True), ("0P", False), ("Was it a car or a cat I saw?", True), ("12321", True), ("12345", False), ] for s, expected in test_cases: r1 = is_palindrome_slice(s) r2 = is_palindrome_two_pointer(s) assert r1 == expected, f"slice方法失败: {s}" assert r2 == expected, f"双指针方法失败: {s}" print(f" '{s[:30]}...' → {r1} ✓")
🔗 关联知识点
| |
|---|
s[::-1] | |
str.isalnum() | |
str.lower() | |
''.join(generator) | |
| |
题目二:字符串反转(保持单词顺序) ⭐⭐
📌 题目描述
编写函数 reverse_words(s),反转字符串中每个单词的字符顺序,但保持单词之间的相对顺序不变。
输入:"Hello Python World"输出:"olleH nohtyP dlroW"输入:" the sky is blue "输出:" eht yks si eulb "
要求:保留原始空格(包括前导、尾部和单词间的多个空格)。
💡 编程思路
这道题考察的是精确的字符串分割与重组:
- 方法一(split + 切片反转):用
split(' ') 按单个空格分割(保留空字符串),对每个非空片段反转,再用 ' '.join() 拼接。注意 split(' ') 和 split() 的区别:前者保留空串,后者丢弃所有空白。 - 方法二(正则分割):用
re.split(r'(\s+)', s) 按空白分组,奇数位是分隔符,偶数位是单词。只反转偶数位的单词,保留分隔符不变。
方法二更精确,能完美保留原始空格格式。
🖥️ 参考代码
import redef reverse_words_split(s): """方法一:split + 切片反转""" # split(' ') 保留空串,split() 丢弃空白 parts = s.split(' ') reversed_parts = [part[::-1] for part in parts] return ' '.join(reversed_parts)def reverse_words_regex(s): """方法二:正则分割,精确保留空白""" # 按连续空白分组,保留分隔符 groups = re.split(r'(\s+)', s) # 偶数索引是单词,奇数索引是空白 for i in range(0, len(groups), 2): groups[i] = groups[i][::-1] return ''.join(groups)# 测试if __name__ == "__main__": print(reverse_words_split("Hello Python World")) # "olleH nohtyP dlroW" print(reverse_words_split(" the sky is blue ")) # " eht yks si eulb " print(reverse_words_regex(" the sky is blue ")) # " eht yks si eulb " # 边界测试 print(reverse_words_split("")) # "" print(reverse_words_split("a")) # "a" print(reverse_words_split(" ")) # " "
🔗 关联知识点
| |
|---|
split(' ') | |
s[::-1] | |
' '.join() | |
re.split(r'(\s+)', s) | |
| |
题目三:手机号脱敏与信息提取 ⭐⭐
📌 题目描述
编写函数 process_user_info(text),对文本中的手机号进行中间四位脱敏(替换为 ****),同时提取文本中的所有邮箱地址。
输入:"""联系方式:13812345678,备用13987654321。邮箱:zhangsan@example.com 和 lisi@test.org工作邮箱:wangwu@company.cn"""输出:脱敏后文本:"联系方式:138****5678,备用139****4321。\n邮箱:zhangsan@example.com 和 lisi@test.org\n工作邮箱:wangwu@company.cn"提取的邮箱:["zhangsan@example.com", "lisi@test.org", "wangwu@company.cn"]
💡 编程思路
这道题综合考察正则表达式的匹配、分组替换和查找:
- 手机号脱敏:用
re.sub() + 捕获分组(\d{3})\d{4}(\d{4}),替换为 \1****\2。分组让我们保留前3位和后4位。 - 邮箱提取:用
re.findall() 匹配邮箱格式 \w+@\w+\.\w+。更严谨的写法是 [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}。
🖥️ 参考代码
import redef mask_phone_numbers(text): """手机号中间四位脱敏""" # (\d{3})\d{4}(\d{4}) → 分组1 + **** + 分组2 pattern = r'(\d{3})\d{4}(\d{4})' return re.sub(pattern, r'\1****\2', text)def extract_emails(text): """提取所有邮箱地址""" pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' return re.findall(pattern, text)def process_user_info(text): """综合处理:脱敏 + 提取""" masked_text = mask_phone_numbers(text) emails = extract_emails(text) return masked_text, emails# 测试if __name__ == "__main__": text = """ 联系方式:13812345678,备用13987654321。 邮箱:zhangsan@example.com 和 lisi@test.org 工作邮箱:wangwu@company.cn """ masked, emails = process_user_info(text) print("=== 脱敏后文本 ===") print(masked) print("\n=== 提取的邮箱 ===") for email in emails: print(f" {email}") # 更多测试 assert mask_phone_numbers("13812345678") == "138****5678" assert mask_phone_numbers("电话13812345678号") == "电话138****5678号" print("\n所有断言通过 ✓")
🔗 关联知识点
| |
|---|
re.sub(pattern, repl, text) | |
| |
re.findall(pattern, text) | |
| [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,} |
| (\d{3})\d{4}(\d{4}) |
题目四:简易模板引擎 ⭐⭐⭐
📌 题目描述
实现一个简易字符串模板引擎,支持 {{变量名}} 占位符替换。
template = "你好,{{name}}!你有 {{count}} 条未读消息,来自 {{source}}。"data = {"name": "张三", "count": 5, "source": "CSDN"}输出:"你好,张三!你有 5 条未读消息,来自 CSDN。"
扩展要求:
- 支持默认值:
{{name|未知用户}},变量不存在时使用默认值 - 支持格式化:
{{price|%.2f}},对数值进行格式化
💡 编程思路
这道题考察正则表达式匹配 + 动态替换:
- 用
re.sub() 的替换函数模式(传入 callable),对每个匹配到的占位符动态处理。 - 正则模式
{{(\w+)(?:\|([^}]+))?}}: - 替换函数中,先从
data 字典取值,取不到则用默认值,再尝试格式化。
🖥️ 参考代码
import reclass TemplateEngine: """简易模板引擎 —— 支持 {{变量}} 和 {{变量|默认值}} 语法""" # 匹配 {{变量名}} 或 {{变量名|默认值/格式}} pattern = re.compile(r'\{\{(\w+)(?:\|([^}]+))?\}\}') @classmethod def render(cls, template, data): """渲染模板""" def replacer(match): var_name = match.group(1) # 变量名 default = match.group(2) # 默认值/格式 # 从数据中取值 value = data.get(var_name) if value is None: # 变量不存在,使用默认值 if default is not None: return default return match.group(0) # 保持原样 # 尝试格式化(默认值是格式化字符串,如 %.2f) if default is not None: try: return default % value except (TypeError, ValueError): pass return str(value) return cls.pattern.sub(replacer, template)# 测试if __name__ == "__main__": # 基础用法 template1 = "你好,{{name}}!你有 {{count}} 条未读消息。" data1 = {"name": "张三", "count": 5} print(TemplateEngine.render(template1, data1)) # 你好,张三!你有 5 条未读消息。 # 默认值 template2 = "用户:{{name|访客}},积分:{{score|0}}" data2 = {"name": "李四"} # score 不存在 print(TemplateEngine.render(template2, data2)) # 用户:李四,积分:0 # 格式化 template3 = "商品:{{item}},价格:¥{{price|%.2f}},折扣:{{discount|%.0f%%}}" data3 = {"item": "Python教程", "price": 99.5, "discount": 0.85} print(TemplateEngine.render(template3, data3)) # 商品:Python教程,价格:¥99.50,折扣:85% # 未匹配保持原样 template4 = "Hello {{name}}, {{unknown_var}} is not replaced." print(TemplateEngine.render(template4, {"name": "World"})) # Hello World, {{unknown_var}} is not replaced.
🔗 关联知识点
| |
|---|
re.sub(pattern, callable, text) | |
| |
| |
dict.get(key) | |
str % value | |
题目五:罗马数字转整数 ⭐⭐⭐
📌 题目描述
罗马数字包含以下七种字符:I(1), V(5), X(10), L(50), C(100), D(500), M(1000)。
编写函数 roman_to_int(s),将罗马数字字符串转换为整数。
输入:"III" 输出:3输入:"IV" 输出:4输入:"IX" 输出:9输入:"LVIII" 输出:58 (50 + 5 + 3)输入:"MCMXCIV" 输出:1994 (1000 + 900 + 90 + 4)
💡 编程思路
罗马数字的规则:通常小的数字在大的数字右边(如 VI = 6),但特殊情况小的在左边表示减法(如 IV = 4, IX = 9)。
核心逻辑:从左到右遍历,如果当前值 < 下一个值,则减去当前值(减法规则);否则加上当前值。
- 方法一(字典 + 遍历):用字典映射字符到数值,逐个比较当前值与下一个值。
- 方法二(字典 + 替换):先把减法组合替换为加法(IV → IIII),然后直接求和。思路巧妙但效率稍低。
🖥️ 参考代码
def roman_to_int(s): """罗马数字转整数 —— 遍历法 O(n)""" roman_map = { 'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000 } total = 0 for i in range(len(s)): current = roman_map[s[i]] # 如果当前值 < 下一个值,说明是减法 if i + 1 < len(s) and current < roman_map[s[i + 1]]: total -= current else: total += current return totaldef int_to_roman(num): """整数转罗马数字(逆向转换,帮助理解)""" value_symbols = [ (1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I') ] result = [] for value, symbol in value_symbols: while num >= value: result.append(symbol) num -= value return ''.join(result)# 测试if __name__ == "__main__": test_cases = [ ("III", 3), ("IV", 4), ("IX", 9), ("LVIII", 58), ("MCMXCIV", 1994), ("MMMCMXCIX", 3999), # 最大标准罗马数字 ] for roman, expected in test_cases: result = roman_to_int(roman) assert result == expected, f"{roman} → {result}, 期望 {expected}" # 逆向验证 back = int_to_roman(result) assert back == roman, f"{result} → {back}, 期望 {roman}" print(f" {roman} → {result} → {back} ✓") print("\n所有测试通过 ✓")
🔗 关联知识点
| |
|---|
| |
| i + 1 < len(s) |
| |
''.join(list) | |
题目六:正则表达式驱动的日志分析器 ⭐⭐⭐⭐
📌 题目描述
实现一个日志分析器,解析 Nginx 风格的访问日志,支持以下功能:
- 解析日志行:提取 IP、时间、请求方法、URL、状态码、响应大小
- 统计 TOP-N 访问 IP
- 统计各状态码出现次数
- 筛选指定时间段的请求
- 统计请求路径的访问频率
日志格式:192.168.1.1 - - [10/Oct/2025:13:55:36 +0800] "GET /api/users HTTP/1.1" 200 123410.0.0.1 - - [10/Oct/2025:13:55:37 +0800] "POST /api/login HTTP/1.1" 401 89192.168.1.1 - - [10/Oct/2025:14:00:00 +0800] "GET /api/users HTTP/1.1" 200 567
💡 编程思路
这道题综合考察正则表达式、字符串解析、字典统计、f-string 格式化:
日志正则:Nginx 日志格式固定,可以用一个精确的正则一次性提取所有字段:
^(\S+) \S+ \S+ \[([^\]]+)\] "(\w+) (\S+) \S+" (\d+) (\d+)' ) def __init__(self): self.entries: List[LogEntry] = [] def parse_line(self, line: str) -> Optional[LogEntry]: """解析单行日志""" match = self.LOG_PATTERN.match(line.strip()) if not match: return None return LogEntry(*match.groups()) def parse(self, log_text: str): """解析多行日志""" for line in log_text.strip().splitlines(): entry = self.parse_line(line) if entry: self.entries.append(entry) def top_ips(self, n: int = 10) -> List[Tuple[str, int]]: """统计 TOP-N 访问 IP""" ip_counter = Counter(entry.ip for entry in self.entries) return ip_counter.most_common(n) def status_distribution(self) -> Dict[int, int]: """状态码分布""" return dict(Counter(entry.status for entry in self.entries)) def top_urls(self, n: int = 10) -> List[Tuple[str, int]]: """统计 TOP-N 请求路径""" url_counter = Counter(entry.url for entry in self.entries) return url_counter.most_common(n) def filter_by_status(self, status: int) -> List[LogEntry]: """按状态码筛选""" return [e for e in self.entries if e.status == status] def filter_by_time(self, start: str, end: str) -> List[LogEntry]: """按时间段筛选(格式:'10/Oct/2025:13:00:00 +0800')""" start_time = datetime.strptime(start, "%d/%b/%Y:%H:%M:%S %z") end_time = datetime.strptime(end, "%d/%b/%Y:%H:%M:%S %z") return [e for e in self.entries if start_time <= e.time <= end_time] def generate_report(self) -> str: """生成分析报告""" top_ips = self.top_ips(5) status_dist = self.status_distribution() top_urls = self.top_urls(5) total_size = sum(e.size for e in self.entries) report = [] report.append("=" * 60) report.append(" Nginx 日志分析报告".center(58)) report.append("=" * 60) report.append(f"\n总请求数: {len(self.entries)}") report.append(f"总流量: {total_size / 1024:.1f} KB") report.append(f"\n--- TOP 5 访问 IP ---") for ip, count in top_ips: bar = "█" * min(count, 30) report.append(f" {ip:<18}{count:>5}{bar}") report.append(f"\n--- 状态码分布 ---") for status, count in sorted(status_dist.items()): label = {200: "OK", 301: "Redirect", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 500: "Server Error" }.get(status, "") report.append(f" {status}{label:<15}{count:>5}") report.append(f"\n--- TOP 5 请求路径 ---") for url, count in top_urls: report.append(f" {url:<30}{count:>5}") return '\n'.join(report)# 测试if __name__ == "__main__": log_text = """192.168.1.1 - - [10/Oct/2025:13:55:36 +0800] "GET /api/users HTTP/1.1" 200 123410.0.0.1 - - [10/Oct/2025:13:55:37 +0800] "POST /api/login HTTP/1.1" 401 89192.168.1.1 - - [10/Oct/2025:14:00:00 +0800] "GET /api/users HTTP/1.1" 200 567172.16.0.1 - - [10/Oct/2025:14:01:00 +0800] "GET /api/products HTTP/1.1" 200 234510.0.0.1 - - [10/Oct/2025:14:02:00 +0800] "GET /api/orders HTTP/1.1" 403 120192.168.1.1 - - [10/Oct/2025:14:03:00 +0800] "POST /api/users HTTP/1.1" 201 456172.16.0.1 - - [10/Oct/2025:14:05:00 +0800] "GET /api/products/1 HTTP/1.1" 200 89010.0.0.2 - - [10/Oct/2025:14:10:00 +0800] "GET /favicon.ico HTTP/1.1" 404 0""" analyzer = LogAnalyzer() analyzer.parse(log_text) # 打印报告 print(analyzer.generate_report()) # 按状态码筛选 print("\n--- 404 请求 ---") for entry in analyzer.filter_by_status(404): print(f" {entry.ip}{entry.method}{entry.url}") # 按时间筛选 print("\n--- 14:00-14:03 的请求 ---") for entry in analyzer.filter_by_time( "10/Oct/2025:14:00:00 +0800", "10/Oct/2025:14:03:00 +0800" ): print(f" {entry.time_str}{entry.ip}{entry.method}{entry.url}")
🔗 关联知识点
| |
|---|
| |
re.compile() | |
Counter.most_common() | |
datetime.strptime() | |
| |
dataclass | |
总结:知识点覆盖矩阵
| | |
|---|
| | isalnum() |
| | split vs split(' ')、join、正则分组分割 |
| | re.sub 分组替换 \1\2、re.findall、邮箱正则 |
| | re.sub |
| | |
| | 复合正则、Counter、datetime、f-string 报告 |
建议:按顺序从第 1 题做到第 6 题。前 3 题巩固基础方法,第 4-5 题锻炼综合应用能力,第 6 题是实战级项目,完整实现后你将掌握正则表达式和字符串处理的工程级用法。
延伸阅读
本文是《Python 全栈修炼之路》第 03 篇配套练习,欢迎点赞收藏!
- LeetCode 557. 反转字符串中的单词 III
- LeetCode 8. 字符串转换整数 (atoi)