编程 (Python / C++)

这一页覆盖 Quant Research 面试里最常被追问的编程八股:Python 的数据结构底层、内存模型、向量化与并发,以及 C++ 的内存语义、多态与零开销抽象,最后落到 NumPy 数值计算。每题都配「🔬 深度拓展」——讲清楚底层怎么跑、为什么快/慢、容易踩的坑,并附一段「面试追问演练」。适合大一新生打地基,也适合求职者临阵磨枪。

Python 基础与数据处理 (Q15–Q19)

Q15Python 中 list、tuple、dict、set 的区别与适用场景?
🌱 大白话先懂

这四个都是装东西的「盒子」。list 是一排能随时改的格子(有序、可变);tuple 像贴了封条的格子,定了就不能改;dict 是字典,给个名字(key)直接翻到对应的值,查得飞快;set 是一份不重复的名单,专门用来去重。要「按名字秒查」用 dict,要「去重」用 set。

标准答案

类型可变性有序性查找复杂度量化场景
list可变有序$O(n)$时间序列存储、策略信号列表
tuple不可变有序$O(n)$作为 dict 的 key(如 (date, ticker))、函数多返回值
dict可变有序(3.7+)$O(1)$ticker→price 映射、参数配置
set可变无序$O(1)$股票池去重、集合运算(交集并集求共同持仓)

面试高频追问:dict 的底层实现是哈希表,冲突解决用开放寻址法(CPython);set 也是哈希表。

🔬 深度拓展:哈希表底层、冲突解决与 $O(1)$ 均摊的来历

哈希表是什么。dict/set 本质是一个数组(桶 buckets),插入时先算 h = hash(key),再用 h % capacity 映射到某个桶。读写都靠这一步直接定位,所以平均 $O(1)$,而 list 的 x in lst 要逐个比较,是 $O(n)$。这就是「股票池去重用 set 而不是 list」的根本原因。

冲突解决:两大流派。

方法思想代表
链地址法 (chaining)每个桶挂一个链表/数组,冲突的 key 串起来Java HashMap、C++ unordered_map
开放寻址 (open addressing)冲突了就按探测序列往后找下一个空桶CPython 的 dict/set

CPython 用开放寻址 + 伪随机探测。冲突时下一个槽位由这段经典逻辑决定(概念版):

perturb = hash
j = hash & mask                 # 初始槽位
while slot[j] is occupied and key mismatch:
    perturb >>= 5
    j = (5 * j + 1 + perturb) & mask   # 探测下一个槽
# 既用低位也逐步混入高位,避免聚集成团(clustering)

开放寻址对缓存友好(数据都在一块连续数组里),但要求负载因子不能太高。CPython 在装填到约 2/3 时触发 resize(扩容并 rehash),保证探测链不会太长——这正是「均摊 $O(1)$」的代价:偶尔一次插入要 $O(n)$ 搬迁,但摊到每次操作仍是常数。

为什么 key 必须可哈希(hashable)。哈希表要求 key 的 hash() 稳定、且相等的 key 哈希值相同(a == b ⇒ hash(a) == hash(b))。list 可变、内容一变哈希就失效,所以 list 不能当 key;tuple 不可变所以可以。这就是量化里常用 (date, ticker) tuple 当复合 key 的原因。

⚠️ 坑:哈希值可被定制,会破坏不变量

如果你自定义类只重写了 __eq__ 没重写 __hash__,两个「相等」的对象可能落到不同桶里,set 去重失效。规则:要么都重写,要么把对象设计成不可变。

Python 3.7+ dict 为什么有序。CPython 用了「紧凑 dict」:一个按插入顺序排列的稠密 entries 数组 + 一个只存索引的稀疏哈希表。遍历走 entries 数组所以保序,查找仍走哈希表所以 $O(1)$,还顺带省内存。

🎤 面试追问演练

问:「dict 的 $O(1)$ 是最坏情况吗?什么时候会退化?」

理想回答:不是最坏情况,是均摊 + 平均。最坏情况下所有 key 哈希到同一探测链,退化成 $O(n)$。现实中如果攻击者构造大量哈希碰撞的 key(哈希洪水攻击),就能把 dict 拖慢——CPython 对 str 的哈希加了随机 seed(PYTHONHASHSEED)来缓解。量化里更现实的退化是「负载因子太高没及时扩容」或「自定义对象哈希函数写得烂、分布不均」。

Q16深拷贝和浅拷贝的区别?在量化中踩坑的例子?
🌱 大白话先懂

复印一本书有两种做法。浅拷贝像只抄了「书放在哪个书架」的地址——你和别人翻的还是同一本,他画一笔你的也花了。深拷贝才是真的整本重新印一份,从此互不影响。量化里多个策略不小心共用了同一份持仓数据、改一个全乱套,就是这个坑。

标准答案

  • 浅拷贝copy.copy()list.copy()):创建新对象,但内部元素仍引用原对象。
  • 深拷贝copy.deepcopy()):递归复制所有层级的对象。

量化踩坑:

# 危险:portfolio_b 和 portfolio_a 共享内部列表
portfolio_a = {'holdings': [{'ticker': 'AAPL', 'weight': 0.5}]}
portfolio_b = portfolio_a.copy()       # 浅拷贝
portfolio_b['holdings'][0]['weight'] = 0.3  # portfolio_a 也被修改了!

回测中修改了一个策略的持仓,另一个策略的持仓也被污染——这类 bug 极其隐蔽。

🔬 深度拓展:引用模型、numpy/pandas 的 view vs copy 与一次真实的回测污染 bug

先建立心智模型。Python 变量是「贴在对象上的标签」。b = a 不复制任何东西,只是再贴一张标签到同一个对象。浅拷贝复制「最外层容器」,但容器里装的还是指向旧对象的引用;深拷贝沿着引用图递归到底,全部重建。

import copy
a = [[1, 2], [3, 4]]
b = copy.copy(a)        # 浅:b 是新 list,但 b[0] is a[0] -> True
c = copy.deepcopy(a)    # 深:c[0] is a[0] -> False
b[0].append(99)         # a 也变了,因为共享内层 list
print(a)                # [[1, 2, 99], [3, 4]]

numpy/pandas 里同样的陷阱,但名字叫 view(视图)。切片一个 ndarray 默认返回 view——和原数组共享同一块内存缓冲区,改 view 等于改原数组。这在性能上是优点(零拷贝),但当成「副本」用就是 bug。

import numpy as np
prices = np.arange(10.0)
window = prices[2:5]     # view,不是副本
window[:] = 0            # prices[2:5] 也被清零了!
print(prices)            # [0 1 0 0 0 5 6 7 8 9]

safe = prices[2:5].copy()  # 显式 copy 才安全
# 判断是否共享内存:
np.shares_memory(prices, window)  # True
np.shares_memory(prices, safe)    # False
🧪 一次真实的回测污染 bug 走查

场景:你有一个基准持仓 base_weights(ndarray),跑参数网格搜索,每组参数在基准上叠加一点扰动后回测。

