编程 (Python / C++)
这一页覆盖 Quant Research 面试里最常被追问的编程八股:Python 的数据结构底层、内存模型、向量化与并发,以及 C++ 的内存语义、多态与零开销抽象,最后落到 NumPy 数值计算。每题都配「🔬 深度拓展」——讲清楚底层怎么跑、为什么快/慢、容易踩的坑,并附一段「面试追问演练」。适合大一新生打地基,也适合求职者临阵磨枪。
Python 基础与数据处理 (Q15–Q19)
这四个都是装东西的「盒子」。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 也是哈希表。
复印一本书有两种做法。浅拷贝像只抄了「书放在哪个书架」的地址——你和别人翻的还是同一本,他画一笔你的也花了。深拷贝才是真的整本重新印一份,从此互不影响。量化里多个策略不小心共用了同一份持仓数据、改一个全乱套,就是这个坑。
标准答案
- 浅拷贝(
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 极其隐蔽。
apply、map、向量化运算的性能差异?同样给一万个数都乘以 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。
GIL 像厨房里只有一把炒锅:你雇再多厨师(线程),同一刻也只能一人用锅,纯靠算力的活(CPU 密集)多线程根本快不了。想真正同时开火,就开几个独立厨房(多进程)。但如果大家多半时间在等外卖(等网络、读文件),轮流用一把锅也够,这时多线程/async 反而合适。
标准答案
GIL(全局解释器锁)使得 CPython 中同一时刻只有一个线程执行 Python 字节码。
影响:CPU 密集型任务(如大规模因子计算)无法通过多线程加速。
解决方案:
| 方案 | 适用场景 | 工具 |
|---|---|---|
| 多进程 | CPU 密集型因子计算 | multiprocessing, joblib, ProcessPoolExecutor |
| 多线程 | IO 密集型(数据下载、API 请求) | threading, asyncio |
| 向量化/C 扩展 | 数值计算 | NumPy, Numba, Cython |
| 分布式计算 | 超大规模回测 | Dask, Ray, Spark |
连着写两个方括号去改数据时,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
Python 进阶 (Q20–Q21)
装饰器就是给函数套一层「包装纸」:不动函数原来的内容,却能顺手加上计时、缓存、出错重试这些额外功能。写个 @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
生成器是「吃一份点一份」,而不是一次把满汉全席全端上桌。普通做法会把上亿行数据一股脑读进内存,直接撑爆;生成器每次只吐一条、用完再取下一条,内存占用始终很小。处理海量 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)
也常用于滚动窗口计算、事件驱动回测引擎的信号生成等。
C++ 基础(QR 延伸阅读)(Q22–Q26)
指针像写着别人家地址的纸条:能擦掉改成另一家,也能空着(nullptr,表示「啥都不指」)。引用像一个人的小名(别名):定了就永远指他,不能改指别人、也不能没有对象。传一个大对象时用 const 引用,相当于只递了张地址条,不用把整个东西重抄一遍,省时间。
标准答案
| 特性 | 指针 | 引用 |
|---|---|---|
| 可否为空 | 可以为 nullptr | 不可以,必须绑定对象 |
| 可否重新绑定 | 可以指向其他对象 | 不可以,绑定后不变 |
| 语法 | 需要 * 解引用 | 直接使用,语法透明 |
| 内存 | 占用空间(存地址) | 通常被编译器优化为别名 |
量化场景:高频交易系统中传递大型订单簿对象时用 const & 避免拷贝开销;智能指针(shared_ptr, unique_ptr)管理行情数据的生命周期。
多态就像喊一声「出招」,每个角色按自己的招式各出各的——同一句命令,不同对象做不同的事,靠一张「查表」(虚函数表)在运行时决定到底调谁。还有个重点:拆对象(析构)时若没加 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 派生类对象时只调用基类析构函数,派生类资源不会释放,造成内存泄漏。
拷贝是照着原样重新盖一栋楼(把数据一个个复制,慢);移动(move)是直接把房产证过户给你,楼没变只是换了主人(几乎不花时间)。对那些马上要扔掉的临时对象,与其复制不如直接「搬走」它的家当。高频交易里省下的每一次拷贝,都是省下的微秒级延迟。
标准答案
C++11 引入右值引用(&&)和 std::move,允许"窃取"临时对象的资源而非拷贝。
// 拷贝:O(n) —— 分配新内存,逐元素复制
std::vector<Order> orders2 = orders1;
// 移动:O(1) —— 直接接管 orders1 的内存
std::vector<Order> orders3 = std::move(orders1);
// orders1 此后处于"有效但未定义"状态
高频意义:在微秒级延迟的交易系统中,一次不必要的深拷贝可能就是几微秒的延迟。移动语义在订单传递、行情数据转发等热路径上至关重要。
不同容器是不同的收纳工具。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。
模板元编程是让编译器在「做饭前」(编译期,也就是程序还没跑起来时)就把一部分活先算好、代码先生成好,等真正运行时直接端上桌,不再花额外时间。这就是所谓「零开销抽象」:代码写得优雅又通用,跑起来却和手写的一样快。代价是编译更慢、报错信息又臭又长。
标准答案
模板允许在编译期生成特化代码,实现零开销抽象。
// 编译期计算阶乘
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):静态多态,高频系统中替代虚函数。
NumPy / 数值计算 (Q27–Q28)
广播就像老师说「每人都加 10 分」——你不用真把「10」抄五十遍,规则会自动让这一个数去和一整排数配对运算。NumPy 还更省:连复制都免了,只是反复读同一处内存,所以又快又省。做「每只股票都减去自己的均值」这种事,一行就够,不用写循环。
标准答案
广播允许不同形状的数组进行运算。规则:
- 从最后一个维度开始对齐;
- 每个维度要么相同,要么其中一个为 1;
- 维度为 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 代码的关键,避免不必要的循环和内存分配。
滚动窗口就是算「最近 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 倍。