ai-interview
CODE · 手撕

AI 面试手撕题集

两类题:A 类是 AI/ML 专属(Attention、LayerNorm 反传、NMS、KMeans、LoRA、GRPO advantage 等),B 类是 AI 面试官偏爱的 LeetCode 子集(LRU、滑窗、Top-K、蓄水池、字典树等)。所有代码都可跑,shape 都标清楚。

先说人话。手撕真正考的不是你能不能写出来,而是:边界处理(padding / causal mask)数值稳定性(softmax 减 max、log-sum-exp)shape 推导(哪一维是 batch、哪一维被归一)以及对底层数学的口述能力。代码跑通只是及格线,面试官会盯着你一边写一边问为什么。
目录

A. AI/ML 专属手撕

  1. 手写 Scaled Dot-Product Attention(numpy + mask)
  2. 手写 Multi-Head Attention(pytorch)
  3. 手写 LayerNorm forward + backward(numpy)
  4. 手写 BatchNorm forward(train / eval)
  5. 手写 NMS(非极大值抑制)
  6. 手写数值稳定 Softmax
  7. 手写 Cross Entropy Loss(带 label smoothing)
  8. 手写 Logistic Regression 训练循环(numpy SGD)
  9. 手写 KMeans
  10. 手写 LoRA Linear
  11. 手写最小 GPT forward
  12. 手写 RoPE 的应用
  13. 手写 Top-K / Top-P 采样
  14. 手写 GRPO 的 advantage + loss

B. LeetCode AI 子集

  1. LRU Cache(LC 146)
  2. 滑动窗口最大值(LC 239)
  3. Top K Frequent Elements(LC 347)
  4. Reservoir Sampling
  5. 搜索旋转排序数组(LC 33)
  6. 二叉树层序遍历(LC 102)
  7. KMP 字符串匹配
  8. Trie(前缀树)

A 类 · AI/ML 专属手撕

这类题是 AI 岗区别于普通 SDE 的核心。面试官要看的是:你能不能用 numpy/pytorch 把模型里最常见的那几块 block 徒手复现,并且在被追问"为什么减 max"、"为什么要 sqrt(d)"、"LayerNorm 反传里那个 mean 梯度项怎么来的"时能答上来。

1. 手写 Scaled Dot-Product Attention(numpy + mask) 高频 初级 numpy

给定 Q、K、V 和可选 mask,用 numpy 实现 softmax(QK^T/√d) V

题目

输入:Q shape [B, Lq, d]K shape [B, Lk, d]V shape [B, Lk, dv]mask shape [B, Lq, Lk]True 处被屏蔽)。输出 shape [B, Lq, dv]

思路
参考实现
import numpy as np

def softmax(x, axis=-1):
    x = x - np.max(x, axis=axis, keepdims=True)
    e = np.exp(x)
    return e / np.sum(e, axis=axis, keepdims=True)

def scaled_dot_product_attention(Q, K, V, mask=None):
    """
    Q: [B, Lq, d]
    K: [B, Lk, d]
    V: [B, Lk, dv]
    mask: [B, Lq, Lk] bool, True 处屏蔽 (例如 padding / causal)
    return: [B, Lq, dv], attn [B, Lq, Lk]
    """
    d = Q.shape[-1]
    scores = np.matmul(Q, np.swapaxes(K, -1, -2)) / np.sqrt(d)   # [B, Lq, Lk]
    if mask is not None:
        scores = np.where(mask, -1e9, scores)
    attn = softmax(scores, axis=-1)                               # [B, Lq, Lk]
    out = np.matmul(attn, V)                                      # [B, Lq, dv]
    return out, attn

if __name__ == "__main__":
    B, Lq, Lk, d, dv = 2, 3, 4, 8, 8
    Q = np.random.randn(B, Lq, d)
    K = np.random.randn(B, Lk, d)
    V = np.random.randn(B, Lk, dv)
    causal = np.triu(np.ones((Lq, Lk), dtype=bool), k=1)[None].repeat(B, axis=0)
    out, attn = scaled_dot_product_attention(Q, K, V, mask=causal)
    print(out.shape, attn.shape, attn[0].sum(-1))   # 每行应为 1
复杂度

时间 O(B · Lq · Lk · max(d, dv)),空间 O(B · Lq · Lk)(attn 矩阵)。FlashAttention 的核心就是不显式建出这个 Lq × Lk 矩阵。

常见追问
2. 手写 Multi-Head Attention(pytorch) 高频 中级 pytorch

用 pytorch 写一个 nn.Module 版的 MHA,支持 causal mask。

题目

输入 x shape [B, L, D]num_heads = hhead_dim = D / h。实现投影、split head、attention、concat、output projection 的整套流程。

思路
参考实现
import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads, dropout=0.0):
        super().__init__()
        assert d_model % num_heads == 0
        self.h = num_heads
        self.d_h = d_model // num_heads
        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.o = nn.Linear(d_model, d_model, bias=False)
        self.drop = nn.Dropout(dropout)

    def forward(self, x, mask=None):
        # x: [B, L, D]
        B, L, D = x.shape
        qkv = self.qkv(x)                                    # [B, L, 3D]
        qkv = qkv.reshape(B, L, 3, self.h, self.d_h)
        qkv = qkv.permute(2, 0, 3, 1, 4)                     # [3, B, h, L, d_h]
        q, k, v = qkv[0], qkv[1], qkv[2]

        scores = (q @ k.transpose(-1, -2)) / (self.d_h ** 0.5)  # [B, h, L, L]
        if mask is not None:
            # mask: [B, 1, L, L] or [1, 1, L, L], True = 屏蔽
            scores = scores.masked_fill(mask, float('-inf'))
        attn = F.softmax(scores, dim=-1)
        attn = self.drop(attn)
        out = attn @ v                                       # [B, h, L, d_h]
        out = out.transpose(1, 2).reshape(B, L, D)           # [B, L, D]
        return self.o(out)

