AI 摘要

C#基础教程系统梳理枚举、一维与二维数组、交错数组、值类型与引用类型、string、函数、ref与out、结构体及冒泡与选择排序,通过代码示例讲解各知识点用法。

整理框架

本文整体框架如下:

枚举
→ 一维数组
→ 二维数组
→ 交错数组
→ 值类型和引用类型
→ string
→ 函数
→ ref 和 out
→ params 与参数默认值
→ 函数重载
→ 递归
→ 结构体
→ 冒泡排序
→ 选择排序

Lesson 1:枚举

什么是枚举?

枚举【1】是一个被命名的整型常量集合。

当某个变量只能在几个固定选项中取值时,可以使用枚举表示,例如:

  • 玩家状态【2】
  • 怪物状态【3】
  • 角色职业【4】
  • 游戏难度【5】
  • QQ 在线状态

如果直接用整数表示玩家状态:

int playerState = 1;

只看到数字 1,很难知道它代表待机、行走还是攻击。

使用枚举可以让代码含义更加清晰:

enum PlayerState
{
    Idle,
    Walk,
    Run,
    Jump
}

PlayerState state = PlayerState.Walk;

这里需要区分三个概念:

PlayerState       枚举类型
state             枚举变量
PlayerState.Walk  枚举值

枚举可以声明在 namespaceclassstruct 中,但不能声明在函数【6】内部。

枚举的整数值

枚举成员默认从 0 开始,后面的成员依次加 1

enum PlayerState
{
    Idle,   // 0
    Walk,   // 1
    Run,    // 2
    Jump    // 3
}

也可以主动指定数值:

enum PlayerState
{
    Idle = 0,
    Walk = 10,
    Run = 20,
    Jump = 30
}

虽然枚举在底层与整数有关,但实际编程时应优先使用枚举成员名称,避免在程序中到处使用含义不明确的数字。

枚举与 switch

枚举非常适合配合 switch 表现状态逻辑:

PlayerState state = PlayerState.Run;

switch (state)
{
    case PlayerState.Idle:
        Console.WriteLine("玩家正在待机");
        break;

    case PlayerState.Walk:
        Console.WriteLine("玩家正在行走");
        break;

    case PlayerState.Run:
        Console.WriteLine("玩家正在奔跑");
        break;

    case PlayerState.Jump:
        Console.WriteLine("玩家正在跳跃");
        break;
}

同样的方式也可以用于处理 QQ 在线状态、咖啡规格【7】、角色职业等固定选项。

枚举类型转换

枚举可以和 int 相互转换:

PlayerState state = PlayerState.Run;

int number = (int)state;
PlayerState newState = (PlayerState)number;

枚举也可以转换成字符串:

string stateName = state.ToString();

将字符串转换成枚举时,可以使用 Enum.Parse

string input = "Walk";

PlayerState state =
    (PlayerState)Enum.Parse(typeof(PlayerState), input);

如果字符串无法匹配对应的枚举成员,程序会产生错误,因此使用时要保证字符串内容正确。


Lesson 2:一维数组

什么是数组

数组【8】用于存储一组相同类型的数据。

例如,使用一个数组存储多名学生的成绩:

int[] scores = { 80, 90, 75, 88, 96 };

数组中的所有元素必须是同一种类型。

C# 中常见的数组包括:

  • 一维数组【9】
  • 二维数组【10】
  • 交错数组【11】

一维数组的声明

一维数组有多种声明方式:

int[] arr1;
arr1 = new int[5];

int[] arr2 = new int[5];

int[] arr3 = new int[] { 1, 2, 3, 4, 5 };

int[] arr4 = { 1, 2, 3, 4, 5 };

使用 new int[5] 创建数组时,数组中会有 5 个整数元素,每个元素的初始值都是 0

数组长度和索引

使用 Length 获取数组长度:

int[] arr = { 10, 20, 30, 40 };

Console.WriteLine(arr.Length);

