Unity面试算法与C#数据结构性能实战指南

发布时间:2026/10/27 22:35:23
Unity面试算法与C#数据结构性能实战指南

前阵子帮一个团队看简历,一个下午翻了三十多份,写着"精通Unity"的人不少,但真到了手写代码那一轮,能稳稳撑住的没几个。我自己当年第一次面Unity客户端岗,就栽在一道看着很简单的容器题上——对方让我聊聊Dictionary内部怎么处理冲突、扩容时元素怎么搬迁、一次扩容要付出多少代价,我张口只答了句"哈希表嘛,O(1)",后面的话就接不上了。那次之后我才慢慢想明白,Unity面试里的算法考察,跟纯后台开发岗的算法考察根本不是一套东西:后台岗看的是你对抽象数据结构的掌握程度,Unity岗看的是你在每帧16毫秒的预算里,能不能把这些结构用对、用省、用到不掉帧。

这篇东西面向三类人:准备跳槽的在职客户端、校招想进游戏方向的应届生、以及那些写了两年业务逻辑但算法早就还给课本的开发者。我会把Unity面试里算法环节的真实考法、必背模块、游戏场景专属算法、以及白板上怎么把思路讲清楚,一层层拆开。所有代码都是C#,都能直接粘进工程里跑,参数选择也会给出推导过程,不做"大概这样就行"的糊弄。

1. 先搞清楚Unity面试到底在考什么算法

1.1 三类岗位的算法考察权重完全不同

很多人准备Unity面试时踩的第一个坑,是把所有岗位当成一个岗位来准备。实际情况是,招聘方在JD里写的岗位名可能都叫"Unity客户端开发",但面试官关心的事情天差地别,你得先判断自己面的是哪一类。

第一类是业务逻辑客户端,做的是UI流程、活动系统、存档、网络协议对接这类活。这类岗位的算法题往往不难,LeetCode简单到中等难度,但它特别在意你对C#集合的熟悉程度,以及你写出来的代码会不会每帧产生垃圾。面试官常问的是"这个List在Update里每帧Clear会不会有问题"、"字符串拼接在循环里写有什么后果"这种落地问题。

第二类是引擎/工具向客户端,涉及资源加载、编辑器扩展、渲染管线周边。这类会考树结构、图遍历、缓存淘汰策略,有时候会让你现场设计一个LRU或者一个资源引用计数方案。

第三类是战斗/表现向客户端,做技能系统、位移、命中判定、寻路。这是算法考察最硬的一类,A*、空间划分、碰撞检测、插值曲线都可能出现,而且会追问性能。

我建议你在准备之前,先翻一遍岗位描述里的动词:出现"设计""优化""框架"字样的,往第二、三类准备;出现"实现""对接""维护"字样的,把第一类的基本功打牢就够。盲目刷三百道题但对C#内存模型一无所知,是性价比最低的准备方式。

1.2 高频考点的真实分布与准备优先级

下面这张表是我根据自己和身边同行近两年面过的二十多场整理出来的粗略分布,样本不大,但方向感是有的。你可以拿它当准备顺序的参考,而不是当成绝对标准。

考察模块出现频率常见形式准备优先级
C#集合与内存基础极高口述+追问内部实现最高
数组/字符串双指针类高白板手写高
排序(快排/归并/堆)高手写+复杂度追问高
哈希与去重统计高手写+边界追问高
二叉树遍历与递归中高手写递归/迭代两种写法中高
广度优先/深度优先中网格地图场景中
A*与寻路中(战斗岗高)讲思路+写核心循环视岗位
空间划分(四叉树/网格)中(开放世界岗高)设计+复杂度分析视岗位
动态规划中低简单状态转移中低
图论高级算法低极少直接考低

注意一个反常识的点:动态规划在Unity面试里的出现频率明显低于后台岗。原因很实际,游戏客户端日常很少遇到需要DP的场景,面试官也更愿意把有限的面试时间花在你未来半年真会用到的内容上。你要是时间紧张,先把表和前三行吃透,比死磕背包问题划算得多。

还有一点,很多面试官会在你写完代码后问一句"这段代码在Unity里跑,会不会有GC"。这不是刁难,这是他们的日常。你要是能主动指出"这里每次循环都new一个List,应该提到循环外复用",印象分直接拉满。

