战斗包子
一个l4公司的面试

一个l4公司的面试

一面

讨论了自动驾驶参考线规划相关技术,包括换道方式、参考线生成、路径规划等内容,还进行了技术面试考查,具体如下:

🚗 参考线与换道技术

  • 基础框架: 系统框架从阿波罗(Apollo)魔改而来,有基础版本。
  • 换道方式:
    • 原平行换道方式:换道距离长,路口连续换道困难。
    • 新参考线直连方式:无 heading 回正过程,用贝塞尔曲线连接,拉长时距保证舒适性。
    • 失败处理:实在换不过去只能去下一个路口掉头。
  • 参考线生成:
    • 有多种参考线,并行会生成自车所在车道、相邻车道及去目标车道参考线。
    • 规划方式分纯平车道和拼接场景两种。
    • 范围与自车位置及感知末端、地图引导点有关。
  • 平滑处理:
    • 用贝塞尔连出点链,放进平滑器优化成一条曲线。
    • 若无动态障碍物,该曲线直接作为控车轨迹。
    • 若有动态障碍物,在此基础上再做处理。

🗺️ 路径规划与决策

  • 规划流程: 后续会进行决策(包括绕行、换道等),规划横向轨迹,再在其上做纵向决策(公共控制和解耦)。
  • 绕行决策:
    • 在参考线下游的路径决策层进行。
    • 将障碍物 bounding box 投影到弗林纳(Frenet)坐标系。
    • 根据其位置和侵入程度决策绕行方向和设置约束,用软约束建模到 QP。
  • 软约束建模: 加松弛变量,在 Hession 上增广义维,松弛变量大小为软约束权重。满足约束时松弛变量为 0,不满足时在目标函数上加惩罚。

🚦 多态上下文与换道决策

  • 参考线生成: 参考线一直生成,与换道不反馈。
  • 换道执行: 换道时按 target 参考线直接换,换道前进行校验。
  • 换道校验: 校验车道有无、目标几秒内有无障碍物、目标车道前后车之间空间是否足够,通过后生成轨迹。

面试官非常了解 Apollo 的框架,因此讲解的基于 Apollo 框架的内容都不感兴趣。

💻 技术面试考查

记事本 Coding

  1. 实现一个父类 parent
  2. 父类要有一个虚函数,由子类实现。
  3. 在父类实现一个成员,这个成员子类可以访问。
  1. 实现一个二叉树节点。
  2. 以非递归的方式遍历二叉树。

C++ 基础

  1. 什么是前序遍历?
  2. 以左中右的顺序遍历二叉树(即中序遍历)。
  1. 讲解什么是 shared_ptr,什么是 weak_ptr
  2. 什么是 unordered_map

二面

我还以为一面寄了,竟然今天和我约二面了!

以下是豆包总结

一、会议核心信息

项目 详情
会议主题 CX835城市领航辅助驾驶项目横向规划算法工作汇报与技术面试
参会人员 汇报人(说话人A)、面试官(说话人B)
会议时长 约40分钟
核心聚焦 横向规划算法(参考线生成、车道选择、轨迹优化等)的设计、实现与技术优化

二、核心工作成果

  1. 完成CX835项目横向规划算法核心模块开发,重点突破参考线生成技术,适配平车道、非结构化道路、疑难场景等多类工况。
  2. 提出轻地图与感知匹配的多版本迭代方案,解决非结构化道路参考线引导偏差问题,减少撞路沿、驶入对向车道等风险。
  3. 设计多状态机切换逻辑,实现平车道、入口、远端感知车道引导等状态的平滑过渡,保障路口通行稳定性。
  4. 构建动态障碍物绕行模块与LEDA异常情况处理模块,提升算法对复杂交通环境的适应性。
  5. 基于QP优化实现轨迹精调,通过硬约束+软约束结合的方式,平衡轨迹安全性与平滑性。

