UE 面试题库 · 整理版

第13章 数据结构面试题

本章共收录 37 道题,覆盖:UE 容器(TArray/TMap/TSet/TSparseArray/TIndirectArray)、链表、栈/队列、哈希、树与二叉树、堆、图、对象池、状态机、内存与缓存优化及复杂度分析。

一、数组与字符串

1.1 环形缓冲区(Ring Buffer)的实现与应用

★★ 中等

考点: 读写指针、空/满判断、无锁 SPSC 环形缓冲、音频/网络缓冲应用

参考答案:

  1. 环形缓冲区是"带环绕"的定长 FIFO:Head(读)/Tail(写)指针随读写前移,到达容量边界时取模回绕,入队/出队均为 O(1);
  2. 空/满判断两种方案:维护 Count 计数(空=Count==0,满=Count==Capacity);或牺牲一个槽位(Tail+1==Head 为满);
  3. 容量取 2 的幂时可用位运算 (p+1)&(Cap-1) 替代取模,进一步提速;
  4. 单生产者单消费者(SPSC)可实现无锁:Head/Tail 分置不同缓存行(alignas(64))避免 false sharing,Tail 用 release、Head 用 acquire 内存序;
  5. 游戏应用:音频解码→播放缓冲、网络消息收发、输入缓冲、帧历史(网络回滚)。

代码示例:

template<typename T>
class TRingBuffer {
    TArray<T> Buffer;
    int32 Head = 0, Tail = 0, Count = 0, Capacity;
public:
    TRingBuffer(int32 Cap) : Capacity(Cap) { Buffer.SetNum(Cap); }
    bool Enqueue(const T& Item) {
        if (Count == Capacity) return false;
        Buffer[Tail] = Item; Tail = (Tail + 1) % Capacity; Count++;
        return true;
    }
    bool Dequeue(T& Out) {
        if (Count == 0) return false;
        Out = Buffer[Head]; Head = (Head + 1) % Capacity; Count--;
        return true;
    }
};

// 无锁 SPSC 环形缓冲:Head/Tail 分置不同缓存行
template<typename T>
class TLockFreeRingBuffer {
    TArray<T> Buffer;
    int32 Capacity;
    alignas(64) std::atomic<int32> Head{0}; // 消费者读
    alignas(64) std::atomic<int32> Tail{0}; // 生产者写
public:
    bool Enqueue(const T& Item) {
        int32 Cur = Tail.load(std::memory_order_relaxed);
        int32 Next = (Cur + 1) % Capacity;
        if (Next == Head.load(std::memory_order_acquire)) return false;
        Buffer[Cur] = Item;
        Tail.store(Next, std::memory_order_release);
        return true;
    }
    bool Dequeue(T& Out) {
        int32 Cur = Head.load(std::memory_order_relaxed);
        if (Cur == Tail.load(std::memory_order_acquire)) return false;
        Out = Buffer[Cur];
        Head.store((Cur + 1) % Capacity, std::memory_order_release);
        return true;
    }
};

1.2 稀疏集合(Sparse Set)与 ECS 存储

★★★ 困难

考点: Sparse Set 双数组原理、O(1) 增删查、与 TSparseArray 的区别、ECS 组件存储

参考答案:

  1. Sparse Set 由两个数组组成:Dense(紧凑存储实际元素,遍历缓存友好)+ Sparse(元素 ID → Dense 下标);
  2. Add:Dense 尾部追加并记录 Sparse[Id];Remove:用 Dense 尾元素填补被删位置(swap-and-pop),O(1);
  3. 遍历直接遍历 Dense,完全连续、缓存友好;代价是 Sparse 数组 O(MaxId) 的空间开销;
  4. 与 TSparseArray 对比:Sparse Set 删除后元素会移动(索引不稳定)但遍历快;TSparseArray 删除不移位(索引稳定)但遍历需跳过空洞;
  5. ECS 中每个组件类型一个 Sparse Set + 连续数据数组,是 EnTT、UE Mass Entity 的核心存储方案。

代码示例:

template<int32 MaxId>
class TSparseSet {
    TArray<int32> Dense;   // 实际 ID,紧凑排列
    int32 Sparse[MaxId];   // ID -> Dense 下标
public:
    TSparseSet() { FMemory::Memset(Sparse, 0xFF, sizeof(Sparse)); }
    bool Contains(int32 Id) const {
        return Id >= 0 && Id < MaxId && Sparse[Id] != -1 && Dense[Sparse[Id]] == Id;
    }
    void Add(int32 Id) {
        if (Contains(Id)) return;
        Sparse[Id] = Dense.Num(); Dense.Add(Id);
    }
    void Remove(int32 Id) {
        if (!Contains(Id)) return;
        int32 Idx = Sparse[Id], Last = Dense.Last();
        Dense[Idx] = Last; Sparse[Last] = Idx;   // 尾元素填补
        Dense.RemoveAt(Dense.Num() - 1); Sparse[Id] = -1;
    }
    int32 Num() const { return Dense.Num(); }
};

1.3 Trie 前缀树(自动补全与敏感词过滤)

★★ 中等

考点: Trie 结构、插入/查找/前缀匹配 O(m)、自动补全、压缩 Trie(Radix Tree)

参考答案:

  1. Trie(字典树)按字符串前缀组织字符,插入/查找/前缀匹配复杂度 O(m)(m 为字符串长度),与词表规模无关;
  2. 节点用"子字符映射 + 结束标记 + 词频"表示;游戏应用:控制台命令补全、技能名查找、聊天敏感词过滤;
  3. 自动补全:找到前缀节点后 DFS 收集单词,按词频排序截取 Top N;
  4. 缺点:每个节点都有子映射,空间开销大;可用压缩 Trie(Radix Tree)合并单链节点优化;
  5. 延伸:Aho-Corasick 多模式匹配基于 Trie;FName 哈希表 vs Trie 的取舍。

代码示例:

class FTrieNode {
public:
    TMap<TCHAR, TUniquePtr<FTrieNode>> Children;
    bool bIsEndOfWord = false;
    int32 Frequency = 0;
    FString Word;
};

class FTrie {
    TUniquePtr<FTrieNode> Root;
public:
    FTrie() { Root = MakeUnique<FTrieNode>(); }
    void Insert(const FString& Word, int32 Freq = 1) {
        FTrieNode* Cur = Root.Get();
        for (TCHAR Ch : Word) {
            if (!Cur->Children.Contains(Ch))
                Cur->Children.Add(Ch, MakeUnique<FTrieNode>());
            Cur = Cur->Children[Ch].Get();
        }
        Cur->bIsEndOfWord = true;
        Cur->Frequency += Freq;
        Cur->Word = Word;
    }
    bool Search(const FString& Word) const {
        const FTrieNode* N = FindNode(Word);
        return N && N->bIsEndOfWord;
    }
    bool StartsWith(const FString& Prefix) const { return FindNode(Prefix) != nullptr; }
private:
    const FTrieNode* FindNode(const FString& S) const {
        const FTrieNode* Cur = Root.Get();
        for (TCHAR Ch : S) {
            const auto* Child = Cur->Children.Find(Ch);
            if (!Child) return nullptr;
            Cur = Child->Get();
        }
        return Cur;
    }
};

二、链表

2.1 TLinkedList 与 TDoubleLinkedList 的区别与使用

★ 简单

考点: 单/双链表结构、插入删除特性、使用场景、与 TArray 对比

