Skip to content

C# 手写

手写单例模式

核心解释

单例模式就是:一个类全局只允许创建一个对象,并提供 Instance 这种统一入口访问它。

csharp-unity-singleton-pattern

C# 手写线程安全单例

c
public sealed class GameManager // sealed 防止别人继承后破坏单例语义。
{ // 类开始。
    private static volatile GameManager instance; // 保存唯一实例,volatile 防止多线程下读到不完整对象。
    private static readonly object locker = new object(); // 创建锁对象,用来保证多线程创建实例时安全。
    private GameManager() // 私有构造函数,禁止外部 new GameManager。
    { // 构造函数开始。
    } // 构造函数结束。
    public static GameManager Instance // 提供全局访问入口。
    { // 属性开始。
        get // 获取单例对象。
        { // get 开始。
            if (instance == null) // 第一层判断,避免每次访问都加锁。
            { // 第一层判断开始。
                lock (locker) // 多线程同时进来时,只允许一个线程创建对象。
                { // lock 开始。
                    if (instance == null) // 第二层判断,防止多个线程排队后重复创建。
                    { // 第二层判断开始。
                        instance = new GameManager(); // 真正创建唯一实例。
                    } // 第二层判断结束。
                } // lock 结束。
            } // 第一层判断结束。
            return instance; // 返回唯一实例。
        } // get 结束。
    } // 属性结束。
    public void Init() // 示例初始化方法。
    { // 方法开始。
        System.Console.WriteLine("GameManager Init"); // 示例输出,真实项目里写初始化逻辑。
    } // 方法结束。
} // 类结束。

怎么使用

c
GameManager.Instance.Init(); // 通过 Instance 访问唯一的 GameManager 对象。

Unity 里的 MonoBehaviour 单例

MonoBehaviour 不能直接 new,所以通常在 Awake 里设置:

c
using UnityEngine; // 引入 UnityEngine 命名空间。
public sealed class AudioManager : MonoBehaviour // 定义 Unity 音频管理器单例。
{ // 类开始。
    public static AudioManager Instance { get; private set; } // 对外只读,对内可设置的单例入口。
    private void Awake() // Unity 创建对象后会调用 Awake。
    { // 方法开始。
        if (Instance != null && Instance != this) // 如果已经存在另一个 AudioManager。
        { // 判断开始。
            Destroy(gameObject); // 销毁重复创建的对象。
            return; // 直接返回,避免继续执行初始化。
        } // 判断结束。
        Instance = this; // 把当前对象设置为唯一实例。
        DontDestroyOnLoad(gameObject); // 切换场景时不销毁这个对象。
    } // 方法结束。
} // 类结束。

面试高分回答

NOTE

单例模式通过私有构造函数限制外部创建对象,再用静态属性提供全局访问点。普通 C# 里要注意线程安全,可以用静态初始化、Lazy<T> 或双重检查锁。Unity 里如果单例继承 MonoBehaviour,不能直接 new,通常在 Awake 中赋值 Instance,并用 DontDestroyOnLoad 保持跨场景存在,同时要销毁重复对象,避免切场景时出现多个实例。

一句话记忆

单例就是全局只留一个对象,访问方便,但不要滥用,否则模块会越来越耦合。

手写对象池

核心解释

对象池就是:对象用完不销毁,而是放回池子;下次需要时直接拿出来复用。Unity 里常用于子弹、特效、飘字、怪物、UI Item,能减少 InstantiateDestroy 和 GC 压力。

csharp-unity-object-pool-handwritten

Unity GameObject 对象池

c
using System.Collections.Generic; // 引入 Stack 集合,用来保存空闲对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、MonoBehaviour。

public sealed class GameObjectPool : MonoBehaviour // 定义一个不可继承的 Unity 对象池类。
{ // 类开始。
    [SerializeField] private GameObject prefab; // 要被池化的预制体。
    [SerializeField] private int defaultCapacity = 10; // 初始预创建数量。
    [SerializeField] private int maxCapacity = 100; // 池子最多缓存多少个对象。
    private readonly Stack<GameObject> pool = new Stack<GameObject>(); // 用栈保存当前空闲对象。
    private void Awake() // Unity 生命周期函数,对象创建时调用。
    { // Awake 开始。
        Prewarm(defaultCapacity); // 预创建一批对象,避免运行时突然创建造成卡顿。
    } // Awake 结束。
    public GameObject Get(Vector3 position, Quaternion rotation) // 从对象池中取出一个对象。
    { // Get 开始。
        GameObject obj = pool.Count > 0 ? pool.Pop() : CreateNewObject(); // 池里有就取,没有就创建新的。
        obj.transform.SetPositionAndRotation(position, rotation); // 设置对象的位置和旋转。
        obj.SetActive(true); // 激活对象,让它重新显示和工作。
        return obj; // 返回取出的对象。
    } // Get 结束。
    public void Release(GameObject obj) // 把对象归还给对象池。
    { // Release 开始。
        if (obj == null) // 如果传入对象为空。
        { // 判断开始。
            return; // 直接返回,避免空引用错误。
        } // 判断结束。
        if (pool.Count >= maxCapacity) // 如果池子已经达到最大容量。
        { // 判断开始。
            Destroy(obj); // 超出容量的对象直接销毁,避免池子无限变大。
            return; // 销毁后直接返回。
        } // 判断结束。
        obj.SetActive(false); // 隐藏对象,表示它暂时不用了。
        obj.transform.SetParent(transform); // 把对象挂回对象池节点下面,方便管理层级。
        pool.Push(obj); // 把对象压入栈中,等待下次复用。
    } // Release 结束。
    public void Prewarm(int count) // 预热对象池。
    { // Prewarm 开始。
        for (int i = 0; i < count; i++) // 循环创建指定数量的对象。
        { // 循环开始。
            if (pool.Count >= maxCapacity) // 如果池子已经满了。
            { // 判断开始。
                return; // 停止预创建。
            } // 判断结束。
            GameObject obj = CreateNewObject(); // 创建一个新对象。
            obj.SetActive(false); // 创建后先隐藏,等待之后使用。
            pool.Push(obj); // 放入池子。
        } // 循环结束。
    } // Prewarm 结束。
    private GameObject CreateNewObject() // 创建新的池对象。
    { // CreateNewObject 开始。
        GameObject obj = Instantiate(prefab, transform); // 根据 prefab 实例化一个新对象,并挂到池节点下面。
        obj.SetActive(false); // 新对象默认隐藏。
        return obj; // 返回新创建的对象。
    } // CreateNewObject 结束。
} // 类结束。

使用方式

c
GameObject bullet = bulletPool.Get(firePoint.position, firePoint.rotation); // 从对象池取出一颗子弹。
bulletPool.Release(bullet); // 子弹命中或生命周期结束后归还对象池。

面试高分回答

NOTE

对象池是一种复用对象的设计。对于子弹、特效、怪物、飘字这种频繁创建和销毁的对象,如果每次都 InstantiateDestroy,会带来 CPU 开销和 GC 压力。对象池会提前创建一批对象,需要时从池里取出并激活,用完后重置状态、隐藏并放回池子。实现时要注意最大容量、重复归还、状态清理,以及 Unity 中归还时不要忘记 SetActive(false)

一句话记忆

对象池就是“借对象”和“还对象”:借的时候初始化,还的时候清状态,下次继续复用。