三、关键技术亮点

  1. 多场景参考线生成:平车道采用中心线平滑方案,非结构化道路采用三段式拼接(历史参考线+贝塞尔曲线+目标车道中心线),疑难场景采用专家轨迹模式。
  2. 轻地图与感知融合:迭代路沿匹配、道线宽度修正、对向停止线辅助三类方案,弥补轻地图精度不足的缺陷。
  3. 状态机智能切换:基于地图标识与感知结果,实现多状态自动切换,提前衔接目标车道,避免近端轨迹摆动。
  4. 横纵解耦+动态补全:参考线聚焦静态规划,通过下游绕行模块处理动态障碍物,兼顾规划效率与环境适应性。

四、面试官核心建议

  1. 表达优化:压缩自我介绍篇幅,突出核心成果,后续再展开细节讨论。
  2. 成果量化:补充算法优化的具体指标与数据(如横向偏差降低幅度、场景适配成功率等),增强成果说服力。
  3. 技术深化
    • 明确“画龙”“猛打方向”等问题的量化标准,结合方向盘转速、横向加加速度等物理量,通过数据埋点、影子模式构建闭环优化体系。
    • 参考阿波罗框架中曲率约束的二阶差分项实现,完善轨迹优化的动力学约束设计。
  4. 数据驱动:针对路口等依赖地图的场景,探索数据驱动补全方案,提升算法对地图偏差的容错性。

五、coding

计算几何,判断一个点是否在多边形内,应该用射线法
但是我理解成了判断在凸多边形内,只判断了左右。

正确coding方法与思路

射线法的解析原理

射线法,也称为奇偶规则(Even-Odd Rule)或交叉数法,是判断点是否在多边形内部最常用、最直观的方法。

1. 核心思想:奇偶规则

其核心思想基于一个简单(但在数学上由“约当曲线定理”保证)的拓扑学事实:

从平面上的任意一点出发,画一条无限长的射线。如果该点在多边形(一个简单的闭合曲线)的内部,那么这条射线穿过(相交于)多边形边界的次数必定为奇数

如果该点在多边形的外部,则射线穿过边界的次数必定为偶数(包括 0 次)。

一个简单的比喻:
想象多边形是一个围栏。

  • 如果你仍在围栏内部,你向任意方向直线行走,要想到达围栏外(无限远处),你必须穿过围栏 1 次(奇数)。
  • 如果你仍在围栏外部,你要么根本不会穿过围栏(0 次,偶数),要么你穿进去了(第 1 次),就必须再穿出来(第 2 次)才能到达无限远处。所以你总会穿过 0、2、4… 次(偶数)。

2. 算法实现:水平射线

为了简化计算,我们通常选择一条水平向右的射线。

给定一个点 P(x, y) 和一个多边形(由顶点 V1, V2, ... Vn 组成):

  1. 初始化一个交叉计数器 crossings = 0
  2. 发射射线:从点 P 出发,画一条 y 值不变、x 向正无穷大(向右)的射线。
  3. 遍历多边形的每一条边:对于从 ViVi+1 的每条边:
    • 检查这条边是否与我们的水平射线相交。
  4. 统计交叉:如果相交,crossings 加 1。
  5. 判断结果:遍历完所有边后,检查 crossings 的奇偶性。
    • crossings % 2 == 1 (奇数) \rightarrow 点在多边形内部
    • crossings % 2 == 0 (偶数) \rightarrow 点在多边形外部

3. 关键:处理特殊情况(边界情况)

简单地“计算交叉”在实践中是不可靠的,因为射线可能会“擦过”顶点或与水平边重合。一个健壮的算法必须精确定义什么是“有效交叉”:

  1. 射线与水平边重合

    • 问题:如果点 Py 坐标与多边形某条水平边的 y 坐标相同,射线可能与这条边重合,产生无限个交点。
    • 解决方案忽略所有水平边。水平边不构成“穿越”,它们不会使点从“内”变“外”。
  2. 射线穿过顶点

    • 问题:如果射线恰好穿过一个顶点,它会同时与两条边(共享该顶点的边)接触。这可能导致被计数两次(偶数)或零次(偶数),即使它只是一次“穿越”。
    • 解决方案:我们必须制定一个一致的规则来计算顶点。一个非常标准且稳健的规则是:
      • 只计算那些端点 y 坐标跨越了射线 y 坐标的边。
      • 对于恰好落在射线上的顶点,我们将其视为属于某一条边。一个常见的约定是:只计算y 坐标大于射线 y 坐标的那个端点(即,只把顶点当作其所在边的“上端点”时才计算)。
      • 一个更简单的实现是:当一条边的两个端点一个y> p.y 而另一个y<= p.y 时,我们才认为它可能与射线相交。这自动处理了顶点问题:
        • 如果射线穿过一个“凸”顶点(两条边都在射线同一侧),两条边都会满足 (y1 > p.y)(y2 <= p.y) 的组合,所以不计数。
        • 如果射线穿过一个“凹”顶点(两条边在射线两侧),只有一条边(向上跨越的或向下跨越的)会被计算,这是正确的。
        • 如果射线与一个顶点相切(p.y == vi.y),该顶点会被视为其所在边的“下端点”,也只会被计算一次。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#include <iostream>
