先看两场面试和两张地图,再一处一处讲:哪里卡住了、正确的是什么、面试里要说哪几句。讲解用中文,代码用英文,代码的输出都是真跑出来的。
先把这四步练到不用想,再往上叠大文件、出错、软链接、改需求。门槛 1 的三道新题就是这么过的(10/25,28/28,约 48 分钟)。
from pathlib import Path
def largest_files(root, k):
root = Path(root)
pairs = []
for p in root.rglob("*"):
if p.is_symlink() or not p.is_file():
continue
try:
size = p.stat().st_size
except OSError:
continue
pairs.append((p.relative_to(root).as_posix(), size))
return sorted(pairs, key=lambda t: (-t[1], t[0]))[:k]
root = Path(root),装结果的 list 或 dict。
rglob("*") 或 os.walk;跳过软链接、不是文件的、.git。
大小、行数、哈希……读不了就 except OSError 跳过这个文件。
大的在前、同样大按名字:(-t[1], t[0]);路径用相对 root 的。
f = open("practice/drill/files/poem.txt", "rb")
f.seek(0, 2) # 46: the end. seek returns where it landed
f.seek(-6, 2) # 40: 6 bytes before the end
f.read() # b'sweet\n'
f.seek(0) # 0: same as f.seek(0, 0)
f.read(5) # b'roses', now at 5
f.seek(3, 1) # 8: 3 more from where we are
| 第二个数 | 从哪儿数 | 例子(poem.txt,46 个字节) |
|---|---|---|
| 0(不写就是 0) | 开头 | seek(40) → 40 |
| 1 | 现在的位置 | 在 5:seek(3, 1) → 8 |
| 2 | 末尾 | seek(0, 2) → 46 · seek(-6, 2) → 40 |
pos = f.seek(0, 2) 顺手拿到了文件大小。os.SEEK_END 就是 2。记不住,面试里可以问语法。practice/drill/out/zh.txt 里是“第一行\n第二行\n”:6 个字 × 3 个字节 + 2 个 \n = 20 个字节。seek、tell、pos 数的是字节,文本模式的 read(n) 数的是字。像物理里米和秒混着用。
块的边界是算出来的(每块 4096 个字节),可能正好切开一个字。所以用 "rb" 读字节,拿到整行以后再 decode。
10/28 实测:把 tail 改成文本模式,三行中文,块大小 3、4、6 都报错,4096 碰巧没事;纯英文永远测不出来。另外文本模式不让 seek(-6, 2)(UnsupportedOperation)。
notes.txt 是 28 个字节(三行:hello world、bye、hello again),每块 8 个字节,要最后 2 行。
| 轮 | pos | 这一块读到 | data(新读的拼在前面) | \n 个数 | 停吗 |
|---|---|---|---|---|---|
| 1 | 28 → 20 | b"o again\n" | b"o again\n" | 1 | 1 ≤ 2,接着读 |
| 2 | 20 → 12 | b"bye\nhell" | b"bye\nhello again\n" | 2 | 2 ≤ 2,接着读 |
| 3 | 12 → 4 | b"o world\n" | b"o world\nbye\nhello again\n" | 3 | 3 > 2,停 |
最后 splitlines()[-2:] → ['bye', 'hello again'](第 11 页)。开头 4 个字节 hell 一直没读。10 GB 的日志要最后 20 行也一样只读最后几块:try_tail 最后一行 read 4096 of 1088895 bytes。
def tail(path, n=10, block_size=4096):
if n <= 0:
return []
with open(path, "rb") as f:
pos = f.seek(0, 2)
data = b""
while pos > 0 and data.count(b"\n") <= n:
step = min(block_size, pos)
pos = pos - step
f.seek(pos)
data = f.read(step) + data
lines = data.splitlines()[-n:]
out = []
for line in lines:
out.append(line.decode("utf-8", errors="replace"))
return out
10/28 19:52 空文件凭记忆写,5 分钟,try_tail 七行全对,隐藏测试 15/15
准备:n ≤ 0 先返回([-0:] 是整个 list);"rb" 打开;跳到末尾,pos 就是文件大小;data 从空的 bytes 开始。
收尾:切成行、留最后 n 行(下一页);只对留下的行 decode。
怎么记住的:别抄,盖住了自己想每一行在回答哪个问题。隔了 4 小时再写,反而一次就对。
splitlines()[-n:] 是两件事:先切成行,再只留最后 n 行要最后 n 行,就得看到倒数第 n 行前面那个 \n:它标出这一行从哪开始。只有 n 个 \n 时,最前面那段可能只是半行。要 2 行,看到 3 个 \n 才停:bye 前面那个 \n 保证 bye 是完整的。
[-0:] 就是 [0:],整个 list。所以一开头先 return []。data = b"o world\nbye\nhello again\n" data.splitlines() # [b'o world', b'bye', b'hello again'] data.splitlines()[-2:] # [b'bye', b'hello again'] [10, 20, 30][-0:] # [10, 20, 30]: -0 is 0
是不是二进制读:是,"rb"。offset 是干什么的:记上次看到第几个字节。服务器又改了文件怎么办:这就是题目本身,每次只读新加的那段。
关键是第 ② 次:半行 GET /ab 不给,新 offset 停在它前面(30);第 ③ 次它写完了,整行 GET /about 才给出来。第 ⑤ 次文件只有 10 个字节,比 41 小,从 0 开始。
f.seek(24):跳过看过的 24 个字节。data = f.read():读到末尾,13 个字节 b"GET /\nGET /ab"。end = data.rfind(b"\n") = 5:最后一个 \n 在 data 里的位置。data[:end + 1] = 前 6 个字节,全是整行 → ['GET /']。end = 5 是 data 里的位置(从 0 数)。data 是从文件第 24 个字节开始的,所以那个 \n 在文件里是 24 + 5 = 29。新 offset 要在它后面:24 + 5 + 1 = 30。
def follow(path, offset):
with open(path, "rb") as f:
size = f.seek(0, 2) # the file size
if size < offset: # smaller: emptied or replaced
offset = 0 # so start over
f.seek(offset) # skip what was seen
data = f.read() # all new bytes, to the end
end = data.rfind(b"\n") # the last \n; -1 if none
if end == -1:
return [], offset # no complete line yet
lines = data[:end + 1].splitlines() # complete lines only
out = []
for line in lines:
out.append(line.decode("utf-8", errors="replace"))
return out, offset + end + 1 # right after that \n
这一份跑过:try_follow 七行全对,隐藏测试 13/13
def follow(path, offset):
with open(path, "rb") as f:
size = f.seek(0, 2)
if size < offset:
offset = 0
f.seek(offset)
data = f.read()
end = data.rfind(b"\n")
if end == -1:
return [], offset
lines = data[:end + 1].splitlines()
out = []
for line in lines:
out.append(line.decode("utf-8", errors="replace"))
return out, offset + end + 1
两个参数:文件路径,上次停在第几个字节。第一次传 0。你问过要不要默认值:不用,忘了传会直接报错,比悄悄从头再给一遍好。
"rb":offset 按字节数(第 8 页)。文件不存在时 open 自己抛 FileNotFoundError,题目就要它抛,不写 try。
跳到末尾,seek 返回跳到的位置 = 文件大小(第 7 页)。第 ② 次调用时 size 是 37。
文件比上次看到的位置还短:被清空了,或换成了新文件。旧位置没意义了,从 0 读。用 < 不用 <=:相等只是“没有新内容”。
跳到上次停下的地方,看过的不读。等于 f.seek(offset, 0);你写的 pos = f.seek(offset, 0) 也对,只是 pos 用不上。
不带数字的 read():从现在的位置读到末尾,拿到 bytes。第 ② 次是 13 个字节;没有新内容时是 b""。出了 with,文件自动关上。
def follow(path, offset):
with open(path, "rb") as f:
size = f.seek(0, 2)
if size < offset:
offset = 0
f.seek(offset)
data = f.read()
end = data.rfind(b"\n")
if end == -1:
return [], offset
lines = data[:end + 1].splitlines()
out = []
for line in lines:
out.append(line.decode("utf-8", errors="replace"))
return out, offset + end + 1
rfind 从右往左找最后一个 \n,返回它在 data 里的位置(从 0 数);一个都没有返回 -1。第 ② 次是 5。
没有一整行:最后那段还在写,先不给,offset 不动。刚被清空过的话,这时 offset 已经是 0,正好是题目要的 [] 和 0。
data[:end + 1] 是从头到最后那个 \n(包括它),全是整行,后面的半行不要。splitlines 在每个 \n 切开、去掉 \n;空行留着:b"a\n\nb\n" → [b'a', b'', b'b']。
字节变成字,只对整行做:整行不会把一个中文字切开。坏字节变成“�”,不报错:只是给 agent 看,不写回文件。
返回两样。data 从文件第 offset 个字节开始,那个 \n 在文件里是 offset + end,新 offset 在它后面再 +1。第 ② 次:24 + 5 + 1 = 30。
| 写成这样 | try_follow 里会看到 |
|---|---|
| 半行也给,新 offset 直接用文件大小 | 第 ② 次给出 'GET /ab',第 ③ 次给出 'out':一行被切成两截 |
| 最后忘了 +1 | offset 停在 \n 上,以后每次都多出一个空行 '' |
变小的判断写成 size <= offset | 第 ④ 次没有新内容(41 = 41),也当成变小,四行全部重给一遍 |
| 文件变小了不管 | 第 ⑤ 次从 41 读,读到 b"",'restarted' 一直拿不到 |
用 split(b"\n") 代替 splitlines() | 末尾多一个 b'',每次多给一个空行 |
| 每行 strip(),或用文本模式 "r" | 行首行尾的空格被吃掉;位置按字节、read 按字,中文会错位 |
答对了:一个人的改动会被另一个盖掉(图里 B 的改动被 A 盖掉)。办法说的是“写之前再查一次哈希”:查完到写回之间还是能插进来,只是空当小了。“查了再做”不是一步,这叫竞态(race condition)。
同一时间只让一个 edit 进去,后来的等着;轮到它时再算哈希,就会正确地报“文件改过了”。
给个大小上限,超了报一个说清楚的错。agent 改的是代码文件;几个 GB 的日志不该用查找替换来改。
一块一块读,一块一块写进临时文件;每块末尾留 len(old) − 1 个字符,接到下一块前面一起找,防止 old 跨在两块中间;出现第二次就删掉临时文件、报错;最后 os.replace 换过去。内存只要一块。
大小那层一个也筛不掉,每个文件都要算快速哈希;开头 4 KB 也都一样的话,最慢的就变成全量哈希。
数格子:d 里每个文件一个 Path,n 个,最大;quick、hashed 只装重复的;chunk 一块一块换,不随 n 变。实测一个 Path 放进 dict 约 290 字节:一千万个约 3 GB,存字符串约 1 GB。往下一层:扫两遍,第一遍只数每个大小有几个,第二遍只存大小重复的。
import os
from pathlib import Path
def atomic_write(path, text):
path = Path(path)
tmp = path.parent / (path.name + ".tmp")
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, path)
except Exception:
tmp.unlink(missing_ok=True)
raise
except Exception 接住任何常见的错;tmp.unlink(missing_ok=True) 删掉写了一半的临时文件,还没建出来也不报错;raise 把错原样扔出去,让调用的人知道没写成。那次还把 os.replace(tmp, path) 的逗号写成了句点。
示范在 practice/d08/merge_demo.py,会打印每一步。
最下面一层只是拆开摆好,不算一轮。
归并用空间换时间:多开 n 格,换来 n log n。
Timsort 是归并的改良版:先找出本来就排好的段(run),再合并。最坏还是 O(n log n);本来快排好的,一整段走一遍就认出来,接近 O(n)。
10/28 早上漏了 Timsort;晚上说成“接近 O(n)”,少了条件:只在本来快排好的时候。
我讲归并排序。两堆各自排好的数合成一堆很便宜,走一遍就行;所以先拆到每堆一个,再一层层两两合并。O(n log n),代价是多用 O(n) 内存。
一直对半分到每堆一个;合并两堆时比两堆最前面的数,小的放进新 list,那堆往后挪一个;一堆空了,另一堆剩下的直接接上;一层层合并到只剩一堆。
[5, 2, 7, 1] → [5] [2] [7] [1] → [2, 5] [1, 7] → 2 比 1 拿 1,2 比 7 拿 2,5 比 7 拿 5,接上 7 → [1, 2, 5, 7]。
每层每个数放下一次 = n 步;每轮堆大小翻倍,log n 轮 → O(n log n)。最直接的排法是 O(n²),100 万时 2,000 万步对 5,000 亿步。合并要新 list,额外 O(n)。Python 的 sorted 是 Timsort,归并的改良版,本来快排好的数据接近 O(n)。
空 list、只有一个数,直接返回;不管输入是乱的、排好的、倒着的,都是 n log n,不会变慢。
10/28 晚漏过的:例子;为什么是 log n 层(每轮翻倍);Timsort 只在本来快排好时才接近 O(n)。
| n | n²(理牌那种) | n · n!(试遍所有顺序) |
|---|---|---|
| 5 | 25 | 600 |
| 10 | 100 | 约 3,600 万 |
| 20 | 400 | 约 4.9 × 10¹⁹(两万多年) |
itertools.permutations。最慢、但还是对的排法,就是把所有顺序都试一遍,每试一种看一眼是不是从小到大。n 个数有 n! 种顺序,每种看一眼要 n 步 → O(n · n!),比 O(n²) 慢得多。
一种一种列出所有顺序;每种从头走到尾,相邻两个比一下,都是前面的小,就找到了,返回。
每个数轮流站第一个,剩下的用同样的办法列(函数调用自己,list 短一个);只剩一个或零个时直接返回,递归在这里停。[A, B, C]:A 站第一 → ABC、ACB;B 站第一 → BAC、BCA;C 站第一 → CAB、CBA。3 × 2 × 1 = 6 种。
n! 种 × n 步。n = 10 约 3,600 万步;n = 20 约 4.9 × 10¹⁹ 步,按 10/25 测的速度两万多年。n! 比 n 的任何次方都长得快。
随机洗牌,洗到排好为止。平均要洗约 n! 次,而且没有上限,运气差可能永远排不完。真要列排列,Python 自带 itertools.permutations。
每组从左到右是第 1、2、3、4 名。橙色是 A 店的两杯,蓝色是 B 店的两杯;绿底是 A 店两杯正好占了前两名。
16 是按“可以重复”数的。排名次每杯只占一个名次,不能重复 → 4! = 24。先问自己:同一个东西能用几次?
10/28 Mercor M1,你答:不知道
推:拿 16 个数。一个一堆 → 两个 → 四个 → 八个 → 十六个,翻了 4 次;2⁴ = 16,所以 4 = log₂ 16。每一轮每个数放下一次,16 步。一共 16 × 4。
10/27 edit_file 取舍,你答:不知道
推:整个读进来一份 2 GB;partition 切开又一份;拼起来再一份,约 6 GB。太大了 → 先给个上限;真要支持,就一块一块读写。
① 我没直接做过,我推一下 → ② 拿一个小例子出声走 → ③ 推不动了,就说卡在哪一步。面试官要看的是你怎么想;一句“不知道”,这一问就结束了。
你告诉我答案 我加深正确的记忆 不想加深错误的记忆
练习时:看五步范例 → 关掉 → 用英文讲 → 对着范例说漏了哪几点。mock 和门槛里不给。
spacex 会考的这么细没
跟面试一样:help(),或者问 Claude,只答语法,不给思路和代码结构。要记的是思路:每一行在回答哪个问题。
我感觉我们开的threads太多了
一条 SpaceXSI、一条 Mercor。一条收尾了(不看提示写出来或讲出来)才开下一条。
tail 就是这么过的:看着抄了三遍,“进不了脑子” → 停了 4 小时 → 空文件凭记忆写,5 分钟,15/15。
这份 PPT 里的代码是我给的,不算你写出来的。照 tail 的办法:关掉 PPT,在空文件里凭记忆写 follow,跑 try_follow,七行都对上再说“写完”。
过了再改一次需求、问一个取舍。卡住了,先说卡在五个问题的哪一个。
用英文讲一遍归并:合并怎么做、一个例子、n 和 log n 从哪来(每轮翻倍)、空间从哪来(新 list)、Timsort(本来快排好时才接近 O(n),最坏还是 n log n)。
先睡够。