# ❌ Bug 版本
base_weights = np.array([0.2, 0.3, 0.5])
results = []
for shock in [0.01, 0.02, 0.03]:
    w = base_weights              # 只是别名!没复制
    w[0] += shock                 # 直接改了 base_weights
    results.append(backtest(w))
# 第 2、3 轮的 base_weights 已被前面累计污染,
# 实际跑的扰动是 0.01, 0.03(=0.01+0.02), 0.06(=...),全错。
# ✅ 修复:每轮独立副本
for shock in [0.01, 0.02, 0.03]:
    w = base_weights.copy()
    w[0] += shock
    results.append(backtest(w))

这种 bug 的可怕之处:结果看起来「合理」(曲线平滑、夏普像那么回事),不会报错,只有当你手算对一组数才发现扰动量串了。在生产里可能上线后才暴露。

性能权衡。深拷贝一个大 DataFrame 是真实成本。回测引擎常用「写时复制」思路:只在真正要写的那一刻才 copy(pandas 2.0+ 的 Copy-on-Write 模式 pd.options.mode.copy_on_write = True 就是把这件事系统化,从此切片读永远安全、写自动触发复制)。

🎤 面试追问演练

问:deepcopy 遇到循环引用会死循环吗?还有什么开销?」

理想回答:不会死循环。deepcopy 内部维护一个 memo 字典记录「已复制过的对象 id → 新对象」,循环引用时第二次遇到同一对象直接返回 memo 里的副本。开销有两块:递归遍历整张对象图的时间,以及 memo 字典的内存。对超大嵌套结构,deepcopy 可能比你想的慢很多,热路径上要么换 view+CoW,要么自己写浅层手工复制。

Q17Pandas 中 applymap、向量化运算的性能差异?
🌱 大白话先懂

同样给一万个数都乘以 2,有两种干法。向量化是把一整袋米倒进机器一次磨完(底层用 C 批量算);apply 和循环是一粒一粒手工喂(每行都回到 Python 慢慢算)。数据一大,前者能快几十上百倍。一句话:能整批算就别一行行抠。

标准答案

性能排序(快→慢):NumPy 向量化 > Pandas 内置方法 > apply > 纯 Python 循环

# 最快:向量化
df['ret'] = df['close'].pct_change()

# 较快:内置方法
df['log_ret'] = np.log(df['close'] / df['close'].shift(1))

# 慢:apply(每行调用一次Python函数)
df['signal'] = df.apply(lambda row: my_func(row['a'], row['b']), axis=1)

# 最慢:iterrows
for idx, row in df.iterrows(): ...

原则:能向量化就不用 apply,能用内置方法就不自己写。处理大型 tick 数据时,性能差异可达 100 倍以上。

进阶:对无法向量化的复杂逻辑,考虑用 numba.jit 加速或用 polars 替代 Pandas。

🔬 深度拓展:apply 为什么慢?向量化为什么快?numba / polars 替代方案

apply 慢的本质:它是「披着糖衣的 Python 循环」。df.apply(func, axis=1) 会对每一行:(1) 把这行打包成一个 Series 对象(构造对象有开销);(2) 回到 Python 解释器调用一次 func;(3) 每次属性访问 row['a'] 又走一遍 Python 的 __getitem__ 派发。$n$ 行就有 $n$ 次解释器往返,每次都付「装箱→Python 字节码→拆箱」的税。

向量化快的本质:把循环下沉到 C 层,外加批量内存访问。df['close'].pct_change() 背后是一整块连续的 float64 缓冲区,循环写在编译好的 C 里跑,没有解释器往返、没有逐元素装箱成 Python float 对象。再加上 CPU 能用 SIMD 一次处理多个元素、缓存命中率高,所以快几十上百倍。

写法循环在哪每元素是否装箱成 Py 对象相对速度
NumPy 向量化C 层1×(基准)
Pandas 内置 (.shift/.rolling)C/Cython 层~1–2×
apply(axis=1)Python 层是(每行一个 Series)~50–200×
iterrowsPython 层是(每行 Series + 拆列)~100–500×

当逻辑真的无法向量化时(比如带状态的路径依赖、复杂分支),有两条路:

numba.jit:把热点函数 JIT 编译成机器码,对 ndarray 友好。注意要传 numpy 数组而不是 Series,且首次调用有编译开销。

from numba import njit
import numpy as np

@njit                      # 编译成机器码,循环以 C 速度跑
def trailing_stop(prices, drop):
    n = len(prices)
    out = np.empty(n, dtype=np.bool_)
    peak = prices[0]
    for i in range(n):
        peak = max(peak, prices[i])
        out[i] = prices[i] < peak * (1 - drop)   # 带状态、难向量化
    return out

signal = trailing_stop(df['close'].to_numpy(), 0.05)

polars:基于 Rust + Arrow 的 DataFrame,查询惰性优化、多线程、列式存储。它的表达式 API 鼓励你写向量化表达式,且原生并行,常比 pandas 快数倍且内存更省。

import polars as pl
df = pl.read_parquet("ticks.parquet")
out = (df.lazy()
         .with_columns((pl.col("close").pct_change()).alias("ret"))
         .filter(pl.col("ret") > 0.01)
         .collect())        # collect 时才真正执行,查询计划被优化
✅ 面试提示

被问「怎么加速这段 apply」时,标准答题路径是:先尝试改写成内置向量化 → 不行就 numba/Cython → 数据量大且 IO 重就上 polars/Dask。能说出「apply 慢是因为每行一次 Python 解释器往返 + Series 装箱」就赢了大半。

🎤 面试追问演练

问:df.apply(func, axis=0)(按列)和 axis=1(按行)性能为什么差很多?」

理想回答:axis=0 只对每调用一次 func,列数通常很少(几十几百),且每次拿到的是一整列向量、可以再向量化,所以快。axis=1 对每调用一次,行数往往是百万级,每次还要把异质 dtype 的一行打包成 Series,开销爆炸。所以 axis=1 的 apply 是性能红线,能避就避。

Q18Python GIL 是什么?如何在量化中实现真正的并行?
🌱 大白话先懂

GIL 像厨房里只有一把炒锅:你雇再多厨师(线程),同一刻也只能一人用锅,纯靠算力的活(CPU 密集)多线程根本快不了。想真正同时开火,就开几个独立厨房(多进程)。但如果大家多半时间在等外卖(等网络、读文件),轮流用一把锅也够,这时多线程/async 反而合适。

标准答案

GIL(全局解释器锁)使得 CPython 中同一时刻只有一个线程执行 Python 字节码。

影响:CPU 密集型任务(如大规模因子计算)无法通过多线程加速。

解决方案:

方案适用场景工具
多进程CPU 密集型因子计算multiprocessing, joblib, ProcessPoolExecutor
多线程IO 密集型(数据下载、API 请求)threading, asyncio
向量化/C 扩展数值计算NumPy, Numba, Cython
分布式计算超大规模回测Dask, Ray, Spark
🔬 深度拓展:GIL 到底锁的是什么?引用计数、CPU-bound 用多进程、IO-bound 用 async 的原理

