C# 从数组到集合的演进与最佳实践

沿途有光
沿途有光
发布时间:2026-08-24| 94人浏览过
原创

前言

在 C# 里,数组和各种集合都能装数据,但它们解决的问题并不一样:有的追求连续内存和索引访问,有的擅长动态增长,有的专门处理键值查找或去重。本文按底层结构、操作成本和典型场景重新梳理一遍,帮助你在写代码时更快判断该用数组、List<T>,还是换成更合适的专用集合。

在 C# 里,数组、List<T>、Dictionary<TKey, TValue> 和 HashSet<T> 经常被放在一起讨论,但它们的设计目标并不一样。要写出性能和可维护性都更稳的代码,关键不是“会用 API”,而是先搞清楚底层代价、扩容机制以及查找方式,再根据数据规模、是否允许重复、是否依赖索引访问来做选择。

这篇文章按“从基础结构到专用集合”的顺序展开:先看数组为什么快、为什么不灵活,再看 List<T> 怎样用内部数组实现动态增长,最后再把按键查找、去重和先进先出这类问题交给更合适的集合类型。读完后,你可以对常见场景做出更明确的选型判断。

数组为什么仍然是理解集合的起点

数组是 C# 中最基础的数据集合形式。很多更高级的集合,本质上都建立在它之上。理解数组,等于先掌握了集合在内存中的最原始模型。

数组的本质:连续的内存空间

当你声明一个数组,例如 int[] numbers = new int[5];,实际上是在内存中申请了一块连续且完整的空间,这块空间足以容纳 5 个 int 类型的数据。

展示 List<T> 从底层数组到扩容复制流程的信息图
List<T> 扩容机制示意用流程图展示 List<T> 的 Count、Capacity 与扩容复制关系。
  • 连续存储:数组元素在内存中是挨着放的,可以理解为一排连续编号的位置。
  • 固定大小:数组一旦创建,长度就确定了,后面不能直接扩容。
  • 元素同型:同一个数组只能保存同一种类型的数据。

正因为有连续存储这个特性,数组才能以极低成本完成随机访问。CPU 不需要遍历前面的元素,只要通过 基地址 + i * 单个元素大小,就能直接定位到 array[i] 的地址,这也是它在读取场景下非常快的原因。

数组的声明、初始化与遍历

// 1. 声明并初始化大小
int[] scores = new int[5]; // 在内存中分配了5个int的空间,默认值为0

// 2. 通过索引访问 (从0开始)
scores[0] = 95;
scores[1] = 88;
int firstScore = scores[0];

// 3. 声明并直接初始化内容
string[] names = new string[] { "Alice", "Bob", "Charlie" };
// 或者更简洁的语法
string[] namesShort = { "Alice", "Bob", "Charlie" };

// 4. 遍历数组
foreach (string name in names)
{
    Console.WriteLine(name);
}

// 5. 获取长度
int nameCount = names.Length; // 结果是 3

这些语法并不复杂,但它们背后的限制很明确:你能高效地读写已有位置,却很难在运行中自由增减容量。

展示 Dictionary、HashSet、Queue 与 Stack 各自适用问题的信息图
特殊集合选型对照把几类特殊集合放在同一张对比图里,帮助读者按“键值查找、去重、FIFO、LIFO”快速匹配数据结。

数组适合什么场景,不适合什么场景

数组的优势主要有两点:

  • 索引访问性能高:通过 array[i] 读取元素属于 O(1) 操作,尤其适合频繁随机读取。
  • 内存开销低:除元素本身外,额外结构成本很少。

数组的问题同样很直接:

  • 长度不可变:创建时就要决定大小,而业务里的数据量往往是动态变化的。
  • 中间插入和删除代价高:如果要在中间插入或删除元素,后续数据都要整体移动,复杂度是 O(n)。

所以,数组更适合“元素个数已知、类型固定、读取频繁”的场景,比如固定配置项、坐标缓存、批量数值处理等。如果集合大小会持续变化,数组往往只是起点,而不是终点。

List<T> 如何把数组变成“可增长”的集合

List<T> 可以理解为“带管理能力的数组”。它内部仍然依赖数组,但把容量变化、元素搬移和常用操作封装起来,因此成为日常开发里最常见的通用集合。

先分清 Count 和 Capacity

List<T> 之所以能动态增长,关键在于它把“实际元素数量”和“底层已分配容量”分开管理:

  • Count:当前列表里真正存放的元素数量。
  • Capacity:内部数组当前最多能容纳多少元素。

两者的关系始终是 Capacity >= Count。只要还有空位,新增元素就可以直接放进去;一旦即将装满,就会触发扩容。

扩容机制到底做了什么

当你向一个 List<T> 追加元素,而 Count 已经逼近 Capacity 时,内部通常会执行下面几步:

  1. 创建一个新的、更大的数组,通常容量会扩到当前的两倍。
  2. 把旧数组中的全部元素复制到新数组。
  3. 丢弃旧数组,让内部引用切换到新数组。
  4. 把新元素追加到新数组末尾。

这也是为什么 List<T> 大多数时候追加很方便,但在容量不够时会突然产生一次额外拷贝成本。数据量较大时,这个过程就值得关注了。

List<int> numbers = new List<int>(); // 初始Capacity通常为0
Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");

numbers.Add(1); // 添加元素
Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");  // 创建Capacity为4的容器
numbers.Add(2);
numbers.Add(3);
numbers.Add(4);
numbers.Add(5); // 此时会触发内部数组的扩容和复制!此时Capacity为8

Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");

从这个例子可以看出,List<T> 的“动态”并不是无限伸缩,而是通过分配更大数组再复制数据实现的。它很好用,但不是没有代价。

常用 API 可以怎么分组理解

