【Unity3D补充知识点】常用数据结构分析-集合(List<T>)

Unity开发中最常用的数据结构

  • 数组:Array
  • 集合(动态数组):List<T>
  • 字典:Dictionary<TKey,TValue>
  • 哈希集:HashSet<T>
  • 链表:LinkedList<T>
  • 栈:Stack<T>
  • 队列:Queue<T>

集合(List)

在C#中,List 是一个非常常用的集合类型,它实现了 IList 接口,并且提供了动态数组的功能。List 允许你添加、删除、查找和遍历一个对象的集合。

内存模型

List<T> 本质上是对托管数组(T[ ])的封装,因此其内存布局与数组高度相似,但增加了动态扩容能力。

内部字段
  • T[] _items:实际存储元素的数组,在托管堆上连续分配。

  • int _size:当前列表中有效元素的数量。

  • int _version:用于枚举器版本控制,防止迭代过程中集合被修改。

值类型与引用类型
  • T 为值类型(如 int、Vector3)时,元素直接存储在 _items 的连续内存中,无额外间接寻址。

  • T 为引用类型(如 GameObject)时,_items 存储的是引用,实际对象分散在堆上。

容量与占用
  • 容量Capacity _items.Length占用内存大小 = sizeof(T) * Capacity + 对象头开销

  • 扩容时,旧数组变为垃圾,新数组大小通常为原来的2倍(.NET 实现策略),产生新的内存分配和旧数据复制。

GC 压力
  • 由于List<T>的底层数组在堆上分配,频繁扩容会导致多次 GC 分配;当列表作为临时变量在每帧使用时,尤其需要注意。

实现原理

底层实现概览
List< T >System.Collections.Generic 命名空间下的泛型类,源码可参考 .NET 参考源。其核心操作实现如下:

  • 构造:无参时初始为空数组,有参时分配指定容量。
csharp 复制代码
public List() => _items = Array.Empty<T>();  // 初始空数组
public List(int capacity) => _items = new T[capacity];
  • 索引器 :有边界检查,O(1) 访问。
csharp 复制代码
public T this[int index] {
    get {
        if ((uint)index >= (uint)_size) throw new IndexOutOfRangeException();
        return _items[index];
    }
    set {
        if ((uint)index >= (uint)_size) throw new IndexOutOfRangeException();
        _items[index] = value;
        _version++;
    }
}
  • Add :若容量不足则扩容(EnsureCapacity),复制原数组,再添加新元素。
csharp 复制代码
public void Add(T item) {
    if (_size == _items.Length) {
        // 扩容:新容量 = _size == 0 ? 4 : _size * 2
        EnsureCapacity(_size + 1);
    }
    _items[_size++] = item;
    _version++;
}
  • Insert :在指定索引插入元素,若空间不足则扩容;然后使用 Array.Copy将插入点后的元素后移一位。

  • RemoveAt :将待删除元素之后的元素向前复制覆盖,并将最后一个元素位置设为 default(避免引用残留),同时_size - -

  • Clear :若元素为引用类型,会将_items中所有位置设为 null;数组本身不释放,只是 _size = 0

  • 枚举器foreach 语法糖使用GetEnumerator()返回的结构体(Enumerator)避免堆分配;但若将 List<T> 转为IEnumerable<T>接口调用,则会产生装箱。

复杂度分析

操作 平均时间复杂度 最坏情况 说明
索引访问 O(1) O(1) 直接访问数组
Add O(1) 摊销 O(n)(扩容) 扩容时需复制所有元素,但均摊成本为 O(1)
Insert O(n) O(n) 需要移动插入点之后的元素
RemoveAt O(n) O(n) 移动删除点之后的元素
Remove(按值) O(n) O(n) 先查找,再执行删除
Contains O(n) O(n) 线性查找
Sort O(n log n) O(n²) 内部 Array.Sort
Clear O(1) 或 O(n) - 引用类型需置 null

关键点:

  • 扩容成本:当容量不足时,扩容会复制整个数组。假设初始容量为 0,经过 n 次 Add,总复制次数约为 O(n),均摊到每次添加为 O(1)。

  • 内存浪费:扩容后可能有多余的未使用容量,可通过TrimExcess()释放。

Unity 实际应用场景

  • 动态管理对象 :存储敌人、子弹等,注意遍历时修改需用倒序 for 或临时副本。