GIL 锁的是「解释器状态」,不是你的业务对象。它是一把全局互斥锁,保证同一时刻只有一个线程在执行 CPython 字节码、操作解释器内部数据结构。最核心的被保护对象是引用计数

为什么引用计数需要 GIL。CPython 靠引用计数管理内存:每个对象有个 ob_refcnt,被引用 +1,引用消失 -1,归零就回收。refcnt += 1 这种操作在多线程下是非原子的(读-改-写三步)。两个线程同时增减同一对象的计数,就会丢更新,导致对象被提前释放(崩溃)或永不释放(泄漏)。GIL 用一把大锁简单粗暴地序列化了所有字节码执行,从而免费保证了引用计数安全。代价就是真并行没了。

所以 CPU-bound 必须用多进程。多线程下,N 个线程抢一把 GIL,CPU 密集任务等于排队单线程跑,4 核也只用 1 核。多进程则每个进程有独立的解释器和独立的 GIL,真正吃满多核。

from concurrent.futures import ProcessPoolExecutor
import numpy as np

def compute_factor(chunk):          # CPU 密集:纯计算
    return (chunk - chunk.mean()) / chunk.std()

chunks = np.array_split(big_returns, 8)
with ProcessPoolExecutor(max_workers=8) as ex:   # 8 进程 = 8 个 GIL
    results = list(ex.map(compute_factor, chunks))
⚠️ 多进程的隐藏成本

进程间不共享内存,参数和返回值要序列化(pickle)跨进程传输。如果每个任务要传几百 MB 的 DataFrame,序列化开销可能吃掉并行收益。对策:用共享内存(multiprocessing.shared_memory / numpy memmap)、或让子进程自己从磁盘读、或换 Ray(有更高效的对象存储)。

为什么 IO-bound 用多线程 / async 反而合适。关键事实:当线程陷入 IO 等待(网络请求、读盘)时,CPython 会主动释放 GIL,让别的线程跑。所以下载 500 只股票行情这种「大部分时间在等网络」的任务,多线程能重叠等待时间,效果很好。

# IO 密集:用 asyncio 单线程并发上千请求,无 GIL 争用、无线程切换开销
import asyncio, aiohttp

async def fetch(session, url):
    async with session.get(url) as r:   # await 时让出控制权
        return await r.json()

async def main(urls):
    async with aiohttp.ClientSession() as s:
        return await asyncio.gather(*[fetch(s, u) for u in urls])

asyncio 是单线程协作式并发:遇到 await 就把控制权交回事件循环去跑别的协程,没有线程切换和锁竞争,比多线程更轻,适合海量并发 IO。

NumPy 为什么能「绕过」GIL。NumPy 的重计算在 C 层做,很多函数在进入纯 C 计算时会释放 GIL,所以即便在多线程里,底层 BLAS(如矩阵乘)也能多核并行。这就是「向量化 + 多线程」对数值计算依然有效的原因。

🎤 面试追问演练

问:「Python 3.13 的 no-GIL(free-threaded)模式出来了,GIL 这道题以后是不是没用了?」

理想回答:方向上 no-GIL 让多线程能真正跑满多核,但它不是免费的:去掉 GIL 后引用计数要改成原子操作或有偏向锁,单线程性能有回退,且大量 C 扩展需要适配才线程安全。现阶段它是可选实验特性,生产里多进程 + 向量化仍是主力。理解 GIL 的存在原因(保护引用计数)比记结论更重要——这正好解释了 no-GIL 为什么这么难做。

Q19如何避免 Pandas 中的 "SettingWithCopyWarning"?
🌱 大白话先懂

连着写两个方括号去改数据时,pandas 可能先递给你一份「复印件」,你在复印件上改,原表纹丝不动——白改一场还不报错。解决办法是用 .loc[行, 列] 一步到位,明确告诉它「我就是要改原表这几行这几列」。

标准答案

这个警告出现在对 DataFrame 切片的赋值操作中,因为切片可能返回视图(view)而非副本(copy),赋值行为不确定。

# 危险写法:链式索引
df[df['sector'] == 'tech']['weight'] = 0.5  # 可能无效

# 正确写法1:.loc
df.loc[df['sector'] == 'tech', 'weight'] = 0.5

# 正确写法2:显式copy
subset = df[df['sector'] == 'tech'].copy()
subset['weight'] = 0.5
🔬 深度拓展:链式索引的机制、view/copy 的不确定性,与一次到位的修法

警告的根源是「链式索引」(chained indexing)。df[mask]['weight'] = 0.5 实际上是两步:

tmp = df[mask]        # 第①步:__getitem__,返回 view 还是 copy 不确定
tmp['weight'] = 0.5   # 第②步:__setitem__,写到 tmp 上

问题在于第①步的返回值到底是原数据的视图还是一份副本,取决于内存布局和 dtype,pandas 自己都无法静态保证。如果是 copy,第②步改的是临时对象、原 df 纹丝不动(赋值「丢了」);如果是 view,原 df 才会被改。结果不确定 → pandas 抛 SettingWithCopyWarning 提醒你「你可能在改一个副本,且我说不准」。

为什么 .loc 能根治。df.loc[mask, 'weight'] = 0.5单次索引操作:行选择和列选择在一个 __setitem__ 调用里完成,pandas 明确知道你要写回原 df,路径唯一、行为确定,所以既正确又不警告。

写法是否链式结果
df[mask]['w'] = x是(两次 [])⚠️ 不确定,常常无效
df.loc[mask, 'w'] = x否(单次 .loc)✅ 确定写回原 df
sub = df[mask].copy(); sub['w'] = x✅ 明确操作独立副本
🧭 决策口诀

「我想改原表」→ 用 .loc[行, 列] 一次性写。
「我想要一份独立子集再改、不影响原表」→ 先 .copy() 再改。
永远不要把两个方括号连写来赋值。

量化里的真实代价。常见 bug:在回测函数里 sub = df[df.date == today] 然后 sub['signal'] = ...,你以为信号写回了主表,其实写进了一个副本,主表里 signal 全是 NaN,回测「无脑空仓」也不报错。pandas 2.0 的 Copy-on-Write(pd.options.mode.copy_on_write = True)彻底消除了这种歧义:切片读永远是逻辑副本,链式赋值要么明确生效要么明确报错,未来这会成为默认行为。

🎤 面试追问演练

问:.loc.iloc 在这个问题上有区别吗?什么时候用哪个?」

理想回答:两者都是单次索引、都能安全写回,区别只是定位方式:.loc标签(label,包括布尔掩码、行名/列名),.iloc整数位置。量化里按条件筛选(如 sector==tech、date 区间)几乎都用 .loc + 布尔掩码;只有在明确按「第几行第几列」操作时才用 .iloc。要避免的是 .loc 链式之外再套一层 [],那又退回到链式索引了。

