DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 08
STEP 01 / CODE / LOGIC

先说结论

List<T> 是可按下标访问、会保持顺序的动态数组。真正容易出错的不是 Add,而是删除后后续元素左移,导致下标和遍历位置发生变化。

初始:[A, B, B, C]
Remove(B):[A, B, C]       // 只删除第一个 B
RemoveAll(x == B):[A, C]  // 删除所有 B

分类:CSharp 基础数据结构

先说结论

List<T> 是可按下标访问、会保持顺序的动态数组。真正容易出错的不是 Add,而是删除后后续元素左移,导致下标和遍历位置发生变化。

先手算一次删除

初始:[A, B, B, C]
Remove(B):[A, B, C]       // 只删除第一个 B
RemoveAll(x == B):[A, C]  // 删除所有 B

如果从左向右删除下标 1,后一个 B 会立刻从下标 2 移到下标 1,而循环随后增加到 2,便可能跳过它。倒序删除时,左侧尚未访问的数据不会因为右侧删除而改变下标。

List 是什么

List<T> 可以理解成“可变长度数组”。

数组长度固定:

int[] nums = new int[3];

List<T> 可以动态添加元素:

List<int> nums = new();
nums.Add(10);
nums.Add(20);
nums.Add(30);

T 表示元素类型:

List<int>        存 int
List<string>     存 string
List<Unit>       存 Unit
List<GameObject> 存 GameObject

跟着一个列表逐步运行

具体场景是维护“当前场上的敌人 ID”。为什么选择 List<int>:业务需要保留进入顺序、允许按下标访问,并且敌人数量会动态增减;这些都是 List 的直接职责。

using System;
using System.Collections.Generic;

List<int> enemyIds = new() { 101, 102, 103 };

enemyIds.Add(104);
Console.WriteLine(string.Join(", ", enemyIds));

enemyIds.Remove(102);
Console.WriteLine(string.Join(", ", enemyIds));

Console.WriteLine(enemyIds.Contains(103));
Console.WriteLine(enemyIds[0]);

按行运行后的状态是:

初始                    [101, 102, 103]
Add(104)               [101, 102, 103, 104]
Remove(102)            [101, 103, 104]
Contains(103)          输出 True,列表不变
enemyIds[0]            输出 101,列表不变

注意两个不同概念:Remove(102) 按“值”删除,RemoveAt(1) 按“下标”删除。在当前状态 [101, 103, 104] 上调用 RemoveAt(1),删掉的是值 103

发现问题:正序删除会跳过元素

List<int> values = new() { 2, 4, 5 };

for (int i = 0; i < values.Count; i++)
{
    if (values[i] % 2 == 0)
        values.RemoveAt(i);
}

逐步看:

i=0,删除 2       -> [4, 5]
循环执行 i++      -> i=1
此时 4 已移到下标 0,却不会再检查
最终错误结果       -> [4, 5]

倒序遍历时,删除只会影响已经检查过的右侧元素:

for (int i = values.Count - 1; i >= 0; i--)
{
    if (values[i] % 2 == 0)
        values.RemoveAt(i);
}

Console.WriteLine(string.Join(", ", values)); // 5

后面的每种删除规则,都可以用这份列表手工跟踪“删除前下标、删除的值、删除后元素怎样左移”。

常用操作

添加:

List<string> names = new();
names.Add("Alice");
names.Add("Bob");

读取数量:

int count = names.Count;

按下标访问:

string first = names[0];

修改:

names[1] = "Cindy";

清空:

names.Clear();

Remove 只删除第一个匹配元素

Remove 不会删除全部相同元素,它只删除第一个匹配到的元素。

List<string> names = new() { "Alice", "Bob", "Alice", "Cindy" };

names.Remove("Alice");

执行后:

["Bob", "Alice", "Cindy"]

只删掉第一个 "Alice"

Remove 有返回值:

bool removed = names.Remove("Alice");

含义:

true:找到了并删除了一个
false:没找到,什么都没删

RemoveAll 删除所有满足条件的元素

如果想删除所有 "Alice"

names.RemoveAll(name => name == "Alice");

完整例子:

List<string> names = new() { "Alice", "Bob", "Alice", "Cindy" };

names.RemoveAll(name => name == "Alice");

// 结果:["Bob", "Cindy"]

这里:

name => name == "Alice"

意思是:

对列表里的每个 name 做判断;
如果判断结果是 true,就删除。

也可以写更复杂的条件:

names.RemoveAll(name => name.StartsWith("A"));

意思是删除所有以 "A" 开头的名字。

边遍历边删除时用倒序 for

不要正序边遍历边 RemoveAt

for (int i = 0; i < names.Count; i++)
{
    if (names[i] == "Bob")
    {
        names.RemoveAt(i);
    }
}

因为删除后,后面的元素会往前移动,i++ 可能跳过元素。

更稳的写法是倒序删:

for (int i = names.Count - 1; i >= 0; i--)
{
    if (names[i] == "Bob")
    {
        names.RemoveAt(i);
    }
}

倒序删除时,删除只会影响后面的元素,而后面的元素已经遍历过了,所以安全。

最重要的收获

Remove:只删除第一个匹配元素。
RemoveAll:删除所有满足条件的元素。
倒序 for:适合边遍历边删除多个元素。