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

相关推荐
何以解忧唯有撸码24 分钟前
c# + YOLODotNet+OpenCVSharp 实现物品轨迹追踪
计算机视觉·c#·winform·yolov8·opencvsharp
白山编程大哥4 小时前
Java 后端如何优雅地把 List 集合返回给前端
java·前端·list
INGNIGHT8 小时前
91 · 最小调整代价(序列dp)
数据结构·算法·leetcode
凯基迪科技8 小时前
Unity游戏加密锁逆向方案、选型与攻防分析
游戏·unity·游戏引擎
阿松爱学习10 小时前
【Unity开发】局部区域截图功能实现
unity·c#
zhanghaha131411 小时前
C# 系列教程_1 基础语法篇(面向新手系统教程)
开发语言·c#
昂好的day12 小时前
固高运动控制卡实战(下):点位运动与直线/圆弧插补 API 详解
开发语言·c#
从小白到软工13 小时前
5-2 从查表到控件:造按钮
c语言·开发语言·数据结构·经验分享·ai编程
David猪大卫15 小时前
【C++修炼】STL容器—stack与queue
c语言·开发语言·数据结构·c++·经验分享·笔记·学习
瑶池酒剑仙15 小时前
C++变量初始化五法完全指南:默认/拷贝/直接/列表/值初始化与窄化转换陷阱(青云之志)
c语言·开发语言·数据结构·c++·算法·visual studio