数组索引【12】0 开始:

索引元素
010
120
230
340

数组的最大索引为:

arr.Length - 1

访问数组元素时使用中括号:

Console.WriteLine(arr[0]);

arr[1] = 100;

如果访问不存在的索引,会产生数组越界【13】错误。

遍历数组

数组通常使用 for 循环【14】遍历:

for (int i = 0; i < arr.Length; i++)
{
    Console.WriteLine(arr[i]);
}

这里的 i 既表示循环次数,也表示当前元素的索引。

例如,创建一个长度为 100 的数组,并让元素值与索引相同:

int[] numbers = new int[100];

for (int i = 0; i < numbers.Length; i++)
{
    numbers[i] = i;
}

这段代码把数组声明、数组长度、索引和遍历结合到了一起。

数组数据统计

数组经常配合循环完成最大值、最小值、总和与平均值计算:

int[] scores = { 78, 92, 65, 88, 96 };

int max = scores[0];
int min = scores[0];
int sum = 0;

for (int i = 0; i < scores.Length; i++)
{
    if (scores[i] > max)
    {
        max = scores[i];
    }

    if (scores[i] < min)
    {
        min = scores[i];
    }

    sum += scores[i];
}

float average = (float)sum / scores.Length;

Console.WriteLine("最高分:" + max);
Console.WriteLine("最低分:" + min);
Console.WriteLine("总分:" + sum);
Console.WriteLine("平均分:" + average);

最大值和最小值通常使用数组第一个元素作为初始值:

int max = scores[0];
int min = scores[0];

计算平均值时,将 sum 转换成 float,可以避免整数除法丢失小数部分。

手动反转数组

反转数组时,可以交换【15】数组前后对应位置:

int[] arr = { 1, 2, 3, 4, 5 };

for (int i = 0; i < arr.Length / 2; i++)
{
    int temp = arr[i];

    arr[i] = arr[arr.Length - 1 - i];
    arr[arr.Length - 1 - i] = temp;
}

数组反转后变成:

5 4 3 2 1

因为每次会同时处理前后两个元素,所以循环到数组长度的一半即可。

数组增加元素

数组创建以后,长度不能直接改变。

如果需要增加元素,就要创建一个更大的数组,再把旧数据复制过去:

int[] oldArr = { 10, 20, 30 };
int[] newArr = new int[oldArr.Length + 1];

for (int i = 0; i < oldArr.Length; i++)
{
    newArr[i] = oldArr[i];
}

newArr[newArr.Length - 1] = 40;
oldArr = newArr;

课程把这种处理方式称为数组“搬家”。

数组删除元素

删除元素时,也需要创建一个长度更小的新数组:

int[] oldArr = { 10, 20, 30, 40 };
int deleteIndex = 1;

int[] newArr = new int[oldArr.Length - 1];

for (int i = 0, j = 0; i < oldArr.Length; i++)
{
    if (i == deleteIndex)
    {
        continue;
    }

    newArr[j] = oldArr[i];
    j++;
}

oldArr = newArr;

最终数组内容为:

10 30 40

查找数组元素

可以通过遍历查找指定元素:

int[] arr = { 15, 28, 36, 42 };
int target = 36;
int targetIndex = -1;

for (int i = 0; i < arr.Length; i++)
{
    if (arr[i] == target)
    {
        targetIndex = i;
        break;
    }
}

if (targetIndex == -1)
{
    Console.WriteLine("没有找到");
}
else
{
    Console.WriteLine("元素索引:" + targetIndex);
}

这里使用 -1 表示没有找到目标元素。

在游戏开发中,一维数组可以用于批量存储怪物、玩家、道具或者其他同类型数据。


Lesson 3:二维数组

二维数组的基本概念

二维数组可以理解为由行和列组成的数据表格。

int[,] map = new int[3, 4];

这表示创建了一个 3 行 4 列的二维数组。

二维数组也可以在声明时直接赋值:

int[,] arr =
{
    { 1, 2, 3 },
    { 4, 5, 6 }
};

这个数组有 2 行、3 列。

获取行数和列数

二维数组使用 GetLength() 获取指定维度的长度:

int rowCount = arr.GetLength(0);
int columnCount = arr.GetLength(1);

其中:

GetLength(0)  获取行数
GetLength(1)  获取列数

获取、修改和遍历元素

访问二维数组元素时,需要同时提供行索引和列索引:

Console.WriteLine(arr[0, 1]);

arr[1, 2] = 100;

二维数组通常使用双层循环【16】遍历:

for (int i = 0; i < arr.GetLength(0); i++)
{
    for (int j = 0; j < arr.GetLength(1); j++)
    {
        Console.Write(arr[i, j] + " ");
    }

    Console.WriteLine();
}

其中:

  • 外层循环控制行
  • 内层循环控制列

例如,将 1~10000 依次填入一个 100 × 100 的二维数组:

int[,] numbers = new int[100, 100];
int value = 1;

for (int i = 0; i < numbers.GetLength(0); i++)
{
    for (int j = 0; j < numbers.GetLength(1); j++)
    {
        numbers[i, j] = value;
        value++;
    }
}

二维数组中的位置关系

二维数组练习的重点之一,是通过行列索引判断元素位置。

例如,将主对角线【17】右上方的元素置为 0

int[,] arr =
{
    { 1,  2,  3,  4 },
    { 5,  6,  7,  8 },
    { 9, 10, 11, 12 },
    { 13, 14, 15, 16 }
};

for (int i = 0; i < arr.GetLength(0); i++)
{
    for (int j = i + 1; j < arr.GetLength(1); j++)
    {
        arr[i, j] = 0;
    }
}

处理后的结果为:

1  0  0  0
5  6  0  0
9 10 11  0
13 14 15 16

主对角线元素的特点是行索引与列索引相同:

int sum = 0;

for (int i = 0; i < arr.GetLength(0); i++)
{
    sum += arr[i, i];
}

在二维数组中查找最大值时,可以同时记录元素的行列位置:

int max = arr[0, 0];
int maxRow = 0;
int maxColumn = 0;

for (int i = 0; i < arr.GetLength(0); i++)
{
    for (int j = 0; j < arr.GetLength(1); j++)
    {
        if (arr[i, j] > max)
        {
            max = arr[i, j];
            maxRow = i;
            maxColumn = j;
        }
    }
}

二维数组常用于表示:

  • 矩阵【18】
  • 棋盘【19】
  • 控制台游戏地图【20】
  • 固定行列的表格数据【21】

Lesson 4:交错数组

交错数组与二维数组的区别

交错数组是“数组的数组”。

二维数组的每一行长度相同,而交错数组的每一行可以拥有不同长度:

int[][] arr = new int[3][];

arr[0] = new int[2];
arr[1] = new int[4];
arr[2] = new int[3];

也可以直接初始化:

int[][] arr =
{
    new int[] { 1, 2 },
    new int[] { 3, 4, 5, 6 },
    new int[] { 7, 8, 9 }
};

获取交错数组的行数:

Console.WriteLine(arr.Length);

获取某一行的长度:

Console.WriteLine(arr[1].Length);

访问元素时使用两个中括号:

Console.WriteLine(arr[1][2]);

arr[1][2] = 100;

遍历时,每一行都要使用自己的长度:

for (int i = 0; i < arr.Length; i++)
{
    for (int j = 0; j < arr[i].Length; j++)
    {
        Console.Write(arr[i][j] + " ");
    }

    Console.WriteLine();
}

交错数组在这套课程中属于非重点知识,理解它与二维数组的区别,并掌握基本访问方式即可。


Lesson 5:值类型和引用类型

值类型的赋值特点

课程使用整数变量演示值类型【22】

int a = 10;
int b = a;

b = 20;

Console.WriteLine(a);
Console.WriteLine(b);

输出:

10
20

执行:

int b = a;

相当于把 a 中的值复制一份交给 b

之后修改 b,不会影响 a,可以总结为:

它变,我不变。

引用类型的赋值特点

课程使用数组演示引用类型【23】

int[] arr1 = { 1, 2, 3 };
int[] arr2 = arr1;

arr2[0] = 100;

Console.WriteLine(arr1[0]);
Console.WriteLine(arr2[0]);

输出:

100
100

这里并没有复制一份完整数组,而是让 arr1arr2 指向同一份数组数据。

因此,通过其中一个变量修改数组,另一个变量也能观察到变化:

它变,我也变。

重新创建引用类型数据

如果重新使用 new 创建数组,变量就会指向一份新数据:

int[] arr1 = { 1, 2, 3 };
int[] arr2 = arr1;

arr2 = new int[] { 10, 20, 30 };
arr2[0] = 100;

Console.WriteLine(arr1[0]);
Console.WriteLine(arr2[0]);

输出:

1
100

重新创建数组以后,arr1arr2 不再指向同一份数据。

本阶段需要掌握的核心结论是:

类型赋值特点
值类型复制一份数据
引用类型可能指向同一份数据
重新 new创建新数据并改变引用

课程还提到了栈空间【24】、堆空间【25】new。现阶段重点是理解赋值和修改的结果,不需要过度深入内存实现细节。


Lesson 6:特殊引用类型 string

string 的特殊表现

string 属于引用类型,但它的赋值表现比较特殊:

string str1 = "123";
string str2 = str1;

str2 = "321";

Console.WriteLine(str1);
Console.WriteLine(str2);

输出:

123
321

看起来很像值类型的“它变,我不变”。

字符串不可变

字符串具有不可变的特点。

执行:

str2 = "321";

并不是把原来的 "123" 修改成 "321",而是让 str2 对应一个新的字符串。

因此:

str1 仍然对应 "123"
str2 重新对应 "321"

下面的代码看起来是在不断修改字符串:

string result = "";

for (int i = 0; i < 10; i++)
{
    result += i;
}

实际上,每次拼接通常都会产生新的字符串结果。

现阶段只需要掌握:

  • string 是引用类型
  • 字符串具有不可变特点
  • 对字符串重新赋值时,原字符串本身不会被直接修改
  • 频繁拼接字符串可能产生较多新数据

字符串优化方案属于后续课程内容,本阶段不展开。


Lesson 7:函数

函数的基本概念

函数本质上是一块具有名称的代码块。

它的主要作用包括:

  1. 封装代码
  2. 提高代码复用率
  3. 抽象程序行为

例如:

static void Welcome()
{
    Console.WriteLine("欢迎学习 C#");
}

调用函数:

Welcome();

本阶段介绍的函数写在 classstruct 中。由于还没有正式学习类,当前程序中的函数暂时配合 static 使用。

函数基本结构

static 返回类型 函数名(参数)
{
    // 函数逻辑

    return 返回值;
}

例如:

static int Add(int a, int b)
{
    return a + b;
}

调用函数并接收返回值【26】

int result = Add(10, 20);
Console.WriteLine(result);

四种基本函数形式

根据是否有参数和返回值,函数可以分为四种形式:

// 无参数、无返回值
static void SayHello()
{
    Console.WriteLine("Hello");
}

// 有参数、无返回值
static void PrintNumber(int number)
{
    Console.WriteLine(number);
}

// 无参数、有返回值
static int GetNumber()
{
    return 100;
}

// 有参数、有返回值
static int GetMax(int a, int b)
{
    return a > b ? a : b;
}

void 表示函数不返回数据。

return 的作用

return 不仅可以返回函数结果,还可以提前结束函数:

static void CheckAge(int age)
{
    if (age < 0)
    {
        Console.WriteLine("年龄不能小于 0");
        return;
    }

    Console.WriteLine("年龄:" + age);
}