csharp 复制代码
public List<Enemy> enemies = new List<Enemy>();

void Update() {
    foreach (var enemy in enemies) {
        enemy.UpdateAI();  // 注意:若在遍历时添加/删除会报错
    }
}

注意:foreach 在 Unity 旧版 Mono 中可能产生装箱,但现代版本(.NET 4.x / .NET Standard 2.1)已优化为无分配。若需在遍历时修改集合,应使用 for 倒序遍历或临时副本。

  • 与 Unity API 配合 :如 Physics.OverlapSphereNonAlloc 复用 List 避免分配。
csharp 复制代码
// ❌ 每次调用都分配新 List
List<Collider> results = new List<Collider>();
Physics.OverlapSphere(pos, radius, results);  // 内部填充,但外部 list 需提前分配

// ✅ 重用 List,减少 GC
List<Collider> results = new List<Collider>();
void Update() {
    results.Clear();                // 不清除容量
    Physics.OverlapSphere(pos, radius, results);
    // 使用 results...
}

类似的 API 有 Physics.RaycastAll(但返回数组,更推荐使用非分配版本 RaycastNonAlloc)。

  • 对象池 :对象池常用List<T>或数组来存储空闲对象
csharp 复制代码
public class ObjectPool<T> where T : new() {
    private List<T> pool = new List<T>();

    public T Get() {
        if (pool.Count > 0) {
            T obj = pool[pool.Count - 1];
            pool.RemoveAt(pool.Count - 1);
            return obj;
        }
        return new T();
    }

    public void Return(T obj) {
        pool.Add(obj);
    }
}

注意 RemoveAt 是 O(1)(删除最后一个),避免移动元素。若需要频繁从中间移除,应考虑LinkedList自定义索引标记

  • 预分配容量 :已知元素数量时,构造时指定 Capacity 减少扩容。
    • 在 Update 中频繁创建 List 是性能大忌,应尽量复用
csharp 复制代码
// ❌ 每帧分配新 List
void Update() {
    List<Transform> nearby = new List<Transform>();
    // ...
}

// ✅ 复用并清空
private List<Transform> nearby = new List<Transform>();
void Update() {
    nearby.Clear();
    // ...
}
  • 替代选择
    • 当集合大小固定且无需修改时,使用数组更轻量。

    • 当需要频繁在头部插入或删除时,考虑 LinkedList<T>双端队列(Deque)自定义实现。

    • 当需要线程安全或高性能并发时,使用 System.Collections.Concurrent 集合或 Unity 的 NativeList

总结

  • 内存模型:封装托管数组,连续内存,扩容时复制到新数组,容量动态增长。

  • 实现原理:通过 _items 数组和 _size 管理,索引访问有边界检查,扩容策略为容量翻倍。

  • 复杂度:索引 O(1),增删尾部 O(1) 摊销,中间插入/删除 O(n),查找 O(n)。

  • Unity 实践:常与 API 配合使用,需注意复用实例、预分配容量、避免foreach修改集合、利用Clear清空而非重建。在性能敏感场景优先考虑数组或原生容器。

相关推荐
会编程的土豆1 天前
【日常做题】栈 中缀前缀后缀
开发语言·数据结构·算法
进击的荆棘1 天前
递归、搜索与回溯——回溯
数据结构·c++·算法·leetcode·dfs
励志的小陈1 天前
数据结构--二叉树(链式结构、C语言实现、层序遍历)
c语言·数据结构
郝学胜-神的一滴1 天前
[简化版 Games 101] 计算机图形学 05:二维变换下
c++·unity·图形渲染·three.js·opengl·unreal
自我意识的多元宇宙1 天前
树与二叉树--二叉树的存储结构
数据结构
xiaoshuaishuai81 天前
C# GPU算力与管理
开发语言·windows·c#
自我意识的多元宇宙1 天前
二叉树的遍历和线索二叉树--二叉树的遍历
数据结构
qq_5024289901 天前
清华大学与微软亚洲研究院出品:Kronos 本地部署教程
数据结构·python·金融量化·kronos开源模型
hez20101 天前
C# 15 类型系统改进:Union Types
c#·.net·.net core
FL16238631292 天前
基于C#winform部署软前景分割DAViD算法的onnx模型实现前景分割
开发语言·算法·c#