手写事件管理器

核心解释

事件管理器就是一个“全局消息分发中心”:发送者只负责发布事件,接收者提前订阅事件,双方不直接互相引用。

csharp-unity-event-manager-handwritten

C# 泛型事件管理器

c
using System; // 引入 Action、Delegate、Type 等基础类型。
using System.Collections.Generic; // 引入 Dictionary 集合。

public sealed class EventManager // 定义事件管理器类,sealed 表示不允许被继承。
{ // 类开始。
    private readonly Dictionary<Type, Delegate> eventTable = new Dictionary<Type, Delegate>(); // 保存事件类型到委托列表的映射。

    public void AddListener<T>(Action<T> listener) // 订阅某一种事件类型。
    { // 方法开始。
        Type eventType = typeof(T); // 获取事件数据类型,作为字典 key。
        if (eventTable.TryGetValue(eventType, out Delegate oldDelegate)) // 如果这个事件类型已经有人监听。
        { // 判断开始。
            eventTable[eventType] = Delegate.Combine(oldDelegate, listener); // 把新的监听函数合并到旧委托链上。
        } // 判断结束。
        else // 如果这个事件类型还没有监听者。
        { // 分支开始。
            eventTable[eventType] = listener; // 直接把当前监听函数保存进去。
        } // 分支结束。
    } // 方法结束。

    public void RemoveListener<T>(Action<T> listener) // 取消订阅某一种事件类型。
    { // 方法开始。
        Type eventType = typeof(T); // 获取事件数据类型,作为字典 key。
        if (!eventTable.TryGetValue(eventType, out Delegate oldDelegate)) // 如果这个事件类型没人监听。
        { // 判断开始。
            return; // 直接返回,不需要取消。
        } // 判断结束。
        Delegate newDelegate = Delegate.Remove(oldDelegate, listener); // 从委托链中移除指定监听函数。
        if (newDelegate == null) // 如果移除后已经没有任何监听者。
        { // 判断开始。
            eventTable.Remove(eventType); // 从字典中删除这个事件类型。
        } // 判断结束。
        else // 如果移除后还有其他监听者。
        { // 分支开始。
            eventTable[eventType] = newDelegate; // 更新字典中的委托链。
        } // 分支结束。
    } // 方法结束。

    public void Dispatch<T>(T eventData) // 派发某一种事件。
    { // 方法开始。
        Type eventType = typeof(T); // 获取事件数据类型,作为字典 key。
        if (!eventTable.TryGetValue(eventType, out Delegate targetDelegate)) // 如果没有人监听这个事件。
        { // 判断开始。
            return; // 直接返回,不做任何处理。
        } // 判断结束。
        Delegate[] callbacks = targetDelegate.GetInvocationList(); // 拿到所有监听函数,避免遍历时委托链变化影响派发。
        for (int i = 0; i < callbacks.Length; i++) // 逐个调用监听函数。
        { // 循环开始。
            Action<T> callback = (Action<T>)callbacks[i]; // 把当前委托转换成对应事件类型的 Action。
            callback.Invoke(eventData); // 调用监听函数,并把事件数据传给它。
        } // 循环结束。
    } // 方法结束。

    public void Clear() // 清空所有事件监听。
    { // 方法开始。
        eventTable.Clear(); // 清空事件表,常用于退出游戏或切换大模块时。
    } // 方法结束。
} // 类结束。

事件数据和使用示例

c
public struct HpChangedEvent // 定义血量变化事件数据。
{ // 结构体开始。
    public int EntityId; // 发生血量变化的角色 ID。
    public int OldHp; // 变化前血量。
    public int NewHp; // 变化后血量。
} // 结构体结束。

EventManager eventManager = new EventManager(); // 创建事件管理器对象。
eventManager.AddListener<HpChangedEvent>(OnHpChanged); // 订阅血量变化事件。
eventManager.Dispatch(new HpChangedEvent { EntityId = 1, OldHp = 100, NewHp = 70 }); // 发布血量变化事件。
eventManager.RemoveListener<HpChangedEvent>(OnHpChanged); // 不需要监听时取消订阅。

Unity 里怎么用

通常在 OnEnable 里订阅,在 OnDisable 里取消订阅。 这样 UI 关闭、对象销毁、切场景时,不容易留下无效监听。

面试高分回答

TIP

事件管理器本质是发布订阅模式。发布者只派发事件,不关心谁处理;订阅者只监听自己关心的事件,不需要直接引用发布者。内部一般用 Dictionary 保存事件类型和委托列表,派发时根据事件类型找到所有监听函数并逐个调用。它的优点是解耦模块,比如战斗模块发血量变化事件,UI、音效、任务模块都可以各自响应;缺点是事件链路不如直接调用直观,忘记取消订阅还可能导致对象无法释放。

一句话记忆

事件管理器就是广播站:谁关心谁订阅,谁发生谁发布。

手写有限状态机

核心解释

有限状态机 FSM,就是:一个对象同一时刻只处于一个状态,比如 IdleMoveAttackDead。每个状态负责自己的逻辑,状态机负责切换状态。

csharp-unity-finite-state-machine-handwritten

C# 手写 FSM 基础版

c
using UnityEngine; // 引入 UnityEngine,用来使用 Vector2、Input、MonoBehaviour。
public interface IState // 定义状态接口,所有具体状态都要实现它。
{ // 接口开始。
    void Enter(); // 进入状态时调用,比如播放动画、初始化参数。
    void Tick(); // 状态持续期间每帧调用,比如移动、检测输入。
    void Exit(); // 离开状态时调用,比如清理临时数据。
} // 接口结束。
public sealed class StateMachine // 定义有限状态机类。
{ // 类开始。
    private IState currentState; // 保存当前正在运行的状态。
    public void ChangeState(IState nextState) // 切换到新状态。
    { // 方法开始。
        if (currentState == nextState) // 如果目标状态就是当前状态。
        { // 判断开始。
            return; // 不重复切换,直接返回。
        } // 判断结束。
        currentState?.Exit(); // 如果当前状态不为空,就先执行退出逻辑。
        currentState = nextState; // 把当前状态替换成新状态。
        currentState?.Enter(); // 如果新状态不为空,就执行进入逻辑。
    } // 方法结束。
    public void Tick() // 每帧更新状态机。
    { // 方法开始。
        currentState?.Tick(); // 如果当前状态不为空,就执行当前状态的 Tick。
    } // 方法结束。
} // 类结束。