参考答案:

  1. TLinkedList(单向):仅 Next 指针,只能从头向后遍历,删除需知道前驱节点;
  2. TDoubleLinkedList(双向):Prev/Next 两个指针,支持双向遍历,已知节点指针时 O(1) 删除;
  3. 两者均非连续内存、缓存不友好,且占用额外指针内存;
  4. 游戏中使用较少,主要用于"频繁中间插入删除且不需要随机访问"的场景,如 LRU 缓存淘汰列表、事件队列;
  5. 大多数情况推荐 TArray / TSparseArray(连续内存、缓存友好)。

代码示例:

TDoubleLinkedList<FString> List;
List.AddTail(TEXT("First"));
List.AddTail(TEXT("Second"));
List.AddHead(TEXT("Zero"));
for (auto It = List.CreateIterator(); It; ++It) {
    UE_LOG(LogTemp, Log, TEXT("%s"), **It);
}
TDoubleLinkedList<FString>::TDoubleLinkedListNode* Node = List.GetTail();
List.RemoveNode(Node); // 已知节点 O(1) 删除

2.2 跳表(Skip List)的原理与应用

★★★ 困难

考点: 多层索引链表、概率性平衡、查找/插入/删除 O(log n)、与平衡树对比

参考答案:

  1. 跳表是带多层索引的有序链表:底层全链表有序,上层每约 2 个节点抽一个作"快速通道";
  2. 节点层数随机生成(p=0.5 时期望层数 2,超过 k 层的概率为 2^-k,指数衰减),实现"概率性平衡",无需旋转操作;
  3. 查找/插入/删除平均 O(log n);范围查询天然支持(沿底层链表顺序遍历);
  4. 与红黑树/AVL 对比:实现简单、范围查询友好、并发友好(局部锁粒度细);空间开销略大(多层指针);
  5. 经典应用:Redis 有序集合(ZSet);游戏排行榜(有序 + 频繁增删)可选方案;UE 无内置跳表,TSortedMap 基于红黑树。

代码示例:

template<typename T>
class TSkipList {
    struct FNode {
        T Value;
        TArray<FNode*> Forward; // 每层的前向指针
        FNode(int32 Lvl, const T& V) : Value(V) { Forward.SetNum(Lvl + 1); }
    };
    FNode* Head;
    int32 MaxLevel, CurrentLevel = 0;
    int32 RandomLevel() const {
        int32 L = 0;
        while (FMath::FRand() < 0.5f && L < MaxLevel) L++;
        return L;
    }
public:
    TSkipList(int32 InMax = 16) : MaxLevel(InMax) { Head = new FNode(MaxLevel, T()); }
    bool Find(const T& V) const {
        FNode* Cur = Head;
        for (int32 i = CurrentLevel; i >= 0; --i)
            while (Cur->Forward[i] && Cur->Forward[i]->Value < V)
                Cur = Cur->Forward[i];
        Cur = Cur->Forward[0];
        return Cur && Cur->Value == V;
    }
    void Insert(const T& V) {
        TArray<FNode*> Update; Update.SetNum(MaxLevel + 1);
        FNode* Cur = Head;
        for (int32 i = CurrentLevel; i >= 0; --i) {
            while (Cur->Forward[i] && Cur->Forward[i]->Value < V) Cur = Cur->Forward[i];
            Update[i] = Cur;
        }
        Cur = Cur->Forward[0];
        if (Cur && Cur->Value == V) return;
        int32 NewLevel = RandomLevel();
        if (NewLevel > CurrentLevel) {
            for (int32 i = CurrentLevel + 1; i <= NewLevel; ++i) Update[i] = Head;
            CurrentLevel = NewLevel;
        }
        FNode* NewNode = new FNode(NewLevel, V);
        for (int32 i = 0; i <= NewLevel; ++i) {
            NewNode->Forward[i] = Update[i]->Forward[i];
            Update[i]->Forward[i] = NewNode;
        }
    }
};

三、栈与队列

3.1 TQueue 与 TStack 的实现方式和使用场景

★ 简单

考点: FIFO/LIFO、TQueue 链表/数组两种模式、TStack、使用场景

参考答案:

  1. TQueue(FIFO):默认 TLinkedList 实现(EQueueMode::List),可切换 TArray 模式(EQueueMode::Array);API:Enqueue/Dequeue/Peek;
  2. TStack(LIFO):默认 TArray 实现,可切换链表模式;API:Push/Pop/Peek;
  3. TQueue 场景:异步任务队列、消息分发、渲染线程命令队列;
  4. TStack 场景:编辑器撤销/重做系统、DFS 遍历、函数调用栈模拟。

代码示例:

TQueue<FTaskData> TaskQueue;
TaskQueue.Enqueue(FTaskData(TEXT("LoadLevel")));
TaskQueue.Enqueue(FTaskData(TEXT("SpawnActor")));
FTaskData Task;
while (TaskQueue.Dequeue(Task)) { ProcessTask(Task); }

TStack<FCommand> UndoStack;
UndoStack.Push(FMoveCommand(Actor, Delta));
FCommand Cmd;
if (UndoStack.Pop(Cmd)) { Cmd.Undo(); }

3.2 无锁线程安全队列(SPSC / MPSC)

★★ 中等

考点: SPSC 无锁、MPSC + CAS、ABA 问题、false sharing、渲染/工作线程任务队列

参考答案:

  1. UE 的 TQueue 支持两种线程安全模式:Spsc(单生产者单消费者)无锁;Mpsc(多生产者单消费者)用 CAS 保护 Tail;
  2. SPSC 无需原子操作(读写端各只有一个线程),仅需保证 Head/Tail 分置不同缓存行,避免 false sharing;
  3. MPSC 入队用 Compare-And-Swap 循环尝试更新 Tail;需注意 ABA 问题(用 epoch counter、hazard pointer 等方案解决);
  4. 应用场景:GameThread→RenderThread 渲染命令队列、多个工作线程→主线程的结果收集、异步加载回调;
  5. 延伸:FCriticalSection / FRWLock 的有锁队列对比;TaskGraph 的任务队列。

代码示例:

// MPSC 入队核心:CAS 循环
template<typename T>
bool MPSCEnqueue(const T& Item) {
    FNode* NewNode = new FNode{Item};
    FNode* OldTail;
    do {
        OldTail = Tail.Load(std::memory_order_relaxed);
        NewNode->Next = OldTail->Next;
    } while (!Tail.CompareExchangeWeak(OldTail, NewNode,
                                       std::memory_order_release,
                                       std::memory_order_relaxed));
    OldTail->Next = NewNode;
    return true;
}

// SPSC 出队:只操作 Head
bool Dequeue(T& Result) {
    FNode* NewHead = Head->Next;
    if (!NewHead) return false;
    Result = NewHead->Value;
    Head = NewHead;
    return true;
}

3.3 优先队列(二叉堆)与 A* 寻路应用

★★ 中等

考点: 最小堆实现、sift up/sift down、A* 的 OpenList、UE Algo 堆操作

参考答案:

  1. 二叉堆是完全二叉树的数组表示:父=(i-1)/2,左子=2i+1,右子=2i+2;Push/Pop O(log n),Peek O(1);
  2. Push:尾部插入后 SiftUp;Pop:堆顶换到尾部后 SiftDown;
  3. A* 用最小堆作 OpenList(按 F=G+H 排序),配合 TSet 作 ClosedList(O(1) 查重);启发式可选 Octile/Manhattan/欧氏距离;
  4. UE 中可用 Algo::Heapify/HeapPush/HeapPop 把 TArray 当堆用,或用 TBinaryHeap;
  5. 其他应用:行为树优先级调度、事件定时调度(按触发时间)。

代码示例:

template<typename T, typename TCompare = TLess<T>>
class FBinaryHeap {
    TArray<T> Heap;
    void SiftUp(int32 I) {
        TCompare C;
        while (I > 0) {
            int32 P = (I - 1) / 2;
            if (C(Heap[I], Heap[P])) { Swap(Heap[I], Heap[P]); I = P; }
            else break;
        }
    }
    void SiftDown(int32 I) {
        TCompare C; int32 Size = Heap.Num();
        while (true) {
            int32 S = I, L = 2*I+1, R = 2*I+2;
            if (L < Size && C(Heap[L], Heap[S])) S = L;
            if (R < Size && C(Heap[R], Heap[S])) S = R;
            if (S != I) { Swap(Heap[I], Heap[S]); I = S; } else break;
        }
    }
public:
    void Push(const T& Item) { Heap.Add(Item); SiftUp(Heap.Num()-1); }
    T Pop() {
        T R = Heap[0]; Heap[0] = Heap.Last(); Heap.RemoveAt(Heap.Num()-1);
        if (Heap.Num() > 0) SiftDown(0);
        return R;
    }
    bool IsEmpty() const { return Heap.Num() == 0; }
};

四、哈希

4.1 哈希表原理与 UE 哈希函数设计

★★ 中等

考点: 哈希函数、哈希桶、冲突、负载因子、GetTypeHash 自定义、HashCombine

参考答案:

  1. 哈希表用哈希函数把键映射为数组下标(Index = Hash(Key) % BucketCount),实现 O(1) 查找/插入/删除;
  2. 好的哈希函数需均匀分布 + 计算快速;负载因子 = 元素数/桶数,过高时冲突加剧需 rehash;
  3. UE 内置类型哈希:int32 直接取值;FString → FCrc::StrCrc32;FName → GetComparisonIndex(极快);UObject* → 指针地址;
  4. 自定义类型作 TMap/TSet 键需实现 friend uint32 GetTypeHash(const T&) 与 operator==;组合字段用 HashCombine(黄金比例常数 0x9e3779b9);
  5. 应用:TMap 属性映射/配置缓存、TSet 去重/标签集合。

代码示例:

struct FInventoryItem {
    int32 ItemId;
    int32 Variant;
    friend uint32 GetTypeHash(const FInventoryItem& I) {
        return HashCombine(::GetTypeHash(I.ItemId), ::GetTypeHash(I.Variant));
    }
    bool operator==(const FInventoryItem& O) const {
        return ItemId == O.ItemId && Variant == O.Variant;
    }
};
TMap<FInventoryItem, int32> InventoryCounts;

// UE 版 HashCombine
FORCEINLINE uint32 HashCombine(uint32 A, uint32 B) {
    return A ^ (B + 0x9e3779b9 + (A << 6) + (A >> 2));
}

4.2 布隆过滤器(Bloom Filter)

★★ 中等

考点: 多个哈希函数 + 位数组、假阳性率、不支持删除、计数布隆过滤器、应用

参考答案:

  1. 布隆过滤器用 m 位数组 + k 个哈希函数判断元素"是否可能在集合中":返回不存在则一定不存在(无假阴性);返回可能存在则有假阳性;
  2. 最优参数:位数组 m ≈ -n·ln(p)/(ln2)²;哈希数 k ≈ (m/n)·ln2;1% 误判率约需 9.6 bit/元素;
  3. 多个哈希可用 double hashing 派生:h_i(x) = h1(x) + i·h2(x);
  4. 不支持删除(多个元素共享位,无法安全重置);Counting Bloom Filter 用计数器代替位以支持删除;
  5. 游戏应用:网络消息去重、资源缓存"先过滤再精确查询"、AI 感知快速排除。

代码示例:

class FBloomFilter {
    TArray<uint64> Bits;
    int32 NumBits, NumHashes;
public:
    FBloomFilter(int32 Expected, double P = 0.01) {
        NumBits  = FMath::CeilToInt(-Expected * FMath::Loge(P) /
                   (FMath::Loge(2.0) * FMath::Loge(2.0)));
        NumHashes = FMath::Clamp(FMath::RoundToInt((double)NumBits / Expected * FMath::Loge(2.0)), 1, 30);
        Bits.SetNumZeroed((NumBits + 63) / 64);
    }
    void Add(const FString& Key) {
        for (int32 i = 0; i < NumHashes; ++i) SetBit(HashN(Key, i) % NumBits);
    }
    bool MightContain(const FString& Key) const {
        for (int32 i = 0; i < NumHashes; ++i)
            if (!GetBit(HashN(Key, i) % NumBits)) return false;
        return true;
    }
private:
    uint32 HashN(const FString& Key, int32 N) const {
        return FCrc::StrCrc32(*Key) + N * FCrc::StrCrc32(*(Key + TEXT("_salt")));
    }
    void SetBit(int32 I) { Bits[I / 64] |= (1ULL << (I % 64)); }
    bool GetBit(int32 I) const { return (Bits[I / 64] >> (I % 64)) & 1; }
};

五、树与二叉树

5.1 四叉树(Quadtree)的原理与应用

★★ 中等

考点: 2D 空间四等分、插入/范围查询、LOD、地形、RTS

参考答案:

  1. 四叉树把平面区域递归四等分(NW/NE/SW/SE),适合 2D 空间查询;
  2. 节点满(Capacity)且未到最大深度时 Subdivide,并把已有元素重新分配到子节点;查询时若与查询范围相交则递归;
  3. 均匀分布下查询 O(log n)、插入 O(log n);
  4. UE 应用:地形系统 LOD 选择、2D 碰撞检测、小地图对象聚合、Boids 群集空间查询、RTS 单位选择。

代码示例:

struct FQuadtreeNode {
    FBox2D Bounds;
    TArray<FVector2D> Points;
    FQuadtreeNode* Children[4] = {};
    static constexpr int32 Capacity = 4;

    bool Insert(const FVector2D& P) {
        if (!Bounds.IsInside(P)) return false;
        if (Points.Num() < Capacity) { Points.Add(P); return true; }
        if (!Children[0]) Subdivide();
        for (int32 i = 0; i < 4; ++i)
            if (Children[i]->Insert(P)) return true;
        return false;
    }
    void QueryRange(const FBox2D& Range, TArray<FVector2D>& Out) {
        if (!Bounds.Intersect(Range)) return;
        for (const auto& P : Points)
            if (Range.IsInside(P)) Out.Add(P);
        if (Children[0])
            for (int32 i = 0; i < 4; ++i) Children[i]->QueryRange(Range, Out);
    }
    void Subdivide() {
        FVector2D C = Bounds.GetCenter();
        Children[0] = new FQuadtreeNode({Bounds.Min, C});                             // SW
        Children[1] = new FQuadtreeNode({{C.X, Bounds.Min.Y}, {Bounds.Max.X, C.Y}}); // SE
        Children[2] = new FQuadtreeNode({C, Bounds.Max});                             // NE
        Children[3] = new FQuadtreeNode({{Bounds.Min.X, C.Y}, {C.X, Bounds.Max.Y}}); // NW
    }
};

5.2 八叉树(Octree)的原理与 UE 应用

★★★ 困难

考点: 3D 空间八等分、光源管理、UE 内置 TOctree、与四叉树/网格对比

参考答案:

  1. 八叉树把 3D 包围盒等分为 2×2×2 八个子象限,深度达 log₂(N) 时叶子基本对应单个对象;
  2. 对均匀分布对象效率高,非均匀分布可能产生不平衡树;
  3. UE 内置 TOctree(Math/Octree.h),通过 FOctreeElementSemantics 定义包围盒与元素相等判定,用 FindElementsWithBoundsTest 做范围查询;
  4. UE 应用:场景光源管理、空间音频声源管理、AI 感知空间查询、大规模植被空间索引。