def causal_mask(L, device):
    return torch.triu(torch.ones(L, L, dtype=torch.bool, device=device), diagonal=1)

if __name__ == "__main__":
    mha = MultiHeadAttention(d_model=64, num_heads=8)
    x = torch.randn(2, 10, 64)
    m = causal_mask(10, x.device)[None, None]   # [1, 1, L, L]
    y = mha(x, mask=m)
    print(y.shape)  # torch.Size([2, 10, 64])
复杂度

时间 O(B · h · L^2 · d_h) = O(B · L^2 · D),空间同量级。KV cache 推理时把 K/V 缓存下来,每步只算一行 Q。

常见追问
3. 手写 LayerNorm 的 forward + backward(numpy) 高频 中高 numpy 手动求导

只用 numpy 实现 LayerNorm,包括反向传播对 x、gamma、beta 的梯度。最容易翻车的题之一。

题目

输入 x shape [N, D],沿最后一维做归一化。输出 y = gamma * (x - μ) / sqrt(σ² + ε) + beta。给定 dy,手算 dx, dgamma, dbeta

思路
参考实现
import numpy as np

def layernorm_forward(x, gamma, beta, eps=1e-5):
    """
    x:     [N, D]
    gamma: [D]
    beta:  [D]
    """
    mu  = x.mean(axis=-1, keepdims=True)                 # [N, 1]
    var = x.var(axis=-1, keepdims=True)                  # [N, 1]
    std = np.sqrt(var + eps)                             # [N, 1]
    x_hat = (x - mu) / std                               # [N, D]
    y = gamma * x_hat + beta                             # [N, D]
    cache = (x_hat, gamma, std)
    return y, cache

def layernorm_backward(dy, cache):
    """
    dy: [N, D]
    return dx [N,D], dgamma [D], dbeta [D]
    """
    x_hat, gamma, std = cache
    N, D = dy.shape

    dbeta  = dy.sum(axis=0)                              # [D]
    dgamma = (dy * x_hat).sum(axis=0)                    # [D]

    # dL/d x_hat
    dx_hat = dy * gamma                                  # [N, D]

    # 沿 D 维的两个均值项 —— 面试重点
    mean_dxh      = dx_hat.mean(axis=-1, keepdims=True)           # [N,1]
    mean_dxh_xhat = (dx_hat * x_hat).mean(axis=-1, keepdims=True) # [N,1]

    dx = (dx_hat - mean_dxh - x_hat * mean_dxh_xhat) / std
    return dx, dgamma, dbeta


# 数值梯度检验
if __name__ == "__main__":
    np.random.seed(0)
    N, D = 4, 5
    x = np.random.randn(N, D)
    gamma = np.random.randn(D)
    beta  = np.random.randn(D)

    y, cache = layernorm_forward(x, gamma, beta)
    dy = np.random.randn(*y.shape)
    dx, dgamma, dbeta = layernorm_backward(dy, cache)

    # 数值梯度
    def f(xx):
        yy, _ = layernorm_forward(xx, gamma, beta)
        return (yy * dy).sum()

    h = 1e-5
    dx_num = np.zeros_like(x)
    for i in range(N):
        for j in range(D):
            x[i,j] += h; fp = f(x)
            x[i,j] -= 2*h; fm = f(x)
            x[i,j] += h
            dx_num[i,j] = (fp - fm) / (2*h)
    print("dx rel err:", np.abs(dx - dx_num).max() / (np.abs(dx_num).max() + 1e-9))
复杂度

forward / backward 都是 O(N · D)

常见追问
4. 手写 BatchNorm forward(区分 train / eval) 高频 中级 numpy

BN 的 forward 区分训练和推理两种模式,推理时用 running stats。

题目

输入 x shape [N, D]。训练阶段用当前 batch 的均值方差并更新 running stats;推理阶段直接用 running stats。

思路
参考实现
import numpy as np

class BatchNorm1d:
    def __init__(self, D, momentum=0.1, eps=1e-5):
        self.gamma = np.ones(D)
        self.beta  = np.zeros(D)
        self.running_mean = np.zeros(D)
        self.running_var  = np.ones(D)
        self.momentum = momentum
        self.eps = eps

    def __call__(self, x, training=True):
        # x: [N, D]
        if training:
            mu  = x.mean(axis=0)                             # [D]
            var = x.var(axis=0)                              # [D]
            x_hat = (x - mu) / np.sqrt(var + self.eps)
            # EMA 更新 (pytorch 风格 momentum: new = (1-m)*old + m*batch)
            self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * mu
            self.running_var  = (1 - self.momentum) * self.running_var  + self.momentum * var
        else:
            x_hat = (x - self.running_mean) / np.sqrt(self.running_var + self.eps)
        return self.gamma * x_hat + self.beta


if __name__ == "__main__":
    bn = BatchNorm1d(5)
    for _ in range(100):
        x = np.random.randn(32, 5) * 2 + 3
        bn(x, training=True)
    x_test = np.random.randn(4, 5) * 2 + 3
    y = bn(x_test, training=False)
    print("running_mean:", bn.running_mean)   # 应接近 3
    print("running_var:",  bn.running_var)    # 应接近 4