2. 手撕代码之前,先过C#基本功这一关

2.1 值类型、引用类型与装箱:面试官最爱追问的角落

Unity岗位的算法题,语言层面几乎都是C#,所以面试官有一半的追问火力会落在C#特性上,而不是算法本身。最容易踩的三个点,我按踩坑频率排一下。

第一是装箱。你把一个struct塞进object、塞进非泛型集合、或者调用一个参数是object的方法,就会发生装箱,在堆上分配一块内存。在每帧执行的逻辑里反复装箱,GC压力就上来了。经典反例是用ArrayList存Vector3,或者拿Hashtable当字典。现在没人这么写了,但面试官喜欢问"为什么早期代码里用Hashtable后来换成Dictionary",答案就是泛型避免了装箱拆箱。

第二是struct的拷贝语义。你写一个方法接收Vector3参数,传的是副本;你在List 里按下标取元素,取出来的也是副本,改它不会影响原列表。这个是新手最容易犯的错,写个移动逻辑发现物体不动,查半小时才发现改的是副本。

第三是闭包捕获。在for循环里给委托或Lambda捕获循环变量,最后执行时全部拿到的是终值。这个坑在写事件回调时特别常见。

2.2 数组、List、Dictionary的取舍与扩容真相

这道题几乎是必问的:"什么时候用数组,什么时候用List,什么时候用Dictionary?"

我的回答框架是这样的:长度固定、访问频繁、对内存布局敏感的用数组,比如顶点数据、帧同步的输入缓存;长度动态但以顺序遍历为主、随机插入删除少的用List,比如同屏单位列表、待处理事件队列;需要按key快速查找的用Dictionary,比如用ID查配置、用物体引用查状态。

然后面试官一定会追问List的扩容机制。这里必须答准:List内部维护一个数组,容量不够时新容量取当前容量的两倍,然后把旧数组元素拷贝过去。这意味着两件事——扩容是有拷贝成本的,所以如果你能预估规模,构造时直接传初始容量能省掉多次扩容和中间产生的垃圾;另外扩容后的新数组地址变了,之前拿到的引用就失效了。

我给个能直接跑的验证代码,你自己在工程里打印一下就有体感:

using System; using System.Collections.Generic; using UnityEngine; public class ListCapacityDemo : MonoBehaviour { void Start() { var list = new List<int>(); int lastCapacity = list.Capacity; Debug.Log($"初始容量: {lastCapacity}"); for (int i = 0; i < 100; i++) { list.Add(i); if (list.Capacity != lastCapacity) { Debug.Log($"第{i + 1}个元素插入后,容量 {lastCapacity} -> {list.Capacity}"); lastCapacity = list.Capacity; } } } }

输出会告诉你容量从0跳到4,再到8、16、32……这就是翻倍策略。理解了这个,你就能回答"为什么已知要存1000个元素时应该写new List<int>(1000)"——省掉十几次扩容拷贝,也省掉十几块中间数组的垃圾。

Dictionary的追问会更狠一些。你需要能说出:它用哈希桶加链式结构(现代实现里桶内存的是索引,冲突元素通过next指针串起来),默认负载因子下元素数达到桶数时触发扩容,扩容要重新计算所有元素的桶位置。所以高频增删的大字典是性能杀手,能预估规模就预估。

2.3 现场手写一个动态数组:把原理变成代码

有些面试官不满足于你口述,会直接让你手写一个简化版动态数组。这题不难,但要写得干净,几个边界必须处理好。