代码示例:

// UE 内置 TOctree 用法
typedef TOctree<FMyOctreeElement, FMyOctreeElementSemantics> FMyOctree;
FMyOctree WorldOctree(FVector::ZeroVector, 10000.0f);
WorldOctree.AddElement(Elem);
TArray<FMyOctreeElement> Results;
WorldOctree.FindElementsWithBoundsTest(
    FBox(FVector(-500), FVector(500)),
    [&](const FMyOctreeElement& E) { Results.Add(E); });

5.3 BVH(层次包围体)的原理与应用

★★★ 困难

考点: AABB 递归合并、SAH 启发式、光线追踪/碰撞宽相、与八叉树对比

参考答案:

  1. BVH 用包围体递归包围场景对象:叶子节点含图元,内部节点为包围盒 + 左右子索引(可用 union 紧凑表示);
  2. 构建:按 SAH(表面积启发)选择分割轴/点,最小化代价(面积 × 数量),代价 = 左面积×左数量 + 右面积×右数量;
  3. 查询:从根开始,与包围体不相交即剪枝整个子树;叶子做精确检测;
  4. 与八叉树/网格对比:BVH 适合光线追踪(GPU 友好)与碰撞宽相;
  5. UE 应用:PhysX/Chaos 宽相、Nanite 与硬件光追 BVH、遮挡剔除。

代码示例:

struct FBVHNode {
    FBox BoundingBox;
    union {
        struct { int32 LeftChild, RightChild; } Internal;
        struct { int32 PrimOffset, PrimCount; } Leaf;
    };
    bool IsLeaf() const { return Leaf.PrimCount > 0; }
};
// SAH 代价 = 左面积×左数量 + 右面积×右数量
float Cost = LeftBox.GetSurfaceArea() * (i - Start) +
             RightBox.GetSurfaceArea() * (End - i);

5.4 均匀网格(Uniform Grid)

★★ 中等

考点: CellSize 划分、索引计算、邻近查询、优缺点

参考答案:

  1. 均匀网格把空间划分为固定 CellSize 的格子,对象按位置放入所在格子:Cell = Floor(Pos / CellSize);
  2. 查询半径内对象只需检查目标格子及相邻 (2R+1)³ 个格子,再做精确距离过滤;
  3. 优点:实现简单、构建 O(1)、查询快、无树结构开销;缺点:对象分布不均时格子空/挤失衡,CellSize 选择是关键;
  4. 适合分布均匀对象:弹幕/粒子碰撞、人群邻近查询;UE Chaos 宽相用网格/空间哈希;
  5. 查询复杂度 O(1)(常数个大格子数),构建 O(n)。

代码示例:

class FSpatialGrid {
    float CellSize;
    int32 GridWidth, GridHeight, GridDepth;
    TArray<TArray<AActor*>> Cells;
    int32 GetCellIndex(int32 X, int32 Y, int32 Z) const {
        return X + Y * GridWidth + Z * GridWidth * GridHeight;
    }
    void GetCellCoords(const FVector& P, int32& X, int32& Y, int32& Z) const {
        X = FMath::FloorToInt(P.X / CellSize);
        Y = FMath::FloorToInt(P.Y / CellSize);
        Z = FMath::FloorToInt(P.Z / CellSize);
    }
public:
    void Insert(AActor* A, const FVector& P) {
        int32 X, Y, Z; GetCellCoords(P, X, Y, Z);
        Cells[GetCellIndex(X, Y, Z)].Add(A);
    }
    void QueryRadius(const FVector& Center, float R, TArray<AActor*>& Out) {
        int32 MinX, MinY, MinZ, MaxX, MaxY, MaxZ;
        GetCellCoords(Center - FVector(R), MinX, MinY, MinZ);
        GetCellCoords(Center + FVector(R), MaxX, MaxY, MaxZ);
        for (int32 Z = MinZ; Z <= MaxZ; Z++)
        for (int32 Y = MinY; Y <= MaxY; Y++)
        for (int32 X = MinX; X <= MaxX; X++)
            for (AActor* A : Cells[GetCellIndex(X, Y, Z)])
                if (FVector::Dist(A->GetActorLocation(), Center) <= R) Out.Add(A);
    }
};

5.5 KD-Tree 的原理与应用

★★★ 困难

考点: 维度交替分割、中位数、最近邻搜索剪枝、应用场景

参考答案:

  1. KD-Tree 按维度轮流(x→y→z→…)以中位数把点集二分,构建 O(n log n);
  2. 最近邻搜索:沿分割维度下探到叶子,回溯时若"分割面距离² < 当前最近距离²"才搜索另一侧(剪枝),平均 O(log n),最坏 O(n);
  3. 适合静态点集的多维最近邻查询:点云/Lidar、粒子邻居、AI 找最近目标;
  4. 局限:动态插入删除需重建;高维(>20)时退化为线性扫描。

代码示例:

void FindNearest(FKDNode* Node, const FVector& T,
                 FVector& Best, float& BestDistSq) {
    if (!Node) return;
    float D = FVector::DistSquared(Node->Point, T);
    if (D < BestDistSq) { BestDistSq = D; Best = Node->Point; }

    int32 Dim = Node->SplitDimension;
    float Diff = T[Dim] - Node->Point[Dim];
    FKDNode* First  = (Diff <= 0) ? Node->Left  : Node->Right;
    FKDNode* Second = (Diff <= 0) ? Node->Right : Node->Left;

    FindNearest(First, T, Best, BestDistSq);
    // 只有分割面距离小于当前最近距离才搜索另一侧(剪枝)
    if (Diff * Diff < BestDistSq) FindNearest(Second, T, Best, BestDistSq);
}

5.6 B 树与 B+ 树

★★★ 困难

考点: 多路平衡树、B+ 树叶子链表、有序遍历/范围查询、与哈希表对比

参考答案:

  1. B 树每个节点可存多个键(m 阶最多 m-1 个键),数据可存于内部节点;所有叶子节点在同一层;
  2. B+ 树数据只存叶子节点,内部节点只存键作索引;叶子节点通过指针相连成有序链表,适合范围查询与顺序访问;
  3. 高度低(log_m N),对磁盘/内存块友好;支持 O(log n) 有序遍历与范围查询(哈希表只支持精确查询 O(1));
  4. 游戏应用:大世界流式加载按区域组织数据、资源按 ID 范围检索、游戏后端数据库(排行榜/玩家数据)、UE Pak 文件索引类似结构;
  5. 选择:需要有序数据/范围查询选 B+ 树,只需快速精确查找选哈希表。

代码示例:

// B+ 树节点(概念性)
template<typename K, typename V, int32 Order>
struct FBPlusNode {
    bool IsLeaf;
    int32 KeyCount;
    K Keys[Order - 1];
    V Values[Order - 1];          // 仅叶子节点使用
    FBPlusNode* NextLeaf;         // 叶子链表指针
    FBPlusNode* Children[Order];  // 仅内部节点使用
};

5.7 线段树(Segment Tree)

★★★ 困难

考点: 区间查询/更新 O(log n)、懒传播、与树状数组(BIT)对比、AOE 伤害应用

参考答案:

  1. 线段树把区间递归二分存储于 4N 数组,支持 O(log n) 区间求和/最值;
  2. 区间更新用懒传播:区间被完全覆盖时只更新当前节点并打 Lazy 标记,访问子节点时才下推,保证 O(log n);
  3. 构建 O(n);与 BIT 对比:BIT 只支持前缀和 + 单点更新(O(n) 空间、极小常数),线段树功能更全(区间更新/多种信息);
  4. 游戏应用:AOE 伤害区间累加、资源/产量区间统计、排行榜区间查询;
  5. 易错点:懒标记下推时子节点更新需乘区间长度。