复杂度

O(N · D)

常见追问
5. 手写 NMS(非极大值抑制) 高频 中级 numpy CV

目标检测后处理。给一堆框和分数,去掉和高分框 IoU 过大的低分框。

题目

输入 boxes shape [N, 4]x1, y1, x2, y2),scores shape [N],阈值 iou_thresh。输出保留的下标列表。

思路
参考实现
import numpy as np

def nms(boxes, scores, iou_thresh=0.5):
    """
    boxes:  [N, 4]  (x1, y1, x2, y2)
    scores: [N]
    return: 保留的 indices (python list)
    """
    x1, y1, x2, y2 = boxes[:,0], boxes[:,1], boxes[:,2], boxes[:,3]
    areas = (x2 - x1) * (y2 - y1)                        # [N]
    order = scores.argsort()[::-1]                       # 分数从大到小

    keep = []
    while order.size > 0:
        i = order[0]
        keep.append(int(i))
        if order.size == 1:
            break
        rest = order[1:]

        # 相交矩形
        xx1 = np.maximum(x1[i], x1[rest])
        yy1 = np.maximum(y1[i], y1[rest])
        xx2 = np.minimum(x2[i], x2[rest])
        yy2 = np.minimum(y2[i], y2[rest])
        w = np.maximum(0.0, xx2 - xx1)
        h = np.maximum(0.0, yy2 - yy1)
        inter = w * h

        iou = inter / (areas[i] + areas[rest] - inter + 1e-9)
        order = rest[iou <= iou_thresh]                  # 留下 IoU 不高的
    return keep


if __name__ == "__main__":
    boxes = np.array([
        [10, 10, 50, 50],
        [12, 12, 52, 52],        # 和第 0 个高度重合
        [100, 100, 150, 150],
        [105, 105, 155, 155],    # 和第 2 个高度重合
    ], dtype=np.float32)
    scores = np.array([0.9, 0.8, 0.95, 0.7])
    print(nms(boxes, scores, 0.5))   # [2, 0]
复杂度

最坏 O(N^2)。GPU 上有 soft-nms、matrix nms 的并行版本。

常见追问
6. 手写数值稳定的 Softmax 高频 初级 numpy

十行内的题,但要能说清"为什么减 max"。

题目

输入任意 shape 的 xaxis,返回 softmax,不能因 exp 上溢出 inf。

思路
参考实现
import numpy as np

def softmax(x, axis=-1):
    x_max = np.max(x, axis=axis, keepdims=True)
    e = np.exp(x - x_max)
    return e / np.sum(e, axis=axis, keepdims=True)

def log_softmax(x, axis=-1):
    x_max = np.max(x, axis=axis, keepdims=True)
    shifted = x - x_max
    return shifted - np.log(np.sum(np.exp(shifted), axis=axis, keepdims=True))

if __name__ == "__main__":
    x = np.array([1000., 1001., 1002.])
    print(np.exp(x) / np.exp(x).sum())   # nan
    print(softmax(x))                    # [0.09, 0.244, 0.665]
    print(log_softmax(x))                # finite
复杂度

O(n)

常见追问
7. 手写 Cross Entropy Loss(带 label smoothing) 高频 中级 numpy

手写一版可选 label smoothing 的 CE,再把对 logits 的梯度一起给出来。

题目

输入 logits shape [N, C]targets shape [N](类别下标)。smoothing = ε:真实类概率变成 1 - ε,其他类平均分 ε/(C-1)

思路
参考实现
import numpy as np

def log_softmax(x, axis=-1):
    m = np.max(x, axis=axis, keepdims=True)
    s = x - m
    return s - np.log(np.exp(s).sum(axis=axis, keepdims=True))

def cross_entropy(logits, targets, smoothing=0.0):
    """
    logits:  [N, C]
    targets: [N] int
    return:  (loss: scalar, dlogits: [N, C])
    """
    N, C = logits.shape
    logp = log_softmax(logits, axis=-1)                 # [N, C]
    p    = np.exp(logp)

    # 构造平滑 target 分布
    if smoothing > 0:
        q = np.full((N, C), smoothing / (C - 1))
        q[np.arange(N), targets] = 1.0 - smoothing
    else:
        q = np.zeros((N, C))
        q[np.arange(N), targets] = 1.0

    loss = -(q * logp).sum() / N
    dlogits = (p - q) / N                               # 对 logits 的梯度
    return loss, dlogits


if __name__ == "__main__":
    np.random.seed(0)
    logits = np.random.randn(4, 5)
    targets = np.array([0, 2, 1, 4])
    loss, g = cross_entropy(logits, targets, smoothing=0.1)
    print(loss, g.shape)
复杂度

O(N · C)

常见追问
8. 手写 Logistic Regression 训练循环(numpy SGD) 经典 初级 numpy

完整的 forward、loss、grad、update 四件套,是所有手撕训练循环的 hello world。

题目

X shape [N, D]y shape [N] ∈ {0, 1},用 SGD + mini-batch 训一个二分类 LR。

思路
参考实现
import numpy as np

def sigmoid(z):
    # 稳定版
    return np.where(z >= 0, 1/(1+np.exp(-z)), np.exp(z)/(1+np.exp(z)))