#include <vector>

// 定义点的结构体,并重载运算符
struct Point {
double x, y;
// 构造函数(可选,但很方便)
Point(double x = 0.0, double y = 0.0) : x(x), y(y) {}
// 重载减法运算符 (P1 - P2)
// 用于获取从 P2 指向 P1 的向量
Point operator-(const Point& other) const {
return Point(x - other.x, y - other.y);
}
// 重载加法运算符 (P1 + V1)
// 用于将一个点按向量 V1 进行平移
Point operator+(const Point& other) const {
return Point(x + other.x, y + other.y);
}
};

/**
* @brief 使用射线法(水平)判断点是否在多边形内部
* @param polygon 多边形的顶点(必须按顺序排列)
* @param p 要测试的点
* @return true 如果点在内部, false 如果点在外部或边上
*/
bool isInside(const std::vector<Point>& polygon, Point p) {
int n = polygon.size();
if (n < 3) {
return false; // 不是一个有效的多边形
}
bool inside = false;
// 遍历多边形的每一条边 (i, j)
for (int i = 0, j = n - 1; i < n; j = i++) {
Point p1 = polygon[i];
Point p2 = polygon[j];
// 1. 检查Y坐标是否跨越了射线的Y坐标
if ( ((p1.y > p.y) != (p2.y > p.y)) ) {
// 2. 计算射线与边的交点的 x 坐标
// (x - p1.x) / (p2.x - p1.x) = (p.y - p1.y) / (p2.y - p1.y)
// (p2.x - p1.x) 是向量 p1->p2 的 x 分量
// (p2.y - p1.y) 是向量 p1->p2 的 y 分量
// 尽管我们重载了运算符,但在这里直接使用 x, y 分量进行
// 斜率和比例计算仍然是最清晰的。
double intersectX = (p2.x - p1.x) * (p.y - p1.y) / (p2.y - p1.y) + p1.x;
// 3. 检查交点是否在射线上(即在点的右侧)
if (p.x < intersectX) {
inside = !inside; // 翻转奇偶性
}
}
}
return inside;
}

int main() {
// 使用新的构造函数初始化
// 矩形 (0,0) -> (5,0) -> (5,5) -> (0,5)
std::vector<Point> polygon = {{0, 0}, {5, 0}, {5, 5}, {0, 5}};

// 凹多边形
std::vector<Point> concave_polygon = {
{0, 0}, {5, 0}, {5, 3}, {3, 3}, {3, 2}, {2, 2}, {2, 3}, {0, 3}
};

// --- 测试运算符重载 ---
Point v1(1.0, 2.0);
Point v2(3.0, 1.0);
Point diff = v1 - v2; // diff 将是 (-2.0, 1.0)
std::cout << "--- 运算符重载测试 ---" << std::endl;
std::cout << "v1 - v2 = (" << diff.x << ", " << diff.y << ")" << std::endl;
std::cout << "------------------------\n" << std::endl;


// --- 测试点 ---
Point p_inside(2.5, 2.5);
Point p_outside(10, 2.5);
Point p_on_edge(5, 2.5);
Point p_on_vertex(5, 5);
Point p_concave_in(1, 1);
Point p_concave_out(2.5, 2.5);

std::cout << std::boolalpha;

std::cout << "--- 矩形测试 ---" << std::endl;
std::cout << "点 (2.5, 2.5) 在内部吗? " << isInside(polygon, p_inside) << std::endl;
std::cout << "点 (10, 2.5) 在内部吗? " << isInside(polygon, p_outside) << std::endl;
std::cout << "点 (5, 2.5) 在内部吗? " << isInside(polygon, p_on_edge) << std::endl;
std::cout << "点 (5, 5) 在内部吗? " << isInside(polygon, p_on_vertex) << std::endl;

std::cout << "\n--- 凹多边形测试 ---" << std::endl;
std::cout << "点 (1, 1) 在内部吗? " << isInside(concave_polygon, p_concave_in) << std::endl;
std::cout << "点 (2.5, 2.5) 在内部吗? " << isInside(concave_polygon, p_concave_out) << std::endl;

return 0;
}