代码示例:

class FSegmentTree {
    TArray<int64> Tree, Lazy;
    int32 N;
public:
    void PushDown(int32 Node, int32 S, int32 E) {
        if (Lazy[Node] != 0) {
            int32 M = (S + E) / 2;
            Tree[2*Node]   += Lazy[Node] * (M - S + 1); Lazy[2*Node]   += Lazy[Node];
            Tree[2*Node+1] += Lazy[Node] * (E - M);     Lazy[2*Node+1] += Lazy[Node];
            Lazy[Node] = 0;
        }
    }
    void UpdateRange(int32 Node, int32 S, int32 E, int32 L, int32 R, int64 V) {
        if (S > R || E < L) return;
        if (S >= L && E <= R) { Tree[Node] += V * (E - S + 1); Lazy[Node] += V; return; }
        PushDown(Node, S, E);
        int32 M = (S + E) / 2;
        UpdateRange(2*Node, S, M, L, R, V);
        UpdateRange(2*Node+1, M+1, E, L, R, V);
        Tree[Node] = Tree[2*Node] + Tree[2*Node+1];
    }
    int64 QueryRange(int32 Node, int32 S, int32 E, int32 L, int32 R) {
        if (S > R || E < L) return 0;
        if (S >= L && E <= R) return Tree[Node];
        PushDown(Node, S, E);
        int32 M = (S + E) / 2;
        return QueryRange(2*Node, S, M, L, R) + QueryRange(2*Node+1, M+1, E, L, R);
    }
};

5.8 空间分区结构的选择

★★ 中等

考点: 四叉/八叉/BVH/网格/KD-Tree 的适用场景对比

参考答案:

  1. 均匀网格:构建 O(1)、查询 O(1),适合分布均匀的对象(弹幕、粒子);
  2. 四叉树/八叉树:构建 O(n log n)、查询 O(log n),适合动态插入删除、分布不均的对象(地形、光源、AI 感知);
  3. BVH:适合光线追踪、GPU 加速、碰撞宽相(SAH 构建质量好);
  4. KD-Tree:适合静态场景的最近邻查询;
  5. 选择标准:对象分布、动态 vs 静态、是否 GPU 友好、内存预算——目标是避免 O(n²) 暴力检测,降到 O(n log n)/O(n)。
| 结构        | 构建      | 查询     | 适用场景                 |
|------------|-----------|---------|-------------------------|
| 均匀网格     | O(1)      | O(1)    | 分布均匀:弹幕、粒子      |
| 四叉/八叉树  | O(n log n)| O(log n)| 分布不均、动态增删        |
| BVH        | O(n log n)| O(log n)| 光线追踪、GPU 加速        |
| KD-Tree    | O(n log n)| O(log n)| 静态场景最近邻            |

六、图

6.1 图结构:邻接矩阵 vs 邻接表

★★ 中等

考点: 两种表示的空间/时间、BFS/DFS、对话/任务图应用

参考答案:

  1. 邻接矩阵:空间 O(V²)、查询边 O(1),适合稠密图;邻接表:空间 O(V+E)、遍历邻居 O(度数),适合稀疏图;
  2. 游戏中的图大多稀疏,邻接表更常用;
  3. 应用:对话系统(有向图 + BFS 可达节点)、任务依赖、技能解锁、NavMesh 导航图、蓝图数据流;
  4. 遍历:BFS(队列,求最短步数)、DFS(栈/递归,路径枚举)。

代码示例:

struct FEdge { int32 Target; float Weight; };
TMap<int32, TArray<FEdge>> Adjacency;
void AddEdge(int32 From, int32 To, float W = 1.0f) {
    Adjacency.FindOrAdd(From).Add({To, W});
}

// BFS 求所有可达节点
TSet<int32> Visited; TQueue<int32> Q;
Q.Enqueue(Start); Visited.Add(Start);
while (int32* Cur = Q.Peek()) {
    Q.Dequeue();
    for (const auto& E : Adjacency.FindRef(*Cur))
        if (!Visited.Contains(E.Target)) { Visited.Add(E.Target); Q.Enqueue(E.Target); }
}

6.2 拓扑排序与循环检测

★★ 中等

考点: Kahn 算法、DFS 后序反转、环检测、蓝图编译/资源加载

参考答案:

  1. Kahn(BFS):计算入度,入度为 0 入队;出队加入结果并把邻居入度减 1,减到 0 再入队;结果数 < 节点数说明有环;
  2. DFS 版:三色标记(Unvisited / InProgress / Completed),后序加入结果最后反转;遇到 InProgress 即发现环;
  3. 环检测必须处理——蓝图/材质图编译、资源加载依赖出现环要报错而非死循环;
  4. 游戏应用:技能解锁顺序、任务依赖、蓝图节点编译顺序、资源加载顺序。

代码示例:

// Kahn 算法
TArray<int32> TopologicalSortKahn(int32 N, const TMap<int32, TArray<int32>>& Adj) {
    TMap<int32, int32> InDegree;
    for (int32 i = 0; i < N; ++i) InDegree.Add(i, 0);
    for (const auto& P : Adj)
        for (int32 Nb : P.Value) InDegree[Nb]++;

    TQueue<int32> Q;
    for (const auto& P : InDegree)
        if (P.Value == 0) Q.Enqueue(P.Key);

    TArray<int32> Result;
    while (int32* Cur = Q.Peek()) {
        Q.Dequeue(); Result.Add(*Cur);
        if (const auto* Nbs = Adj.Find(*Cur))
            for (int32 Nb : *Nbs)
                if (--InDegree[Nb] == 0) Q.Enqueue(Nb);
    }
    return (Result.Num() == N) ? Result : TArray<int32>(); // 空 = 有环
}

6.3 并查集(Union-Find)

★★ 中等

考点: Find/Union、路径压缩、按秩合并、O(α(n))、连通性应用

参考答案:

  1. 并查集维护不相交集合:Find(x) 找代表根,Union(x,y) 合并;均摊复杂度 O(α(n))≈O(1)(反阿克曼函数);
  2. 路径压缩:Find 时把节点直接指向根,压平树;按秩合并:矮树挂到高树下(Rank 小者并入大者);
  3. 游戏应用:连通岛屿/区域检测、碰撞组归类、技能连携分组、Kruskal 最小生成树;
  4. 扩展:带权并查集(维护节点到根的关系)、可撤销并查集(离线算法)。

代码示例:

class FUnionFind {
    TArray<int32> Parent, Rank;
    int32 NumSets;
public:
    FUnionFind(int32 N) : NumSets(N) {
        Parent.SetNum(N); Rank.SetNumZeroed(N);
        for (int32 i = 0; i < N; ++i) Parent[i] = i;
    }
    int32 Find(int32 X) {
        if (Parent[X] != X) Parent[X] = Find(Parent[X]); // 路径压缩
        return Parent[X];
    }
    bool Union(int32 X, int32 Y) {
        int32 Rx = Find(X), Ry = Find(Y);
        if (Rx == Ry) return false;
        if (Rank[Rx] < Rank[Ry]) Parent[Rx] = Ry;
        else if (Rank[Rx] > Rank[Ry]) Parent[Ry] = Rx;
        else { Parent[Ry] = Rx; Rank[Rx]++; }
        NumSets--;
        return true;
    }
    bool IsConnected(int32 X, int32 Y) { return Find(X) == Find(Y); }
};

七、UE 容器 TArray/TMap/TSet

7.1 TArray 内存布局、扩容策略与常用 API

★★ 中等

