(十二)集合 Collection
1. 什么是集合
- 确定性:
给定一个集合,任给一个元素,该元素或者属于或者不属于该集合,二者必居其一,不允许有模棱两可的情况出现 。 - 互异性:
一个集合中,任何两个元素都必须是不相同的。如果从集合论的角度来说,位置也同样是元素的信息。所以,集合可以表示为这样。当每个元素搭配了位置信息以后,它依然是唯一的。 - 无序性:
一个集合中,每个元素的地位都是相同的,元素之间是没有顺序的。不过,在编程语言中,可以在集合的基础上给每个元素定义顺序,定义了序关系后,元素之间就可以按照序关系排序。
但就集合本身的特性而言,元素之间没有必然的序。比如,数组、列表就是有顺序的集合,而哈希表就是没有顺序的集合。
不管是任何一种编程语言,java也好、python也罢、甚至是JavaScript,它们所定义的集合都必须严格遵循以上的三个特点。就程序而言,从本质上来说,集合还是一种数据类型,是一种可以把数据组合起来的类型,这种类型可以让你在同一时间处理大量的数据。
1.1 重要而且常用
集合是我们在实际工作中极为常用的数据类型,因为几乎所有的数据都是由各种各样的对象组成,而孤立的数据是没有意义的,只有数据之间形成了特定的关系才具备使用价值。而一般来说,对于相似的数据我们倾向于使用集合来进行处理。
比如说,大量的客户数据、大量的员工数据、大量的销售数据、甚至是各种系统之间的接口数据,我们都可以使用一个或多个集合对象来对这些真实数据进行统一的封装和处理。
我们可以针对集合进行数据搜索、枚举、添加、删除等各种各样的操作。
1.2 集合类型
一般来说,所有定义在 System.Collections 命名空间中的类型,都属于集合的范畴。常见的类型包括:

1.3 集合的特点:
1. 可以储存无限个元素,(数组除外)
a. 我们在声明或者初始化集合的时候不需要像数组一样指明上线
b. 所以我们可以不断向集合中添加元素,直到内存被消耗殆尽。
2. 任何一个集合都支持搜索、排序、复制、添加、删除等操作,以上操作都已经被c#封装好了,我们可以直接调用
2. 数组 array

- 固定长度
- 访问安全
它不会返回任何不存在的数据。而c#中所有的集合类型都有类似的这种对集合边界的检查,我们绝对无法访问集合没有的数据,如果强行访问,系统会在运行时报错。
3.列表 list
在编程中有一个重要概念叫做API,全程叫做 application programing interface,指的就是一个对象预留给我们可以调用的编程接口,通过这些预留的接口,程序可以为我们封装具体的实现逻辑,而我们的特定需要就可以直接通过对象调用方法的形式来进行处理了。
对于列表来说,它有几十个api可以让我们直接调用,其实这些就是List class中的对外成员方法或者属性。下面的表格就是列表最常用的几个api

3.1 构造方法
我们List类型有三个构造器,分别一个是无参数构造器,以及两个带参数的构造器,而参数可以传入一个可以被枚举的集合或者传入一个整数。
List<string> daysOfWeek3 = new List<string>();
而我们也可以把一个列表或这数组传递给构造方法来创建新的列表
List<string> daysOfWeek4 = new List<string>(daysOfWeek3);
List<string> daysOfWeek5 = new List<string>(daysOfWeek);
List<string> daysOfWeek6 = new List<string>(7);
Console.WriteLine($"{daysOfWeek6.Count}/{daysOfWeek6.Capacity}");
实际上在使用列表额时候,当列表达到上限的时候,列表会重新创建一个新的长度更长的数据,并使用这个新数组来代替就的数组。
特殊初始化
List<string> daysOfWeek7 = new List<string>()
{
"Monday",
"Tuesday",
"Wendesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
};
3.2 插入,添加,删除
- 相较于只能添加一个元素的Add方法,AddRange方法的可以用来添加一整段元素,而这一整段元素同样可以是列表、可以是数组。
- Insert插入一个或者InsertRange插入集合(列表的某个位置插入元素的情况)
daysOfWeek7.Insert(2, "whatever");
在进行列表操作的时候,我们应该尽量避免数据的插入的操作,因为在插入的时候,编译器会先把列表先分解为两段,然后把要插入的数据放入中间,最后再把这三组数据合在一起。整个过程的性能非常低,如果不小心在for循环中进行这个操作,那简直就是灾难。添加性能好一些。
- RemoveRange就是可以删除一个位置片段内的数据,比如,我们删除第三位到第七位之间的数据daysOfWeek7.RemoveRange(2, 6);
- daysOfWeek7.RemoveAt(0),那么,这个操作就会把列表7的第一个元素删掉。
- 初次以外,列表还有一个Remove方法,这里传入的就是需要被删除的对象。比如,我们把“Monday”传入Remove方法,那么程序就会遍历整个列表,找到第一个看到的Monday元素,然后把它删掉。至于其他的“Monday”,remove方法就不管了,会继续保留。
- RemoveAll并不是接收一个具体的值,而是接受一个对比条件。然后它使用这个条件去扫描列表中的所有元素,一旦发现有哪个元素满足对比条件,也就是委托返回true,那么这个元素就需要从列表中删掉。
daysOfWeek7.RemoveAll(i => i == "Monday"); - RemoveAll里面还可以删除,包含某部分的元素,如包含"day"的,使用contains
daysOfWeek7.RemoveAll(i => i.Contains("day"));
Console.WriteLine(String.Join(", ", daysOfWeek7));
使用一个lambda表达式,或者叫做匿名函数来处理。我们要求每次扫描的item,i,都要等于字符串“Monday”,也就是这个匿名函数返回值为true的时候,数据才能被删除。
3.3 arraylist