Unity 角色状态机示例

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Input、Vector2。
public sealed class PlayerController : MonoBehaviour // 定义玩家控制器。
{ // 类开始。
    private StateMachine stateMachine; // 保存玩家自己的状态机。
    private PlayerIdleState idleState; // 保存待机状态对象。
    private PlayerMoveState moveState; // 保存移动状态对象。
    public float MoveSpeed = 5.0f; // 定义玩家移动速度。
    private void Awake() // Unity 在对象创建后调用 Awake。
    { // Awake 开始。
        stateMachine = new StateMachine(); // 创建状态机对象。
        idleState = new PlayerIdleState(this, stateMachine); // 创建待机状态,并传入玩家和状态机。
        moveState = new PlayerMoveState(this, stateMachine); // 创建移动状态,并传入玩家和状态机。
    } // Awake 结束。
    private void Start() // Unity 在第一帧 Update 前调用 Start。
    { // Start 开始。
        stateMachine.ChangeState(idleState); // 游戏开始时让玩家进入待机状态。
    } // Start 结束。
    private void Update() // Unity 每帧调用 Update。
    { // Update 开始。
        stateMachine.Tick(); // 每帧驱动当前状态执行逻辑。
    } // Update 结束。
    public Vector2 GetMoveInput() // 获取玩家移动输入。
    { // 方法开始。
        float x = Input.GetAxisRaw("Horizontal"); // 读取横向输入。
        float y = Input.GetAxisRaw("Vertical"); // 读取纵向输入。
        return new Vector2(x, y).normalized; // 返回归一化后的移动方向。
    } // 方法结束。
    public void Move(Vector2 direction) // 根据方向移动玩家。
    { // 方法开始。
        Vector3 offset = new Vector3(direction.x, 0.0f, direction.y) * MoveSpeed * Time.deltaTime; // 计算本帧位移。
        transform.position += offset; // 把位移加到玩家当前位置上。
    } // 方法结束。
    public IState IdleState // 对外提供待机状态。
    { // 属性开始。
        get { return idleState; } // 返回待机状态对象。
    } // 属性结束。
    public IState MoveState // 对外提供移动状态。
    { // 属性开始。
        get { return moveState; } // 返回移动状态对象。
    } // 属性结束。
} // 类结束。

具体状态类

c
public sealed class PlayerIdleState : IState // 定义玩家待机状态。
{ // 类开始。
    private readonly PlayerController owner; // 保存玩家控制器引用。
    private readonly StateMachine stateMachine; // 保存状态机引用。
    public PlayerIdleState(PlayerController owner, StateMachine stateMachine) // 构造待机状态。
    { // 构造函数开始。
        this.owner = owner; // 保存玩家控制器。
        this.stateMachine = stateMachine; // 保存状态机。
    } // 构造函数结束。
    public void Enter() // 进入待机状态。
    { // 方法开始。
        Debug.Log("Enter Idle"); // 打印日志,真实项目里可以播放待机动画。
    } // 方法结束。
    public void Tick() // 待机状态每帧执行。
    { // 方法开始。
        Vector2 input = owner.GetMoveInput(); // 获取玩家移动输入。
        if (input.sqrMagnitude > 0.0f) // 如果玩家有移动输入。
        { // 判断开始。
            stateMachine.ChangeState(owner.MoveState); // 切换到移动状态。
        } // 判断结束。
    } // 方法结束。
    public void Exit() // 离开待机状态。
    { // 方法开始。
        Debug.Log("Exit Idle"); // 打印日志,真实项目里可以清理待机状态数据。
    } // 方法结束。
} // 类结束。
public sealed class PlayerMoveState : IState // 定义玩家移动状态。
{ // 类开始。
    private readonly PlayerController owner; // 保存玩家控制器引用。
    private readonly StateMachine stateMachine; // 保存状态机引用。
    public PlayerMoveState(PlayerController owner, StateMachine stateMachine) // 构造移动状态。
    { // 构造函数开始。
        this.owner = owner; // 保存玩家控制器。
        this.stateMachine = stateMachine; // 保存状态机。
    } // 构造函数结束。
    public void Enter() // 进入移动状态。
    { // 方法开始。
        Debug.Log("Enter Move"); // 打印日志,真实项目里可以播放移动动画。
    } // 方法结束。
    public void Tick() // 移动状态每帧执行。
    { // 方法开始。
        Vector2 input = owner.GetMoveInput(); // 获取玩家移动输入。
        if (input.sqrMagnitude <= 0.0f) // 如果玩家已经没有移动输入。
        { // 判断开始。
            stateMachine.ChangeState(owner.IdleState); // 切换回待机状态。
            return; // 切换后直接返回,避免继续执行移动逻辑。
        } // 判断结束。
        owner.Move(input); // 根据输入方向移动玩家。
    } // 方法结束。
    public void Exit() // 离开移动状态。
    { // 方法开始。
        Debug.Log("Exit Move"); // 打印日志,真实项目里可以清理移动状态数据。
    } // 方法结束。
} // 类结束。

面试高分回答

NOTE

FSM 的核心是把对象行为拆成多个状态,每个状态有进入、执行、退出三个阶段。状态机内部保存当前状态,每帧只更新当前状态;当满足条件时,状态机先调用旧状态的 Exit,再切换引用,最后调用新状态的 Enter。这样可以避免把所有逻辑都写成巨大的 if else,角色移动、怪物 AI、UI 页面流程都很适合用 FSM。

一句话记忆

有限状态机就是:当前只做一个状态的事,条件满足就切到下一个状态。

手写计时器管理器

核心解释

计时器管理器就是统一管理“延迟执行”和“循环执行”的任务。比如技能 CD、Buff 持续时间、UI 倒计时、延迟关闭窗口,都可以交给它。

csharp-unity-timer-manager-handwritten

Unity C# 手写版

c
using System; // 引入 Action 回调类型。
using System.Collections.Generic; // 引入 List 集合。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Time。

public sealed class TimerManager : MonoBehaviour // 定义一个 Unity 计时器管理器。
{ // 类开始。
    private sealed class TimerTask // 定义内部计时器任务类。
    { // 内部类开始。
        public int Id; // 计时器唯一编号,用于取消计时器。
        public float Delay; // 每次触发的间隔时间。
        public float Remain; // 当前剩余时间。
        public bool Loop; // 是否循环执行。
        public bool UseUnscaledTime; // 是否使用不受 Time.timeScale 影响的时间。
        public bool Cancelled; // 是否已经被取消。
        public Action Callback; // 时间到后要执行的回调函数。
    } // 内部类结束。

    private readonly List<TimerTask> timers = new List<TimerTask>(); // 保存所有正在运行的计时器。
    private int nextId = 1; // 下一个计时器编号,从 1 开始递增。

    public int AddTimer(float delay, Action callback, bool loop = false, bool useUnscaledTime = false) // 添加一个计时器。
    { // 方法开始。
        TimerTask task = new TimerTask(); // 创建一个新的计时器任务。
        task.Id = nextId++; // 分配唯一编号,并让下一个编号递增。
        task.Delay = delay; // 保存计时器间隔时间。
        task.Remain = delay; // 初始化剩余时间为间隔时间。
        task.Loop = loop; // 保存是否循环。
        task.UseUnscaledTime = useUnscaledTime; // 保存是否使用不受暂停影响的时间。
        task.Cancelled = false; // 新计时器默认没有被取消。
        task.Callback = callback; // 保存时间到后要执行的回调。
        timers.Add(task); // 把计时器加入管理列表。
        return task.Id; // 返回计时器编号,方便外部取消。
    } // 方法结束。

    public void CancelTimer(int id) // 根据编号取消计时器。
    { // 方法开始。
        for (int i = 0; i < timers.Count; i++) // 遍历所有计时器。
        { // 循环开始。
            if (timers[i].Id == id) // 如果找到了指定编号的计时器。
            { // 判断开始。
                timers[i].Cancelled = true; // 标记为取消,稍后统一移除。
                return; // 找到后直接返回。
            } // 判断结束。
        } // 循环结束。
    } // 方法结束。