考点: Data/ArrayNum/ArrayMax、1.618 扩容、常用 API、缓存友好

参考答案:

  1. TArray 维护三个属性:Data(数据指针)、ArrayNum(元素个数)、ArrayMax(分配容量);内存连续、缓存友好;
  2. 扩容策略:默认按约 1.618 倍(黄金比例)扩容;分配新内存后用 MoveItem 迁移元素(POD 类型走 memcpy 优化);
  3. 常用 API:Add / Emplace(原地构造)、AddUnique、Insert、RemoveAt / RemoveAtSwap(O(1) 无序删除)、Reserve / Shrink / Reset / Empty、Contains / Find / BinarySearch(需有序)、Sort(IntroSort);
  4. 易错点:Reserve 只扩容不构造元素;扩容会使元素指针/迭代器失效;
  5. UE 应用:Actor 列表、组件数组、Replicated 属性等几乎所有动态数组场景。

代码示例:

TArray<int32> Arr;
Arr.Reserve(100);        // 预分配避免频繁扩容
Arr.Add(10); Arr.AddUnique(10); Arr.Insert(5, 0);
Arr.Emplace(42);         // 原地构造,避免拷贝
Arr.RemoveAtSwap(0);     // O(1) 无序删除
Arr.Sort();
int32 Idx = Arr.BinarySearch(10); // 需先排序

7.2 TMap 底层实现与哈希冲突解决

★★★ 困难

考点: 开放寻址、探测法、负载因子 0.7、rehash、懒删除(Tombstone)、与 STL 对比

参考答案:

  1. TMap 底层是开放寻址(Open Addressing)哈希表,数据存于 TSparseArray + Hash 索引表,冲突用探测法解决(资料有线性探测与二次探测两种描述,取决于实现版本),与 STL unordered_map 的链表法本质不同;
  2. 负载因子默认约 0.7,超过阈值触发 rehash(重新计算所有元素位置);
  3. 删除采用"懒删除/墓碑标记"(Tombstone),不能简单清空槽位(会破坏探测链);
  4. 开放寻址优点:连续内存、缓存友好、无指针开销;缺点:负载因子受限于 ~0.7,最坏退化为 O(n);
  5. 自定义 Key 需提供 KeyFuncs(GetTypeHash + Matches);
  6. 延伸:TMultiMap(一 Key 多 Value)、THashMap(UE5 高性能 Robin Hood 哈希)。

代码示例:

// 简化结构:TMap 内部是 TSet<TPair<Key,Value>>,TSet 由 TSparseArray + Hash 索引组成
template<typename K, typename V, typename Alloc, typename KeyFuncs>
class TMap {
    TSet<TPair<K, V>, KeyFuncs, Alloc> Pairs;
};

// 自定义 KeyFuncs
struct FMyKeyFuncs : BaseKeyFuncs<FMyKey, int32> {
    static KeyInitType GetSetKey(const FMyKey& E) { return E.Id; }
    static bool Matches(KeyInitType A, KeyInitType B) { return A == B; }
    static uint32 GetKeyHash(KeyInitType K) { return GetTypeHash(K); }
};

7.3 TSet 与 TMap 的关系及去重机制

★★ 中等

考点: TSet = 无 Value 的 TMap、去重机制、集合运算、自定义哈希要求

参考答案:

  1. TSet 本质是只有 Key 没有 Value 的 TMap,共用同一套开放寻址哈希与冲突策略;
  2. 去重机制:插入时先算哈希定位桶,若桶已占用用 operator== 判断是否相同,相同则忽略插入;
  3. 自定义类型需实现 GetTypeHash() 和 operator== 才能进 TSet;
  4. 支持 Union / Intersect / Difference 等集合运算;
  5. UE 应用:GameplayTag 集合、已处理对象去重、网络 dirty 集合。

代码示例:

TSet<FString> Names;
Names.Add(TEXT("Player1")); Names.Add(TEXT("Player1"));
Names.Num(); // 1(重复被忽略)

TSet<int32> U = A.Union(B);        // 并集
TSet<int32> I = A.Intersect(B);    // 交集
TSet<int32> D = A.Difference(B);   // 差集

// 自定义类型
struct FItemKey {
    int32 Id;
    friend uint32 GetTypeHash(const FItemKey& K) { return ::GetTypeHash(K.Id); }
    bool operator==(const FItemKey& O) const { return Id == O.Id; }
};

7.4 TSparseArray 深入分析

★★★ 困难

考点: 空洞数组 + 位掩码 + 侵入式空闲链表、O(1) 删除、索引稳定、与 TArray 对比

参考答案:

  1. TSparseArray 是"带空洞的数组":Data 数组 + AllocationFlags 位图(标记槽位是否有效)+ 侵入式 FreeList(空闲槽位复用自身内存串成链表,存下一个空闲索引);
  2. 删除 O(1):只清标志位、把槽位头插进 FreeList,不移动其他元素;插入优先复用空闲槽位;
  3. 与 TArray 对比:TArray 删除 O(n)(移动元素、索引变化);TSparseArray 索引稳定但遍历需检查标志位、有内存空洞碎片;
  4. 优势:稳定索引 + 相对连续内存(比纯链表缓存友好);
  5. 应用:Actor/组件稳定索引、粒子存储、ECS 组件存储;常与 TMap(名字 → FSetElementId)配合实现"稳定索引 + 快速查找"。

代码示例:

TSparseArray<FString> SparseArr;
FSetElementId Id0 = SparseArr.Add(TEXT("Alpha"));
FSetElementId Id1 = SparseArr.Add(TEXT("Beta"));
SparseArr.Remove(Id1);       // O(1),不移动元素
SparseArr.IsAllocated(0);    // true
for (auto It = SparseArr.CreateIterator(); It; ++It) { /* 只遍历有效元素 */ }

7.5 TIndirectArray 与间接数组

★★ 中等

考点: 裸指针 + 独占所有权、多态支持、与 TArray<T*>/TSharedPtr 对比、注意事项

参考答案:

  1. TIndirectArray 存裸指针但拥有对象所有权,析构时自动 delete 全部元素;支持多态(无对象切片);
  2. 对比:TArray<T> 值语义但多态会切片;TArray<T*> 无所有权需手动管理;TArray<TSharedPtr> 共享所有权但有引用计数开销;
  3. 缺点:对象分散堆上缓存不友好、不支持线程安全的共享;
  4. 易错点:切勿在外部再 delete(双重释放);
  5. 现代 UE 开发优先用 TUniquePtr / TSharedPtr,TIndirectArray 用于部分内部容器场景。

代码示例:

class AIAgent {
    TIndirectArray<AIBehaviorBase> Behaviors; // 多态 + 自动释放
    void Setup() {
        Behaviors.Add(new FleeBehavior());
        Behaviors.Add(new AttackBehavior());
    }
    // ~AIAgent() 自动 delete 所有 Behavior
};

7.6 TMap 高级用法与性能陷阱

★★ 中等

考点: FindRef/Find/FindOrAdd/operator[] 区别、迭代中删除、哈希质量、TMap vs TArray

参考答案:

  1. FindRef 返回 Value 拷贝(大对象慎用);Find 返回指针(无拷贝);FindOrAdd 不存在时插入默认值并返回引用;operator[] 不存在时也会自动插入(意外空条目陷阱);
  2. 迭代中删除是未定义行为:先收集 Key 再删除,或用迭代器 It.RemoveCurrent();
  3. 哈希质量差会让 TMap 退化为 O(n)(如所有键映射同一桶);多字段用 HashCombine 组合;
  4. 选择依据:数据量小(<50)、顺序访问、稳定布局、少删除 → TArray;频繁 Key 查找、大量数据、非连续标识符、频繁增删 → TMap。

