C# 试题 25:Dictionary、HashSet 与相等性
0481 关于 Dictionary<TKey, TValue> 的查找复杂度,哪项正确?
难度: 进阶
- A. 平均接近 O(1),最坏情况受哈希冲突与扩容影响,可能退化为 O(n)
- B. 无论比较器实现和键分布如何,查找都严格 O(1)
- C. 平均 O(log n)
- D. 最坏情况也是 O(1),扩容后自动重哈希保证
查看答案与解析
正确答案A
解析: Dictionary 用哈希桶定位,平均情况下每个桶元素很少,接近 O(1);一旦大量键哈希碰撞(如劣质比较器或恶意输入),单桶链表变长,最坏退化为 O(n)。
关键点: “严格 O(1)”是常见误解,最坏情况由哈希质量决定。
0482 以下代码输出什么?
难度: 实战
class Key
{
public int A;
public int B;
public override bool Equals(object? o) => o is Key k && A == k.A && B == k.B;
public override int GetHashCode() => HashCode.Combine(A, B);
}
var key = new Key { A = 1, B = 2 };
var dict = new Dictionary<Key, string> { [key] = "v" };
key.B = 3;
Console.WriteLine(dict.ContainsKey(new Key { A = 1, B = 3 }));
- A.
True,键值相等即可命中 - B.
False,键的哈希码随 B 改变而变,条目存放在旧哈希对应的桶中,新哈希查不到 - C.
True,字典会在查找时重新计算并迁移条目 - D.
False,因为传入的是不同对象引用(即使不修改 B 也找不到)
查看答案与解析
正确答案B
解析: 入表后键的 B 从 2 改为 3,GetHashCode 也随之变化;条目仍留在按旧哈希定位的桶里,用新哈希查找必然落空。这与“值相等但引用不同”无关——比较器本身是值语义。
关键点: 键入表后不应改变参与相等与哈希的状态。
0483 以下代码输出什么?
难度: 实战
var dict = new Dictionary<string, int> { ["a"] = 1 };
Console.WriteLine(dict.TryGetValue("b", out int v));
Console.WriteLine(v);
- A.
True/0 - B.
False/ 抛异常 - C.
True/1 - D.
False/0,查找失败时 out 参数被赋default
查看答案与解析
正确答案D
解析: TryGetValue 未命中时返回 false,并把 out 参数赋为 default(int) 即 0,不会抛异常。注意 v 的初值无意义,必须读返回值后再使用。
关键点: TryGetValue 的 out 参数在失败时是 default,不是上一次的值。
0484 以下代码运行结果是什么?
难度: 实战
var dict = new Dictionary<string, int> { ["a"] = 1 };
Console.WriteLine(dict["b"]);
- A. 输出
0 - B. 输出
null - C. 运行抛
KeyNotFoundException - D. 输出
1
查看答案与解析
正确答案C
解析: 索引器 dict["b"] 对缺失键抛 KeyNotFoundException;只有 TryGetValue 和 GetValueOrDefault 才提供不抛异常的读取路径。
关键点: 索引器是“假设存在”的契约,读取前先判断或改用 TryGetValue。
0485 以下代码输出什么?
难度: 进阶
class K
{
public int Id;
public override bool Equals(object? o) => true;
public override int GetHashCode() => Id;
}
var set = new HashSet<K>();
set.Add(new K { Id = 1 });
Console.WriteLine(set.Contains(new K { Id = 2 }));
- A.
True,Equals 恒为 true - B.
False,哈希码决定桶位置,Id=2 与 Id=1 落入不同桶,永远不会走到 Equals - C.
True,HashSet 会线性扫描全部元素 - D.
False,因为 K 没有实现 IEquatable<K>
查看答案与解析
正确答案B
解析: HashSet 先用 GetHashCode 定位桶,只在同桶内用 Equals 精比。Id=1 与 Id=2 哈希不同、落入不同桶,Contains 直接返回 false,即便 Equals 恒真也不会被调用。
关键点: Equals 相等必须保证 GetHashCode 相等,否则元素“永远找不到”。
0486 以下代码输出什么?
难度: 进阶
var dict = new Dictionary<double, string> { [double.NaN] = "x" };
Console.WriteLine(dict[double.NaN]);
- A. 输出
x - B. 输出空字符串
- C. 抛
KeyNotFoundException,NaN 不等于自身 - D. 编译失败
查看答案与解析
正确答案A
解析: double.Equals(NaN, NaN) 返回 true(IEEE 754 的 NaN != NaN 只适用于比较运算符),EqualityComparer<double>.Default 按 Equals 语义工作且 NaN 的哈希稳定,因此 NaN 作为键可正常命中。
关键点: “NaN 不等于自身”对 == 成立,对 Equals/字典查找不成立。
0487 以下代码输出什么?
难度: 实战
struct Point
{
public int X;
public int Y;
}
var dict = new Dictionary<Point, string>();
dict[new Point { X = 1, Y = 2 }] = "v";
Console.WriteLine(dict.ContainsKey(new Point { X = 1, Y = 2 }));
- A.
False,两个 struct 实例引用不同 - B.
False,struct 默认使用引用相等 - C.
True,未重写时 struct 按字段逐项比较且哈希一致 - D. 运行抛异常
查看答案与解析
正确答案C
解析: 未重写 Equals/GetHashCode 的 struct 由 ValueType 提供字段级值相等,两个成员相同的实例相等且哈希相同,因此可以命中。注意这条路径有反射开销,热点代码应显式实现 IEquatable<T>。
关键点: struct 默认是值相等,与 class 的引用相等相反。
0488 以下代码输出什么?
难度: 实战
var ci = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["Key"] = 1 };
var cs = new Dictionary<string, int> { ["Key"] = 1 };
Console.WriteLine(ci["key"]);
Console.WriteLine(cs.ContainsKey("key"));
- A.
1/True - B.
0/False - C.
1/1 - D.
1/False,默认字符串键比较是区分大小写的序号比较
查看答案与解析
正确答案D
解析: StringComparer.OrdinalIgnoreCase 让 "Key" 与 "key" 视为同一键;而 Dictionary 的默认比较器是 EqualityComparer<string>.Default,按序号且区分大小写,ContainsKey("key") 为 false。
关键点: 字符串默认相等是 ordinal(区分大小写),需要忽略大小写必须显式传入比较器。
0489 关于 string.GetHashCode() 的稳定性,哪项正确?
难度: 进阶
- A. 跨进程、跨运行时完全稳定,可用于持久化存储键
- B. 每次调用都返回随机值
- C. 同一进程内同一字符串多次调用结果一致;但 .NET Core 起字符串哈希按进程随机化,跨进程可能不同,不能用于持久化
- D. 结果受当前区域文化影响
查看答案与解析
正确答案C
解析: .NET Core 对 string.GetHashCode() 使用进程级随机种子,防御哈希碰撞攻击。同一进程内结果稳定,但不同进程可能不同,因此绝不能把该值写入数据库或用于跨进程协议。
关键点: 哈希码是进程内寻址用的,不是稳定标识符。
0490 以下代码输出什么?
难度: 实战
var dict = new Dictionary<string, int>();
dict[null!] = 1;
Console.WriteLine(dict.Count);
- A.
0,null 不是合法键 - B.
1,引用类型键允许 null - C. 抛
ArgumentNullException - D. 编译失败
查看答案与解析
正确答案B
解析: 对引用类型键,Dictionary 显式允许 null,dict[null!] = 1 正常新增一个条目。只有值类型键才不可能为 null。
关键点: null 键合法,别把“不能为 null”当成通用规则。
0491 以下代码输出什么?
难度: 实战
var dict = new Dictionary<string, int>();
Console.WriteLine(dict.GetValueOrDefault("x"));
Console.WriteLine(dict.GetValueOrDefault("x", 42));
- A.
0/42 - B.
0/0 - C. 抛
KeyNotFoundException - D.
null/42
查看答案与解析
正确答案A
解析: GetValueOrDefault 是 .NET Core 2.0+ 提供的便捷方法:无重载版本返回 default(TValue),带默认值重载返回指定默认值,都不抛异常。注意返回值类型是 int,不存在 null。
关键点: 读取缺失键有三条路径:索引器抛异常、TryGetValue 返回 bool、GetValueOrDefault 返回默认值。
0492 以下代码运行结果是什么?
难度: 实战
var dict = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };
foreach (var k in dict.Keys)
{
dict.Add("c", 3);
}
- A. 正常结束,Count 变为 3
- B. 只添加一次后正常结束
- C. 无限循环
- D. 第一次 Add 时抛
InvalidOperationException
查看答案与解析
正确答案D
解析: Dictionary 的枚举器通过版本号检测结构修改,遍历期间调用 Add/Remove 会使版本号变化,下一次 MoveNext 抛 InvalidOperationException。先收集键再批量修改是安全做法。
关键点: 枚举期间任何结构性修改都会让迭代失效。
0493 以下代码输出什么?
难度: 进阶
var dict = new Dictionary<string, int>();
ref int v = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, "a", out bool exists);
v = 42;
Console.WriteLine($"{exists} {dict["a"]}");
- A.
True 42 - B.
False 0 - C.
False 42 - D.
True 0
查看答案与解析
正确答案C
解析: 键 "a" 不存在时 GetValueRefOrAddDefault 会以默认值插入条目,out exists 为 false,并返回指向该值的 ref;通过 ref 写入 42 直接落进字典,等价于一次查找完成“取或加 + 赋值”。
关键点: 该 API 避免 ContainsKey + 索引器的两次查找,但只应作用于引用类型值或立即使用的场景。
0494 以下代码输出什么?
难度: 进阶
var dict = new Dictionary<string, int>();
ref int v = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, "k", out bool first);
v = 7;
ref int w = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, "k", out bool second);
Console.WriteLine($"{first} {second} {w}");
- A.
False False 7 - B.
False True 7 - C.
True True 7 - D.
False True 0
查看答案与解析
正确答案B
解析: 第一次调用键不存在,first=false 并插入;第二次调用键已存在,second=true,返回的 ref 指向同一个 7,所以 w 为 7。
关键点: out 参数表示“本次调用是否新增”,读取已有值时拿到的是现存值。
0495 以下代码结果是什么?
难度: 实战
struct S { public int X; }
var dict = new Dictionary<string, S>();
dict["a"] = new S { X = 1 };
dict["a"].X = 2;
- A. 编译失败(CS1612):索引器返回值是副本,不能就地修改结构体字段
- B. 运行后 X 变为 2
- C. 运行抛
InvalidOperationException - D. 静默无效,X 仍为 1
查看答案与解析
正确答案A
解析: dict["a"] 返回的是结构体的副本,对副本字段赋值没有意义,编译器直接以 CS1612 拒绝。要让字典里的结构体字段可改,应取 ref(CollectionsMarshal)或改成可变类、改用整体替换 dict["a"] = new S { X = 2 }。
关键点: 字典索引器返回副本——“改了没生效”多半是这里。
0496 以下代码输出什么?
难度: 实战
var set = new HashSet<Person>();
set.Add(new Person("A"));
Console.WriteLine(set.Contains(new Person("A")));
record Person(string Name);
- A.
False,record 是引用类型,使用引用相等 - B.
False,每次 new 都是不同对象 - C. 编译失败,record 不能作为 HashSet 元素
- D.
True,record 自动实现基于成员的 Equals/GetHashCode
查看答案与解析
正确答案D
解析: 编译器为 record 合成值语义的 Equals、GetHashCode(基于 Name),两个 Person("A") 相等且哈希相同,因此 Contains 返回 true。容易误以为“record 也是 class 就该引用相等”。
关键点: record 的相等语义是成员级,与普通 class 不同。
0497 以下代码输出什么?
难度: 实战
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
dict["A"] = 1;
dict["a"] = 2;
Console.WriteLine(dict.Count);
Console.WriteLine(dict["A"]);
- A.
2/1 - B.
1/1 - C.
1/2,忽略大小写下两次写入命中同一键,后者覆盖 - D.
2/2
查看答案与解析
正确答案C
解析: 比较器忽略大小写后,"A" 与 "a" 是同一个键:第一次 Add 语义、第二次覆盖值。因此 Count 为 1,读 "A" 得到 2。
关键点: 比较器决定“同键”的判定,写覆盖不产生新条目。
0498 以下代码输出什么?
难度: 进阶
var dict = new Dictionary<object, string>();
dict[1] = "a";
Console.WriteLine(dict[1]);
- A. 输出
a,装箱后仍按值比较 - B. 抛
KeyNotFoundException,两次装箱引用不同 - C. 输出
null - D. 编译失败,
object键无法装箱
查看答案与解析
正确答案A
解析: 两个内容相同的装箱 int 在 Equals/GetHashCode 上按值比较(boxed.Equals(boxed) 委托给 int.Equals),所以 dict[1] 能命中先前存入的键。
关键点: 装箱不改变相等语义,别把“引用不同”套到值类型的装箱对象上。
0499 以下代码输出什么?
难度: 进阶
class K
{
public int Id;
public override bool Equals(object? o) => o is K k && Id == k.Id;
// 未重写 GetHashCode
}
var dict = new Dictionary<K, int>();
dict[new K { Id = 1 }] = 10;
Console.WriteLine(dict.ContainsKey(new K { Id = 1 }));
- A.
True,Equals 相等即可命中 - B.
False,未重写 GetHashCode 时使用基于引用的哈希,两个实例落入不同桶 - C.
True,编译器会自动生成 GetHashCode - D. 编译警告但结果仍为
True
查看答案与解析
正确答案B
解析: 只重写 Equals 不重写 GetHashCode 会触发 CS0659 警告;GetHashCode 仍是 object 的基于引用实现,两个内容相同但引用不同的键哈希不同,落入不同桶,ContainsKey 返回 false。
关键点: 相等性契约要求 Equals 与 GetHashCode 成对重写。
0500 关于 CollectionsMarshal.GetValueRefOrAddDefault 返回的 ref 生命周期,哪项正确?
难度: 进阶
- A. ref 永久有效,可长期保存并跨方法传递
- B. 只有扩容才失效,Add 已有键不影响
- C. ref 在字典发生结构变化(Add/Remove/扩容/清空)后失效,继续使用是未定义行为
- D. ref 指向值类型的装箱副本,修改不会反映到字典
查看答案与解析
正确答案C
解析: 返回的 ref 直接指向字典内部槽位,任何改变内部数组或条目布局的结构性操作都会使该 ref 失效。官方文档明确:修改字典后继续使用旧 ref 属于未定义行为,可能读到垃圾数据甚至破坏内存。
关键点: 拿到 ref 后应立即读写完,不要跨越结构修改继续使用。