C++ 单例面试题(耗时 init + 失败返回空 + 线程安全)+力扣128. 最长连续序列

LeetCode 128. 最长连续序列 C++ 解法解析

题目核心

给定一个未排序的整数数组,找出数字连续的最长序列的长度,要求 O(n) 时间复杂度


核心思路:哈希集合 + 只从起点开始计数

1. 去重存入哈希集合

将所有数字存入 unordered_set,去重的同时实现 O(1) 查找。

2. 只从连续段的"起点"开始统计

关键判断:只有当 num - 1 不在集合中时,num 才是一个连续段的起点。

  • ✅ 如果是起点 → 开始循环找 num+1num+2……统计长度
  • ❌ 如果不是起点 → 跳过,因为它必被从更小的起点开始的统计覆盖

3. 更新全局最大长度


复杂度分析

项目 复杂度
时间复杂度 O(n)(每个元素最多被访问两次)
空间复杂度 O(n)(哈希集合存储)

C++ 代码实现

class Solution {
public:
    int longestConsecutive(vector<int>& nums) {
        // 1. 存入哈希集合去重
        unordered_set<int> set;
        for (int num : nums) {
            set.insert(num);
        }
        
        int maxLen = 0;
        
        // 2. 遍历集合中的每个数
        for (int num : set) {
            // 关键:只有是连续段起点时才统计
            if (!set.count(num - 1)) {
                int cur = num;
                int len = 1;
                
                // 向后找连续的数字
                while (set.count(cur + 1)) {
                    cur++;
                    len++;
                }
                
                maxLen = max(maxLen, len);
            }
        }
        
        return maxLen;
    }
};

C++ 单例面试题(耗时 init + 失败返回空 + 线程安全)

使用说明:以下内容以“面试官期望听到的答案逻辑”为出发点,进行了高度结构化梳理。省略了代码细节和现场对话口吻,只保留严密的推导过程和核心知识点,非常适合快速复习和背诵。


一、 题目考点定位(破题)

面试官主要想考察以下三个层面的能力:

  1. 对象生命周期管理:如何将“构造”与“耗时初始化”解耦,以处理初始化失败的情况。
  2. 错误处理机制:如何打破传统单例“永远返回有效对象”的惯例,向外部暴露“创建失败”的语义(返回空)。
  3. 高并发下的稳健性:不仅保证首次创建时的线程安全,还要解决“初始化失败后,后续线程如何重试”这一深水区问题。

二、 基础方案的选择与淘汰(第一层)

核心论点:教科书上的标准写法无法满足本题约束,必须推翻重来。

  • C++11 Meyers Singleton(函数内静态局部变量)为什么不适用?
    • 缺陷 1:构造函数无法返回错误码,无法向外部传达“初始化失败”的信号。
    • 缺陷 2:接口返回的是引用,无法返回空指针(nullptr)来表示对象不可用。

结论:必须将“对象内存分配”与“业务逻辑初始化(init)”拆分成两个独立的步骤,并将 getInstance 的返回类型修改为指针语义。


三、 进阶方案的陷阱(第二层)

核心论点std::call_once 看起来完美,实则暗藏致命的生产环境风险。

  • 使用思路:利用 std::once_flag 保证耗时 init() 在多线程下只执行一次。
  • 致命缺陷(面试必杀技)
    • once_flag 一旦被触发,无论内部的 init() 执行结果是成功还是失败,该标志位都会被置为“已调用”状态。
    • 后果:如果首次执行因为磁盘 IO 错误或网络超时而失败,后续所有线程在调用时都会直接跳过初始化逻辑,导致该单例永远返回空指针。服务在故障恢复后无法自愈,必须重启进程。

结论call_once 适用于“初始化绝不会失败”的场景,在本业务背景下必须放弃。


四、 终极解决方案(第三层,核心重点)

核心论点:引入“原子状态机”配合“双重检查锁(DCLP)”思想,实现失败可重试。

1. 状态定义

维护一个全局的、线程可见的状态枚举,包含以下四种状态:

  • 未初始化:初始状态,对象尚未创建。
  • 初始化中:标记当前有线程正在执行耗时的 init,防止其他线程重复进入。
  • 初始化成功:对象已就绪,可正常提供服务。
  • 初始化失败:本次尝试失败,允许后续请求重新触发初始化流程。

2. 获取实例的运行流程(双路径设计)

  • 快速路径(无锁读取)

    • 条件:当状态为“初始化成功”时。
    • 行为:所有线程直接读取已发布的对象指针,不涉及互斥锁竞争,保证极高并发性能。
  • 慢速路径(加锁与状态切换)

    • 触发条件:状态为“未初始化”或“初始化失败”。
    • 执行逻辑:
      1. 线程尝试获取互斥锁。
      2. 获取锁后,再次检查状态(防止等待期间已被其他线程初始化成功)。
      3. 若确认为“未初始化”或“失败”,先将状态置为“初始化中”,然后执行真正的耗时 init()
      4. 分支处理
        • init() 返回 true → 状态更新为“成功”,发布对象指针。
        • init() 返回 false → 状态回退为“失败”,释放锁,外部获取到空指针。
      5. 其他处于等待或稍后重试的线程,检测到“失败”状态时会重新进入慢速路径,从而实现了故障恢复后的自动重试。

五、 深度防护措施(第四层,加分项)

核心论点:除了逻辑正确,还需保证底层内存模型的绝对安全。

1. 内存序与指令重排(可见性)

  • 潜在风险:编译器和 CPU 可能会对赋值操作进行重排序。如果对象指针的写入发生在 init 逻辑完成之前,其他线程可能拿到一个未初始化完全的对象。
  • 解决方案
    • 状态变量的读取必须使用获取语义,确保后续的读操作不会提前执行。
    • 状态变量的写入必须使用释放语义,确保之前的初始化写入全部落盘。
    • 通过这种配对关系,在不加额外重量级屏障的前提下,保证跨线程的可见性。

2. 异常安全

  • 耗时 init() 内部绝对不能向外抛出原生异常。
  • 策略:在 init() 内部用 try-catch 包裹所有可能异常的操作,统一转换为布尔值(true/false)返回。保证状态机始终运行在可控的布尔逻辑下。

六、 总结陈词(价值升华)

  • 权衡取舍:虽然没有使用最简单的 call_once,但手动维护状态机换来了业务的弹性恢复能力
  • 性能保证:一旦初始化成功,后续读取路径完全无锁,不影响高频调用场景。
  • 设计原则:该方案完美契合“面向接口编程”和“单一职责”原则,将“对象创建”、“业务加载”、“状态流转”清晰解耦,是应对生产环境不稳定因素的成熟工程手段。
posted @ 2026-08-03 21:21  01y  阅读(4)  评论(0)    收藏  举报