当年龄小于 0 时,函数执行到 return 就会立即结束。

使用函数封装数组统计

此前在数组中编写的统计逻辑,可以进一步封装成函数:

static float[] GetArrayInfo(int[] arr)
{
    int sum = 0;
    int max = arr[0];
    int min = arr[0];

    for (int i = 0; i < arr.Length; i++)
    {
        sum += arr[i];

        if (arr[i] > max)
        {
            max = arr[i];
        }

        if (arr[i] < min)
        {
            min = arr[i];
        }
    }

    float average = (float)sum / arr.Length;

    return new float[] { sum, max, min, average };
}

通过数组,可以暂时让一个函数返回多个同类型结果:

int[] numbers = { 12, 35, 18, 46 };

float[] result = GetArrayInfo(numbers);

Console.WriteLine("总和:" + result[0]);
Console.WriteLine("最大值:" + result[1]);
Console.WriteLine("最小值:" + result[2]);
Console.WriteLine("平均值:" + result[3]);

使用函数判断质数和闰年

函数适合封装能够重复使用的判断逻辑。

判断质数:

static bool IsPrime(int number)
{
    if (number < 2)
    {
        return false;
    }

    for (int i = 2; i < number; i++)
    {
        if (number % i == 0)
        {
            return false;
        }
    }

    return true;
}

判断闰年:

static bool IsLeapYear(int year)
{
    return year % 400 == 0 ||
           year % 4 == 0 && year % 100 != 0;
}

函数把判断过程封装起来,调用者只需要关心返回结果。


Lesson 8:ref 和 out

普通参数为什么不能直接修改外部变量

普通值类型参数传入函数时,函数得到的是数据副本:

static void ChangeNumber(int number)
{
    number = 100;
}

int value = 10;
ChangeNumber(value);

Console.WriteLine(value);

输出仍然是:

10

如果希望函数内部的修改影响外部变量,可以使用 refout

ref 参数

static void ChangeNumber(ref int number)
{
    number = 100;
}

int value = 10;
ChangeNumber(ref value);

Console.WriteLine(value);

使用 ref 时:

  • 定义参数时需要写 ref
  • 调用函数时也需要写 ref
  • 传入函数前必须初始化
  • 函数内部可以修改,也可以不修改

out 参数

static void GetNumber(out int number)
{
    number = 100;
}

int value;
GetNumber(out value);

Console.WriteLine(value);

使用 out 时:

  • 定义参数时需要写 out
  • 调用函数时也需要写 out
  • 传入前可以不初始化
  • 函数内部必须赋值

两者的核心区别如下:

对比项目ref【27】out【28】
调用前是否必须初始化必须不需要
函数内部是否必须赋值不强制必须
是否能修改外部变量可以可以
常见用途修改已有数据返回额外结果

可以简单理解为:

ref:带着已有数据进入函数
out:从函数中领取一份结果

使用 out 返回额外信息

登录函数可以直接返回登录是否成功,再通过 out 返回提示信息:

static bool Login(
    string inputName,
    string inputPassword,
    out string message)
{
    string correctName = "admin";
    string correctPassword = "123456";

    if (inputName != correctName)
    {
        message = "用户名错误";
        return false;
    }

    if (inputPassword != correctPassword)
    {
        message = "密码错误";
        return false;
    }

    message = "登录成功";
    return true;
}

调用:

string message;

bool result = Login(
    "admin",
    "123456",
    out message);

Console.WriteLine(result);
Console.WriteLine(message);

Lesson 9:变长参数与参数默认值

params 变长参数

如果函数需要接收数量不确定的同类型参数,可以使用 params

static int Sum(params int[] numbers)
{
    int sum = 0;

    for (int i = 0; i < numbers.Length; i++)
    {
        sum += numbers[i];
    }

    return sum;
}

调用时可以传入不同数量的整数:

Console.WriteLine(Sum());
Console.WriteLine(Sum(10));
Console.WriteLine(Sum(10, 20, 30));
Console.WriteLine(Sum(1, 2, 3, 4, 5));

