C# 基础

C# 学习系列: C# 入门 → C# 基础 → C# 进阶 → C# 高级

接续《C#入门》,学习枚举与数组、值类型与引用类型、函数、结构体和十种排序,最后完成控制台小游戏《飞行棋》。

配套项目使用 .NET 10 SDK,语言版本固定为 C# 12,不依赖第三方包。内容以已经掌握变量、类型转换、运算符、分支、循环和异常捕获为前提。

阅读与运行约定

  • “复杂数据类型”是本教程的教学分类,不是 C# 规范中的独立类型类别;枚举实际上属于值类型,数组属于引用类型。
  • 日常所说的“函数”,在本文的类或结构体内主要指“方法”。“项目事件”按“项目实践”组织。
  • 需求中的“飞行器”结合“骰子掷出 6 后出击”理解为简化飞行棋。本版为玩家与电脑各一架飞机,不包含完整商业飞行棋的四机、跳格、连掷等全部规则。
  • 标为“Main 内”的短片段放在 Main 方法中;标为“类型声明”的代码放在方法外;方法声明放在 Program 类内、Main 外。各示例默认独立运行,不要把重复变量声明全部放在同一作用域。
  • 第五章的排序方法放入同一个 SortingAlgorithms 类。源码包已整理好文件位置,可直接编译运行。
  • 文中的图使用 Mermaid。部分 Markdown 阅读器不能渲染 Mermaid 时,仍可根据配套的说明和表格阅读。

目录

  1. 第一章:复杂数据类型
  2. 第二章:值类型和引用类型
  3. 第三章:函数
  4. 第四章:结构体
  5. 第五章:排序——十种方法
  6. 第六章:项目实践——飞行棋
  7. 附录 A:练习与参考答案
  8. 附录 B:运行、排查与验证
  9. 附录 C:官方参考资料

配套项目如何运行

先安装 .NET SDK,使用 dotnet --version 确认。解压源码包,打开其根目录,在终端执行:

1
2
3
4
5
6
7
8
9
10
11
12
# 运行第一至五章的示例。
dotnet run --project BasicExamples -- all

# 也可只运行某一部分。
dotnet run --project BasicExamples -- arrays
dotnet run --project BasicExamples -- types
dotnet run --project BasicExamples -- functions
dotnet run --project BasicExamples -- structs
dotnet run --project BasicExamples -- sorts

# 运行第六章游戏。
dotnet run --project FlightChess

游戏需要交互终端,窗口至少 73 列 × 33 行。普通章节示例不需要这么大的窗口。

第一章:复杂数据类型

1.1 枚举:为一组固定选项命名

假设场景编号为 0、1、2,直接写数字容易忘记含义。可以用枚举给它们起名字。

类型声明,放在 Main 外:

1
2
3
4
5
6
internal enum GameScene
{
Start,
Playing,
End
}

Main 内:

1
2
3
4
5
6
7
8
GameScene scene = GameScene.Start;
scene = GameScene.Playing;

if (scene == GameScene.Playing)
{
Console.WriteLine("正在游戏中。");
}
Console.WriteLine((int)scene); // 1

默认情况下,枚举以 int 作为底层类型,第一个成员为 0,后续依次加一。也可以显式指定:

1
2
3
4
5
6
internal enum Difficulty : byte
{
Easy = 1,
Normal = 2,
Hard = 10
}

底层类型必须是允许的整数类型,不是 float、string 或 bool。枚举成员是命名常量,不是字符串变量。枚举官方说明

1.2 枚举转换与输入校验

1
2
3
4
5
6
7
8
GameScene scene = GameScene.End;
int number = (int)scene;
string text = scene.ToString();
Console.WriteLine(number); // 2
Console.WriteLine(text); // End

GameScene unknown = (GameScene)99; // 能编译,不代表定义过名为 99 的成员。
Console.WriteLine(Enum.IsDefined(typeof(GameScene), unknown)); // False

枚举的默认值为 (枚举类型)0,即使枚举没有定义数值为零的成员也是如此。设计普通状态枚举时,通常给零设置一个合理的成员,例如 None 或 Start。

读取文字时可以使用 Enum.TryParse,但还应检查是否为已定义的普通枚举成员:

1
2
3
4
5
6
7
8
9
10
11
12
Console.Write("请输入 Start、Playing 或 End:");
string? input = Console.ReadLine();

if (Enum.TryParse(input, ignoreCase: true, out GameScene scene) &&
Enum.IsDefined(typeof(GameScene), scene))
{
Console.WriteLine($"选择了:{scene}");
}
else
{
Console.WriteLine("无效场景。");
}

TryParse 也可能接受数字文本,例如 "99",所以“解析成功”不等于“这个值在普通枚举中已定义”。

1.3 标志枚举:可以组合的状态

普通场景通常只选择一个;装备则可能同时拥有多项。可以结合上一篇的位运算:

1
2
3
4
5
6
7
8
[Flags]
internal enum Equipment
{
None = 0,
Sword = 1 << 0,
Shield = 1 << 1,
Key = 1 << 2
}
1
2
3
4
5
6
Equipment equipment = Equipment.Sword | Equipment.Key;
bool hasKey = (equipment & Equipment.Key) != Equipment.None;
equipment &= ~Equipment.Key;

Console.WriteLine(hasKey); // True
Console.WriteLine(equipment); // Sword

独立标志通常使用不同的二进制位,如 1、2、4、8。[Flags] 表达组合意图并影响格式化表现,不会自动帮你分配这些位。

Enum.IsDefined 不适合单独用来判断所有合法的标志组合:Sword | Key 可能是合法组合,但没有恰好对应这个组合值的命名成员。

1.4 数组:同一种类型的有序数据

数组把多个同类型元素放在一起,通过索引访问。它从 0 开始编号;长度为 n 的数组,有效索引为 0~n-1。

1
2
3
4
5
6
7
8
int[] scores;                   // 只声明变量,尚未创建数组。
scores = new int[3]; // 创建 3 个元素,初始都是 0。
int[] hp = new int[] { 100, 80, 60 };
int[] dice = { 1, 2, 3, 4, 5, 6 };
int[] empty = Array.Empty<int>(); // 合法的空数组,长度为 0。

Console.WriteLine(hp[0]); // 100
Console.WriteLine(hp.Length); // 3

C# 12 还支持 int[] values = [1, 2, 3]; 这样的集合表达式。为了保持经典写法清楚一致,正文主要使用 new int[] 或花括号初始化。

数组实例一旦创建,长度就固定。 后文的增加和删除,是创建不同长度的新数组,再让变量指向它,不是改变原实例的长度。数组官方说明

1.5 数组默认值与边界

元素类型 创建数组后,每个元素的默认值
int、float 等数值类型 0
bool false
char '\0'
普通枚举 转换为该枚举类型的 0
普通引用类型 null
结构体 字段逐项取默认值,第四章详述
1
2
3
4
int[] values = { 10, 20, 30 };
Console.WriteLine(values[values.Length - 1]); // 30
// Console.WriteLine(values[3]); // 越界:IndexOutOfRangeException。
// Console.WriteLine(values[-1]); // 同样越界。

空数组没有第一个元素,也没有最后一个元素。访问 values[values.Length - 1] 前,应确认长度大于零。

1.6 查询:按索引、遍历、按值查找

按索引读取:

1
2
int[] scores = { 30, 50, 80 };
Console.WriteLine(scores[1]); // 50

使用 for 遍历,适合需要索引的情况:

1
2
3
4
5
int[] scores = { 30, 50, 80 };
for (int i = 0; i < scores.Length; i++)
{
Console.WriteLine($"第 {i} 项:{scores[i]}");
}

使用 foreach 遍历,适合逐项读取:

1
2
3
4
5
int[] scores = { 30, 50, 80 };
foreach (int score in scores)
{
Console.WriteLine(score);
}

普通 foreach 的迭代变量不能直接当成一个可重新赋值的数组槽位;要批量修改数值元素,优先使用 for 和索引。

按值查找第一个匹配项:

1
2
3
4
int[] scores = { 30, 50, 80, 50 };
int index = Array.IndexOf(scores, 50);
Console.WriteLine(index); // 1
Console.WriteLine(Array.IndexOf(scores, 99)); // -1

也可以手写线性查找,观察 -1 作为“未找到”的约定:

1
2
3
4
5
6
7
8
9
10
11
12
int[] scores = { 30, 50, 80 };
int target = 50;
int foundIndex = -1;
for (int i = 0; i < scores.Length; i++)
{
if (scores[i] == target)
{
foundIndex = i;
break;
}
}
Console.WriteLine(foundIndex); // 1

1.7 修改:改变某个已有元素

1
2
3
int[] hp = { 100, 80, 60 };
hp[1] = 90;
Console.WriteLine(string.Join(", ", hp)); // 100, 90, 60

string.Join 把多个元素转换成一段带分隔符的文字,方便观察数组。修改元素不会改变数组长度。

1.8 增加:追加与插入

追加一个元素:

1
2
3
4
5
6
7
8
9
int[] scores = { 10, 20, 30 };
int[] larger = new int[scores.Length + 1];
for (int i = 0; i < scores.Length; i++)
{
larger[i] = scores[i];
}
larger[larger.Length - 1] = 40;
scores = larger;
Console.WriteLine(string.Join(", ", scores)); // 10, 20, 30, 40

在指定索引插入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int[] scores = { 10, 20, 30 };
int insertIndex = 1;
int value = 15;

if (insertIndex < 0 || insertIndex > scores.Length)
{
Console.WriteLine("插入索引不合法。");
}
else
{
int[] result = new int[scores.Length + 1];
for (int i = 0; i < insertIndex; i++)
result[i] = scores[i];
result[insertIndex] = value;
for (int i = insertIndex; i < scores.Length; i++)
result[i + 1] = scores[i];
scores = result;
Console.WriteLine(string.Join(", ", scores)); // 10, 15, 20, 30
}

插入允许 index == Length,代表追加到末尾;读取或修改已有元素则不允许这个索引。

1.9 删除:按索引删除与按值删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int[] scores = { 10, 20, 30, 40 };
int removeIndex = 1;

if (removeIndex < 0 || removeIndex >= scores.Length)
{
Console.WriteLine("删除索引不合法。");
}
else
{
int[] result = new int[scores.Length - 1];
for (int i = 0; i < removeIndex; i++)
result[i] = scores[i];
for (int i = removeIndex + 1; i < scores.Length; i++)
result[i - 1] = scores[i];
scores = result;
Console.WriteLine(string.Join(", ", scores)); // 10, 30, 40
}

按值删除时,先用 Array.IndexOf 找索引,再执行同样的删除步骤。如果有重复值,这样只删除第一个匹配项;若要全部删除,需要逐项筛选并重新构建数组。

把元素设为零不等于删除元素: scores[1] = 0; 只是修改,长度不变;Array.Clear(scores, 0, scores.Length) 只是把元素恢复默认值,长度同样不变。

1.10 Array.Resize 与数组操作成本

1
2
3
4
int[] scores = { 10, 20 };
Array.Resize(ref scores, 3);
scores[2] = 30;
Console.WriteLine(string.Join(", ", scores)); // 10, 20, 30

长度不同时,Array.Resize 创建新数组、复制可保留的元素,再更新传入的数组变量。这里的 ref 会在第三章详细解释。Resize 官方说明

操作 常见时间成本 是否改变已有数组实例的长度
按索引读取/修改 O(1) 否
无序数组按值查找 O(n) 否
新建数组并追加、插入或删除 O(n) 否,得到另一个实例
清空全部元素 O(n) 否

第一遍可以把 O(n) 理解为“工作量随元素数量大致成比例增长”;第五章会进一步解释。频繁增删时通常会学习 List<T> 等集合,本章先把数组的机制弄清楚。

1.11 二维数组:规则的行列网格

1
2
3
4
5
6
7
8
9
10
11
12
int[,] map = new int[2, 3]; // 2 行 3 列。
int[,] values =
{
{ 1, 2, 3 },
{ 4, 5, 6 }
};