def bce_with_logits(z, y):
    # numerically stable: log(1+exp(-|z|)) + max(z,0) - z*y
    return np.mean(np.logaddexp(0, -np.abs(z)) + np.maximum(z, 0) - z * y)

def fit_logreg(X, y, lr=0.1, epochs=50, batch_size=32, seed=0):
    rng = np.random.default_rng(seed)
    N, D = X.shape
    w = np.zeros(D)
    b = 0.0
    for ep in range(epochs):
        idx = rng.permutation(N)
        for s in range(0, N, batch_size):
            bi = idx[s:s+batch_size]
            xb, yb = X[bi], y[bi]
            z = xb @ w + b                                   # [B]
            p = sigmoid(z)
            grad_w = xb.T @ (p - yb) / len(bi)               # [D]
            grad_b = (p - yb).mean()
            w -= lr * grad_w
            b -= lr * grad_b
        if ep % 10 == 0:
            z_all = X @ w + b
            print(f"epoch {ep} loss={bce_with_logits(z_all, y):.4f}")
    return w, b


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    X = rng.standard_normal((200, 3))
    true_w = np.array([1.5, -2.0, 0.7])
    y = (X @ true_w + 0.3 + rng.standard_normal(200) * 0.1 > 0).astype(np.float64)
    w, b = fit_logreg(X, y, lr=0.2, epochs=30)
    print("w =", w, " b =", b)
复杂度

每个 epoch O(N · D)

常见追问
9. 手写 KMeans 经典 初级 numpy

无监督聚类的入门题,向量化实现。

题目

X shape [N, D]K,返回聚类中心和每个样本的标签。

思路
参考实现
import numpy as np

def kmeans(X, K, max_iter=100, tol=1e-4, seed=0):
    rng = np.random.default_rng(seed)
    N, D = X.shape
    # kmeans++ 初始化
    centers = np.empty((K, D))
    centers[0] = X[rng.integers(N)]
    for k in range(1, K):
        d2 = ((X[:, None, :] - centers[:k][None]) ** 2).sum(-1).min(1)  # [N]
        probs = d2 / d2.sum()
        centers[k] = X[rng.choice(N, p=probs)]

    for it in range(max_iter):
        # E 步:距离 [N, K]
        d2 = ((X[:, None, :] - centers[None, :, :]) ** 2).sum(-1)
        labels = d2.argmin(axis=1)

        # M 步
        new_centers = np.empty_like(centers)
        for k in range(K):
            mask = labels == k
            if mask.any():
                new_centers[k] = X[mask].mean(axis=0)
            else:
                new_centers[k] = X[rng.integers(N)]       # 空簇重采样

        shift = np.linalg.norm(new_centers - centers)
        centers = new_centers
        if shift < tol:
            break
    return centers, labels


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    X = np.vstack([
        rng.standard_normal((50, 2)) + np.array([0, 0]),
        rng.standard_normal((50, 2)) + np.array([5, 5]),
        rng.standard_normal((50, 2)) + np.array([0, 5]),
    ])
    c, lab = kmeans(X, 3)
    print(c)
复杂度

每次迭代 O(N · K · D),总共跑 T 次迭代。

常见追问
10. 手写 LoRA Linear 高频 中级 pytorch PEFT

给一个冻结的 nn.Linear,在外面套一对低秩矩阵 A、B,实现 y = W x + (α/r) · B A x

题目

包装一个 base: nn.Linear(in, out),加入可训练参数 A: [r, in]B: [out, r]。A 用 Kaiming 初始化,B 初始化为 0(保证训练开始时 LoRA 分支输出 0,不破坏原模型)。

思路
参考实现
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

class LoRALinear(nn.Module):
    def __init__(self, base: nn.Linear, r: int = 8, alpha: float = 16.0, dropout: float = 0.0):
        super().__init__()
        self.base = base
        for p in self.base.parameters():
            p.requires_grad = False

        in_f, out_f = base.in_features, base.out_features
        self.r = r
        self.scaling = alpha / r
        self.A = nn.Parameter(torch.empty(r, in_f))
        self.B = nn.Parameter(torch.zeros(out_f, r))
        nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
        self.drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()

    def forward(self, x):
        # x: [..., in_f]
        y = self.base(x)                              # 冻结主干
        lora = F.linear(self.drop(x), self.A)         # [..., r]
        lora = F.linear(lora, self.B)                 # [..., out_f]
        return y + self.scaling * lora

    @torch.no_grad()
    def merge_(self):
        """把 LoRA 合进 base.weight,之后可以当普通 Linear 用。"""
        delta = self.scaling * (self.B @ self.A)      # [out, in]
        self.base.weight.add_(delta)


if __name__ == "__main__":
    base = nn.Linear(16, 32)
    lora = LoRALinear(base, r=4, alpha=8)
    x = torch.randn(2, 16)
    # 初始化时 B=0,输出应等于 base(x)
    assert torch.allclose(lora(x), base(x))
    # 训几下 A/B 之后就不等了
    lora.A.data.normal_(); lora.B.data.normal_()
    y1 = lora(x)
    lora.merge_()
    y2 = base(x)
    print((y1 - y2).abs().max())   # ~0
复杂度

训练额外参数量 r · (in + out),对于 in = out = 4096, r = 8:65k vs 原 16M,节省 ~250×。

常见追问
11. 手写一个最小 GPT 的 forward 高频 高级 pytorch

embedding + N×(pre-norm → attention → residual → pre-norm → mlp → residual) → final norm → lm_head。跑得通。

题目