- 不支持泛型
- 提取数据会频繁进行装箱拆箱操作,性能不好
4. 读取数据
- 读取列表大小用count
- 读取列表容量用Capacity
- 读取列表某一个成员需要使用索引器
- 遍历一个列表需要使用迭代器。
4.1 索引器
- 就是方括号
- 并不是任何一种集合或者每一种数据类型都有索引器,stack、queue、hashset,这些集合在c#中就没有索引器。
4.2 迭代器
不过呢,我们最好不要说从头到尾遍历,因为说起从头到尾,会给人一种这个集合是有顺序的。但并不是所有的集合都有顺序。对于没有顺序的集合,给他安装迭代器以后,它依然能够按照一定的规律来遍历集合内所有的元素。
所以,迭代器的意义就是可以帮助我们把集合中的数据全部访问一遍

也可以用var声明
我们直接使用迭代器来处理遍历多多少少会有一点风险,于是,很多编程语言都准备了一个语句来应对这种情况,这个forEach语句在底层是线上还是依然使用了迭代器,但使用forEcha最大的好处就是可以帮我们避开迭代器在一开始和最末尾current属性指向空值的情况。(底层是while)

- 迭代器当前指向的元素可以通过MoveNext来调整
4.3 是否可以添加元素在循环中
for
for(int i=0; i < daysOfWeek6.Count - 1; i++)
{
Console.WriteLine(i);
daysOfWeek6.Add("one day");
}
可以添加且是死循环,因为Count是变量
foreach
foreach(var i in daysOfWeek6)
{
Console.WriteLine(i);
daysOfWeek6.Add("one day");//报错
}
其实从原理上来说forEach并不属于for循环,而更接近while循环,因为它在底层实现上使用的是迭代器,而我们在使用迭代器来遍历集合的时候这个集合的元素个数是不允许有任何变化的.
4.4 可否更改值
在foreach中把所有的元素都改为某一天,i 等于“someday”。结果报错了,其实,这是foreach循环的另一个限制,foreach迭代的元素全都是只读的,不能修改。
List<Customer> customers = new()
{
new Customer(1, "阿莱克斯", "广州"),
new Customer(2, "莱克斯", "北京"),
new Customer(3, "克斯", "上海"),
new Customer(4, "斯", "深圳")
};
foreach (var i in customers)
{
Console.WriteLine(i);
i.Name = "someday";
//daysOfWeek6.Add("one day");
}
所有的类、所有的对象都是引用类型,其实他们的本质就是一串内存地址,只要内存地址不改变,无论里面的数据如何改变,编译器都把这个变量视为一个没有改变过的数据。
值类型不可以,但引用类型可以改变。
5.IEnumerable IEnumerator
5.1 IEnumerable
而c#中几乎所有的集合都实现了接口IEnumerable,而这个IEnumerable接口的唯一工作就是提供迭代。只要一个对象实现了IEnumerable接口,可以创建迭代器,那么这个对象就可以被迭代循环。
var bank = new Bank();
foreach (Customer customer in bank)
{
Console.WriteLine(customer.Name);
}
让银行实现foreach循环的内在逻辑,也就是要给他创建一个迭代器,用迭代器来迭代客户列表。给一个集合创建迭代器就需要实现IEnumerable这个接口了,而它的泛型类型则是客户,Customer。
5.2 创建自己的泛型列表 List
public class MyList<T> : IEnumerable<T>
{
T[] data; //存储数据
int currentIndex;
public MyList(int length)
{
this.data = new T[length];
currentIndex = 0;
}
public void Add(T s)
{
data[currentIndex++] = s;
}
public IEnumerator<T> GetEnumerator()
{
return new MyEnumerator<T>(data);
}
IEnumerator IEnumerable.GetEnumerator()
{
throw new NotImplementedException();
}
}
MyEnumerator
public class MyEnumerator<T> : IEnumerator<T>
{
private T[] _data;
private int position = -1;
public MyEnumerator(T[] data)
{
_data = data;
}
public T Current
{
get
{
return _data[position];
}
}
object IEnumerator.Current
{
get
{
return Current;
}
}
public void Dispose()
{
}
public bool MoveNext()
{
position++;
return (position < _data.Length);
}
public void Reset()
{
position = -1;
}
}
6.迭代与yield return
static void Main(string[] args)
{
var customers = GetCustomers(10);
foreach (var c in customers)
{
if (c.Id < 1000)
{
Console.WriteLine($"customer id {c.Id}, name {c.Name}");
}
else
{
break;
}
}
}
static IEnumerable<Customer> GetCustomers(int count)
{
List<Customer> customers = new List<Customer>();
for (int i = 0; i < count; i++)
{
customers.Add(new Customer(i, $"hui {i}", "beijin"));
}
return customers;
}
虽然我们创建了10万个客户,却仅仅使用了前100个客户,然后程序就结束了。也就是说程序中绝大部分的内存是白白浪费掉了。针对于这种情况,C#提出了一个非常独特的解决方案,这就是关键词 yield。
6.1 使用yield更新GetCustomers方法