Python 进阶 (Q20–Q21)

Q20装饰器(Decorator)的原理?在量化系统中的实际应用?
🌱 大白话先懂

装饰器就是给函数套一层「包装纸」:不动函数原来的内容,却能顺手加上计时、缓存、出错重试这些额外功能。写个 @timer 放在函数头顶,就等于把它包了一层再用。量化里常用来监控某个因子算了多久、缓存重复计算、数据源抽风时自动重试。

标准答案

装饰器本质上是一个接收函数并返回新函数的高阶函数,语法糖 @decorator 等价于 func = decorator(func)

量化中的实际应用:

import time
import functools

# 1. 计时器:监控因子计算耗时
def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

# 2. 缓存:避免重复计算(如日频因子值)
from functools import lru_cache

@lru_cache(maxsize=1024)
def get_factor_exposure(ticker, date):
    ...

# 3. 重试机制:应对数据源不稳定
def retry(max_retries=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_retries - 1:
                        raise
                    time.sleep(delay * (attempt + 1))
        return wrapper
    return decorator
🔬 深度拓展:闭包机制、functools.wraps 解决了什么、带参装饰器的三层结构

装饰器 = 闭包 + 高阶函数。wrapper 引用了外层的 func,即使 timer 早已返回,wrapper 仍然「记得」func——这就是闭包:内层函数捕获了外层作用域的变量。Python 用 __closure__ 里的 cell 对象保存这些被捕获的自由变量。

def timer(func):
    def wrapper(*args, **kwargs):
        ...
        return func(*args, **kwargs)   # ← 捕获了外层的 func
    return wrapper

@timer
def calc(): ...
# 等价于 calc = timer(calc)
print(timer(calc).__closure__[0].cell_contents)  # 就是 func 本身

functools.wraps 解决「身份丢失」问题。不加 wraps 时,被装饰后的函数对外暴露的是 wrapper 的元数据:__name__ 变成 'wrapper'__doc__ 丢失、签名也变了。这会破坏日志、调试、依赖反射的框架(如 pytest、FastAPI 路由)。

def bad(func):
    def wrapper(*a, **k): return func(*a, **k)
    return wrapper

def good(func):
    @functools.wraps(func)          # 把 func 的 __name__/__doc__/__wrapped__ 复制给 wrapper
    def wrapper(*a, **k): return func(*a, **k)
    return wrapper

@bad
def f(): "doc"
@good
def g(): "doc"

print(f.__name__)   # 'wrapper'  ❌ 身份丢了
print(g.__name__)   # 'g'        ✅ 身份保留
print(g.__wrapped__)  # 还能拿回原函数

带参数的装饰器是三层嵌套。因为 @retry(max_retries=3) 先调用 retry(3) 拿到「真正的装饰器」,再用它去装饰函数。所以结构是「参数层 → 装饰器层 → wrapper 层」:

def repeat(n):                      # ① 参数层:吃装饰器参数
    def decorator(func):            # ② 装饰器层:吃被装饰的函数
        @functools.wraps(func)
        def wrapper(*args, **kwargs):  # ③ 执行层:吃调用参数
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)                          # = repeat(3) -> decorator -> 装饰 task
def task(): print("run")
# 等价于 task = repeat(3)(task)
⚠️ 量化里 lru_cache 的坑

@lru_cache 要求参数可哈希,所以不能直接缓存吃 DataFrame/ndarray 的函数。而且它永不失效(除非手动 cache_clear())——如果用它缓存「当日因子」,跨交易日不清缓存就会读到昨天的脏数据。日内回测里尤其危险。

🎤 面试追问演练

问:「装饰器和直接调函数比,多了什么开销?热路径上能用吗?」

理想回答:每次调用多了一层 wrapper 的 Python 函数调用 + *args, **kwargs 的打包解包,纳秒级但在百万次/秒的热路径上会累积。对策:热点上要么内联掉装饰器逻辑,要么把装饰器做的事(计时、缓存)移到批量层而非单元素层。另外 functools.wraps 本身只在「定义时」执行一次,不影响每次调用的开销。

Q21Python 中的生成器(Generator)是什么?在量化中的应用?
🌱 大白话先懂

生成器是「吃一份点一份」,而不是一次把满汉全席全端上桌。普通做法会把上亿行数据一股脑读进内存,直接撑爆;生成器每次只吐一条、用完再取下一条,内存占用始终很小。处理海量 tick 数据时全靠它撑场子。

标准答案

生成器是使用 yield 关键字的函数,它惰性地产生值,不会一次性将所有结果加载到内存。

量化应用:处理大规模 tick 数据时避免内存溢出。

def read_ticks(filepath):
    """逐行读取数亿行tick数据,内存占用恒定"""
    with open(filepath) as f:
        header = f.readline().strip().split(',')
        for line in f:
            values = line.strip().split(',')
            yield dict(zip(header, values))

# 流式处理,内存友好
for tick in read_ticks('huge_tick_data.csv'):
    process(tick)

也常用于滚动窗口计算、事件驱动回测引擎的信号生成等。

🔬 深度拓展:惰性求值、内存对比、generator vs list comprehension、yield from

惰性求值(lazy evaluation)的机制。调用生成器函数不会执行函数体,只返回一个生成器对象。每次 next() 才执行到下一个 yield、产出一个值、然后冻结整个执行状态(局部变量、指令指针都保存在 frame 里),等下次 next() 再解冻继续。所以它能「跑一半停住」,这是普通函数做不到的。

内存对比:恒定 vs 线性。读 1 亿行 tick:

import sys

# list:一次性materialize,内存 O(n),1 亿条可能几十 GB → OOM
all_ticks = [parse(line) for line in open("huge.csv")]   # 💥

# generator:内存 O(1),任意时刻只持有一条
gen = (parse(line) for line in open("huge.csv"))         # ✅
sys.getsizeof([x for x in range(1_000_000)])  # ~8 MB(列表)
sys.getsizeof((x for x in range(1_000_000)))  # ~200 B(生成器,与 n 无关)
维度list comprehension [...]generator expression (...)
求值时机立即全部算出惰性、按需
内存$O(n)$$O(1)$
可否多次遍历可以只能一次(耗尽即止)
可否随机访问/切片可以 lst[5]不可以
适合小数据、要复用大数据流、一次性管道

yield from:委托给子生成器。用来把多个数据源串成一个流,避免手写嵌套循环,也能正确传递 send()/异常。量化里常用于「把若干天的 tick 文件拼成一条连续流」:

def read_one(path):
    with open(path) as f:
        for line in f:
            yield parse(line)

def read_many(paths):
    for p in paths:
        yield from read_one(p)     # 把每个文件的流「接力」出来

stream = read_many(["d1.csv", "d2.csv", "d3.csv"])  # 仍是 O(1) 内存的单一流
✅ 面试提示:生成器还能做「协程式」流水线