实现一个极简 GPT:vocab_size、n_layer、n_head、d_model、max_len,输入 token_ids shape [B, L],输出 logits shape [B, L, vocab]。causal self-attention。

思路
参考实现
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

class CausalSelfAttention(nn.Module):
    def __init__(self, d_model, n_head):
        super().__init__()
        assert d_model % n_head == 0
        self.h = n_head
        self.d_h = d_model // n_head
        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.o = nn.Linear(d_model, d_model, bias=False)

    def forward(self, x):
        B, L, D = x.shape
        qkv = self.qkv(x).reshape(B, L, 3, self.h, self.d_h).permute(2, 0, 3, 1, 4)
        q, k, v = qkv[0], qkv[1], qkv[2]                         # [B, h, L, d_h]
        att = (q @ k.transpose(-1, -2)) / math.sqrt(self.d_h)    # [B, h, L, L]
        mask = torch.triu(torch.ones(L, L, device=x.device, dtype=torch.bool), diagonal=1)
        att = att.masked_fill(mask, float('-inf'))
        att = F.softmax(att, dim=-1)
        out = (att @ v).transpose(1, 2).reshape(B, L, D)
        return self.o(out)

class MLP(nn.Module):
    def __init__(self, d_model, mult=4):
        super().__init__()
        self.fc1 = nn.Linear(d_model, mult * d_model)
        self.fc2 = nn.Linear(mult * d_model, d_model)

    def forward(self, x):
        return self.fc2(F.gelu(self.fc1(x)))

class Block(nn.Module):
    def __init__(self, d_model, n_head):
        super().__init__()
        self.ln1 = nn.LayerNorm(d_model)
        self.attn = CausalSelfAttention(d_model, n_head)
        self.ln2 = nn.LayerNorm(d_model)
        self.mlp = MLP(d_model)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.mlp(self.ln2(x))
        return x

class MiniGPT(nn.Module):
    def __init__(self, vocab, d_model=128, n_layer=4, n_head=4, max_len=256):
        super().__init__()
        self.tok = nn.Embedding(vocab, d_model)
        self.pos = nn.Embedding(max_len, d_model)
        self.blocks = nn.ModuleList([Block(d_model, n_head) for _ in range(n_layer)])
        self.ln_f = nn.LayerNorm(d_model)
        self.lm_head = nn.Linear(d_model, vocab, bias=False)
        # weight tying
        self.lm_head.weight = self.tok.weight

    def forward(self, ids):
        # ids: [B, L]
        B, L = ids.shape
        pos = torch.arange(L, device=ids.device)
        x = self.tok(ids) + self.pos(pos)[None]          # [B, L, D]
        for blk in self.blocks:
            x = blk(x)
        x = self.ln_f(x)
        return self.lm_head(x)                           # [B, L, vocab]


if __name__ == "__main__":
    m = MiniGPT(vocab=100, d_model=64, n_layer=2, n_head=4, max_len=16)
    ids = torch.randint(0, 100, (2, 12))
    logits = m(ids)
    print(logits.shape)   # torch.Size([2, 12, 100])
复杂度

forward O(L^2 · D · n_layer),MLP 项 O(L · D^2)。两者中哪个大取决于 L 和 D 的比。

常见追问
12. 手写 RoPE(Rotary Position Embedding)的应用 高频 中高 pytorch

给 Q、K,应用 RoPE 旋转(不改 V)。LLaMA / Qwen / DeepSeek 全系都用。

题目

Q、K shape [B, h, L, d_h]d_h 必须是偶数)。对每两个相邻维度当作复数,按位置 m 的角度 θ_i = 10000^(-2i/d_h) 旋转。

思路
参考实现
import torch

def build_rope_cache(seq_len, d_h, base=10000.0, device='cpu'):
    assert d_h % 2 == 0
    # [d_h/2]
    inv_freq = 1.0 / (base ** (torch.arange(0, d_h, 2, device=device).float() / d_h))
    t = torch.arange(seq_len, device=device).float()     # [L]
    freqs = torch.outer(t, inv_freq)                     # [L, d_h/2]
    cos = freqs.cos()[None, None]                        # [1, 1, L, d_h/2]
    sin = freqs.sin()[None, None]
    return cos, sin