params 的使用规则包括:

  1. params 后面必须是数组
  2. 数组可以是任意数据类型
  3. 一个函数最多只能有一个 params
  4. params 必须放在参数列表【29】最后
  5. 参数数量可以为 0

例如,通过 params 计算平均值:

static float GetAverage(params int[] numbers)
{
    if (numbers.Length == 0)
    {
        return 0;
    }

    int sum = 0;

    for (int i = 0; i < numbers.Length; i++)
    {
        sum += numbers[i];
    }

    return (float)sum / numbers.Length;
}

参数默认值

参数默认值【30】也叫可选参数:

static void PrintPlayer(
    string name,
    int level = 1,
    int health = 100)
{
    Console.WriteLine("姓名:" + name);
    Console.WriteLine("等级:" + level);
    Console.WriteLine("生命值:" + health);
}

调用时可以省略可选参数:

PrintPlayer("勇者");
PrintPlayer("勇者", 10);
PrintPlayer("勇者", 10, 500);

没有传入数据时,函数使用默认值;传入数据时,则使用调用者提供的值。

普通参数必须放在可选参数前面:

static void Test(int a, int b = 10, int c = 20)
{
}

Lesson 10:函数重载

函数重载的基本概念

在同一个语句块中,可以声明多个同名函数。

只要参数列表不同,就可以构成函数重载【31】

参数列表不同包括:

  • 参数数量不同
  • 参数类型不同
  • 参数顺序不同

例如,根据参数类型重载【32】比较函数:

static int GetMax(int a, int b)
{
    return a > b ? a : b;
}

static float GetMax(float a, float b)
{
    return a > b ? a : b;
}

static double GetMax(double a, double b)
{
    return a > b ? a : b;
}

调用时,程序会根据传入的数据类型选择对应函数:

Console.WriteLine(GetMax(10, 20));
Console.WriteLine(GetMax(3.5f, 8.2f));
Console.WriteLine(GetMax(7.8, 9.6));

重载的作用是让一组相似功能使用同一个函数名,减少函数名称数量,提高代码可读性。

重载与返回值无关

下面两个函数不能构成重载:

static int Test(int number)
{
    return number;
}

static float Test(int number)
{
    return number;
}

它们只有返回值类型不同,参数列表完全相同,因此会产生冲突。

函数重载判断的是参数列表,而不是返回值类型。

另外,refout 不能作为唯一的重载区别,不能只依靠一个函数使用 ref、另一个函数使用 out 来构成重载。


Lesson 11:递归函数

什么是递归

递归【33】就是函数自己调用自己。

如果函数无条件调用自己:

static void Test()
{
    Test();
}

程序将无法停止,最终产生错误。

正确的递归必须具备三个条件:

  1. 存在结束条件
  2. 控制结束条件的数据不断变化
  3. 最终能够达到结束条件

递归调用过程

下面的函数会递归打印 0~10

static void PrintNumber(int number)
{
    if (number > 10)
    {
        return;
    }

    Console.WriteLine(number);

    PrintNumber(number + 1);
}

调用:

PrintNumber(0);

每次调用时,number 都会加 1,最终达到结束条件。

递归求阶乘

阶乘具有明显的递归关系:

1! = 1
2! = 2 × 1!
3! = 3 × 2!
n! = n × (n - 1)!

代码如下:

static int Factorial(int number)
{
    if (number == 1)
    {
        return 1;
    }

    return number * Factorial(number - 1);
}

调用:

Console.WriteLine(Factorial(5));

结果为:

120

这里:

  • number == 1 是结束条件
  • number - 1 让控制数据发生变化
  • 数据最终会减少到 1

递归的重点不是记住某一道题,而是学会识别结束条件和数据变化规律。


Lesson 12:结构体

什么是结构体

结构体【34】是一种自定义变量类型,是数据和函数的集合。