生成器配合 itertoolsislicechaingroupby)能搭出零拷贝的数据处理管道:filter→map→window 全程惰性,整条 1 亿行只走一遍、内存恒定。这正是事件驱动回测引擎的常见骨架。

🎤 面试追问演练

问:「生成器只能遍历一次,如果我后面又要用一遍怎么办?」

理想回答:三种办法看场景:(1) 数据不大就直接 list(gen) 落地成列表复用;(2) 想复用「生成逻辑」就把它写成生成器函数,每次调用得到一个新生成器;(3) 想缓存又保持惰性可以用 itertools.tee,但要注意 tee 会在内部缓存两个迭代器的进度差,若一快一慢仍可能占大量内存。量化里 tick 流通常是真的大,默认就该用一次性流 + 不回头的设计。

C++ 基础(QR 延伸阅读)(Q22–Q26)

Q22C++ 中指针和引用的区别?
🌱 大白话先懂

指针像写着别人家地址的纸条:能擦掉改成另一家,也能空着(nullptr,表示「啥都不指」)。引用像一个人的小名(别名):定了就永远指他,不能改指别人、也不能没有对象。传一个大对象时用 const 引用,相当于只递了张地址条,不用把整个东西重抄一遍,省时间。

标准答案

特性指针引用
可否为空可以为 nullptr不可以,必须绑定对象
可否重新绑定可以指向其他对象不可以,绑定后不变
语法需要 * 解引用直接使用,语法透明
内存占用空间(存地址)通常被编译器优化为别名

量化场景:高频交易系统中传递大型订单簿对象时用 const & 避免拷贝开销;智能指针(shared_ptr, unique_ptr)管理行情数据的生命周期。

🔬 深度拓展:从内存模型看指针 vs 引用,以及智能指针的所有权语义

内存模型视角。指针本身是一个变量,占 8 字节(64 位机),里面存的是另一个对象的地址;你能改这 8 字节让它指向别处,也能让它存 0(nullptr)。引用不是独立对象,它在语言层面是「目标对象的别名」,编译器通常直接用目标地址替换,不一定占额外内存(也可能用指针实现,标准不强制)。

int a = 10, b = 20;
int* p = &a;     // p 存 a 的地址
p = &b;          // ✅ 重新指向 b
*p = 99;         // 改 b

int& r = a;      // r 永远是 a 的别名
r = b;           // ⚠️ 不是重绑定!是把 b 的值赋给 a(a 变成 20)
// 引用一旦绑定不能改绑,这行只是普通赋值

为什么热路径传 const&按值传一个大对象(如整本订单簿 OrderBook)会触发拷贝构造,复制全部成员,$O(n)$;传 const OrderBook& 只传一个地址(8 字节),$O(1)$,且 const 保证不被修改。这是 C++ 性能代码的默认习惯。

double mid_price(const OrderBook& ob) {   // 不拷贝,只读
    return (ob.best_bid() + ob.best_ask()) / 2.0;
}

裸指针 → 智能指针的所有权革命。裸指针不表达「谁负责 delete」,是内存泄漏和悬垂指针的源头。智能指针用类型表达所有权:

类型所有权开销用途
unique_ptr独占,不可拷贝只能 move≈ 裸指针,零开销默认首选,明确单一所有者
shared_ptr共享,引用计数原子计数 + 控制块,有开销多处共享同一行情对象的生命周期
weak_ptr不持有,观察打破 shared_ptr 循环引用
⚠️ shared_ptr 在高频里不是免费的

引用计数的增减是原子操作,多线程频繁拷贝 shared_ptr 会引发缓存行竞争(cache-line contention),在微秒级热路径上是真实延迟。能用 unique_ptrconst& 就别滥用 shared_ptr。

🎤 面试追问演练

问:「既然引用更安全,为什么还需要指针?」

理想回答:引用「必须绑定、不可改绑、不能为空」这些约束在很多场景下恰恰是限制:需要表达「可选/可能不存在」时要 nullptr(或 std::optional);需要在容器里存一堆可重新指向的句柄时要指针;需要做指针算术遍历连续内存时要指针;管理动态生命周期时要(智能)指针。引用适合「函数参数/返回值这种确定存在、用完即走」的别名场景。

Q23C++ 中的虚函数(virtual function)和多态是什么?
🌱 大白话先懂

多态就像喊一声「出招」,每个角色按自己的招式各出各的——同一句命令,不同对象做不同的事,靠一张「查表」(虚函数表)在运行时决定到底调谁。还有个重点:拆对象(析构)时若没加 virtual,可能只拆了外壳、里面没清理就漏了(内存泄漏)。

标准答案

虚函数允许通过基类指针/引用调用派生类的重写方法,实现运行时多态。底层通过虚函数表(vtable)实现。

class Strategy {
public:
    virtual void on_market_data(const MarketData& md) = 0;  // 纯虚函数
    virtual ~Strategy() = default;  // 虚析构函数(重要!)
};

class MomentumStrategy : public Strategy {
public:
    void on_market_data(const MarketData& md) override {
        // 动量策略的具体实现
    }
};

面试追问:为什么基类析构函数要声明为 virtual?——如果不声明,通过基类指针 delete 派生类对象时只调用基类析构函数,派生类资源不会释放,造成内存泄漏。

🔬 深度拓展:vtable / vptr 的内存布局,虚析构泄漏实测,虚调用的代价

vtable 与 vptr 的布局。任何带虚函数的类,编译器为每个类生成一张静态的虚函数表(vtable),里面是该类各虚函数的函数指针。每个对象的开头被悄悄塞进一个隐藏指针 vptr,指向它真实类型的 vtable。

// 对象内存(概念):
// MomentumStrategy 实例
// ┌────────────┐
// │ vptr ──────┼──► MomentumStrategy 的 vtable
// │ 成员数据... │       ├─ &MomentumStrategy::on_market_data
// └────────────┘       └─ &MomentumStrategy::~MomentumStrategy

Strategy* s = new MomentumStrategy();
s->on_market_data(md);
// 运行时:取 s 的 vptr → 查 vtable 第 0 槽 → 跳转到 Momentum 版本
// 这就是「运行时多态」:调哪个版本由对象的真实类型(vptr)决定

虚析构泄漏的机制(实测心智)。delete 一个基类指针时,编译器要决定调谁的析构:

struct Base { ~Base() { } };                 // ❌ 非虚
struct Derived : Base {
    int* big = new int[1'000'000];           // 持有资源
    ~Derived() { delete[] big; }
};

Base* p = new Derived();
delete p;   // 析构非虚 → 只调 ~Base(),~Derived() 没跑 → big 泄漏 100 万 int

~Base() 改成 virtual 后,delete 会先查 vtable 找到 ~Derived()、释放 big,再链式调 ~Base(),资源干净释放。规则:任何打算被继承、且会用基类指针管理的类,析构函数必须 virtual。

📐 虚调用的代价 = 为什么高频常避开它