Console.WriteLine(values[1, 2]); // 6
Console.WriteLine(values.Length); // 6:总元素数。
Console.WriteLine(values.Rank); // 2:维数。
Console.WriteLine(values.GetLength(0)); // 2:第一维长度,本文约定为行。
Console.WriteLine(values.GetLength(1)); // 3:第二维长度,本文约定为列。

C# 本身只规定第 0 维、第 1 维,不强迫你把它们叫作行、列。本教程统一使用 map[y, x]:先行后列,避免把屏幕坐标与数组下标写反。

1
2
3
4
5
6
7
8
9
int[,] map = { { 1, 2, 3 }, { 4, 5, 6 } };
for (int y = 0; y < map.GetLength(0); y++)
{
for (int x = 0; x < map.GetLength(1); x++)
{
Console.Write($"{map[y, x]} ");
}
Console.WriteLine();
}

二维数组适合固定地图、棋盘、表格。它的各维长度同样固定,不能直接用 Array.Resize 改成不同的行列数;通常新建二维数组再按行列复制。

1.12 交错数组:数组里面保存数组

不同批次的数据可能有不同长度,例如每一波敌人的数量不同:

1
2
3
4
5
6
7
8
int[][] waves = new int[3][];
waves[0] = new int[] { 1, 2 };
waves[1] = new int[] { 3, 4, 5 };
waves[2] = Array.Empty<int>();

Console.WriteLine(waves.Length); // 3:外层数组长度。
Console.WriteLine(waves[1].Length); // 3:第 2 个内层数组长度。
Console.WriteLine(waves[1][2]); // 5

new int[3][] 只创建外层数组,此时三个内层引用默认都是 null,并不会自动创建三行整数。使用前需要逐项初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
int[][] waves =
{
new int[] { 1, 2 },
new int[] { 3, 4, 5 },
Array.Empty<int>()
};

for (int row = 0; row < waves.Length; row++)
{
for (int column = 0; column < waves[row].Length; column++)
Console.Write($"{waves[row][column]} ");
Console.WriteLine();
}
比较 二维数组 int[,] 交错数组 int[][]
结构 一个规则的多维数组实例 一个外层数组与多个内层数组
访问 map[row, column] map[row][column]
每行长度 固定且相同 可以不同
查询长度 GetLength(0/1) 外层 .Length、内层 .Length
内层默认状态 元素直接取 int 默认值 内层数组引用最初可能为 null
典型用途 棋盘、固定矩阵 每人不同长度的历史记录、分组数据

第六章中,固定地图与骰子计数使用二维数组,每位玩家的掷骰历史使用交错数组。

1.13 本章练习

  1. 定义 Start、Playing、End 三种场景,使用 switch 输出场景说明。
  2. 对 {10, 20, 30} 依次追加 40、在索引 1 插入 15、删除索引 2、把索引 0 改成 99,再查找 30。
  3. 创建 3 行 4 列的地图,把每个位置赋值为 y * 10 + x。
  4. 创建三组长度分别为 2、0、4 的交错数组,并安全遍历。

第二章:值类型和引用类型

2.1 两种分类,先看复制行为

类型类别 普通变量保存什么 普通赋值复制什么 常见类型
值类型 类型的值本身 值 数值类型、bool、char、枚举、结构体
引用类型 对对象的引用 引用 数组、string、普通 class

这两类首先描述类型语义,不能简单等同于“一个永远在栈上、一个永远在堆上”。例如类对象中可以有值类型字段,数组内也可以直接存放结构体元素,装箱又会引入新的对象。值类型、引用类型

2.2 值类型赋值:两个独立的数值

1
2
3
4
5
int first = 10;
int second = first;
second = 20;
Console.WriteLine(first); // 10
Console.WriteLine(second); // 20

赋值完成后,修改 second 不会修改 first。枚举和后面的结构体,在普通赋值时也采用值复制。

2.3 引用类型赋值:共享同一个对象

1
2
3
4
5
6
int[] first = { 10, 20 };
int[] second = first;
second[0] = 99;

Console.WriteLine(first[0]); // 99
Console.WriteLine(ReferenceEquals(first, second)); // True

这里没有复制数组元素,只让两个变量保存了指向同一个数组对象的引用。


flowchart TD
    A[first 变量] --> C[同一个数组对象]
    B[second 变量] --> C
    C --> D[第 0 项为 99]

重新给其中一个变量赋另一个数组,不会让另一个变量也改指向:

1
2
3
4
5
int[] first = { 10, 20 };
int[] second = first;
second = new int[] { 7, 8 };
Console.WriteLine(first[0]); // 10
Console.WriteLine(second[0]); // 7

修改对象的内容和替换变量保存的引用是两件不同的事情。

2.4 数组扩容为什么不影响旧别名

1
2
3
4
5
6
7
8
int[] values = { 1, 2 };
int[] alias = values;
Array.Resize(ref values, 3);
values[2] = 3;

Console.WriteLine(values.Length); // 3
Console.WriteLine(alias.Length); // 2
Console.WriteLine(ReferenceEquals(values, alias)); // False

values 已指向新的数组;alias 仍然指向旧的数组。这个例子也说明为什么“Resize 改变原数组实例长度”是不准确的说法。

2.5 浅拷贝与深拷贝

对整数数组,复制元素通常已经足以获得互不影响的数组内容:

1
2
3
4
5
int[] original = { 1, 2 };
int[] copy = (int[])original.Clone();
copy[0] = 99;
Console.WriteLine(original[0]); // 1
Console.WriteLine(copy[0]); // 99

但 Clone() 是浅拷贝:创建新数组并复制每个槽位的值。若槽位本身保存引用,复制的仍然只是引用。Array.Clone

1
2
3
4
int[][] original = { new int[] { 1, 2 } };
int[][] shallow = (int[][])original.Clone();
shallow[0][0] = 99;
Console.WriteLine(original[0][0]); // 99:内层数组仍共享。

这个特定的交错整数数组,可以逐个复制内层数组:

1
2
3
4
5
6
7
8
int[][] original = { new int[] { 1, 2 }, new int[] { 3 } };
int[][] deep = new int[original.Length][];
for (int i = 0; i < original.Length; i++)
{
deep[i] = (int[])original[i].Clone();
}
deep[0][0] = 99;
Console.WriteLine(original[0][0]); // 1

这里假设每个内层数组都已初始化。对更复杂的对象图,“深拷贝”需要明确哪些成员要复制、哪些要共享,不能认为调用一次 Clone() 就完成了全部层级的复制。

2.6 string 是引用类型,但对象内容不可变

1
2
3
4
5
6
string first = "勇者";
string second = first;
second += "A";

Console.WriteLine(first); // 勇者
Console.WriteLine(second); // 勇者A

字符串拼接产生新的字符串结果,再让 second 保存该结果的引用;原字符串对象没有被修改。

相等判断也要看具体类型的规则:数组的普通 == 比较引用,string 的 == 比较文字内容;自定义结构体并不会自动获得可用的 == 运算符。

1
2
3
4
int[] a = { 1, 2 };
int[] b = { 1, 2 };
Console.WriteLine(a == b); // False:不同数组实例。
Console.WriteLine("hero" == "hero"); // True:字符串内容相等。

2.7 装箱与拆箱:值类型也能放进 object

1
2
3
4
5
6
int hp = 100;
object boxed = hp; // 装箱:复制 hp 的值到一个装箱对象中。
hp = 80;
int restored = (int)boxed; // 拆箱。

Console.WriteLine(restored); // 100

装箱发生后,修改原来的 hp 不会修改那个装箱对象中的副本。

拆箱类型要匹配实际被装箱的值类型:

1
2
3
4
object boxed = 100; // 实际装箱的是 int。
// long wrong = (long)boxed; // InvalidCastException,不会先自动拆成 int 再转 long。
long correct = (int)boxed;
Console.WriteLine(correct); // 100

装箱通常涉及对象分配与复制。理解它有助于后续阅读性能问题,但不需要因此把所有 object 的使用都视为错误。装箱与拆箱

2.8 类型类别与传参方式是两个维度

默认传参都是按值传递。 只是:值类型参数得到值的副本,引用类型参数得到引用的副本。

参数声明方式 传入一个 int 传入一个 int[]
普通参数 复制整数值 复制数组引用,因此可以访问同一数组
ref 参数 可改写调用方的整数变量 可替换调用方变量保存的数组引用

所以“引用类型默认就是 ref 传参”不成立。第三章会用完整方法示例验证这一点。参数传递说明

2.9 本章练习

  1. 预测 int[] b = a; b[0] = 9; 对 a[0] 的影响。
  2. 解释 Array.Resize(ref a, ...) 后为什么 b 可能仍保留旧长度。
  3. 对交错数组执行一次外层 Clone(),再修改内层元素,观察是否影响原数组。
  4. 解释为什么“值类型全部在栈上”不能作为普遍规则。

第三章:函数

3.1 声明方法:把一段操作命名

当多处都需要显示标题、计算伤害或检查坐标时,可以把这些操作放进方法,避免重复编写。

1
2
3
4
5
6
// 方法声明:放在 Program 类内、Main 外。
private static int Add(int first, int second)
{
int result = first + second;
return result;
}
组成 当前含义
private 当前类内部使用
static 属于类型,本例不需要先创建 Program 对象
int 方法返回一个整数
Add 方法名称
int first, int second 两个形式参数,简称形参
return result; 结束方法并把结果交给调用者

调用方法,放在 Main 内:

1
2
int sum = Add(10, 20);
Console.WriteLine(sum); // 30

调用时的 10、20 是实际参数,简称实参。声明方法不等于已经调用方法;程序走到调用语句时才执行它。方法官方说明

3.2 无参、有参与返回值的四种组合

下面是一个可独立运行的完整程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System;

internal class Program
{
private static void Main()
{
ShowTitle();
int maximum = GetMaxHp();
PrintHp(maximum);
int damage = CalculateDamage(10, 5);
Console.WriteLine($"伤害:{damage}");
}

private static void ShowTitle() // 无参、无返回值。
{
Console.WriteLine("飞行棋");
}

private static int GetMaxHp() // 无参、有返回值。
{
return 100;
}

private static void PrintHp(int hp) // 有参、无返回值。
{
Console.WriteLine($"血量:{hp}");
}

private static int CalculateDamage(int attack, int defense) // 有参、有返回值。
{
return Math.Max(0, attack - defense);
}
}

void 表示不返回结果,不是一个可以接收的“空值”。因此不能写 int result = ShowTitle();。

3.3 参数、局部变量与作用域

参数是方法内部可使用的变量,名字不必与调用方变量相同。

1
2
3
4
5
private static int DoubleValue(int value)
{
int result = value * 2;
return result;
}
1
2
3
4
int score = 10;
int doubled = DoubleValue(score);
Console.WriteLine(doubled); // 20
// Console.WriteLine(result); // 错误:result 是方法内部的局部变量。

在方法中定义的普通局部变量,不会成为另一个方法可以随意读取的共享变量。确实需要共享状态时,可以使用字段,但不要为了省参数而把所有临时数据都做成字段。

非 void 方法的每条正常返回路径都需要提供兼容的返回值;抛出异常的路径则没有正常返回结果。

3.4 return 与 break 的区别

1
2
3
4
5
6
7
8
private static void PrintPositive(int value)
{
if (value <= 0)
{
return;
}
Console.WriteLine(value);
}

这里的 return 直接结束整个方法。break 则用于离开最近的循环或 switch,不会自动离开整个方法。

游戏会用这种方式处理不满足条件的情况:无法起飞、超出终点或不需要处理碰撞时,提前结束当前操作。

3.5 普通传参:引用类型仍然传递引用的副本

先看整数:

1
2
3
4
private static void ChangeNumber(int value)
{
value = 99;
}
1
2
3
int number = 10;
ChangeNumber(number);
Console.WriteLine(number); // 10

