Appearance
C++ 作业
写一个动态数组
标准答案
动态数组本质就是“底层数组 + 当前元素数量”。数组满了以后,它不会原地变大,而是创建一个更大的新数组,把旧元素复制过去,再把内部引用指向新数组。C# 的 List<T> 底层就是这种思路。
c
using System; // 引入异常类型
using System.Collections; // 引入 IEnumerable
using System.Collections.Generic; // 引入 IEnumerator 和 EqualityComparer
public sealed class DynamicArray<T> : IEnumerable<T> // 定义泛型动态数组
{ // DynamicArray 类开始
private T[] items; // 真正存储元素的底层数组
private int count; // 当前已经存放的元素数量
private const int DefaultCapacity = 4; // 默认初始容量
public int Count => count; // 返回当前元素数量
public int Capacity => items.Length; // 返回底层数组容量
public DynamicArray(int capacity = DefaultCapacity) // 定义构造函数
{ // 构造函数开始
if (capacity < 0) // 如果容量小于 0
{ // 容量判断开始
throw new ArgumentOutOfRangeException(nameof(capacity)); // 抛出参数异常
} // 容量判断结束
items = new T[Math.Max(capacity, 0)]; // 创建底层数组
count = 0; // 初始化元素数量为 0
} // 构造函数结束
public T this[int index] // 定义索引器
{ // 索引器开始
get // 读取元素
{ // get 开始
CheckIndex(index); // 检查下标是否合法
return items[index]; // 返回对应下标的元素
} // get 结束
set // 修改元素
{ // set 开始
CheckIndex(index); // 检查下标是否合法
items[index] = value; // 设置对应下标的元素
} // set 结束
} // 索引器结束
public void Add(T value) // 尾部添加元素
{ // Add 方法开始
EnsureCapacity(count + 1); // 确保容量足够
items[count] = value; // 把新元素放到 count 位置
count++; // 元素数量加 1
} // Add 方法结束
public void Insert(int index, T value) // 在指定位置插入元素
{ // Insert 方法开始
if (index < 0 || index > count) // 插入位置可以等于 count
{ // 下标判断开始
throw new ArgumentOutOfRangeException(nameof(index)); // 抛出下标异常
} // 下标判断结束
EnsureCapacity(count + 1); // 确保容量足够
if (index < count) // 如果不是插入到末尾
{ // 移动判断开始
Array.Copy(items, index, items, index + 1, count - index); // 把 index 后面的元素整体后移一格
} // 移动判断结束
items[index] = value; // 把新元素放到 index 位置
count++; // 元素数量加 1
} // Insert 方法结束
public bool Remove(T value) // 删除第一个匹配的元素
{ // Remove 方法开始
int index = IndexOf(value); // 查找元素下标
if (index < 0) // 如果没有找到
{ // 未找到判断开始
return false; // 删除失败
} // 未找到判断结束
RemoveAt(index); // 删除指定下标的元素
return true; // 删除成功
} // Remove 方法结束
public void RemoveAt(int index) // 删除指定下标的元素
{ // RemoveAt 方法开始
CheckIndex(index); // 检查下标是否合法
int moveCount = count - index - 1; // 计算需要前移的元素数量
if (moveCount > 0) // 如果后面还有元素
{ // 移动判断开始
Array.Copy(items, index + 1, items, index, moveCount); // 把后面的元素整体前移一格
} // 移动判断结束
count--; // 元素数量减 1
items[count] = default(T); // 清理最后一个位置,避免引用类型无法释放
} // RemoveAt 方法结束
public int IndexOf(T value) // 查找元素下标
{ // IndexOf 方法开始
EqualityComparer<T> comparer = EqualityComparer<T>.Default; // 获取默认相等比较器
for (int i = 0; i < count; i++) // 遍历有效元素
{ // for 开始
if (comparer.Equals(items[i], value)) // 如果元素相等
{ // 相等判断开始
return i; // 返回下标
} // 相等判断结束
} // for 结束
return -1; // 没找到就返回 -1
} // IndexOf 方法结束
public bool Contains(T value) // 判断是否包含元素
{ // Contains 方法开始
return IndexOf(value) >= 0; // 下标大于等于 0 表示存在
} // Contains 方法结束
public void Clear() // 清空动态数组
{ // Clear 方法开始
Array.Clear(items, 0, count); // 清理有效元素,释放引用
count = 0; // 元素数量归零
} // Clear 方法结束
public void EnsureCapacity(int minCapacity) // 确保容量至少达到指定大小
{ // EnsureCapacity 方法开始
if (items.Length >= minCapacity) // 如果当前容量已经足够
{ // 容量足够判断开始
return; // 不需要扩容
} // 容量足够判断结束
int newCapacity = items.Length == 0 ? DefaultCapacity : items.Length * 2; // 计算新容量
newCapacity = Math.Max(newCapacity, minCapacity); // 确保新容量不小于需求容量
T[] newItems = new T[newCapacity]; // 创建更大的新数组
Array.Copy(items, 0, newItems, 0, count); // 把旧数组的有效元素复制过去
items = newItems; // 替换底层数组引用
} // EnsureCapacity 方法结束
private void CheckIndex(int index) // 检查访问下标
{ // CheckIndex 方法开始
if (index < 0 || index >= count) // 如果下标越界
{ // 越界判断开始
throw new ArgumentOutOfRangeException(nameof(index)); // 抛出下标越界异常
} // 越界判断结束
} // CheckIndex 方法结束
public IEnumerator<T> GetEnumerator() // 获取泛型迭代器
{ // GetEnumerator 方法开始
for (int i = 0; i < count; i++) // 遍历有效元素
{ // for 开始
yield return items[i]; // 返回当前元素
} // for 结束
} // GetEnumerator 方法结束
IEnumerator IEnumerable.GetEnumerator() // 获取非泛型迭代器
{ // 非泛型 GetEnumerator 方法开始
return GetEnumerator(); // 复用泛型迭代器
} // 非泛型 GetEnumerator 方法结束
} // DynamicArray 类结束使用示例
c
DynamicArray<int> array = new DynamicArray<int>(); // 创建动态数组
array.Add(10); // 添加元素 10
array.Add(20); // 添加元素 20
array.Insert(1, 15); // 在下标 1 插入 15
int value = array[0]; // 读取下标 0 的元素
array.RemoveAt(1); // 删除下标 1 的元素
bool hasTwenty = array.Contains(20); // 判断是否包含 20
array.Clear(); // 清空所有元素复杂度
尾部 Add:均摊 O(1),扩容那一次是 O(n)。 下标访问:O(1)。 中间 Insert / RemoveAt:最坏 O(n),因为要移动元素。 空间复杂度:O(n)。
Unity 里如果已知大概数量,最好提前设置容量,避免运行时频繁扩容带来 GC 和复制开销。
写一个字符串类
标准答案
这里按你前面一直用的 C# 风格,写一个教学版 MyString。它不是替代 C# 原生 string,而是用来理解字符串底层:char[] 存字符,Length 表示有效长度,Capacity 表示底层数组容量,追加时容量不够就扩容。
c
using System; // 引入异常类型和 Array
using System.Collections; // 引入 IEnumerable
using System.Collections.Generic; // 引入 IEnumerator
public sealed class MyString : IEnumerable<char> // 定义一个教学版字符串类
{ // MyString 类开始
private const int DefaultCapacity = 16; // 默认初始容量
private char[] buffer; // 底层字符缓冲区
private int length; // 当前有效字符数量
public int Length => length; // 返回字符串有效长度
public int Capacity => buffer.Length; // 返回底层数组容量
public bool IsEmpty => length == 0; // 返回字符串是否为空
public MyString() // 默认构造函数
{ // 默认构造函数开始
buffer = new char[DefaultCapacity]; // 创建默认容量的字符数组
length = 0; // 初始长度为 0
} // 默认构造函数结束
public MyString(string value) // 用 C# string 构造 MyString
{ // string 构造函数开始
value = value ?? string.Empty; // 如果传入 null,就当作空字符串
int capacity = Math.Max(DefaultCapacity, value.Length); // 计算初始容量
buffer = new char[capacity]; // 创建底层字符数组
value.CopyTo(0, buffer, 0, value.Length); // 把 string 的字符复制到 buffer
length = value.Length; // 设置有效字符数量
} // string 构造函数结束
public MyString(char[] source) // 用 char 数组构造 MyString
{ // char 数组构造函数开始
int sourceLength = source == null ? 0 : source.Length; // 计算源数组长度
int capacity = Math.Max(DefaultCapacity, sourceLength); // 计算初始容量
buffer = new char[capacity]; // 创建自己的底层数组
if (sourceLength > 0) // 如果源数组有内容
{ // 源数组判断开始
Array.Copy(source, 0, buffer, 0, sourceLength); // 深拷贝源数组字符
} // 源数组判断结束
length = sourceLength; // 设置有效字符数量
} // char 数组构造函数结束
public char this[int index] // 定义下标访问器
{ // 索引器开始
get // 读取字符
{ // get 开始
CheckIndex(index); // 检查下标是否合法
return buffer[index]; // 返回指定位置的字符
} // get 结束
set // 修改字符
{ // set 开始
CheckIndex(index); // 检查下标是否合法
buffer[index] = value; // 修改指定位置的字符
} // set 结束
} // 索引器结束
public MyString Append(char value) // 追加单个字符
{ // Append(char) 方法开始
EnsureCapacity(length + 1); // 确保容量足够放下新字符
buffer[length] = value; // 把新字符写到末尾
length++; // 有效长度加 1
return this; // 返回自身,方便链式调用
} // Append(char) 方法结束
public MyString Append(string value) // 追加 C# string
{ // Append(string) 方法开始
if (string.IsNullOrEmpty(value)) // 如果追加内容为空
{ // 空字符串判断开始
return this; // 不做任何修改
} // 空字符串判断结束
EnsureCapacity(length + value.Length); // 确保容量足够
value.CopyTo(0, buffer, length, value.Length); // 把字符串复制到末尾
length += value.Length; // 更新有效长度
return this; // 返回自身
} // Append(string) 方法结束
public MyString Append(MyString value) // 追加另一个 MyString
{ // Append(MyString) 方法开始
if (value == null || value.length == 0) // 如果追加对象为空或没内容
{ // 空对象判断开始
return this; // 不做任何修改
} // 空对象判断结束
EnsureCapacity(length + value.length); // 确保容量足够
Array.Copy(value.buffer, 0, buffer, length, value.length); // 复制对方有效字符
length += value.length; // 更新有效长度
return this; // 返回自身
} // Append(MyString) 方法结束
public MyString Insert(int index, string value) // 在指定位置插入字符串
{ // Insert 方法开始
CheckInsertIndex(index); // 检查插入位置是否合法
if (string.IsNullOrEmpty(value)) // 如果插入内容为空
{ // 空内容判断开始
return this; // 不做任何修改
} // 空内容判断结束
EnsureCapacity(length + value.Length); // 确保容量足够
Array.Copy(buffer, index, buffer, index + value.Length, length - index); // 把插入点后的字符整体后移
value.CopyTo(0, buffer, index, value.Length); // 把新字符串复制到插入位置
length += value.Length; // 更新有效长度
return this; // 返回自身
} // Insert 方法结束
public MyString Remove(int index, int removeCount) // 删除指定范围的字符
{ // Remove 方法开始
CheckRemoveRange(index, removeCount); // 检查删除范围是否合法
if (removeCount == 0) // 如果删除数量为 0
{ // 数量判断开始
return this; // 不做任何修改
} // 数量判断结束
int moveCount = length - index - removeCount; // 计算需要前移的字符数量
if (moveCount > 0) // 如果删除位置后面还有字符
{ // 移动判断开始
Array.Copy(buffer, index + removeCount, buffer, index, moveCount); // 把后面的字符前移
} // 移动判断结束
Array.Clear(buffer, length - removeCount, removeCount); // 清理末尾无效区域
length -= removeCount; // 更新有效长度
return this; // 返回自身
} // Remove 方法结束
public int IndexOf(char value) // 查找字符第一次出现的位置
{ // IndexOf 方法开始
for (int i = 0; i < length; i++) // 遍历有效字符
{ // for 开始
if (buffer[i] == value) // 如果当前字符匹配
{ // 匹配判断开始
return i; // 返回当前位置
} // 匹配判断结束
} // for 结束
return -1; // 没找到返回 -1
} // IndexOf 方法结束
public bool Contains(char value) // 判断是否包含某个字符
{ // Contains 方法开始
return IndexOf(value) >= 0; // 下标大于等于 0 表示存在
} // Contains 方法结束
public void Clear() // 清空字符串内容
{ // Clear 方法开始
Array.Clear(buffer, 0, length); // 清理有效字符区域
length = 0; // 长度归零
} // Clear 方法结束
public void TrimExcess() // 裁剪多余容量
{ // TrimExcess 方法开始
if (buffer.Length == length) // 如果容量刚好等于长度
{ // 容量判断开始
return; // 不需要裁剪
} // 容量判断结束
int newCapacity = Math.Max(length, DefaultCapacity); // 计算新容量
char[] newBuffer = new char[newCapacity]; // 创建新缓冲区
Array.Copy(buffer, 0, newBuffer, 0, length); // 复制有效字符
buffer = newBuffer; // 替换底层数组
} // TrimExcess 方法结束
public override string ToString() // 转成 C# 原生 string
{ // ToString 方法开始
return new string(buffer, 0, length); // 只把有效字符区间转成 string
} // ToString 方法结束
private void EnsureCapacity(int minCapacity) // 确保容量足够
{ // EnsureCapacity 方法开始
if (buffer.Length >= minCapacity) // 如果当前容量足够
{ // 容量足够判断开始
return; // 不需要扩容
} // 容量足够判断结束
int newCapacity = buffer.Length == 0 ? DefaultCapacity : buffer.Length * 2; // 计算初始新容量
while (newCapacity < minCapacity) // 如果新容量仍然不够
{ // while 开始
newCapacity *= 2; // 容量继续翻倍
} // while 结束
char[] newBuffer = new char[newCapacity]; // 创建更大的字符数组
Array.Copy(buffer, 0, newBuffer, 0, length); // 复制旧字符
buffer = newBuffer; // 替换底层缓冲区
} // EnsureCapacity 方法结束
private void CheckIndex(int index) // 检查访问下标
{ // CheckIndex 方法开始
if (index < 0 || index >= length) // 如果下标越界
{ // 越界判断开始
throw new ArgumentOutOfRangeException(nameof(index)); // 抛出下标越界异常
} // 越界判断结束
} // CheckIndex 方法结束
private void CheckInsertIndex(int index) // 检查插入下标
{ // CheckInsertIndex 方法开始
if (index < 0 || index > length) // 插入位置允许等于 length
{ // 越界判断开始
throw new ArgumentOutOfRangeException(nameof(index)); // 抛出插入位置异常
} // 越界判断结束
} // CheckInsertIndex 方法结束
private void CheckRemoveRange(int index, int removeCount) // 检查删除范围
{ // CheckRemoveRange 方法开始
if (index < 0 || index > length) // 如果起始位置越界
{ // 起始位置判断开始
throw new ArgumentOutOfRangeException(nameof(index)); // 抛出起始位置异常
} // 起始位置判断结束
if (removeCount < 0 || index + removeCount > length) // 如果删除数量非法
{ // 删除数量判断开始
throw new ArgumentOutOfRangeException(nameof(removeCount)); // 抛出删除数量异常
} // 删除数量判断结束
} // CheckRemoveRange 方法结束
public IEnumerator<char> GetEnumerator() // 获取字符迭代器
{ // GetEnumerator 方法开始
for (int i = 0; i < length; i++) // 遍历有效字符
{ // for 开始
yield return buffer[i]; // 返回当前字符
} // for 结束
} // GetEnumerator 方法结束
IEnumerator IEnumerable.GetEnumerator() // 获取非泛型迭代器
{ // 非泛型 GetEnumerator 方法开始
return GetEnumerator(); // 复用泛型迭代器
} // 非泛型 GetEnumerator 方法结束
} // MyString 类结束使用示例
c
MyString text = new MyString("Hello"); // 创建字符串对象
text.Append(' '); // 追加空格
text.Append("Unity"); // 追加字符串
text.Insert(5, ","); // 在 Hello 后面插入逗号
text.Remove(5, 1); // 删除刚才插入的逗号
string result = text.ToString(); // 转成 C# 原生 string复杂度
下标访问是 O(1),尾部追加均摊是 O(1),扩容那一次是 O(n),中间插入和删除最坏是 O(n),因为要移动字符。
C# 原生 string 是不可变的,每次拼接通常会产生新对象;这个教学版更像简化版 StringBuilder,适合理解“字符数组 + 长度 + 容量 + 扩容”的底层思路。
写一个智能指针
标准答案
面试里“写一个智能指针”一般默认写简化版 shared_ptr,因为它能考到 RAII、引用计数、拷贝构造、赋值运算符、析构释放这些核心点。
c
#include <cstddef> // 引入空指针相关基础定义
template <typename T> // 定义模板,让智能指针可以管理任意类型
class SharedPtr // 定义简化版 shared_ptr
{ // 类开始
private: // 私有成员开始
T* ptr_; // 保存真正被管理的对象指针
int* count_; // 保存引用计数指针,多个 SharedPtr 共享同一个计数
void AddRef() // 增加引用计数
{ // AddRef 开始
if (count_ != nullptr) // 如果计数指针有效
{ // 判断开始
++(*count_); // 引用计数加一
} // 判断结束
} // AddRef 结束
void Release() // 释放当前持有的资源
{ // Release 开始
if (count_ == nullptr) // 如果当前没有管理任何资源
{ // 判断开始
return; // 直接返回
} // 判断结束
--(*count_); // 引用计数减一
if (*count_ == 0) // 如果当前是最后一个拥有者
{ // 判断开始
delete ptr_; // 释放真正的对象
delete count_; // 释放引用计数
} // 判断结束
ptr_ = nullptr; // 清空对象指针
count_ = nullptr; // 清空计数指针
} // Release 结束
public: // 公有成员开始
explicit SharedPtr(T* ptr = nullptr) // 构造函数,接管裸指针
: ptr_(ptr), // 保存对象指针
count_(ptr == nullptr ? nullptr : new int(1)) // 如果 ptr 不为空,就创建引用计数 1
{ // 构造函数开始
} // 构造函数结束
SharedPtr(const SharedPtr& other) // 拷贝构造函数
: ptr_(other.ptr_), // 共享同一个对象指针
count_(other.count_) // 共享同一个引用计数
{ // 拷贝构造开始
AddRef(); // 拷贝后引用计数加一
} // 拷贝构造结束
SharedPtr(SharedPtr&& other) noexcept // 移动构造函数
: ptr_(other.ptr_), // 接管对方的对象指针
count_(other.count_) // 接管对方的引用计数
{ // 移动构造开始
other.ptr_ = nullptr; // 清空对方对象指针
other.count_ = nullptr; // 清空对方计数指针
} // 移动构造结束
SharedPtr& operator=(const SharedPtr& other) // 拷贝赋值运算符
{ // 拷贝赋值开始
if (this == &other) // 如果是自己给自己赋值
{ // 自赋值判断开始
return *this; // 直接返回自己
} // 自赋值判断结束
Release(); // 先释放自己原来管理的资源
ptr_ = other.ptr_; // 共享对方的对象指针
count_ = other.count_; // 共享对方的引用计数
AddRef(); // 新资源引用计数加一
return *this; // 返回当前对象
} // 拷贝赋值结束
SharedPtr& operator=(SharedPtr&& other) noexcept // 移动赋值运算符
{ // 移动赋值开始
if (this == &other) // 如果是自己移动给自己
{ // 自赋值判断开始
return *this; // 直接返回自己
} // 自赋值判断结束
Release(); // 先释放自己原来管理的资源
ptr_ = other.ptr_; // 接管对方对象指针
count_ = other.count_; // 接管对方引用计数
other.ptr_ = nullptr; // 清空对方对象指针
other.count_ = nullptr; // 清空对方计数指针
return *this; // 返回当前对象
} // 移动赋值结束
~SharedPtr() // 析构函数
{ // 析构开始
Release(); // 对引用计数减一,必要时释放资源
} // 析构结束
T& operator*() const // 解引用运算符
{ // operator* 开始
return *ptr_; // 返回对象引用
} // operator* 结束
T* operator->() const // 箭头运算符
{ // operator-> 开始
return ptr_; // 返回对象指针
} // operator-> 结束
T* Get() const // 获取裸指针
{ // Get 开始
return ptr_; // 返回内部对象指针
} // Get 结束
int UseCount() const // 获取引用计数
{ // UseCount 开始
return count_ == nullptr ? 0 : *count_; // 没有资源返回 0,否则返回计数
} // UseCount 结束
explicit operator bool() const // 判断是否持有对象
{ // bool 转换开始
return ptr_ != nullptr; // 对象指针不为空就表示有效
} // bool 转换结束
void Reset(T* ptr = nullptr) // 重置管理的新对象
{ // Reset 开始
if (ptr_ == ptr) // 如果传入的就是当前指针
{ // 相同指针判断开始
return; // 直接返回,避免重复释放
} // 相同指针判断结束
Release(); // 释放旧资源
ptr_ = ptr; // 保存新对象指针
count_ = ptr == nullptr ? nullptr : new int(1); // 新对象引用计数从 1 开始
} // Reset 结束
}; // 类结束使用示例
c
SharedPtr<int> a(new int(10)); // 创建智能指针 a,引用计数是 1
SharedPtr<int> b = a; // 拷贝给 b,a 和 b 共享对象,引用计数变成 2
*b = 20; // 通过 b 修改对象,a 看到的也是同一个值
int count = a.UseCount(); // 获取引用计数,此时是 2
b.Reset(); // b 放弃资源,引用计数变成 1
a.Reset(); // a 放弃资源,引用计数变成 0,对象被 delete面试关键点
这个版本是教学版,不等于标准库 std::shared_ptr。真正的 shared_ptr 有控制块、线程安全引用计数、自定义 deleter、weak count、allocator 等机制。它的最大坑是循环引用,比如 A 持有 B,B 又持有 A,引用计数永远不归零,这时要用 weak_ptr 打破环。
写一个线程安全队列
标准答案
线程安全队列的核心是:普通 std::queue 不能被多个线程同时读写,所以要用 std::mutex 保护队列操作;如果消费者发现队列为空,就用 std::condition_variable 睡眠等待,避免一直空转浪费 CPU。
c
#include <condition_variable> // 引入条件变量
#include <mutex> // 引入互斥锁
#include <queue> // 引入 std::queue
#include <utility> // 引入 std::move
template <typename T> // 定义模板队列
class ThreadSafeQueue // 定义线程安全队列
{ // 类开始
public: // 公有接口开始
ThreadSafeQueue() = default; // 使用默认构造函数
ThreadSafeQueue(const ThreadSafeQueue&) = delete; // 禁止拷贝构造,避免锁和队列被错误复制
ThreadSafeQueue& operator=(const ThreadSafeQueue&) = delete; // 禁止拷贝赋值,避免多个对象管理同一状态
void Push(const T& value) // 推入左值任务
{ // Push 左值开始
{ // 加锁作用域开始
std::lock_guard<std::mutex> lock(mutex_); // 自动加锁并在作用域结束时解锁
if (closed_) // 如果队列已经关闭
{ // 关闭判断开始
return; // 不再接受新任务
} // 关闭判断结束
queue_.push(value); // 把任务放入队列尾部
} // 加锁作用域结束
condition_.notify_one(); // 唤醒一个等待中的消费者
} // Push 左值结束
void Push(T&& value) // 推入右值任务
{ // Push 右值开始
{ // 加锁作用域开始
std::lock_guard<std::mutex> lock(mutex_); // 保护队列写入
if (closed_) // 如果队列已经关闭
{ // 关闭判断开始
return; // 不再接受新任务
} // 关闭判断结束
queue_.push(std::move(value)); // 移动任务到队列尾部
} // 加锁作用域结束
condition_.notify_one(); // 通知一个消费者可以取任务
} // Push 右值结束
bool TryPop(T& out) // 非阻塞取出任务
{ // TryPop 开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护队列读取
if (queue_.empty()) // 如果队列为空
{ // 空队列判断开始
return false; // 立即返回失败,不阻塞
} // 空队列判断结束
out = std::move(queue_.front()); // 取出队头任务
queue_.pop(); // 删除队头任务
return true; // 返回取出成功
} // TryPop 结束
bool WaitPop(T& out) // 阻塞等待并取出任务
{ // WaitPop 开始
std::unique_lock<std::mutex> lock(mutex_); // 条件变量需要 unique_lock
condition_.wait(lock, [this]() // 等待直到有任务或队列关闭
{ // wait 谓词开始
return closed_ || !queue_.empty(); // 防止虚假唤醒,醒来后再次检查条件
}); // wait 谓词结束
if (queue_.empty()) // 如果醒来后队列仍为空
{ // 空队列判断开始
return false; // 说明队列已关闭并且没有任务了
} // 空队列判断结束
out = std::move(queue_.front()); // 取出队头任务
queue_.pop(); // 删除队头任务
return true; // 返回取出成功
} // WaitPop 结束
void Close() // 关闭队列
{ // Close 开始
{ // 加锁作用域开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护 closed_ 状态
closed_ = true; // 标记队列已经关闭
} // 加锁作用域结束
condition_.notify_all(); // 唤醒所有等待线程,让它们能正常退出
} // Close 结束
bool IsClosed() const // 判断队列是否关闭
{ // IsClosed 开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁读取 closed_
return closed_; // 返回关闭状态
} // IsClosed 结束
bool Empty() const // 判断队列是否为空
{ // Empty 开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁读取队列状态
return queue_.empty(); // 返回是否为空
} // Empty 结束
std::size_t Size() const // 获取队列大小
{ // Size 开始
std::lock_guard<std::mutex> lock(mutex_); // 加锁读取队列大小
return queue_.size(); // 返回元素数量
} // Size 结束
private: // 私有成员开始
mutable std::mutex mutex_; // 保护队列和关闭状态的互斥锁
std::condition_variable condition_; // 用于阻塞和唤醒消费者线程
std::queue<T> queue_; // 真正保存任务的普通队列
bool closed_ = false; // 队列是否已经关闭
}; // 类结束使用示例
c
ThreadSafeQueue<int> queue; // 创建线程安全队列
queue.Push(100); // 生产者放入任务
int value = 0; // 准备接收任务
bool ok = queue.WaitPop(value); // 消费者阻塞等待任务
queue.Close(); // 关闭队列并唤醒所有等待线程面试关键点
TryPop 是非阻塞的,队列空就直接返回;WaitPop 是阻塞的,队列空就睡眠等待。wait 一定要带谓词,因为条件变量可能虚假唤醒。Close 很重要,否则消费者线程可能永远卡在 WaitPop。
游戏里常见用途是日志队列、资源加载任务队列、异步 IO 任务队列。注意任务处理不要放在锁里,锁只保护“队列结构本身”。
写一个内存池
标准答案
内存池的核心是:先向系统申请一大块内存 Chunk,再切成很多固定大小的 Block。申请对象时从空闲链表 FreeList 取一个块,释放对象时把块放回 FreeList,这样就避免频繁 new/delete 或 malloc/free。
c
#include <cstddef> // 引入 std::size_t
#include <new> // 引入 placement new
#include <utility> // 引入 std::forward
#include <vector> // 引入 std::vector
class FixedBlockMemoryPool // 定义固定块内存池
{ // 类开始
private: // 私有成员开始
struct FreeNode // 定义空闲块节点
{ // FreeNode 开始
FreeNode* next; // 指向下一个空闲块
}; // FreeNode 结束
std::size_t blockSize_; // 每个块的大小
std::size_t blockCount_; // 每个 Chunk 里有多少个块
FreeNode* freeList_; // 空闲块链表头
std::vector<void*> chunks_; // 保存所有申请过的大块内存
public: // 公有成员开始
FixedBlockMemoryPool(std::size_t blockSize, std::size_t blockCount) // 构造函数
: blockSize_(blockSize < sizeof(FreeNode*) ? sizeof(FreeNode*) : blockSize), // 块大小至少能放下 next 指针
blockCount_(blockCount), // 保存每个 Chunk 的块数量
freeList_(nullptr) // 初始化空闲链表为空
{ // 构造函数开始
AllocateChunk(); // 先申请一个 Chunk
} // 构造函数结束
FixedBlockMemoryPool(const FixedBlockMemoryPool&) = delete; // 禁止拷贝构造
FixedBlockMemoryPool& operator=(const FixedBlockMemoryPool&) = delete; // 禁止拷贝赋值
~FixedBlockMemoryPool() // 析构函数
{ // 析构开始
for (void* chunk : chunks_) // 遍历所有 Chunk
{ // for 开始
::operator delete(chunk); // 释放整块原始内存
} // for 结束
} // 析构结束
void* Allocate() // 申请一块内存
{ // Allocate 开始
if (freeList_ == nullptr) // 如果没有空闲块
{ // 空闲链表判断开始
AllocateChunk(); // 申请新的 Chunk
} // 空闲链表判断结束
FreeNode* node = freeList_; // 取出空闲链表头
freeList_ = freeList_->next; // 链表头移动到下一个节点
return node; // 返回这块内存
} // Allocate 结束
void Deallocate(void* ptr) // 归还一块内存
{ // Deallocate 开始
if (ptr == nullptr) // 如果传入空指针
{ // 空指针判断开始
return; // 直接返回
} // 空指针判断结束
FreeNode* node = static_cast<FreeNode*>(ptr); // 把内存块当成 FreeNode 使用
node->next = freeList_; // 新归还的块指向旧链表头
freeList_ = node; // 更新空闲链表头
} // Deallocate 结束
private: // 私有函数开始
void AllocateChunk() // 申请一个新的 Chunk
{ // AllocateChunk 开始
void* chunk = ::operator new(blockSize_ * blockCount_); // 一次申请一大块原始内存
chunks_.push_back(chunk); // 保存 Chunk 指针,析构时统一释放
char* start = static_cast<char*>(chunk); // 转成 char* 方便按字节偏移
for (std::size_t i = 0; i < blockCount_; ++i) // 遍历 Chunk 中每个块
{ // for 开始
void* block = start + i * blockSize_; // 计算第 i 个块的地址
Deallocate(block); // 把这个块加入空闲链表
} // for 结束
} // AllocateChunk 结束
}; // FixedBlockMemoryPool 类结束
template <typename T> // 定义对象池模板
class ObjectPool // 定义面向对象的内存池封装
{ // ObjectPool 类开始
private: // 私有成员开始
FixedBlockMemoryPool pool_; // 底层固定块内存池
public: // 公有成员开始
explicit ObjectPool(std::size_t blockCount = 64) // 构造函数
: pool_(sizeof(T), blockCount) // 每个块大小等于 T 的大小
{ // 构造函数开始
} // 构造函数结束
ObjectPool(const ObjectPool&) = delete; // 禁止拷贝构造
ObjectPool& operator=(const ObjectPool&) = delete; // 禁止拷贝赋值
template <typename... Args> // 支持任意构造参数
T* Create(Args&&... args) // 创建对象
{ // Create 开始
void* memory = pool_.Allocate(); // 从内存池申请原始内存
return new (memory) T(std::forward<Args>(args)...); // 用 placement new 在这块内存上构造对象
} // Create 结束
void Destroy(T* object) // 销毁对象
{ // Destroy 开始
if (object == nullptr) // 如果对象为空
{ // 空对象判断开始
return; // 直接返回
} // 空对象判断结束
object->~T(); // 手动调用析构函数
pool_.Deallocate(object); // 把内存块归还给内存池
} // Destroy 结束
}; // ObjectPool 类结束使用示例
c
struct Bullet // 定义子弹对象
{ // Bullet 开始
int id; // 子弹 ID
float speed; // 子弹速度
Bullet(int id, float speed) // 子弹构造函数
: id(id), // 初始化 id
speed(speed) // 初始化 speed
{ // 构造函数开始
} // 构造函数结束
}; // Bullet 结束
ObjectPool<Bullet> bulletPool(128); // 创建子弹对象池
Bullet* bullet = bulletPool.Create(1, 30.0f); // 从对象池创建子弹
bulletPool.Destroy(bullet); // 销毁子弹并归还内存面试关键点
内存池把“申请内存”和“构造对象”分开了:Allocate 只拿原始内存,placement new 才真正构造对象;释放时也要先手动调用析构函数,再把内存块归还池子。
它适合大量同尺寸对象频繁创建销毁,比如子弹、粒子、消息节点。常见坑是重复释放、对象尺寸不匹配、多线程访问没加锁,以及没有做泄漏检测。
写一个组件容器
标准答案
组件容器常用于 ECS:Entity 只是 ID,真正的组件数据连续存在 vector<T> 里;再用 unordered_map<Entity, index> 把实体映射到组件下标。这样查询快,遍历也快。
c
#include <cstdint> // 引入固定宽度整数类型
#include <stdexcept> // 引入异常类型
#include <unordered_map> // 引入哈希表
#include <utility> // 引入 std::forward 和 std::move
#include <vector> // 引入 std::vector
using Entity = std::uint32_t; // 定义实体 ID 类型
template <typename T> // 定义组件容器模板
class ComponentContainer // 定义组件容器类
{ // 类开始
private: // 私有成员开始
std::vector<T> components_; // 稠密存储同类型组件
std::vector<Entity> entities_; // 保存每个组件对应的实体 ID
std::unordered_map<Entity, std::size_t> entityToIndex_; // 保存实体 ID 到组件下标的映射
public: // 公有接口开始
bool Has(Entity entity) const // 判断实体是否拥有该组件
{ // Has 开始
return entityToIndex_.find(entity) != entityToIndex_.end(); // 哈希表中存在就表示有组件
} // Has 结束
template <typename... Args> // 支持任意构造参数
T& Emplace(Entity entity, Args&&... args) // 给实体添加组件
{ // Emplace 开始
if (Has(entity)) // 如果实体已经有该组件
{ // 判断开始
throw std::runtime_error("Entity already has this component."); // 抛出重复添加异常
} // 判断结束
std::size_t index = components_.size(); // 新组件下标就是当前数组长度
components_.emplace_back(std::forward<Args>(args)...); // 在组件数组尾部构造组件
entities_.push_back(entity); // 记录这个下标对应的实体
entityToIndex_[entity] = index; // 建立实体到下标的映射
return components_.back(); // 返回刚创建的组件引用
} // Emplace 结束
template <typename... Args> // 支持任意构造参数
T& AddOrReplace(Entity entity, Args&&... args) // 添加或替换组件
{ // AddOrReplace 开始
auto it = entityToIndex_.find(entity); // 查找实体是否已有组件
if (it != entityToIndex_.end()) // 如果已经有组件
{ // 判断开始
components_[it->second] = T(std::forward<Args>(args)...); // 用新组件覆盖旧组件
return components_[it->second]; // 返回替换后的组件引用
} // 判断结束
return Emplace(entity, std::forward<Args>(args)...); // 没有组件就新建组件
} // AddOrReplace 结束
T& Get(Entity entity) // 获取组件引用
{ // Get 开始
auto it = entityToIndex_.find(entity); // 查找实体对应下标
if (it == entityToIndex_.end()) // 如果找不到组件
{ // 判断开始
throw std::runtime_error("Entity does not have this component."); // 抛出不存在异常
} // 判断结束
return components_[it->second]; // 返回组件引用
} // Get 结束
const T& Get(Entity entity) const // 获取只读组件引用
{ // const Get 开始
auto it = entityToIndex_.find(entity); // 查找实体对应下标
if (it == entityToIndex_.end()) // 如果找不到组件
{ // 判断开始
throw std::runtime_error("Entity does not have this component."); // 抛出不存在异常
} // 判断结束
return components_[it->second]; // 返回只读组件引用
} // const Get 结束
T* TryGet(Entity entity) // 尝试获取组件指针
{ // TryGet 开始
auto it = entityToIndex_.find(entity); // 查找实体对应下标
return it == entityToIndex_.end() ? nullptr : &components_[it->second]; // 找不到返回空指针
} // TryGet 结束
bool Remove(Entity entity) // 删除实体身上的组件
{ // Remove 开始
auto it = entityToIndex_.find(entity); // 查找实体对应下标
if (it == entityToIndex_.end()) // 如果实体没有该组件
{ // 判断开始
return false; // 删除失败
} // 判断结束
std::size_t removeIndex = it->second; // 记录要删除的位置
std::size_t lastIndex = components_.size() - 1; // 记录最后一个组件的位置
Entity lastEntity = entities_[lastIndex]; // 记录最后一个组件对应的实体
if (removeIndex != lastIndex) // 如果删除的不是最后一个
{ // 判断开始
components_[removeIndex] = std::move(components_[lastIndex]); // 用最后一个组件填补空洞
entities_[removeIndex] = lastEntity; // 同步移动实体 ID
entityToIndex_[lastEntity] = removeIndex; // 更新被移动实体的新下标
} // 判断结束
components_.pop_back(); // 删除最后一个组件
entities_.pop_back(); // 删除最后一个实体记录
entityToIndex_.erase(entity); // 删除被移除实体的映射
return true; // 删除成功
} // Remove 结束
template <typename Func> // 支持传入任意可调用对象
void ForEach(Func&& func) // 遍历所有组件
{ // ForEach 开始
for (std::size_t i = 0; i < components_.size(); ++i) // 遍历组件数组
{ // for 开始
func(entities_[i], components_[i]); // 把实体 ID 和组件传给外部逻辑
} // for 结束
} // ForEach 结束
void Clear() // 清空容器
{ // Clear 开始
components_.clear(); // 清空组件数组
entities_.clear(); // 清空实体数组
entityToIndex_.clear(); // 清空映射表
} // Clear 结束
std::size_t Size() const // 获取组件数量
{ // Size 开始
return components_.size(); // 返回组件数组大小
} // Size 结束
}; // 类结束使用示例
c
struct Transform // 定义 Transform 组件
{ // Transform 开始
float x; // x 坐标
float y; // y 坐标
}; // Transform 结束
ComponentContainer<Transform> transforms; // 创建 Transform 组件容器
transforms.Emplace(100, Transform{1.0f, 2.0f}); // 给实体 100 添加组件
Transform& t = transforms.Get(100); // 获取实体 100 的组件
t.x += 10.0f; // 修改组件数据
transforms.Remove(100); // 删除实体 100 的组件面试关键点
这个设计的优点是遍历快,因为组件连续存储,CPU 缓存友好;查询也快,因为 Entity -> index 是哈希表。删除用 swap-remove 可以做到 O(1),但代价是组件顺序不稳定。
写一个事件分发器
标准答案
事件分发器的核心是:发布者只负责发事件,不关心谁处理;监听者按事件类型注册回调;分发器根据事件类型找到监听列表并依次调用。它适合 UI 刷新、任务进度、成就触发、日志通知这类“通知型逻辑”。
c
#include <algorithm> // 引入 std::remove_if
#include <cstdint> // 引入 std::uint64_t
#include <functional> // 引入 std::function
#include <typeindex> // 引入 std::type_index
#include <typeinfo> // 引入 typeid
#include <unordered_map> // 引入 std::unordered_map
#include <utility> // 引入 std::move
#include <vector> // 引入 std::vector
class EventDispatcher // 定义事件分发器
{ // 类开始
public: // 公有类型开始
using ListenerId = std::uint64_t; // 定义监听器 ID 类型
private: // 私有成员开始
struct Listener // 定义监听器结构
{ // Listener 开始
ListenerId id = 0; // 保存监听器唯一 ID
std::function<void(const void*)> callback; // 保存类型擦除后的回调
bool removed = false; // 标记该监听器是否已被移除
}; // Listener 结束
std::unordered_map<std::type_index, std::vector<Listener>> listeners_; // 事件类型到监听列表的映射
std::unordered_map<std::type_index, int> dispatchDepth_; // 记录某类事件当前是否正在派发
ListenerId nextId_ = 1; // 下一个监听器 ID
public: // 公有函数开始
template <typename Event> // 订阅某种事件类型
ListenerId Subscribe(std::function<void(const Event&)> callback) // 注册监听回调
{ // Subscribe 开始
if (!callback) // 如果回调为空
{ // 空回调判断开始
return 0; // 返回无效 ID
} // 空回调判断结束
std::type_index type(typeid(Event)); // 获取事件类型标识
Listener listener; // 创建监听器对象
listener.id = nextId_++; // 分配监听器 ID
listener.removed = false; // 默认没有被移除
listener.callback = [callback](const void* eventPtr) // 包装成 void* 回调
{ // lambda 开始
callback(*static_cast<const Event*>(eventPtr)); // 转回真实事件类型并调用
}; // lambda 结束
listeners_[type].push_back(std::move(listener)); // 加入对应事件类型的监听列表
return nextId_ - 1; // 返回刚刚分配的监听器 ID
} // Subscribe 结束
template <typename Event> // 取消订阅某种事件类型
void Unsubscribe(ListenerId id) // 注销监听器
{ // Unsubscribe 开始
std::type_index type(typeid(Event)); // 获取事件类型标识
auto it = listeners_.find(type); // 查找该事件类型的监听列表
if (it == listeners_.end()) // 如果该事件类型没有监听器
{ // 判断开始
return; // 直接返回
} // 判断结束
std::vector<Listener>& list = it->second; // 取出监听列表引用
bool dispatching = IsDispatching(type); // 判断当前是否正在派发该事件
for (auto listenerIt = list.begin(); listenerIt != list.end(); ++listenerIt) // 遍历监听列表
{ // for 开始
if (listenerIt->id != id) // 如果 ID 不匹配
{ // 判断开始
continue; // 继续找下一个
} // 判断结束
if (dispatching) // 如果正在派发中
{ // 派发中分支开始
listenerIt->removed = true; // 只做标记,避免迭代器失效
} // 派发中分支结束
else // 如果当前没有派发
{ // 非派发分支开始
list.erase(listenerIt); // 直接从列表删除
} // 非派发分支结束
break; // 找到目标后结束循环
} // for 结束
if (!dispatching && list.empty()) // 如果没有派发且列表已经空了
{ // 空列表判断开始
listeners_.erase(it); // 移除该事件类型
} // 空列表判断结束
} // Unsubscribe 结束
template <typename Event> // 派发某种事件
void Dispatch(const Event& event) // 分发事件
{ // Dispatch 开始
std::type_index type(typeid(Event)); // 获取事件类型标识
auto it = listeners_.find(type); // 查找监听列表
if (it == listeners_.end()) // 如果没有监听者
{ // 判断开始
return; // 直接返回
} // 判断结束
BeginDispatch(type); // 标记该事件正在派发
std::vector<Listener>& list = it->second; // 获取监听列表引用
std::size_t originalSize = list.size(); // 记录派发开始时的监听数量
for (std::size_t i = 0; i < originalSize && i < list.size(); ++i) // 按注册顺序遍历监听器
{ // for 开始
if (list[i].removed) // 如果监听器已经被标记删除
{ // 判断开始
continue; // 跳过这个监听器
} // 判断结束
list[i].callback(&event); // 调用监听器回调
} // for 结束
EndDispatch(type); // 标记派发结束并清理删除项
} // Dispatch 结束
void Clear() // 清空所有事件监听
{ // Clear 开始
listeners_.clear(); // 清空所有监听列表
dispatchDepth_.clear(); // 清空派发状态
} // Clear 结束
private: // 私有函数开始
bool IsDispatching(const std::type_index& type) const // 判断某类事件是否正在派发
{ // IsDispatching 开始
auto it = dispatchDepth_.find(type); // 查找派发深度
return it != dispatchDepth_.end() && it->second > 0; // 深度大于 0 表示正在派发
} // IsDispatching 结束
void BeginDispatch(const std::type_index& type) // 开始派发
{ // BeginDispatch 开始
dispatchDepth_[type]++; // 派发深度加一
} // BeginDispatch 结束
void EndDispatch(const std::type_index& type) // 结束派发
{ // EndDispatch 开始
dispatchDepth_[type]--; // 派发深度减一
if (dispatchDepth_[type] == 0) // 如果该事件派发完全结束
{ // 判断开始
dispatchDepth_.erase(type); // 移除派发状态
Compact(type); // 清理被标记删除的监听器
} // 判断结束
} // EndDispatch 结束
void Compact(const std::type_index& type) // 清理删除标记
{ // Compact 开始
auto it = listeners_.find(type); // 查找监听列表
if (it == listeners_.end()) // 如果列表不存在
{ // 判断开始
return; // 直接返回
} // 判断结束
std::vector<Listener>& list = it->second; // 获取监听列表
list.erase(std::remove_if(list.begin(), list.end(), [](const Listener& listener) // 删除被标记的监听器
{ // lambda 开始
return listener.removed; // removed 为 true 就删除
}), list.end()); // erase-remove 惯用法结束
if (list.empty()) // 如果清理后列表为空
{ // 空列表判断开始
listeners_.erase(it); // 删除该事件类型
} // 空列表判断结束
} // Compact 结束
}; // 类结束使用示例
c
struct DamageEvent // 定义伤害事件
{ // DamageEvent 开始
int targetId; // 受击目标 ID
int damage; // 伤害值
}; // DamageEvent 结束
EventDispatcher dispatcher; // 创建事件分发器
EventDispatcher::ListenerId id = dispatcher.Subscribe<DamageEvent>([](const DamageEvent& event) // 注册伤害事件监听
{ // lambda 开始
int target = event.targetId; // 读取受击目标
int damage = event.damage; // 读取伤害值
}); // lambda 结束
dispatcher.Dispatch(DamageEvent{1001, 50}); // 派发一次伤害事件
dispatcher.Unsubscribe<DamageEvent>(id); // 取消监听面试关键点
事件分发器能降低模块耦合,比如战斗模块发 DamageEvent,UI、任务、成就系统各自监听。但它不是万能的:核心流程不要全靠事件串起来,否则调用链会很难追踪。派发过程中注销监听时,不能直接 erase,否则容易导致迭代器失效,所以这里用 removed 标记,派发结束后统一清理。
写一个二叉树遍历
标准答案
事件分发器的核心是:发布者只负责发事件,不关心谁处理;监听者按事件类型注册回调;分发器根据事件类型找到监听列表并依次调用。它适合 UI 刷新、任务进度、成就触发、日志通知这类“通知型逻辑”。
c
#include <algorithm> // 引入 std::remove_if
#include <cstdint> // 引入 std::uint64_t
#include <functional> // 引入 std::function
#include <typeindex> // 引入 std::type_index
#include <typeinfo> // 引入 typeid
#include <unordered_map> // 引入 std::unordered_map
#include <utility> // 引入 std::move
#include <vector> // 引入 std::vector
class EventDispatcher // 定义事件分发器
{ // 类开始
public: // 公有类型开始
using ListenerId = std::uint64_t; // 定义监听器 ID 类型
private: // 私有成员开始
struct Listener // 定义监听器结构
{ // Listener 开始
ListenerId id = 0; // 保存监听器唯一 ID
std::function<void(const void*)> callback; // 保存类型擦除后的回调
bool removed = false; // 标记该监听器是否已被移除
}; // Listener 结束
std::unordered_map<std::type_index, std::vector<Listener>> listeners_; // 事件类型到监听列表的映射
std::unordered_map<std::type_index, int> dispatchDepth_; // 记录某类事件当前是否正在派发
ListenerId nextId_ = 1; // 下一个监听器 ID
public: // 公有函数开始
template <typename Event> // 订阅某种事件类型
ListenerId Subscribe(std::function<void(const Event&)> callback) // 注册监听回调
{ // Subscribe 开始
if (!callback) // 如果回调为空
{ // 空回调判断开始
return 0; // 返回无效 ID
} // 空回调判断结束
std::type_index type(typeid(Event)); // 获取事件类型标识
Listener listener; // 创建监听器对象
listener.id = nextId_++; // 分配监听器 ID
listener.removed = false; // 默认没有被移除
listener.callback = [callback](const void* eventPtr) // 包装成 void* 回调
{ // lambda 开始
callback(*static_cast<const Event*>(eventPtr)); // 转回真实事件类型并调用
}; // lambda 结束
listeners_[type].push_back(std::move(listener)); // 加入对应事件类型的监听列表
return nextId_ - 1; // 返回刚刚分配的监听器 ID
} // Subscribe 结束
template <typename Event> // 取消订阅某种事件类型
void Unsubscribe(ListenerId id) // 注销监听器
{ // Unsubscribe 开始
std::type_index type(typeid(Event)); // 获取事件类型标识
auto it = listeners_.find(type); // 查找该事件类型的监听列表
if (it == listeners_.end()) // 如果该事件类型没有监听器
{ // 判断开始
return; // 直接返回
} // 判断结束
std::vector<Listener>& list = it->second; // 取出监听列表引用
bool dispatching = IsDispatching(type); // 判断当前是否正在派发该事件
for (auto listenerIt = list.begin(); listenerIt != list.end(); ++listenerIt) // 遍历监听列表
{ // for 开始
if (listenerIt->id != id) // 如果 ID 不匹配
{ // 判断开始
continue; // 继续找下一个
} // 判断结束
if (dispatching) // 如果正在派发中
{ // 派发中分支开始
listenerIt->removed = true; // 只做标记,避免迭代器失效
} // 派发中分支结束
else // 如果当前没有派发
{ // 非派发分支开始
list.erase(listenerIt); // 直接从列表删除
} // 非派发分支结束
break; // 找到目标后结束循环
} // for 结束
if (!dispatching && list.empty()) // 如果没有派发且列表已经空了
{ // 空列表判断开始
listeners_.erase(it); // 移除该事件类型
} // 空列表判断结束
} // Unsubscribe 结束
template <typename Event> // 派发某种事件
void Dispatch(const Event& event) // 分发事件
{ // Dispatch 开始
std::type_index type(typeid(Event)); // 获取事件类型标识
auto it = listeners_.find(type); // 查找监听列表
if (it == listeners_.end()) // 如果没有监听者
{ // 判断开始
return; // 直接返回
} // 判断结束
BeginDispatch(type); // 标记该事件正在派发
std::vector<Listener>& list = it->second; // 获取监听列表引用
std::size_t originalSize = list.size(); // 记录派发开始时的监听数量
for (std::size_t i = 0; i < originalSize && i < list.size(); ++i) // 按注册顺序遍历监听器
{ // for 开始
if (list[i].removed) // 如果监听器已经被标记删除
{ // 判断开始
continue; // 跳过这个监听器
} // 判断结束
list[i].callback(&event); // 调用监听器回调
} // for 结束
EndDispatch(type); // 标记派发结束并清理删除项
} // Dispatch 结束
void Clear() // 清空所有事件监听
{ // Clear 开始
listeners_.clear(); // 清空所有监听列表
dispatchDepth_.clear(); // 清空派发状态
} // Clear 结束
private: // 私有函数开始
bool IsDispatching(const std::type_index& type) const // 判断某类事件是否正在派发
{ // IsDispatching 开始
auto it = dispatchDepth_.find(type); // 查找派发深度
return it != dispatchDepth_.end() && it->second > 0; // 深度大于 0 表示正在派发
} // IsDispatching 结束
void BeginDispatch(const std::type_index& type) // 开始派发
{ // BeginDispatch 开始
dispatchDepth_[type]++; // 派发深度加一
} // BeginDispatch 结束
void EndDispatch(const std::type_index& type) // 结束派发
{ // EndDispatch 开始
dispatchDepth_[type]--; // 派发深度减一
if (dispatchDepth_[type] == 0) // 如果该事件派发完全结束
{ // 判断开始
dispatchDepth_.erase(type); // 移除派发状态
Compact(type); // 清理被标记删除的监听器
} // 判断结束
} // EndDispatch 结束
void Compact(const std::type_index& type) // 清理删除标记
{ // Compact 开始
auto it = listeners_.find(type); // 查找监听列表
if (it == listeners_.end()) // 如果列表不存在
{ // 判断开始
return; // 直接返回
} // 判断结束
std::vector<Listener>& list = it->second; // 获取监听列表
list.erase(std::remove_if(list.begin(), list.end(), [](const Listener& listener) // 删除被标记的监听器
{ // lambda 开始
return listener.removed; // removed 为 true 就删除
}), list.end()); // erase-remove 惯用法结束
if (list.empty()) // 如果清理后列表为空
{ // 空列表判断开始
listeners_.erase(it); // 删除该事件类型
} // 空列表判断结束
} // Compact 结束
}; // 类结束使用示例
c
struct DamageEvent // 定义伤害事件
{ // DamageEvent 开始
int targetId; // 受击目标 ID
int damage; // 伤害值
}; // DamageEvent 结束
EventDispatcher dispatcher; // 创建事件分发器
EventDispatcher::ListenerId id = dispatcher.Subscribe<DamageEvent>([](const DamageEvent& event) // 注册伤害事件监听
{ // lambda 开始
int target = event.targetId; // 读取受击目标
int damage = event.damage; // 读取伤害值
}); // lambda 结束
dispatcher.Dispatch(DamageEvent{1001, 50}); // 派发一次伤害事件
dispatcher.Unsubscribe<DamageEvent>(id); // 取消监听面试关键点
事件分发器能降低模块耦合,比如战斗模块发 DamageEvent,UI、任务、成就系统各自监听。但它不是万能的:核心流程不要全靠事件串起来,否则调用链会很难追踪。派发过程中注销监听时,不能直接 erase,否则容易导致迭代器失效,所以这里用 removed 标记,派发结束后统一清理。
写一个哈希表
一句话定义 哈希表就是用 hash(key) 把 key 映射到数组下标,通过“桶数组 + 冲突处理”实现平均 O(1) 的增删查。
底层原理
- 先算
hashCode。 - 用
hash % buckets.Length找到桶下标。 - 如果多个 key 落到同一个桶,就形成冲突。
- 这里用“链地址法”:同一个桶里挂一条链表。
- 元素太多时扩容,然后重新计算桶位置。
c
using System; // 引入异常类型
using System.Collections.Generic; // 引入 EqualityComparer 和 KeyNotFoundException
public sealed class SimpleHashTable<TKey, TValue> // 定义一个简化版哈希表
{ // 哈希表类开始
private sealed class Entry // 定义桶里的链表节点
{ // 节点类开始
public TKey Key; // 保存键
public TValue Value; // 保存值
public int Hash; // 保存 hash 值,避免反复计算
public Entry Next; // 指向同一个桶中的下一个节点
public Entry(TKey key, TValue value, int hash, Entry next) // 节点构造函数
{ // 构造函数开始
Key = key; // 初始化键
Value = value; // 初始化值
Hash = hash; // 初始化 hash
Next = next; // 初始化下一个节点
} // 构造函数结束
} // 节点类结束
private Entry[] buckets; // 桶数组,每个位置挂一条链表
private int count; // 当前键值对数量
private readonly float loadFactor; // 负载因子,超过后扩容
private readonly EqualityComparer<TKey> comparer; // key 的相等比较器
public int Count => count; // 对外暴露元素数量
public int Capacity => buckets.Length; // 对外暴露桶数组容量
public SimpleHashTable(int capacity = 16, float loadFactor = 0.75f) // 构造哈希表
{ // 构造函数开始
if (capacity <= 0) // 判断初始容量是否合法
{ // if 开始
throw new ArgumentOutOfRangeException(nameof(capacity)); // 容量非法就抛异常
} // if 结束
this.loadFactor = loadFactor; // 保存负载因子
buckets = new Entry[capacity]; // 创建桶数组
comparer = EqualityComparer<TKey>.Default; // 使用默认 key 比较器
} // 构造函数结束
public void Put(TKey key, TValue value) // 添加或更新键值对
{ // Put 开始
EnsureCapacity(count + 1); // 插入前检查是否需要扩容
int hash = GetHash(key); // 计算 key 的 hash
int index = GetBucketIndex(hash, buckets.Length); // 根据 hash 算桶下标
Entry current = buckets[index]; // 取出当前桶的链表头
while (current != null) // 遍历当前桶的冲突链
{ // while 开始
if (current.Hash == hash && comparer.Equals(current.Key, key)) // 先比 hash 再比 Equals
{ // if 开始
current.Value = value; // key 已存在就覆盖 value
return; // 更新完成后直接返回
} // if 结束
current = current.Next; // 继续检查下一个节点
} // while 结束
buckets[index] = new Entry(key, value, hash, buckets[index]); // 头插法插入新节点
count++; // 元素数量加一
} // Put 结束
public bool TryGet(TKey key, out TValue value) // 尝试根据 key 获取 value
{ // TryGet 开始
int hash = GetHash(key); // 计算 key 的 hash
int index = GetBucketIndex(hash, buckets.Length); // 找到桶下标
Entry current = buckets[index]; // 取出桶头节点
while (current != null) // 遍历冲突链
{ // while 开始
if (current.Hash == hash && comparer.Equals(current.Key, key)) // 判断是否找到目标 key
{ // if 开始
value = current.Value; // 输出找到的 value
return true; // 返回查找成功
} // if 结束
current = current.Next; // 继续找下一个节点
} // while 结束
value = default(TValue); // 找不到时返回默认值
return false; // 返回查找失败
} // TryGet 结束
public TValue Get(TKey key) // 根据 key 获取 value,找不到就抛异常
{ // Get 开始
if (TryGet(key, out TValue value)) // 调用 TryGet 查找
{ // if 开始
return value; // 找到就返回 value
} // if 结束
throw new KeyNotFoundException("Key not found."); // 找不到就抛异常
} // Get 结束
public bool Remove(TKey key) // 删除指定 key
{ // Remove 开始
int hash = GetHash(key); // 计算 key 的 hash
int index = GetBucketIndex(hash, buckets.Length); // 找到桶下标
Entry current = buckets[index]; // 当前节点从桶头开始
Entry previous = null; // 前一个节点初始为空
while (current != null) // 遍历冲突链
{ // while 开始
if (current.Hash == hash && comparer.Equals(current.Key, key)) // 判断是否找到目标节点
{ // if 开始
if (previous == null) // 如果删除的是桶头节点
{ // if 开始
buckets[index] = current.Next; // 桶头改成下一个节点
} // if 结束
else // 如果删除的不是桶头节点
{ // else 开始
previous.Next = current.Next; // 让前一个节点跳过当前节点
} // else 结束
count--; // 元素数量减一
return true; // 返回删除成功
} // if 结束
previous = current; // 当前节点变成前一个节点
current = current.Next; // 当前节点往后移动
} // while 结束
return false; // 没找到就返回删除失败
} // Remove 结束
private void EnsureCapacity(int targetCount) // 检查是否需要扩容
{ // EnsureCapacity 开始
if (targetCount <= buckets.Length * loadFactor) // 如果数量没有超过阈值
{ // if 开始
return; // 不需要扩容
} // if 结束
Resize(buckets.Length * 2); // 扩容为原来的两倍
} // EnsureCapacity 结束
private void Resize(int newCapacity) // 扩容并重新分桶
{ // Resize 开始
Entry[] oldBuckets = buckets; // 保存旧桶数组
buckets = new Entry[newCapacity]; // 创建新桶数组
count = 0; // 数量清零,后面重新统计
for (int i = 0; i < oldBuckets.Length; i++) // 遍历旧桶数组
{ // for 开始
Entry current = oldBuckets[i]; // 取出旧桶头节点
while (current != null) // 遍历旧桶链表
{ // while 开始
Entry next = current.Next; // 先保存下一个节点
int index = GetBucketIndex(current.Hash, buckets.Length); // 用新容量重新计算桶下标
current.Next = buckets[index]; // 当前节点头插到新桶
buckets[index] = current; // 更新新桶头节点
count++; // 元素数量加一
current = next; // 继续处理旧链表下一个节点
} // while 结束
} // for 结束
} // Resize 结束
private int GetHash(TKey key) // 计算非负 hash
{ // GetHash 开始
if (key == null) // 这里简单禁止 null key
{ // if 开始
throw new ArgumentNullException(nameof(key)); // key 为空就抛异常
} // if 结束
return comparer.GetHashCode(key) & 0x7fffffff; // 去掉符号位,保证 hash 非负
} // GetHash 结束
private int GetBucketIndex(int hash, int bucketLength) // 根据 hash 计算桶下标
{ // GetBucketIndex 开始
return hash % bucketLength; // 对桶数组长度取模
} // GetBucketIndex 结束
} // 哈希表类结束复杂度 平均查找、插入、删除都是 O(1);如果冲突非常严重,最坏会退化成 O(n)。空间复杂度是 O(n)。
面试补一句 真实的 C# Dictionary<TKey, TValue> 也核心依赖 hashCode + bucket + entry,但实现更工程化,比如用数组下标串链、处理空槽复用、版本号、防止遍历时修改等。
写一个 A* 伪代码
标准答案 A* 的核心是:每次优先选择 f = g + h 最小的节点继续搜索。
g 是从起点走到当前节点的真实代价,h 是从当前节点到终点的预估代价,f 是综合评分。找到终点后,通过每个节点保存的 parent 指针反向回溯出路径。
A* 伪代码,C# 风格
c
List<Node> AStar(Node start, Node end, Grid grid) // 从起点 start 搜索到终点 end
{ // 方法开始
PriorityQueue<Node> openSet = new PriorityQueue<Node>(); // OpenSet 保存待搜索节点,并按 f 最小优先
HashSet<Node> closedSet = new HashSet<Node>(); // ClosedSet 保存已经处理过的节点,避免重复搜索
start.G = 0; // 起点到自己的真实代价是 0
start.H = Heuristic(start, end); // 计算起点到终点的预估代价
start.F = start.G + start.H; // 起点的综合评分等于 g + h
start.Parent = null; // 起点没有父节点
openSet.Push(start, start.F); // 把起点放入 OpenSet
while (openSet.Count > 0) // 只要还有候选节点就继续搜索
{ // 循环开始
Node current = openSet.PopMin(); // 取出 f 最小的节点作为当前节点
if (current == end) // 如果当前节点就是终点
{ // 判断开始
return BuildPath(end); // 沿 parent 反向回溯路径并返回
} // 判断结束
closedSet.Add(current); // 当前节点处理完,加入 ClosedSet
foreach (Node neighbor in grid.GetNeighbors(current)) // 遍历当前节点的所有邻居
{ // 遍历开始
if (neighbor.IsWall) // 如果邻居是墙或者障碍物
{ // 判断开始
continue; // 跳过这个邻居
} // 判断结束
if (closedSet.Contains(neighbor)) // 如果邻居已经处理过
{ // 判断开始
continue; // 跳过这个邻居
} // 判断结束
int newG = current.G + MoveCost(current, neighbor); // 计算从当前节点走到邻居的新 g 值
if (!openSet.Contains(neighbor) || newG < neighbor.G) // 如果邻居没进过 OpenSet,或者找到了更短路径
{ // 判断开始
neighbor.G = newG; // 更新邻居的真实路径代价
neighbor.H = Heuristic(neighbor, end); // 更新邻居到终点的预估代价
neighbor.F = neighbor.G + neighbor.H; // 更新邻居的综合评分
neighbor.Parent = current; // 记录父节点,方便最后回溯路径
if (!openSet.Contains(neighbor)) // 如果邻居还不在 OpenSet 中
{ // 判断开始
openSet.Push(neighbor, neighbor.F); // 加入 OpenSet 等待后续搜索
} // 判断结束
else // 如果邻居已经在 OpenSet 中
{ // 否则开始
openSet.UpdatePriority(neighbor, neighbor.F); // 更新它在优先队列里的优先级
} // 否则结束
} // 判断结束
} // 遍历结束
} // 循环结束
return null; // OpenSet 空了还没找到终点,说明没有路径
} // 方法结束
int Heuristic(Node a, Node b) // 计算启发函数 h
{ // 方法开始
return Abs(a.X - b.X) + Abs(a.Y - b.Y); // 四方向网格常用曼哈顿距离
} // 方法结束
int MoveCost(Node a, Node b) // 计算两个相邻节点之间的移动代价
{ // 方法开始
return 1; // 普通四方向网格每走一格代价为 1
} // 方法结束
List<Node> BuildPath(Node end) // 根据终点反向构建路径
{ // 方法开始
List<Node> path = new List<Node>(); // 创建路径列表
Node current = end; // 从终点开始回溯
while (current != null) // 只要当前节点不为空
{ // 循环开始
path.Add(current); // 把当前节点加入路径
current = current.Parent; // 移动到父节点
} // 循环结束
path.Reverse(); // 因为是从终点回溯到起点,所以需要反转
return path; // 返回从起点到终点的路径
} // 方法结束面试里这样说更稳 A* 比 BFS 多了一个启发函数 h,所以它不是盲目一圈圈扩散,而是优先往“看起来更接近终点”的方向走。h = 0 时,A* 会退化成 Dijkstra;如果 h 估得过大,可能更快,但不一定保证最短路径。
复杂度 如果 OpenSet 用堆或优先队列维护,常见复杂度是 O(E log V);空间复杂度是 O(V)。在 Unity 里,地图很大时不要一次性全图寻路,可以分帧、异步、分块,或者用寻路缓存减少重复计算。