一次虚调用 = 一次内存间接(取 vptr)+ 一次表查找 + 一次间接跳转。间接跳转让 CPU 难以分支预测、可能流水线停顿,且妨碍编译器内联。在每秒百万次回调的热路径上,这点开销会累积——所以 HFT 里常用模板/CRTP(见 Q26)做静态多态来消除虚调用。

🎤 面试追问演练

问:「在构造函数或析构函数里调用虚函数,会发生什么?」

理想回答:不会多态分发到派生类版本。因为对象的构造是「从基类到派生类」逐层进行的,在 Base 构造期间,vptr 还指向 Base 的 vtable(派生部分尚未构造好),所以调到的是 Base 版本;析构期间反向同理。这常常是新手 bug——想在基类构造里调派生逻辑,结果调了空实现。正确做法是构造完成后再显式初始化,或用两段式 init。

Q24什么是 move 语义和右值引用?在高频系统中的意义?
🌱 大白话先懂

拷贝是照着原样重新盖一栋楼(把数据一个个复制,慢);移动(move)是直接把房产证过户给你,楼没变只是换了主人(几乎不花时间)。对那些马上要扔掉的临时对象,与其复制不如直接「搬走」它的家当。高频交易里省下的每一次拷贝,都是省下的微秒级延迟。

标准答案

C++11 引入右值引用(&&)和 std::move,允许"窃取"临时对象的资源而非拷贝。

// 拷贝:O(n) —— 分配新内存,逐元素复制
std::vector<Order> orders2 = orders1;

// 移动:O(1) —— 直接接管 orders1 的内存
std::vector<Order> orders3 = std::move(orders1);
// orders1 此后处于"有效但未定义"状态

高频意义:在微秒级延迟的交易系统中,一次不必要的深拷贝可能就是几微秒的延迟。移动语义在订单传递、行情数据转发等热路径上至关重要。

🔬 深度拓展:左值/右值、移动如何「偷指针」、std::move 其实不 move、完美转发

左值 vs 右值。左值(lvalue)是有名字、有持久地址、能被取地址的东西(变量);右值(rvalue)是临时的、即将销毁的值(字面量、函数返回的临时对象)。右值引用 T&& 专门绑定右值——既然这东西马上要死,我们就可以放心「掏空」它而不是复制。

移动到底做了什么:偷指针,不搬数据。一个 vector 内部无非是三个指针(data 头、size、capacity)。拷贝构造要 new 一块新内存 + memcpy 所有元素($O(n)$);移动构造只是把那三个指针过来、再把源对象的指针置空($O(1)$),底层那块大内存的「房产证」直接过户。

class Buffer {
    int* data_; size_t n_;
public:
    // 移动构造:偷资源,O(1)
    Buffer(Buffer&& o) noexcept : data_(o.data_), n_(o.n_) {
        o.data_ = nullptr;   // 源置空,防止它析构时 double-free
        o.n_ = 0;
    }
    ~Buffer() { delete[] data_; }   // 源已置空,delete nullptr 安全
};
⚠️ 移动构造一定要 noexcept

std::vector 扩容时,只有当元素的移动构造是 noexcept 才会用「移动」搬迁旧元素,否则为保证异常安全会退回到「拷贝」——你以为有移动优化,其实悄悄变回 $O(n)$ 拷贝。高频里这是隐蔽的性能杀手。

std::move 名字骗人:它不移动任何东西。它只是一个 static_cast<T&&>,把左值「强转」成右值引用,从而让重载决议选中移动构造/移动赋值。真正干活的是那些移动成员函数。

std::vector<Order> v = std::move(orders1);
// std::move(orders1) 等价于 static_cast<vector<Order>&&>(orders1)
// 仅仅是「类型转换+表态」,触发 vector 的移动构造才真正偷了内存

完美转发(perfect forwarding)。模板里用 T&&(转发引用)+ std::forward 能保持参数的左/右值属性原样传下去,避免不必要的拷贝——这是写高效泛型工厂/包装器(如把订单转发给下游处理器)的关键。

template<class... Args>
void submit(Args&&... args) {
    downstream.handle(std::forward<Args>(args)...);  // 右值仍是右值,可被移动
}

🎤 面试追问演练

问:「函数 return local_vector; 要不要写 return std::move(local_vector);?」

理想回答:不要。直接 return local_vector; 时编译器会做 NRVO(具名返回值优化),直接在调用方的内存里构造,连移动都省了,零开销。若你手写 std::move 反而抑制了 NRVO,强制走一次移动构造,更慢。结论:返回局部对象就裸 return,让编译器优化。

Q25C++ STL 容器的选择原则?
🌱 大白话先懂

不同容器是不同的收纳工具。vector 是连成一片的一排抽屉,电脑读连续的东西特别快;map 是按顺序排好的文件柜(能有序遍历);unordered_map 像查字典,按名字直接翻到。反直觉的一点:数据不多时,老实扫一遍连续的 vector,往往比理论更快的红黑树还快,因为连续内存对缓存友好。

标准答案

容器底层结构查找插入/删除量化场景
vector连续数组$O(n)$尾部 $O(1)$时间序列、K 线存储
deque分段连续$O(n)$头尾 $O(1)$滑动窗口
map/set红黑树$O(\log n)$$O(\log n)$有序订单簿
unordered_map/set哈希表$O(1)$ 均摊$O(1)$ 均摊ticker→策略映射
priority_queue取极值 $O(1)$插入 $O(\log n)$事件驱动引擎的事件队列

高频考虑vector 因内存连续、缓存友好,在遍历性能上远优于链表。高频系统中优先使用 vector + 排序,而非 map

🔬 深度拓展:缓存局部性为什么让 vector 吊打 map / list,大 O 之外的真实世界

大 O 会骗人,因为它忽略了「常数」和内存墙。现代 CPU 从主存取一个数据要约 100ns,从 L1 缓存只要约 1ns——差 100 倍。CPU 每次访存按「缓存行」(通常 64 字节)成块搬运,并会预取后续连续地址。所以数据是否连续,比理论复杂度更能决定真实速度。

容器内存布局遍历时缓存表现
vector一整块连续✅ 完美:预取器全程命中,几乎无 miss
list节点散落堆各处❌ 每跳一个节点几乎必 cache miss + 指针解引用
map(红黑树)节点散落 + 树指针❌ 每层一次 miss,还要比较跳转

实战结论:小数据集,vector + 线性扫描 / 二分常胜过 map对几十~几千个元素,遍历连续 vector(即使是 $O(n)$)往往比查 $O(\log n)$ 的红黑树还快,因为后者每步都 cache miss。所以高频里常见模式是「vector 存好、std::sort 排序、std::lower_bound 二分查」,全程连续内存。

// 高频偏好:连续内存 + 二分,而非 std::map
std::vector<std::pair<int, Quote>> book;   // 按 price 排序的连续数组
auto it = std::lower_bound(book.begin(), book.end(), key,
            [](auto& e, int k){ return e.first < k; });   // O(log n) 但缓存友好
🧩 各容器的隐藏陷阱