    public void ClearAllTimers() // 清空所有计时器。
    { // 方法开始。
        timers.Clear(); // 清空计时器列表。
    } // 方法结束。

    private void Update() // Unity 每帧调用 Update。
    { // 方法开始。
        for (int i = timers.Count - 1; i >= 0; i--) // 倒序遍历,方便安全删除元素。
        { // 循环开始。
            TimerTask task = timers[i]; // 取出当前计时器任务。
            if (task.Cancelled) // 如果这个计时器已经被取消。
            { // 判断开始。
                timers.RemoveAt(i); // 从列表中移除这个计时器。
                continue; // 继续处理下一个计时器。
            } // 判断结束。
            float deltaTime = task.UseUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime; // 根据配置选择是否受暂停影响。
            task.Remain -= deltaTime; // 减少当前计时器剩余时间。
            if (task.Remain > 0.0f) // 如果还没到触发时间。
            { // 判断开始。
                continue; // 跳过当前计时器,等下一帧继续计时。
            } // 判断结束。
            task.Callback?.Invoke(); // 时间到了,执行回调函数。
            if (task.Loop && !task.Cancelled) // 如果是循环计时器,并且回调中没有取消它。
            { // 判断开始。
                task.Remain += task.Delay; // 重置剩余时间,让它下次继续触发。
            } // 判断结束。
            else // 如果不是循环计时器,或者已经被取消。
            { // 分支开始。
                timers.RemoveAt(i); // 执行完一次后从列表中移除。
            } // 分支结束。
        } // 循环结束。
    } // 方法结束。
} // 类结束。

使用示例

c
int id = timerManager.AddTimer(2.0f, () => Debug.Log("2 秒后执行"), false); // 添加一个 2 秒后执行一次的计时器。
timerManager.AddTimer(1.0f, () => Debug.Log("每 1 秒执行一次"), true); // 添加一个每 1 秒循环执行的计时器。
timerManager.CancelTimer(id); // 根据 id 取消之前创建的计时器。

面试高分回答

CAUTION

计时器管理器本质上是维护一个任务列表,每个任务保存剩余时间、间隔、是否循环、回调函数和唯一 ID。每帧用 deltaTime 扣减剩余时间,时间到就触发回调;一次性计时器触发后移除,循环计时器触发后重置剩余时间。Unity 里要注意 Time.deltaTime 会受 timeScale 影响,如果是暂停界面的 UI 倒计时,要用 Time.unscaledDeltaTime

一句话记忆

计时器管理器就是统一的任务闹钟:时间到了执行,执行完决定删除还是继续循环。

手写 LRU 缓存

核心解释

LRU 缓存就是:容量满了以后,优先淘汰“最久没有被使用”的数据。面试里最经典的实现是:Dictionary 负责 O(1) 查找,双向链表负责 O(1) 调整冷热顺序。

csharp-lru-cache-handwritten

核心思路

Dictionary<TKey, Node>:通过 key 直接找到链表节点。

双向链表:越靠近头部越“新”,越靠近尾部越“旧”。

Get(key):如果找到,就把节点移动到头部。

Put(key, value):如果 key 已存在,更新值并移动到头部;如果 key 不存在,新增到头部;如果超出容量,删除尾部旧节点。

C# 代码

c
using System; // 引入 Exception 等基础类型。
using System.Collections.Generic; // 引入 Dictionary 集合类型。

public class LruCache<TKey, TValue> // 定义一个泛型 LRU 缓存类。
{ // LruCache 类开始。
    private class Node // 定义双向链表中的节点类型。
    { // Node 类开始。
        public TKey Key; // 保存缓存项的 key。
        public TValue Value; // 保存缓存项的 value。
        public Node Prev; // 指向前一个节点。
        public Node Next; // 指向后一个节点。

        public Node(TKey key, TValue value) // 定义节点构造函数。
        { // 构造函数开始。
            Key = key; // 初始化 key。
            Value = value; // 初始化 value。
        } // 构造函数结束。
    } // Node 类结束。

    private readonly int _capacity; // 保存缓存最大容量。
    private readonly Dictionary<TKey, Node> _map; // 保存 key 到链表节点的映射。
    private readonly Node _head; // 虚拟头节点,表示最新一端。
    private readonly Node _tail; // 虚拟尾节点,表示最旧一端。

    public LruCache(int capacity) // 定义 LRU 缓存构造函数。
    { // 构造函数开始。
        if (capacity <= 0) // 判断容量是否合法。
        { // if 代码块开始。
            throw new ArgumentException("容量必须大于 0"); // 容量非法时抛出异常。
        } // if 代码块结束。

        _capacity = capacity; // 保存最大容量。
        _map = new Dictionary<TKey, Node>(); // 创建哈希表。
        _head = new Node(default(TKey), default(TValue)); // 创建虚拟头节点。
        _tail = new Node(default(TKey), default(TValue)); // 创建虚拟尾节点。
        _head.Next = _tail; // 让头节点指向尾节点。
        _tail.Prev = _head; // 让尾节点指向头节点。
    } // 构造函数结束。

    public bool TryGet(TKey key, out TValue value) // 尝试通过 key 获取 value。
    { // TryGet 方法开始。
        if (!_map.TryGetValue(key, out Node node)) // 如果哈希表里找不到 key。
        { // if 代码块开始。
            value = default(TValue); // 返回默认值。
            return false; // 表示没有命中缓存。
        } // if 代码块结束。

        MoveToHead(node); // 命中后说明刚被使用,移动到链表头部。
        value = node.Value; // 输出节点中保存的 value。
        return true; // 表示命中缓存。
    } // TryGet 方法结束。

    public void Put(TKey key, TValue value) // 添加或更新缓存。
    { // Put 方法开始。
        if (_map.TryGetValue(key, out Node node)) // 如果 key 已经存在。
        { // if 代码块开始。
            node.Value = value; // 更新旧节点的 value。
            MoveToHead(node); // 更新后也算刚使用,移动到头部。
            return; // 直接结束方法。
        } // if 代码块结束。

        Node newNode = new Node(key, value); // 创建新的缓存节点。
        _map[key] = newNode; // 把 key 和节点加入哈希表。
        AddToHead(newNode); // 把新节点放到链表头部。

        if (_map.Count > _capacity) // 如果缓存数量超过容量。
        { // if 代码块开始。
            Node oldNode = RemoveTail(); // 删除链表尾部最久未使用节点。
            _map.Remove(oldNode.Key); // 从哈希表中移除对应 key。
        } // if 代码块结束。
    } // Put 方法结束。

    private void MoveToHead(Node node) // 把某个节点移动到头部。
    { // MoveToHead 方法开始。
        RemoveNode(node); // 先把节点从当前位置摘下来。
        AddToHead(node); // 再把节点插入到头部。
    } // MoveToHead 方法结束。

    private void AddToHead(Node node) // 把节点插入到虚拟头节点后面。
    { // AddToHead 方法开始。
        node.Prev = _head; // 新节点的前驱指向虚拟头节点。
        node.Next = _head.Next; // 新节点的后继指向原来的第一个节点。
        _head.Next.Prev = node; // 原来的第一个节点的前驱改成新节点。
        _head.Next = node; // 虚拟头节点的后继改成新节点。
    } // AddToHead 方法结束。