def apply_rope(x, cos, sin):
    """
    x: [B, h, L, d_h]
    cos, sin: [1, 1, L, d_h/2]
    """
    B, h, L, d = x.shape
    x = x.reshape(B, h, L, d // 2, 2)
    x1, x2 = x[..., 0], x[..., 1]                        # [B, h, L, d/2]
    rot1 = x1 * cos - x2 * sin
    rot2 = x1 * sin + x2 * cos
    out = torch.stack([rot1, rot2], dim=-1).reshape(B, h, L, d)
    return out


if __name__ == "__main__":
    B, h, L, d_h = 2, 4, 8, 16
    q = torch.randn(B, h, L, d_h)
    k = torch.randn(B, h, L, d_h)
    cos, sin = build_rope_cache(L, d_h)
    q_rot = apply_rope(q, cos, sin)
    k_rot = apply_rope(k, cos, sin)
    print(q_rot.shape, k_rot.shape)
复杂度

O(B · h · L · d_h),cos/sin 可以预计算缓存。

常见追问
13. 手写 Top-K / Top-P (nucleus) 采样 高频 中级 pytorch inference

推理 sampling 三件套(temperature / top-k / top-p),写一个函数输入 logits 输出一个 token。

题目

输入 logits shape [B, V],参数 T, top_k, top_p,返回采样出的 ids shape [B]

思路
参考实现
import torch
import torch.nn.functional as F

def sample(logits, temperature=1.0, top_k=0, top_p=0.0):
    """
    logits: [B, V]
    return ids: [B]
    """
    logits = logits / max(temperature, 1e-6)

    # Top-K
    if top_k > 0:
        kth = torch.topk(logits, top_k, dim=-1).values[:, -1, None]  # [B, 1]
        logits = torch.where(logits < kth, torch.full_like(logits, float('-inf')), logits)

    # Top-P
    if 0.0 < top_p < 1.0:
        sorted_logits, sorted_idx = torch.sort(logits, descending=True, dim=-1)
        sorted_probs = F.softmax(sorted_logits, dim=-1)
        cum = sorted_probs.cumsum(dim=-1)
        # 要移除的 token:累积概率已超 p 的"之后"的位置
        remove_sorted = cum > top_p
        # 保证至少留一个(把最前面那个标为 False)
        remove_sorted[..., 0] = False
        # 把 sorted 空间的 mask 散回原始位置
        remove = torch.zeros_like(remove_sorted)
        remove.scatter_(dim=-1, index=sorted_idx, src=remove_sorted)
        logits = logits.masked_fill(remove, float('-inf'))

    probs = F.softmax(logits, dim=-1)
    ids = torch.multinomial(probs, num_samples=1).squeeze(-1)        # [B]
    return ids


if __name__ == "__main__":
    torch.manual_seed(0)
    logits = torch.randn(2, 10)
    print(sample(logits, temperature=0.8, top_k=5, top_p=0.9))
复杂度

top-k 用 partial sort O(V log k),top-p 需要完整排序 O(V log V)。大词表时 top-p 比 top-k 贵不少。

常见追问
14. 手写 GRPO 的 advantage + loss 2024 新热点 高级 RLHF pytorch

DeepSeek 提出、现在 R1 系列在用的 GRPO。核心:一个 prompt 采 G 个 rollout,组内用奖励均值做 baseline,不需要 critic。

题目

给定一个 batch:prompts B 条,每条采 G 个 rollout;rewards shape [B, G]logp(新策略)和 old_logpref_logp 都是 shape [B, G, T](T 是 response token 数),mask 同 shape。实现 advantage 和 GRPO loss(含 KL)。

思路
参考实现
import torch

def grpo_loss(logp, old_logp, ref_logp, rewards, mask,
              clip_eps=0.2, kl_coef=0.04):
    """
    logp, old_logp, ref_logp, mask: [B, G, T]
    rewards:                        [B, G]   (trajectory-level outcome reward)

    返回 scalar loss 和一些 logging dict。
    """
    B, G, T = logp.shape

    # 1) 组内标准化 advantage
    r_mean = rewards.mean(dim=1, keepdim=True)             # [B, 1]
    r_std  = rewards.std(dim=1, keepdim=True) + 1e-8       # [B, 1]
    adv    = (rewards - r_mean) / r_std                    # [B, G]
    adv    = adv.unsqueeze(-1).expand(-1, -1, T)           # [B, G, T]
    adv    = adv.detach()                                  # stop grad

    # 2) PPO clip surrogate
    ratio = torch.exp(logp - old_logp)                     # [B, G, T]
    unclipped = ratio * adv
    clipped   = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * adv
    pg_per_tok = -torch.minimum(unclipped, clipped)        # 最小化 -min

    # 3) KL (ref || policy), Schulman k3 无偏估计
    diff = ref_logp - logp
    kl_per_tok = torch.exp(diff) - diff - 1.0              # >= 0

    # 4) mask 平均(沿 T 有效 token,再 batch 平均)
    loss_per_tok = pg_per_tok + kl_coef * kl_per_tok
    denom = mask.sum().clamp(min=1.0)
    loss = (loss_per_tok * mask).sum() / denom

    with torch.no_grad():
        stats = {
            'pg_loss': (pg_per_tok * mask).sum() / denom,
            'kl':      (kl_per_tok * mask).sum() / denom,
            'adv_abs': adv.abs().mean(),
            'ratio':   (ratio * mask).sum() / denom,
        }
    return loss, stats


if __name__ == "__main__":
    torch.manual_seed(0)
    B, G, T = 2, 4, 6
    logp     = torch.randn(B, G, T, requires_grad=True)
    old_logp = logp.detach() + 0.01 * torch.randn_like(logp)
    ref_logp = logp.detach() + 0.05 * torch.randn_like(logp)
    rewards  = torch.randn(B, G)
    mask     = torch.ones(B, G, T); mask[:, :, -1] = 0      # 最后一个 token 被屏蔽
    loss, stats = grpo_loss(logp, old_logp, ref_logp, rewards, mask)
    loss.backward()
    print("loss:", loss.item(), "kl:", stats['kl'].item())
复杂度

O(B · G · T)。真正贵的是前面的 rollout + logp 计算,这部分 loss 本身是小钱。

常见追问

B 类 · LeetCode 的 AI 面试子集

不是所有 LC 题 AI 面试都爱考。下面这 8 道是面到吐的 —— 每道都能和 AI 系统里的真实场景挂上钩(KV cache、流式数据、tokenizer、检索等),这也是面试官喜欢它们的原因。

1. LRU Cache(LC 146) 高频 中级 哈希 + 双向链表

get / put 均 O(1) 的定容缓存。

为什么 AI 面试爱考

KV cache 管理、embedding cache、推理时 prefix cache 的淘汰策略本质都是 LRU 或其变种(LFU、ARC)。vLLM 的 block manager 就是这套思路。

思路
参考实现
class Node:
    __slots__ = ('key', 'val', 'prev', 'next')
    def __init__(self, k=0, v=0):
        self.key = k; self.val = v
        self.prev = None; self.next = None

class LRUCache:
    def __init__(self, capacity: int):
        self.cap = capacity
        self.map = {}
        self.head = Node(); self.tail = Node()          # 哨兵
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, n):
        n.prev.next = n.next
        n.next.prev = n.prev

    def _insert_front(self, n):
        n.next = self.head.next
        n.prev = self.head
        self.head.next.prev = n
        self.head.next = n

    def get(self, key):
        if key not in self.map:
            return -1
        n = self.map[key]
        self._remove(n); self._insert_front(n)
        return n.val

    def put(self, key, val):
        if key in self.map:
            n = self.map[key]
            n.val = val
            self._remove(n); self._insert_front(n)
            return
        if len(self.map) >= self.cap:
            lru = self.tail.prev
            self._remove(lru)
            del self.map[lru.key]
        n = Node(key, val)
        self._insert_front(n)
        self.map[key] = n