vector 扩容会整体重分配 + 失效所有迭代器/指针;预知大小就 reserve()
unordered_map 理论 $O(1)$,但节点也是散布在堆上(不像 vector 连续),缓存不如想象中好,且 rehash 时全员搬迁。
deque 分段连续,随机访问比 vector 略慢,但头尾插入 $O(1)$ 且不使所有迭代器失效,适合滑动窗口。
map 的优势是有序遍历和稳定迭代器,订单簿要按价排序时它仍有价值。

🎤 面试追问演练

问:vector 中间删除一个元素是 $O(n)$,但我又想快速删,怎么办?」

理想回答:看是否在乎顺序。不在乎顺序时用「swap-and-pop」:把要删的元素和最后一个 std::swap,再 pop_back(),$O(1)$。在乎顺序就只能 $O(n)$ 搬移,或者改用别的结构(如带空洞标记的对象池)。高频里订单池常用 swap-and-pop 或 freelist 复用槽位,避免重分配。

Q26什么是模板(Template)元编程?在量化中有什么用?
🌱 大白话先懂

模板元编程是让编译器在「做饭前」(编译期,也就是程序还没跑起来时)就把一部分活先算好、代码先生成好,等真正运行时直接端上桌,不再花额外时间。这就是所谓「零开销抽象」:代码写得优雅又通用,跑起来却和手写的一样快。代价是编译更慢、报错信息又臭又长。

标准答案

模板允许在编译期生成特化代码,实现零开销抽象。

// 编译期计算阶乘
template<int N>
struct Factorial {
    static constexpr int value = N * Factorial<N-1>::value;
};
template<>
struct Factorial<0> {
    static constexpr int value = 1;
};

量化应用:

  • 表达式模板(Expression Templates):避免临时对象,实现高效矩阵运算(Eigen 库的核心技术)。
  • 策略模式的编译期实现:用模板参数选择不同的执行逻辑,避免虚函数的间接调用开销。
  • CRTP(Curiously Recurring Template Pattern):静态多态,高频系统中替代虚函数。
🔬 深度拓展:CRTP 如何用零成本替代虚函数、表达式模板如何消灭临时对象

CRTP:把运行时多态搬到编译期。核心套路是「派生类把自己作为模板参数传给基类」,基类用 static_cast 拿到派生类型,在编译期就解析出该调哪个实现——没有 vptr、没有间接跳转、可以内联

template<class Derived>
struct Strategy {                       // 基类模板
    void on_data(const MarketData& md) {
        // 编译期就知道真实类型,调用可内联,零虚调用开销
        static_cast<Derived*>(this)->handle(md);
    }
};

struct Momentum : Strategy<Momentum> {   // ← 把自己传进去(CRTP)
    void handle(const MarketData& md) { /* 动量逻辑 */ }
};

Momentum m;
m.on_data(md);   // 编译后等价于直接内联 Momentum::handle,无 vtable
虚函数(动态多态)CRTP(静态多态)
分发时机运行时(查 vtable)编译期
开销间接跳转 + 难内联零开销,可内联
能否运行时切换类型能(基类指针存异质对象)不能(类型编译期固定)
代码膨胀每个实例化一份代码,可能膨胀

所以选型是权衡:要把不同策略放进同一个容器、运行时插拔 → 用虚函数;策略类型在编译期已知、追求极致延迟 → 用 CRTP。

表达式模板:消灭中间临时对象。朴素的运算符重载里,d = a + b + c(向量)会先算 a+b 生成一个临时向量、再加 c 生成另一个临时,多次分配 + 多遍遍历。表达式模板让 a+b+c 不立即计算,而是构造一棵「表达式类型」,到赋值时一次循环算出每个元素:

// 概念:a+b+c 的类型是 Add<Add<Vec,Vec>, Vec>,不产生临时数组
for (size_t i = 0; i < n; ++i)
    d[i] = a[i] + b[i] + c[i];   // 编译器最终生成这一个融合循环(loop fusion)
// 零临时对象、一遍内存、SIMD 友好 —— 这正是 Eigen 比手写还快的秘密
⚠️ 模板的代价

编译期计算 + 代码生成意味着:编译时间变长、编译错误信息冗长难读、每个实例化生成独立机器码可能导致二进制膨胀和指令缓存压力。元编程是「用编译期复杂度换运行期零开销」,别为了炫技在不需要极致性能的地方滥用。

🎤 面试追问演练

问:「现在有 constexpr 和 C++20 concepts 了,传统那种递归模板元编程是不是过时了?」

理想回答:很大程度上是。像 Factorial 这种编译期计算,现在直接写 constexpr/consteval 函数即可,可读性远好于递归模板特化;类型约束用 concepts 比 SFINAE 清晰得多。但「类型层面」的编排(表达式模板、CRTP、类型列表)仍要靠模板。趋势是:能用 constexpr 做值计算就别用模板递归,模板留给真正的泛型与零开销抽象。

NumPy / 数值计算 (Q27–Q28)

Q27NumPy 的广播(Broadcasting)机制是什么?
🌱 大白话先懂

广播就像老师说「每人都加 10 分」——你不用真把「10」抄五十遍,规则会自动让这一个数去和一整排数配对运算。NumPy 还更省:连复制都免了,只是反复读同一处内存,所以又快又省。做「每只股票都减去自己的均值」这种事,一行就够,不用写循环。

标准答案

广播允许不同形状的数组进行运算。规则:

  1. 从最后一个维度开始对齐;
  2. 每个维度要么相同,要么其中一个为 1;
  3. 维度为 1 的那个轴会被"广播"(逻辑复制)。
# 对每只股票的收益率减去其均值(截面去均值)
returns = np.random.randn(252, 500)     # (252天, 500只股票)
mean_ret = returns.mean(axis=0)          # (500,)
demeaned = returns - mean_ret            # 广播:(252, 500) - (500,) → (252, 500)

理解广播是写出高效 NumPy 代码的关键,避免不必要的循环和内存分配。

🔬 深度拓展:对齐规则逐步演算、一个广播失败的例子、为什么广播零拷贝

对齐规则一步步走(右对齐)。两个数组形状从最右开始逐维比较,每一维满足「相等 或 其中一方为 1」即可兼容,缺失的高维补 1。结果取每维的较大值。

A.shape = (252, 500)
B.shape =      (500,)     # 右对齐后视作 (1, 500)
比较:  dim -1: 500 vs 500 ✅ 相等
       dim -2: 252 vs   1 ✅ 一方为1 → 广播到 252
结果:        (252, 500)   # B 沿第0轴被「逻辑复制」252 次

再看一个三维例子,给每只股票每天的若干特征做标准化:

X    = np.random.randn(252, 500, 8)   # (天, 股票, 特征)
mean = X.mean(axis=0)                  # (500, 8)
# (252,500,8) vs (500,8)→(1,500,8):每维 8=8, 500=500, 252 vs 1 → OK
Xc   = X - mean                        # (252,500,8) 零循环搞定截面去均值
⚠️ 一个会报错的广播
a = np.ones((3, 4))
b = np.ones((3,))      # 右对齐: dim-1 是 4 vs 3 → 既不相等也都不为1
a + b
# ValueError: operands could not be broadcast together
#             with shapes (3,4) (3,)