方法修改的是它自己的参数副本。

再看数组:

1
2
3
4
5
private static void ChangeArray(int[] values)
{
values[0] = 99; // 修改双方共享的数组对象。
values = new int[] { 7, 8 }; // 只替换方法内部参数保存的引用。
}
1
2
3
4
int[] values = { 1, 2 };
ChangeArray(values);
Console.WriteLine(values[0]); // 99,不是 7。
Console.WriteLine(values.Length); // 2

普通数组参数能修改元素,并不意味着它能替换调用方变量指向的数组。 若需要替换,通常返回新数组并由调用方接收,或者使用 ref。

3.6 ref:让方法访问调用方变量

1
2
3
4
5
6
private static void Swap(ref int first, ref int second)
{
int temporary = first;
first = second;
second = temporary;
}
1
2
3
4
int x = 10;
int y = 20;
Swap(ref x, ref y);
Console.WriteLine($"x={x}, y={y}"); // x=20, y=10

使用 ref 的要点:

  1. 声明处和调用处都写 ref。
  2. 传入前必须已初始化,因为方法可能读取旧值。
  3. 通常需要可作为变量引用的实参,不能直接传 ref 10 或 ref (x + y)。
  4. 方法可以修改实参,但不要求一定修改。

替换数组引用的例子:

1
2
3
4
private static void ReplaceArray(ref int[] values)
{
values = new int[] { 7, 8, 9 };
}
1
2
3
int[] values = { 1, 2 };
ReplaceArray(ref values);
Console.WriteLine(values.Length); // 3

这正是 Array.Resize(ref values, ...) 能更新调用方变量的关键。

3.7 out:让方法明确输出结果

out 适合“返回成功状态,同时输出计算结果”的接口。下面的方法从非空数组中找最小值和最大值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static bool TryGetMinMax(int[] values, out int minimum, out int maximum)
{
ArgumentNullException.ThrowIfNull(values);
minimum = 0;
maximum = 0;
if (values.Length == 0)
{
return false;
}

minimum = maximum = values[0];
for (int i = 1; i < values.Length; i++)
{
minimum = Math.Min(minimum, values[i]);
maximum = Math.Max(maximum, values[i]);
}
return true;
}
1
2
3
4
5
6
7
8
9
int[] values = { 8, 2, 5 };
if (TryGetMinMax(values, out int minimum, out int maximum))
{
Console.WriteLine($"最小:{minimum},最大:{maximum}"); // 2、8
}
else
{
Console.WriteLine("数组为空。");
}

ArgumentNullException.ThrowIfNull 用于拒绝空引用参数,不是判断数组长度是否为零。这里把 null 视为调用错误,把空数组视为可以返回 false 的正常情况。

对比项 ref out
调用前是否必须初始化 是 否
方法是否可以读取传入旧值 可以 必须先在方法中赋值后才能读取该 out 参数
正常返回前是否必须赋值 不要求 每个正常返回路径都必须赋值
主要意图 读取并可能修改现有变量 产生输出结果
调用语法 Method(ref value) Method(out value) 或 Method(out int value)

TryGetMinMax 返回 false 时也已经给两个输出参数赋值为零;调用方仍应检查布尔结果,而不能把零自行理解成成功。参数修饰符说明

3.8 变长参数:params

1
2
3
4
5
6
7
8
9
10
private static int Sum(params int[] values)
{
ArgumentNullException.ThrowIfNull(values);
int total = 0;
foreach (int value in values)
{
total = checked(total + value);
}
return total;
}
1
2
3
4
5
Console.WriteLine(Sum());         // 0
Console.WriteLine(Sum(1)); // 1
Console.WriteLine(Sum(1, 2, 3)); // 6
int[] numbers = { 4, 5, 6 };
Console.WriteLine(Sum(numbers)); // 15

本教程采用 params T[]:它是一个一维数组参数,调用方可传零个、一个、多个元素,也可传已有数组。

规则是一个参数列表中最多一个 params,并且放在最后;它也不能同时加 ref 或 out。已有数组作为实参时,不要假设一定会再复制一份数组;方法若修改元素,可能影响调用方的数组。

本例的 checked 用于在求和超出 int 范围时抛出异常,避免静默得到环绕后的数值。C# 13 起 params 支持更多集合类型,当前 C# 12 项目只使用数组形式。

3.9 参数默认值与具名参数

1
2
3
4
private static string Describe(string name, int level = 1)
{
return $"{name}:等级 {level}";
}
1
2
3
Console.WriteLine(Describe("勇者"));           // 勇者:等级 1
Console.WriteLine(Describe("勇者", 3)); // 勇者:等级 3
Console.WriteLine(Describe(name: "勇者", level: 5)); // 勇者:等级 5

level 是可选参数,调用时省略就采用默认值;name: 是具名实参,用名字明确参数含义。

1
2
3
4
private static int Move(int steps, int bonus = 0, int maximum = 40)
{
return Math.Min(maximum, steps + bonus);
}
1
Console.WriteLine(Move(10, maximum: 20)); // 10:省略 bonus,单独指定 maximum。

必需参数通常放在可选参数之前。默认值必须满足语言允许的编译时默认值规则;不能把 DateTime.Now 或 new Random() 直接当作普通可选参数默认值。

默认参数不意味着方法会自动记住上次调用值,每次省略时仍使用声明中的默认值。可选与具名参数

3.10 函数重载:名字相同,参数列表不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static int Add(int first, int second)
{
return checked(first + second);
}

private static double Add(double first, double second)
{
return first + second;
}

private static int Add(int first, int second, int third)
{
return checked(first + second + third);
}
1
2
3
Console.WriteLine(Add(1, 2));       // int 版本,3。
Console.WriteLine(Add(1.5, 2.5)); // double 版本,4。
Console.WriteLine(Add(1, 2, 3)); // 三个 int 版本,6。
修改方式 是否足以形成重载
改参数数量 可以
改参数类型,或不同类型参数的顺序 可以
只改参数名称 不可以
只改返回类型 不可以
只改默认值 不可以
只把 int[] 改为 params int[] 不可以
只把 ref int 改为 out int 不可以
普通 int 参数与 ref int 参数 可以,但调用方式不同

不要依赖过多隐式转换来猜会命中哪个重载,尤其是可选参数、null 与多个重载混用时。学习阶段让每个调用的参数类型和数量尽量清楚。

3.11 函数递归:自己调用自己

阶乘定义:0! = 1,n! = n × (n - 1)!。

1
2
3
4
5
6
7
8
9
10
11
12
private static long Factorial(int n)
{
if (n < 0 || n > 20)
{
throw new ArgumentOutOfRangeException(nameof(n), "只接受 0~20。");
}
if (n <= 1)
{
return 1;
}
return checked(n * Factorial(n - 1));
}
1
2
Console.WriteLine(Factorial(0)); // 1
Console.WriteLine(Factorial(5)); // 120
执行阶段 计算
调用 Factorial(3) 等待 3 × Factorial(2)
调用 Factorial(2) 等待 2 × Factorial(1)
到达终止条件 Factorial(1) 返回 1
返回上一层 Factorial(2) 返回 2
返回最外层 Factorial(3) 返回 6

递归需要两个条件:明确的终止条件,以及每次调用都更接近终止条件。缺少任意一个,都可能导致调用层数不断增长。

本例限制到 20,是因为 20! 仍能放入 long,21! 已不能。递归还需要调用栈空间;不要尝试通过普通 try-catch 把无限递归当成可恢复的流程。

阶乘也可以使用循环,时间均为 O(n),但循环额外空间可为 O(1),递归调用栈为 O(n)。递归的优势是表达分治结构,第五章排序中的归并与快速排序会用到它。

3.12 把数组操作整理成函数

第一章的增加、删除已经写了不少循环。可以把它们整理成 ArrayTools.Insert、ArrayTools.RemoveAt 等方法:

1
2
3
4
5
6
7
8
9
10
// Main 内;需要配套 BasicExamples 项目的 ArrayTools.cs。
int[] scores = { 10, 20, 30 };
scores = ArrayTools.Append(scores, 40);
scores = ArrayTools.Insert(scores, 1, 15);
scores = ArrayTools.RemoveAt(scores, 2);
bool changed = ArrayTools.TryUpdate(scores, 0, 99);
int index = ArrayTools.IndexOf(scores, 30);

Console.WriteLine(string.Join(", ", scores)); // 99, 15, 30, 40
Console.WriteLine($"修改成功:{changed},30 的索引:{index}");

这里的新数组通过返回值交给调用方,所以必须写 scores = ... 来接收。只调用 ArrayTools.Append(scores, 40); 而丢弃返回值,不会让 scores 自动变长。

3.13 本章练习

  1. 编写无参方法显示标题,再编写有参方法输出指定玩家名称。
  2. 用 ref 交换两个整数。
  3. 用 out 从数组获得最小值和最大值,确保空数组路径也为输出参数赋值。
  4. 编写 Sum(params int[] values),验证不传实参的结果。
  5. 定义带默认等级的介绍方法,再添加不同参数类型的 Add 重载。
  6. 计算 5!,说明终止条件和可接受的数值范围。

第四章:结构体

4.1 把相关数据组合成一个值

如果一个坐标总是同时需要 x 和 y,可以用结构体把它们组合起来。

类型声明:

1
2
3
4
5
6
7
8
9
10
11
public struct GridPoint
{
public int X;
public int Y;

public GridPoint(int x, int y)
{
X = x;
Y = y;
}
}

Main 内:

1
2
3
GridPoint position = new GridPoint(2, 3);
Console.WriteLine($"坐标:({position.X}, {position.Y})");
position.X = 4;

struct 定义值类型,可包含字段、方法、属性、构造函数等。它适合表达坐标、颜色、少量状态数据等小型数据组合;普通结构体不能继承另一个类或结构体,也不能成为派生类的基类,但可以实现接口。结构体官方说明

4.2 使用与复制

1
2
3
4
5
GridPoint first = new GridPoint(2, 3);
GridPoint second = first;
second.X = 99;
Console.WriteLine(first.X); // 2
Console.WriteLine(second.X); // 99

结构体普通赋值会复制各字段的值。new GridPoint(...) 不意味着它因此变成引用类型,new 的出现不能用来判断类型类别。

数组中的结构体元素也是具体的结构体值:

1
2
3
GridPoint[] points = new GridPoint[2];
points[0] = new GridPoint(2, 3);
points[0].X = 9; // 可以直接修改数组元素中的字段。

4.3 访问修饰符

修饰符 基本含义 在本章中的用途
public 在包含类型可访问的前提下对外开放 坐标字段、需要公开的构造函数和方法
private 只在包含它的类型内部访问 隐藏结构体内部状态
internal 当前程序集内访问 游戏内部使用的结构体和枚举
protected 等派生访问规则 主要用于类继承 结构体没有这种派生体系,本章结构体成员不使用

命名空间直接包含的结构体若不写访问修饰符,默认是 internal;结构体内的普通字段和方法不写时,默认是 private。不要把类型声明的默认可见性与成员的默认可见性混淆。

public 成员也不能突破其包含类型的可见范围。例如一个 internal struct 的公开字段,不会让整个结构体自动变成外部程序集可见。访问修饰符

4.4 构造函数的语法

构造函数用于初始化对象或值,它与类型同名、没有返回类型。

1
2
3
4
5
6
7
8
9
10
11
public struct GridPoint
{
public int X;
public int Y;

public GridPoint(int x, int y)
{
X = x;
Y = y;
}
}

public void GridPoint(...) 不是构造函数的正确声明。示例明确为每个实例字段赋初值,方便阅读,也避免依赖未写出的初始化意图。

如果参数名与字段名相同,可以用 this 区分:

1
2
3
4
5
6
7
8
9
10
11
public struct NumberPair
{
public int first;
public int second;

public NumberPair(int first, int second)
{
this.first = first;
this.second = second;
}
}

4.5 封装:通过方法维护合法状态