    private void RemoveNode(Node node) // 从链表中删除某个节点。
    { // RemoveNode 方法开始。
        node.Prev.Next = node.Next; // 让前一个节点跳过当前节点。
        node.Next.Prev = node.Prev; // 让后一个节点跳过当前节点。
    } // RemoveNode 方法结束。

    private Node RemoveTail() // 删除尾部最旧的真实节点。
    { // RemoveTail 方法开始。
        Node oldNode = _tail.Prev; // 尾节点前面的节点就是最久未使用节点。
        RemoveNode(oldNode); // 从链表中摘掉这个旧节点。
        return oldNode; // 返回被删除的旧节点。
    } // RemoveTail 方法结束。
} // LruCache 类结束。

面试高分回答

IMPORTANT

LRU 的关键不是“会删除旧数据”这么简单,而是要保证 GetPut 都尽量是 O(1)。所以不能只用数组或普通链表,因为查找会慢;也不能只用 Dictionary,因为它不知道谁最久没用。最常见方案就是 Dictionary + 双向链表:字典负责定位节点,链表负责维护使用顺序。每次访问或更新都把节点移到头部,容量满时删除尾部节点。

复杂度

时间复杂度:Get 平均 O(1),Put 平均 O(1)。

空间复杂度:O(n),因为需要一个哈希表和一条链表保存缓存数据。

一句话记忆

LRU = 字典负责“找得快”,双向链表负责“删得快、移动快”。

手写简单背包数据结构

核心解释

LRU 缓存就是:容量满了以后,优先淘汰“最久没有被使用”的数据。面试里最经典的实现是:Dictionary 负责 O(1) 查找,双向链表负责 O(1) 调整冷热顺序。

csharp-unity-simple-bag-data-structure

核心思路

Dictionary<TKey, Node>:通过 key 直接找到链表节点。

双向链表:越靠近头部越“新”,越靠近尾部越“旧”。

Get(key):如果找到,就把节点移动到头部。

Put(key, value):如果 key 已存在,更新值并移动到头部;如果 key 不存在,新增到头部;如果超出容量,删除尾部旧节点。

C# 代码

c
using System; // 引入 Exception 等基础类型。
using System.Collections.Generic; // 引入 Dictionary 集合类型。

public class LruCache<TKey, TValue> // 定义一个泛型 LRU 缓存类。
{ // LruCache 类开始。
    private class Node // 定义双向链表中的节点类型。
    { // Node 类开始。
        public TKey Key; // 保存缓存项的 key。
        public TValue Value; // 保存缓存项的 value。
        public Node Prev; // 指向前一个节点。
        public Node Next; // 指向后一个节点。

        public Node(TKey key, TValue value) // 定义节点构造函数。
        { // 构造函数开始。
            Key = key; // 初始化 key。
            Value = value; // 初始化 value。
        } // 构造函数结束。
    } // Node 类结束。

    private readonly int _capacity; // 保存缓存最大容量。
    private readonly Dictionary<TKey, Node> _map; // 保存 key 到链表节点的映射。
    private readonly Node _head; // 虚拟头节点,表示最新一端。
    private readonly Node _tail; // 虚拟尾节点,表示最旧一端。

    public LruCache(int capacity) // 定义 LRU 缓存构造函数。
    { // 构造函数开始。
        if (capacity <= 0) // 判断容量是否合法。
        { // if 代码块开始。
            throw new ArgumentException("容量必须大于 0"); // 容量非法时抛出异常。
        } // if 代码块结束。

        _capacity = capacity; // 保存最大容量。
        _map = new Dictionary<TKey, Node>(); // 创建哈希表。
        _head = new Node(default(TKey), default(TValue)); // 创建虚拟头节点。
        _tail = new Node(default(TKey), default(TValue)); // 创建虚拟尾节点。
        _head.Next = _tail; // 让头节点指向尾节点。
        _tail.Prev = _head; // 让尾节点指向头节点。
    } // 构造函数结束。

    public bool TryGet(TKey key, out TValue value) // 尝试通过 key 获取 value。
    { // TryGet 方法开始。
        if (!_map.TryGetValue(key, out Node node)) // 如果哈希表里找不到 key。
        { // if 代码块开始。
            value = default(TValue); // 返回默认值。
            return false; // 表示没有命中缓存。
        } // if 代码块结束。

        MoveToHead(node); // 命中后说明刚被使用,移动到链表头部。
        value = node.Value; // 输出节点中保存的 value。
        return true; // 表示命中缓存。
    } // TryGet 方法结束。

    public void Put(TKey key, TValue value) // 添加或更新缓存。
    { // Put 方法开始。
        if (_map.TryGetValue(key, out Node node)) // 如果 key 已经存在。
        { // if 代码块开始。
            node.Value = value; // 更新旧节点的 value。
            MoveToHead(node); // 更新后也算刚使用,移动到头部。
            return; // 直接结束方法。
        } // if 代码块结束。

        Node newNode = new Node(key, value); // 创建新的缓存节点。
        _map[key] = newNode; // 把 key 和节点加入哈希表。
        AddToHead(newNode); // 把新节点放到链表头部。

        if (_map.Count > _capacity) // 如果缓存数量超过容量。
        { // if 代码块开始。
            Node oldNode = RemoveTail(); // 删除链表尾部最久未使用节点。
            _map.Remove(oldNode.Key); // 从哈希表中移除对应 key。
        } // if 代码块结束。
    } // Put 方法结束。

    private void MoveToHead(Node node) // 把某个节点移动到头部。
    { // MoveToHead 方法开始。
        RemoveNode(node); // 先把节点从当前位置摘下来。
        AddToHead(node); // 再把节点插入到头部。
    } // MoveToHead 方法结束。

    private void AddToHead(Node node) // 把节点插入到虚拟头节点后面。
    { // AddToHead 方法开始。
        node.Prev = _head; // 新节点的前驱指向虚拟头节点。
        node.Next = _head.Next; // 新节点的后继指向原来的第一个节点。
        _head.Next.Prev = node; // 原来的第一个节点的前驱改成新节点。
        _head.Next = node; // 虚拟头节点的后继改成新节点。
    } // AddToHead 方法结束。

    private void RemoveNode(Node node) // 从链表中删除某个节点。
    { // RemoveNode 方法开始。
        node.Prev.Next = node.Next; // 让前一个节点跳过当前节点。
        node.Next.Prev = node.Prev; // 让后一个节点跳过当前节点。
    } // RemoveNode 方法结束。

    private Node RemoveTail() // 删除尾部最旧的真实节点。
    { // RemoveTail 方法开始。
        Node oldNode = _tail.Prev; // 尾节点前面的节点就是最久未使用节点。
        RemoveNode(oldNode); // 从链表中摘掉这个旧节点。
        return oldNode; // 返回被删除的旧节点。
    } // RemoveTail 方法结束。
} // LruCache 类结束。

面试高分回答

TIP

LRU 的关键不是“会删除旧数据”这么简单,而是要保证 GetPut 都尽量是 O(1)。所以不能只用数组或普通链表,因为查找会慢;也不能只用 Dictionary,因为它不知道谁最久没用。最常见方案就是 Dictionary + 双向链表:字典负责定位节点,链表负责维护使用顺序。每次访问或更新都把节点移到头部,容量满时删除尾部节点。

复杂度

时间复杂度:Get 平均 O(1),Put 平均 O(1)。

