C# 一维数组的定义和基础使用:一排整齐的「储物柜」
引言:从一排储物柜说起
想象一下超市入口的储物柜:一排柜子,每个柜子有一个编号(01、02、03……),每个柜子里可以放一件物品。你想取东西,只要报出编号,就能精准找到对应的柜子。
C# 中的**数组(Array)**就是这样一排「储物柜」:
- 它是一组相同类型数据的集合,就像一整排柜子只能放同一类物品;
- 每个位置有一个编号(称为「索引」或「下标」),通过编号就能快速存取数据;
- 柜子的数量在建造时就固定了,一旦创建,不能随意加柜子或拆柜子。
如果你没有数组,要存储 5 个学生的成绩,就得写 score1、score2、score3、score4、score5 五个变量;要是有 500 个学生呢?数组就是为解决这类「批量存储同类型数据」的问题而生的。
一、数组的本质:什么是数组
1.1 官方定义
数组:一种用于存储固定数量、相同数据类型元素的数据结构。所有元素在内存中连续存放,通过从 0 开始的索引进行访问。
1.2 三个关键特性
| 特性 | 通俗解释 |
|---|---|
| 类型统一 | 一个数组只能装一种类型的数据。int 数组只能装整数,不能混进字符串 |
| 长度固定 | 创建时指定能装多少个元素,之后不能改变(想扩容只能新建一个更大的数组再搬过去) |
| 索引从 0 开始 | 第 1 个元素的索引是 0,第 2 个是 1……最后一个元素的索引是「长度 - 1」 |
为什么索引从 0 开始?可以把索引理解为「距离起点的偏移量」:第一个元素就在起点,偏移 0 步;第二个元素偏移 1 步。这是计算机底层内存寻址方式决定的。
1.3 什么是一维数组
一维数组就是「一排」储物柜——只有一行,用一个索引就能定位元素。后面章节还会学到二维数组(像 Excel 表格,需要行号和列号两个索引)和交错数组,本文只聚焦一维数组。
二、一维数组的三种定义方式
2.1 动态初始化:先建柜子,后放东西
using System;
class Program
{
static void Main()
{
// 语法:数据类型[] 数组名 = new 数据类型[长度];
int[] scores = new int[5]; // 创建一个能装 5 个整数的数组
// 此时 5 个「柜子」里都是 int 的默认值 0
Console.WriteLine(scores[0]); // 输出:0
Console.WriteLine(scores[4]); // 输出:0
// 通过索引逐个赋值
scores[0] = 85; // 第 1 个柜子放 85
scores[1] = 92; // 第 2 个柜子放 92
scores[2] = 78;
scores[3] = 60;
scores[4] = 95; // 第 5 个柜子(最后一个)放 95
Console.WriteLine(scores[1]); // 输出:92
}
}逐行拆解:
int[] scores = new int[5];——int[]表示「整数数组类型」,new int[5]表示在内存中开辟 5 个连续的整数空间。由于没赋值,每个位置自动填充int的默认值0。Console.WriteLine(scores[0]);—— 访问索引 0 的元素,此时是默认值,输出0。scores[0] = 85;—— 把 85 写入索引 0 的位置,覆盖原来的 0。- 最后输出
scores[1],得到92。
知识点:不同类型的默认值不同——数值类型默认
0,bool默认false,string等引用类型默认null(空,表示什么都没装)。
2.2 静态初始化:建柜子的同时放好东西
using System;
class Program
{
static void Main()
{
// 完整写法:声明的同时用大括号给出所有元素
int[] scores = new int[] { 85, 92, 78, 60, 95 };
// 简写形式(推荐):省略 new int[],编译器能推断出来
string[] names = { "张三", "李四", "王五" };
Console.WriteLine(scores.Length); // 输出:5(数组长度自动由元素个数决定)
Console.WriteLine(names[2]); // 输出:王五
}
}逐行拆解:
int[] scores = new int[] { 85, 92, 78, 60, 95 };—— 大括号里写了 5 个数,数组长度自动确定为 5,并依次填入各位置。string[] names = { "张三", "李四", "王五" };—— 简写形式,效果完全相同,代码更清爽。注意:简写只能在声明的同一行使用,先声明后赋值时不能省略new string[]。scores.Length——Length属性返回数组长度,这里输出5。names[2]—— 索引 2 是第 3 个元素,输出王五。
2.3 隐式类型初始化:让编译器猜类型
using System;
class Program
{
static void Main()
{
// 用 var 关键字,编译器根据大括号里的元素自动推断数组类型
var scores = new[] { 85, 92, 78 }; // 推断为 int[]
var prices = new[] { 9.9, 19.5, 3.0 }; // 推断为 double[]
var fruits = new[] { "苹果", "香蕉" }; // 推断为 string[]
Console.WriteLine(scores.GetType()); // 输出:System.Int32[]
Console.WriteLine(prices.GetType()); // 输出:System.Double[]
Console.WriteLine(fruits[0]); // 输出:苹果
}
}逐行拆解:
var scores = new[] { 85, 92, 78 };——var告诉编译器「你自己看着办」。编译器看到大括号里全是整数,就把数组推断为int[]。prices里是带小数的数,推断为double[]。GetType()返回变量的真实类型,打印出来可以验证推断结果。
注意:隐式初始化要求大括号里的元素类型能统一。
new[] { 1, "abc" }会编译报错,因为整数和字符串无法「凑成一家」。
2.4 三种方式怎么选
| 场景 | 推荐方式 |
|---|---|
| 事先不知道具体值,只知道数量(如「接收用户输入的 10 个数」) | 动态初始化 new int[10] |
| 写代码时就知道所有元素 | 静态初始化简写 { ... } |
| 局部变量、想让代码更简洁 | 隐式初始化 var x = new[] { ... } |
三、数组长度:Length 属性
using System;
class Program
{
static void Main()
{
int[] scores = { 85, 92, 78, 60, 95 };
// Length 是数组自带的属性,返回元素总个数
Console.WriteLine($"数组长度:{scores.Length}"); // 输出:数组长度:5
Console.WriteLine($"最后一个元素的索引:{scores.Length - 1}"); // 输出:4
Console.WriteLine($"最后一个元素的值:{scores[scores.Length - 1]}"); // 输出:95
}
}拆解说明:
Length读作「长度」,表示数组能装几个元素。它是属性不是方法,所以后面不加括号,写scores.Length而不是scores.Length()。- 由于索引从 0 开始,最后一个元素的索引永远是
Length - 1。这个公式在做数组遍历、取末尾元素时会反复用到,务必记牢。
四、索引的工作原理与边界问题
4.1 索引是如何定位元素的
数组在内存中是一块连续的空间。假设一个 int 占 4 个字节,数组起始地址是 1000,那么:
索引: 0 1 2 3
地址: 1000 1004 1008 1012
元素: [85] [92] [78] [60]要访问 scores[2],计算机直接用公式「起始地址 + 索引 × 单个元素大小」算出地址 1000 + 2×4 = 1008,一步直达。这就是数组随机访问速度极快的原因——不管数组有 5 个还是 500 万个元素,通过索引取任何一个元素所花的时间几乎一样。
4.2 越界:最常见的初学者错误
using System;
class Program
{
static void Main()
{
int[] scores = { 85, 92, 78 }; // 长度为 3,合法索引是 0、1、2
Console.WriteLine(scores[2]); // 正确:输出 78
// 错误示范:索引 3 不存在!
Console.WriteLine(scores[3]); // 运行时抛出 IndexOutOfRangeException(索引越界异常)
}
}运行结果:程序打印 78 后直接崩溃,报错 System.IndexOutOfRangeException: Index was outside the bounds of the array.(索引超出了数组的边界)。
为什么会错? 数组只有 3 个柜子(编号 0、1、2),你却要打开编号 3 的柜子——那里根本没有柜子。C# 会在运行时检查索引合法性,一旦越界立刻抛出异常,防止你读到别人的内存数据。
如何避免? 访问前确认索引在 0 到 Length - 1 之间:
int index = 3;
if (index >= 0 && index < scores.Length) // 先做边界检查
{
Console.WriteLine(scores[index]);
}
else
{
Console.WriteLine("索引越界了!");
}经典易错写法:
scores[scores.Length]—— 长度是 3 时,合法最大索引是 2,写scores[3]正好越界。记住:合法索引范围是0 ~ Length - 1,不含Length。
五、元素的访问与修改
using System;
class Program
{
static void Main()
{
string[] weekDays = { "周一", "周二", "周三", "周四", "周五" };
// 读取:通过索引取出元素,可以参与任何运算和赋值
string today = weekDays[0];
Console.WriteLine($"今天是 {today}"); // 输出:今天是 周一
// 修改:通过索引直接赋值,覆盖旧值
weekDays[4] = "周五(happy)";
Console.WriteLine(weekDays[4]); // 输出:周五(happy)
// 元素参与运算(以 int 数组为例)
int[] nums = { 10, 20, 30 };
nums[1] = nums[1] + 5; // 取出 20,加 5,再写回索引 1
Console.WriteLine(nums[1]); // 输出:25
}
}拆解说明:
weekDays[0]读取时,相当于把柜子里的东西复制一份拿出来用,柜子里的原值不变。weekDays[4] = "..."是写入操作,旧值被新值覆盖,不可恢复。nums[1] = nums[1] + 5;体现了「先读后写」:等号右边先从数组取出 20,算出 25,再写回原位置。
六、数组的遍历:for 与 foreach
「遍历」就是把数组里的每个元素挨个访问一遍。C# 提供了两种主流方式。
6.1 for 循环:带索引的全能选手
using System;
class Program
{
static void Main()
{
int[] scores = { 85, 92, 78, 60, 95 };
// 用 for 循环遍历:索引 i 从 0 走到 Length - 1
for (int i = 0; i < scores.Length; i++)
{
Console.WriteLine($"第 {i + 1} 位同学的成绩:{scores[i]}");
}
// for 循环还能修改元素:给每人加 5 分
for (int i = 0; i < scores.Length; i++)
{
scores[i] += 5;
}
Console.WriteLine($"加分后第一位同学:{scores[0]}"); // 输出:90
}
}逐行拆解:
int i = 0—— 索引从 0 开始;i < scores.Length—— 注意是小于而不是小于等于!当i等于Length时已经越界,循环必须在这之前停止;i++—— 每轮结束后索引加 1,走向下一个元素;- 循环体里
scores[i]依次代表每个元素,i + 1把「从 0 开始的索引」换算成「从 1 开始的序号」,更符合人类阅读习惯。
输出:
第 1 位同学的成绩:85
第 2 位同学的成绩:92
第 3 位同学的成绩:78
第 4 位同学的成绩:60
第 5 位同学的成绩:95
加分后第一位同学:906.2 foreach 循环:只管读,不管索引
using System;
class Program
{
static void Main()
{
int[] scores = { 85, 92, 78, 60, 95 };
// foreach:依次把每个元素「倒」进变量 score,无需关心索引
foreach (int score in scores)
{
Console.WriteLine($"成绩:{score}");
}
// 注意:foreach 里不能通过迭代变量修改原数组
foreach (int score in scores)
{
// score += 5; // 编译错误!score 是只读的
}
}
}拆解说明:
foreach (int score in scores)读作「对 scores 里的每一个 int,叫它 score,执行一遍循环体」。编译器自动帮你处理索引和边界,永远不会越界。- 循环变量
score是每个元素的副本,而且是只读的,不能给它赋值。想修改数组元素,必须用 for 循环配合索引。
6.3 两者对比与适用场景
| 对比项 | for | foreach |
|---|---|---|
| 能否拿到索引 | 能 | 不能(除非自己额外计数) |
| 能否修改元素 | 能 | 不能 |
| 越界风险 | 有(条件写错就崩) | 无(编译器保证) |
| 代码简洁度 | 略繁琐 | 更简洁 |
| 适用场景 | 需要索引、需要修改、需要跳过/逆序访问 | 只读遍历全部元素 |
一句话记忆:只看不改选 foreach,要改要索引选 for。
七、数组的常用基础方法
数组创建后自带一些实用方法(来自 System.Array 基类),这里介绍三个最基础的。
7.1 GetValue / SetValue:用方法替代下标
using System;
class Program
{
static void Main()
{
int[] scores = { 85, 92, 78 };
// GetValue(索引):读取指定位置的元素,返回 object 类型
object value = scores.GetValue(1);
Console.WriteLine(value); // 输出:92
// SetValue(值, 索引):向指定位置写入元素
scores.SetValue(100, 1); // 把索引 1 改成 100
Console.WriteLine(scores[1]); // 输出:100
}
}拆解说明:
GetValue(1)等价于scores[1],区别在于它返回的是object类型(所有类型的「老祖宗」,什么都能装),如果需要参与 int 运算,要做一次类型转换:(int)scores.GetValue(1)。SetValue(100, 1)等价于scores[1] = 100。- 日常开发中直接用下标
[]更简洁高效;GetValue/SetValue主要用于不知道数组具体类型的场景(如反射、通用工具方法)。
7.2 Clone:复制一个一模一样的数组
using System;
class Program
{
static void Main()
{
int[] original = { 1, 2, 3 };
// Clone() 创建一个新数组,内容与原数组完全相同
int[] copy = (int[])original.Clone(); // 返回值是 object,需要强制转换回 int[]
copy[0] = 999; // 修改副本
Console.WriteLine($"原数组[0]:{original[0]}"); // 输出:1(不受影响)
Console.WriteLine($"副本[0]:{copy[0]}"); // 输出:999
// 对比:直接赋值 ≠ 复制!
int[] alias = original; // 只是起了个「别名」,指向同一个数组
alias[1] = 888;
Console.WriteLine($"原数组[1]:{original[1]}"); // 输出:2 → 变成 888 了!
}
}拆解说明:
original.Clone()在内存中新建一个数组,把每个元素拷贝过去。修改副本不影响原数组。- 最后三行展示了关键陷阱:
int[] alias = original;不是复制,只是让alias和original指向同一个数组(就像一个人有两个名字)。通过alias修改,original也会跟着变。 - 这背后的原因正是下一节要讲的:数组是引用类型。
八、初学者易错点深度解析
8.1 易错点一:数组长度固定,不能「加塞」
错误认知:以为数组像购物清单一样可以随时添加元素。
int[] scores = new int[3];
scores[0] = 85;
scores[1] = 92;
scores[2] = 78;
// scores[3] = 100; // 运行时异常!数组只有 3 个位置,没有第 4 个正确做法:需要更大容量时,新建一个大数组,把旧数据搬过去(Array.Resize 或 CopyTo 可以简化搬运,后续章节详解):
int[] scores = new int[3] { 85, 92, 78 };
// 扩容到 5:新建大数组,拷贝旧数据
int[] bigger = new int[5];
for (int i = 0; i < scores.Length; i++)
{
bigger[i] = scores[i];
}
bigger[3] = 100; // 现在可以放了实际开发中如果需要频繁增删元素,通常会用
List<T>(动态列表)而不是数组,那是后续章节的内容。
8.2 易错点二:数组是引用类型
C# 中的类型分两类:
- 值类型(
int、double、bool等):变量本身就装着数据,赋值时会复制一份; - 引用类型(数组、字符串、类等):变量里装的是数据的「地址」(门牌号),赋值时只复制门牌号,两个变量指向同一份数据。
using System;
class Program
{
static void Main()
{
// 值类型:各管各的
int a = 10;
int b = a; // b 复制了 a 的值
b = 20;
Console.WriteLine(a); // 输出:10(a 不受影响)
// 引用类型(数组):动一个,全都变
int[] arr1 = { 1, 2, 3 };
int[] arr2 = arr1; // arr2 拿到的是 arr1 的「门牌号」,指向同一排柜子
arr2[0] = 999;
Console.WriteLine(arr1[0]); // 输出:999(arr1 也变了!)
}
}内存图示:
值类型: 引用类型:
a → [10] arr1 ──→ [1][2][3] ←── arr2
b → [20](独立) (两个变量,同一排柜子)避坑指南:想得到一个独立的数组副本,用 Clone();不要用 = 直接赋值,那只是起了个别名。
8.3 易错点三:值类型数组与引用类型数组的默认值差异
using System;
class Program
{
static void Main()
{
// 值类型数组:每个位置自动填默认值 0
int[] numbers = new int[3];
Console.WriteLine(numbers[0]); // 输出:0(可以直接使用)
// 引用类型数组:每个位置是 null(空),直接使用会崩溃!
string[] names = new string[3];
Console.WriteLine(names[0] == null); // 输出:True
// 错误示范:对 null 调用方法
// Console.WriteLine(names[0].Length); // 抛出 NullReferenceException(空引用异常)
// 正确做法:先赋值,再使用
names[0] = "张三";
Console.WriteLine(names[0].Length); // 输出:2
}
}拆解说明:
int[]里的每个元素是值类型,默认就有合法的值0,拿来即用。string[]里的每个元素是引用类型,默认是null——表示「这个柜子是空的,连门牌号都没有」。对null做任何操作(如取.Length)都会抛出空引用异常,这是 C# 程序中最常见的崩溃原因之一。- 安全习惯:使用引用类型数组的元素前,先判断
if (names[i] != null)。
8.4 易错点速查表
| 错误写法 | 后果 | 正确做法 |
|---|---|---|
arr[arr.Length] | 索引越界异常 | 最大索引是 arr.Length - 1 |
int[] b = a; b[0]=9; 以为互不影响 | 改了 b,a 也变了 | 用 a.Clone() 复制 |
直接用 new string[5] 里的元素 | 空引用异常 | 先逐个赋值,或判空 |
声明后分开用简写初始化 arr = {1,2}; | 编译错误 | 写成 arr = new int[]{1,2}; |
九、实战案例
9.1 案例一:班级成绩统计
需求:统计 6 名学生的成绩,输出总分、平均分、最高分和及格人数。
using System;
class Program
{
static void Main()
{
int[] scores = { 85, 92, 78, 60, 95, 54 };
int sum = 0; // 累加器:存总分
int max = scores[0]; // 先假设第一个人是最高分
int passCount = 0; // 及格人数计数器
for (int i = 0; i < scores.Length; i++)
{
sum += scores[i]; // 把当前成绩累加进总分
if (scores[i] > max) // 发现更高的成绩就更新 max
{
max = scores[i];
}
if (scores[i] >= 60) // 60 分及以上算及格
{
passCount++;
}
}
double average = (double)sum / scores.Length; // 强转 double 避免整数除法截断
Console.WriteLine($"总分:{sum}"); // 输出:总分:464
Console.WriteLine($"平均分:{average:F1}"); // 输出:平均分:77.3(F1 保留 1 位小数)
Console.WriteLine($"最高分:{max}"); // 输出:最高分:95
Console.WriteLine($"及格人数:{passCount}"); // 输出:及格人数:5
}
}核心思路拆解:
- 累加器模式:
sum从 0 开始,每轮循环把当前元素加进来,循环结束即得总和——这是数组统计的万能套路。 - 打擂台模式:
max先假设为第一个元素,之后每遇到更大的就「换擂主」,循环结束剩下的就是最大值。 - 计数器模式:满足条件就
passCount++,循环结束即得个数。 - 整数除法陷阱:
sum / scores.Length如果两边都是int,结果会被截断成整数(464/6 = 77)。把其中一个转成double才能得到小数。
9.2 案例二:数据筛选——找出所有不及格成绩
需求:从成绩数组中筛选出不及格的成绩,存入一个新数组并输出。
using System;
class Program
{
static void Main()
{
int[] scores = { 85, 42, 78, 59, 95, 38, 60 };
// 第一步:先数一遍有多少个不及格的,确定新数组的长度
int failCount = 0;
foreach (int score in scores)
{
if (score < 60)
{
failCount++;
}
}
// 第二步:按数量创建新数组,再遍历一遍把不及格的装进去
int[] failScores = new int[failCount];
int index = 0; // 新数组的写入位置
foreach (int score in scores)
{
if (score < 60)
{
failScores[index] = score;
index++; // 写入位置后移
}
}
// 第三步:输出筛选结果
Console.WriteLine($"不及格成绩共 {failScores.Length} 个:");
foreach (int score in failScores)
{
Console.WriteLine(score);
}
}
}输出:
不及格成绩共 3 个:
42
59
38核心思路拆解:
由于数组长度固定,筛选类问题通常采用「两遍遍历」策略:第一遍数数(确定新数组长度),第二遍装数据。index 变量专门记录新数组的写入进度,每装入一个就往后挪一格。
这个案例还体现了 for 与 foreach 的配合:不关心位置时用 foreach 更清爽。
9.3 案例三:数组反转
需求:把 {1, 2, 3, 4, 5} 反转成 {5, 4, 3, 2, 1},不许借助新数组。
using System;
class Program
{
static void Main()
{
int[] nums = { 1, 2, 3, 4, 5 };
// 双指针法:left 从头部向右走,right 从尾部向左走,边走边交换
int left = 0;
int right = nums.Length - 1;
while (left < right)
{
// 经典三行交换:借助临时变量
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++; // 左指针右移
right--; // 右指针左移
}
foreach (int n in nums)
{
Console.Write(n + " "); // 输出:5 4 3 2 1
}
}
}核心思路拆解:
- 两个「指针」(其实就是索引变量)分别指向首尾;
- 每轮交换首尾元素,然后两个指针各向中间靠一步;
- 当
left >= right时说明所有位置都交换过了,循环结束。 - 交换两个变量的经典套路:
temp = a; a = b; b = temp;——必须借助第三个变量,直接a = b; b = a;会丢失数据。
十、知识点总结
- 数组的本质:固定长度、类型统一、内存连续的元素集合,通过从 0 开始的索引访问,访问速度极快。
- 三种定义方式:动态初始化
new int[5](只定长度)、静态初始化{1, 2, 3}(直接给值)、隐式初始化var arr = new[] {...}(编译器推断类型)。 - Length 属性:获取元素个数,最后一个元素的索引是
Length - 1。 - 索引边界:合法范围
0 ~ Length - 1,越界会抛出IndexOutOfRangeException。 - 两种遍历:只读用
foreach(安全简洁),需要索引或修改元素用for。 - 基础方法:
GetValue/SetValue用方法读写元素;Clone()创建独立副本。 - 引用类型特性:数组赋值只复制「门牌号」,两个变量会互相影响;想要独立副本必须
Clone()。 - 默认值陷阱:值类型数组默认是 0 可直接用;引用类型数组默认是
null,使用前必须赋值或判空。
十一、入门练习题
- 基础定义:创建一个长度为 7 的
string数组,存入一周七天的名称,输出星期三(注意索引从 0 开始)。 - 求和练习:定义数组
{12, 45, 7, 89, 23, 56},用 for 循环计算所有元素之和,用 foreach 循环计算所有偶数之和。 - 查找练习:在数组
{34, 67, 12, 90, 45}中找出最小值及其所在的索引。 - 计数练习:统计数组
{85, 42, 91, 38, 76, 55, 60}中大于等于 60 分的元素个数,并计算它们的平均分。 - 反转练习:把字符数组
{'H', 'e', 'l', 'l', 'o'}原地反转后输出。 - 思考题:下面这段代码输出什么?为什么?
int[] a = { 1, 2, 3 };
int[] b = a;
b[0] = 100;
Console.WriteLine(a[0]);点击查看第 6 题答案
输出 100。因为数组是引用类型,b = a 只复制了地址,两个变量指向同一个数组,通过 b 修改元素,a 看到的内容也会变。想要互不影响,应该使用 int[] b = (int[])a.Clone();。
掌握了数组这个「储物柜」,下一篇我们将学习数组更丰富的遍历技巧和更多实用方法(排序、查找、复制等),让你的数据处理能力更上一层楼。