坐标公开字段便于教学;如果血量要求不能小于零,直接允许外部随意赋值就难以维持规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public struct Health
{
private int current;
public int Current { get { return current; } }
public int Max { get; }

public Health()
{
current = 100;
Max = 100;
}

public Health(int current, int max = 100)
{
if (max <= 0 || current < 0 || current > max)
{
throw new ArgumentOutOfRangeException(nameof(current));
}
this.current = current;
Max = max;
}

public void Damage(int amount)
{
if (amount < 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}
current = Math.Max(0, current - amount);
}
}
1
2
3
4
5
Health health = new Health(100);
health.Damage(25);
Console.WriteLine($"{health.Current}/{health.Max}"); // 75/100
// health.current = -10; // 错误:private 字段不能从外部直接访问。
// health.Current = -10; // 错误:Current 没有公开的写入入口。

Current 与 Max 是只读访问的属性。当前先理解为“通过点号读取,但不能像公开字段一样随意赋值”。nameof(current) 得到名称文字 "current",用于异常参数说明。

4.6 无参构造、default 与数组元素

C# 10 起允许显式定义 public 无参结构体构造函数。 上面的 Health() 会将血量设为 100。

1
2
3
4
5
6
7
Health created = new Health();
Health zero = default(Health);
Health[] values = new Health[1];

Console.WriteLine(created.Current); // 100:调用了显式无参构造函数。
Console.WriteLine(zero.Current); // 0:default 不调用该构造函数。
Console.WriteLine(values[0].Current); // 0:数组元素默认初始化,不逐项调用该构造函数。
写法 本例 Current 是否调用显式 Health()
new Health() 100 是
new Health(80) 80 否,调用带参版本
default(Health) 0 否
new Health[10] 的每一项 0 否

所以结构体的构造校验不能阻止所有默认值的产生。若默认状态不符合业务要求,使用位置还需要识别或显式初始化它。游戏的飞机数组创建后,会逐项执行 new PlaneState(...),否则默认 Steps = 0 会被误认为已经起飞。

C# 11 起,结构体构造函数还支持由编译器为未显式赋值的字段补默认值。本教程仍把初始化写全,不采用“所有版本都必须由开发者手动赋齐字段”的过时绝对说法。

4.7 结构体中的引用字段仍然可能共享对象

1
2
3
4
5
6
7
8
9
public struct Bag
{
public int[] Items;

public Bag(int[] items)
{
Items = items;
}
}
1
2
3
4
Bag first = new Bag(new int[] { 1, 2 });
Bag second = first;
second.Items[0] = 99;
Console.WriteLine(first.Items[0]); // 99

复制了结构体,并不表示递归复制其中引用指向的所有对象。Items 字段的值本身是数组引用,复制后两个字段仍可能引用同一数组。

另外,default(Bag).Items 是 null,即使字段声明成了没有 ? 的 int[],默认初始化也不会自动创建数组。

4.8 传结构体:修改副本还是修改原值

1
2
3
4
5
6
7
8
9
private static void MoveCopy(GridPoint point)
{
point.X++;
}

private static void MoveOriginal(ref GridPoint point)
{
point.X++;
}
1
2
3
4
5
GridPoint point = new GridPoint(2, 3);
MoveCopy(point);
Console.WriteLine(point.X); // 2
MoveOriginal(ref point);
Console.WriteLine(point.X); // 3

游戏会使用 TryAdvance(ref PlaneState plane, ...) 直接更新数组中的飞机状态。若漏掉 ref,很可能只更新了方法内部的副本。

4.9 使用结构体时的判断

  • 小型、偏向数据表达、复制后应成为独立值的对象,适合考虑结构体。
  • 需要共享身份、复杂生命周期或大量可变行为时,后续学习普通类往往更合适。
  • 对正式设计,优先考虑不可变结构体;本教程使用可变字段,是为了清楚展示赋值、数组元素与 ref 的行为。
  • readonly struct 能限制结构体自身字段的重新赋值,但若字段指向可变对象,不等于把该对象的内部内容也冻结。

4.10 本章练习

  1. 定义一个二维坐标结构体,创建副本并只修改副本,观察原值。
  2. 定义飞机状态结构体,包含玩家编号和前进步数,构造时把步数设为 -1。
  3. 比较 new Health()、default(Health) 与 new Health[1] 的元素。
  4. 用 ref 编写一个修改坐标的方法,说明与普通传参的差异。

第五章:排序——十种方法

5.1 排序解决什么问题

排序是按照某个比较规则重新排列数据,例如分数从小到大、时间从早到晚、等级从高到低。

本章统一把 int[] 升序排列,输入 {5, 3, 8, 4, 2} 时输出 {2, 3, 4, 5, 8}。所有方法都把结果写回调用方传入的数组;其中一些方法仍会使用辅助数组,所以“修改传入数组”不等于“额外空间只有常数”。

本章代码分别放在源码包的 Shared/SortingAlgorithms.cs,类名为 CSharpBasics.SortingAlgorithms。代码是针对教学编写的实现,不是从下面的参考资料复制的语言版本。

5.2 时间复杂度、额外空间与稳定性

n 表示数据个数。复杂度描述输入规模增大时工作量或空间用量的增长,不是某台电脑上精确的毫秒数。

复杂度 直观含义 常见例子
O(1) 额外工作量不随 n 增长 按索引读取一个元素
O(log n) 规模反复减半 有序数组的二分查找
O(n) 大致遍历全部元素一次 查找最大值
O(n log n) 多层分治,每层处理约 n 个元素 归并排序
O(n²) 数量级接近双重遍历 选择排序

稳定排序指相同关键字的记录在排序后仍保持原相对顺序。

例如三条记录依次是 A:90、B:80、C:90,按分数升序稳定排序后是 B:80、A:90、C:90,A 仍然在 C 前面。单纯整数数组中的两个 90 看起来一样,但排序学生、装备等记录时,稳定性可能有实际意义。

“原地”通常关注是否只需很少的辅助存储;本章在空间复杂度中把递归调用栈算进去,避免把递归快速排序说成完全不需要额外空间。

5.3 代码放置与公共交换方法

把后面十个小节的所有方法与本节 Swap 放在同一个类中。每个小节包含它自己的辅助方法;不要把这些方法粘贴进 Main。

1
2
3
4
5
6
7
8
9
using System;

namespace CSharpBasics
{
public static class SortingAlgorithms
{
// 放入 Swap 和后面十节的排序方法、辅助方法。
}
}

公共交换方法:

1
2
3
4
5
6
private static void Swap(int[] data, int first, int second)
{
int temporary = data[first];
data[first] = data[second];
data[second] = temporary;
}

排序入口使用 ArgumentNullException.ThrowIfNull(data) 拒绝空引用;空数组和单元素数组则可以正常处理。

5.4 冒泡排序:每一轮把较大值推到右边

相邻两个元素比较,前一个较大就交换。一轮结束,当前范围中的最大值已经在末尾,下一轮可以缩小范围。

阶段 数组状态
初始 5, 3, 8, 4, 2
第 1 轮后 3, 5, 4, 2, 8
第 2 轮后 3, 4, 2, 5, 8
第 3 轮后 3, 2, 4, 5, 8
第 4 轮后 2, 3, 4, 5, 8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void BubbleSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
for (int end = data.Length - 1; end > 0; end--)
{
bool changed = false;
for (int i = 0; i < end; i++)
{
if (data[i] > data[i + 1])
{
Swap(data, i, i + 1);
changed = true;
}
}
if (!changed)
break;
}
}

changed 用于检测某一轮是否完全没有交换;没有交换就已经有序,可以提前结束。只在严格大于时交换,相等值不互相越过,因此本实现稳定。

最好时间 O(n),平均与最坏时间 O(n²),额外空间 O(1)。适合入门理解,不是大型数据排序的常规首选。

5.5 选择排序:选出最小值,放到前面

每轮从未排序区域找最小值,与区域开头交换。左侧已排序区域逐步扩大。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void SelectionSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
for (int start = 0; start < data.Length - 1; start++)
{
int minimum = start;
for (int i = start + 1; i < data.Length; i++)
if (data[i] < data[minimum])
minimum = i;
if (minimum != start)
Swap(data, start, minimum);
}
}

例如 {5, 3, 8, 4, 2} 第一轮找到 2,与 5 交换,得到 {2, 3, 8, 4, 5}。

即使输入原本有序,仍然需要扫描剩余范围找最小值,所以最好、平均、最坏时间均为 O(n²);额外空间 O(1)。

本实现不稳定。例如 2A、2B、1,第一轮把 1 与 2A 交换后变成 1、2B、2A,两个相同关键字的顺序已经改变。

5.6 插入排序:把新元素插入已排序区域

从第二个元素开始,暂存当前值,把左侧比它大的元素依次右移,再把它放进空位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void InsertionSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
for (int i = 1; i < data.Length; i++)
{
int value = data[i];
int j = i - 1;
while (j >= 0 && data[j] > value)
{
data[j + 1] = data[j];
j--;
}
data[j + 1] = value;
}
}

data[j] > value 使用严格大于,不把相等值往右推,因此相等元素保持相对顺序。

输入已排序时,每轮很快停止,最好时间 O(n);平均与最坏时间 O(n²);额外空间 O(1),稳定。小数组或接近有序的数据常适合它。游戏结束时排序少量骰子记录就采用这个方法。

5.7 希尔排序:先做远距离插入,再缩小间隔

普通插入排序一次只挪一格。希尔排序先把相隔 gap 的元素当成同一组做插入,之后逐步减小 gap,最后执行 gap = 1 的插入排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void ShellSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
// 本实现采用不断折半的增量,不套用其他增量序列的复杂度。
for (int gap = data.Length / 2; gap > 0; gap /= 2)
{
for (int i = gap; i < data.Length; i++)
{
int value = data[i];
int j = i;
while (j >= gap && data[j - gap] > value)
{
data[j] = data[j - gap];
j -= gap;
}
data[j] = value;
}
}
}

本实现使用 n/2、n/4、...、1 的折半增量。增量序列会显著影响性能,不能把其他序列的结论直接套过来。NIST:Shell sort

对本实现,已排序输入仍需执行多轮扫描,最好时间为 O(n log n);最坏时间为 O(n²)。平均性能依赖增量和数据分布,本教程不把它固定写成某一个通用幂次。

额外空间 O(1),不稳定:远距离移动可能让相等关键字互相越过。

5.8 归并排序:先分成小段,再合并有序段

把数组划成左右两半,分别递归排序,再用两个指针合并。递归终止于区间只有零个或一个元素。


flowchart TD
    A[一个待排序区间] --> B[左半区间递归排序]
    A --> C[右半区间递归排序]
    B --> D[合并两个有序区间]
    C --> D
    D --> E[写回当前区间]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public static void MergeSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
if (data.Length < 2)
return;
int[] buffer = new int[data.Length];
MergeRange(data, buffer, 0, data.Length - 1);
}

private static void MergeRange(int[] data, int[] buffer, int left, int right)
{
if (left >= right)
return;
int middle = left + (right - left) / 2;
MergeRange(data, buffer, left, middle);
MergeRange(data, buffer, middle + 1, right);

int i = left;
int j = middle + 1;
int output = left;
while (i <= middle && j <= right)
buffer[output++] = data[i] <= data[j] ? data[i++] : data[j++];
while (i <= middle)
buffer[output++] = data[i++];
while (j <= right)
buffer[output++] = data[j++];
for (int index = left; index <= right; index++)
data[index] = buffer[index];
}

两个当前元素相等时,data[i] <= data[j] 优先取左侧,保证稳定性。缓冲区在入口创建一次,被递归层共享,避免每个小区间都创建一份完整缓冲。

本实现没有“区间已经有序就跳过合并”的优化,因此最好、平均、最坏时间均为 O(n log n)。额外缓冲区 O(n),递归栈 O(log n),合计额外空间为 O(n)。稳定,代价是需要辅助存储。

