一、数组基本认知

1.1为什么要学习数组

数组不是“一个能放多个值的变量”这么简单。在C#里,数组是:

  1. 所有集合类型的底层基石List<T> 内部就是一个动态扩容的数组。Dictionary<TKey,TValue> 的buckets也是数组。Stack<T>Queue<T> 全部基于数组。

  2. 图像数据的直接表达:一张1920×1080的灰度图在内存里就是一个byte[1920*1080]。彩色图是 byte[height, width, 3]。你未来处理的每一帧图像都是数组。

  3. 模型输入输出的载体:ONNX Runtime、ML.NET、TensorFlow.NET 的输入张量底层都是数组或 Span。

  4. 高性能代码的核心:数组在内存中连续分布,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.i4 IL 指令,将值写入数组的对应偏移位置

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 设计原理

数组的长度在创建时就已经写入对象头,之后无法改变。这是出于:

  1. 内存连续性:数组是一块连续内存,扩容意味着后面的内存可能已被其他对象占用

  2. 性能:固定长度让索引计算变成最简单的指针偏移

  3. 安全:长度固定的数据结构更容易进行边界检查和优化

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。实际上:

  1. 小对象:通过 JIT 编译的 initblk IL 指令,CPU 直接批量清零

  2. 大对象(>= 85000 字节,约 21250 个 int):分配在大对象堆(LOH),由 FCall(内部 C++ 实现)快速清零

  3. 对于引用类型数组(如 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

二分查找的注意点

  1. 必须先排序

  2. 中值计算用 left + (right - left) / 2 而非 (left + right) / 2,防止整数溢出

  3. 循环条件用 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 安全建议

  1. 避免利用数组协变:除非你很清楚在做什么

  2. 使用泛型集合替代List<T> 不协变,是类型安全的

  3. 如果实在要用,只读不写:只从协变数组中读取元素,绝不写入

六、现代特性与性能优化

聚合现代 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.Arrayint[] 继承自 Array(单维数组有特殊的运行时支持),int[,] 也继承自 ArrayArray 又继承自 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 自动补全手写出数组的声明、创建、初始化和遍历代码

本文分享到此结束,欢迎各位博友点出错误,友好交流。

更多推荐