新手想「按行减」却写成上面这样。修法:把 b 变成列向量 (3,1) 显式表达广播意图:

a + b[:, None]     # b.shape (3,) → (3,1),dim-1: 4 vs 1 ✅ 广播成功
# b[:, None] 等价于 b.reshape(3, 1),None/np.newaxis 插入新轴

为什么广播零拷贝、省内存。「逻辑复制」不是真的把 mean_ret 复制 252 份。NumPy 用 stride(步长)技巧:把被广播的那一维 stride 设为 0,遍历时该维「原地踏步」,反复读同一块内存。所以 returns - mean_ret 不会为 mean_ret 额外分配 252×500 的内存,只在输出上分配一次。这正是广播比「手动 np.tile 复制再相减」更快更省的原因。

🎤 面试追问演练

问:returns.mean(axis=0)axis=1 在这个去均值里分别是什么含义?写错会怎样?」

理想回答:returns 是 (天, 股票)。axis=0 沿时间维聚合 → 得到每只股票的时序均值,减去它是「每只股票自己去均值」(时序 demean)。axis=1 沿股票维聚合 → 每天所有股票的截面均值,减去它才是「截面去均值/市场中性化」。两者在量化里意义完全不同:截面 demean 用于做市场中性因子,时序 demean 用于去趋势。写错轴不会报错(因为广播可能照样成立),但因子含义全错——这是非常隐蔽且致命的 bug,必须用 keepdims=True 配合明确意图。

# 截面去均值(每天减去当天市场均值)正确写法:
cross_mean = returns.mean(axis=1, keepdims=True)   # (252, 1)
demeaned   = returns - cross_mean                  # 广播 (252,500)-(252,1)
Q28如何用 NumPy 高效实现滚动窗口计算?
🌱 大白话先懂

滚动窗口就是算「最近 20 天的平均」,窗口随着时间一天天往后挪。笨办法每挪一步都把 20 个数重加一遍,很浪费。聪明做法是让相邻窗口共用重叠的数据(零拷贝),或者用「累加总和相减」一步求出每段的和——算一亿行也很快。

标准答案

# 方法1:stride_tricks(零拷贝,最高效)
from numpy.lib.stride_tricks import sliding_window_view
windows = sliding_window_view(prices, window_shape=20)  # (n-19, 20)
rolling_mean = windows.mean(axis=1)

# 方法2:cumsum技巧(O(n),适合求和/均值)
cumsum = np.cumsum(np.insert(prices, 0, 0))
rolling_sum = cumsum[20:] - cumsum[:-20]
rolling_mean = rolling_sum / 20

# 方法3:Pandas(最方便,但慢于纯NumPy)
pd.Series(prices).rolling(20).mean()

在大规模因子计算中,方法 1 和 2 比 Pandas 快 3–10 倍。

🔬 深度拓展:stride_tricks 的零拷贝内存原理、cumsum 技巧的 O(n) 正确性与数值陷阱

stride_tricks 为什么零拷贝。ndarray = 一块连续 buffer + 一组「步长」(strides,每个轴前进一步要跳几个字节) + shape。sliding_window_view 不复制数据,只是造一个新的 (shape, strides) 视图,让相邻窗口在内存上重叠地指向同一块 buffer。

prices = np.arange(8.0)          # 连续: [0 1 2 3 4 5 6 7], itemsize=8字节
w = sliding_window_view(prices, 3)
# w.shape  = (6, 3)
# w.strides = (8, 8)  ← 行步长和列步长都是 8 字节!
# 含义:往下一行只前移 1 个元素,所以窗口重叠:
#   w[0]=[0,1,2]  w[1]=[1,2,3]  w[2]=[2,3,4] ...
# 6×3=18 个元素「看起来」存在,实际仍只占用原来 8 个元素的内存
⚠️ 视图是只读级别的危险,且聚合才materialize

sliding_window_view 返回的是只读视图,别往里写(重叠内存,一处写会污染多个窗口)。② 真正的内存峰值发生在 windows.mean(axis=1):虽然窗口本身零拷贝,但许多聚合实现会临时物化,对超长序列 + 大窗口仍要留意。要极致省内存就用方法 2。

cumsum 技巧:把窗口求和从 $O(nk)$ 降到 $O(n)$。朴素滚动求和每个窗口都重新加 $k$ 个数,总共 $O(nk)$。前缀和(prefix sum)的洞见是:窗口 $[i, i+k)$ 的和 = S[i+k] - S[i],其中 $S$ 是累加和数组,一次相减就得一个窗口和。

prices = np.array([2., 4., 6., 8., 10.])
k = 2
# 前面插 0,让 S[0]=0,使索引对齐干净
S = np.cumsum(np.insert(prices, 0, 0))   # [0, 2, 6, 12, 20, 30]
rolling_sum = S[k:] - S[:-k]             # [6-0? ...] 逐项:
#  S[2]-S[0]=6-0=6   → 窗口[2,4] 和 = 6  ✅
#  S[3]-S[1]=12-2=10 → 窗口[4,6] 和 = 10 ✅
#  S[4]-S[2]=20-6=14 → 窗口[6,8] 和 = 14 ✅
#  S[5]-S[3]=30-12=18→ 窗口[8,10]和 = 18 ✅
rolling_mean = rolling_sum / k

正确性要点:插一个 0 在最前面,使 S[j] 表示「前 j 个元素之和」,于是 S[i+k]-S[i] 精确等于第 $i$ 个窗口的和,索引不偏移。结果长度是 n-k+1

📉 cumsum 的数值陷阱(面试加分项)

前缀和会把所有数累加进一个越来越大的数,再相减得局部和。当序列很长、量级悬殊时,大数相减会损失浮点精度(catastrophic cancellation)。所以 pandas 的 rolling 默认用 cumsum 技巧,而用更稳定(但稍慢)的逐窗口算法。结论:追求速度且数据量级温和 → cumsum;要数值稳健 → stride 视图或 pandas。

🎤 面试追问演练

问:「滚动标准差能不能也用 cumsum 技巧 $O(n)$ 搞定?」

理想回答:能,用「平方和的前缀和」。方差 $\text{Var} = \frac{1}{k}\sum x_i^2 - \left(\frac{1}{k}\sum x_i\right)^2$,所以同时维护 cumsum(x)cumsum(x**2) 两个前缀和,各做一次区间相减就能 $O(n)$ 得到每个窗口的均值和平方均值,组合出方差再开根号。但这个公式正是上面说的「大数相减」重灾区——平方放大了量级,精度损失更严重,靠近 0 的方差甚至可能算出微小负数再 NaN。生产里若要稳健,用 Welford 在线算法或直接 stride 视图 + np.std