空间复杂度:O(n),因为需要一个哈希表和一条链表保存缓存数据。

一句话记忆

LRU = 字典负责“找得快”,双向链表负责“删得快、移动快”。

手写技能 CD 管理器

核心解释

技能 CD 管理器的本质是:不要每帧给每个技能倒计时,而是记录“这个技能什么时候冷却结束”。需要判断时,用 当前时间结束时间 比较。

csharp-unity-skill-cooldown-manager-handwritten

核心思路

Dictionary<int, float> 保存:

key:技能 ID。

value:这个技能的冷却结束时间。

比如当前时间是 10 秒,技能 CD 是 3 秒,那么记录:

endTime = 10 + 3;

之后只要当前时间还没到 13,技能就不能释放。

C# 代码

c
using System; // 引入 Func 和 ArgumentNullException 等基础类型。
using System.Collections.Generic; // 引入 Dictionary 和 List 集合类型。

public sealed class SkillCooldownManager // 定义技能 CD 管理器类。
{ // SkillCooldownManager 类开始。
    private readonly Dictionary<int, float> _cooldownEndTimes; // 保存每个技能的冷却结束时间。
    private readonly Dictionary<int, float> _cooldownDurations; // 保存每个技能本次冷却的总时长,用于计算 UI 进度。
    private readonly List<int> _removeBuffer; // 保存需要清理的技能 ID,避免遍历字典时直接修改字典。
    private readonly Func<float> _getTime; // 保存获取当前时间的方法,Unity 里可以传 Time.time。

    public SkillCooldownManager(Func<float> getTime) // 定义构造函数,由外部传入当前时间来源。
    { // 构造函数开始。
        _getTime = getTime ?? throw new ArgumentNullException(nameof(getTime)); // 保存时间函数,如果为空就抛异常。
        _cooldownEndTimes = new Dictionary<int, float>(); // 创建技能结束时间字典。
        _cooldownDurations = new Dictionary<int, float>(); // 创建技能 CD 总时长字典。
        _removeBuffer = new List<int>(); // 创建临时清理列表。
    } // 构造函数结束。

    public bool TryUseSkill(int skillId, float cooldownSeconds) // 尝试释放技能,成功释放后自动进入 CD。
    { // TryUseSkill 方法开始。
        if (!IsReady(skillId)) // 如果技能还没有冷却完成。
        { // if 代码块开始。
            return false; // 返回 false,表示技能不能释放。
        } // if 代码块结束。

        StartCooldown(skillId, cooldownSeconds); // 技能释放成功后开始冷却。
        return true; // 返回 true,表示技能释放成功。
    } // TryUseSkill 方法结束。

    public void StartCooldown(int skillId, float cooldownSeconds) // 让某个技能进入 CD。
    { // StartCooldown 方法开始。
        float safeCooldown = Math.Max(0f, cooldownSeconds); // 保证 CD 时间不会小于 0。
        float now = _getTime(); // 获取当前时间。
        float endTime = now + safeCooldown; // 计算冷却结束时间。
        _cooldownEndTimes[skillId] = endTime; // 保存这个技能的冷却结束时间。
        _cooldownDurations[skillId] = safeCooldown; // 保存这个技能本次冷却的总时长。
    } // StartCooldown 方法结束。

    public bool IsReady(int skillId) // 判断某个技能是否已经冷却完成。
    { // IsReady 方法开始。
        if (!_cooldownEndTimes.TryGetValue(skillId, out float endTime)) // 如果字典里没有这个技能的 CD 记录。
        { // if 代码块开始。
            return true; // 没有 CD 记录,说明技能可以释放。
        } // if 代码块结束。

        return _getTime() >= endTime; // 当前时间大于等于结束时间,说明技能可以释放。
    } // IsReady 方法结束。

    public float GetRemainingTime(int skillId) // 获取某个技能剩余 CD 时间。
    { // GetRemainingTime 方法开始。
        if (!_cooldownEndTimes.TryGetValue(skillId, out float endTime)) // 如果没有找到这个技能的结束时间。
        { // if 代码块开始。
            return 0f; // 没有 CD 记录,剩余时间就是 0。
        } // if 代码块结束。

        float remaining = endTime - _getTime(); // 用结束时间减当前时间,得到剩余时间。
        return Math.Max(0f, remaining); // 如果已经结束,就返回 0,避免出现负数。
    } // GetRemainingTime 方法结束。

    public float GetProgress01(int skillId) // 获取 CD 进度,0 表示刚开始,1 表示已完成。
    { // GetProgress01 方法开始。
        if (!_cooldownDurations.TryGetValue(skillId, out float duration)) // 如果找不到这个技能的 CD 总时长。
        { // if 代码块开始。
            return 1f; // 没有记录就认为已经完成。
        } // if 代码块结束。

        if (duration <= 0f) // 如果 CD 总时长小于等于 0。
        { // if 代码块开始。
            return 1f; // 直接认为技能已经冷却完成。
        } // if 代码块结束。

        float remaining = GetRemainingTime(skillId); // 获取当前剩余 CD 时间。
        float progress = 1f - remaining / duration; // 用剩余时间反推完成进度。
        return Clamp01(progress); // 把进度限制在 0 到 1 之间。
    } // GetProgress01 方法结束。

    public void CancelCooldown(int skillId) // 取消某个技能的 CD。
    { // CancelCooldown 方法开始。
        _cooldownEndTimes.Remove(skillId); // 从结束时间字典中移除这个技能。
        _cooldownDurations.Remove(skillId); // 从总时长字典中移除这个技能。
    } // CancelCooldown 方法结束。

    public void ClearAll() // 清空所有技能 CD。
    { // ClearAll 方法开始。
        _cooldownEndTimes.Clear(); // 清空所有技能的结束时间。
        _cooldownDurations.Clear(); // 清空所有技能的 CD 总时长。
    } // ClearAll 方法结束。

    public void CleanupFinishedCooldowns() // 清理已经结束的 CD 记录。
    { // CleanupFinishedCooldowns 方法开始。
        _removeBuffer.Clear(); // 先清空临时列表。
        float now = _getTime(); // 获取当前时间。

        foreach (KeyValuePair<int, float> pair in _cooldownEndTimes) // 遍历所有技能 CD 记录。
        { // foreach 代码块开始。
            if (now >= pair.Value) // 如果当前时间已经超过这个技能的结束时间。
            { // if 代码块开始。
                _removeBuffer.Add(pair.Key); // 把这个技能 ID 加入待清理列表。
            } // if 代码块结束。
        } // foreach 代码块结束。

        for (int i = 0; i < _removeBuffer.Count; i++) // 遍历所有待清理技能 ID。
        { // for 代码块开始。
            CancelCooldown(_removeBuffer[i]); // 移除这个技能的 CD 记录。
        } // for 代码块结束。
    } // CleanupFinishedCooldowns 方法结束。

    private static float Clamp01(float value) // 定义一个限制 0 到 1 的工具方法。
    { // Clamp01 方法开始。
        if (value < 0f) // 如果值小于 0。
        { // if 代码块开始。
            return 0f; // 返回 0。
        } // if 代码块结束。

        if (value > 1f) // 如果值大于 1。
        { // if 代码块开始。
            return 1f; // 返回 1。
        } // if 代码块结束。

        return value; // 返回原始值。
    } // Clamp01 方法结束。
} // SkillCooldownManager 类结束。