5.9 快速排序:按基准分区,再处理左右两边

选择一个基准值,左指针寻找不小于基准的值,右指针寻找不大于基准的值,交换后继续向中间收缩。分区后递归处理两边。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public static void QuickSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
QuickRange(data, 0, data.Length - 1);
}

private static void QuickRange(int[] data, int left, int right)
{
if (left >= right)
return;
int i = left;
int j = right;
int pivot = data[left + (right - left) / 2];

while (i <= j)
{
while (i <= right && data[i] < pivot)
i++;
while (j >= left && data[j] > pivot)
j--;
if (i <= j)
{
Swap(data, i, j);
i++;
j--;
}
}
if (left < j)
QuickRange(data, left, j);
if (i < right)
QuickRange(data, i, right);
}

这里取中间位置的值作为基准,不表示一定能得到最平均的分区。交换后同时执行 i++、j--,可保证遇到重复值时也继续推进。

最好时间 O(n log n);在通常的随机输入假设下平均为 O(n log n);分区持续不均衡时最坏为 O(n²)。递归栈平均 O(log n),最坏 O(n)。本实现不稳定。

中间值选基准能避开一些朴素首元素基准的问题,但不是最坏情况保证。实际库实现常有更多保护措施,不应直接把教学版当成所有生产输入下的最优方案。

5.10 堆排序:用最大堆反复取出最大值

最大堆满足:父节点的值不小于子节点。它不是“每一层从左到右都有序”。

使用从零开始的数组表示完全二叉树时:

节点关系 数组索引
左孩子 2 * i + 1
右孩子 2 * i + 2
父节点,i 大于零时 (i - 1) / 2

先从最后一个非叶节点开始下沉,建立最大堆;然后交换堆顶与未排序区末尾,把堆范围缩小,并重新下沉堆顶。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void HeapSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
for (int root = data.Length / 2 - 1; root >= 0; root--)
SiftDown(data, root, data.Length);
for (int end = data.Length - 1; end > 0; end--)
{
Swap(data, 0, end);
SiftDown(data, 0, end);
}
}

private static void SiftDown(int[] data, int root, int count)
{
while (root < count / 2)
{
int child = root * 2 + 1;
if (child + 1 < count && data[child + 1] > data[child])
child++;
if (data[root] >= data[child])
return;
Swap(data, root, child);
root = child;
}
}

count 表示当前堆的有效元素个数,不是最后一个下标。交换到末尾的最大值已经有序,下一次调整不能把它再纳入堆。

建堆为 O(n),完整排序平均与最坏时间为 O(n log n);当前下沉实现遇到全相等键时可以很快停止,最好可为 O(n)。额外空间 O(1),不稳定。常见资料把堆排序统一概括为 O(n log n),通常是在表达一般表现或统一上界。

5.11 计数排序:为每种取值统计位置

如果数据都处在较小整数范围内,可以为每个可能的值设置计数槽。使用最小值偏移,就可以支持负数。

例如 {-2, 1, -2, 0},键偏移后对应槽 0、3、0、2。先计数,再做前缀累计,最后按位置输出。NIST:counting sort

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static void CountingSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
if (data.Length < 2)
return;
int minimum = data[0];
int maximum = data[0];
foreach (int value in data)
{
minimum = Math.Min(minimum, value);
maximum = Math.Max(maximum, value);
}
long range = (long)maximum - minimum + 1;
const int MaxRange = 1_000_000;
if (range > MaxRange)
throw new ArgumentException("CountingSort requires a value range <= 1,000,000.", nameof(data));

int[] counts = new int[(int)range];
foreach (int value in data)
counts[(int)((long)value - minimum)]++;
for (int i = 1; i < counts.Length; i++)
counts[i] += counts[i - 1];

int[] output = new int[data.Length];
for (int i = data.Length - 1; i >= 0; i--)
{
int key = (int)((long)data[i] - minimum);
output[--counts[key]] = data[i];
}
Array.Copy(output, data, data.Length);
}

本实现从后向前放置元素,采用稳定计数排序的步骤。对纯整数看不出相等元素的身份,但该放置顺序能够推广到带相同键的记录。

用 R = maximum - minimum + 1 表示值域大小,时间为 O(n + R),额外空间为 O(n + R)。不能只写“计数排序始终是 O(n)”而忽略 R。

实现先以 long 计算范围,避免 int.MinValue 到 int.MaxValue 的差值溢出;并把计数槽数量限制为一百万。范围过大时主动抛出参数异常,而不尝试创建巨大的数组。

5.12 桶排序:按数值范围分组,桶内再排序

把值按范围分到若干桶,每个桶内部排序,再按桶序连接。这里用交错数组保存各桶,桶内使用前面的稳定插入排序。NIST:bucket sort

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public static void BucketSort(int[] data, int bucketCount = 10)
{
ArgumentNullException.ThrowIfNull(data);
if (bucketCount <= 0)
throw new ArgumentOutOfRangeException(nameof(bucketCount));
if (data.Length < 2)
return;
bucketCount = Math.Min(bucketCount, data.Length);
int minimum = data[0];
int maximum = data[0];
foreach (int value in data)
{
minimum = Math.Min(minimum, value);
maximum = Math.Max(maximum, value);
}
long span = (long)maximum - minimum + 1;
int[] counts = new int[bucketCount];
foreach (int value in data)
{
int bucket = (int)(((long)value - minimum) * bucketCount / span);
counts[bucket]++;
}

int[][] buckets = new int[bucketCount][];
for (int i = 0; i < bucketCount; i++)
buckets[i] = new int[counts[i]];
int[] writePositions = new int[bucketCount];
foreach (int value in data)
{
int bucket = (int)(((long)value - minimum) * bucketCount / span);
buckets[bucket][writePositions[bucket]++] = value;
}

int output = 0;
foreach (int[] bucket in buckets)
{
InsertionSort(bucket);
foreach (int value in bucket)
data[output++] = value;
}
}

关键步骤:先统计每桶容量,再创建刚好足够的内层数组;按原始顺序装入桶;逐桶执行插入排序。

span 和映射乘法使用 long,因此可以处理整个 int 范围的数值,而不要求全部非负。桶数必须大于零,实际使用桶数不会超过元素数。

设 k 为桶数。均匀分布、各桶内部顺序一般的假设下,本实现的平均时间可写为 O(n + n²/k + k);若 k 与 n 同阶,可以接近 O(n + k)。若默认只用 10 个桶,不能把大规模输入的平均表现无条件当作线性。最坏为 O(n² + k),额外空间 O(n + k)。

由于分桶保留顺序、桶内插入排序稳定,且相等值映射到同一个桶,本实现稳定。

5.13 基数排序:逐位稳定排序

以十进制理解,可以先按个位排、再按十位排,并保证每一轮稳定。低位已经形成的相对顺序因此可以在更高位相同时保留。NIST:radix sort

本实现针对固定 32 位 int,每次处理 8 位,也就是以 256 为基数,固定执行四轮;每轮采用稳定计数分发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public static void RadixSort(int[] data)
{
ArgumentNullException.ThrowIfNull(data);
if (data.Length < 2)
return;
const int Base = 256;
int[] output = new int[data.Length];

// int 为 32 位,每轮处理 8 位,固定执行四轮。
for (int shift = 0; shift < 32; shift += 8)
{
int[] counts = new int[Base];
foreach (int value in data)
{
uint key = unchecked((uint)value) ^ 0x80000000u;
int digit = (int)((key >> shift) & 255);
counts[digit]++;
}
for (int i = 1; i < Base; i++)
counts[i] += counts[i - 1];

for (int i = data.Length - 1; i >= 0; i--)
{
uint key = unchecked((uint)data[i]) ^ 0x80000000u;
int digit = (int)((key >> shift) & 255);
output[--counts[digit]] = data[i];
}
Array.Copy(output, data, data.Length);
}
}

unchecked((uint)value) ^ 0x80000000u 把有符号整数映射成适合无符号比较的键:

原整数 映射后的无符号键
int.MinValue 0
-1 2,147,483,647
0 2,147,483,648
int.MaxValue 4,294,967,295

这样负数会排在非负数之前,也能处理 int.MinValue,不用对它求无法用 int 表示的绝对值。

一般写法为 O(d(n + b)),其中 d 是位数轮次,b 是基数;本实现 d = 4、b = 256 固定,所以对固定宽度整数可概括为线性时间。额外空间 O(n + b),稳定。

基数排序、计数排序使用了键域的信息,不属于只靠两两比较的通用比较排序,不能直接套用比较排序的同一理论下界。

5.14 十种排序对照表

下表针对本文代码,而不是同名算法的所有变体;n 为元素数,R 为整数值域大小,k 为实际桶数,d 为位数轮次,b 为基数。

这些复杂度结合参考资料及本实现中的循环、分区、递归和辅助数组推导,不是运行计时结果;其中平均情况需要满足相应的数据分布假设。

方法 最好时间 平均时间 最坏时间 额外空间 稳定
冒泡,带提前退出 O(n) O(n²) O(n²) O(1) 是
选择,交换最小值 O(n²) O(n²) O(n²) O(1) 否
插入,移动大于当前键的项 O(n) O(n²) O(n²) O(1) 是
希尔,折半增量 O(n log n) 依增量与分布而定 O(n²) O(1) 否
归并,始终合并 O(n log n) O(n log n) O(n log n) O(n) 是
快速,中间值基准 O(n log n) O(n log n),通常随机输入假设 O(n²) 平均 O(log n),最坏 O(n) 否
堆,允许下沉提前停止 O(n),全相等键等情况 O(n log n) O(n log n) O(1) 否
计数,稳定输出版 O(n + R) O(n + R) O(n + R) O(n + R) 是
桶,桶内插入 O(n + k) 均匀假设下 O(n + n²/k + k) O(n² + k) O(n + k) 是
基数,稳定计数分发 O(d(n + b)) O(d(n + b)) O(d(n + b)) O(n + b) 是

Princeton 的算法教材对多种排序的实现与性质提供了对照资料,但例如其希尔增量与本文不同,阅读时应对应实现条件。Princeton 排序对照

5.15 调用、保留原数组与实际选择

1
2
3
4
5
6
7
// Main 内;需要 Shared/SortingAlgorithms.cs,命名空间为 CSharpBasics。
int[] original = { 7, -2, 7, 0, 4, -1 };
int[] sorted = (int[])original.Clone();
SortingAlgorithms.MergeSort(sorted);

Console.WriteLine(string.Join(", ", original)); // 7, -2, 7, 0, 4, -1
Console.WriteLine(string.Join(", ", sorted)); // -2, -1, 0, 4, 7, 7

想直接使用标准库时:

1
2
3
int[] values = { 3, 1, 2 };
Array.Sort(values);
Console.WriteLine(string.Join(", ", values)); // 1, 2, 3

实际项目通常优先使用经过维护的库,再根据稳定性、类型、性能和数据规模选择方案。Array.Sort 对相等关键字的顺序不提供稳定排序保证;按记录排序时,如果相等键原顺序有意义,需要明确选择稳定方案或加入确定的次级键。Array.Sort

不要只用 {3, 2, 1} 检查排序。至少考虑空数组、单元素、重复值、已排序、逆序、负数,以及整数边界;计数排序还要额外检查值域限制。

5.16 本章练习

  1. 手算 {4, 1, 3, 2} 的第一轮冒泡与第一轮选择。
  2. 解释为什么插入排序遇到相等键时不能随意改成 >= 仍声称稳定。
  3. 比较归并排序与快速排序的辅助空间和最坏时间。
  4. 解释 5 个元素也可能不适合计数排序的原因。
  5. 分别用十个方法处理同一输入,比较结果;保留一份未修改的原数组。

第六章:项目实践——飞行棋

6.1 需求与教学版规则

这个项目把前面的知识组合成可玩的控制台程序:用枚举表达场景与玩家,用数组保存航线和记录,用结构体保存坐标与飞机状态,用函数组织掷骰、前进、攻击和绘制。