代码示例:

FVector* Loc = Map.Find(TEXT("Spawn"));       // 无拷贝
FVector& L2 = Map.FindOrAdd(TEXT("Spawn"));   // 不存在自动添加默认值
// FVector V = Map.FindRef(TEXT("Spawn"));    // 拷贝!大对象勿用

// 安全删除:迭代器
for (auto It = Map.CreateIterator(); It; ++It)
    if (It->Value < 0) It.RemoveCurrent();

7.7 UE 容器选择策略

★★ 中等

考点: 决策树、各容器操作复杂度、经验法则

参考答案:

  1. 决策:需键值对 → TMap(需有序遍历 → TSortedMap/红黑树,需快速查找 → 哈希);唯一元素集合 → TSet;有序序列 → TArray;频繁中间增删 → TSparseArray(索引稳定)或 TDoubleLinkedList;FIFO → TQueue;LIFO → TStack;
  2. 复杂度:TArray 随机访问 O(1)、查找 O(n)、尾部插入摊销 O(1)、删除 O(n);TMap/TSet 查找/增删 O(1);TSparseArray 增删 O(1)、查找 O(n);
  3. 经验法则:默认用 TArray(缓存最友好),当性能分析确认是瓶颈后再换其他容器。
| 操作        | TArray      | TMap        | TSet        | TSparseArray |
|------------|-------------|-------------|-------------|--------------|
| 随机访问    | O(1)        | O(1) by key | N/A         | O(1)         |
| 查找        | O(n)        | O(1)        | O(1)        | O(n)         |
| 尾部插入    | O(1) 摊销   | O(1) 摊销   | O(1) 摊销   | O(1) 摊销    |
| 删除        | O(n)        | O(1)        | O(1)        | O(1)         |

7.8 UE 容器性能对比与优化技巧

★★★ 困难

考点: 遍历性能差异、Reserve/Emplace/RemoveAtSwap/Append/MoveTemp/Shrink、性能陷阱

参考答案:

  1. 遍历性能:TArray 最快(连续内存、高缓存命中率);TMap 较慢(有空洞);TLinkedList 最慢(节点分散堆上);
  2. 优化技巧:Reserve 预分配避免反复扩容;Emplace 原地构造避免拷贝;不关心顺序用 RemoveAtSwap(O(1));Append 批量添加;MoveTemp 转移大对象所有权;确定不再增长后 Shrink 释放多余内存;
  3. POD 类型引擎自动走 memcpy/memset 优化;
  4. 性能陷阱:Tick 内频繁 Add/Remove 引起内存抖动;TMap 负载因子过高 rehash 卡顿;大对象容器拷贝未用引用或移动语义。

代码示例:

TArray<FHeavyStruct> Data;
Data.Reserve(10000);            // 预分配
Data.Emplace(P1, P2);           // 原地构造,避免拷贝
Data.Append(MoveTemp(NewData)); // 移动而非拷贝
Data.RemoveAtSwap(Index);       // O(1) 无序删除
Data.Shrink();

7.9 UE 容器内存分配策略与类型特征

★★★ 困难

考点: FHeapAllocator/TInlineAllocator/TFixedAllocator、SBO、对齐、类型特征

参考答案:

  1. 分配器模板参数:FHeapAllocator(默认堆分配)、TInlineAllocator<N>(前 N 个元素内联在容器内部、超出转堆,即 Small Buffer Optimization)、TFixedAllocator<N>(固定大小不扩容);
  2. TInlineAllocator 典型应用:技能目标列表(通常 1-5 个)、射线检测 Hit 数组、临时缓冲——避免频繁堆分配,可快 10-100 倍;
  3. 对齐:默认按 alignof(T) 自然对齐;SIMD 类型需 16 字节对齐,由分配器处理;
  4. 类型特征:TIsPODType(POD 用 memcpy 优化)、TIsTriviallyCopyable;自定义类型需实现构造/析构/拷贝/移动;
  5. UE 实际应用:Nanite 用 TInlineAllocator 减少小簇分配;网络同步用 TFixedAllocator 管理固定大小 replicated 数组。

代码示例:

TArray<int32, TInlineAllocator<16>> InlineArr; // 前16个元素在栈上
TArray<AActor*, TInlineAllocator<8>> SkillTargets;
// 对齐分配
void* P = FMemory::Malloc(Size, 16);
FMemory::Free(P);

八、状态机与对象池

8.1 有限状态机(FSM)

★★ 中等

考点: 状态表、转移规则、进入/退出回调、Guard 条件、HFSM 局限

参考答案:

  1. FSM = 状态 + 事件 + 转移规则(From, Event, To, Guard),HandleEvent 触发转移,执行 OnExit → OnEnter 回调;
  2. 状态表实现简单直接;可加 Tick 回调做每帧逻辑;
  3. 局限:N 个状态 × M 个事件的转移规则会指数膨胀;HFSM 用"状态包含子状态机"缓解;
  4. UE 内置更高级方案:UBehaviorTree、UStateTreeComponent(UE5);行为树 vs FSM vs Utility AI 各有取舍;
  5. 游戏应用:角色动画/移动状态、AI 行为状态。

代码示例:

struct FStateTransition {
    ECharacterState From;
    ECharacterEvent Event;
    ECharacterState To;
    TFunction<bool()> Guard; // 转移条件(可选)
};

class FStateMachine {
    ECharacterState CurrentState;
    TArray<FStateTransition> Transitions;
    TMap<ECharacterState, TFunction<void()>> OnEnterCallbacks, OnExitCallbacks;
public:
    bool HandleEvent(ECharacterEvent Event) {
        for (const auto& T : Transitions) {
            if (T.From == CurrentState && T.Event == Event) {
                if (T.Guard && !T.Guard()) continue;
                if (auto* Ex = OnExitCallbacks.Find(CurrentState)) (*Ex)();
                CurrentState = T.To;
                if (auto* En = OnEnterCallbacks.Find(CurrentState)) (*En)();
                return true;
            }
        }
        return false;
    }
};

8.2 对象池(Object Pool)

★★ 中等

考点: 避免频繁分配、数组实现 vs 链表实现、Actor 池、线程安全

参考答案:

  1. 目的:避免 GC / 减少堆分配 / 缓存友好;预分配一批对象,用完归还复用;
  2. 数组实现:Pool 数组 + ActiveFlags 位图 + FreeList 索引,Acquire/Release O(1);比链表缓存友好(Release 需找索引,可加 TMap 做 O(1) 映射);
  3. 侵入式链表实现:节点内嵌 NextFree 指针,无额外 FreeList 内存;
  4. Actor 池要点:归还时重置组件状态(碰撞/渲染/Tick 开关、Transform 重置),池耗尽要有告警策略;
  5. UE 应用:子弹/弹幕、Niagara 粒子内置池、渲染目标池(FPooledRenderTarget)。

代码示例:

template<typename T>
class TArrayObjectPool {
    TArray<T*> Pool;
    TBitArray ActiveFlags;
    TArray<int32> FreeList;
public:
    T* Acquire() {
        if (FreeList.Num() == 0) return nullptr; // 池耗尽
        int32 Idx = FreeList.Last();
        FreeList.RemoveAt(FreeList.Num() - 1);
        ActiveFlags[Idx] = true;
        return Pool[Idx];
    }
    void Release(T* Obj) {
        int32 Idx = Pool.Find(Obj);
        if (Idx != INDEX_NONE && ActiveFlags[Idx]) {
            ActiveFlags[Idx] = false;
            FreeList.Add(Idx);
            Obj->Reset();
        }
    }
};

九、内存与缓存