using System; public class SimpleList<T> { private T[] _items; private int _count; public int Count => _count; public int Capacity => _items.Length; public T this[int index] { get { if (index < 0 || index >= _count) throw new ArgumentOutOfRangeException(nameof(index)); return _items[index]; } set { if (index < 0 || index >= _count) throw new ArgumentOutOfRangeException(nameof(index)); _items[index] = value; } } public SimpleList(int capacity = 4) { _items = new T[capacity < 4 ? 4 : capacity]; _count = 0; } public void Add(T item) { if (_count == _items.Length) Resize(_items.Length * 2); _items[_count++] = item; } public void Insert(int index, T item) { if (index < 0 || index > _count) throw new ArgumentOutOfRangeException(nameof(index)); if (_count == _items.Length) Resize(_items.Length * 2); // 从后往前搬,避免覆盖 for (int i = _count; i > index; i--) _items[i] = _items[i - 1]; _items[index] = item; _count++; } public bool Remove(T item) { int idx = IndexOf(item); if (idx < 0) return false; RemoveAt(idx); return true; } public void RemoveAt(int index) { if (index < 0 || index >= _count) throw new ArgumentOutOfRangeException(nameof(index)); for (int i = index; i < _count - 1; i++) _items[i] = _items[i + 1]; _count--; _items[_count] = default; // 清引用,防泄漏 } public int IndexOf(T item) { var cmp = System.Collections.Generic.EqualityComparer<T>.Default; for (int i = 0; i < _count; i++) if (cmp.Equals(_items[i], item)) return i; return -1; } private void Resize(int newCapacity) { var newArr = new T[newCapacity]; Array.Copy(_items, newArr, _count); _items = newArr; } }

写这段的时候有几个细节值得在面试里主动点出来,都是加分项。第一,RemoveAt之后把尾部位置置为default,否则引用类型元素会一直被数组引用着,垃圾回收器收不掉,这是真实项目里排查内存泄漏时常见的一类原因。第二,Insert必须从后往前搬,从前往后会把自己覆盖掉。第三,索引器的越界检查不能省,虽然会有一点点开销,但这是容器的底线。第四,比较元素用EqualityComparer .Default而不是直接Equals,这样对值类型不会装箱。

提示:手写容器题如果时间紧张,优先保证Add、索引器、Resize三个部分的正确性,Insert和RemoveAt可以只写思路。面试官更在意你有没有意识到扩容和引用清理这两件事。

3. 必须闭眼写出来的基础算法模块

3.1 排序:为什么Unity面试偏爱快排胜过归并

排序题在Unity面试里出现得非常多,但观察下来,问快排的概率明显高于归并。原因不复杂:快排是原地排序,空间复杂度O(log n)只花在递归栈上,这对内存敏感的游戏场景更友好;而归并需要一块等长的辅助数组,在移动端是要认真算账的。所以面试官更愿意让你写快排,然后追问"最坏情况怎么办"。

快排的关键不在于能不能写出来,而在于你会不会做基准值选取。固定取首元素,在近乎有序的数据上会退化成O(n²),这在游戏里不是理论问题——比如你给一批已经按ID排好序的单位再排一次,就正好撞上最坏情况。常见的补救是三数取中:取首、中、尾三个元素的中位数当基准。

public static class SortUtil { public static void QuickSort(int[] arr) { if (arr == null || arr.Length < 2) return; QuickSortInternal(arr, 0, arr.Length - 1); } private static void QuickSortInternal(int[] arr, int left, int right) { // 小区间用插入排序,减少递归开销 if (right - left < 16) { InsertionSort(arr, left, right); return; } int pivotIndex = MedianOfThree(arr, left, right); Swap(arr, pivotIndex, right); // 基准挪到末尾 int pivot = arr[right]; int i = left; for (int j = left; j < right; j++) { if (arr[j] < pivot) { Swap(arr, i, j); i++; } } Swap(arr, i, right); QuickSortInternal(arr, left, i - 1); QuickSortInternal(arr, i + 1, right); } private static int MedianOfThree(int[] arr, int left, int right) { int mid = left + (right - left) / 2; if (arr[left] > arr[mid]) Swap(arr, left, mid); if (arr[left] > arr[right]) Swap(arr, left, right); if (arr[mid] > arr[right]) Swap(arr, mid, right); return mid; } private static void InsertionSort(int[] arr, int left, int right) { for (int i = left + 1; i <= right; i++) { int cur = arr[i]; int j = i - 1; while (j >= left && arr[j] > cur) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = cur; } } private static void Swap(int[] arr, int a, int b) { if (a == b) return; int tmp = arr[a]; arr[a] = arr[b]; arr[b] = tmp; } }

这段代码里有三个可以主动讲出来的工程考量。小区间切插入排序的阈值一般取8到16,因为递归调用本身有开销,小数组上快排并不比插入排序快。三数取中缓解了有序数据退化的问题,但它不是银弹,遇到大量重复元素仍然会退化,这时候要提三路划分。Swap里判断a==b看着多余,实际上当两个下标相同时,异或交换法会把它清零,用临时变量的写法虽然没这个问题,但加上判断也能省一次无谓的内存写。

如果面试官继续问"如果要稳定排序呢",你就答归并,并说明归并的辅助数组可以预先分配好反复复用,不要每次递归都new,这样能把GC压力降到最低。

3.2 二分查找:会写的人多,写对边界的人少

二分是那种"人人都会,一半人写错"的题。Unity面试里它经常出现在一个具体场景下,比如"给你一个按时间戳排序的技能CD配置表,怎么快速查到当前时间对应的阶段"。这时候你要做的不是背模板,而是先确认两件事:数组是否有序,以及要找的是第一个满足条件的还是任意一个。

下面这个是查找第一个大于等于目标值的位置,也就是常说的下界,实际业务里用得最多:

public static int LowerBound(int[] arr, int target) { if (arr == null || arr.Length == 0) return -1; int left = 0, right = arr.Length; // 注意是长度,不是长度-1 while (left < right) { int mid = left + (right - left) / 2; // 防溢出写法 if (arr[mid] < target) left = mid + 1; else right = mid; } return left < arr.Length ? left : -1; }

几个必须说清楚的点。mid用left + (right-left)/2而不是(left+right)/2,防止两数相加溢出,虽然int范围很大,但这个习惯面试官会认。right初始化为长度而不是长度减一,配合左闭右开区间,能让循环终止条件干净。返回值要判断是否越界,找不到时返回-1而不是直接返回left。

还有个常被追问的延伸:如果数据是浮点数、要做精度逼近怎么办?答案是固定迭代次数,比如循环60次,比用epsilon判断更可靠,因为浮点误差在不同量级下表现不一致。

3.3 递归与字符串:从斐波那契聊到不产生垃圾的写法

递归题在Unity面试里通常不单独出现,而是嵌在树的遍历、路径搜索里。但有一道题几乎可以当模板用:斐波那契。面试官让你写,你写个朴素递归,他一定问"时间复杂度多少、怎么优化"。这是给你展示递进思路的机会。

朴素递归是O(2^n),因为存在大量重复子问题。优化路径有两条:记忆化搜索(自顶向下加缓存)和迭代(自底向上)。在游戏场景里我更推荐迭代,因为它是O(1)空间。

// 迭代版,O(n)时间 O(1)空间 public static long FibIterative(int n) { if (n < 0) throw new System.ArgumentOutOfRangeException(nameof(n)); if (n < 2) return n; long a = 0, b = 1; for (int i = 2; i <= n; i++) { long c = a + b; a = b; b = c; } return b; } // 记忆化版,适合状态转移更复杂的场景 public static long FibMemo(int n, long[] memo = null) { if (n < 0) throw new System.ArgumentOutOfRangeException(nameof(n)); memo ??= new long[n + 1]; if (n < 2) return n; if (memo[n] != 0) return memo[n]; memo[n] = FibMemo(n - 1, memo) + FibMemo(n - 2, memo); return memo[n]; }

字符串题在Unity面试里有个特殊考法,就是"这段代码会不会产生垃圾"。比如让你统计一个字符串里每个字符出现的次数,你要是写foreach (char c in str),在很多实现里是不会额外分配的;但你要是写str.Split(','),那就不是一次分配的事了。更进一步,如果面试官问"要给一个战斗日志做高频字符串匹配怎么办",你应该主动提到用StringBuilder做拼接、用字符数组或只读的字符视图避免Substring——Substring在很多语言实现里会复制一份新字符串,高频调用就是高频分配。

注意:面试里只要涉及字符串处理,主动补一句"这里会不会产生临时字符串",比多写十行代码更能体现你的工程素养。

4. 游戏场景专属算法:这一块才是Unity岗的分水岭

4.1 A*寻路:开放列表用堆还是用有序表

A*几乎是战斗岗和开放世界岗的必问题。很多人能背出"f = g + h",但一被追问数据结构就露馅。我按面试里最容易被问到的顺序讲。

核心三个容器:开放列表(待探索节点)、关闭列表(已探索节点)、父节点记录(用于回溯路径)。其中开放列表每次都要取f值最小的节点,所以它的实现直接决定了算法效率。

开放列表实现取最小插入适用规模
无序列表线性扫描O(n)O(1)节点数很少,几十个以内
有序数组插入O(1)O(n) 搬移中小规模
二叉堆O(log n)O(log n)主流选择
斐波那契堆O(log n) 摊还更优O(1)理论优越但实现复杂

实际工程里几乎都用二叉堆,因为实现难度和收益的性价比最高。关闭列表用HashSet或者用一个bool数组按格子索引标记,后者在网格地图上更快也更省内存。

启发函数h的选择也有讲究。曼哈顿距离适合四方向移动,**对角距离(也叫八方向距离)**适合允许斜着走的地图,欧几里得距离适合完全自由移动。如果你选的h永远不大于真实代价,A保证能找到最优路径;如果h估大了,搜索会更快但可能不是最短路径。面试官问"为什么有时候A找出来的路看着很怪",答案八成就是h选得和移动规则不匹配。

给一段能直接跑的核心循环,用堆实现开放列表:

using System; using System.Collections.Generic; public class MinHeap<T> { private readonly List<T> _data = new List<T>(); private readonly Comparison<T> _cmp; public MinHeap(Comparison<T> cmp) { _cmp = cmp; } public int Count => _data.Count; public bool IsEmpty => _data.Count == 0; public void Push(T item) { _data.Add(item); int i = _data.Count - 1; while (i > 0) { int parent = (i - 1) / 2; if (_cmp(_data[i], _data[parent]) >= 0) break; (_data[i], _data[parent]) = (_data[parent], _data[i]); i = parent; } } public T Pop() { if (_data.Count == 0) throw new InvalidOperationException("heap empty"); T top = _data[0]; int last = _data.Count - 1; _data[0] = _data[last]; _data.RemoveAt(last); int i = 0, n = _data.Count; while (true) { int l = i * 2 + 1, r = l + 1, smallest = i; if (l < n && _cmp(_data[l], _data[smallest]) < 0) smallest = l; if (r < n && _cmp(_data[r], _data[smallest]) < 0) smallest = r; if (smallest == i) break; (_data[i], _data[smallest]) = (_data[smallest], _data[i]); i = smallest; } return top; } }

A*主体逻辑不复杂,但有一个高频追问:同一个格子被多次加入开放列表怎么办?标准做法是维护一个记录每个格子当前最优g值的数组,当新路径的g值反而更大时直接跳过,这比在堆里做删除操作简单得多。堆里允许存在过期条目,弹出时检查一下它的g值是否还等于记录值,不等就丢弃。这个技巧叫"惰性删除",一定要讲出来。

4.2 空间划分:网格、四叉树什么时候用哪个

如果说A*考的是搜索,空间划分考的就是"你有没有规模意识"。面试官的场景往往是这样的:地图上有几千个怪物,你要每帧找出玩家周围一定范围内的敌人,怎么写?

最朴素的写法是遍历所有怪物算距离,复杂度O(n)。几千个单位每帧算一次距离,在PC上可能还好,在移动端就够呛了。优化方向有两级。

第一级是均匀网格。把地图切成固定大小的格子,每个单位注册到自己所在的格子里。查询时只检查玩家所在格子及周围八个格子。实现极简单,查询也快,问题在于分布不均时浪费严重——所有单位挤在一个角落,网格就退化成线性扫描了。

第二级是四叉树(2D)或八叉树(3D)。每个节点在元素数超过阈值时分裂成四个子节点,把元素按位置分下去。它适应不均匀分布,但维护成本更高:单位移动时要更新树中的位置,跨节点移动需要先删再插。

下面是一个简化版的四叉树插入逻辑,重点看分裂和归属判断:

using System.Collections.Generic; public class QuadNode { public Rect Bounds; public int Capacity = 4; public List<Vector2> Points; public QuadNode[] Children; public bool IsLeaf => Children == null; public QuadNode(Rect bounds, int capacity) { Bounds = bounds; Capacity = capacity; Points = new List<Vector2>(capacity); } public bool Insert(Vector2 p) { if (!Bounds.Contains(p)) return false; if (IsLeaf) { if (Points.Count < Capacity) { Points.Add(p); return true; } Split(); } for (int i = 0; i < 4; i++) if (Children[i].Insert(p)) return true; return false; } private void Split() { float halfW = Bounds.width * 0.5f; float halfH = Bounds.height * 0.5f; float x = Bounds.x, y = Bounds.y; Children = new QuadNode[4]; Children[0] = new QuadNode(new Rect(x, y, halfW, halfH), Capacity); Children[1] = new QuadNode(new Rect(x + halfW, y, halfW, halfH), Capacity); Children[2] = new QuadNode(new Rect(x, y + halfH, halfW, halfH), Capacity); Children[3] = new QuadNode(new Rect(x + halfW, y + halfH, halfW, halfH), Capacity); var old = Points; Points = new List<Vector2>(Capacity); foreach (var pt in old) for (int i = 0; i < 4; i++) if (Children[i].Insert(pt)) break; } }

真实工程里还有两个必须处理的问题。一是单位正好落在分割线上,属于哪个子节点会导致重复插入或漏插,常见做法是只在完全落在某一边时才下放,跨线的留在父节点。二是对象池化,四叉树在单位密集时分裂频繁,每次new节点都是垃圾,工程里基本都会给节点做对象池。这两点面试时主动提,能明显拉开和其他候选人的差距。

4.3 碰撞检测:包围盒、包围球与分离轴的分层策略

面试官问碰撞,考的不是让你写一套物理引擎,而是看你有没有"分层"的意识。真实的做法从来不是一上来就算精确碰撞,而是粗略筛一遍、再精确算一遍。

第一层用包围球或轴对齐包围盒。包围球的距离判断最快,只要比较距离平方和半径平方之和,注意这里要避免开方。轴对齐包围盒判断相交更简单,四次比较就够:

public struct AABB2D { public float MinX, MinY, MaxX, MaxY; public bool Intersects(AABB2D other) { return MinX <= other.MaxX && MaxX >= other.MinX && MinY <= other.MaxY && MaxY >= other.MinY; } // 比较距离时用平方,避免开方 public static bool SphereOverlap(Vector2 c1, float r1, Vector2 c2, float r2) { float dx = c1.x - c2.x; float dy = c1.y - c2.y; float rSum = r1 + r2; return dx * dx + dy * dy <= rSum * rSum; } }

第二层才进入精确判定。如果是矩形和任意朝向,就要上分离轴定理:把两个多边形的所有边的法线当作候选轴,逐一投影,只要有一条轴上两边投影不重叠,就说明没碰撞。轴的数量是边数之和,所以多边形的边数要尽量少,这也是为什么游戏里碰撞体基本都是简化过的凸多边形,而不是美术给的原始模型。

提示:面试被问到碰撞时,先说分层策略(先粗筛后精算),再说具体算法。直接扑上去讲分离轴,容易给人"只懂局部"的印象。

4.4 游戏循环里的数据结构:环形缓冲与时间轮

还有一类题,不算传统算法,但Unity面试里特别常见,因为它直指游戏开发的核心——每帧都在跑的东西,不能每帧都分配内存。

典型的问法是:"我要记录最近100帧的帧率用于显示和统计,怎么实现?"很多人的第一反应是List,加满了RemoveAt(0),这有两个问题:RemoveAt是O(n)搬移,而且频繁增删可能触发扩容和垃圾。正确做法是环形缓冲区,固定长度数组加一个写指针,写指针绕回去就覆盖最旧的数据。

public class RingBuffer<T> { private readonly T[] _data; private int _head; // 下一个写入位置 private int _count; public RingBuffer(int capacity) { _data = new T[capacity > 0 ? capacity : 1]; } public int Count => _count; public void Add(T item) { _data[_head] = item; _head = (_head + 1) % _data.Length; if (_count < _data.Length) _count++; } public T this[int index] { get { if (index < 0 || index >= _count) throw new System.ArgumentOutOfRangeException(); // 从最旧的元素开始算 int real = (_head - _count + index + _data.Length * 2) % _data.Length; return _data[real]; } } }

再进阶一点会问时间轮,用于处理大量定时器,比如几千个技能CD。朴素做法是每个定时器挂一个协程或一个计时变量,每帧遍历所有定时器减时间,单位数一多就爆。时间轮把时间切成槽,定时器按到期时间挂到对应槽里,指针每秒(或每帧)走一格,只处理当前槽里的定时器,复杂度从O(n)降到接近O(1)。这个思路讲清楚就足够,不一定要写完整实现。

5. 白板上怎么把思路讲清楚

5.1 复杂度分析的讲法:别只报结论

面试官让你分析复杂度,很多人只丢一句"时间O(n),空间O(1)"就没了,这是浪费机会。有说服力的讲法是先说指标来源,再说最坏情况,最后说实际场景下的表现。

举个例子,你写了快排。可以这样说:"平均O(n log n),最坏O(n²)出现在每次基准都取到极值的情况,比如已经有序的数据。我这里用三数取中缓解了,但没有完全消除,要彻底避免可以用随机基准或者改用归并。空间上递归栈平均O(log n),最坏O(n)。"这三句话包含了平均值、最坏值、以及补救措施,面试官立刻知道你理解深度在哪。

还有个技巧:主动做常数因子的说明。比如两个都是O(n)的写法,一个每轮做两次取模,一个只做一次比较,在每帧调用几千次的场景下差距是实打实的。提到这一层,说明你不是只会在纸上算复杂度。

5.2 一时写不出来时的救场顺序

白板卡壳是常事,关键是怎么把局面拉回来。我自己的顺序是这样:

第一步,先复述题意并确认边界。"我理解是输入保证非空,元素可能有重复,对吗?"这一步既争取了思考时间,也避免了方向性错误。

第二步,说出暴力解法。哪怕是双重循环,先把它讲出来并给出复杂度。面试官通常会说"能不能优化",这就给了你明确的下一步,比干坐着强。

第三步,从暴力解法里找重复计算。绝大多数优化都是从"这块算过又算了"来的,哈希表、缓存、排序后双指针,基本覆盖了八成场景。

第四步,如果确实想不出来,直接问提示。"我目前想到的方向是X,但卡在Y,能不能给个方向?"这不是扣分项,死撑十分钟写出一堆错代码才扣分。

注意:写完代码一定要主动跑一个样例。拿最简单的输入走一遍,很多边界错误自己就暴露了,比等面试官指出来体面得多。

6. 踩过的坑与排查实录

6.1 高频翻车点速查表

下面这些是我和身边人真实翻过的车,按场景整理成速查表,面试前扫一眼比刷题有用。

症状根因处理方式
代码逻辑对但被判性能不行循环内new容器或字符串拼接容器提到循环外复用,拼接用StringBuilder
手写快排超时固定首元素做基准,数据有序退化三数取中或随机基准
二分死循环区间开闭不统一,mid未推进统一用左闭右开,left=mid+1
A* 路径不是最短启发函数估大了,或移动规则与h不匹配h取对应距离度量,保证不高估
四叉树插入丢对象元素落在分割线上,子节点都不包含跨线的留在父节点
排序结果不稳定导致表现抖动用了快排但业务需要稳定换归并,或加次级排序键
每帧统计导致卡顿每帧分配List或闭包捕获环形缓冲+预分配
struct改了没生效拿到的是副本用类,或把结果写回原容器

6.2 代码之外的几个坑,一样会挂人

第一个坑是只写不管边界。空数组、单元素、null、负数,这四个是必查项。我见过一个候选人把整个算法写得很漂亮,最后面试官丢了个空数组进去,直接空引用异常。

第二个坑是忽略了Unity的运行环境。有些人写出的代码在纯C#控制台跑得飞快,但里面用了System.Linq的链式调用,在每帧逻辑里就是持续的迭代器分配。面试官问一句"这段放Update里行不行",你要能立刻反应过来。

第三个坑是沟通问题。白板上闷头写十分钟不说话,面试官不知道你在想什么,也没法给提示。正确的节奏是写一段讲一段,把"我为什么这么选"说出来,哪怕最后没写完,思路分也拿到了。

第四个坑是对追问准备不足。"如果数据量从一万变成一百万呢""如果要求支持并发呢""如果内存只有十分之一呢",这类追问没有标准答案,考的是你能不能顺着约束调整方案。我的经验是,回答这类问题时先承认当前方案的瓶颈在哪,再给出调整方向,不要硬说现在的方案也能扛。

最后分享一个我自己一直在用的准备方法:把每道做过的题都问自己三个问题——这段代码在Unity里跑会产生垃圾吗、数据量放大一百倍还成立吗、如果面试官追问底层实现我能不能答上来。三个问题都能顺畅回答,这道题才算真的过了,比刷十道新题都值。