项目要求 本版实现
开始场景 显示标题、玩法;Enter 开始,Esc 退出
固定地图 每局生成相同的 40 格顺时针矩形公共航线
双方飞机 A 是玩家,B 是电脑;各一架,最初在各自机库
掷出 6 出击 在机库时只有 6 能起飞,到达各自起点,起飞这回合不额外前进 6 格
起飞后移动 后续掷到几就前进几格
回合输入 一次空格执行玩家一回合,若玩家尚未获胜,再自动执行电脑一回合
掷出 6 后的回合 本教学版不奖励额外回合,仍正常轮换
起点差异 A 从公共格 0 起飞,B 从公共格 20 起飞,均顺时针前进
攻击规则 移动后落到对手所在的普通格,将对方送回机库,需重新掷 6 起飞
安全格 两个起点 a、b 为安全格,同格不发生攻击,显示 *
终点 起飞后累计前进 40 格,进入 F;必须恰好到达,超出则本回合原地不动
结束场景 显示胜负、投骰次数、攻击次数,以及最近骰子点数的排序副本
再玩一局 结束场景按 R 重新开始;Enter 回主菜单;Esc 退出
游戏中返回 Esc 回主菜单,本局不保留

本项目不是四架飞机的完整标准飞行棋。它保留掷骰起飞、固定路线、回合移动、撞机攻击和终点判定,让代码复杂度适合当前课程。

6.2 场景与单次输入的执行顺序


flowchart TD
    A[开始场景] -->|Enter| B[游戏场景]
    B -->|玩家先走| C{"玩家到终点?"}
    C -->|是| D[玩家胜利]
    C -->|否| E[电脑走一回合]
    E --> F{"电脑到终点?"}
    F -->|是| G[电脑胜利]
    F -->|否| B
    D -->|R| B
    G -->|R| B
    B -->|Esc| A
    D -->|Enter| A
    G -->|Enter| A

电脑没有独立的“高速循环”,而是在玩家本次按键之后执行一次回合。玩家已经到达终点时,本轮电脑不能再行动,以免覆盖胜利结果。

6.3 画面:上四分之三与下四分之一

画面固定为 72 列 × 32 行,左上角坐标为 (0, 0)。总高度计算:

1
2
3
const int Height = 32;
const int GameHeight = Height * 3 / 4; // 24
const int LogHeight = Height - GameHeight; // 8
行号,从 0 开始 内容
0 游戏区域上边框
1 双方进度与最近骰子点数
2 状态栏分隔线
3~22 地图、机库、终点与点数统计
23 游戏区域下边框
24 日志区域上边框
25 攻击/回合日志标题
26~30 最近五条记录
31 日志区域下边框

游戏区域含边框和状态栏,共 24 行;下方日志区域共 8 行。每行左右都有边框。

符号 含义
A 玩家飞机
B 电脑飞机
o 普通航线格
a、b 两方起点,也是安全格
* 双方在同一个安全格
F 终点显示位置
-、竖线 航线连接线或边框

航线中的 o、a、b 才是计步格;中间的连接线只是视觉连线,不额外占步数。固定路线共 40 个格子,不是按屏幕字符的每一列逐格移动。

为了避免自动换行和末行滚屏,终端要求至少 73 列 × 33 行。界面使用单列宽的英文字符,中文解释保留在教程中。

6.4 数据设计:每个类型负责什么

数据 类型 用途
scene Scene 枚举 开始、游戏、结束
Side PlayerSide 枚举 区分玩家与电脑
map TileKind[,] 保存固定地图格点和连接线的类别
path Position[] 按前进顺序保存 40 个航线坐标
planes PlaneState[] 保存双方飞机状态
diceCounts int[2, 6] 统计每方掷出 1~6 的次数
diceHistory int[][] 双方各自不同长度的掷骰历史
logs string[] 固定容量为五条的滚动记录
canvas char[,] 这一帧准备输出的字符画布

飞机进度采用一个统一数值:

Steps 状态
-1 在机库,需要 6 起飞
0 已经到达自己的起点
1~39 起飞后的前进进度
40 已到终点,触发结局

同一个公共地图格,不一定对应两方相同的进度。当前公共格编号为:

1
2
3
// 算法片段:plane 是飞机状态,TrackLength 是 40。
int offset = plane.Side == PlayerSide.Human ? 0 : TrackLength / 2;
int index = (offset + plane.Steps) % TrackLength;

例如 A 的进度 5 与 B 的进度 25,都对应公共格 5,因此可能发生撞机。不能直接用 A.Steps == B.Steps 判断双方是否同格。

6.5 固定地图如何生成

路线按以下顺序把坐标放进 path:

  1. 上边从左到右 15 格。
  2. 右边从上往下补 6 格,不重复右上角。
  3. 下边从右往左补 14 格,不重复右下角。
  4. 左边从下往上补 5 格,不重复左右角点。

总数为 15 + 6 + 14 + 5 = 40。相邻坐标之间再填入横线或竖线,最后把公共格 0 与 20 标记成安全起点。

每局重新执行这个函数会得到相同地图;随机的只有骰子,不是航线位置。

6.6 一回合的核心:ref 与 out

核心方法形状为:

1
2
// 声明形状示意,完整方法见 6.9。
// bool TryAdvance(ref PlaneState plane, int dice, out string message)
  • ref plane:修改调用方保存的结构体状态。
  • dice:本次骰子点数,是普通整数参数。
  • out message:无论本轮移动与否,都产生一条说明。
  • 返回 bool:这次是否改变了位置。

机库中没掷出 6 和超出终点,都会返回 false,但本回合的投骰次数与历史仍然要记录。没有移动不等于没有掷骰。

调用时使用 TryAdvance(ref planes[index], ...),直接修改数组元素。写成 PlaneState copy = planes[index] 后只修改 copy,却不写回,就会丢失变化。

6.7 攻击、历史记录与排序

攻击需要同时满足:双方都在公共航线上;公共格编号相同;该格不是 0 或 20。成立时,把防守方 Steps 设为 -1,并增加攻击方的 Captures。


flowchart TD
    A[成功移动] --> B{"到达终点?"}
    B -->|是| C[进入结束场景]
    B -->|否| D{"双方在同一公共格?"}
    D -->|否| E[结束本方回合]
    D -->|是| F{"是否安全起点?"}
    F -->|是| G[同格共存并显示星号]
    F -->|否| H[对手回机库并记录攻击]
    G --> E
    H --> E

掷骰历史的每一行使用 Array.Resize(ref diceHistory[index], ...) 追加。为了练习数组机制,这一版每次追加都扩容,单次复制成本为 O(n);后续可以换成集合降低频繁扩容成本。

结束场景会提取最近最多 16 次点数的副本,使用第五章的插入排序升序展示。原历史仍保持时间顺序,不会为了显示结果而破坏原始记录。

6.8 项目文件与启动方式

配套目录包含两个控制台项目和一个共享代码文件:

目录/文件 作用
BasicExamples/ 第一至五章示例与数组/函数辅助代码
Shared/SortingAlgorithms.cs 十种排序,供示例和游戏共同编译使用
FlightChess/Program.cs 游戏枚举、结构体、流程与绘制
FlightChess/FlightChess.csproj 游戏项目配置,包含共享排序文件

游戏项目文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>12.0</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Include="../Shared/SortingAlgorithms.cs" Link="SortingAlgorithms.cs" />
</ItemGroup>
</Project>

在源码包根目录运行:

1
2
dotnet build FlightChess/FlightChess.csproj
dotnet run --project FlightChess

如果自己建立项目,除了下一节 Program.cs,还要放入第五章的 SortingAlgorithms.cs。两者是一个完整项目的组成部分,不能只复制游戏文件却遗漏其排序依赖。

6.9 完整游戏代码:FlightChess/Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
using System;
using System.IO;
using CSharpBasics;