- 然后进入foreach的第二次循环,当我们需要第二个客户数据的时候,GetCustomersYield方法就好像听到了客户对象的呼唤一样,开始执行第二次for循环,创建并返回第二个客户。而鼠标放在客户列表上也能看到,count从1变为了2。
- 代码继续执行,检查第二个客户Id,打印第二个客户数据
在使用yield关键词以后,代码就会产生一个懒加载的效果,也就是除非数据将会使用,或者说不到万不得已的时候,yield关键词后面所返回的对象是不会被创建的。
从原理上来说,关键词yield的返回其实就是一个迭代器
yield return所产生的列表的长度只与它“出现”的次数有关,与执行次数无关。也就是说即使不执行yield return,只要你代码里写了yield return也会产生相应数量长度的列表,只不过列表中每个元素的数据都是暂时没有占用内存空间的。
static IEnumerable<int> CreateEnumerable()
{
yield return 1;
yield return 2;
yield return 3;
}
//main
foreach (int item in enumerableFuc())
{
Console.WriteLine(item);
}
这三个数字将会被捆绑在一起创建一个可以迭代的集合,这个集合中按顺序保存的元素就是数字1、2、3。
命令行按顺序输出了数字1,2,3。咋一看,似乎使用yield return以后就是创建了一个列表嘛,但实际上这个列表内的数据是动态加载的,加载原则就是除非必要否则不创建。
7.Benchmark性能基准测试
- benchmarkdotnet,它可以帮我们同时对比多个代码段的性能,提供性能的量化标准,
- 这是BenchmarkDotNet的官网: https://benchmarkdotnet.org/ 。 它可以运行在传统的 .NET Framework (4.6+) 框架中,也可以运行在 .NET Core 框架中。安装和使用也是极为简单的,通过nuget就能完成安装。

static IEnumerable<Customer> GetCustomers(int count)
{
List<Customer> customers = new List<Customer>();
for (int i = 0; i < count; i++)
{
customers.Add(new Customer(i, $"hui {i}", "beijin"));
}
return customers;
}
static IEnumerable<Customer> GetCustomersYield(int count)
{
for (int i = 0; i < count; i++)
{
yield return new Customer(i, $"hui {i}", "beijin");
}
}

