【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清空而非重建。在性能敏感场景优先考虑数组或原生容器。

相关推荐
格林威2 小时前
Baumer相机铝型材表面划伤长度测量:实现损伤量化评估的 5 个关键技术,附 OpenCV+Halcon 实战代码!
开发语言·人工智能·数码相机·opencv·计算机视觉·c#·工业相机
计算机安禾2 小时前
【数据结构与算法】第16篇:串(String)的定长顺序存储与朴素模式匹配
c语言·数据结构·c++·学习·算法·visual studio code·visual studio
2401_827499992 小时前
python核心语法01-数据存储与运算
java·数据结构·python
副露のmagic2 小时前
链表章节 leetcode 思路&实现
数据结构·leetcode·链表
Dr.F.Arthur2 小时前
我的算法学习笔记——链表篇
数据结构·笔记·学习·链表
DowneyJoy3 小时前
【Unity3D补充知识点】常用数据结构分析-数组(Array)
数据结构·unity·c#
格林威3 小时前
Baumer相机铝箔表面针孔检测:提升包装阻隔性的 7 个核心策略,附 OpenCV+Halcon 实战代码!
开发语言·人工智能·数码相机·opencv·计算机视觉·c#·工业相机
w-白兰地3 小时前
配置Unity中的ADB环境变量
unity·adb·游戏引擎
程序员zgh3 小时前
C++ 环形队列 从原理到实例演示
c语言·开发语言·数据结构·c++·学习