namespace FlightChess
{
internal enum Scene { Start, Playing, End }
internal enum PlayerSide { Human, Computer }
internal enum TileKind { Empty, Track, Horizontal, Vertical, StartA, StartB }

internal struct Position
{
public int X;
public int Y;

public Position(int x, int y)
{
X = x;
Y = y;
}
}

internal struct PlaneState
{
public PlayerSide Side;
public int Steps; // -1:机库;0~39:公共航线;40:终点。
public int Rolls;
public int Captures;

public PlaneState(PlayerSide side)
{
Side = side;
Steps = -1;
Rolls = 0;
Captures = 0;
}
}

internal class Program
{
private const int Width = 72;
private const int Height = 32;
private const int GameHeight = Height * 3 / 4;
private const int TrackLength = 40;
private static readonly Random random = new Random();
private static readonly Position[] path = new Position[TrackLength];
private static readonly TileKind[,] map = new TileKind[GameHeight, Width];
private static readonly PlaneState[] planes = new PlaneState[2];
private static readonly int[,] diceCounts = new int[2, 6];
private static readonly int[] lastDice = new int[2];
private static readonly int[][] diceHistory =
{
Array.Empty<int>(), Array.Empty<int>()
};
private static readonly string[] logs = { "", "", "", "", "" };
private static Scene scene = Scene.Start;
private static PlayerSide winner;

private static int Main()
{
if (Console.IsInputRedirected || Console.IsOutputRedirected)
{
Console.Error.WriteLine("Run this game in an interactive terminal.");
return 2;
}
try
{
Console.CursorVisible = false;
Console.Clear();
bool running = true;
while (running)
{
if (!WaitForTerminalSize())
break;
Draw();
ConsoleKey key = Console.ReadKey(intercept: true).Key;
switch (scene)
{
case Scene.Start:
if (key == ConsoleKey.Enter)
StartGame();
else if (key == ConsoleKey.Escape)
running = false;
break;
case Scene.Playing:
if (key == ConsoleKey.Spacebar)
PlayRound();
else if (key == ConsoleKey.Escape)
scene = Scene.Start;
break;
case Scene.End:
if (key == ConsoleKey.R)
StartGame();
else if (key == ConsoleKey.Enter)
scene = Scene.Start;
else if (key == ConsoleKey.Escape)
running = false;
break;
}
}
}
catch (IOException ex)
{
Console.Error.WriteLine("Terminal I/O error: " + ex.Message);
return 1;
}
catch (ArgumentOutOfRangeException)
{
Console.Error.WriteLine("Terminal resized during redraw. Resize and restart.");
return 1;
}
finally
{
Console.CursorVisible = true;
Console.ResetColor();
Console.WriteLine();
}
return 0;
}

private static bool WaitForTerminalSize()
{
while (Console.WindowWidth < Width + 1 || Console.WindowHeight < Height + 1)
{
Console.Clear();
Console.WriteLine("Terminal must be at least 73 columns x 33 rows.");
Console.WriteLine("Resize, then press a key. ESC: quit.");
if (Console.ReadKey(intercept: true).Key == ConsoleKey.Escape)
return false;
Console.Clear();
}
return true;
}

private static void StartGame()
{
BuildFixedMap();
planes[0] = new PlaneState(PlayerSide.Human);
planes[1] = new PlaneState(PlayerSide.Computer);
Array.Clear(diceCounts, 0, diceCounts.Length);
Array.Clear(lastDice, 0, lastDice.Length);
diceHistory[0] = Array.Empty<int>();
diceHistory[1] = Array.Empty<int>();
Array.Fill(logs, "");
winner = PlayerSide.Human;
scene = Scene.Playing;
AddLog("A is you. B is the computer. Roll 6 to leave the hangar.",
"SPACE: your roll, then B rolls automatically. ESC: menu.");
}

private static void BuildFixedMap()
{
Array.Clear(map, 0, map.Length);
int index = 0;
for (int column = 0; column < 15; column++)
path[index++] = new Position(8 + column * 4, 6);
for (int row = 1; row < 7; row++)
path[index++] = new Position(64, 6 + row * 2);
for (int column = 13; column >= 0; column--)
path[index++] = new Position(8 + column * 4, 18);
for (int row = 5; row >= 1; row--)
path[index++] = new Position(8, 6 + row * 2);

for (int i = 0; i < path.Length; i++)
{
Position current = path[i];
Position next = path[(i + 1) % path.Length];
map[current.Y, current.X] = TileKind.Track;
int dx = Math.Sign(next.X - current.X);
int dy = Math.Sign(next.Y - current.Y);
int x = current.X + dx;
int y = current.Y + dy;
while (x != next.X || y != next.Y)
{
map[y, x] = dx == 0 ? TileKind.Vertical : TileKind.Horizontal;
x += dx;
y += dy;
}
}
map[path[0].Y, path[0].X] = TileKind.StartA;
map[path[20].Y, path[20].X] = TileKind.StartB;
}

private static void PlayRound()
{
TakeTurn(PlayerSide.Human, random.Next(1, 7));
// 玩家已获胜时,电脑不能再走一回合覆盖结局。
if (scene == Scene.Playing)
TakeTurn(PlayerSide.Computer, random.Next(1, 7));
}

private static void TakeTurn(PlayerSide side, int dice)
{
int index = (int)side;
lastDice[index] = dice;
diceCounts[index, dice - 1]++;
int oldLength = diceHistory[index].Length;
Array.Resize(ref diceHistory[index], oldLength + 1);
diceHistory[index][oldLength] = dice;

bool moved = TryAdvance(ref planes[index], dice, out string message);
AddLog(message);
if (!moved)
return;
if (planes[index].Steps == TrackLength)
{
winner = side;
scene = Scene.End;
AddLog($"{Symbol(side)} reached F and wins!");
return;
}
ResolveCapture(ref planes[index], ref planes[1 - index]);
}

private static bool TryAdvance(ref PlaneState plane, int dice, out string message)
{
if (dice < 1 || dice > 6)
throw new ArgumentOutOfRangeException(nameof(dice));
plane.Rolls++;
char name = Symbol(plane.Side);
if (plane.Steps < 0)
{
if (dice != 6)
{
message = $"{name} rolled {dice}: still in hangar; need 6 to launch.";
return false;
}
plane.Steps = 0;
message = $"{name} rolled 6: launched to its start. No extra move.";
return true;
}
if (plane.Steps + dice > TrackLength)
{
message = $"{name} rolled {dice}: overshoots F; stays at {plane.Steps}/40.";
return false;
}
int oldSteps = plane.Steps;
plane.Steps += dice;
message = $"{name} rolled {dice}: moves {oldSteps} -> {plane.Steps}/40.";
return true;
}

private static int TrackIndex(PlaneState plane)
{
int offset = plane.Side == PlayerSide.Human ? 0 : TrackLength / 2;
return (offset + plane.Steps) % TrackLength;
}

private static void ResolveCapture(ref PlaneState attacker, ref PlaneState defender)
{
if (attacker.Steps < 0 || attacker.Steps >= TrackLength ||
defender.Steps < 0 || defender.Steps >= TrackLength)
return;
int index = TrackIndex(attacker);
if (index != TrackIndex(defender))
return;
if (index == 0 || index == TrackLength / 2)
{
AddLog("Both planes share a safe start (*). No capture here.");
return;
}
defender.Steps = -1;
attacker.Captures++;
AddLog($"ATTACK: {Symbol(attacker.Side)} captured {Symbol(defender.Side)}! Back to hangar.");
}

private static char Symbol(PlayerSide side)
{
return side == PlayerSide.Human ? 'A' : 'B';
}

private static Position GetPosition(PlaneState plane)
{
if (plane.Steps < 0)
return new Position(plane.Side == PlayerSide.Human ? 26 : 46, 10);
if (plane.Steps >= TrackLength)
return new Position(36, 15);
return path[TrackIndex(plane)];
}

private static void AddLog(params string[] messages)
{
foreach (string message in messages)
{
for (int i = 0; i < logs.Length - 1; i++)
logs[i] = logs[i + 1];
logs[logs.Length - 1] = message;
}
}

private static string Progress(PlaneState plane)
{
return plane.Steps < 0 ? "HANGAR" : $"{plane.Steps}/40";
}

private static string SortedRecentDice(PlayerSide side, int limit = 16)
{
int[] history = diceHistory[(int)side];
int count = Math.Min(limit, history.Length);
int[] copy = new int[count];
Array.Copy(history, history.Length - count, copy, 0, count);
SortingAlgorithms.InsertionSort(copy);
return string.Join(" ", copy);
}

private static void PutText(char[,] canvas, int x, int y, string text, int maxWidth = Width - 2)
{
for (int i = 0; i < text.Length && i < maxWidth && x + i < Width - 1; i++)
canvas[y, x + i] = text[i];
}

private static void PutText(char[,] canvas, Position position, char symbol)
{
canvas[position.Y, position.X] = symbol;
}

private static void HorizontalBorder(char[,] canvas, int y)
{
for (int x = 0; x < Width; x++)
canvas[y, x] = x == 0 || x == Width - 1 ? '+' : '-';
}

private static void DrawMenu(char[,] canvas)
{
if (scene == Scene.Start)
{
PutText(canvas, 22, 4, "FLIGHT CHESS - C# BASICS");
PutText(canvas, 4, 8, "You control A; the computer controls B. One plane each.");
PutText(canvas, 4, 10, "Roll 6 to launch. Later rolls move that many spaces.");
PutText(canvas, 4, 12, "Go clockwise for 40 spaces. An exact roll reaches F.");
PutText(canvas, 4, 14, "Land on the opponent to send it back to its hangar.");
PutText(canvas, 4, 16, "Starts a/b are safe. A roll of 6 gives no extra turn.");
PutText(canvas, 4, 19, "ENTER: start ESC: quit");
PutText(canvas, 4, 22, "During play: SPACE rolls A, then B. ESC returns to menu.");
}
else
{
PutText(canvas, 27, 4, winner == PlayerSide.Human ? "YOU WIN!" : "COMPUTER WINS");
PutText(canvas, 4, 8, $"A rolls: {planes[0].Rolls} captures: {planes[0].Captures}");
PutText(canvas, 4, 10, $"B rolls: {planes[1].Rolls} captures: {planes[1].Captures}");
PutText(canvas, 4, 13, "Recent dice (up to 16 per side), sorted:");
PutText(canvas, 4, 15, "A: " + SortedRecentDice(PlayerSide.Human));
PutText(canvas, 4, 17, "B: " + SortedRecentDice(PlayerSide.Computer));
PutText(canvas, 4, 21, "R: play again ENTER: main menu ESC: quit");
PutText(canvas, 4, 25, logs[logs.Length - 1]);
}
}

private static void DrawGame(char[,] canvas)
{
HorizontalBorder(canvas, 2);
HorizontalBorder(canvas, GameHeight - 1);
HorizontalBorder(canvas, GameHeight);
PutText(canvas, 1, 1, $" A:{Progress(planes[0])} B:{Progress(planes[1])} Dice A:{lastDice[0]} B:{lastDice[1]}");
PutText(canvas, 1, 3, " Clockwise loop: 40 spaces. Starts a/b are safe; *=both.");
PutText(canvas, 1, 4, " SPACE: roll both turns. Need 6 to launch; exact finish.");

for (int y = 3; y < GameHeight - 1; y++)
{
for (int x = 1; x < Width - 1; x++)
{
switch (map[y, x])
{
case TileKind.Track: canvas[y, x] = 'o'; break;
case TileKind.Horizontal: canvas[y, x] = '-'; break;
case TileKind.Vertical: canvas[y, x] = '|'; break;
case TileKind.StartA: canvas[y, x] = 'a'; break;
case TileKind.StartB: canvas[y, x] = 'b'; break;
}
}
}
PutText(canvas, 22, 9, "HANGARS");
PutText(canvas, 22, 11, "A parking");
PutText(canvas, 42, 11, "B parking");
PutText(canvas, 32, 14, "FINISH");
PutText(canvas, new Position(36, 15), 'F');
Position first = GetPosition(planes[0]);
Position second = GetPosition(planes[1]);
if (first.X == second.X && first.Y == second.Y)
PutText(canvas, first, '*');
else
{
PutText(canvas, first, 'A');
PutText(canvas, second, 'B');
}
PutText(canvas, 1, 20, $" A dice counts 1..6: {CountsText(0)}");
PutText(canvas, 1, 21, $" B dice counts 1..6: {CountsText(1)}");
PutText(canvas, 1, 25, " ATTACK / TURN LOG (latest 5) | SPACE: roll | ESC: menu");
for (int i = 0; i < logs.Length; i++)
PutText(canvas, 1, GameHeight + 2 + i, logs[i]);
}

private static string CountsText(int player)
{
string text = "";
for (int face = 0; face < 6; face++)
text += (face == 0 ? "" : " ") + diceCounts[player, face];
return text;
}

private static char[,] BuildCanvas()
{
char[,] canvas = new char[Height, Width];
for (int y = 0; y < Height; y++)
for (int x = 0; x < Width; x++)
canvas[y, x] = x == 0 || x == Width - 1 ? '|' : ' ';
HorizontalBorder(canvas, 0);
HorizontalBorder(canvas, Height - 1);
if (scene == Scene.Playing)
DrawGame(canvas);
else
DrawMenu(canvas);
return canvas;
}

private static void Draw()
{
char[,] canvas = BuildCanvas();
string frame = "";
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
frame += canvas[y, x];
if (y < Height - 1)
frame += Environment.NewLine;
}
Console.SetCursorPosition(0, 0);
Console.Write(frame);
}
}
}

6.10 代码阅读路线

方法 主要职责 对应知识点
Main 主循环、场景切换、按键输入与终端恢复 枚举、循环、分支、异常捕获
StartGame 重置飞机、统计、记录与地图 数组、结构体构造、默认值
BuildFixedMap 创建航线坐标与二维地图 一维/二维数组、嵌套循环
PlayRound 玩家一回合,然后电脑一回合 函数调用、条件判断
TakeTurn 记录骰子,调用移动,再处理终点或攻击 交错数组、二维统计数组、函数分解
TryAdvance 起飞、前进、超出终点判定 ref、out、结构体、布尔返回值
TrackIndex 将双方各自进度转换成公共格编号 取余、枚举判断
ResolveCapture 同格攻击与安全格判定 结构体引用传参、逻辑条件
AddLog 移动旧日志并追加多条新日志 数组、循环、params
SortedRecentDice 复制最近点数并排序 默认参数、数组复制、插入排序
两个 PutText 按坐标或位置结构体写入画布 函数重载
BuildCanvas、Draw 生成并输出 72×32 字符画面 二维数组、字符串、控制台定位

先看“每个字段保存什么”,再读 TryAdvance,最后读绘制代码。不要为了理解起飞规则,先把整套字符画布循环全部背下来。

6.11 游玩步骤

  1. 打开终端,调整到至少 73 列 × 33 行,运行游戏。
  2. 在开始界面按 Enter,A 与 B 都在各自机库。
  3. 按空格掷骰。A 若不是 6,继续留在机库;之后电脑自动掷一次。
  4. 某方掷出 6 后,它会出现在自己的 a 或 b 起点,进度为 0。
  5. 后续空格让双方轮流按点数前进。下方记录会说明本轮动作。
  6. 落在对方所在普通格时,将其送回机库;安全起点同格不攻击。
  7. 接近 40 时,如果点数太大则原地不动;恰好到达的那方获胜。
  8. 查看结束界面的投骰与攻击统计,按 R 再玩,或 Enter 回菜单。

只需空格即可完成游戏,不用 WASD 直接移动棋子。这样移动距离由骰子和固定航线决定。

6.12 验收清单与扩展练习