Unity 中怎么用

c
SkillCooldownManager cdManager = new SkillCooldownManager(() => UnityEngine.Time.time); // 创建使用游戏时间的 CD 管理器。
bool success = cdManager.TryUseSkill(1001, 3f); // 尝试释放技能 1001,并设置 3 秒 CD。
float remain = cdManager.GetRemainingTime(1001); // 获取技能 1001 的剩余 CD 时间。
float progress = cdManager.GetProgress01(1001); // 获取技能 1001 的 CD 进度,用于 UI 冷却遮罩。

面试高分回答

NOTE

我会用一个 CD 管理器统一维护技能冷却,不建议每个技能各自写一套倒计时逻辑。核心做法是用字典保存 skillId -> cooldownEndTime,释放技能时记录结束时间,查询技能是否可释放时只需要比较当前时间和结束时间。这样结构简单,查询平均 O(1),也方便 UI 获取剩余时间和冷却进度。

一句话记忆

技能 CD 不一定要每帧减秒数,记录“结束时间”再用当前时间判断,最简单也最稳。

手写优先队列

核心解释

优先队列不是“谁先来谁先出”,而是“谁优先级高谁先出”。常见底层是二叉堆。面试手写时,一般写“小根堆”:priority 越小,越早出队。

csharp-priority-queue-handwritten

核心公式

数组下标 i 的父子关系:

父节点 = (i - 1) / 2
左孩子 = i * 2 + 1
右孩子 = i * 2 + 2

入队:先放数组末尾,再向上比较,也叫 SiftUp

出队:先拿走堆顶,把最后一个元素放到堆顶,再向下比较,也叫 SiftDown

C# 代码

c
using System; // 引入基础异常类型。
using System.Collections.Generic; // 引入 List 集合类型。

public sealed class PriorityQueue<T> // 定义一个泛型优先队列。
{ // PriorityQueue 类开始。
    private struct Entry // 定义堆中的一个元素结构。
    { // Entry 结构开始。
        public T Value; // 保存真正的数据。
        public int Priority; // 保存优先级,数字越小优先级越高。
        public Entry(T value, int priority) // 定义 Entry 构造函数。
        { // Entry 构造函数开始。
            Value = value; // 初始化数据。
            Priority = priority; // 初始化优先级。
        } // Entry 构造函数结束。
    } // Entry 结构结束。

    private readonly List<Entry> _heap; // 用数组列表保存二叉堆。
    public int Count => _heap.Count; // 返回当前优先队列中的元素数量。

    public PriorityQueue() // 定义优先队列构造函数。
    { // 构造函数开始。
        _heap = new List<Entry>(); // 创建空的堆数组。
    } // 构造函数结束。

    public void Enqueue(T value, int priority) // 入队一个元素。
    { // Enqueue 方法开始。
        Entry entry = new Entry(value, priority); // 创建新的堆元素。
        _heap.Add(entry); // 先把新元素放到数组末尾。
        SiftUp(_heap.Count - 1); // 从最后一个位置开始向上调整。
    } // Enqueue 方法结束。

    public T Peek() // 查看堆顶元素但不删除。
    { // Peek 方法开始。
        if (_heap.Count == 0) // 如果堆里没有元素。
        { // if 代码块开始。
            throw new InvalidOperationException("优先队列为空"); // 空队列不能查看堆顶。
        } // if 代码块结束。
        return _heap[0].Value; // 返回堆顶元素的数据。
    } // Peek 方法结束。

    public T Dequeue() // 出队优先级最高的元素。
    { // Dequeue 方法开始。
        if (_heap.Count == 0) // 如果堆里没有元素。
        { // if 代码块开始。
            throw new InvalidOperationException("优先队列为空"); // 空队列不能出队。
        } // if 代码块结束。
        T result = _heap[0].Value; // 保存堆顶元素,稍后返回。
        int lastIndex = _heap.Count - 1; // 计算最后一个元素的下标。
        _heap[0] = _heap[lastIndex]; // 把最后一个元素放到堆顶。
        _heap.RemoveAt(lastIndex); // 删除最后一个位置。
        if (_heap.Count > 0) // 如果删除后堆里还有元素。
        { // if 代码块开始。
            SiftDown(0); // 从堆顶开始向下调整。
        } // if 代码块结束。
        return result; // 返回原来的堆顶元素。
    } // Dequeue 方法结束。

    private void SiftUp(int index) // 把指定位置的元素向上调整。
    { // SiftUp 方法开始。
        while (index > 0) // 只要当前节点不是根节点,就继续比较。
        { // while 代码块开始。
            int parentIndex = (index - 1) / 2; // 计算父节点下标。
            if (_heap[parentIndex].Priority <= _heap[index].Priority) // 如果父节点优先级已经更高或相等。
            { // if 代码块开始。
                break; // 堆结构已经正确,停止上浮。
            } // if 代码块结束。
            Swap(parentIndex, index); // 交换父节点和当前节点。
            index = parentIndex; // 当前节点位置更新为父节点位置。
        } // while 代码块结束。
    } // SiftUp 方法结束。

    private void SiftDown(int index) // 把指定位置的元素向下调整。
    { // SiftDown 方法开始。
        while (true) // 持续向下比较,直到堆结构正确。
        { // while 代码块开始。
            int leftIndex = index * 2 + 1; // 计算左孩子下标。
            int rightIndex = index * 2 + 2; // 计算右孩子下标。
            int smallestIndex = index; // 先假设当前节点优先级最高。
            if (leftIndex < _heap.Count && _heap[leftIndex].Priority < _heap[smallestIndex].Priority) // 如果左孩子存在并且优先级更高。
            { // if 代码块开始。
                smallestIndex = leftIndex; // 记录左孩子为最小优先级节点。
            } // if 代码块结束。
            if (rightIndex < _heap.Count && _heap[rightIndex].Priority < _heap[smallestIndex].Priority) // 如果右孩子存在并且优先级更高。
            { // if 代码块开始。
                smallestIndex = rightIndex; // 记录右孩子为最小优先级节点。
            } // if 代码块结束。
            if (smallestIndex == index) // 如果当前节点已经比两个孩子优先级都高。
            { // if 代码块开始。
                break; // 堆结构已经正确,停止下沉。
            } // if 代码块结束。
            Swap(index, smallestIndex); // 交换当前节点和优先级更高的孩子。
            index = smallestIndex; // 当前节点位置更新为孩子位置。
        } // while 代码块结束。
    } // SiftDown 方法结束。

    private void Swap(int a, int b) // 交换堆数组中的两个元素。
    { // Swap 方法开始。
        Entry temp = _heap[a]; // 临时保存 a 位置的元素。
        _heap[a] = _heap[b]; // 把 b 位置元素放到 a 位置。
        _heap[b] = temp; // 把临时保存的元素放到 b 位置。
    } // Swap 方法结束。
} // PriorityQueue 类结束。

复杂度

Enqueue:O(log n),因为最多从叶子上浮到根。

Dequeue:O(log n),因为最多从根下沉到叶子。

Peek:O(1),因为堆顶永远在数组下标 0

面试高分回答

TIP