它可以用于描述:

  • 学生
  • 玩家
  • 怪物
  • 矩形
  • 其他具有一组相关信息的数据

基本语法如下:

struct 自定义结构体名
{
    // 成员变量
    // 构造函数
    // 成员函数
}

例如,使用结构体描述学生:

enum Gender
{
    Male,
    Female
}

struct Student
{
    public string name;
    public Gender gender;
    public int age;
    public string className;
    public string major;
}

结构体可以把原本分散的相关数据组织在一起。

public 与 private

课程介绍了两个访问修饰符【35】

修饰符作用
public外部可以访问
private只能在结构体内部访问

如果没有写访问修饰符,结构体成员默认为 private

struct Player
{
    public string name;
    private int health;
}

外部可以访问 name,但不能直接访问 health

结构体构造函数与 this

构造函数【36】用于方便地创建和初始化结构体数据:

struct Rectangle
{
    public float width;
    public float height;

    public Rectangle(float width, float height)
    {
        this.width = width;
        this.height = height;
    }
}

结构体构造函数具有以下特点:

  • 没有返回值
  • 名称与结构体名称相同
  • 当前课程使用带参数的构造函数
  • 需要初始化结构体中的成员数据
  • 构造函数可以重载

this 表示当前这个结构体自己:

this.width  结构体自己的成员变量
width       构造函数传入的参数

结构体中的成员函数

结构体中的成员函数【37】可以直接访问结构体自己的数据:

struct Rectangle
{
    public float width;
    public float height;

    public Rectangle(float width, float height)
    {
        this.width = width;
        this.height = height;
    }

    public float GetArea()
    {
        return width * height;
    }

    public float GetPerimeter()
    {
        return (width + height) * 2;
    }

    public void ShowInfo()
    {
        Console.WriteLine("面积:" + GetArea());
        Console.WriteLine("周长:" + GetPerimeter());
    }
}

使用:

Rectangle rectangle = new Rectangle(10, 5);
rectangle.ShowInfo();

当前阶段,结构体中的普通成员函数不需要写 static

结构体与数组结合

结构体可以描述一只怪物,数组可以批量存储多只怪物:

struct Monster
{
    public string name;
    public int health;
    public int attack;

    public Monster(string name, int health, int attack)
    {
        this.name = name;
        this.health = health;
        this.attack = attack;
    }
}

创建怪物数组:

Monster[] monsters = new Monster[10];

for (int i = 0; i < monsters.Length; i++)
{
    monsters[i] = new Monster(
        "小怪兽" + (i + 1),
        100,
        10);
}

这里综合使用了:

  • 结构体
  • 构造函数
  • 数组
  • 循环
  • 字符串拼接【38】

结构体与 ref 结合

结构体属于值类型。将结构体传入函数时,如果希望函数直接修改外部结构体数据,可以使用 ref

struct Fighter
{
    public string name;
    public int health;
    public int attack;

    public Fighter(string name, int health, int attack)
    {
        this.name = name;
        this.health = health;
        this.attack = attack;
    }
}

攻击函数:

static bool Attack(
    ref Fighter attacker,
    ref Fighter target,
    out string message)
{
    target.health -= attacker.attack;

    if (target.health <= 0)
    {
        target.health = 0;
        message = attacker.name + "击败了" + target.name;
        return true;
    }

    message =
        attacker.name + "攻击了" + target.name +
        ",目标剩余生命值:" + target.health;

    return false;
}

调用:

Fighter hero = new Fighter("奥特曼", 500, 80);
Fighter monster = new Fighter("小怪兽", 300, 30);

string message;
bool isOver = Attack(
    ref hero,
    ref monster,
    out message);

Console.WriteLine(message);

这段代码把结构体、函数、refout 联系了起来。


Lesson 13:冒泡排序

冒泡排序的基本思路

排序【39】是把无序数据调整为指定顺序:

原数组:5 2 8 1 4
升序:  1 2 4 5 8
降序:  8 5 4 2 1