检查项 应有行为
每次新开局 相同固定航线;双方回机库;统计与历史清零
机库掷 1~5 记录掷骰,但不移动
机库掷 6 起飞到自己起点,进度为 0
已起飞后掷 6 前进 6 格,不额外再掷一次
玩家走完但未获胜 电脑恰好行动一次
玩家先到终点 直接结束,电脑不再行动
普通格同格 被攻击方回机库,攻击次数增加
安全起点同格 不攻击,并显示 *
超出 40 原地不动,仍记录本次点数
恰好 40 切到对应胜负结束界面
查看结束排序 只排序最近记录的副本,原历史顺序不变
日志区域 固定在下方 8 行,最多五条记录
重新开始 飞机、胜负、投骰、攻击、历史全部复位

可按以下顺序继续练习:

  • 给机库起飞规则增加难度选项,例如允许 5 或 6 起飞。
  • 给固定地图加入前进、后退或暂停格,用枚举扩展地图类型。
  • 增加每方多架飞机,使用数组保存,并设计如何选择出动对象。
  • 用集合替换每次扩容的历史数组,比较接口与成本。
  • 给战绩增加“对局轮数、攻击次数”等记录,用结构体保存后排序。
  • 尝试改为双方本地玩家轮流掷骰,复用现有的移动与攻击函数。

附录 A:练习与参考答案

A.1 场景枚举

已定义第一章 GameScene 后,在 Main 中运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
GameScene scene = GameScene.Playing;
switch (scene)
{
case GameScene.Start:
Console.WriteLine("开始界面");
break;
case GameScene.Playing:
Console.WriteLine("正在游戏");
break;
case GameScene.End:
Console.WriteLine("结束界面");
break;
}

A.2 数组增删改查

操作 数组结果
初始 10, 20, 30
追加 40 10, 20, 30, 40
在索引 1 插入 15 10, 15, 20, 30, 40
删除索引 2 10, 15, 30, 40
把索引 0 改为 99 99, 15, 30, 40
查询 30 索引为 2

配套 ArrayTools.cs 的完整实现如下。方法放在类内,调用时按第三章示例接收返回的新数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System;

namespace CSharpBasics
{
public static class ArrayTools
{
public static int[] Append(int[] source, int value)
{
ArgumentNullException.ThrowIfNull(source);
return Insert(source, source.Length, value);
}

public static int[] Insert(int[] source, int index, int value)
{
ArgumentNullException.ThrowIfNull(source);
if (index < 0 || index > source.Length)
throw new ArgumentOutOfRangeException(nameof(index));
int[] result = new int[source.Length + 1];
for (int i = 0; i < index; i++)
result[i] = source[i];
result[index] = value;
for (int i = index; i < source.Length; i++)
result[i + 1] = source[i];
return result;
}

public static int[] RemoveAt(int[] source, int index)
{
ArgumentNullException.ThrowIfNull(source);
if (index < 0 || index >= source.Length)
throw new ArgumentOutOfRangeException(nameof(index));
int[] result = new int[source.Length - 1];
for (int i = 0; i < index; i++)
result[i] = source[i];
for (int i = index + 1; i < source.Length; i++)
result[i - 1] = source[i];
return result;
}

public static int IndexOf(int[] source, int value)
{
ArgumentNullException.ThrowIfNull(source);
for (int i = 0; i < source.Length; i++)
if (source[i] == value)
return i;
return -1;
}

public static bool TryUpdate(int[] source, int index, int value)
{
ArgumentNullException.ThrowIfNull(source);
if (index < 0 || index >= source.Length)
return false;
source[index] = value;
return true;
}
}
}

A.3 二维与交错数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int[,] map = new int[3, 4];
for (int y = 0; y < map.GetLength(0); y++)
{
for (int x = 0; x < map.GetLength(1); x++)
{
map[y, x] = y * 10 + x;
}
}
Console.WriteLine(map[2, 3]); // 23

int[][] groups =
{
new int[2],
Array.Empty<int>(),
new int[4]
};
foreach (int[] group in groups)
{
Console.WriteLine($"组内数量:{group.Length}");
foreach (int item in group)
{
Console.WriteLine(item);
}
}

第二组为空数组,循环自然执行零次;它不是 null,所以仍然可以安全读取 .Length。

A.4 复制行为

代码行为 结果与原因
int[] b = a; b[0] = 9; a[0] 也为 9,因为两者引用同一个数组
Array.Resize(ref a, ...) 且新旧长度不同 a 指向新数组,b 仍指向旧数组
对 int[][] 只克隆外层 内层数组仍共享,修改内层元素会互相影响
修改结构体副本的纯数值字段 原结构体不受影响
修改结构体副本中引用字段指向的数组 原结构体可能观察到相同数组变化

值类型与引用类型描述保存与复制的语义,存放位置还受到字段、数组元素和装箱等上下文影响。

A.5 函数调用练习

在配套 BasicExamples 项目中,可以直接使用已整理的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int x = 10;
int y = 20;
FunctionExamples.Swap(ref x, ref y);
Console.WriteLine($"{x}, {y}"); // 20, 10

bool success = FunctionExamples.TryGetMinMax(new int[] { 8, 2, 5 }, out int min, out int max);
Console.WriteLine($"{success}, {min}, {max}"); // True, 2, 8

Console.WriteLine(FunctionExamples.Sum()); // 0
Console.WriteLine(FunctionExamples.Sum(1, 2, 3)); // 6
Console.WriteLine(FunctionExamples.Describe("勇者")); // 勇者:等级 1
Console.WriteLine(FunctionExamples.Add(1, 2)); // 3
Console.WriteLine(FunctionExamples.Add(1.5, 2.5)); // 4
Console.WriteLine(FunctionExamples.Factorial(5)); // 120

ref 的实参已经初始化;out 的实参可以直接在调用时声明。阶乘终止条件是 n <= 1,每次递归把 n 减一,入口限制在 0~20。

A.6 飞机结构体

1
2
3
4
5
6
7
8
9
10
11
internal struct PracticePlane
{
public int PlayerId;
public int Steps;

public PracticePlane(int playerId)
{
PlayerId = playerId;
Steps = -1;
}
}
1
2
3
4
5
PracticePlane original = new PracticePlane(0);
PracticePlane copy = original;
copy.Steps = 6;
Console.WriteLine(original.Steps); // -1
Console.WriteLine(copy.Steps); // 6

new PracticePlane[2] 的元素默认 Steps = 0,不会自动调用带参构造函数。要表达“全部在机库”,需要逐项执行构造初始化。

A.7 排序练习

问题 答案
{4,1,3,2} 第一轮冒泡 1,3,2,4
{4,1,3,2} 第一轮选择 1,4,3,2
插入判断改为 >= 会怎样 相等键也会向右移动,新键可能插到旧的同键项之前,失去稳定性
归并和快速的最坏时间 本文归并为 O(n log n),快速为 O(n²)
归并和快速的额外空间 本文归并为 O(n);快速递归栈平均 O(log n)、最坏 O(n)
5 个元素也可能不适合计数排序吗 可以,例如同时含 int.MinValue、int.MaxValue,值域远大于元素数量
为什么排序前克隆数组 保留原记录的时间顺序或原始输入,避免比较不同算法时输入已被上一个算法改动

A.8 游戏规则判断

  1. 飞机在机库,掷出 5:本回合不移动,但投骰次数增加。
  2. 飞机在机库,掷出 6:起飞到自己的起点,进度设为 0。
  3. 飞机进度 38,掷出 3:超出终点,本回合原地不动。
  4. 飞机进度 38,掷出 2:恰好到达 40,触发胜利。
  5. A 进度 5、B 进度 25:公共格相同,若是在成功落子之后判定,会发生普通格攻击。
  6. A 进度 20、B 进度 0:同在公共格 20,但它是安全起点,不攻击。

附录 B:运行、排查与验证

B.1 新建练习项目的最小外壳

如果不使用源码包,可以先新建控制台项目:

1
2
dotnet new console -n CSharpBasics --framework net10.0
cd CSharpBasics

然后使用下面的 Program.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;

internal class Program
{
private static void Main()
{
// 放本章的调用片段。
}

// 在这里放方法声明。
}

// 在这里放枚举或结构体等类型声明。

不要把完整程序再套进另一个 Main。如果使用源码包,保持已有文件结构即可;不要把源码包里所有文件重新粘贴进一个文件。

B.2 常见问题

现象 排查方向
SDK 不支持 net10.0 安装 .NET 10 SDK;只安装运行时不能编译
找不到 dotnet 安装后重新打开终端,检查系统路径
IndexOutOfRangeException 检查是否把 Length 当作合法最后下标,或二维行列写反
交错数组的 NullReferenceException 只创建了外层,未初始化内层数组
增删数组后长度没变 是否丢弃了方法返回的新数组;是否仍在观察旧别名
out 参数未赋值 检查每条正常返回路径是否都赋值
ref 实参报错 调用前是否赋值;是否传了字面量、表达式或普通属性
“同名同参方法已定义” 是否只改了返回值、参数名、默认值,或只改了 ref/out
结构体修改后恢复原状 是否修改了副本而没有写回,或漏写 ref
new Health[10] 全是零 数组元素取默认值,不自动逐项调用显式无参构造函数
计数排序拒绝输入 取值范围超过一百万,改选比较排序或本章其他适用算法
找不到 CSharpBasics.SortingAlgorithms 是否保留 Shared 目录及 csproj 中的共享文件包含项
游戏总在机库 需要掷到 6;不代表程序卡死
接近终点却不移动 本版要求恰好到达,点数过大则等待下一轮
游戏画面错位 使用等宽字体,终端至少 73×33,避免普通输出面板
游戏按 WASD 不走 此项目按骰子点数前进,只需空格;WASD 不控制棋子
修改历史排序后回合记录乱了 是否直接排序了历史原数组;应复制后再排序

B.3 验证记录

本教程配套程序完成以下验证:

  • 示例项目与游戏项目在 .NET 10 环境编译通过。
  • 文档内 99 段 C# 代码在各自需要的类型、方法或调用上下文中编译检查通过。
  • 十种排序与标准库升序结果对照,包含短数组排列穷举、随机数组、负数、重复值和整数边界,共通过 15,764 个有效排序用例;超大计数值域另行验证为主动拒绝。
  • 数组增删改查、ref 交换、out 各返回路径、params、递归范围和结构体默认值进行了程序化检查。
  • 游戏完成 200 局可重复的随机序列模拟,覆盖双方胜利、普通格攻击、安全格共存、起飞、超出终点、精确到终点与状态复位。
  • 交互终端完成实际按键流程:菜单、掷骰、起飞、前进、胜利、记录展示、重开、返回与退出,并检查小窗口调整后的恢复。

这些检查验证了规则和当前终端流程,不表示所有操作系统、字体、窗口管理器的显示行为完全相同。游戏的边界检查与固定英文列宽用于减少终端差异。

附录 C:官方参考资料

语言与 API 规则优先参考 Microsoft Learn;排序概念与典型性质参考 NIST 和 Princeton 教材资料。示例、练习和教学游戏均为本文的配套实现。

编号 资料 对应内容
R01 .NET 安装指南 SDK 与开发环境
R02 枚举类型 默认底层类型、转换、标志枚举
R03 数组 一维、二维、交错数组与长度
R04 Array.Resize 扩容、复制与引用更新
R05 值类型 值复制与包含引用字段的情况
R06 内置引用类型 object、string 与引用语义
R07 Array.Clone 浅拷贝
R08 装箱与拆箱 object 与值类型转换
R09 方法参数和修饰符 按值传递、ref、out、params
R10 方法 声明、调用与重载
R11 具名和可选参数 默认值、具名调用
R12 结构体类型 构造、default、数组元素、readonly
R13 访问修饰符 public、private、internal
R14 NIST:Shell sort 增量排序与序列影响
R15 NIST:counting sort 计数与累计位置
R16 NIST:bucket sort 分桶、桶内排序与组合
R17 NIST:radix sort 逐位稳定分发
R18 Princeton:算法对照 典型排序性质与实现条件
R19 Array.Sort 标准库排序及稳定性说明

C# 学习系列: C# 入门 → C# 基础 → C# 进阶 → C# 高级