if __name__ == "__main__":
    c = LRUCache(2)
    c.put(1, 1); c.put(2, 2)
    print(c.get(1))     # 1
    c.put(3, 3)         # 淘汰 2
    print(c.get(2))     # -1
复杂度

get / put 均 O(1),空间 O(capacity)

常见追问
2. 滑动窗口最大值(LC 239) 高频 中级 单调队列

给数组和窗口大小 k,输出每个窗口的最大值。

为什么 AI 面试爱考

和流式数据、实时监控、moving max/min 特征工程直接相关;也是"在线算法 O(1) 均摊"的标志题。

思路
参考实现
from collections import deque

def max_sliding_window(nums, k):
    dq = deque()           # 存 index, 对应 nums[index] 单调递减
    res = []
    for i, x in enumerate(nums):
        while dq and nums[dq[-1]] <= x:
            dq.pop()
        dq.append(i)
        # 移除越界的队首
        if dq[0] <= i - k:
            dq.popleft()
        if i >= k - 1:
            res.append(nums[dq[0]])
    return res


if __name__ == "__main__":
    print(max_sliding_window([1,3,-1,-3,5,3,6,7], 3))
    # [3,3,5,5,6,7]
复杂度

时间 O(n)(每个下标进队、出队各一次),空间 O(k)

常见追问
3. Top K Frequent Elements(LC 347) 高频 中级 堆 / 桶排序

数组里出现次数最多的 k 个元素。

为什么 AI 面试爱考

推荐系统、召回 top-k、向量检索打分排序的基础;也能测你对堆的理解(小顶堆维护 top-k)。

思路
参考实现
from collections import Counter
import heapq

def top_k_heap(nums, k):
    cnt = Counter(nums)
    # nlargest 内部就是小顶堆 size=k
    return [x for x, _ in heapq.nlargest(k, cnt.items(), key=lambda it: it[1])]

def top_k_bucket(nums, k):
    cnt = Counter(nums)
    n = len(nums)
    buckets = [[] for _ in range(n + 1)]
    for x, c in cnt.items():
        buckets[c].append(x)
    res = []
    for c in range(n, 0, -1):
        res.extend(buckets[c])
        if len(res) >= k:
            return res[:k]
    return res


if __name__ == "__main__":
    print(top_k_heap([1,1,1,2,2,3], 2))      # [1, 2]
    print(top_k_bucket([1,1,1,2,2,3], 2))    # [1, 2]
复杂度

堆法 O(n log k);桶排序 O(n) 时间和空间。

常见追问
4. Reservoir Sampling(蓄水池采样) 高频 中级 流式算法

从一个长度未知的数据流里均匀随机采 k 个。

为什么 AI 面试爱考

大数据训练集采样、日志抽样、评估集构造全是这套;而且一问 probability 证明,考的就是你推导概率的基本功。

思路
参考实现
import random

def reservoir_sample(stream, k, seed=0):
    rng = random.Random(seed)
    pool = []
    for i, x in enumerate(stream):
        if i < k:
            pool.append(x)
        else:
            j = rng.randint(0, i)        # [0, i]
            if j < k:
                pool[j] = x
    return pool


if __name__ == "__main__":
    # 验证均匀性:采 1 个,重复 100k 次
    from collections import Counter
    c = Counter()
    for seed in range(100_000):
        out = reservoir_sample(range(10), 1, seed=seed)
        c[out[0]] += 1
    print(c)       # 每个数应接近 10000
复杂度

O(n) 时间,O(k) 空间。

常见追问
5. 搜索旋转排序数组(LC 33) 经典 中级 二分

有序数组在某处旋转过,找目标值。O(log n)

为什么 AI 面试爱考

二分的"变体意识" —— 很多 AI 系统题本质是"单调性 + 二分"(最长序列 / 学习率搜索 / context window 判定),这题是变体二分的入门。

思路
参考实现
def search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        if nums[lo] <= nums[mid]:       # 左半有序
            if nums[lo] <= target < nums[mid]:
                hi = mid - 1
            else:
                lo = mid + 1
        else:                           # 右半有序
            if nums[mid] < target <= nums[hi]:
                lo = mid + 1
            else:
                hi = mid - 1
    return -1