然后代码习惯需要注意,不修改的量应该传入常量引用。

三面

这是一场关于求职的技术面试会议。本次会议主要围绕候选人的工作经历、技术能力以及对新工作的期望展开讨论。面试官评估了候选人的编程技能和对自动驾驶规划算法的理解,并介绍了团队的技术方向和工作内容。

1、面试者背景介绍
面试者于2024年7月毕业后加入经纬恒润,担任CX 835项目中的算法工程师,主要负责横向规划模块中的参考线生成部分。
参考线是整套框架的基石,基于定位模块、导航地图和感知车道线信息生成,优化后形成平滑的参考线。
2、离职原因及求职期望
离职原因包括项目已交付,缺乏技术升级空间,以及出差时间过长(一年300多天中有180天出差)。
下一份工作期望待遇提升、减少出差频率,并希望接触端到端和时空联合规划等新技术框架。
3、技术讨论:端到端与时空联合规划
时空联合规划方案多基于采样方法,通过评分和优化生成轨迹;端到端方案存在数据不足和模型缺陷(如copycat问题)。
新公司端到端方案预计明年6月上线,面试者可参与场景迭代,但需依赖模型团队提供初版。
端到端技术需与robust框架结合,部分模块(如control)可能上游化。
4、出差频率与工作安排
新公司出差频率因岗位而异,核心骨干出差较多,特殊场景迭代时需出差,但无强制驻场开发要求。
5、编程能力考察
面试者实现了一个模板类队列(链表结构),讨论了链表与数组的区别及C++新特性(如右值引用、optional)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
template<typename T>
class Queue {
private:
struct Node {
T data;
std::shared_ptr<Node> next;
Node(const T& val) : data(val), next(nullptr) {}
};

std::shared_ptr<Node> front; // 队首指针
std::weak_ptr<Node> rear; // 队尾指针(弱引用避免循环引用)
size_t count; // 元素数量

public:
// 构造函数
Queue() : front(nullptr), rear(), count(0) {}

// 禁用拷贝构造和赋值(防止浅拷贝问题)
Queue(const Queue&) = delete;
Queue& operator=(const Queue&) = delete;

// 移动构造
Queue(Queue&& other) noexcept
: front(std::move(other.front)), rear(other.rear), count(other.count) {
other.count = 0;
}

// 移动赋值
Queue& operator=(Queue&& other) noexcept {
if (this != &other) {
front = std::move(other.front);
rear = other.rear;
count = other.count;
other.count = 0;
}
return *this;
}

// 析构函数(智能指针自动释放内存)
~Queue() = default;

// 入队操作
void enqueue(const T& val) {
auto new_node = std::make_shared<Node>(val);
if (empty()) {
front = rear = new_node;
} else {
rear.lock()->next = new_node;
rear = new_node;
}
++count;
}

// 出队操作
T dequeue() {
if (empty()) {
throw std::out_of_range("Queue is empty");
}
auto old_front = front;
front = front->next;
if (!front) { // 如果队列变空
rear.reset();
}
--count;
return old_front->data;
}

// 查看队首元素
T peek() const {
if (empty()) {
throw std::out_of_range("Queue is empty");
}
return front->data;
}

// 判断队列是否为空
bool empty() const {
return count == 0;
}

// 获取队列大小
size_t size() const {
return count;
}

// 打印队列内容(调试用)
void print() const {
auto current = front;
while (current) {
std::cout << current->data << " ";
current = current->next;
}
std::cout << std::endl;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 实现一个先入先出的队列


template <typename T>
struct QueueNode {
T data;
QueueNode *next;
QueueNode(const T& value) : data(value), next(nullptr) {} //
};


template <typename T>
class Queue {
public:
Queue() {} // 构造函数
~Queue() {clear()} // 析构函数

void push(const &T value) { // 入队方法
// 创建一个新的Queue节点
QueueNode<T>* new_node = new QueueNode<T>(value);
if (size == 0) {
front = new_node; // 新入队的节点就是front
} else {
rear->next = new_node;
rear = new_node;
}
size++;
}

void pop() { // 出队方法
// 将node 出队列
if (size == 0) return; // 判空
QueueNode<T> *temp_node = front;
front = front->next; // 更新front指针
delete temp_node; // 删除temp_node
size--;
if (size == 0) rear = nullptr; // 如果队列为空,则rear指针为nullptr
}

// 获取队列头

T& getFront() {
return front->data;
}

T& getRear() {
return rear->data;
}

// 清空
void clear() {
while (size > 0) {
pop();
}
}
private:
Queue<T>* front = nullptr; // 队列的头
Queue<T>* rear = nullptr; // 队列的尾
int size = 0; // 队列的长度
}

对比以上内容,一个是指针,需要手动释放,一个是智能指针,不需要手动释放。

右值引用 vs 拷贝构造函数

核心概念

特性 右值引用 (T&&) 拷贝构造函数
绑定对象 临时对象(右值) 左值(具名对象)
核心作用 移动语义(资源转移) 深拷贝(创建独立副本)
生命周期 延长临时对象生存期 无直接影响
典型场景 临时对象处理、容器优化 对象复制、返回值

右值引用工作机制

  1. 绑定规则
  • 仅能绑定右值(临时对象、std::move转换的左值)
  • 通过T&&语法实现,避免拷贝开销
  1. 移动语义
1
2
3
4
5
6
7
8
class Buffer {
public:
Buffer(Buffer&& other) noexcept : data(other.data) {
other.data = nullptr; // 资源转移
}
private:
int* data;
};
  • 直接接管资源指针,源对象进入无效状态

拷贝构造函数演进

  1. 传统问题
  • 浅拷贝风险:指针成员共享内存,导致双重释放
  • 性能瓶颈:大对象深拷贝耗时(如std::vector
  1. 现代优化
  • Rule of Five:自定义移动构造/赋值运算符
  • 智能指针std::unique_ptr自动管理资源,避免手动深拷贝

关键代码对比

场景:字符串类资源管理

1
2
3
4
5
6
7
8
// 拷贝构造(深拷贝)
MyString(const MyString& other) : data(new char[other.size]) {
std::copy(other.data, other.data + other.size, data);
}
// 移动构造(资源转移)
MyString(MyString&& other) noexcept : data(other.data) {
other.data = nullptr;
}
  • 拷贝构造:分配新内存并复制内容
  • 移动构造:直接接管指针,源对象置空

性能对比与建议

指标 移动语义 拷贝构造
时间复杂度 O(1)(仅指针操作) O(n)(数据复制)
内存分配 复用临时对象内存 需要新内存空间
适用场景 临时对象、资源回收 需要独立副本的场景

建议

  1. 优先使用移动语义处理临时对象
  2. 对需持久化的对象使用拷贝构造
  3. 结合std::movestd::forward实现完美转发

HR面

意向度:

很高,很靠前

薪资:

从底线往上给个区间,留有argue的余地

为什么看好新石器的机会

落地机会:全球最大的L4级无人城配(RoboVan)解决方案提供商。覆盖顺丰、京东等头部客户,赛道内的独角兽。而且从现在的趋势看,人力成本的提高,自动驾驶的普及,这种无人配送是大势所趋。

三面的时候的面试官提到过是主机厂,不是tier 1。全链路闭环能力,省去了很多tier 1和主机厂交互沟通的效率浪费。

面试官也都很厉害,技术平台上是有一个学习机会,上升空间。L4级自动驾驶全栈研发(感知-决策-控制),接触无图导航、多模态融合等核心技术。

现在是一个发展的上升期,可以和平台一同成长

本文作者:战斗包子
本文链接:https://paipai121.github.io/2025/11/07/工作/面试/新石器面试/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可