
刷社区时看到一个帖子,挺感慨的。一位程序员的妻子说,老公税后月收入从2万多骤降到6千,而她自己有个去发达国家发展的机会,税后能有13K。家里还有个刚出生的宝宝,她问男同胞们,该怎么让老公好受一些。
这事儿吧,我觉得关键不在于“怎么安慰”,而在于“怎么一起扛”。如果真把彼此当成一家人,就别太计较眼前谁赚得多谁赚得少,而是一起算算长远的总账:未来的收入潜力、孩子的教育规划、夫妻的感情维系,哪个更重要?
想去国外发展,不见得就是抛下他。完全可以商量出一个节奏来:比如前期你先去冲一冲,他在国内稳住大后方,照看好孩子。双方约定好视频的频率和见面的时间表,让他清楚自己不是“被替代”了,而是家庭战略中的另一个重要角色,换了个方式在扛起这个家。
说到底,这是家庭角色的一次调整。只要目标是一家三口都过得更好,暂时的收入波动和角色变化,其实没那么可怕。对于正在经历类似困境的程序员家庭,坦诚沟通、共同规划比单纯的安慰更有力量。
算法题时间:电话号码的字母组合
聊完家常,切回我们的老本行。昨晚十一点多,我在公司楼下买宵夜,手机没电了,只好用前台的座机打电话点餐。一边按着数字键,我忽然想起一个经典的算法题:电话号码的字母组合。
这场景是不是特别有年代感?回想一下功能机时代,我们用数字键“2、3、4”来输入字母发短信。现在这道题就是把那个映射过程自动化了。
题目很简单:给你一个仅包含数字2-9的字符串,比如 "23",要求返回所有可能的字母组合。数字到字母的映射就和老式手机键盘一样(2对应abc,3对应def,以此类推)。那么 "23" 的答案就是:["ad","ae","af","bd","be","bf","cd","ce","cf"]。
仔细一想,这逻辑特别生活化:第一位数字你随便选一个字母,第二位再选一个,把所有可能性都列举一遍就行。听起来像暴力枚举?没错,但它其实是一个理解回溯(Backtracking) 或者说 深度优先搜索(DFS) 的绝佳入门题。
我常跟组里同事这么比喻:想象你在拨号,手指正停在第 i 个数字上。这一位你有几种字母可以选?每做出一个选择,你就“走”到下一位数字去,直到走完所有位数,把这条完整的“路径”记下来。然后,你退回上一位,换一个字母再重新走一遍。这个“走下去再退回来”的过程,就是回溯的精髓。
核心步骤就三件事:
- 准备好数字到字母串的映射字典。
- 准备一个列表(通常叫
path 或 combination)来记录“当前已经选了哪些字母”。
- 写一个递归函数
dfs(index),其中 index 表示当前正在处理输入字符串中的第几位数字。
直接上代码更直观(Python版本):
from typing import List
class Solution:
def letterCombinations(self, digits: str) -> List[str]:
# 处理空字符串的特殊情况
if not digits:
return []
# 数字到字母的映射表
phone = {
"2": "abc",
"3": "def",
"4": "ghi",
"5": "jkl",
"6": "mno",
"7": "pqrs",
"8": "tuv",
"9": "wxyz"
}
res = [] # 用于保存所有结果组合
path = [] # 用于记录当前路径(已选择的字母)
def dfs(index: int):
# 终止条件:如果index已经等于数字串长度,说明一条路径走完了
if index == len(digits):
res.append("".join(path)) # 将路径列表转为字符串存入结果
return
digit = digits[index] # 当前数字
letters = phone[digit] # 当前数字对应的所有可选字母
for ch in letters: # 遍历每一个可选字母
path.append(ch) # 做出选择,将字母加入路径
dfs(index + 1) # 递归,处理下一位数字
path.pop() # 撤销选择,回溯到上一步状态
dfs(0) # 从第0位数字开始递归
return res
这段代码清晰地展示了回溯的三层操作:
- “深度优先”往下走:通过
dfs(index + 1) 进入下一层递归。
- “记录路径”:
path 列表实时记录着从起点到当前节点的选择序列。
- “回退一步”:
path.pop() 至关重要,它撤销了当前选择,让状态恢复到上一层,以便进行下一个选择。很多初学者会忘记这一步,导致结果错误地累积。
复杂度方面心里有个数:假设输入有 n 个数字,每个数字平均对应 m 个字母(通常是3或4),那么结果组合总数是 O(m^n)。因为每个组合长度是 n,所以总体时间复杂度是 O(n * m^n)。面试时能轻松说出这个分析就够了。
有的同学对递归有点发怵,总觉得可能栈溢出。没问题,我们也可以用迭代(循环)的方式来解。思路就像是不断扩展一个前缀列表:
- 初始时,前缀列表里只有一个空字符串:
[""]。
- 遇到数字
"2",对应 "abc",就把列表中的每个前缀(目前只有空串)分别拼接上 a,b,c,得到新列表 ["a","b","c"]。
- 再遇到数字
"3",对应 "def",就把当前列表中的每个前缀("a","b","c")再分别拼接上 d,e,f,得到最终结果。
迭代版本的代码如下:
from typing import List
class Solution2:
def letterCombinations(self, digits: str) -> List[str]:
if not digits:
return []
phone = {
"2": "abc", "3": "def", "4": "ghi", "5": "jkl",
"6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"
}
res = [""] # 初始前缀列表:一个空字符串
for d in digits:
letters = phone[d]
new_res = [] # 用于存放本轮扩展后的新前缀
for prefix in res: # 遍历所有已有前缀
for ch in letters: # 遍历当前数字的所有字母
new_res.append(prefix + ch) # 拼接并加入新列表
res = new_res # 用新列表替换旧列表,进行下一轮扩展
return res
你可以把这个迭代过程理解为在不断计算“笛卡尔积”:上一轮的所有结果前缀,与当前数字的所有字母,两两组合生成新一轮的所有前缀。
两种写法,面试时选择你最有把握、思路最清晰的一种即可。但更重要的是,这道题是掌握回溯模板的“敲门砖”。后续像「组合总和」、「子集」、「全排列」这些经典难题,核心框架几乎一模一样,只是在细节上有些变化:
- 有的题目需要增加“剪枝”条件来提前终止无效分支。
- 有的题目
path 里存的是数字或别的对象。
- 有的题目需要先排序,以便在递归中跳过重复元素,避免结果集重复。
当你把“电话号码字母组合”这道题的回溯逻辑吃透了,再遇到同类型题目时,就会有种“噢,这不过是套模板”的熟悉感,解题效率自然大大提升。