if __name__ == "__main__":
    print(search([4,5,6,7,0,1,2], 0))    # 4
    print(search([4,5,6,7,0,1,2], 3))    # -1
复杂度

O(log n)

常见追问
6. 二叉树层序遍历(LC 102) 经典 初级 BFS

按层输出二叉树节点值。

为什么 AI 面试爱考

BFS / DFS 的代表题,改几下就成 zigzag、右视图、最深层等一串变体。图的遍历是 GNN、依赖分析、计算图调度的基础。

思路
参考实现
from collections import deque

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val; self.left = left; self.right = right

def level_order(root):
    if not root: return []
    res = []
    q = deque([root])
    while q:
        sz = len(q)
        level = []
        for _ in range(sz):
            n = q.popleft()
            level.append(n.val)
            if n.left:  q.append(n.left)
            if n.right: q.append(n.right)
        res.append(level)
    return res


if __name__ == "__main__":
    #      3
    #     / \
    #    9   20
    #       /  \
    #      15   7
    root = TreeNode(3, TreeNode(9), TreeNode(20, TreeNode(15), TreeNode(7)))
    print(level_order(root))   # [[3], [9, 20], [15, 7]]
复杂度

时间空间均 O(n)

常见追问
7. KMP 字符串匹配 字节爱考 中高 字符串

在文本中找模式串第一次出现位置,O(n + m)

为什么 AI 面试爱考

tokenizer 的字典匹配、stopword 过滤、敏感词检测,很多时候绕不开 KMP/AC 自动机。字节那几个团队(搜索、风控、广告)反复考。

思路
参考实现
def build_fail(p):
    m = len(p)
    f = [0] * m
    k = 0
    for i in range(1, m):
        while k > 0 and p[k] != p[i]:
            k = f[k-1]
        if p[k] == p[i]:
            k += 1
        f[i] = k
    return f

def kmp_search(text, pattern):
    if not pattern:
        return 0
    f = build_fail(pattern)
    j = 0
    for i, ch in enumerate(text):
        while j > 0 and pattern[j] != ch:
            j = f[j-1]
        if pattern[j] == ch:
            j += 1
        if j == len(pattern):
            return i - j + 1      # 首次匹配起点
    return -1


if __name__ == "__main__":
    print(kmp_search("ababcabcabababd", "ababd"))   # 10
    print(build_fail("ababd"))                      # [0,0,1,2,0]
复杂度

O(n + m)

常见追问
8. Trie(前缀树) 高频 中级 字典树

支持 insert / search / startsWith。

为什么 AI 面试爱考

BPE / WordPiece 的 vocab 匹配、n-gram LM、自动补全、路由前缀匹配。tokenizer 里"最长前缀匹配"直接跑 trie。

思路
参考实现
class TrieNode:
    __slots__ = ('children', 'end')
    def __init__(self):
        self.children = {}
        self.end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word: str):
        node = self.root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]
        node.end = True

    def _walk(self, prefix):
        node = self.root
        for ch in prefix:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node

    def search(self, word: str) -> bool:
        n = self._walk(word)
        return n is not None and n.end

    def starts_with(self, prefix: str) -> bool:
        return self._walk(prefix) is not None

    def longest_prefix_match(self, s: str):
        """tokenizer 常见用法:返回 s 从头开始最长的在 trie 中的完整词的长度。"""
        node = self.root
        best = 0
        for i, ch in enumerate(s):
            if ch not in node.children:
                break
            node = node.children[ch]
            if node.end:
                best = i + 1
        return best


if __name__ == "__main__":
    t = Trie()
    for w in ["cat", "car", "care", "dog"]:
        t.insert(w)
    print(t.search("car"))          # True
    print(t.search("ca"))           # False
    print(t.starts_with("ca"))      # True
    print(t.longest_prefix_match("careful"))   # 4 ("care")
复杂度

insert / search / startsWith 均 O(len(word))。空间和所有 word 的总字符数成正比,可用 double array trie 压缩。

常见追问

白板写代码的 5 条建议

建议 1 · 先写函数签名和输入输出 shape。 不要一上来就写循环。先在顶上写 # Q: [B, L, d], K: [B, L, d], return: [B, L, d],这逼着你把 shape 先想清楚;面试官看到这个注释也会认为你"训练有素"。
建议 2 · 边写边口述。 每写两三行停一下,说一句"这里我除以 sqrt(d) 是因为..."、"这个 mask 我要广播到 [B, 1, L, L]"。你口述的是思路,而不是在念代码。很多时候思路对了、代码有小 bug,面试官会直接放过。
建议 3 · 留 TODO 标记不确定的地方。 碰到细节卡住(比如 LN backward 里那一项系数),写 # TODO: 确认 1/N 还是 1/(N-1) 先过,继续往下写。最后回来处理。卡住不动是手撕最大的死穴。
建议 4 · 主动写一个最小测试。 代码写完后加一段 if __name__ == "__main__":,构造小 shape 跑一下。能跑通给面试官看到 shape 对,是大大的加分项。跑不通也没事,面试官会和你一起 debug,这反而是互动的好机会。
建议 5 · 数值稳定性和边界一定点出来。 softmax 减 max、log 加 eps、mask 用 -1e9 还是 -inf、fp16 vs fp32、padding 和 causal 的 mask shape 区别 —— 这些是你能和普通 coder 拉开身位的地方。写到相关行时顺嘴带一句"这里如果 fp16 会怎样",面试官会给你记一笔。