9.1 内存对齐(Alignment)对性能的影响

★★ 中等

考点: 对齐规则、结构体大小计算、SIMD 对齐、跨缓存行访问、UE 对齐工具

参考答案:

  1. 规则:基本类型地址必须是其大小的整数倍;结构体对齐等于最大成员对齐;结构体大小必须是对齐的整数倍(可能有填充);
  2. 字段重排(大类型排前、char 类排后)可减少填充,省 20-40% 内存;
  3. SIMD(SSE/AVX)要求 16/32 字节对齐,未对齐会崩溃或性能下降;
  4. 跨缓存行(64 字节)访问需要两次内存事务,性能损失可达 2 倍;
  5. UE 工具:FMath::Align、FMemory::Malloc(Size, Align)、UE_ALIGN(16) / alignas(16)、static_assert 校验。

代码示例:

// 坏布局 16 字节 vs 好布局 12 字节
struct FBadLayout  { char A; int32 B; char C; int32 D; }; // 16B(6B 浪费)
struct FGoodLayout { int32 B; int32 D; char A; char C; }; // 12B

struct FVector4SIMD { UE_ALIGN(16) float X, Y, Z, W; };
static_assert(alignof(FVector4SIMD) == 16);
static_assert(sizeof(FVector4SIMD) == 16);

9.2 缓存友好设计(Cache-Friendly Design)

★★★ 困难

考点: 缓存层次、空间/时间局部性、SoA vs AoS、分支预测、池化链表

参考答案:

  1. 缓存层次:L1 ~1ns → L2 ~3-5ns → L3 ~10-20ns → 主存 ~100ns;利用局部性(时间/空间)减少 cache miss;
  2. 批量遍历场景 SoA 比 AoS 快 2-5 倍(缓存行利用率 100% vs 部分);
  3. 分支预测:按类型排序/分离处理减少预测失败;数据导向设计(DOD)= 按数据访问模式组织数据;
  4. 传统链表节点分散堆上缓存不友好,可用"池化链表"(用索引代替指针、节点连续分配);
  5. UE 应用:Mass Entity 用 SoA 存储;TArray 连续内存天然缓存友好。

代码示例:

// 池化链表:节点在连续内存中,索引代替指针
template<typename T>
class FPooledList {
    struct FNode { T Data; int32 NextIndex; };
    TArray<FNode> Pool;
    int32 HeadIndex = -1, FreeHead = -1;
public:
    int32 PushFront(const T& Item) {
        int32 Idx;
        if (FreeHead != -1) { Idx = FreeHead; FreeHead = Pool[FreeHead].NextIndex; }
        else { Idx = Pool.Num(); Pool.Add({}); }
        Pool[Idx].Data = Item;
        Pool[Idx].NextIndex = HeadIndex;
        HeadIndex = Idx;
        return Idx;
    }
};

9.3 自定义内存池(Pool Allocator)

★★★ 困难

考点: 固定大小池、FreeList、O(1) 分配/释放、placement new、Arena 分配器、UE 分配器层次

参考答案:

  1. 固定大小内存池:预分配大块内存切成等大小 Block,用 FreeList 头插管理;Allocate/Deallocate 均 O(1);
  2. 使用 placement new 构造对象、手动调用析构后再归还;Block 大小至少 sizeof(FBlock);
  3. Arena 分配器:只整体释放(O(1) Reset),适合帧内临时分配(帧分配器每帧重置);
  4. 与系统 malloc 对比:减少内存碎片与分配/释放开销;适合固定大小对象的频繁分配(碰撞结果、网络包);
  5. UE 层次:FMallocBinned2(<256B 分箱池化)、FMallocBinned3(UE5 新增)、FMallocAnsi(系统默认)——工业级实现思想即池化。

代码示例:

class FPoolAllocator {
    struct FBlock { FBlock* Next; };
    FBlock* FreeList = nullptr;
    int32 BlockSize;
    TArray<void*> Chunks;
public:
    FPoolAllocator(int32 InBlockSize)
        : BlockSize(FMath::Max(InBlockSize, (int32)sizeof(FBlock))) { AllocateChunk(); }
    void* Allocate() {
        if (!FreeList) AllocateChunk();
        FBlock* B = FreeList;
        FreeList = B->Next;
        return B;
    }
    void Deallocate(void* P) {
        if (!P) return;
        FBlock* B = (FBlock*)P;
        B->Next = FreeList;
        FreeList = B;
    }
private:
    void AllocateChunk() {
        void* Chunk = FMemory::Malloc(BlockSize * 64, 16);
        Chunks.Add(Chunk);
        uint8* P = (uint8*)Chunk;
        for (int32 i = 0; i < 64; ++i) {
            FBlock* B = (FBlock*)P;
            B->Next = FreeList;
            FreeList = B;
            P += BlockSize;
        }
    }
};

9.4 SoA vs AoS 在游戏中的选择

★★★ 困难

考点: SoA/AoS 缓存差异、数据导向设计(DOD)、粒子/物理适用场景、UE5 Mass Entity

参考答案:

  1. AoS(Array of Structures):对象所有字段打包,需同时访问对象多字段时好(一次缓存加载全拿到);但批量更新单个字段时缓存行利用率低(例 36/64 ≈ 56%);
  2. SoA(Structure of Arrays):同类字段连续,批量处理时缓存行利用率接近 100%,且方便 SIMD 向量化;
  3. 选择指南:批量更新同类数据、对象数量巨大(>10K)、SIMD 并行 → SoA;同时访问对象多字段、对象少(<100)、频繁增删 → AoS;
  4. UE5 Mass Entity 框架用 SoA 存储(Fragment 按类型连续 + Chunk 批处理),是 DOD 的工业级实现;
  5. 混合方案 AoSoA:SoA 的每个槽位是一个小 AoS。

代码示例:

// AoS:每个粒子一个结构体
struct FParticleAoS { FVector Position, Velocity, Acceleration; float Life, MaxLife; };

// SoA:同类数据连续排列
struct FParticleSystemSoA {
    TArray<FVector> Positions, Velocities, Accelerations;
    TArray<float> Lives, MaxLives;
};
// 批量更新位置时 SoA 缓存行利用率接近 100%,通常比 AoS 快 2-5 倍

十、复杂度分析

10.1 UE 容器操作复杂度对比与经验法则

★★ 中等

考点: 各容器操作复杂度、摊销分析、选择经验

参考答案:

  1. TArray:随机访问 O(1)、查找 O(n)、尾部插入摊销 O(1)、中间插入/删除 O(n);
  2. TMap/TSet:平均 O(1)(哈希)、最坏 O(n)(冲突退化);删除用懒标记;
  3. TSparseArray:增删 O(1)、随机访问 O(1)、查找 O(n)、遍历 O(n)(需跳过空洞);
  4. 链表:已知节点插入删除 O(1)、随机访问 O(n);
  5. 堆/优先队列:Push/Pop O(log n)、Peek O(1);
  6. 经验法则:先默认 TArray(缓存友好),性能分析确认为瓶颈后再换其他容器;遍历优先连续内存容器。
| 数据结构    | 查找     | 插入        | 删除     | 随机访问 |
|------------|---------|------------|---------|---------|
| TArray     | O(n)    | O(1) 摊销   | O(n)    | O(1)    |
| TMap/TSet  | O(1)    | O(1)        | O(1)    | N/A     |
| TSparseArray | O(n)  | O(1)        | O(1)    | O(1)    |
| 链表        | O(n)    | O(1)*       | O(1)*   | O(n)    |
| 二叉堆      | O(1) Peek | O(log n)   | O(log n)| N/A     |

说明:* 表示已知节点位置时 O(1)。