C#数组(超级深度解析C#数组)
一、数组基本认知
1.1为什么要学习数组
数组不是“一个能放多个值的变量”这么简单。在C#里,数组是:
-
所有集合类型的底层基石:
List<T>内部就是一个动态扩容的数组。Dictionary<TKey,TValue>的buckets也是数组。Stack<T>、Queue<T>全部基于数组。 -
图像数据的直接表达:一张1920×1080的灰度图在内存里就是一个
byte[1920*1080]。彩色图是byte[height, width, 3]。你未来处理的每一帧图像都是数组。 -
模型输入输出的载体:ONNX Runtime、ML.NET、TensorFlow.NET 的输入张量底层都是数组或 Span。
-
高性能代码的核心:数组在内存中连续分布,CPU 缓存友好。
Span<T>让数组操作可以零拷贝。
如果数组不扎实,后面集合、LINQ、图像处理、模型推理全部会卡住。上述内容在后续学习中都会学到,如果学习到底层级别,数组必须牢固。
1.2数组的本质
1.2.1什么是数组
数组是同一类型的多个元素在托管堆上连续存储的数据结构。同一类型表明存储的数据都是一个类型,比如int、double、string等;托管堆表明数组的值都存储在堆上,栈上仅仅存储一个指向堆上的引用;数组的特点是连续存储,可以推出数组在查询上面比较快,在插入删除操作中比较慢。看下面这个数组:
int[] scores = new int[5];
对于这个代码,我们深度解析一下,这个代码干了什么:
栈上的变量 scores (一个引用,8 字节在 64 位系统)
托管堆上的数组对象:
┌──────────────────────────────────────┐
│ 对象头 (8 字节: SyncBlock 索引) │
│ 方法表指针 (8 字节: 指向 int[] 类型) |
│ 长度 (4 字节: 5) | 栈上的scores指向这里
│ 填充 (4 字节: 对齐到 8 字节边界) |
├──────────────────────────────────────┤
│ scores[0] = 0 (4 字节) |
│ scores[1] = 0 (4 字节) |│ scores[2] = 0 (4 字节) |
│ scores[3] = 0 (4 字节) |
│ scores[4] = 0 (4 字节) |
└──────────────────────────────────────┘
栈上面仅仅存储了一个scores变量,他指向堆相关数据;
对象头:所有.NET 引用类型对象都自带。 用来实现线程锁、同步标记、GC 标记信息。 简单理解:CLR 给对象加的管理标签。
方法表指针(TypeHandle,8 字节):存着类型地址,指向这个数组的类型信息。 CLR 依靠它识别:这是int[]、string[]还是别的数组,找到对应的类型方法。 作用:识别对象是什么类型。
长度字段(4 字节):只有数组才有这个字段。 保存数组的元素总数,对应代码里 array.Length 读取的值。
内存对齐填充(4 字节):CPU 要求内存地址必须是 8 字节的整数倍(这个题:对象头 8B + 方法表指针 8B + 数组长度 4B = 20 字节,缺四个字节组成8字节的整数倍)。 则前面头部加起来多出 4 个字节空位,就用空白字节补齐,保证内存对齐,提升读写性能(提高访问速度)。 这部分不存有效数据,只是占位。
连续存储的元素:紧跟在头部后面,一整块连续内存存放每一个数组成员。
-
int 数组:每个元素占 4 字节,紧密排列;
-
引用类型数组:这里存的是一个个对象地址。
1.2.2 关键特性
| 特性 | 说明 |
|---|---|
| 类型固定 | 声明后只能存放一种类型的元素 |
| 长度固定 | 创建后长度不可改变(Array.Resize 本质是新建) |
| 连续存储 | 元素在内存中紧密排列,无间隙 |
| 零基索引 | 索引从 0 开始,到 Length-1 结束 |
| 引用类型 | 即使元素是值类型,数组本身也是引用类型(分配在堆上) |
| 隐式继承 | 所有数组都继承自 System.Array 抽象类 |
1.2.3 内存布局的深层含义
因为元素连续存储,CPU 在读取 scores[0] 时,会顺带把 scores[1]、scores[2] 等相邻元素加载到 CPU 缓存(缓存行通常是 64 字节)。这意味着:
// 快:顺序访问,CPU 缓存命中率高
for (int i = 0; i < scores.Length; i++)
sum += scores[i];
// 也快,但跳步大时缓存命中率下降
for (int i = 0; i < scores.Length; i += 8)
sum += scores[i];
这种"缓存局部性"是数组相比链表的根本性能优势。地址连续,所以访问速度更快。
二、数组语法与基本使用
这一部分聚焦数组怎么声明、创建、访问、赋值,以及空数组、固定长度等最容易混淆的基础语法问题。
2.1 声明、创建、初始化
2.1.1 方式一:声明后单独创建
int[] scores; // ① 声明:栈上分配一个引用变量(目前是 null)
scores = new int[5]; // ② 创建:堆上分配数组对象,变量指向它
scores[0] = 90; // ③ 赋值:逐个索引赋值
scores[1] = 85;
scores[2] = 92;
scores[3] = 78;
scores[4] = 88;
每一步发生了什么:
-
①
int[] scores;→在栈上创建了一个引用变量,当前值为null(不指向任何对象) -
②
new int[5]→CLR在托管堆上分配连续内存(对象头 + 5 个 int = 8+8+4+4 + 5×4 = 44 字节,对齐后 48 字节),所有元素被初始化为default(int)即0 -
③
scores[0] = 90;→CLR 执行stelem.i4IL 指令,将值写入数组的对应偏移位置
2.1.2 方式二:声明时直接 new
int[] scores = new int[5]; // 声明 + 创建一步完成,所有元素为 0
double[] heights = new double[10]; // 所有元素为 0.0
bool[] flags = new bool[3]; // 所有元素为 false
string[] names = new string[4]; // 所有元素为 null(引用类型默认值)
2.1.3 方式三:集合初始化器(最常用)
// 完整写法
int[] scores = new int[] { 90, 85, 92, 78, 88 };
// 简写(编译器推断类型和长度)
int[] scores = { 90, 85, 92, 78, 88 };
// 这两种写法生成的IL完全一样。编译器会:
// 1. 数出有 5 个元素
// 2. 生成 new int[5]
// 3. 逐个用 stelem.i4 赋值
语法限制:简写形式(省略 new int[])只能用在声明语句里:
int[] scores = { 90, 85, 92 }; // 声明时可以使用简写
int[] scores;
scores = { 90, 85, 92 }; // 编译错误!
scores = new int[] { 90, 85, 92 }; // 必须带 new int[]
2.1.4 方式四:使用 var 推断
var scores = new int[] { 90, 85, 92 }; // var 推断为 int[]
var matrix = new int[,] { {1,2}, {3,4} }; // var 推断为 int[,]
// 注意:不能写 var scores = { 90, 85, 92 }; // 编译器无法推断类型
2.1.5 方式五:Array.CreateInstance(反射场景)
// 动态创建数组(类型在运行时才知道)
Type elementType = typeof(int);
Array array = Array.CreateInstance(elementType, 5);
array.SetValue(90, 0);
int value = (int)array.GetValue(0); // 90
// 多维
Array matrix = Array.CreateInstance(typeof(int), 2, 3);
matrix.SetValue(42, 0, 1); // matrix[0,1] = 42
这个方法主要用于需要运行时动态确定类型的场景(如序列化框架、ORM、反射工具),日常开发用得少。
2.2 访问与赋值
2.2.1 基本操作
int[] arr = { 10, 20, 30, 40, 50 };
// 读:使用 ldelem.i4 IL 指令
int first = arr[0]; // first = 10
int last = arr[arr.Length - 1]; // last = 50
// 写:使用 stelem.i4 IL 指令
arr[2] = 99; // { 10, 20, 99, 40, 50 }
// 自增
arr[0]++; // { 11, 20, 99, 40, 50 }
arr[1] += 5; // { 11, 25, 99, 40, 50 }
2.2.2 索引表达式
int[] arr = { 10, 20, 30, 40, 50 };
int index = 2;
// 索引可以是任何结果为 int 的表达式
Console.WriteLine(arr[index]); // 30
Console.WriteLine(arr[index + 1]); // 40
Console.WriteLine(arr[index * 2]); // arr[4] = 50
// 边界检查在运行时执行
// arr[index * 10] // 如果计算结果 >= Length,抛异常
2.3 null 数组、空数组、未初始化数组
这是初学者最容易混淆的三个概念:
2.3.1 三者的区别
// 1. null 数组 —— 没有数组对象
int[] nullArray = null;
Console.WriteLine(nullArray == null); // True
// nullArray.Length; // NullReferenceException!
// 2. 空数组 —— 有数组对象,但长度为 0
int[] emptyArray = new int[0];
Console.WriteLine(emptyArray.Length); // 0
// emptyArray[0]; // IndexOutOfRangeException! 但 emptyArray 本身不是 null
// 3. 未初始化的数组 —— 仅在作为类字段时出现
class Example
{
public int[] UninitializedArray; // 默认值为 null(引用类型字段)
}
// 4. 声明但未赋值(局部变量)
// int[] local; // 如果在赋值前使用,编译错误(明确赋值检查)
2.3.2 安全处理模式
// 推荐:返回空数组而不是 null
int[] GetEmptyOrDefault()
{
// 如果没有数据,返回空数组
return Array.Empty<int>(); // 或 new int[0]
}
// 使用前检查
if (array != null && array.Length > 0)
{
// 安全访问
}
// 使用 null 条件运算符(C# 6.0+)
int? first = array?.FirstOrDefault(); // 如果 array 为 null,返回 0
int length = array?.Length ?? 0; // 如果 array 为 null,返回 0
// 使用 null 条件索引(C# 8.0+)
int? value = array?[0]; // 如果 array 为 null,返回 null(需要可空值类型)
2.3.3 Array.Empty<T>() vs new T[0]
// Array.Empty<T>() 更好 —— 返回一个缓存的单例空数组,不分配新内存
int[] empty1 = Array.Empty<int>(); // 每次调用返回同一个实例
int[] empty2 = Array.Empty<int>(); // 内存中只有一个空数组对象
int[] empty3 = new int[0]; // 每次创建一个新的空数组对象
int[] empty4 = new int[0]; // 又创建一个新的
// empty3 和 empty4 是两个不同的对象!浪费 GC
Console.WriteLine(ReferenceEquals(empty1, empty2)); // True(同一个对象)
Console.WriteLine(ReferenceEquals(empty3, empty4)); // False(两个不同对象)
2.4 数组长度为什么不能改
2.4.1 设计原理
数组的长度在创建时就已经写入对象头,之后无法改变。这是出于:
-
内存连续性:数组是一块连续内存,扩容意味着后面的内存可能已被其他对象占用
-
性能:固定长度让索引计算变成最简单的指针偏移
-
安全:长度固定的数据结构更容易进行边界检查和优化
2.4.2 Array.Resize 的真相
int[] numbers = { 1, 2, 3 };
Array.Resize(ref numbers, 5);
// Resize 实际上等价于:
// int[] newArray = new int[5];
// Array.Copy(numbers, newArray, Math.Min(numbers.Length, 5));
// numbers = newArray; // 通过 ref 参数重新赋值引用
这带来一个重要的陷阱:
int[] original = { 1, 2, 3 };
int[] another = original; // another 和 original 指向同一个数组
Array.Resize(ref original, 5); // original 现在指向新数组
Console.WriteLine(original.Length); // 5 — 新数组
Console.WriteLine(another.Length); // 3 — 还指向旧数组!
Console.WriteLine(another[0]); // 1 — 旧数据还在旧数组里
Array.Resize(ref 数组,值),不要忘记ref;带 ref,函数拿到的是原数组变量的内存地址,可以直接修改外面变量的指向。
2.4.3 如何"动态扩容"
如果你需要动态扩容,不要手动 Array.Resize,直接使用 List<T>:
// 推荐:使用 List<T>
List<int> list = new List<int> { 1, 2, 3 };
list.Add(4);
list.Add(5);
Console.WriteLine(list.Count); // 5
// List<T> 内部管理一个数组,自动扩容策略是 2 倍增长
// 当元素超过 Capacity 时,创建 2 倍大小的新数组并复制
2.5 索引与长度
2.5.1 底层原因
数组元素的内存地址计算方式是:
元素地址 = 基地址 + 索引 × 元素大小
当索引 = 0 时,元素地址 = 基地址,即第一个元素就在数组数据的起始位置。
基地址 (数组数据起始位置)
│
▼
┌────────┐ ← scores[0] 地址 = 基地址 + 0 × 4 = 基地址 + 0
│ 90 │
├────────┤ ← scores[1] 地址 = 基地址 + 1 × 4 = 基地址 + 4
│ 85 │
├────────┤ ← scores[2] 地址 = 基地址 + 2 × 4 = 基地址 + 8
│ 92 │
├────────┤
│ 78 │
├────────┤
│ 88 │
└────────┘
如果索引从 1 开始,每次访问都需要 基地址 + (索引 - 1) × 元素大小,多一次减法运算。这对现代 CPU 不算什么,但 C 语言设计者(以及后来的 C#)选择了零基索引——这也是 CPU 指令集和寻址方式最自然的映射。
2.5.2 循环边界
int[] arr = { 10, 20, 30, 40, 50 };
// 正确:i < arr.Length
for (int i = 0; i < arr.Length; i++)
Console.WriteLine(arr[i]); // 访问索引 0, 1, 2, 3, 4
// 错误:i <= arr.Length —— 最后一次访问 arr[5],越界!
for (int i = 0; i <= arr.Length; i++)
Console.WriteLine(arr[i]); // IndexOutOfRangeException!
记住:数组最后一个元素的索引是 Length - 1,不是 Length。循环i从0开始则不能等于(=)数组长度arr.Length。
2.5.3 Length 属性
int[] arr = { 10, 20, 30, 40, 50 };
Console.WriteLine(arr.Length); // 5
// Length 是只读属性,不能直接修改
// arr.Length = 10; // 编译错误
Length 返回 int 类型,最大可表示 int.MaxValue(约 21.47 亿)个元素。CLR 实际上对单个数组的大小有更严格的限制——单个数组最大约 2GB(即使在 64 位系统上,除非启用 gcAllowVeryLargeObjects)。
2.5.4 Long Length 属性
// 当数组可能超过 2^31 个元素时(需要启用 gcAllowVeryLargeObjects)
long length = arr.LongLength; // 返回 long 类型
在常规开发中很少用,但在处理大型数据集(如大规模图像拼接、科学计算)时会出现。
2.5.5 Rank 属性
返回数组的维数:
int[] a = new int[5];
Console.WriteLine(a.Rank); // 1
int[,] b = new int[3, 4];
Console.WriteLine(b.Rank); // 2
int[,,] c = new int[2, 3, 4];
Console.WriteLine(c.Rank); // 3
2.5.6 GetLength 和 GetUpperBound
int[,] matrix = new int[3, 4]; // 3行 4列
// 获取指定维度的长度
Console.WriteLine(matrix.GetLength(0)); // 3(第0维:行)
Console.WriteLine(matrix.GetLength(1)); // 4(第1维:列)
// 获取指定维度的最后一个索引
Console.WriteLine(matrix.GetUpperBound(0)); // 2(行索引范围 0-2)
Console.WriteLine(matrix.GetUpperBound(1)); // 3(列索引范围 0-3)
// GetLowerBound 总是 0(C# 数组零基)
Console.WriteLine(matrix.GetLowerBound(0)); // 0
2.5.7 边界检查
int[] arr = { 10, 20, 30 };
// 每次索引访问,CLR 都会进行边界检查
arr[5] = 100; // 运行时抛出 IndexOutOfRangeException
// 边界检查的性能开销通常可以忽略,但在极端高频循环中:
// 1. 如果 JIT 能证明索引一定在范围内(如 for i < arr.Length),它会消除边界检查
// 2. 使用 Span<T> 可以在某些场景下跳过边界检查
2.6 默认值与初始化本质
2.6.1 各类型默认值表
new 数组后,CLR 会将所有元素初始化为该类型的默认值:
| 元素类型 | 默认值 | 说明 |
|---|---|---|
int, long, short, byte 等整数 |
0 |
数值类型的零值 |
float, double, decimal |
0.0 |
浮点类型的零值 |
bool |
false |
布尔类型的默认值 |
char |
'\0' |
空字符(Unicode 0) |
string |
null |
字符串是引用类型 |
| 任何引用类型(class) | null |
所有引用类型默认值 |
struct(值类型结构体) |
所有字段为各自默认值 | 结构体被"零初始化" |
enum |
0(对应枚举值) |
底层是整数 |
2.6.2 CLR 的初始化机制
int[] numbers = new int[1000000];
这一行代码执行时,CLR 不会逐个循环 100 万次来设 0。实际上:
-
小对象:通过 JIT 编译的
initblkIL 指令,CPU 直接批量清零 -
大对象(>= 85000 字节,约 21250 个 int):分配在大对象堆(LOH),由 FCall(内部 C++ 实现)快速清零
-
对于引用类型数组(如
string[]),清零就是把引用全部置为null
性能提示:new int[1000000] 的初始化几乎是 O(1) 时间(因为 CLR 知道内存页已经被操作系统清零,可以延迟提交)。
2.6.3 验证默认值
int[] ints = new int[3];
Console.WriteLine(ints[0]); // 0
Console.WriteLine(ints[1]); // 0
Console.WriteLine(ints[2]); // 0
double[] doubles = new double[3];
Console.WriteLine(doubles[0]); // 0
bool[] bools = new bool[3];
Console.WriteLine(bools[0]); // False
string[] strings = new string[3];
Console.WriteLine(strings[0]); // (null,控制台输出空白行)
Console.WriteLine(strings[0] == null); // True
// 结构体数组
Point[] points = new Point[3];
Console.WriteLine(points[0].X); // 0
Console.WriteLine(points[0].Y); // 0
struct Point { public int X; public int Y; }
三、 遍历与常见操作
把开发里最常写的遍历、统计、查找、反转、复制等操作归在一起,方便连贯阅读。
3.1 数组遍历方式
3.1.1 for 循环(可读写、有索引)
int[] scores = { 90, 85, 92, 78, 88 };
// 正向遍历
for (int i = 0; i < scores.Length; i++)
{
Console.WriteLine($"索引 {i}: {scores[i]}");
}
// 反向遍历
for (int i = scores.Length - 1; i >= 0; i--)
{
Console.WriteLine($"索引 {i}: {scores[i]}");
}
// 修改元素
for (int i = 0; i < scores.Length; i++)
{
scores[i] += 5; // 每个分数加 5 分
}
适用场景:需要索引、需要修改元素、需要控制遍历方向/步长。
3.1.2 foreach 循环(只读、无索引)
int[] scores = { 90, 85, 92, 78, 88 };
foreach (int score in scores)
{
Console.WriteLine(score);
}
// foreach 不能修改元素值
foreach (int score in scores)
{
// score += 5; // 编译错误!score 是迭代变量,只读
}
foreach 的底层原理:
// foreach 编译后大致等价于:
int[] tempArray = scores;
for (int i = 0; i < tempArray.Length; i++)
{
int score = tempArray[i];
Console.WriteLine(score);
}
// 对于数组,C# 编译器会优化成 for 循环,避免分配枚举器
适用场景:只读遍历,不关心索引。代码更简洁。
3.1.3 Array.ForEach 静态方法
int[] scores = { 90, 85, 92, 78, 88 };
// 使用 Lambda
Array.ForEach(scores, score => Console.WriteLine(score));
// 使用已定义的方法
Array.ForEach(scores, Console.WriteLine);
// 复杂操作
Array.ForEach(scores, score =>
{
if (score >= 90)
Console.WriteLine($"优秀: {score}");
else
Console.WriteLine($"一般: {score}");
});
3.1.4 while 和 do-while
int[] scores = { 90, 85, 92, 78, 88 };
int i = 0;
// while:先判断再执行
while (i < scores.Length)
{
Console.WriteLine(scores[i]);
i++;
}
// do-while:至少执行一次
i = 0;
do
{
Console.WriteLine(scores[i]);
i++;
} while (i < scores.Length);
// 注意:如果数组为空(Length=0),do-while 也会执行一次导致越界!
3.1.5 IEnumerator 显式迭代(很少用)
int[] scores = { 90, 85, 92 };
System.Collections.IEnumerator enumerator = scores.GetEnumerator();
while (enumerator.MoveNext())
{
int value = (int)enumerator.Current;
Console.WriteLine(value);
}
3.1.6 Span<T> 遍历(高性能场景)
int[] scores = { 90, 85, 92, 78, 88 };
Span<int> span = scores.AsSpan();
// 类似数组的索引访问,但可能跳过边界检查
for (int i = 0; i < span.Length; i++)
{
span[i] += 5; // 可以修改
}
// 切片后遍历
Span<int> slice = span.Slice(1, 3); // { 90,97,83 }
foreach (int s in slice)
Console.WriteLine(s);
3.1.7 遍历方式选型指南
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 只读遍历,不需要索引 | foreach |
简洁、安全 |
| 需要修改元素 | for |
唯一能直接修改的方式 |
| 需要索引参与计算 | for |
有索引变量 |
| 反向遍历 | for (i-- 递减) |
控制方向 |
| 需要 Lambda 风格 | Array.ForEach |
函数式编程 |
| 高性能热点路径 | for + Span<T> |
消除边界检查 |
| 不确定数组是否为空 | 先用 if 判断,再 for/foreach |
避免空操作 |
3.2 基础数据处理
3.2.1 求和与平均值
int[] scores = { 90, 85, 92, 78, 88 };
// 方法一:手动循环
int sum = 0;
for (int i = 0; i < scores.Length; i++)
sum += scores[i];
double average = (double)sum / scores.Length; // 强制转 double 保留小数
// 方法二:使用 LINQ(简洁但有些性能开销)
sum = scores.Sum();
average = scores.Average(); // 自动返回 double
// ⚠️ 关键坑:整数除法
int wrongAvg = sum / scores.Length; // 整数除法,小数被截断!
Console.WriteLine(wrongAvg); // 可能不是期望的结果
Console.WriteLine((double)sum / scores.Length); // 正确:先转换再除
3.2.2 最大值与最小值
int[] scores = { 90, 85, 92, 78, 88 };
// 手动实现
int max = scores[0];
int min = scores[0];
for (int i = 1; i < scores.Length; i++)
{
if (scores[i] > max) max = scores[i];
if (scores[i] < min) min = scores[i];
}
Console.WriteLine($"最高分: {max}, 最低分: {min}");
// LINQ 方式
max = scores.Max();
min = scores.Min();
// 注意:如果数组为空,scores[0] 会抛出 IndexOutOfRangeException
// LINQ 的 Max()/Min() 对空数组也会抛 InvalidOperationException
// 安全写法:
if (scores.Length > 0)
{
max = scores[0];
// ... 遍历
}
3.2.3 线性查找
int[] numbers = { 10, 20, 30, 40, 50, 30 };
int target = 30;
// 找到第一个匹配的索引
int foundIndex = -1;
for (int i = 0; i < numbers.Length; i++)
{
if (numbers[i] == target)
{
foundIndex = i;
break; // 找到即停止
}
}
Console.WriteLine(foundIndex >= 0
? $"找到目标值在索引 {foundIndex}"
: "未找到");
// 找到所有匹配的索引
List<int> allIndices = new List<int>();
for (int i = 0; i < numbers.Length; i++)
{
if (numbers[i] == target)
allIndices.Add(i);
}
Console.WriteLine($"目标值出现在索引: {string.Join(", ", allIndices)}");
// 输出: 目标值出现在索引: 2, 5
3.2.4 数组反转
int[] arr = { 1, 2, 3, 4, 5 };
// 方法一:手动双指针
for (int i = 0, j = arr.Length - 1; i < j; i++, j--)
{
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
// 现在 arr = { 5, 4, 3, 2, 1 }
// 方法二:Array.Reverse
Array.Reverse(arr); // 原地反转
// 方法三:使用 LINQ(创建新数组)
int[] reversed = arr.Reverse().ToArray(); // 需要 using System.Linq;
3.2.5 数组复制
int[] source = { 1, 2, 3, 4, 5 };
// 方法一:Array.Copy(最快)
int[] target1 = new int[source.Length];
Array.Copy(source, target1, source.Length);
// 方法二:Array.Copy 部分复制
int[] target2 = new int[3];
Array.Copy(source, 1, target2, 0, 3); // 从 source[1] 开始复制 3 个到 target2[0]
// target2 = { 2, 3, 4 }
// 方法三:Clone(返回 object,需要转型)
int[] target3 = (int[])source.Clone();
// 方法四:CopyTo
int[] target4 = new int[source.Length];
source.CopyTo(target4, 0);
// 方法五:LINQ
int[] target5 = source.ToArray();
int[] target6 = source.Select(x => x).ToArray();
// 方法六:Buffer.BlockCopy(字节级复制,最快但需要手动计算字节数)
int[] target7 = new int[source.Length];
Buffer.BlockCopy(source, 0, target7, 0, source.Length * sizeof(int));
3.2.6 浅拷贝 vs 深拷贝
// 对于值类型数组:Copy 就是"深拷贝"(因为值类型直接存值)
int[] src = { 1, 2, 3 };
int[] dst = new int[3];
Array.Copy(src, dst, 3);
dst[0] = 999;
Console.WriteLine(src[0]); // 1 — 不受影响
// 对于引用类型数组:Copy 只是"浅拷贝"(复制引用,不复制对象)
StringBuilder[] src2 = { new StringBuilder("A"), new StringBuilder("B") };
StringBuilder[] dst2 = new StringBuilder[2];
Array.Copy(src2, dst2, 2);
dst2[0].Append("X");
Console.WriteLine(src2[0].ToString()); // "AX" — 原数组也受影响!
// 深拷贝需要逐个复制对象
StringBuilder[] deepCopy = new StringBuilder[src2.Length];
for (int i = 0; i < src2.Length; i++)
deepCopy[i] = new StringBuilder(src2[i].ToString());
四、 Array 类与常用算法
先掌握 System.Array 提供的标准能力,再延伸到排序和查找算法的实现与比较。
4.1 Array 类核心 API
System.Array 是所有数组的抽象基类,提供了大量静态和实例方法。
4.1.1 Sort — 排序
int[] numbers = { 5, 1, 9, 3, 7 };
// 默认升序
Array.Sort(numbers); // { 1, 3, 5, 7, 9 }
// 部分排序:对索引 1 到 3(共 3 个元素)排序
int[] arr = { 5, 9, 3, 7, 1 };
Array.Sort(arr, 1, 3); // { 5, 3, 7, 9, 1 } — 只排了 [1] 到 [3]
// 降序排序
Array.Sort(numbers); // 先升序
Array.Reverse(numbers); // 再反转 → 降序
// 或使用 Comparison 委托
Array.Sort(numbers, (a, b) => b.CompareTo(a)); // 降序
// 自定义排序规则
string[] names = { "Alice", "bob", "Charlie" };
Array.Sort(names, StringComparer.OrdinalIgnoreCase); // 忽略大小写
// { "Alice", "bob", "Charlie" }
// 对两个关联数组排序(如按分数排学生姓名)
string[] students = { "张三", "李四", "王五" };
int[] scores = { 85, 92, 78 };
Array.Sort(scores, students); // 按 scores 排序,students 跟随调整
// scores: { 78, 85, 92 }
// students:{ "王五", "张三", "李四" }
底层实现:Array.Sort 对基本类型使用 内省排序(IntroSort)——以快速排序开始,递归深度过大时切换到堆排序,小分区使用插入排序。这是目前最快的通用排序算法之一。
4.1.2 Reverse — 反转
int[] numbers = { 1, 2, 3, 4, 5 };
Array.Reverse(numbers); // { 5, 4, 3, 2, 1 }
// 部分反转
int[] arr = { 1, 2, 3, 4, 5 };
Array.Reverse(arr, 1, 3); // { 1, 4, 3, 2, 5 } — 从索引1开始反转3个元素
4.1.3 IndexOf / LastIndexOf — 查找索引
int[] numbers = { 10, 20, 30, 20, 50 };
int idx = Array.IndexOf(numbers, 20); // 1(第一个 20 的索引)
int idx2 = Array.IndexOf(numbers, 20, 2); // 3(从索引 2 开始找,找到第二个 20)
int idx3 = Array.IndexOf(numbers, 99); // -1(未找到)
int lastIdx = Array.LastIndexOf(numbers, 20); // 3(最后一个 20 的索引)
4.1.4 BinarySearch — 二分查找
// 必须先排序!二分查找的前提是数组已排序
int[] numbers = { 5, 1, 9, 3, 7 };
Array.Sort(numbers); // { 1, 3, 5, 7, 9 }
int idx = Array.BinarySearch(numbers, 7); // 3(找到了)
int idx2 = Array.BinarySearch(numbers, 6); // -4(没找到,~(-4) = 3,应插入在索引 3)
// 理解负数返回值:
// 如果没找到,返回值为负数。按位取反(~result)得到应该插入的位置
int searchResult = Array.BinarySearch(numbers, 6);
if (searchResult < 0)
{
int insertIndex = ~searchResult; // 3 — 把 6 插入到索引 3 保持有序
Console.WriteLine($"应该插入在索引 {insertIndex}");
}
4.1.5 Clear — 清零
int[] numbers = { 1, 2, 3, 4, 5 };
Array.Clear(numbers, 0, numbers.Length); // 全部清零 → { 0, 0, 0, 0, 0 }
Array.Clear(numbers, 1, 2); // 从索引1开始清2个 → { 1, 0, 0, 4, 5 }
// 对于引用类型数组,Clear 将所有引用置为 null
string[] names = { "Alice", "Bob", "Charlie" };
Array.Clear(names, 0, names.Length); // { null, null, null }
4.1.6 Copy — 复制
int[] source = { 1, 2, 3, 4, 5 };
int[] target = new int[5];
Array.Copy(source, target, 5); // 全部复制
Array.Copy(source, 1, target, 2, 3); // 从 source[1] 开始复制 3 个到 target[2]
// target = { 0, 0, 2, 3, 4 }
// 注意:Copy 会在遇到重叠时正确处理(使用 memmove)
// 类型检查:运行时检查元素类型兼容性
4.1.7 Resize — 调整大小
int[] numbers = { 1, 2, 3 };
Array.Resize(ref numbers, 5);
// numbers = { 1, 2, 3, 0, 0 } — 扩容,新增元素为默认值
Array.Resize(ref numbers, 2);
// numbers = { 1, 2 } — 截断,多余元素被丢弃
// 注意 ref 关键字!Resize 内部:
// 1. 创建新数组
// 2. 复制旧数据
// 3. 让传入的变量指向新数组
// 原来引用旧数组的其他变量不会自动更新!
4.1.8 Exists / TrueForAll / Find / FindAll / FindIndex — 条件查找
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// 是否存在
bool hasLarge = Array.Exists(numbers, n => n > 5); // true
// 是否全部满足
bool allPositive = Array.TrueForAll(numbers, n => n > 0); // true
// 查找第一个匹配元素
int firstEven = Array.Find(numbers, n => n % 2 == 0); // 2
// 查找最后一个匹配元素
int lastEven = Array.FindLast(numbers, n => n % 2 == 0); // 10
// 查找所有匹配元素
int[] allEvens = Array.FindAll(numbers, n => n % 2 == 0); // { 2, 4, 6, 8, 10 }
// 查找第一个匹配索引
int firstEvenIdx = Array.FindIndex(numbers, n => n % 2 == 0); // 1
// 查找最后一个匹配索引
int lastEvenIdx = Array.FindLastIndex(numbers, n => n % 2 == 0); // 9
4.1.9 ConvertAll — 类型转换
int[] numbers = { 1, 2, 3, 4, 5 };
// 将 int[] 转换为 string[]
string[] strings = Array.ConvertAll(numbers, n => n.ToString());
// { "1", "2", "3", "4", "5" }
// 将 int[] 转换为 double[]
double[] doubles = Array.ConvertAll(numbers, n => (double)n);
// 复杂转换
string[] descriptions = Array.ConvertAll(numbers,
n => $"这是数字 {n},它的平方是 {n * n}");
4.1.10 Fill — 填充(.NET Core 2.0+ / .NET 5+)
int[] numbers = new int[5];
Array.Fill(numbers, 42); // { 42, 42, 42, 42, 42 }
Array.Fill(numbers, 99, 1, 3); // { 42, 99, 99, 99, 42 } — 从索引1填充3个
4.1.11 ConstrainedCopy — 安全复制
// ConstrainedCopy 保证原子性:要么全部复制成功,要么一个都不复制
// 如果中途失败(如类型不匹配),目标数组不会被部分修改
try
{
Array.ConstrainedCopy(source, 0, target, 0, source.Length);
}
catch (Exception)
{
// target 保持不变
}
4.1.12 Array 类常用方法速查表
| 方法 | 说明 | 是否修改原数组 |
|---|---|---|
Array.Sort(arr) |
原地升序排序 | 是 |
Array.Reverse(arr) |
原地反转 | 是 |
Array.IndexOf(arr, val) |
查找第一个匹配值的索引 | 否 |
Array.LastIndexOf(arr, val) |
查找最后一个匹配值的索引 | 否 |
Array.BinarySearch(arr, val) |
二分查找(需先排序) | 否 |
Array.Clear(arr, idx, len) |
将指定范围元素置为默认值 | 是 |
Array.Copy(src, dst, len) |
复制数组 | 否(对 dst) |
Array.Resize(ref arr, size) |
调整数组大小(新建) | 是(重建) |
Array.Exists(arr, pred) |
是否存在满足条件的元素 | 否 |
Array.TrueForAll(arr, pred) |
是否所有元素都满足条件 | 否 |
Array.Find(arr, pred) |
查找第一个满足条件的元素 | 否 |
Array.FindAll(arr, pred) |
查找所有满足条件的元素 | 否 |
Array.FindIndex(arr, pred) |
查找第一个满足条件的索引 | 否 |
Array.ConvertAll(arr, conv) |
类型转换,返回新数组 | 否 |
Array.Fill(arr, val) |
填充数组 | 是 |
Array.ForEach(arr, action) |
对每个元素执行操作 | 取决于 action |
arr.Clone() |
浅拷贝,返回 object | 否 |
arr.CopyTo(dst, idx) |
复制到目标数组 | 否 (对 dst) |
arr.GetLength(dim) |
获取指定维度长度 | 否 |
4.2 排序算法
4.1.1 冒泡排序(Bubble Sort)
// 思想:相邻元素两两比较,大的往后"冒"
// 时间复杂度:O(n²),空间复杂度:O(1),稳定排序
void BubbleSort(int[] arr)
{
int n = arr.Length;
for (int i = 0; i < n - 1; i++)
{
bool swapped = false; // 优化:如果一轮没有交换,说明已经有序
for (int j = 0; j < n - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
// 交换
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true;
}
}
if (!swapped) break; // 提前退出
}
}
// 测试
int[] arr = { 64, 34, 25, 12, 22, 11, 90 };
BubbleSort(arr);
// { 11, 12, 22, 25, 34, 64, 90 }
4.1.2 选择排序(Selection Sort)
// 思想:每次找到未排序部分的最小值,放到已排序部分的末尾
// 时间复杂度:O(n²),空间复杂度:O(1),不稳定排序
void SelectionSort(int[] arr)
{
int n = arr.Length;
for (int i = 0; i < n - 1; i++)
{
int minIndex = i;
for (int j = i + 1; j < n; j++)
{
if (arr[j] < arr[minIndex])
minIndex = j;
}
// 交换
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
4.1.3 插入排序(Insertion Sort)
// 思想:将未排序元素逐个插入到已排序部分的正确位置
// 时间复杂度:O(n²),最好情况 O(n),空间复杂度:O(1),稳定排序
// 对于小数组(< 20 个元素)或基本有序的数组非常快
void InsertionSort(int[] arr)
{
int n = arr.Length;
for (int i = 1; i < n; i++)
{
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
4.1.4 快速排序(Quick Sort)
// 思想:选基准值(pivot),将数组分为小于和大于 pivot 的两部分,递归排序
// 时间复杂度:平均 O(n log n),最坏 O(n²),空间复杂度:O(log n),不稳定排序
// 这是 Array.Sort 的主要算法
void QuickSort(int[] arr, int left, int right)
{
if (left >= right) return;
int pivot = Partition(arr, left, right);
QuickSort(arr, left, pivot - 1);
QuickSort(arr, pivot + 1, right);
}
int Partition(int[] arr, int left, int right)
{
int pivot = arr[right]; // 选最右元素为基准
int i = left - 1;
for (int j = left; j < right; j++)
{
if (arr[j] <= pivot)
{
i++;
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
int temp2 = arr[i + 1];
arr[i + 1] = arr[right];
arr[right] = temp2;
return i + 1;
}
// 调用
int[] arr = { 64, 34, 25, 12, 22, 11, 90 };
QuickSort(arr, 0, arr.Length - 1);
4.1.5 排序算法对比
| 算法 | 时间复杂度(平均) | 最坏 | 空间 | 稳定 | 适用场景 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 是 | 教学演示 |
| 选择排序 | O(n²) | O(n²) | O(1) | 否 | 写入次数少 |
| 插入排序 | O(n²) | O(n²) | O(1) | 是 | 小数组、基本有序 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 否 | 通用(Array.Sort 基础) |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 否 | 快速排序退化时 |
| Array.Sort | O(n log n) | O(n log n) | O(log n) | 否 | 日常首选 |
4.3 查找算法
4.3.1 线性查找
// 时间复杂度:O(n),适用于未排序数组
int LinearSearch(int[] arr, int target)
{
for (int i = 0; i < arr.Length; i++)
{
if (arr[i] == target)
return i;
}
return -1; // 未找到
}
4.3.2 二分查找
// 前提:数组必须已排序
// 时间复杂度:O(log n)
int BinarySearch(int[] sortedArr, int target)
{
int left = 0;
int right = sortedArr.Length - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; // 防溢出写法
if (sortedArr[mid] == target)
return mid;
else if (sortedArr[mid] < target)
left = mid + 1;
else
right = mid - 1;
}
return -1; // 未找到
}
// 测试
int[] arr = { 11, 12, 22, 25, 34, 64, 90 }; // 必须已排序
int idx = BinarySearch(arr, 25); // 3
int idx2 = BinarySearch(arr, 50); // -1
二分查找的注意点:
-
必须先排序
-
中值计算用
left + (right - left) / 2而非(left + right) / 2,防止整数溢出 -
循环条件用
left <= right,不是left < right(会漏掉最后一个元素)
五、进阶数组模型
从多维数组到交错数组,再到协变问题,帮助你理解数组在结构设计层面的差异与风险。
5.1 多维数组
5.1.1 二维数组的声明与创建
// 声明二维数组(逗号数量 = 维数 - 1)
int[,] matrix; // 声明
matrix = new int[3, 4]; // 3 行 4 列
// 一步完成
int[,] grid = new int[2, 5];
// 声明时初始化
int[,] matrix2 = new int[,]
{
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
// 简写
int[,] matrix3 =
{
{ 1, 2, 3 },
{ 4, 5, 6 }
};
5.1.2 二维数组的内存布局
int[,] matrix = { { 1, 2, 3 }, { 4, 5, 6 } };
内存中的实际布局(行优先,Row-Major):
[0,0]=1 [0,1]=2 [0,2]=3 [1,0]=4 [1,1]=5 [1,2]=6
C# 的多维数组在托管堆上是一块连续内存,按照行优先(Row-Major)顺序存储。这对缓存性能很重要。
5.1.3 二维数组的访问
int[,] matrix =
{
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
// 访问单个元素
matrix[1, 2] = 99; // 将第 1 行第 2 列改成 99(原来为 7)
Console.WriteLine(matrix[1, 2]); // 99
Console.WriteLine(matrix[2, 3]); // 12
// 获取维度信息
int rows = matrix.GetLength(0); // 3 行
int cols = matrix.GetLength(1); // 4 列
int total = matrix.Length; // 12(总元素数 = 3 × 4)
// 注意:matrix.Length 不能获取行数或列数!
// 错误用法:matrix.Length 误以为是行数
5.1.4 遍历二维数组
int[,] matrix =
{
{ 1, 2, 3 },
{ 4, 5, 6 },
{ 7, 8, 9 }
};
// 方法一:双重 for 循环(推荐)
for (int row = 0; row < matrix.GetLength(0); row++)
{
for (int col = 0; col < matrix.GetLength(1); col++)
{
Console.Write($"{matrix[row, col],3} ");
}
Console.WriteLine();
}
// 输出:
// 1 2 3
// 4 5 6
// 7 8 9
// 方法二:foreach(按行优先顺序遍历所有元素,打平输出)
foreach (int value in matrix)
{
Console.Write($"{value} "); // 1 2 3 4 5 6 7 8 9
}
// foreach 在多维数组中按行优先顺序遍历,不提供行列信息
// 方法三:反向遍历行
for (int row = matrix.GetLength(0) - 1; row >= 0; row--)
{
for (int col = 0; col < matrix.GetLength(1); col++)
{
Console.Write($"{matrix[row, col],3} ");
}
Console.WriteLine();
}
5.1.5 三维数组及多维数组
// 三维数组(如 RGB 图像:高度 × 宽度 × 通道)
int[,,] tensor = new int[2, 3, 4]; // 2×3×4 = 24 个元素
tensor[0, 1, 2] = 42;
Console.WriteLine(tensor.Rank); // 3
Console.WriteLine(tensor.GetLength(0)); // 2
Console.WriteLine(tensor.GetLength(1)); // 3
Console.WriteLine(tensor.GetLength(2)); // 4
Console.WriteLine(tensor.Length); // 24
// 遍历三维数组
for (int i = 0; i < tensor.GetLength(0); i++)
for (int j = 0; j < tensor.GetLength(1); j++)
for (int k = 0; k < tensor.GetLength(2); k++)
Console.WriteLine($"[{i},{j},{k}] = {tensor[i, j, k]}");
// 理论上 C# 支持最高 32 维数组,但实际上几乎只用 1-3 维
int[,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,] insaneArray; // 32维(永远不会用到)
5.1.6 二维数组的实战应用
// 例 1:成绩单
string[] students = { "张三", "李四", "王五" };
string[] subjects = { "语文", "数学", "英语" };
int[,] scores =
{
{ 85, 92, 78 },
{ 90, 88, 95 },
{ 76, 84, 80 }
};
// 打印成绩单
Console.Write("姓名\t");
foreach (string subject in subjects)
Console.Write($"{subject}\t");
Console.WriteLine("总分\t平均分");
for (int row = 0; row < scores.GetLength(0); row++)
{
Console.Write($"{students[row]}\t");
int rowSum = 0;
for (int col = 0; col < scores.GetLength(1); col++)
{
Console.Write($"{scores[row, col]}\t");
rowSum += scores[row, col];
}
double rowAvg = (double)rowSum / scores.GetLength(1);
Console.Write($"{rowSum}\t{rowAvg:F1}");
Console.WriteLine();
}
// 例 2:矩阵加法
int[,] AddMatrices(int[,] a, int[,] b)
{
int rows = a.GetLength(0);
int cols = a.GetLength(1);
int[,] result = new int[rows, cols];
for (int i = 0; i < rows; i++)
for (int j = 0; j < cols; j++)
result[i, j] = a[i, j] + b[i, j];
return result;
}
// 例 3:图像翻转(水平镜像)
int[,] FlipHorizontal(int[,] image)
{
int h = image.GetLength(0);
int w = image.GetLength(1);
int[,] result = new int[h, w];
for (int row = 0; row < h; row++)
for (int col = 0; col < w; col++)
result[row, col] = image[row, w - 1 - col];
return result;
}
5.2 交错数组
5.2.1 什么是交错数组
交错数组(Jagged Array)是"数组的数组"——每个元素本身又是一个数组。每一行的长度可以不同。
// int[][] 是一个一维数组,每个元素是 int[](一个一维数组)
int[][] jagged = new int[3][]; // 创建有 3 个"行"的交错数组
// 每一行需要单独创建
jagged[0] = new int[] { 1, 2, 3 };
jagged[1] = new int[] { 4, 5 };
jagged[2] = new int[] { 6, 7, 8, 9 };
// 形状:
// 行0: [ 1, 2, 3 ] 长度 = 3
// 行1: [ 4, 5 ] 长度 = 2
// 行2: [ 6, 7, 8, 9 ] 长度 = 4
5.2.2 交错数组的内存结构
栈 托管堆
┌──────────┐ ┌────────────────┐
│ jagged │──────────────▶│ int[3][] 对象 |
└──────────┘ │ [0] ──────────────┐
│ [1] ──────────┐ │
│ [2] ──────┐ │ │
└────────────┼───┼───┼──┐
│ │ │ │
┌────────────┘ │ │ │
▼ │ │ │
┌──────────┐ │ │ │
│ {1,2,3} │ │ │ │
└──────────┘ │ │ │
│ │ │
┌────────────────┘ │ │
▼ │ │
┌──────────┐ │ │
│ {4,5} │ │ │
└──────────┘ │ │
│ │
┌────────────────────┘ │
▼ │
┌──────────┐ │
│ {6,7,8,9}│ │
└──────────┘ │
│
// 注意:各行数组在内存中不一定连续
关键区别:交错数组的行不是连续存储的——每一行是一个独立分配的数组对象,可以分布在堆上任意位置。
5.2.3 初始化交错数组
// 完整初始化
int[][] jagged = new int[3][]
{
new int[] { 1, 2, 3 },
new int[] { 4, 5 },
new int[] { 6, 7, 8, 9 }
};
// 简写
int[][] jagged2 =
{
new int[] { 1, 2, 3 },
new int[] { 4, 5 },
new int[] { 6, 7, 8, 9 }
};
// 先创建行,再逐一赋值
int[][] jagged3 = new int[3][];
jagged3[0] = new int[4]; // 第 0 行有 4 个元素
jagged3[1] = new int[2]; // 第 1 行有 2 个元素
jagged3[2] = new int[5]; // 第 2 行有 5 个元素
// 注意:new int[3][] 只创建了"外数组",内层数组全部为 null
5.2.4 访问交错数组
int[][] jagged =
{
new int[] { 1, 2, 3 },
new int[] { 4, 5 },
new int[] { 6, 7, 8, 9 }
};
// 访问元素:使用 [行索引][列索引]
Console.WriteLine(jagged[0][1]); // 2
Console.WriteLine(jagged[2][3]); // 9
// 修改元素
jagged[1][0] = 99;
Console.WriteLine(jagged[1][0]); // 99
// 获取长度
Console.WriteLine(jagged.Length); // 3(行数)
Console.WriteLine(jagged[0].Length); // 3(第 0 行有 3 个元素)
Console.WriteLine(jagged[1].Length); // 2(第 1 行有 2 个元素)
Console.WriteLine(jagged[2].Length); // 4(第 2 行有 4 个元素)
5.2.5 遍历交错数组
int[][] jagged =
{
new int[] { 1, 2, 3 },
new int[] { 4, 5 },
new int[] { 6, 7, 8, 9 }
};
// 方法一:双重 for 循环(安全,推荐)
for (int i = 0; i < jagged.Length; i++)
{
Console.Write($"行 {i}: ");
for (int j = 0; j < jagged[i].Length; j++)
{
Console.Write($"{jagged[i][j],3} ");
}
Console.WriteLine();
}
// 方法二:foreach + foreach(简洁)
foreach (int[] row in jagged)
{
foreach (int value in row)
{
Console.Write($"{value} ");
}
Console.WriteLine();
}
// 方法三:混合 for + foreach
for (int i = 0; i < jagged.Length; i++)
{
Console.Write($"行 {i}: ");
foreach (int value in jagged[i])
Console.Write($"{value} ");
Console.WriteLine();
}
5.2.6 交错数组的常见操作
// 统计总元素数
int total = 0;
for (int i = 0; i < jagged.Length; i++)
total += jagged[i].Length;
Console.WriteLine($"总元素数: {total}");
// 求所有元素的和
int sum = 0;
foreach (int[] row in jagged)
foreach (int value in row)
sum += value;
Console.WriteLine($"总和: {sum}");
// 找全局最大值
int max = int.MinValue;
foreach (int[] row in jagged)
foreach (int value in row)
if (value > max) max = value;
Console.WriteLine($"最大值: {max}");
5.3 多维数组与交错数组对比
5.3.1 对比表
| 特性 | 二维数组 int[,] |
交错数组 int[][] |
|---|---|---|
| 语法 | int[,] |
int[][] |
| 访问方式 | matrix[i, j] |
jagged[i][j] |
| 内存布局 | 连续内存块 | 各行独立分配 |
| 行长度 | 所有行等长 | 每行长度可以不同 |
| 缓存友好度 | 高(连续内存) | 较低(各行分散) |
| 分配效率 | 一次分配 | 多次分配(外数组 + 每个行数组) |
| 内存开销 | 一个对象头 | 多个对象头(1 + 行数) |
| CLR 索引优化 | 有特殊 IL 指令 | 两次数组访问 |
| 与其它语言互操作 | 与 Fortran/Matlab 类似 | 需要额外转换 |
| 适合场景 | 规则表格、图像、矩阵 | 不规则数据(学生-不同科目数) |
5.3.2 选型指南
// 使用二维数组 (int[,]):
// - 数据是规则的矩形表格
// - 每行长度完全相同
// - 需要高性能矩阵运算
// - 处理图像数据(像素网格)
int[,] image = new int[1080, 1920]; // 图像数据
// 使用交错数组 (int[][]):
// - 每行长度不同
// - 需要动态增删行
// - 只需要部分行
// - 稀疏数据
int[][] studentCourses = new int[100][];
studentCourses[0] = new int[5]; // 学生0修5门课
studentCourses[1] = new int[3]; // 学生1修3门课
// ...每行长度不同
5.3.3 性能对比:两种数组的遍历
// 二维数组访问:一次 ldelem 指令 + 行列索引计算
int sum1 = 0;
for (int i = 0; i < 1000; i++)
for (int j = 0; j < 1000; j++)
sum1 += matrix[i, j]; // 单次数组访问
// 交错数组访问:两次数组访问(先取行,再取元素)
int sum2 = 0;
for (int i = 0; i < 1000; i++)
for (int j = 0; j < 1000; j++)
sum2 += jagged[i][j]; // jagged[i] 是一次访问,[j] 是第二次
// 在紧密循环中,二维数组通常更快
5.4 数组协变
5.4.1 什么是数组协变
C# 支持数组协变(Array Covariance),意味着你可以把 string[] 赋值给 object[] 变量:
string[] strings = { "Hello", "World" };
object[] objects = strings; // 编译通过!数组协变
Console.WriteLine(objects[0]); // "Hello" — 读取没问题
5.4.2 协变的危险
string[] strings = { "Hello", "World" };
object[] objects = strings; // 合法:string[] 赋值给 object[]
objects[0] = 123; // 编译通过!但运行时抛异常!
// ArrayTypeMismatchException: 试图在 string[] 中存储 int
为什么编译通过但运行时报错?
-
编译时:
objects的类型是object[],存储int(装箱为object)是合法的 -
运行时:CLR 记得数组的真实类型是
string[],存储int会触发类型检查,抛出ArrayTypeMismatchException
5.4.3 数组协变的历史原因
这个特性是 C# 1.0 时期为了兼容 Java 风格和方便使用而保留的(那时没有泛型)。在 C# 2.0 引入泛型后,数组协变的意义大大降低。
// 没有泛型的时代(C# 1.0),数组协变让你可以写通用方法:
void PrintAll(object[] items)
{
foreach (object item in items)
Console.WriteLine(item);
}
string[] names = { "Alice", "Bob" };
PrintAll(names); // 得益于数组协变
// 现在的替代方案(C# 2.0+):
void PrintAll<T>(T[] items) // 泛型版本,类型安全
{
foreach (T item in items)
Console.WriteLine(item);
}
5.4.4 值类型数组不参与协变
int[] ints = { 1, 2, 3 };
// object[] objs = ints; // 编译错误!
// 值类型数组不能协变为 object[](因为值类型和 object 的内存布局不同)
// int 是 4 字节值,而 object 是 8 字节引用,无法直接转换
5.4.5 安全建议
-
避免利用数组协变:除非你很清楚在做什么
-
使用泛型集合替代:
List<T>不协变,是类型安全的 -
如果实在要用,只读不写:只从协变数组中读取元素,绝不写入
六、现代特性与性能优化
聚合现代 C# 对数组的增强能力,并补齐实际项目里常见的性能优化与应用场景。
6.1 C# 现代数组特性
6.1.1 索引(Index)— C# 8.0+
int[] numbers = { 10, 20, 30, 40, 50 };
// 从末尾索引:^1 表示倒数第一个
Console.WriteLine(numbers[^1]); // 50(最后一个)
Console.WriteLine(numbers[^2]); // 40(倒数第二个)
Console.WriteLine(numbers[^3]); // 30
// 等价于
Console.WriteLine(numbers[numbers.Length - 1]); // 50
Console.WriteLine(numbers[numbers.Length - 2]); // 40
// Index 类型
Index last = ^1;
Console.WriteLine(numbers[last]); // 50
// Index 的方法
int offset = last.GetOffset(numbers.Length); // 4(从开头算的偏移)
bool fromEnd = last.IsFromEnd; // true
6.1.2 范围(Range)— C# 8.0+
int[] numbers = { 10, 20, 30, 40, 50, 60, 70, 80 };
// Range 语法:[start..end] — start 包含,end 不包含
int[] slice1 = numbers[1..4]; // { 20, 30, 40 } — 索引 1,2,3
int[] slice2 = numbers[2..^1]; // { 30, 40, 50, 60, 70 } — 索引 2 到倒数第 2 个
int[] slice3 = numbers[..3]; // { 10, 20, 30 } — 从开头到索引 3(不含)
int[] slice4 = numbers[3..]; // { 40, 50, 60, 70, 80 } — 从索引 3 到末尾
int[] slice5 = numbers[..]; // { 10, 20, ..., 80 } — 整个数组的浅拷贝
// Range 类型变量
Range range = 2..^2;
int[] slice6 = numbers[range]; // { 30, 40, 50, 60 }
// 注意:Range 操作会创建新数组(分配新内存)!
// 对于大数组,如果只是要读取,考虑用 Span<T> 代替
6.1.3 Span<T> — 零拷贝的数组视图
int[] numbers = { 10, 20, 30, 40, 50, 60, 70, 80 };
// Span<T> 是数组的一个"窗口",不分配新内存
Span<int> span = numbers.AsSpan(); // 整个数组的视图
Span<int> slice = span.Slice(2, 4); // { 30, 40, 50, 60 } — 零拷贝切片
// 通过 Span 读取
Console.WriteLine(slice[0]); // 30
Console.WriteLine(slice[1]); // 40
// 通过 Span 修改(会影响原数组!)
slice[0] = 999;
Console.WriteLine(numbers[2]); // 999 — 原数组也被修改了!
// 使用 Range 语法创建 Span(C# 8.0+)
Span<int> span2 = numbers.AsSpan()[2..^2]; // 零拷贝切片
6.1.4 Span<T> vs Array Segment vs 复制
| 操作 | 是否分配新内存 | 是否影响原数组 |
|---|---|---|
numbers[2..5] (Range) |
分配 | 不影响 |
numbers.AsSpan()[2..5] |
零分配 | 影响 |
ArraySegment<int> |
零分配 | 影响 |
Array.Copy + 新数组 |
分配 | 不影响 |
// 对于大数组(如 4K 图像 = 约 8MB),避免复制至关重要:
byte[] image = new byte[3840 * 2160 * 3]; // 4K RGB 图像 ≈ 24.9 MB
// 不好:复制一份 ROI 区域
byte[] roi = image[1000..3000]; // 分配新数组,复制 ~6MB
// 好:使用 Span 零拷贝引用 ROI
Span<byte> roiSpan = image.AsSpan()[1000 * 2160 * 3 .. 3000 * 2160 * 3];
ProcessROI(roiSpan);
6.1.5 ReadOnlySpan<T>
int[] numbers = { 10, 20, 30, 40, 50 };
// 只读视图:不能修改元素
ReadOnlySpan<int> readOnly = numbers.AsSpan();
// readOnly[0] = 99; // 编译错误!
// 适用于只需要读取数据的场景,如方法参数
void PrintData(ReadOnlySpan<int> data)
{
foreach (int value in data)
Console.WriteLine(value);
}
PrintData(numbers); // 从数组隐式转换
PrintData(stackalloc int[3]); // 从栈分配数组转换
PrintData(new int[] { 1, 2 }); // 所有类型都行
6.2 数组与 List<T> 的选型
6.2.1 对比表
| 特性 | 数组 T[] |
List<T> |
Span<T> |
|---|---|---|---|
| 长度 | 固定 | 动态增长 | 固定(只是视图) |
| 内存分配 | 一次 | 可能多次(扩容时) | 零分配 |
| 添加元素 | 不支持 | Add() |
不支持 |
| 删除元素 | 不支持 | Remove() |
不支持 |
| 性能(访问) | 最快 | 接近数组 | 最快 |
| 性能(增删) | 否 | 自动扩容 | 否 |
| 多维度支持 | int[,] |
需要嵌套 | 否 |
| 与 API 互操作 | 广泛使用 | 可能需要 ToArray() |
需要新版本 |
| 类型安全 | 是 | 是(泛型) | 是 |
6.2.2 选型指南
// 使用数组 T[] 当:
// - 数据量固定不变
// - 需要最高性能
// - 处理多维数据
// - 与旧 API 交互
int[] daysInMonth = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
// 使用 List<T> 当:
// - 需要动态增删元素
// - 数量不确定
// - 需要使用 LINQ 的各种扩展方法
List<Student> students = new List<Student>();
students.Add(new Student("张三"));
students.RemoveAt(0);
// 使用 Span<T> 当:
// - 需要对数组的部分进行零拷贝操作
// - 高性能场景(图像处理、网络包解析)
// - 方法参数接受多种内存类型
void ProcessData(Span<byte> buffer) { }
6.3 性能优化专题
6.1.1 缓存局部性
CPU 缓存以缓存行为单位(通常 64 字节):
// 缓存友好:顺序访问
int[] arr = new int[10000];
for (int i = 0; i < arr.Length; i++)
arr[i] = i * 2; // CPU 预取相邻数据到缓存
// 缓存不友好:大跨步访问导致频繁缓存未命中
for (int i = 0; i < arr.Length; i += 16) // 64 字节 = 16 个 int
arr[i] = i * 2;
//多维数组的行优先遍历(缓存友好)
int[,] matrix = new int[1000, 1000];
for (int i = 0; i < matrix.GetLength(0); i++) // 外层:行
for (int j = 0; j < matrix.GetLength(1); j++) // 内层:列
matrix[i, j] = i + j; // 顺序访问连续内存
//列优先遍历(缓存不友好)
for (int j = 0; j < matrix.GetLength(1); j++) // 外层:列
for (int i = 0; i < matrix.GetLength(0); i++) // 内层:行
matrix[i, j] = i + j; // 每次跳跃一整行,缓存未命中
6.2.2 使用 Buffer.BlockCopy 进行快速复制
// Buffer.BlockCopy 以字节为单位复制,比逐个元素复制快得多
int[] source = new int[1000000];
int[] target = new int[1000000];
// 慢:逐个复制
for (int i = 0; i < source.Length; i++)
target[i] = source[i];
// 快:BlockCopy(使用 memcpy/memmove 的优化实现)
Buffer.BlockCopy(source, 0, target, 0, source.Length * sizeof(int));
6.3.3 栈上分配:stackalloc
// 对于小型临时数组,可以在栈上分配(免 GC 压力)
Span<int> temp = stackalloc int[128]; // 在栈上分配 128 个 int
for (int i = 0; i < temp.Length; i++)
temp[i] = i * i;
// 栈空间有限(通常 1MB),只适合小型数组
// 不要 stackalloc 大数组,否则会 StackOverflow
// 不安全代码中的栈分配(需要 unsafe 上下文)
unsafe
{
int* ptr = stackalloc int[100];
for (int i = 0; i < 100; i++)
ptr[i] = i;
}
6.3.4 数组池:ArrayPool<T>
// 对于频繁创建和销毁数组的场景,使用 ArrayPool 避免 GC 压力
using System.Buffers;
// 租用数组
int[] buffer = ArrayPool<int>.Shared.Rent(1024); // 至少 1024 长度的数组
// 注意:返回的数组可能比请求的长!
try
{
// 使用 buffer(只用前 1024 个元素)
for (int i = 0; i < 1024; i++)
buffer[i] = ProcessItem(i);
}
finally
{
// 归还数组(必须归还!否则池会耗尽)
ArrayPool<int>.Shared.Return(buffer, clearArray: true); // clearArray 清空数据
}
6.4 数组在 AI 视觉中的应用
6.4.1 图像就是数组
// 灰度图:2D 数组
// 1920×1080 的灰度图像
byte[,] grayImage = new byte[1080, 1920];
// grayImage[y, x] 表示坐标 (x, y) 处的像素亮度(0-255)
// 彩色图:3D 数组
// 1920×1080 的 RGB 图像
byte[,,] colorImage = new byte[1080, 1920, 3];
// colorImage[y, x, 0] = R 分量
// colorImage[y, x, 1] = G 分量
// colorImage[y, x, 2] = B 分量
// 更常见的图像存储:一维数组 + 偏移计算
byte[] imageData = new byte[width * height * channels];
// 访问像素 (x, y) 的 R 通道:
// imageData[y * width * channels + x * channels + 0]
6.4.2 常见视觉操作
// 1. 灰度化:RGB 转灰度
byte[] RgbToGray(byte[,,] rgb, int height, int width)
{
byte[] gray = new byte[height * width];
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
{
// 加权平均:0.299R + 0.587G + 0.114B
gray[y * width + x] = (byte)(
0.299 * rgb[y, x, 0] +
0.587 * rgb[y, x, 1] +
0.114 * rgb[y, x, 2]
);
}
return gray;
}
// 2. 图像翻转
void FlipVertical(byte[] image, int width, int height, int channels)
{
int rowSize = width * channels;
byte[] tempRow = new byte[rowSize];
for (int y = 0; y < height / 2; y++)
{
int topRow = y * rowSize;
int bottomRow = (height - 1 - y) * rowSize;
// 交换两行
Array.Copy(image, topRow, tempRow, 0, rowSize);
Array.Copy(image, bottomRow, image, topRow, rowSize);
Array.Copy(tempRow, 0, image, bottomRow, rowSize);
}
}
// 3. ROI 提取(Region of Interest)
Span<byte> ExtractROI(byte[] image, int imageWidth, int channels,
int roiX, int roiY, int roiW, int roiH)
{
// 返回 ROI 的 Span(零拷贝)
int startIndex = roiY * imageWidth * channels + roiX * channels;
// 注意:ROI 跨越多行不能直接用单个 Slice
// 需要逐行处理或使用 2D Span
return image.AsSpan().Slice(startIndex, roiW * channels * roiH);
}
6.4.3 模型推理中的数组
// 模型输入预处理:Normalization
float[] Normalize(byte[] image, float mean, float std)
{
float[] normalized = new float[image.Length];
for (int i = 0; i < image.Length; i++)
normalized[i] = (image[i] / 255.0f - mean) / std;
return normalized;
}
// 模型输出处理:Softmax
float[] Softmax(float[] logits)
{
float[] result = new float[logits.Length];
float max = logits.Max();
float sum = 0;
for (int i = 0; i < logits.Length; i++)
{
result[i] = MathF.Exp(logits[i] - max);
sum += result[i];
}
for (int i = 0; i < result.Length; i++)
result[i] /= sum;
return result;
}
// NMS(非极大值抑制)中的数组操作
// 根据置信度排序边界框
void SortByConfidence(float[] boxes, float[] scores)
{
Array.Sort(scores, boxes); // 按分数排序,边界框跟随调整
}
七、排错、实战与复习
最后把错误排查、项目实践、面试问答和速查总结放在一起,形成完整闭环。
7.1 常见错误与调试
7.1.1 索引越界(IndexOutOfRangeException)
最最常见的数组错误,占数组相关 bug 的 70% 以上。
int[] arr = { 10, 20, 30 };
// 错误 1:访问 Length
arr[arr.Length] = 40; // arr.Length = 3,最大索引是 2
// 错误 2:循环条件用 <=
for (int i = 0; i <= arr.Length; i++) // 最后一次 i=3,越界!
Console.WriteLine(arr[i]);
// 错误 3:空数组
int[] empty = new int[0];
// var x = empty[0]; // 空数组没有元素
// 错误 4:反向循环
for (int i = arr.Length; i >= 1; i--) // 第一次 i=3,越界!
Console.WriteLine(arr[i]);
// 正确:for (int i = arr.Length - 1; i >= 0; i--)
// 错误 5:计算出的索引超出范围
int index = arr.Length * 2 - 5;
// arr[index] // 如果计算结果 >= Length
7.1.2 空引用(NullReferenceException)
int[] arr = null;
// arr.Length; // NullReferenceException
// arr[0]; // NullReferenceException
// foreach (int x in arr) // NullReferenceException
// 排查方法:找出为什么数组是 null
// - 是否忘记 new?
// - 是否从某个返回 null 的方法获取?
// - 是否在类字段中未初始化?
7.1.3 数组类型不匹配(ArrayTypeMismatchException)
string[] strings = { "Hello", "World" };
object[] objects = strings; // 数组协变,编译通过
objects[0] = 123; // ArrayTypeMismatchException
// 运行时发现:试图在 string[] 中放入 int
7.1.4 编译错误 CS0021、CS0022
// CS0021:对非数组类型使用了 []
int number = 42;
// number[0] = 10; // CS0021: 不能将 [] 索引应用于 int
// CS0022:索引数量错误
int[,] matrix = new int[3, 4];
// matrix[0] = 10; // CS0022: 需要 2 个索引
// matrix[0, 1, 2] = 10; // CS0022: 需要 2 个索引,提供了 3 个
7.1.5 隐式错误
// 错误:foreach 中修改元素(值类型)
int[] arr = { 1, 2, 3 };
foreach (int item in arr)
{
// item += 1; // 编译通过但无效(item 是副本)
Console.WriteLine(item); // 不会影响 arr
}
// 错误:硬编码长度
int[] data = GetData();
for (int i = 0; i < 5; i++) // 如果 data.Length < 5 则越界
Console.WriteLine(data[i]);
// 正确做法
for (int i = 0; i < data.Length; i++)
Console.WriteLine(data[i]);
// 错误:整数除法
int[] scores = { 85, 90, 92 };
int sum = scores.Sum();
double avg = sum / scores.Length; // 整数除法!结果被截断
double correctAvg = (double)sum / scores.Length; //浮点除法
7.1.6 调试技巧
// 1. 在 Watch 窗口中查看数组内容
// 在断点处,将 arr 添加到 Watch,可以看到所有元素
// 2. 条件断点
// 设置断点条件:i == 5 时中断
// 3. 验证循环边界
Debug.Assert(i >= 0 && i < arr.Length, "索引越界!");
// 4. 打印诊断信息
Console.WriteLine($"数组长度: {arr.Length}, 当前索引: {i}");
7.2 实战项目
7.2.1 项目一:学生成绩管理系统(完整版)
using System;
namespace Student_Grade
{
internal class Program
{
static void Main(string[] args)
{
const int MAX_STUDENTS = 100;
string[] names = new string[MAX_STUDENTS];
int[] scores = new int[MAX_STUDENTS];
int count = 0; // 当前学生数量
while (true)
{
Console.WriteLine("\n===== 学生成绩管理系统 =====");
Console.WriteLine("1. 添加学生");
Console.WriteLine("2. 显示所有学生");
Console.WriteLine("3. 查找学生");
Console.WriteLine("4. 统计信息");
Console.WriteLine("5. 排序显示");
Console.WriteLine("6. 删除学生");
Console.WriteLine("0. 退出");
Console.Write("请选择: ");
string choice = Console.ReadLine();
switch (choice)
{
case "1":
if (count >= MAX_STUDENTS)
{
Console.WriteLine("学生数量已满!");
break;
}
Console.Write("输入姓名: ");
names[count] = Console.ReadLine();
Console.Write("输入成绩: ");
scores[count] = int.Parse(Console.ReadLine());
count++;
Console.WriteLine("添加成功!");
break;
case "2":
Console.WriteLine("\n姓名\t成绩\t等级");
Console.WriteLine("--------------------");
for (int i = 0; i < count; i++)
{
string grade = scores[i] >= 90 ? "优秀" :
scores[i] >= 80 ? "良好" :
scores[i] >= 70 ? "中等" :
scores[i] >= 60 ? "及格" : "不及格";
Console.WriteLine($"{names[i]}\t{scores[i]}\t{grade}");
}
break;
case "3":
Console.Write("输入要查找的姓名: ");
string searchName = Console.ReadLine();
int foundIndex = -1;
for (int i = 0; i < count; i++)
{
if (names[i] == searchName)
{
foundIndex = i;
break;
}
}
if (foundIndex >= 0)
Console.WriteLine($"{names[foundIndex]} 的成绩是 {scores[foundIndex]}");
else
Console.WriteLine("未找到该学生");
break;
case "4":
if (count == 0)
{
Console.WriteLine("暂无学生数据");
break;
}
int sum = 0, max = scores[0], min = scores[0];
for (int i = 0; i < count; i++)
{
sum += scores[i];
if (scores[i] > max) max = scores[i];
if (scores[i] < min) min = scores[i];
}
double avg = (double)sum / count;
Console.WriteLine($"总人数: {count}");
Console.WriteLine($"平均分: {avg:F2}");
Console.WriteLine($"最高分: {max}");
Console.WriteLine($"最低分: {min}");
// 统计各等级人数
int[] gradeCount = new int[5]; // 优秀/良好/中等/及格/不及格
for (int i = 0; i < count; i++)
{
if (scores[i] >= 90) gradeCount[0]++;
else if (scores[i] >= 80) gradeCount[1]++;
else if (scores[i] >= 70) gradeCount[2]++;
else if (scores[i] >= 60) gradeCount[3]++;
else gradeCount[4]++;
}
Console.WriteLine($"优秀: {gradeCount[0]}, 良好: {gradeCount[1]}, 中等: {gradeCount[2]}, 及格: {gradeCount[3]}, 不及格: {gradeCount[4]}");
break; // 修复警告:补充break
case "5":
// 排序(同时排序姓名和成绩)
for (int i = 0; i < count - 1; i++)
{
for (int j = 0; j < count - 1 - i; j++)
{
if (scores[j] < scores[j + 1]) // 降序
{
// 交换成绩
int tempScore = scores[j];
scores[j] = scores[j + 1];
scores[j + 1] = tempScore;
// 交换姓名
string tempName = names[j];
names[j] = names[j + 1];
names[j + 1] = tempName;
}
}
}
Console.WriteLine("已按成绩降序排列!");
goto case "2"; // 跳转到显示
break;
case "6":
Console.Write("输入要删除的学生姓名: ");
string deleteName = Console.ReadLine();
int deleteIndex = -1;
for (int i = 0; i < count; i++)
{
if (names[i] == deleteName)
{
deleteIndex = i;
break;
}
}
if (deleteIndex >= 0)
{
// 后面的元素向前移动
for (int i = deleteIndex; i < count - 1; i++)
{
names[i] = names[i + 1];
scores[i] = scores[i + 1];
}
count--;
Console.WriteLine("删除成功!");
}
else
Console.WriteLine("未找到该学生");
break;
case "0":
Console.WriteLine("再见!");
return;
default:
Console.WriteLine("无效选项,请重试");
break;
}
}
}
}
}
7.2.2 项目二:数组元素移位(循环左移 K 位)
// 方法一:三次反转法(O(n) 时间,O(1) 空间)
void RotateLeft(int[] arr, int k)
{
int n = arr.Length;
k = k % n; // 处理 k > n 的情况
if (k == 0) return;
Reverse(arr, 0, k - 1); // 反转前半部分
Reverse(arr, k, n - 1); // 反转后半部分
Reverse(arr, 0, n - 1); // 整体反转
// 示例:arr = {1,2,3,4,5,6,7}, k=3
// 第一步:{3,2,1,4,5,6,7}
// 第二步:{3,2,1,7,6,5,4}
// 第三步:{4,5,6,7,1,2,3}
}
void Reverse(int[] arr, int start, int end)
{
while (start < end)
{
int temp = arr[start];
arr[start] = arr[end];
arr[end] = temp;
start++;
end--;
}
}
// 测试
int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
RotateLeft(arr, 3);
Console.WriteLine(string.Join(", ", arr));
// 输出: 4, 5, 6, 7, 8, 9, 1, 2, 3
7.3.3 项目三:稀疏矩阵压缩
// 将稀疏矩阵压缩为 (行, 列, 值) 三元组数组
(int row, int col, int value)[] CompressSparseMatrix(int[,] matrix)
{
int rows = matrix.GetLength(0);
int cols = matrix.GetLength(1);
// 先数非零元素个数
int nonZeroCount = 0;
for (int i = 0; i < rows; i++)
for (int j = 0; j < cols; j++)
if (matrix[i, j] != 0)
nonZeroCount++;
// 创建压缩数组
var compressed = new (int, int, int)[nonZeroCount];
int index = 0;
for (int i = 0; i < rows; i++)
for (int j = 0; j < cols; j++)
if (matrix[i, j] != 0)
compressed[index++] = (i, j, matrix[i, j]);
return compressed;
}
// 测试
int[,] sparse =
{
{ 1, 0, 0, 0 },
{ 0, 0, 2, 0 },
{ 0, 0, 0, 0 },
{ 0, 3, 0, 0 }
};
var compressed = CompressSparseMatrix(sparse);
foreach (var (row, col, value) in compressed)
Console.WriteLine($"({row}, {col}) = {value}");
// 输出:
// (0, 0) = 1
// (1, 2) = 2
// (3, 1) = 3
7.3 面试高频问题
Q1: 数组的索引为什么从 0 开始?
答:数组元素的内存地址 = 基地址 + 索引 × 元素大小。当索引为 0 时,地址 = 基地址,第一个元素恰好在起始位置。这是 C 语言设计者的选择,被后续语言继承。
Q2: Array.Resize 的原理是什么?
答:Array.Resize 不是原地扩容。它内部创建新数组,复制旧数据,然后通过 ref 参数把变量指向新数组。旧数组如果没有其他引用,会被 GC 回收。
Q3: 二维数组和交错数组有什么区别?
答:
-
二维数组
int[,]是一整块连续内存,所有行等长,访问用[i,j] -
交错数组
int[][]是"数组的数组",每行独立分配,行长度可不同,访问用[i][j] -
二维数组缓存友好度更高,交错数组灵活度更高
Q4: int[] 和 int[,] 分别继承自什么?
答:都继承自 System.Array。int[] 继承自 Array(单维数组有特殊的运行时支持),int[,] 也继承自 Array。Array 又继承自 Object。
Q5: 值类型数组和引用类型数组的内存区别?
答:
-
int[]:数组对象中直接存储 4 字节整数值 -
string[]:数组对象中存储的是 8 字节引用(指向堆上的 string 对象) -
值类型数组的元素在数组内存块内,引用类型数组的元素是一个个指针
Q6: 如何高效地复制数组?
答:
-
Array.Copy— 最常用的快速复制 -
Buffer.BlockCopy— 字节级复制,最快(但需要手动计算字节数) -
Array.CopyTo— 实例方法 -
Clone()— 返回 object,需要转型 -
LINQ
ToArray()— 有 LINQ 开销
Q7: 什么是数组协变?有什么风险?
答:数组协变允许 string[] 赋值给 object[]。风险是向协变数组中写入不兼容类型时,编译通过但运行时抛 ArrayTypeMismatchException。
Q8: foreach 遍历数组时能修改元素吗?
答:
-
对于值类型数组:不能。迭代变量是副本,修改不影响原数组
-
对于引用类型数组:可以修改对象的内容(但不能替换引用本身)
-
要修改数组元素,使用
for循环
7.4 速查与复习清单
7.4.1 一维数组
// 声明
int[] arr;
// 创建
arr = new int[5]; // 5 个元素,默认值为 0
arr = new int[] { 1, 2, 3 }; // 显式初始化
int[] arr2 = { 1, 2, 3 }; // 简写(仅声明时)
var arr3 = new[] { 1, 2, 3 }; // 类型推断
// 访问
int first = arr[0]; // 第一个元素
int last = arr[arr.Length - 1]; // 最后一个元素
int fromEnd = arr[^1]; // C# 8.0:倒数第一个
// 属性
int len = arr.Length; // 长度
long longLen = arr.LongLength; // 长度(long)
int rank = arr.Rank; // 维数(一维数组 = 1)
// 常用操作
Array.Sort(arr); // 排序
Array.Reverse(arr); // 反转
int idx = Array.IndexOf(arr, 5); // 查找索引
Array.Clear(arr, 0, arr.Length); // 清零
Array.Copy(src, dst, len); // 复制
Array.Resize(ref arr, 10); // 调整大小
int[] found = Array.FindAll(arr, x => x > 5); // 条件查找
Array.Fill(arr, 42); // 填充(.NET Core 2.0+)
// 切片(C# 8.0+)
int[] slice = arr[1..4]; // 索引 1-3,创建新数组
int[] slice2 = arr[..3]; // 开头到索引 2
int[] slice3 = arr[3..]; // 索引 3 到末尾
Span<int> span = arr.AsSpan()[1..4]; // 零拷贝切片
// 遍历
for (int i = 0; i < arr.Length; i++) // 可读可写
foreach (int x in arr) // 只读
Array.ForEach(arr, x => Console.WriteLine(x)); // Lambda 风格
7.4.2 二维数组
// 声明与创建
int[,] matrix = new int[3, 4]; // 3 行 4 列
int[,] matrix2 = { { 1, 2 }, { 3, 4 } }; // 初始化
// 访问
matrix[1, 2] = 99; // 第 1 行第 2 列
int rows = matrix.GetLength(0); // 3(行数)
int cols = matrix.GetLength(1); // 4(列数)
int total = matrix.Length; // 12(总元素数)
// 遍历
for (int i = 0; i < matrix.GetLength(0); i++)
for (int j = 0; j < matrix.GetLength(1); j++)
Console.WriteLine(matrix[i, j]);
7.4.3 交错数组
// 声明与创建
int[][] jagged = new int[3][]; // 3 行,每行未初始化
jagged[0] = new int[] { 1, 2, 3 }; // 第 0 行
jagged[1] = new int[] { 4, 5 }; // 第 1 行
jagged[2] = new int[] { 6, 7, 8, 9 }; // 第 2 行
// 一行初始化
int[][] jagged2 = {
new int[] { 1, 2, 3 },
new int[] { 4, 5 },
new int[] { 6, 7, 8, 9 }
};
// 访问
int value = jagged[1][2]; // 第 1 行第 2 列
int rowCount = jagged.Length; // 3(行数)
int row0Len = jagged[0].Length; // 3(第 0 行长度)
// 遍历
for (int i = 0; i < jagged.Length; i++)
for (int j = 0; j < jagged[i].Length; j++)
Console.WriteLine(jagged[i][j]);
本章复习检查清单
完成本章学习后,你应该能够:
- 解释数组在内存中的布局(对象头 + 长度 + 元素)
- 写出所有数组声明和初始化的语法形式
- 说出各类型数组元素的默认值
- 解释索引为什么从 0 开始
- 使用 for、foreach、Array.ForEach、Span 遍历数组
- 实现求和、平均值、最大/最小值、线性查找
- 使用 Array 类的 Sort、Reverse、IndexOf、BinarySearch、Copy、Resize
- 说出 Array.Resize 的内部原理和 ref 参数的必要性
- 区分 null 数组和空数组
- 创建和遍历二维数组
- 创建和遍历交错数组
- 根据场景选择二维数组还是交错数组
- 理解数组协变及其风险
- 使用 Index 和 Range 语法(C# 8.0+)
- 使用 Span<T> 进行零拷贝切片操作
- 知道什么时候用数组、什么时候用 List<T>
- 理解缓存局部性对数组性能的影响
- 能写出手动排序算法(冒泡、选择、插入、快速)
- 能写出二分查找的实现
- 把数组知识关联到图像处理和模型推理
- 排查数组相关的常见错误(IndexOutOfRange、NullReference)
- 回答数组相关的面试高频问题
- 不使用 IDE 自动补全手写出数组的声明、创建、初始化和遍历代码
本文分享到此结束,欢迎各位博友点出错误,友好交流。
更多推荐
所有评论(0)