冒泡排序【40】的核心过程是:

  1. 比较两个相邻元素
  2. 如果顺序不符合要求,就交换
  3. 一轮比较后,让一个元素到达正确位置
  4. 重复多轮,直到数组有序

升序【41】冒泡排序:

int[] arr = { 5, 2, 8, 1, 4 };

for (int i = 0; i < arr.Length - 1; i++)
{
    for (int j = 0; j < arr.Length - 1 - i; j++)
    {
        if (arr[j] > arr[j + 1])
        {
            int temp = arr[j];

            arr[j] = arr[j + 1];
            arr[j + 1] = temp;
        }
    }
}

外层循环控制比较轮数,内层循环比较相邻元素。

冒泡排序优化

每完成一轮,就有一个元素到达最终位置,因此内层循环可以缩小比较范围:

j < arr.Length - 1 - i

还可以使用一个 bool 变量记录当前轮是否发生交换。

如果一轮比较完全没有发生交换,说明数组已经有序,可以提前结束:

static void BubbleSort(int[] arr, bool ascending)
{
    for (int i = 0; i < arr.Length - 1; i++)
    {
        bool exchanged = false;

        for (int j = 0; j < arr.Length - 1 - i; j++)
        {
            bool needExchange;

            if (ascending)
            {
                needExchange = arr[j] > arr[j + 1];
            }
            else
            {
                needExchange = arr[j] < arr[j + 1];
            }

            if (needExchange)
            {
                int temp = arr[j];

                arr[j] = arr[j + 1];
                arr[j + 1] = temp;

                exchanged = true;
            }
        }

        if (!exchanged)
        {
            break;
        }
    }
}

调用时通过参数控制排序方向:

BubbleSort(arr, true);   // 升序
BubbleSort(arr, false);  // 降序

Lesson 14:选择排序

选择排序的基本思路

选择排序【42】每一轮都会从剩余元素中找到一个极值【43】,再把它放到当前目标位置。

升序排序时:

  1. 假设当前位置是最小值位置
  2. 向后查找真正的最小值
  3. 记录最小值索引
  4. 将最小值与当前位置交换
  5. 继续处理后面的元素

代码如下:

int[] arr = { 5, 2, 8, 1, 4 };

for (int i = 0; i < arr.Length - 1; i++)
{
    int minIndex = i;

    for (int j = i + 1; j < arr.Length; j++)
    {
        if (arr[j] < arr[minIndex])
        {
            minIndex = j;
        }
    }

    if (minIndex != i)
    {
        int temp = arr[i];

        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
    }
}

课程中将 minIndex 这类记录极值位置的变量形容为“中间商【44】”。

如果需要降序【45】排列,就在每一轮查找最大值。

也可以通过参数统一控制排序方向:

static void SelectionSort(int[] arr, bool ascending)
{
    for (int i = 0; i < arr.Length - 1; i++)
    {
        int targetIndex = i;

        for (int j = i + 1; j < arr.Length; j++)
        {
            bool foundTarget;

            if (ascending)
            {
                foundTarget = arr[j] < arr[targetIndex];
            }
            else
            {
                foundTarget = arr[j] > arr[targetIndex];
            }

            if (foundTarget)
            {
                targetIndex = j;
            }
        }

        if (targetIndex != i)
        {
            int temp = arr[i];

            arr[i] = arr[targetIndex];
            arr[targetIndex] = temp;
        }
    }
}

冒泡排序与选择排序的区别

对比项目冒泡排序选择排序
比较方式比较相邻元素在剩余元素中寻找极值
每轮结果一个元素逐渐移动到正确位置直接把极值放到目标位置
交换次数一轮可能交换多次一轮通常最多交换一次
基本结构两层循环两层循环
练习重点相邻元素交换极值索引记录

两种算法都适合练习:

  • 数组遍历
  • 双层循环
  • 条件判断【46】
  • 元素交换
  • 函数封装【47】