相比数组,List<T> 真正拉开差距的地方,在于它提供了更完整的操作接口。与其死记方法名,更有效的方式是按用途理解。

添加与插入

  • Add(T item):在末尾追加一个元素。
  • AddRange(IEnumerable<T> collection):把一组元素整体追加到末尾。
  • Insert(int index, T item):在指定位置插入一个元素。
  • InsertRange(int index, IEnumerable<T> collection):在指定位置插入一组元素。

末尾追加通常是最自然、成本也最低的使用方式;而在中间插入,本质上仍然需要移动后续元素,代价并没有因为换成 List<T> 就消失。

查找与判断

  • Contains(T item):判断某个元素是否存在。
  • Exists(Predicate<T> match):判断是否存在满足条件的元素。
  • Find(Predicate<T> match):返回第一个满足条件的元素。
  • FindAll(Predicate<T> match):返回所有满足条件的元素组成的新 List<T>。
  • IndexOf(T item) 和 LastIndexOf(T item):查找元素索引,找不到返回 -1。

这类方法非常适合一般业务开发,但要注意,List<T> 的查找大多仍然需要遍历,因此当你的核心需求是“按键快速定位”时,就应该考虑字典了。

删除、排序与转换

  • Remove(T item)、RemoveAt(int index)、RemoveAll(Predicate<T> match)、RemoveRange(int index, int count)、Clear()
  • Sort()、Sort(Comparison<T> comparison)、Reverse()
  • ForEach(Action<T> action)、ToArray()、GetRange(int index, int count)

这些方法说明了一件事:List<T> 不是简单的“可变数组”,而是一套围绕顺序数据操作优化过的通用容器。它对绝大多数增删改查场景都足够友好,因此在不知道该选什么时,List<T> 往往是比数组更稳妥的默认选择。

什么时候该换成 Dictionary、HashSet、Queue 或 Stack

一旦你的需求不再是“按顺序存一串数据”,就不应该继续勉强使用数组或 List<T>。C# 提供这些特殊集合,本质上是为了把不同问题交给不同结构处理。

Dictionary<TKey, TValue>:按键查值的首选

当你需要通过一个唯一的 Key 快速找到对应的 Value 时,Dictionary<TKey, TValue> 几乎总是比列表遍历更合适。

  • 底层原理:基于哈希表(Hash Table)实现,通过哈希函数把 Key 映射到内部索引。
  • 性能特征:查找、插入、删除通常接近 O(1)。
  • 使用约束:Key 必须唯一,而且不能为 null。
Dictionary<string, int> studentAges = new Dictionary<string, int>();

// 添加键值对
studentAges.Add("Alice", 20);
studentAges["Bob"] = 22; // 更方便的索引器语法

// 查找
if (studentAges.TryGetValue("Alice", out int age))
{
    Console.WriteLine($"Alice's age is {age}"); // 推荐用法,避免异常
}

// 遍历
foreach (var pair in studentAges)
{
    Console.WriteLine($"Key: {pair.Key}, Value: {pair.Value}");
}

其中 TryGetValue 是非常值得养成的写法。它能在不存在对应 Key 时避免异常,既更稳,也更符合高频查找的编码习惯。

HashSet<T>:只关心是否存在,而且不允许重复

如果你的需求从头到尾都不是“保存顺序”,而是“某个元素是否已经出现过”,那就应该优先考虑 HashSet<T>。

  • 底层结构:同样基于哈希表,但只存储元素本身,不保存额外的 Value。
  • 核心价值:元素唯一,Contains 检查通常接近 O(1)。
  • 适用问题:去重、成员判断、交集并集差集这类集合运算。
HashSet<string> uniqueNames = new HashSet<string>();
uniqueNames.Add("Alice");
uniqueNames.Add("Bob");
uniqueNames.Add("Alice"); // 添加失败,因为 "Alice" 已存在

Console.WriteLine(uniqueNames.Count); // 输出: 2

bool hasBob = uniqueNames.Contains("Bob"); // 极快

很多人一开始会用 List<T> 配合 Contains 做去重,但只要数据量上来,这种写法就会越来越吃亏。HashSet<T> 更贴合问题本身。

Queue<T> 和 Stack<T>:顺序规则不同,结构就不同

当数据处理的关键不是“存多少”,而是“按什么顺序取”,就轮到队列和栈出场了。

  • Queue<T>:先进先出(FIFO, First-In, First-Out)
    • Enqueue():入队,添加到队尾。
    • Dequeue():出队,从队首移除并返回元素。
  • Stack<T>:后进先出(LIFO, Last-In, First-Out)
    • Push():入栈,添加到栈顶。
    • Pop():出栈,从栈顶移除并返回元素。

这两种集合在任务调度、撤销操作、深度遍历、消息排队等场景里都很常见。和用 List<T> 自己模拟相比,它们的表达意图更清晰,也更不容易写出方向反了的逻辑。

C# 集合选型可以怎样快速判断

如果把前面的内容压缩成实战规则,可以得到一套很直接的判断方式:

  • 元素数量固定,且需要高频按索引读取:优先用数组。
  • 元素数量会变化,主要还是顺序存取:优先用 List<T>。
  • 需要通过唯一键快速找到值:用 Dictionary<TKey, TValue>。
  • 需要去重,或者只关心元素是否存在:用 HashSet<T>。
  • 需要先进先出:用 Queue<T>。
  • 需要后进先出:用 Stack<T>。

真正的最佳实践,并不是机械记忆“哪个集合更高级”,而是把数据访问方式、是否允许重复、是否需要键值映射、是否依赖顺序规则这些问题先问清楚。集合选对了,代码性能、可读性和后续维护成本通常都会一起变好。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。