优先队列通常用二叉堆实现。二叉堆可以用数组存,不需要真的创建树节点。堆顶保存当前优先级最高的元素,入队时把元素放到末尾再上浮,出队时删除堆顶,把最后一个元素放到堆顶再下沉。它常用于 A* 寻路、Dijkstra 最短路、任务调度、延迟事件等场景。

一句话记忆

优先队列 = 数组存二叉堆,入队上浮,出队下沉,堆顶永远最优先。

手写协程模拟器思路

核心解释

协程模拟器的核心是:把 IEnumerator 存起来,然后在每一帧调用它的 MoveNext()。遇到 yield return null 就下一帧继续;遇到等待对象就记录恢复时间;MoveNext() 返回 false 就说明协程结束。

csharp-unity-coroutine-simulator-handwritten

底层思路

IEnumerator 本质像一个“可以暂停的函数状态机”。

yield return 会把函数暂停在当前位置。

下一次调用 MoveNext() 时,会从上次暂停的位置继续执行。

所以协程不是线程,它只是被主线程每帧推进一点点。

C# 代码

c
using System; // 引入 Func、Math 和异常类型。
using System.Collections; // 引入 IEnumerator 接口。
using System.Collections.Generic; // 引入 List 集合类型。
public sealed class WaitForSecondsSim // 定义一个模拟 WaitForSeconds 的等待对象。
{ // WaitForSecondsSim 类开始。
    public float Seconds { get; } // 保存需要等待的秒数。
    public WaitForSecondsSim(float seconds) // 定义等待对象的构造函数。
    { // 构造函数开始。
        Seconds = Math.Max(0f, seconds); // 保证等待时间不会小于 0。
    } // 构造函数结束。
} // WaitForSecondsSim 类结束。
public sealed class CoroutineSimulator // 定义一个简单协程模拟器。
{ // CoroutineSimulator 类开始。
    private sealed class CoroutineTask // 定义内部任务类,用来保存一个正在运行的协程。
    { // CoroutineTask 类开始。
        public int Id; // 保存协程唯一 ID。
        public IEnumerator Routine; // 保存真正的 IEnumerator 协程对象。
        public float ResumeTime; // 保存下一次允许恢复执行的时间。
        public bool Stopped; // 保存协程是否已经被停止。
    } // CoroutineTask 类结束。
    private readonly List<CoroutineTask> _tasks; // 保存所有正在运行的协程任务。
    private readonly Func<float> _getTime; // 保存获取当前时间的方法。
    private int _nextId; // 保存下一个要分配的协程 ID。
    public CoroutineSimulator(Func<float> getTime) // 定义协程模拟器构造函数。
    { // 构造函数开始。
        _getTime = getTime ?? throw new ArgumentNullException(nameof(getTime)); // 保存时间函数,如果为空就抛异常。
        _tasks = new List<CoroutineTask>(); // 创建协程任务列表。
        _nextId = 1; // 从 1 开始分配协程 ID。
    } // 构造函数结束。
    public int StartCoroutine(IEnumerator routine) // 启动一个协程。
    { // StartCoroutine 方法开始。
        if (routine == null) // 判断协程对象是否为空。
        { // if 代码块开始。
            throw new ArgumentNullException(nameof(routine)); // 协程为空就抛异常。
        } // if 代码块结束。
        CoroutineTask task = new CoroutineTask(); // 创建一个新的协程任务。
        task.Id = _nextId++; // 分配协程 ID 并递增。
        task.Routine = routine; // 保存传入的 IEnumerator。
        task.ResumeTime = _getTime(); // 设置为当前时间,表示下一帧检查时可以执行。
        task.Stopped = false; // 标记协程没有停止。
        _tasks.Add(task); // 把协程任务加入任务列表。
        return task.Id; // 返回协程 ID,方便外部停止它。
    } // StartCoroutine 方法结束。
    public void StopCoroutine(int id) // 停止指定 ID 的协程。
    { // StopCoroutine 方法开始。
        for (int i = 0; i < _tasks.Count; i++) // 遍历所有协程任务。
        { // for 代码块开始。
            if (_tasks[i].Id == id) // 如果找到了指定 ID 的协程。
            { // if 代码块开始。
                _tasks[i].Stopped = true; // 标记这个协程已经停止。
                return; // 停止后直接返回。
            } // if 代码块结束。
        } // for 代码块结束。
    } // StopCoroutine 方法结束。
    public void Update() // 每帧调用一次,用来推进所有协程。
    { // Update 方法开始。
        float now = _getTime(); // 获取当前时间。
        for (int i = _tasks.Count - 1; i >= 0; i--) // 倒序遍历,方便删除结束的协程。
        { // for 代码块开始。
            CoroutineTask task = _tasks[i]; // 取出当前协程任务。
            if (task.Stopped) // 如果这个协程已经被停止。
            { // if 代码块开始。
                _tasks.RemoveAt(i); // 从任务列表里删除它。
                continue; // 继续处理下一个协程。
            } // if 代码块结束。
            if (now < task.ResumeTime) // 如果还没有到恢复执行的时间。
            { // if 代码块开始。
                continue; // 本帧跳过这个协程。
            } // if 代码块结束。
            bool hasNext = task.Routine.MoveNext(); // 推进协程到下一个 yield。
            if (!hasNext) // 如果 MoveNext 返回 false。
            { // if 代码块开始。
                _tasks.RemoveAt(i); // 说明协程结束,把它移除。
                continue; // 继续处理下一个协程。
            } // if 代码块结束。
            object yielded = task.Routine.Current; // 读取这次 yield return 出来的对象。
            task.ResumeTime = CalculateResumeTime(yielded, now); // 根据 yield 的内容计算下次恢复时间。
        } // for 代码块结束。
    } // Update 方法结束。
    private float CalculateResumeTime(object yielded, float now) // 根据 yield 对象计算恢复时间。
    { // CalculateResumeTime 方法开始。
        if (yielded == null) // 如果是 yield return null。
        { // if 代码块开始。
            return now; // 下一帧再次 Update 时就可以继续。
        } // if 代码块结束。
        if (yielded is WaitForSecondsSim wait) // 如果是等待指定秒数。
        { // if 代码块开始。
            return now + wait.Seconds; // 当前时间加等待秒数,得到恢复时间。
        } // if 代码块结束。
        return now; // 未识别的 yield 类型默认下一帧继续。
    } // CalculateResumeTime 方法结束。
} // CoroutineSimulator 类结束。

测试用法

c
IEnumerator Demo() // 定义一个测试协程。
{ // Demo 协程开始。
    Console.WriteLine("第一帧执行"); // 打印第一步。
    yield return null; // 暂停到下一帧。
    Console.WriteLine("第二帧执行"); // 打印第二步。
    yield return new WaitForSecondsSim(2f); // 暂停 2 秒。
    Console.WriteLine("两秒后执行"); // 打印第三步。
} // Demo 协程结束。

面试高分回答

TIP

我会说:协程底层可以理解为 IEnumerator 状态机。StartCoroutine 只是把这个状态机注册到调度器,并不会创建线程。调度器每帧检查协程是否可以继续执行,如果可以就调用 MoveNext()。执行到 yield return 时协程暂停,Current 告诉调度器下一次什么时候恢复。MoveNext() 返回 false 时,协程生命周期结束。

一句话记忆

协程 = 编译器生成的状态机 + 主线程每帧调用 MoveNext() 推进。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0