列表同其他线性表一样,会将元素保存在一个集合内,并支持增改删查的操作。在使用列表时,通常
从数据结构的角度来看,列表(List)是一种抽象数据类型(ADT),它可以基于数组或链表实现:
- 基于数组:提供快速的随机访问,但插入和删除操作可能涉及大量元素的移动。若元素数量超过当前容量,则需进行
扩容操作 。 - 基于链表:插入和删除操作灵活,但随机访问性能较低,且占用更多的内存空间。
在 .NET 中,System.Collections.Generic.List<T> 具体是基于数组实现的。这意味着它本质上是一个动态数组。

扩容机制 (Capacity)
由于 List<T> 基于数组实现,其必须应对数组长度固定的限制。大多数动态数组实现会在初始化时预设一个
在 .NET 8 中,使用无参构造函数 new List<T>() 初始化时,内部数组实际上是一个长度为 0 的空数组。只有在添加第一个元素时,容量才会被扩展为默认值(通常是 4)。
为什么要扩容
为了保持列表的“动态”特性,必须监控内部数组的容量。一旦添加的元素数量超过了当前容量(Count >= Capacity),就需要创建一个容量更大的新数组,并将原数组的元素复制过去。
这一过程称为扩容,它是一个相对昂贵的操作(内存分配 + 数据复制)。
比如说,对于一个容量为4的数组:
1 | int[] Add(int[] arr, int element) // 一个简单的添加元素实现,与语言实现不同 |
扩容策略
每次添加元素都扩容显然是低效的。因此,.NET 采用了倍增策略:当容量不足时,新容量通常扩大为原来的 2 倍。
这种策略将扩容操作的摊还时间复杂度 (Amortized Complexity) 降低到了 O(1)。
1 | // System.Collections.Generic.List<T> 扩容逻辑的简化示意 |
最佳实践:如果你预先知道列表将存储多少元素,请在构造函数中指定容量(如
new List<int>(1000))。这可以避免多次扩容带来的性能损耗。
常用操作
初始化列表
创建一个空列表:
1 | List<int> numbers = new List<int>(); |
从已有的数组创建列表:
1 | int[] s = [1, 1, 4, 5, 1, 4]; |
访问列表元素
由于 List<T> 基于数组,它支持常数时间 O(1) 的随机访问。
1 | int first = numbers[0]; // 获取列表的第1个元素 |
添加,插入与删除
向列表末尾添加元素通常是高效的 O(1) 操作(除非触发扩容即 O(n)):
1 | // 在列表的尾部添加元素 |
而在列表中间插入或删除元素,则需要移动后续所有元素,时间复杂度为 O(n):
1 | numbers.Insert(3, 0); // 在索引 3 的位置插入 0 |
遍历
可以通过 for 或 foreach 循环遍历列表:
1 | // 推荐:通过 foreach 遍历元素(语法糖,底层使用 Enumerator) |
.NET 的列表提供了原生的 Sort() 方法,使用快速排序等算法进行原地排序(In-place sort)。
1 | var numbers = new List<int> { 5, 1, 4 }; |
对于对象列表的排序,推荐使用
1 | var personA = new Person(10, "A"); |
实践案例
场景:清理缓存文件
假设一个播放器应用需要限制缓存文件夹的最大占用空间(例如 1145 MB)。每次启动时,程序需要检查缓存大小,如果超标,则按从旧到新的顺序删除文件,直到满足限制。
思考过程:
- 获取文件列表:使用
DirectoryInfo获取所有文件 (FileInfo)。 - 计算总大小:使用 LINQ
Sum计算当前占用。 - 删除策略:
- 我们需要删除“最旧”的文件。
- 如果我们按时间升序(Ascending,旧 -> 新)排序,那么最旧的文件在列表头部(索引 0)。
List<T>.RemoveAt(0)会导致所有后续元素前移,时间复杂度为O(n)。如果在循环中频繁执行,性能极差。
优化方案:
将文件按时间降序(Descending,新 -> 旧)排序。这样,最旧的文件(需要被删除的)就位于列表的尾部。
List<T>.RemoveAt(Count - 1) 是 O(1) 操作,无需移动元素,效率极高。
相关代码:
1 | void CleanupCache(string cacheFolderPath, int desiredCacheSizeInMb) |