- 回到main方法。使用BenchmarkRunner,调用run方法,泛型BenchmarkTester,加上括号来执行run方法。
var sumery = BenchmarkRunner.Run<BenchmarkTester>(); - 请注意,跑分测试必须得使用release版本的可执行程序,不能直接跑visual studio。所以,打开cmd或这powershell,一路cd到代码的根目录下。我们来执行 release 命令,使用dotnet命令,
dotnet build -c Release - 命令执行好以后,在程序的bin文件夹中就会生成一个dll文件。我们需要执行这个dll,请使用dotnet命令执行下面的命令
dotnet D:\program\csharp\CsharpStudy09\12\bin\Release\net6.0\12.dll

- 规模更大如1000000次,时间也会有差异,yield更加效率高
- 使用yield关键词可以完成数据的懒加载,明显提升程序的运算效率,并且极大的节省了空间。
8.数据搜索:字典

数组array和列表list,这两种集合是有顺序的,他们的内存排列的位置信息则成为了代表了他们的顺序的索引 index。所以,这一类可以遍历循环、有顺序、有位置信息的集合统称为indexed based collection,也就是所谓的索引基底集合。
不过,在C#中除了索引基底集合以外,他还有另外的三种集合类型。Key value pair collection 键值对集合、prioritized collection 优先集合、以及Specialized colleciton特殊集合或专业化集合。
键值对集合有三种典型代表,哈希表hashset、有序列表sortedList、以及字典dictionary。
优先集合同样也是一种带有顺序的集合,主要描述的是不同元素的输入输出的优先级顺序,比如说先进先出的队列queue和后进先出的栈stack。
var customerdictionary = GetCustomersDictionary(100000);
var customer = customerdictionary[99999];
Console.WriteLine($"customer id {customer.Id}, name {customer.Name}");
var customerhashtable = GetCustomersHashtable(100000);
var customer2 = (Customer)customerhashtable[99999];
Console.WriteLine($"customer id {customer2.Id}, name {customer2.Name}");
static Dictionary<int, Customer> GetCustomersDictionary(int count)
{
var customers = new Dictionary<int, Customer>();
for (int i = 0; i < count; i++)
{
customers.Add(i, new Customer(i, $"hui {i}", "beij"));
}
return customers;
}
static Hashtable GetCustomersHashtable(int count)
{
var customers = new Hashtable();
for (int i = 0; i < count; i++)
{
customers.Add(i, new Customer(i, $"hui {i}", "ebijin"));
}
return customers;
}
以上就是hashtable的基本使用方法。那么HashTable和Dictionary的区别就在于:
- HashTable不支持泛型,而Dictionary支持泛型。
- Hashtable中key-value键值对均为object类型,所以在存储或检索值类型时通常发生装箱和拆箱的操作,所以你可能需要进行一些类型转换的操作,而且对于int,float这些值类型还需要进行装箱等操作,非常耗时。
9.hashset集合的并交差运算

这个 HashSet 是一个相对“冷门”的类型,在我们日常的代码开发中用得其实并不多。不过,一旦要处理特定的业务场景的时候,比如存储集合、做高性能集运算、求两个甚至是多个集合的交集、并集、差集等比较特殊的业务的时候,HashSet是一个极为高效的工具。
hashset和hastable没有关系,数据结构、使用场景完全不一样。HashSet名称里带有hash表示的是使用哈希的方式来保存数据而已。哈希是一种在密码学广泛应用的算法。
9.1 例子
分别对起点和终点进行搜索,使用数据仓库中的方法FindBusesTo。这样我们就能找到两个集合,分别是起点集合和终点集合。
static void Main(string[] args)
{
var repository = new BusRouteRepository();
Console.WriteLine("打哪儿来?");
string startingAt = Console.ReadLine(); //广州大学
Console.WriteLine("上哪儿去?");
string goingTo = Console.ReadLine(); // 动物园
BusRoute[] originRoutes = repository.FindBusesTo(startingAt);
BusRoute[] destinationRoutes = repository.FindBusesTo(goingTo);
HashSet<BusRoute> routes = new HashSet<BusRoute>(originRoutes);
routes.IntersectWith(destinationRoutes);
if (routes.Count > 0)
foreach (BusRoute route in routes)
Console.WriteLine($"乘坐公车 {route}");
else
Console.WriteLine($"路线找不到");
}


浙公网安备 33010602011771号