【LeetCode-C】 Knowledges May

数位 DP

数位 DP(Digit DP)是一种用来解决与数字的十进制表示(或其他进制)相关的计数问题的动态规划方法。它的典型应用场景是:给定一个区间 ([L, R]),求其中满足某种与“数字的每一位”有关性质的数的个数。

常见的数位 DP 问题:

  • ([L,R]) 内有多少个不含数字 4 的数?
  • 有多少个相邻数字之差至少为 2 的数(Windy 数)?
  • 有多少个数字的各位之和能被某个数整除?
  • 有多少个数字本身能被自己的各位之和整除?
  • 数字中是否出现“49”、“13”等特定模式……

这些题目直接枚举会超时((R) 可达 (10^{18}) 甚至更大),而数位 DP 可以把复杂度降到与数字位数相关(如 (O(\text{位数} \times \text{状态数})))。


1. 核心思想:按位枚举 + 状态压缩

我们把一个数 (x) 写成一个固定长度的数字序列(高位在前面)。假设最大位数为 (len),我们就从最高位到最低位(第 (len-1) 位到第 0 位)一位一位地填数,并在填的过程中维护一些状态,例如:

  • 前一位填了什么数字(用于判断相邻关系)
  • 已经填的数字之和是多少
  • 某个模运算的结果

因为很多不同的前缀最终可能归到相同的状态,我们可以用记忆化搜索(DFS + 备忘录)来避免重复计算。


2. 两个关键控制标志:limitlead

在枚举过程中,我们通常用两个布尔标志来控制搜索。

2.1 上界限制 limit

当我们按位构造一个数时,如果前面所有位都恰好等于原数 (x) 的对应位,那么当前位只能取 (0 \sim \text{digit}[pos]);否则,当前位可以取 (0 \sim 9) 而不会超过 (x)。

  • limit = true:当前位受到上界限制。
  • limit = false:当前位不受限制,可以取满 0~9。

这种限制传递是:只有前面所有高位都贴着上限,这一位才继续贴上限。一旦某一位选的数字小于上限对应位,后面的位就永久解除限制(limit = false)。

2.2 前导零 lead

很多问题对前导零有特殊要求。例如,数字 013 的实际值为 13,最高位是 1 而不是 0。如果题目要求“不含前导零”或“计算相邻数字差时前导零不能算作真正的 0”,就需要用 lead 标志。

  • lead = true:当前位之前所有位都是前导零。
  • lead = false:已经填了非零的最高位。

前导零处理的核心:lead = true 时,如果当前位填 0,则下一层的 lead 仍为 true;一旦填了非零数字,lead 变成 false


3. 记忆化搜索模板

递归函数通常这样设计(以十进制为例):

def dfs(pos, ..., limit, lead):
    if pos == -1:          # 所有位都填完了
        return 1  某种条件判断的结果

    # 只有不受限制且没有前导零时,才能用记忆化结果
    if not limit and not lead and dp[pos][...] != -1:
        return dp[pos][...]

    up = digit[pos] if limit else 9
    res = 0
    for i in range(0, up + 1):
        # 处理前导零
        if lead and i == 0:
            # 继续传递 lead=True
            res += dfs(pos - 1, ..., limit and i == up, True)
        else:
            # 根据 i 更新状态 ...
            res += dfs(pos - 1, ..., limit and i == up, False)

    if not limit and not lead:
        dp[pos][...] = res
    return res

为了计算 ([L, R]) 内的答案,一般用前缀和思想: [ \text{ans} = \text{solve}(R) - \text{solve}(L-1) ] 其中 solve(x) 计算 ([0, x])(或 ([1, x]))中满足条件的数的个数。


4. 经典例题详解

4.1 【例题 1】不含数字 4 的数

题目:求 ([L, R]) 中有多少个数的十进制表示中不包含数字 4

状态设计:不需要额外状态,只需判断当前位是否选 4。但要注意:前导零不影响。

DP 数组dp[pos] 表示在没有限制、没有前导零时,从第 pos 位往后任意填且不包含 4 的方案数。

代码(Python 示例):

def solve(x):
    if x < 0: return 0
    digits = list(map(int, str(x)))
    digits.reverse()   # 让低位在索引 0,方便递归
    n = len(digits)

    dp = [-1] * n

    def dfs(pos, limit, lead):
        if pos == -1:
            return 1 if not lead else 0   # 如果全是前导零,就是数字0,算还是不算根据题意。这里假设不算0。
        if not limit and not lead and dp[pos] != -1:
            return dp[pos]

        up = digits[pos] if limit else 9
        res = 0
        for i in range(0, up + 1):
            if i == 4:        # 不允许4
                continue
            if lead and i == 0:
                res += dfs(pos - 1, limit and i == up, True)
            else:
                res += dfs(pos - 1, limit and i == up, False)

        if not limit and not lead:
            dp[pos] = res
        return res

    return dfs(n - 1, True, True)

# 区间询问
L, R = 1, 100
print(solve(R) - solve(L - 1))

分析

  • 边界 pos == -1 时,如果 lead 还是 True,说明这个数是 0。根据题意,0 是否包含通常要特殊判断,这里选择不算。
  • 记忆化条件 not limit and not lead 非常重要,因为 limitleadTrue 时的状态不具备普遍性,不能直接复用。

4.2 【例题 2】含有连续 “49” 的数

题目:统计 ([L,R]) 中有多少个数出现过子串 “49”。

状态设计:需要记录上一位是什么数字,用来判断是否会形成 “49”。

  • pre:上一位填的数字。可用 0~9,其中 -1 表示没有上一位(初始态或刚结束前导零)。
  • 需要一个状态 has(或直接判断)表示是否已经出现 “49”。但这里我们可以直接在 dfs 过程中判断:如果 pre == 4i == 9,则后续无论填什么都满足条件。可以用一个额外的 flag ok 表示当前是否已经包含 “49”。

DP 状态dp[pos][pre][ok],其中 ok 是 0/1。

代码片段

def dfs(pos, pre, ok, limit, lead):
    if pos == -1:
        return ok   # 整个数填完,ok=1 表示出现过 49
    if not limit and not lead and dp[pos][pre][ok] != -1:
        return dp[pos][pre][ok]
    up = digits[pos] if limit else 9
    res = 0
    for i in range(up + 1):
        nxt_ok = ok or (pre == 4 and i == 9)
        if lead and i == 0:
            res += dfs(pos - 1, -1, 0, limit and i == up, True)
        else:
            nxt_pre = i if not (lead and i == 0) else -1
            # 其实 lead=False 后 pre 一定不会是 -1
            res += dfs(pos - 1, i, nxt_ok, limit and i == up, False)
    if not limit and not lead:
        dp[pos][pre][ok] = res
    return res

注意:记忆化时状态 pre 维度要能表示 -1(数组下标可以加一偏移)。


4.3 【例题 3】Windy 数

题目:不含前导零且相邻两个数字之差至少为 2 的数称为 Windy 数。求 ([L,R]) 中的 Windy 数个数。

关键点

  • 前导零:当 lead=True 时,最高位实际还没确定,所以“相邻差”条件不应强行判断。
  • 首位(即刚脱离前导零的第一位)没有“上一位”来限制,可以直接任意取 1~9。

状态设计dp[pos][pre]pre 表示前一位数字(0~9)。如果处于前导零状态,pre 可以用特殊值(如 10)表示“还没有前一位”。

DP 记忆化dp[pos][pre] 在不限制、无前导零时记录。

代码核心部分

def dfs(pos, pre, limit, lead):
    if pos == -1:
        return 1 if not lead else 0   # 忽略全零的数
    if not limit and not lead and dp[pos][pre] != -1:
        return dp[pos][pre]
    up = digits[pos] if limit else 9
    res = 0
    for i in range(0, up + 1):
        if lead and i == 0:
            res += dfs(pos - 1, pre, limit and i == up, True)
        else:
            if lead:  # 这一位作为第一个非零位
                # 没有 pre 限制,可以直接选
                res += dfs(pos - 1, i, limit and i == up, False)
            else:
                if abs(i - pre) >= 2:
                    res += dfs(pos - 1, i, limit and i == up, False)
    if not limit and not lead:
        dp[pos][pre] = res
    return res

初始化 pre 可以给一个不影响的值(如 0),因为 lead=True 时不会检查 abs


4.4 【例题 4】能被自己的数位和整除的数

题目:求 ([L,R]) 中有多少个数,能被自己的各位数字之和整除。
(例如 12:数位和 3,12 能被 3 整除,符合条件)

复杂点:数位和不是固定的,需要在 DP 中同时记录当前数位和以及原数模数位和的结果。但模数随状态改变,不能直接存。
技巧:枚举最后的数位和 sum_mod。因为最大数位和不超过 9 * len(如 18 位数最大和 162),我们可以外层循环 sum_mod 从 1 到 max_sum,然后 DP 里计算:

  • sum: 当前已累计的数位和(不能超过 sum_mod
  • mod: 当前构成的数对 sum_mod 取模的余数

最后 pos == -1 时,判断 sum == sum_modmod == 0

DP 状态dp[pos][sum][mod],针对固定的 sum_mod 做记忆化(注意不同 sum_mod 要清空 dp 数组)。

复杂度为 (O(\text{位数} \times \text{最大数位和}^3)),在合适范围(如 18 位数)内可行。


5. 实现技巧与注意事项

5.1 记忆化只记录通用状态

dp 数组只应该在 !limit && !lead 时写入和读取。因为 limit=true 的状态严重依赖于原始数字的上界,而 lead=true 的状态只在数字起始阶段出现,复用机会很少。

5.2 递推式预处理 + 组合计数

有些简单题(如不含 4)可以不用记忆化搜索,直接预处理 f[pos][state] 再用常规的按位统计。
例如:f[i][0] 表示 i 位数中不含 4 的个数,最后扫描上界的每一位。这种方法通常空间更小,但思维要求稍高。记忆化 DFS 写法更直观、不易错。

5.3 处理前导零的通用原则

  • 如果题目求的是“正整数的性质”,要注意 0 是否应该计入。通常 solve(0) 返回 0 或 1 要结合题义调整。
  • 凡涉及“相邻位比较”时,一定要等脱离前导零后再比较。
  • 前导零也会影响数字实际长度,如“不含前导零的 3 位数”统计。

5.4 状态压缩

当状态较多时,DP 数组可能多维。可以用字典或 functools.lru_cache,但注意 limitlead 不能缓存。性能上,多维数组直接索引通常更快。

5.5 二进制 / 其它进制

数位 DP 不仅用于十进制,也可以处理二进制表示下的计数(如“二进制中 1 的个数”相关问题)。只需将 up 改成相应进制的最大数码。


6. 总结

数位 DP 的核心是:把区间计数问题转换成“按位决策”的树上搜索,并通过标志位 limitlead 控制边界,最后用前缀和差分得到答案
由于大部分状态的限制条件在脱离上限和前导零后会变得普遍一致,记忆化搜索可以极大减少重复计算。

学习路线建议:

  1. 先掌握最简单的不含某数字的题,理解 limit 与记忆化机制。
  2. 再练习带前导零判断的 Windy 数等。
  3. 进阶到多维状态(如模运算、数位和、是否出现某模式)。
  4. 尝试自己改写为递推,加深理解。

掌握了这些模板和状态设计思路,绝大多数数位 DP 题都可以套用解决。

匿名函数

C++ 中的匿名函数(anonymous function)在语言规范里称为 lambda 表达式。自 C++11 引入后,它在 STL 算法、回调、并发等场景中极大地简化了代码。下面从基础到高级,详细介绍 C++ lambda。


1. 什么是 lambda 表达式?

Lambda 表达式是一个可调用的无名函数对象,可以捕获当前作用域中的变量,并在后续被调用。
编译器会把它展开为一个匿名的函数对象类(闭包类),每个 lambda 表达式生成一个独一无二的类。

// 最简单的 lambda
auto f = [] { std::cout << "Hello\n"; };
f(); // 调用

2. 基本语法

完整形式:

[ capture ] ( parameters ) specifiers -> return_type { body }

所有组成部分:

  • capture:捕获列表,描述如何捕获外部变量。
  • parameters:参数列表(可为空,可省略括号如果无参数、无 mutable 等)。
  • specifiers:可选限定符,如 mutableconstexprnoexcept 等。
  • return_type:尾置返回类型(可省略让编译器推导)。
  • body:函数体。

最简形式[]{} 是一个合法但什么都不做的 lambda。


3. 捕获列表(captures)

捕获列表决定了 lambda 体内可以使用哪些外部变量,以及如何访问它们(值或引用)。

3.1 空捕获:[]

内部不能使用任何外部局部变量(全局变量、静态变量无需捕获可直接用)。

3.2 值捕获:[x]

将变量 x 的副本捕获到闭包对象中。默认不能修改这个副本(等效闭包类的 operator()const)。想修改需要用 mutable

int a = 10;
auto f = [a] { return a + 1; };          // OK,读取 a 的副本
// auto g = [a] { a++; };                // 错误:不能修改 const 副本
auto h = [a]() mutable { a++; return a; };// 正确,加了 mutable

3.3 引用捕获:[&x]

捕获引用,内部可以修改外部变量,并且修改会反映到外部。

int a = 10;
auto f = [&a] { a += 5; };
f();
// a 变成 15

3.4 隐式捕获

  • [=]:按值捕获所有用到的外部变量(自动推导需要哪些)。
  • [&]:按引用捕获所有用到的外部变量。
int x = 1, y = 2;
auto add = [=] { return x + y; };   // 值捕获 x 和 y
auto inc = [&] { x++; y++; };       // 引用捕获 x 和 y

3.5 混合捕获

可以混合默认捕获和显式捕获,但要求:

  • 如果默认捕获是 =,显式捕获必须是 &变量
  • 如果默认捕获是 &,显式捕获必须是 变量
int x = 1, y = 2;
auto f = [=, &y] { y += x; };   // x 值捕获,y 引用捕获
auto g = [&, x] { x + y; };     // y 引用捕获(隐式),x 值捕获

3.6 捕获 this*this(C++17)

在成员函数内部,可以捕获当前对象:

  • [this]:捕获 this 指针(按值捕获指针,成员按引用访问)。相当于可以修改成员。
  • [*this](C++17):捕获当前对象的副本,lambda 内部对成员的修改不影响原对象。
struct Widget {
    int val = 0;
    auto get_lambda() {
        return [*this] { return val; }; // 捕获整个对象的副本
    }
};

3.7 初始化捕获(C++14)

允许在捕获列表中直接定义并初始化一个新变量,或者移动外部对象

auto ptr = std::make_unique<int>(5);
auto f = [p = std::move(ptr)] { return *p; }; // 移动捕获

auto g = [value = 42] { return value; };      // 自定义变量

3.8 捕获全局/静态/常量

全局变量、静态局部变量、常量表达式不需要写在捕获列表中,可以直接在 lambda 体中使用。

int global = 100;
void test() {
    static int s = 5;
    auto f = [] { return global + s; }; // OK
}

4. 参数列表与返回类型

4.1 参数

和普通函数一样,可以带参数,也可以不带。如果 lambda 没有任何参数且不包含 mutablenoexcept 等修饰,可以省略参数括号。

auto f = [] { return 1; };           // 省略 ()
auto g = [](int a, int b) { return a + b; };

4.2 尾置返回类型

大部分情况下编译器可以自动推导返回类型。若 lambda 体内有多个 return 语句且类型不同,或想明确指定返回类型,就需要尾置:

auto f = [](int x) -> double {
    if (x > 0) return 1.0;
    else return 2.0;     // 必须一致,或显式指定返回类型
};

如果省略,且多个 return 类型不一致则编译错误。


5. mutable 限定符

前面提到,值捕获的变量副本在默认 const 闭包函数中不可修改。加上 mutable 后可以修改这些副本,但不影响外部原变量

int n = 0;
auto f = [n]() mutable { n++; return n; };
std::cout << f() << '\n'; // 1
std::cout << f() << '\n'; // 2
std::cout << n << '\n';   // 0 (外部不变)

注意:当参数列表为空时,如果要加 mutable,参数括号不可省略。

auto f = [n]() mutable { /*...*/ };

6. 泛型 lambda(C++14)

参数类型可以声明为 auto,让 lambda 成为函数模板。编译器会为每次调用生成对应类型的实例化。

auto sum = [](auto a, auto b) { return a + b; };
std::cout << sum(1, 2.5) << '\n';     // 3.5
std::cout << sum(std::string("A"), "B"); // "AB"

C++20 进一步允许直接使用模板语法:

auto f = []<typename T>(const std::vector<T>& v) {
    // ...
};

这在需要访问类型本身(而非仅 auto)时很有用,例如 T::value_type


7. constexpr lambda(C++17 起)

Lambda 表达式默认隐含 constexpr(如果函数体满足常量表达式条件)。也可以显式标记为 constexprconsteval(C++20)。

auto square = [](int n) constexpr { return n * n; };
static_assert(square(10) == 100);

在编译期计算时非常方便。


8. 立即调用的 lambda 表达式(IILE)

定义 lambda 的同时直接调用它,常用于复杂的常量初始化。

const auto vec = [] {
    std::vector<int> v;
    for (int i = 0; i < 10; ++i)
        v.push_back(i * i);
    return v;
}();   // 立即调用

这种方式可以代替传统的立即执行函数,并且清晰地限定临时变量的作用域。


9. lambda 与可调用对象的转换

9.1 转换为函数指针

只有无捕获[])的 lambda 能转换为函数指针。

void (*fp)(int) = [](int x) { std::cout << x; };
fp(10);

有捕获的 lambda 则不能。

9.2 转换为 std::function

任何 lambda 都可以存入 std::function,但有额外的类型擦除开销。

std::function<int(int, int)> f = [](int a, int b) { return a + b; };

9.3 作为模板参数(泛型编程)

可以直接用 auto 接收 lambda,保持其具体类型,无 std::function 开销。标准库算法通常按值接受这样的可调用对象。

auto cmp = [](int a, int b) { return a > b; };
std::set<int, decltype(cmp)> s(cmp);

10. 底层实现与性能

编译器为每个 lambda 生成一个匿名的类(闭包类型)。该类有:

  • 一个 operator() 成员函数(默认 const,除非有 mutable)。
  • 捕获的变量作为其数据成员。
  • 如果是值捕获,成员通过拷贝初始化;引用捕获则存储引用。

由于类型确定,lambda 的调用通常可以被完全内联优化,性能与手写的函数对象或普通函数无异。
开销注意

  • 按值捕获大量变量会增加闭包对象体积,影响拷贝成本。
  • std::function 包装会引入间接调用和可能的堆分配,慎用在性能热点。

11. 常见使用场景

  1. STL 算法的自定义谓词/操作:

    std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
    std::for_each(v.begin(), v.end(), [](int &n) { n *= 2; });
    
  2. 回调函数:如定时器、事件处理、异步任务等。

    std::async(std::launch::async, [result = std::move(data)] { process(result); });
    
  3. 一次性辅助函数:避免定义独立函数污染命名空间。

    auto legal = [&](int x) { return x > 0 && x < max_val; };
    if (legal(a) && legal(b)) ...
    
  4. 惰性求值 / 延迟执行:将 lambda 存储起来,稍后调用。

  5. 自定义删除器(如智能指针):

    std::unique_ptr<FILE, decltype([](FILE* f) { fclose(f); })> fp(fopen("..."), {});
    

    (C++20 允许 lambda 在未求值上下文中使用)


12. 各版本特性速查

版本特性
C++11lambda 基础语法、捕获列表、mutable
C++14泛型 lambda(auto 参数)、初始化捕获
C++17constexpr lambda、捕获 *this
C++20模板 lambda、默认构造/赋值(无捕获)、可以在未求值上下文出现、consteval lambda

13. 注意事项 & 最佳实践

  • 生命周期:引用捕获时,务必保证被引用的变量在 lambda 调用时仍然存活,避免悬垂引用(尤其是在异步或延迟调用中)。
  • 显式优于隐式:尽量写清楚捕获哪些变量,避免不必要的 [=][&] 大面积捕获,降低意外修改的风险。
  • 移动捕获:如果外部对象不可复制或希望转移所有权,用 C++14 初始化捕获 [p = std::move(ptr)]
  • 递归 lambda:需要结合 std::function 或使用带有 auto 参数与 Y 组合子的技巧(通常用 std::function 更简单)。
  • this 捕获:在成员函数中使用 lambda 并捕获 this 时,注意对象生命周期;C++17 的 *this 可捕获副本避免该问题。

Lambda 表达式是现代 C++ 中不可或缺的组成部分,能写出更简洁、内聚的代码。掌握从基础捕获到 C++20 的高级用法,可以让你在不同场景下都游刃有余。

快速选择

快速选择算法(Quickselect)是一种在未排序序列中查找第 k 小(或第 k 大)元素的高效选择算法。它与快速排序同源,都基于分治思想,但每次只需递归处理一侧,因此平均时间复杂度为 (O(n))。下面从原理到实现深入解析。


1. 核心思想

快速选择的核心步骤与快速排序的划分(partition)完全一致:

  • 选择一个基准元素(pivot)
  • 将序列重新排列,使得小于基准的元素在左边,大于基准的在右边(等于的元素可左可右或单独处理)
  • 此时基准会落在其最终有序位置上(假设序列按升序排列),记该索引为 pivot_index

假如要找第 k 小的元素(k 从 0 开始计数),那么:

  • pivot_index == k,基准正是我们要找的元素,直接返回。
  • pivot_index > k,说明目标元素在基准左边,递归进入左半部分继续查找。
  • pivot_index < k,说明目标元素在基准右边,递归进入右半部分,同时将 k 调整为 k - pivot_index - 1(或保持全局 k 不变,但只在右部分继续划分)。

关键优势:每次递归只需要处理一侧的子序列,平均情况下每次问题规模减半,因此总操作次数为 (n + n/2 + n/4 + \dots = O(n))。


2. 算法步骤

以寻找第 k 小(升序,k 从 0 开始)为例:

  1. 边界检查:若序列为空或 k 非法,返回错误。
  2. 选择枢纽元:通常随机选取或取首/尾/三数中值,避免最坏退化。
  3. 执行划分(partition):
    • 使用 LomutoHoare 划分方案。
    • 最终将序列分成 [小于基准] + 基准 + [大于基准],并得到基准的最终位置 p
  4. 决策
    • 如果 p == k,返回 nums[p]
    • 如果 p > k,递归处理左区间 [left, p-1]
    • 如果 p < k,递归处理右区间 [p+1, right]
  5. 重复 直到找到目标。

3. 与快速排序的关系

快速排序(Quicksort)快速选择(Quickselect)
每次划分后左右两侧都要递归每次划分后只递归一侧
时间复杂度 平均 (O(n \log n)),最坏 (O(n^2))平均 (O(n)),最坏 (O(n^2))
目的是完全排序整个序列目的是找到第 k 小的元素,其他元素不必有序

快速选择相当于快速排序的“减半治之”(decrease and conquer),而快速排序是标准的分治(divide and conquer)。


4. 时间复杂度分析

4.1 平均情况

假设每次划分都能将序列大致对半分,问题规模递减的规律为: [ T(n) = T(n/2) + O(n) ] 解此递推式: [ T(n) = O(n) + O(n/2) + O(n/4) + \dots = O(2n) = O(n) ] 即使在每次划分比例为 1/3 和 2/3 的情况下,依然有 (T(n) = T(2n/3) + O(n)),等比级数仍收敛于 (O(n))。

4.2 最坏情况

若每次划分极不均匀(如选到最小或最大元素),则可能退化为: [ T(n) = T(n-1) + O(n) \implies O(n^2) ] 例如:已排序的数组且始终选第一个/最后一个元素作为枢纽。

4.3 优化最坏情况的常用手段

  • 随机化:每次从当前区间随机选取一个元素作为枢纽,可使最坏情况发生的概率极低,期望时间复杂度仍为 (O(n))。
  • Median-of-Medians(BFPRT):永远选择一个“好”的枢纽,保证划分比例在最坏情况下也有界,从而实现确定性线性时间 (O(n))。这是快速选择的加强版本,但常数因子较大,实践中随机化更常用。

5. 空间复杂度

  • 递归实现需要额外的调用栈空间。平均递归深度为 (O(\log n)),最坏 (O(n))。
  • 可轻松改写为迭代形式(只需一个循环,每次更新区间边界),此时空间复杂度降为 (O(1))。
  • 划分操作在原地进行,不需要额外数组。

6. 实现细节

6.1 划分函数的选择

  • Lomuto 划分:将基准放在区间末尾,简单易写,但可能对重复元素效率较低。
  • Hoare 划分:使用双指针从两端向中间扫描,效率通常更高,且能较好处理大量重复元素。但划分后基准的位置不一定在最终位置,需要小心递归边界。

快速选择通常使用 Lomuto 足够清晰,但推荐随机化的 Hoare 划分以获得更稳定的性能。

6.2 关于 k 的计数

  • 通常约定 k 从 0 开始,即 k=0 表示最小元素。
  • 若要“第 k 大”,可以转换为第 (n-k) 小,或者修改比较方向。

6.3 处理重复元素

当存在大量重复值时,Hoare 划分可能不会将等于枢纽的元素全部集中到中间,而是左右都有。这并不影响快速选择的正确性,因为只要保证 左半部分全部 ≤ 枢纽右半部分全部 ≥ 枢纽,根据索引与 k 的比较仍能正确决定递归方向。如果使用三路划分(分成小于、等于、大于三区),可以进一步跳过整个“等于”区,提高效率。


7. 代码示例(C++ 迭代实现,随机化枢纽)

#include <vector>
#include <random>
#include <algorithm>

// 随机枢纽的划分(Hoare版)
int partition(std::vector<int>& nums, int left, int right) {
    int rand_idx = left + rand() % (right - left + 1);
    std::swap(nums[rand_idx], nums[right]);      // 将随机枢纽移到末尾
    int pivot = nums[right];
    int i = left;
    for (int j = left; j < right; ++j) {
        if (nums[j] <= pivot) {
            std::swap(nums[i], nums[j]);
            ++i;
        }
    }
    std::swap(nums[i], nums[right]);   // 将枢纽放到正确位置
    return i;
}

int quickselect(std::vector<int>& nums, int k) {
    int left = 0, right = nums.size() - 1;
    while (left <= right) {
        int p = partition(nums, left, right);
        if (p == k) return nums[p];
        else if (p > k) right = p - 1;
        else left = p + 1;
    }
    return -1; // 不会到达,除非k非法
}

使用方式:

std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
int kth_smallest = quickselect(v, 3);  // 第4小的元素(k=3) → 结果为3

说明:代码采用迭代,无需递归,空间复杂度 (O(1))。每次随机选取枢纽,期望线性时间。


8. 应用场景

  • 求中位数:直接调用 quickselect(v, v.size()/2),常用于统计学、图像处理、流式数据的分位数估计。
  • Top K 问题:找到第 k 小的元素后,序列中该元素左侧即为最小的 k 个元素(但不一定有序)。这比完全排序 (O(n \log n)) 或堆方法 (O(n \log k)) 更优,尤其是当 k 接近 n/2 时。
  • K 分之一位数(Quantile):可反复使用快速选择找出多个分位点。
  • 作为其他算法的子程序:如 BFPRT 线性时间选择算法,最短路快速选择,或一些数据分块算法。

9. 与其他选择算法的对比

算法平均时间最坏时间额外空间备注
快速选择(随机化)(O(n))(O(n^2))(极低概率)(O(1))实用首选
BFPRT 算法(O(n))(O(n))(O(\log n))?理论最优,实现复杂
排序后取第 k(O(n \log n))(O(n \log n))(O(1)) 或 (O(n))简单但浪费
最小堆(k 固定)(O(n \log k))(O(n \log k))(O(k))适合 k 很小
最大堆(全量)(O(k \log n))?不常用

通常情况下,随机化快速选择是实际工程中最常用的平衡解。


10. 总结

快速选择算法是分治思想在选择问题上的精妙应用。它继承了快速排序的划分逻辑,却通过单边递归将平均复杂度降至线性。通过随机化可以基本避免最坏退化,而结合 BFPRT 又可获得理论保证。其原地迭代版本空间开销极小,是解决“无需全排序,只求第 k 小”问题的利器。在实际开发中,C++ STL 的 std::nth_element 就是快速选择的典型工业实现,采用 Introselect(快速选择 + 堆排序兜底)来同时保证平均性能和最坏时间复杂度。

upper_bound & lower_bound

std::lower_boundstd::upper_bound 是 C++ 标准库中基于二分查找的算法,定义在 <algorithm> 头文件中。它们用于在已排序的序列中快速定位元素,是对数复杂度的高效工具。下面从原理、接口、用法到高级技巧全面介绍。


1. 核心定义

在一个升序排列的区间 [first, last) 中,给定一个值 value

函数返回的迭代器指向含义(区间升序)
lower_bound第一个 大于等于 value 的元素不小于 value 的起始点
upper_bound第一个 严格大于 value 的元素大于 value 的起始点

图示(升序数组):

元素:    1   2   2   3   4   4   5
下标:    0   1   2   3   4   5   6
查找 value = 4:
  lower_bound → 下标 4 (元素4)
  upper_bound → 下标 6 (元素5)
查找 value = 2:
  lower_bound → 下标 1 (第一个2)
  upper_bound → 下标 3 (第一个大于2的元素,即3)
查找 value = 6:
  lower_bound / upper_bound → 下标 7 (end())

如果 value 大于序列中所有元素,则两个函数都返回 last


2. 函数签名

// 使用 operator< 进行默认比较
template< class ForwardIt, class T >
ForwardIt lower_bound( ForwardIt first, ForwardIt last, const T& value );

template< class ForwardIt, class T >
ForwardIt upper_bound( ForwardIt first, ForwardIt last, const T& value );

// 使用自定义比较函数 comp,要求 comp(element, value) 为严格弱序
template< class ForwardIt, class T, class Compare >
ForwardIt lower_bound( ForwardIt first, ForwardIt last, const T& value, Compare comp );

template< class ForwardIt, class T, class Compare >
ForwardIt upper_bound( ForwardIt first, ForwardIt last, const T& value, Compare comp );
  • first, last:已排序的区间(前闭后开)。
  • value:要查找的值。
  • comp:二元谓词,当第一个参数应排在第二个参数之前(即严格小于)时返回 true。对于 lower_bound,它寻找第一个使 comp(element, value)false 的位置;对于 upper_bound,寻找第一个使 comp(value, element)true 的位置(即 value < element)。

返回值ForwardIt,指向目标位置的迭代器;若所有元素都不满足条件,返回 last


3. 使用前提

  • 区间 必须根据相同的比较逻辑有序。例如默认 operator< 时,元素必须按升序排列。若使用 comp,区间必须按 comp 排序(即所有满足 comp(element, value) 的元素在不满之前)。
  • 如果区间未排序,调用行为是未定义的(通常结果错误)。
  • 如果区间中存在与 value 等价的元素,lower_boundupper_bound 能准确定位到其边界。

4. 时间复杂度

  • 比较次数:对于随机访问迭代器为 (O(\log N)),对于其他迭代器(如 std::set 的迭代器)比较次数仍是对数,但前进步数是线性的(标准库中 std::set 有自己的成员函数 lower_bound/upper_bound,时间复杂度 (O(\log N)))。
  • 空间复杂度:(O(1)),不需要额外内存。

5. 底层实现原理

二者均使用二分查找,但循环中判断分支略有不同。

lower_bound 典型实现

while (first < last) {
    auto mid = first + (last - first) / 2;
    if (*mid < value)      // 或者 comp(*mid, value)
        first = mid + 1;   // 目标在右半区
    else
        last = mid;        // 目标在左半区或就是 mid
}
return first;

upper_bound 典型实现

while (first < last) {
    auto mid = first + (last - first) / 2;
    if (!(value < *mid))   // 即 value >= *mid 或 !comp(value, *mid)
        first = mid + 1;   // 目标在右半区
    else
        last = mid;        // *mid > value,目标在左半区
}
return first;

两者配合可以高效求出等于 value 的元素个数upper_bound - lower_bound 即为等价元素的数量(对于严格弱序定义下的“相等”)。


6. 常见用例与示例

6.1 基本用法(升序数组)

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> v = {1, 2, 2, 3, 4, 4, 5};
    
    auto lb = std::lower_bound(v.begin(), v.end(), 4);
    auto ub = std::upper_bound(v.begin(), v.end(), 4);
    
    std::cout << "lower_bound(4) at index: " << (lb - v.begin()) << '\n'; // 4
    std::cout << "upper_bound(4) at index: " << (ub - v.begin()) << '\n'; // 6
    std::cout << "number of 4s: " << (ub - lb) << '\n';                   // 2
}

6.2 在降序数组中查找

需要传入比较函数,如 std::greater<int>()

std::vector<int> v = {5, 4, 4, 3, 2, 2, 1};
// 注意:区间按 greater 排序(降序),value 类型为 int
auto lb = std::lower_bound(v.begin(), v.end(), 4, std::greater<int>());
// lower_bound 此时寻找第一个 **不大于** 4 的元素(即第一个 <=4)
// 升序时:lower_bound 是第一个 >= value;降序时比较逻辑翻转,可理解为第一个 comp(element, value) 为 false 的位置。
// 实际效果:指向第一个 4 或更小的元素,即下标 1 处的 4。
// 更通用的记忆:lower_bound 返回第一个不满足“元素在 value 前”的位置。

6.3 自定义结构体排序与查找

struct Student {
    std::string name;
    int score;
};

// 按分数升序排列
std::vector<Student> students = {
    {"Alice", 80}, {"Bob", 90}, {"Cathy", 90}, {"David", 100}
};

// 查找第一个分数 >= 90 的学生
auto lb = std::lower_bound(students.begin(), students.end(), 90,
    [](const Student& s, int val) { return s.score < val; });
// 结果指向 Bob

// 查找第一个分数 > 90 的学生
auto ub = std::upper_bound(students.begin(), students.end(), 90,
    [](int val, const Student& s) { return val < s.score; });
// 结果指向 David

注意:upper_bound 的自定义比较签名是 comp(value, element)。上面传入的 lambda 正确表达了 value < element 的含义。

6.4 检查元素是否存在

可以使用 lower_bound 加判断:

auto it = std::lower_bound(v.begin(), v.end(), target);
if (it != v.end() && *it == target) {
    // 存在
}

或者直接用 std::binary_search


7. equal_range 与两者的关系

std::equal_range 返回一个 std::pair,其 first 等同于 lower_boundsecond 等同于 upper_bound。它是一次二分查找得到两个边界的便捷函数。

auto [lb, ub] = std::equal_range(v.begin(), v.end(), value);
// lb == lower_bound, ub == upper_bound

8. C++20 中的 ranges 版本

C++20 引入了 range 适配,可以直接作用于容器,无需传递迭代器对,代码更简洁。

#include <ranges>
#include <algorithm>

std::vector<int> v = {1, 2, 2, 3, 4};
auto it = std::ranges::lower_bound(v, 3);   // 返回迭代器
// 还有 std::ranges::upper_bound 等

同时也支持投影(projection)和自定义比较。


9. 注意事项与常见错误

  • 未排序的序列:绝对不要对无序序列使用这两个函数,结果未定义。
  • 比较函数不一致:排序和查找必须使用相同的比较逻辑,否则二分查找的前提被破坏。
  • upper_bound 的比较参数顺序:自定义比较时,upper_bound( first, last, value, comp )comp 应该接受 (value, element) 并返回 truevalue < element。这和 lower_boundcomp(element, value) 参数顺序不同,容易写错。一个安全习惯:用 equal_range 或始终统一使用“元素与值比较”的 lambda,并通过调整参数位置来实现 upper_bound。
  • 容器自带成员函数:关联容器(std::setstd::map 等)拥有自己的 lower_boundupper_bound 成员函数,它们利用内部红黑树结构,时间复杂度为 (O(\log N)) 且比泛型算法更快。对于这类容器,应优先使用成员函数。
  • 严格弱序:自定义比较必须满足严格弱序(类似 < 的性质),否则行为未定义。

10. 总结对比表

函数条件(默认升序)返回位置
lower_bound第一个 >= value插入到最左边保持序
upper_bound第一个 > value插入到最右边保持序
binary_search是否存在 == valuebool
equal_range[lower_bound, upper_bound)等价元素范围

熟练掌握 lower_boundupper_bound 是高效处理有序数据、实现插入位置查找、统计频次等操作的基础,也是理解二分查找思想的重要一步。

vector capacity

C++ 的 std::vector 没有提供类似 set_capacity() 这样的直接方法,但可以通过几个成员函数和惯用法来增加减少其容量,从而控制内存分配。下面从概念到技巧全面梳理。


1. 基本概念:sizecapacity

  • size():容器中实际存储的元素个数。
  • capacity():容器当前已分配内存能容纳的元素总数(不重新分配的前提下最多可容纳多少元素)。

capacity >= size 恒成立。当插入新元素导致 size > capacity 时,vector 会自动重新分配一块更大的内存,并将原有元素拷贝/移动到新空间——这耗时且会使原有迭代器、指针、引用失效。


2. 查看当前容量

std::vector<int> v;
std::cout << v.capacity() << '\n';   // 通常为 0

3. 增加容量:reserve(n)

reserve(n) 保证 vector 的 capacity() 至少为 n(如果 n 大于当前容量,则重新分配;否则可能什么都不做,标准不强制缩小)。

  • 不会改变 size(),也不会添加元素。
  • 如果发生重新分配,所有迭代器、指针、引用失效。
  • 常用于预分配,避免后续插入时多次重新分配。
std::vector<int> v;
v.reserve(1000);           // 容量至少 1000
for (int i = 0; i < 1000; ++i)
    v.push_back(i);        // 这 1000 次 push_back 不会引发重新分配

注意

  • reserve(n) 如果 n <= capacity() 则通常不做任何事(实现可能忽略,也可能收缩?标准不要求收缩,所以不能依赖它来减少容量)。
  • n 很大(如 reserve(v.size() + 1) 后马上 shrink_to_fit() ?),需合理预估,避免浪费内存。

4. 减少容量

4.1 shrink_to_fit() (C++11 起)

请求容器释放未使用的多余内存,使得 capacity() 尽可能接近 size()

  • 非强制:这是一个请求,具体实现可以选择忽略,依赖实现。
  • 若发生重新分配,迭代器等失效;否则可能失效也可能不失效(若实现采用原地收缩则不失效,但极少)。
std::vector<int> v(100);
v.resize(10);               // size = 10, capacity 仍可能为 100
v.shrink_to_fit();          // 请求 capacity 接近 10

4.2 经典 swap 惯用法(C++11 之前及现在仍适用)

创建一个与 v 具有相同元素的临时 vector,然后与之交换。临时 vector 的容量恰等于其大小,交换后原 v 拥有该紧凑容量,临时对象被销毁。

std::vector<int> v(100, 0);
v.resize(10);                       // 希望缩减容量
std::vector<int>(v).swap(v);       // 现在 v.capacity() == v.size()

C++11 之后同样可以用,而且 shrink_to_fit 内部通常也实现为类似操作。这种方式保证容量降低到 size()

4.3 clear() 不改变容量

v.clear();      // size = 0, capacity 不变

若想清空同时释放内存,用 clear() + shrink_to_fit() 或与空 vector 交换:

std::vector<int>().swap(v);     // v 变为空,capacity 通常为 0
// 或
v.clear();
v.shrink_to_fit();

5. 构造时“设置”容量

vector 的构造函数不能直接指定 capacity,只能指定初始 size 或提供元素范围。

  • vector<int> v(100):创建含有 100 个元素的 vector(全部为 0),size = 100capacity 至少为 100。
  • vector<int> v; v.reserve(100)size = 0capacity >= 100,适合后续 push_back

如果既想预分配容量,又不想构造多余元素,先默认构造再 reserve 是最佳选择。


6. 其他影响容量的操作

  • push_back / emplace_back / insert:若 size == capacity 则会触发扩容,通常容量增加为原来的两倍或类似因子(具体由实现决定)。
  • resize(n)
    • n > capacity(),则会重新分配使容量至少为 n
    • n <= capacity()capacity() 通常不变。
  • assign:可能重新分配,取决于新元素数量和现有容量,但不保证会缩小容量。
  • 移动构造 / 移动赋值:源 vector 的容量通常被偷走(目标获得源的容量,源变成空,有的实现下源依然保留一些容量,但常见是源被清空容量为 0 或实现定义)。注意标准仅规定移动操作后源处于“有效但未指定状态”,一般使用前应重新 reserve 或确认。

7. 常用场景与最佳实践

目的方法
预分配内存,避免反复分配vec.reserve(N);
释放多余内存vec.shrink_to_fit();vector<T>(vec).swap(vec);
清空所有元素并释放内存vector<T>().swap(vec);
构造后立即分配容量vector<int> v; v.reserve(100);

8. 示例代码综合演示

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    std::cout << "初始 capacity: " << v.capacity() << '\n';   // 0

    v.reserve(10);
    std::cout << "reserve(10) 后 capacity: " << v.capacity() << ", size: " << v.size() << '\n';  // 10, 0

    for (int i = 0; i < 5; ++i) v.push_back(i);
    std::cout << "插入5个元素后 capacity: " << v.capacity() << ", size: " << v.size() << '\n'; // 10, 5

    v.shrink_to_fit();
    std::cout << "shrink_to_fit 后 capacity: " << v.capacity() << '\n';   // 可能 >= 5

    // swap 惯用法精确缩减
    std::vector<int>(v).swap(v);
    std::cout << "swap 后 capacity: " << v.capacity() << '\n';            // 5

    // 彻底清空并释放内存
    std::vector<int>().swap(v);
    std::cout << "与空 vector 交换后 capacity: " << v.capacity() << '\n'; // 0
}

9. 注意事项

  • 迭代器失效:任何导致容量改变的操作(reserveshrink_to_fit 等)都可能使所有迭代器、指针和引用失效。小心在循环中操作。
  • shrink_to_fit 的非强制性:如果需要可靠地释放内存,请使用 swap 惯用法。
  • 移动后的 vector:移后源 vector 的容量未定义,不要依赖其值,使用前重新 reserve 或直接丢弃。
  • 空间开销vector<bool> 特化特殊,capacity 和普通 vector 语义有差异,但一般讨论不涉及它。

掌握了 reserveshrink_to_fit 和 swap 技巧,就能灵活控制 vector 的内存容量,既避免频繁分配提升性能,又能在需要时及时释放内存。

iterator

在 C++ 中,反向迭代器 std::reverse_iterator 是一个适配器,它包装一个正向迭代器,使其遍历方向反转。要将一个正向迭代器转换为反向迭代器,通常使用 std::reverse_iterator 的构造函数或 std::make_reverse_iterator。但这个过程有许多关键细节,尤其是迭代器类型要求解引用的偏移关系,下面详细展开。


1. 什么是反向迭代器?

std::reverse_iterator<It> 是一个迭代器适配器,它持有一个类型为 It正向迭代器(称为 base),并重载了 operator*operator++operator-- 等,使得向“前”移动实际调用的是正向迭代器的向“后”移动。例如:

  • ++rit 会调用 --base
  • --rit 会调用 ++base
  • *rit 返回的是 *(base - 1)(即正向迭代器前一个位置的值)。

因此,所有反向迭代器的遍历都基于一个正向迭代器。容器的 rbegin()rend() 就是通过这种适配产生的:

  • rbegin() 相当于 std::reverse_iterator(end())
  • rend() 相当于 std::reverse_iterator(begin())

2. 必要条件:正向迭代器必须是双向迭代器

尽管模板参数常被命名为 ForwardIt,但 std::reverse_iterator 要求底层的正向迭代器至少是双向迭代器(BidirectionalIterator),因为它需要在内部执行 -- 操作。如果你的迭代器只是前向迭代器(如 std::forward_list 的迭代器),则无法构造 reverse_iterator,会导致编译错误。

#include <forward_list>
#include <iterator>

std::forward_list<int> fl = {1,2,3};
// 编译错误:forward_list 的迭代器不支持 --
// std::reverse_iterator<std::forward_list<int>::iterator> rit(fl.end());

只有双向或随机访问迭代器(如 std::liststd::vectorstd::deque 的迭代器,以及原生指针)才能生成反向迭代器。


3. 从正向迭代器构造反向迭代器

方法一:直接构造

std::vector<int> v = {1, 2, 3, 4, 5};
auto it = v.begin() + 2;          // 指向元素 3

// 构造反向迭代器,传入正向迭代器
std::reverse_iterator<decltype(it)> rit(it);
// 或使用模板推导 (C++17 CTAD)
std::reverse_iterator rit2(it);

方法二:使用 std::make_reverse_iterator (C++14 起)

auto rit = std::make_reverse_iterator(it);  // 自动推导类型

这两种方式完全等价,make_reverse_iterator 仅是为了便利和类型推导。


4. 核心陷阱:base() 与解引用偏移

这是最容易犯错的地方:反向迭代器内部存储的正向迭代器(通过 base() 获取)并不指向反向迭代器“逻辑上”指向的那个元素,而是指向该元素的下一个位置(正向次序中的下一个)。

更精确地说,若 rit 是一个反向迭代器,那么:

  • rit.base() 返回内部正向迭代器;
  • *rit 返回 *(rit.base() - 1)

为什么要这样设计?
这是为了保持“前闭后开”的半开区间约定。以 rbegin()rend() 为例:

  • rbegin() 的内部正向迭代器是 end(),其解引用得到的是最后一个元素 *(end()-1)
  • rend() 的内部正向迭代器是 begin(),表示反向遍历的终止位置(不包含 begin() 指向的元素)。

所以,如果你拿到了一个指向元素 x 的正向迭代器 pos,直接用它构造 reverse_iterator(pos),那么反向迭代器逻辑上指向的是 x 的前一个元素,而不是 x 本身!

示例

std::vector<int> v = {10, 20, 30, 40, 50};
auto it = std::find(v.begin(), v.end(), 30);   // it 指向 30
auto rit = std::make_reverse_iterator(it);     // rit 内部存储 it,但逻辑指向 20
std::cout << *rit << '\n';                     // 输出 20!

如果你想从 30 开始反向遍历(包含 30),就需要让反向迭代器的内部正向迭代器指向 30下一个位置,即 it + 1

auto rit_from_30 = std::make_reverse_iterator(it + 1);  // 逻辑上指向 30
// 现在遍历:
while (rit_from_30 != v.rend())
    std::cout << *rit_from_30++ << ' ';   // 输出 30 20 10

通用规则

  • 若正向迭代器 pos 表示某个元素的开始位置,你希望反向遍历包含该元素,则传给 reverse_iterator 的参数应当是 std::next(pos)(或 pos + 1 对于随机迭代器)。
  • pos 就是 end(),则直接传 end() 就能得到 rbegin(),自然包含最后一个元素。
  • posbegin(),则 reverse_iterator(begin()) 等于 rend(),表示反向遍历的终点(不包含第一个元素)。

5. 完整示例:正向查找后反向输出

#include <iostream>
#include <vector>
#include <algorithm>
#include <iterator>

int main() {
    std::vector<int> v = {5, 12, 8, 3, 7, 3, 21};

    // 正向查找第一个 3
    auto it = std::find(v.begin(), v.end(), 3);
    if (it == v.end()) return 1;

    // 我们希望从找到的 3(包含)开始反向输出直到 vector 开头
    // 因为反向迭代器内部需要指向元素的下一个位置,所以传入 it+1
    auto rit = std::make_reverse_iterator(it + 1);   // 逻辑指向第一个 3

    std::cout << "反向输出从第一个3开始: ";
    while (rit != v.rend()) {
        std::cout << *rit << ' ';
        ++rit;    // 注意 ++rit 是向“前”移动,即向容器头部方向
    }
    // 输出:3 8 12 5 (因为第一个3之前的元素是 8,12,5)

    return 0;
}

6. 反向迭代器转回正向迭代器

随时可以通过 base() 取回原始的正向迭代器:

auto reverse_it = v.rbegin();       // 类型为 std::reverse_iterator<...>
auto forward_it = reverse_it.base(); // 得到 end() 迭代器

注意 base() 会反向偏移一次,所以 rbegin().base() == end()


7. 总结

转换需求代码注意事项
正向迭代器 → 反向迭代器std::reverse_iterator<It>(it)std::make_reverse_iterator(it)it 必须是双向迭代器。反向迭代器逻辑上指向 *(it-1)
希望反向遍历包含 pos 指向的元素传入 std::next(pos)pos+1否则会漏掉该元素。
从容器的正向迭代器获取反向视图直接使用 v.rbegin(), v.rend()安全可靠,无需手动转换。
反向迭代器 → 正向迭代器rit.base()返回的内部迭代器是“下一个位置”。

反向迭代器是遍历有序容器的利器。理解其底层正向迭代器的偏移关系,是在算法中混合使用正反向迭代器的关键。

GNU GCC bits/stdc++.h

bits/stdc++.h 是一个在竞赛编程和某些快速开发场景中广为人知的非标准 C++ 头文件。它不是 C++ 标准的一部分,而是 GNU GCC 编译器提供的一个实现细节。下面从原理、内容、优缺点、移植性、编译时影响等角度,对其做一次全面剖析。


1. 它是什么?

bits/stdc++.h 实质上是一个预编译头文件的变体,包含了几乎所有 C++ 标准库头文件。GCC 将其放在编译器安装路径的 bits/ 子目录下,因此带上了 bits/ 前缀。

其典型实现是一个长长的 #include 列表,将 <iostream><vector><algorithm><string><map><set> 等全部包含进来,同时为了兼容性,可能还会根据编译器版本定义一些宏来避免重复错误。


2. 它包含什么?

以下取自 GCC 12 中的典型内容(简化示意):

#ifndef _GLIBCXX_BITS_STDCXX_H
#define _GLIBCXX_BITS_STDCXX_H 1

#pragma GCC system_header

#include <cassert>
#include <cctype>
#include <cerrno>
#include <cfloat>
#include <ciso646>
#include <climits>
#include <clocale>
#include <cmath>
#include <csetjmp>
#include <csignal>
#include <cstdarg>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <cwchar>
#include <cwctype>

// C++ 标准库头文件
#include <algorithm>
#include <any>
#include <array>
#include <atomic>
#include <barrier>
#include <bit>
#include <bitset>
#include <charconv>
#include <chrono>
#include <codecvt>
#include <compare>
#include <complex>
#include <concepts>
#include <condition_variable>
#include <coroutine>
#include <deque>
#include <exception>
#include <execution>
#include <expected>
#include <filesystem>
#include <format>
#include <forward_list>
#include <fstream>
#include <functional>
#include <future>
#include <initializer_list>
#include <iomanip>
#include <ios>
#include <iosfwd>
#include <iostream>
#include <istream>
#include <iterator>
#include <latch>
#include <limits>
#include <list>
#include <locale>
#include <map>
#include <memory>
#include <memory_resource>
#include <mutex>
#include <new>
#include <numbers>
#include <numeric>
#include <optional>
#include <ostream>
#include <queue>
#include <random>
#include <ranges>
#include <regex>
#include <scoped_allocator>
#include <semaphore>
#include <set>
#include <shared_mutex>
#include <source_location>
#include <span>
#include <sstream>
#include <stack>
#include <stdexcept>
#include <stop_token>
#include <streambuf>
#include <string>
#include <string_view>
#include <syncstream>
#include <system_error>
#include <thread>
#include <tuple>
#include <type_traits>
#include <typeindex>
#include <typeinfo>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <valarray>
#include <variant>
#include <vector>
// ... 还可能包含扩展如 <ext/...>
#endif

它几乎包含了 C++ 标准库(包括 C 标准库包装)的所有标准头文件,以及部分编译器扩展。文件本身很大,包含后会把几万行代码引入编译单元。


3. 为什么它在竞赛编程中流行?

  1. 极速编码:不需要逐个记忆和 #include 所需头文件,一行代码即可使用整个标准库,不会出现“忘记包含某个头文件导致编译错误”的问题。
  2. 比赛环境兼容:大多数在线判题系统(如 Codeforces、AtCoder、HDU 等)使用 GCC 编译器,默认包含该头文件,选手无需担忧移植问题。
  3. 思维负担小:专注于算法实现而非库的导入细节。

4. 主要缺点与潜在风险

4.1 非标准,不可移植

bits/stdc++.h 是 GCC 的特有文件,只存在于 GCC 编译环境。在以下环境中编译会直接失败:

  • MSVC(Visual Studio)
  • Clang 如果不配合 GCC 的标准库(libstdc++),或者未特意提供该文件
  • Apple Clang + libc++ 环境(macOS 默认)
  • 其它非 GCC 工具链

一旦你需要将代码迁移到不同编译器或不同平台,就需要逐一替换为标准头文件。

4.2 显著增加编译时间

包含 bits/stdc++.h 会一次性展开大量头文件(超过几十万行代码),每次编译都要处理所有这些符号,导致编译极慢。即使你只使用了 std::vector,也会把所有标准库的声明都拉进来,严重影响增量编译和构建速度。

4.3 命名污染

该头文件将整个 std 命名空间(甚至某些内部名字)暴露在全局作用域,可能导致意料之外的命名冲突。虽然标准库名字多在 std 内,但宏(如 max/min)或 C 库遗留符号可能造成干扰。

4.4 不利于工程实践

  • 破坏了模块化原则,无法从包含的头文件明确看出代码的依赖。
  • 在一些严格代码规范(如 MISRA、公司准则)中会被禁止。
  • 无法利用 C++20 模块带来的隔离优势(该头文件本身依然基于古老的文本包含模型)。

4.5 版本依赖与内部实现可能变更

既然不是标准,GCC 随时可能修改或移除该文件(尽管概率极低)。事实上,GCC 开发人员曾多次表示它只是一个内部快捷方式,不保证稳定性。


5. 与标准预编译头、模块的对比

特性bits/stdc++.h标准预编译头 (PCH)C++20 模块
是否标准编译器扩展支持是 (C++20)
编译速度极慢(全量包含)可快(预编译缓存)快(符号隔离)
可移植性仅 GCC依赖编译器理论上跨编译器
显式依赖无(由构建系统管理)显式 import
污染风险

在真正的软件开发中,若想提升编译速度,应使用预编译头(包含常用的标准库头文件,如 <vector>, <string> 等)或 C++20 模块,而不是依赖这个非标准的大杂烩。


6. 能否在 Windows 上使用?

如果你在 Windows 上使用 MinGW-w64Cygwin(它们都基于 GCC + libstdc++),那么 bits/stdc++.h 是可用的。但在 Visual Studio(MSVC)中无法直接使用。有些使用者会手动创建一个同名文件,放入搜索路径,内容仿照 GCC 版本,但这是极不推荐的做法,因为 MSVC 的标准库头文件结构不同,强行打包会导致各种宏重定义和编译错误。


7. 最佳实践建议

  • 竞赛编程:如果确定判题环境为 GCC 且时间宽松,可以使用。但注意某些比赛已经开始限制其使用(如 Google Code Jam 在某些规则中提醒不要依赖)。
  • 日常练习 / 算法学习:也可以使用它快速测试想法,但建议在养成习惯后逐步转换为显式包含必要的标准头文件。
  • 工程与生产代码严禁使用。应该遵循 include what you use 原则,每个源文件只包含实际用到的标准库头文件。
  • 替代方案:可以自己维护一个项目级的 common.hpp 或预编译头,包含常用头文件(<bits/stdc++.h> 的极度精简版),但务必明确此头文件是项目的一部分,不涉及系统私有路径。

8. 示例与总结

竞赛常见写法:

#include <bits/stdc++.h>
using namespace std;
int main() {
    vector<int> a = {3, 1, 4, 1, 5};
    sort(a.begin(), a.end());
    // ...
}

推荐的标准写法:

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> a = {3, 1, 4, 1, 5};
    std::sort(a.begin(), a.end());
    // ...
}

结论bits/stdc++.h 是 GCC 赠送的一把双刃剑,它带来了便利的编码体验,但以牺牲可移植性、编译速度和工程清晰度为代价。理解它的本质,在合适的场合谨慎使用,并清楚它的局限,才是明智的态度。

Compiler

常用的 C/C++ 编译环境涵盖编译器、集成开发环境(IDE)、轻量级编辑器配合构建工具,以及在线平台。不同的操作系统和项目需求会对应不同的选择,以下按类别逐一介绍。


1. 主流编译器工具链

这是编译环境的核心,将源代码变成可执行程序。三大主力:

  • GCC (GNU Compiler Collection)
    Linux 上的标准 C/C++ 编译器(命令 gcc / g++)。在 Windows 上可通过 MinGW-w64(提供原生 Windows 二进制)或 Cygwin(模拟 POSIX 环境)使用。特点是成熟、标准支持及时,几乎所有开源项目默认兼容。

  • Clang / LLVM
    macOS 的默认编译器(命令 clang / clang++)。前端 Clang 提供极清晰的错误和警告信息,编译速度快;后端 LLVM 擅长优化。它跨平台,Windows 上也能配合 MSVC 或 MinGW 的库使用。许多现代化项目(如 Chromium、Firefox)都用它编译。

  • MSVC (Microsoft Visual C++)
    Windows 原生编译器,集成在 Visual Studio 或独立的 Build Tools 中。命令为 cl.exe。对 Windows API 支持最佳,是开发 Windows 桌面应用、游戏(DirectX)的首选。

此外,还有 Intel oneAPI DPC++/C++ Compiler(前身是 ICC,现基于 LLVM,在 Intel 硬件上优化强)等,但日常使用较少。


2. 集成开发环境(IDE)

提供代码编辑、调试、编译、项目管理的一体化图形界面。

  • Visual Studio (Windows)
    被誉为 Windows 上最强大的 C++ IDE。社区版免费,功能包含高级调试器、性能分析器、图形化界面设计器。编译器为 MSVC,但也支持安装 Clang/LLVM 工具集。适合大型项目、Windows 平台开发。

  • CLion (跨平台)
    JetBrains 出品,智能代码补全、重构、一站式调试。它强制使用 CMake 构建系统。商业软件,有面向学生和开源项目的免费许可。适合跨平台 C++ 项目。

  • Qt Creator (跨平台)
    主要围绕 Qt 框架,但也可以纯粹作为 C++ IDE 使用。内建对 CMake、qmake 的支持,自带 UI 设计器。免费开源,是开发图形界面程序的不错选择。

  • Eclipse CDT (跨平台)
    老牌 IDE,可通过插件管理。略显沉重,但在 Java 生态中仍有用户,对 C++ 支持尚可,能集成多种编译器。

  • Code::Blocks (跨平台)
    轻量级免费 IDE,安装包即可直接使用(自带 MinGW)。配置简单,适合初学者和中小型项目。

  • Dev-C++ (Windows)
    经典轻量 IDE,最初由 Bloodshed 开发,现由 Orwell Dev-C++ 接手维护,集成了 MinGW。适合学习算法和入门教学,不适合大规模工程。

  • Xcode (macOS)
    苹果官方开发环境,用于开发 macOS/iOS 应用。编译器为 Clang,界面与苹果生态深度绑定。仅限 Mac 平台。


3. 轻量级编辑器 + 命令行工具

很多开发者偏好代码编辑器加自行配置编译调试环境。

  • Visual Studio Code (跨平台)
    安装 C/C++clangd 扩展后,可提供智能提示、调试支持。配合 CMake Tools 插件,可直接调用 CMake 构建项目。底层编译器可任意选择(MSVC、GCC、Clang)。

  • Vim / NeovimEmacs
    高级用户喜爱,通过插件(如 YouCompleteMe、coc.nvim)可实现 IDE 级功能。编译时在终端直接调用 g++makecmake

  • 其他
    Sublime Text、Atom 等也可配置 C++ 编译环境,但现在较少作为首选。


4. 在线编译环境

无需本地安装,浏览器中写代码并运行,适合快速测试、教学分享、代码片段调试。

  • Compiler Explorer (godbolt.org)
    神器,可同时使用多个编译器(不同版本)并即时查看生成的汇编代码,是理解编译优化的利器。

  • Replit / JDoodle / OnlineGDB / Wandbox
    支持多种语言,提供简单编辑界面,可直接运行。其中 Wandbox 支持众多编译器版本和 Boost 库,非常实用。

  • GitHub Codespaces / GitPod
    云端开发环境,提供完整的远程 Linux 容器,可自由配置任何编译工具链,相当于远程的 VS Code。


5. 构建系统与包管理器(编译环境的重要补充)

现代 C++ 开发离不开构建系统和依赖管理。

  • CMake:事实上的跨平台标准,大多数 IDE 和编辑器直接支持。
  • Make / Ninja:更底层的构建执行器,常由 CMake 生成。
  • MSBuild:Visual Studio 的构建引擎。
  • vcpkg / Conan:C++ 包管理器,帮你轻松安装和管理第三方库(如 Boost、OpenSSL),与 CMake、MSBuild 等良好集成。

总结与选择建议

用户场景推荐编译环境
Windows 初学者 / 学生Visual Studio CommunityCode::Blocks + MinGW
Linux 开发者系统自带 GCC + VS Code / Vim + CMake
macOS 开发者Xcode(若开发苹果应用),或 CLion / VS Code + Clang
跨平台项目CLion + CMake,或 VS Code + 相应编译器
追求极致性能或底层优化Compiler Explorer 搭配 GCC/Clang/MSVC 比较汇编
快速线上测试WandboxGodbolt

“编译环境”并不只是编译器,而是编译器 + 编辑器/IDE + 构建系统 + 调试器的集合。根据你的平台和项目复杂度,选择顺手且生态良好的组合即可。

Clang

split

C++ 标准库不像 Python 或 Java 那样提供一个内建的 split 函数,但由于字符串处理的灵活性,有多种方式可以实现字符串分割,并且现代 C++ 标准(C++17/20)提供了越来越便捷和高效的工具。下面从传统的标准库方法到 C++20 的现代方案全面介绍。


1. 使用 std::string::find + substr(最经典的手工实现)

这种写法适用于所有 C++ 版本,可以精确控制分隔逻辑。

#include <string>
#include <vector>
#include <iostream>

std::vector<std::string> split(const std::string& s, char delimiter) {
    std::vector<std::string> tokens;
    size_t start = 0;
    size_t pos = s.find(delimiter);
    while (pos != std::string::npos) {
        tokens.push_back(s.substr(start, pos - start));
        start = pos + 1;
        pos = s.find(delimiter, start);
    }
    tokens.push_back(s.substr(start)); // 最后一段
    return tokens;
}

// 使用示例
// std::string s = "one,two,three";
// auto v = split(s, ',');
// v -> {"one", "two", "three"}

特点

  • 逻辑清晰,完全可控。
  • 性能较好(仅遍历一次字符串),且不依赖高级特性。
  • 可轻松改造为支持多字符分隔符空白分隔

2. 使用 std::istringstream + std::getline

将字符串包装成流,利用 getline 按分隔符读取,语法简洁。

#include <sstream>
#include <string>
#include <vector>

std::vector<std::string> split(const std::string& s, char delimiter) {
    std::vector<std::string> tokens;
    std::istringstream iss(s);
    std::string token;
    while (std::getline(iss, token, delimiter)) {
        tokens.push_back(token);
    }
    return tokens;
}

注意:这种方法对于末尾带分隔符会丢失空字符串,例如 "a,b," 默认只得到 {"a", "b"},若需保留空段,需要额外处理。


3. 使用 std::regex_token_iterator(支持正则分隔符)

C++11 引入的正则库提供了强大的分割功能,尤其适用于分隔符较复杂(如多个空白、标点组合)的场景。

#include <regex>
#include <string>
#include <vector>

std::vector<std::string> split(const std::string& s, const std::regex& sep = std::regex{"\\s+"}) {
    std::vector<std::string> tokens(
        std::sregex_token_iterator(s.begin(), s.end(), sep, -1),
        std::sregex_token_iterator()
    );
    return tokens;
}

// 使用:auto v = split("hello  world \t foo");  -> {"hello","world","foo"}
  • 第四个参数 -1 表示提取分隔符之间的子序列。
  • 可以传入正则表达式,如 std::regex("[,\\s]+") 表示逗号或空白。
  • 性能相比前两种方法略低,但优势在于表达能力。

4. C++17:利用 std::string_view 避免拷贝

std::string_view 可以持有字符串的“视图”,分割后的结果可以不拷贝原始字符串,大幅提高效率。

#include <string_view>
#include <vector>
#include <iostream>

std::vector<std::string_view> split(std::string_view sv, char delimiter) {
    std::vector<std::string_view> tokens;
    size_t pos = 0;
    while (pos < sv.size()) {
        size_t next = sv.find(delimiter, pos);
        if (next == std::string_view::npos) {
            tokens.push_back(sv.substr(pos));
            break;
        }
        tokens.push_back(sv.substr(pos, next - pos));
        pos = next + 1;
    }
    return tokens;
}

注意:返回的 string_view 集合仅在原始字符串存在期间有效,不能超出原字符串的生命周期。


5. C++20:std::ranges::split_view / views::split

C++20 引入了 ranges 库,可以直接对字符串进行惰性分割,不生成中间容器。

#include <iostream>
#include <ranges>
#include <string_view>

int main() {
    std::string_view sv = "one,two,three";
    auto delimiter = ',';

    for (auto word : sv | std::views::split(delimiter)) {
        // word 是一个 ranges::subrange,可以用 std::string_view 封装
        std::string_view part(word.begin(), word.end());
        std::cout << part << '\n';
    }
}
  • 完全惰性:不创建临时 vector。
  • 返回的是 subrange,可以手动构造 std::string_viewstd::string
  • 分隔符可以是单个字符、字符数组或另一个 range。

转为传统容器(若需要随机访问):

auto splitted = sv | std::views::split(',') 
                   | std::views::transform([](auto r) { 
                        return std::string_view(r.begin(), r.end()); 
                     });
std::vector<std::string_view> v(splitted.begin(), splitted.end());

6. 使用 Boost 库:boost::split

如果项目已经使用了 Boost,boost::algorithm::split 是最直接的解决方案。

#include <boost/algorithm/string.hpp>
#include <string>
#include <vector>

std::string s = "one,two,three";
std::vector<std::string> tokens;
boost::split(tokens, s, boost::is_any_of(","));
// tokens: {"one", "two", "three"}
  • 支持灵活的分隔谓词(is_any_of, is_space 等)。
  • 可以用 boost::token_compress_on 选项忽略连续分隔符。

7. C 风格:std::strtok

不推荐在现代 C++ 中使用,因为它修改原始字符串(需要可写缓冲区),且非线程安全。

#include <cstring>
#include <vector>
#include <string>

char s[] = "one,two,three";  // 必须可修改
std::vector<std::string> tokens;
for (char* p = strtok(s, ","); p; p = strtok(nullptr, ",")) {
    tokens.push_back(p);
}

8. 各方法对比

方法标准版本是否拷贝支持正则分隔性能易用性
find + substrC++98需自己实现
stringstream + getlineC++98
regex_token_iteratorC++11较低
string_view + findC++17视图极高
ranges::split_viewC++20视图/惰性
boost::split第三方灵活谓词极高
strtokC 标准原地修改低(危险)

9. 推荐策略

  • C++17 前,并且不需要正则:使用 find + substr 手写,或 getline 简写。
  • C++17 及以上,追求零拷贝:使用 std::string_view 自定义分割。
  • C++20,代码简洁性与惰性求值:直接使用 std::views::split
  • 分隔符复杂(如正则):使用 std::sregex_token_iterator
  • 已在项目中引入 Boostboost::split 是最佳选择。

对于大多数日常需求,简单封装一个 find + substrstring_view 版本就足够高效,且不依赖外部库。如果你需要处理空串保留、多个连续分隔符等细节,可以在循环中稍加判断,非常灵活。

isdigit & isalpha

C 和 C++ 提供了 isdigitisalpha 这两个字符分类函数,它们定义在 <ctype.h>(C)或 <cctype>(C++)中。尽管看似简单,但正确使用它们涉及参数类型、编码安全性、本地化等一系列细节,下面全面解析。


1. 函数原型

// C 语言 <ctype.h>
int isdigit(int ch);
int isalpha(int ch);
// C++ <cctype>
int isdigit(int ch);
int isalpha(int ch);

参数 ch 的类型是 int不是 char。这是历史原因形成的,因为它们需要能够容纳 unsigned char 的所有取值以及特殊值 EOF(通常为 -1)。


2. 函数功能与返回值

2.1 isdigit

  • 功能:检查参数 ch 是否是一个十进制数字字符(即 '0''9')。
  • 返回值:如果 ch 是数字字符,返回一个非零值(通常是 1,但不保证);否则返回 0。

在默认的 "C" 语言环境下,只有字符 '0'(ASCII 48)到 '9'(ASCII 57)会使 isdigit 返回真。其他任何值,包括字母、标点、EOF 等,都返回 0。

2.2 isalpha

  • 功能:检查参数 ch 是否是一个字母字符
  • 返回值:若为字母,返回非零值;否则返回 0。

"C" 语言环境下,字母包括大写字母 'A''Z'(ASCII 6590)和小写字母 'a''z'(ASCII 97122)。其他语言环境可能将带变音符号的字符(如 'é''ü')也视为字母,具体依赖 locale 设置。


3. 参数的正确传递方式(重要!)

isdigitisalpha 的形参类型是 int,但能接受的合法值是范围在 unsigned char 内的整数值EOF
如果你直接传入一个 char 类型的变量,而 char 在平台上是带符号的(如 x86 GCC),那么负数(如 0x80 以上的字节)会被符号扩展为负 int,这时函数的行为是未定义的

安全的调用方式

char c = '0';
if (isdigit(static_cast<unsigned char>(c))) {
    // ...
}

if (isdigit(static_cast<unsigned char>(c))) ...

如果从文件中读取字符,使用 fgetc 之类的函数返回 int,它会返回 unsigned char 范围的数值或 EOF,这时可直接传递给这些函数:

int ch = fgetc(fp);
if (isdigit(ch)) { ... }   // 安全,因为 ch 已经是 unsigned char 或 EOF

错误的例子(可能导致未定义行为):

char c = 'é';  // 在 UTF-8 编码下可能为负值
if (isdigit(c)) { ... }  // 未定义行为!

4. C++ 中的两个版本:C 库函数与 std::locale 版本

除了继承自 C 的 <cctype> 全局函数外,C++ 还提供了面向对象的本地化版本:

#include <locale>

bool std::isdigit(char ch, const std::locale& loc);
bool std::isalpha(char ch, const std::locale& loc);
  • 参数是 char,不再是 int
  • 返回 bool,更直观。
  • 可以传入不同的 locale 对象,例如支持中文、德语等本地化判断。

使用示例:

#include <locale>
#include <iostream>

int main() {
    std::locale loc("de_DE.UTF-8");
    char c = 'ä';
    if (std::isalpha(c, loc)) {
        std::cout << c << " 是字母\n";  // 在德语 locale 下输出
    }
}

5. 相互关系与组合

  • isdigit(c) 只认数字,isalpha(c) 只认字母。
  • isalnum(c) 等价于 isalpha(c) || isdigit(c)
  • isxdigit(c) 检查十六进制数字(0-9, A-F, a-f),包含了 isdigit 的范围,另加大写 A-F 和小写 a-f。

常见字符分类函数一览(均需相同安全转换):

函数检查内容(C locale)
isdigit十进制数字 '0'-'9'
isalpha大写字母和小写字母
isalnum字母或数字(isalpha || isdigit
isxdigit十六进制数字
isspace空白字符(空格、换行、制表等)
isupper大写字母
islower小写字母
ispunct标点符号
isprint可打印字符(含空格)
isgraph可打印字符(不含空格)
iscntrl控制字符

6. 实际应用示例

6.1 验证字符串是否为纯数字

#include <cctype>
#include <string>
#include <algorithm>

bool is_all_digits(const std::string& str) {
    return !str.empty() && std::all_of(str.begin(), str.end(),
        [](unsigned char c) { return std::isdigit(c); });
}

6.2 统计字符串中字母和数字的个数

#include <iostream>
#include <string>
#include <cctype>

int main() {
    std::string s = "Hello 2024!";
    int letters = 0, digits = 0;
    for (char c : s) {
        if (std::isalpha(static_cast<unsigned char>(c))) ++letters;
        else if (std::isdigit(static_cast<unsigned char>(c))) ++digits;
    }
    std::cout << "字母: " << letters << ", 数字: " << digits << '\n';
    // 输出: 字母: 5, 数字: 4
}

6.3 从流中安全读取并判断

#include <cstdio>
#include <cctype>

int main() {
    int ch;
    while ((ch = std::getchar()) != EOF) {
        if (std::isdigit(ch))
            std::putchar('D');
        else if (std::isalpha(ch))
            std::putchar('A');
        else
            std::putchar(ch);
    }
}

6.4 使用 C++ locale 实现本地化

#include <iostream>
#include <locale>

int main() {
    std::locale loc(""); // 用户默认 locale
    std::string test = "Fiancée 99";
    for (char c : test) {
        if (std::isalpha(c, loc))
            std::cout << c << " is alpha\n";
        else if (std::isdigit(c, loc))
            std::cout << c << " is digit\n";
    }
}

7. 常见陷阱与注意事项

  • 不要对 char 直接调用:始终通过 unsigned char 转换或确保值是 0~127 范围内(纯 ASCII)。
  • 返回值不是 bool:C 版函数返回非零/零,不要写 if (isdigit(c) == true),直接使用 if (isdigit(c))if (isdigit(c) != 0)
  • EOF 的处理:若传入 EOF,函数返回 0,表示既不是数字也不是字母,这合乎预期。
  • 多字节编码isdigit/isalpha 只能处理单字节字符。在 UTF-8 等多字节编码下,一个字符可能由多个字节组成,单独检查每个字节没有意义。此时应考虑使用 C++ locale 或第三方库(如 ICU)。
  • 宽字符版本:C 标准库还有 iswdigitiswalpha,用于 wint_t 类型的宽字符,头文件 <wctype.h> / <cwctype>

8. 总结

isdigitisalpha 是字符分类的基础设施,功能单一但须小心使用:

  • 参数类型 int,只接受 unsigned charEOF
  • 返回值非零代表真,零代表假;
  • 在 C++ 中可通过 std::locale 获得更丰富且安全的本地化支持;
  • 性能极佳,通常以查表实现。

掌握其用法和边界条件,可以避免许多潜在的移植性 bug 和未定义行为。

s.find

std::string::find 是 C++ 中 std::string 类的核心成员函数,用于在字符串内正向搜索子串或字符。其功能强大、重载丰富,几乎涵盖了所有常见的查找需求。下面从函数签名、参数、返回值、使用示例、性能特征到高级技巧进行全面讲解。


1. 基本概念

find 在字符串中从前向后查找第一次出现的目标(子串、字符或字符数组),返回一个 std::string::size_type 类型的索引(通常是 size_t)。如果未找到,则返回一个特殊常量 std::string::npos

函数定义(以 C++17 为例):

// 1. 查找子串
size_type find(const basic_string& str, size_type pos = 0) const noexcept;
// 2. 查找 C 风格字符串
size_type find(const char* s, size_type pos, size_type count) const;
// 3. 查找 C 风格字符串(完整长度)
size_type find(const char* s, size_type pos = 0) const;
// 4. 查找单个字符
size_type find(char ch, size_type pos = 0) const;
// 5. 查找 string_view(C++17)
size_type find(std::string_view sv, size_type pos = 0) const noexcept;

注意:basic_stringstring 的模板基类(std::string 就是 std::basic_string<char>)。上面签名适用于 std::string


2. 参数详解

重载参数含义
find(str, pos)str:要搜索的 string 子串;pos:开始搜索的位置(默认为 0)pos 处开始查找与 str 完全匹配的子串
find(s, pos, count)s:指向字符数组的指针;count:要比较的字符个数;pos:起始位置s 的前 count 个字符构造一个临时“子串”,然后查找
find(s, pos)s:以空字符结尾的 C 字符串;pos:起始位置等价于 find(s, pos, Traits::length(s))
find(ch, pos)ch:单个字符;pos:起始位置查找第一次出现字符 ch 的位置
find(sv, pos) (C++17)svstd::string_viewpos:起始位置无需构造临时 string,避免拷贝

pos 参数:表示搜索的起始索引,必须在 [0, size()] 范围内。若 pos == size(),则搜索范围为 [size(), size())(空范围),此时 find 永远返回 npos。若 pos > size(),C++17 前为未定义行为,C++17 起改为始终返回 npos(更安全)。


3. 返回值

  • 成功:返回第一个匹配子串/字符的起始索引(size_type)。
  • 失败:返回 std::string::nposnpos 是一个静态常量,定义为 -1(转换为 size_type 即最大可能值)。

检查是否找到的惯用写法:

if (s.find("abc") != std::string::npos) {
    // 找到了
}

因为 npos 是一个很大的无符号数,不能直接与 0 或 true/false 比较。


4. 基本使用示例

#include <string>
#include <iostream>

int main() {
    std::string str = "Hello, world! Hello, universe!";

    // 1. 查找子串
    auto pos1 = str.find("Hello");        // 返回 0
    auto pos2 = str.find("Hello", 1);     // 从索引 1 开始找,返回 14

    // 2. 查找单个字符
    auto pos3 = str.find('w');            // 返回 7
    auto pos4 = str.find('z');            // 返回 npos

    // 3. 查找 C 字符串部分长度
    auto pos5 = str.find("world!", 0, 3); // 只比较 "wor",返回 7

    // 4. C++17 string_view 避免拷贝
    std::string_view sv = "universe";
    auto pos6 = str.find(sv);             // 返回 21

    // 检查
    if (pos4 == std::string::npos)
        std::cout << "字符 'z' 未找到\n";
}

5. 搜索方向与边界

find正向搜索(从左到右)。若需要反向搜索,应使用 rfind

pos 不为 0 时,搜索区间为 [pos, size())。注意:搜索时考虑的子串可以跨越 pos 之后的所有字符,但 pos 是起始检查点。例如:

std::string s = "abcabc";
s.find("abc", 1);   // 返回 3,不是 0,因为从索引1开始检查,跳过第一个abc

6. 查找空字符串的特殊行为

无论什么版本,查找空字符串时行为一致:空字符串被认为在任何位置都能找到,包括 pos == size() 时。

  • s.find("", pos) 返回 pos(如果 pos <= size());否则(pos > size())C++17 起返回 npos
  • s.find(std::string_view{}, pos) 同样返回 pos
  • s.find('a', pos) 中的 'a' 不是空,自然不受此规则影响。

示例:

std::string s = "test";
auto p1 = s.find("");        // 返回 0
auto p2 = s.find("", 2);     // 返回 2
auto p3 = s.find("", 4);     // 返回 4
auto p4 = s.find("", 5);     // C++17前UB,C++17后返回npos

7. 性能与时间复杂度

  • 最坏情况:无优化时是 (O(N \cdot M)),其中 N = size()M 为子串长度。很多标准库实现使用了优化的搜索算法(如双向搜索、Boyer-Moore 的简化版、SSE 加速等),但对一般短子串仍是朴素匹配。
  • C++17 引入的 std::boyer_moore_searcher:可以配合 std::search 使用更高性能的搜索,但不直接作用于 find。这意味着 find 在极致性能要求下可能不是最优选择。
  • 常见实践find 的单字符版本通常非常快(直接遍历,可利用 SIMD 指令),子串版本对于中等长度也能胜任,通常无须过早优化。

8. 相关函数族

find 同一系列的还有其他成员函数,它们共享相似的接口:

成员函数搜索方向说明
find正向查找第一次出现
rfind反向查找最后一次出现(参数类似)
find_first_of正向查找参数中任意字符第一次出现的位置
find_last_of反向查找参数中任意字符最后一次出现的位置
find_first_not_of正向查找第一个不在参数中的字符
find_last_not_of反向查找最后一个不在参数中的字符

这些函数都支持 pos 参数(对 rfind 系列,pos 通常表示搜索的右边界,从该位置向左搜索)。


9. 与其他查找方式对比

  • std::search:泛型算法,可用于任意容器,但通常比 string::find 慢(因为无法利用字符串的连续内存特性)。
  • std::string_view::find:C++17 起 string_view 也有完全相同的 find 成员函数,可用来在不拷贝的情况下搜索另一个视图。
  • std::regex_search:支持正则表达式,功能强大但性能开销大,适合复杂模式匹配。
  • 手动循环 + 下标:容易出错,不推荐,除非有特殊需求。

10. C++11/14/17 的变化小结

C++版本变化
C++98最初的基本重载
C++11增加右值引用、noexcept;部分成员函数转为 noexcept
C++14无明显更改
C++171. 增加 string_view 重载,避免临时 string 创建;2. pos > size() 行为从 UB 改为返回 npos;3. 部分重载添加 noexcept

11. 常见陷阱与最佳实践

  1. 返回值测试:始终与 npos 比较,不要写成 if (s.find("a"))(如果 "a" 在索引 0 找到,返回 0,条件为假)。
  2. pos 参数越界:C++17 之前的代码应保证 pos <= size(),否则导致未定义行为。升级到 C++17 或手动检查边界。
  3. 空字符串陷阱:注意 find("") 永远成功,返回 pos。有时用于循环中可能造成无限循环。
  4. 效率与临时变量:如果频繁查找同一个字符串字面值,可以考虑将其存为 string_viewconst char*,减少构造开销。C++17 引入 string_view 重载正是为此。
  5. rfind 混淆find 是正向,rfind 是反向。明确需求。
  6. 多字节/UTF-8find 按字节搜索,不能直接识别 Unicode 字符边界。处理 Unicode 时,需先将字符串转换为宽字符或其他编码,或使用专门的库。
  7. 大小写敏感find 是大小写敏感的。若要大小写不敏感,需要先转换字符串(如使用 std::tolower)或自定义搜索。

12. 完整实例:解析键值对

#include <iostream>
#include <string>
#include <vector>

std::vector<std::pair<std::string, std::string>> parse(const std::string& data) {
    std::vector<std::pair<std::string, std::string>> result;
    size_t pos = 0;
    while (pos < data.size()) {
        auto end = data.find(';', pos);
        if (end == std::string::npos) end = data.size();
        std::string token = data.substr(pos, end - pos);
        auto eq = token.find('=');
        if (eq != std::string::npos) {
            std::string key = token.substr(0, eq);
            std::string value = token.substr(eq + 1);
            result.emplace_back(key, value);
        }
        pos = end + 1;
    }
    return result;
}

int main() {
    std::string input = "name=John;age=30;city=New York";
    for (const auto& [k, v] : parse(input))
        std::cout << k << " -> " << v << '\n';
}

13. 总结

std::string::find 是处理字符串查找的基本工具,功能直观且高效。正确理解它的多重重载、pos 控制、npos 返回值和空字符串行为,是写出健壮且高效字符串处理代码的关键。在现代 C++ 中,结合 string_view 和适当算法,可以让查找逻辑既清晰又零开销。

s.substr

std::string::substr 是 C++ 标准库中 std::string 最重要的成员函数之一,用于从原字符串中提取一个子串,并返回一个新的 std::string 对象。它不修改原字符串,而是生成一个独立的副本。下面从语法、语义、版本演进、性能、最佳实践等角度进行详细介绍。


1. 函数原型

从 C++98 到 C++20,substr 的重载集不断丰富,当前标准(C++20)共有两个重载:

// 1. 基本形式:指定起始位置和长度
string substr(size_type pos = 0, size_type count = npos) const;

// 2. C++20 新增:右值引用限定的重载(高效移动版本)
string substr(size_type pos = 0, size_type count = npos) &&;
  • 第一个重载处理左值(如一个命名的 string 变量),返回新副本。
  • 第二个重载处理右值(临时 string 对象),可以直接复用源对象的内存,避免分配和拷贝,效率极高。

2. 参数详解

参数类型含义
possize_type (通常 size_t)子串起始索引(从 0 开始)。默认值为 0,表示从字符串开头截取。
countsize_type要包含的字符个数。默认值为 npos,这是一个非常大的值(静态常量 -1 转换的无符号数),表示从 pos 开始直到字符串末尾的所有字符。

3. 返回值

返回一个新构造的 std::string 对象,其内容为原字符串从 pos 开始、长度为 min(count, size() - pos) 的子串。
如果 pos == size(),返回空字符串("")。
如果 pos > size(),则抛出异常(见下)。


4. 异常(何时抛出 std::out_of_range

pos 大于 原字符串的 size() 时,substr 会抛出 std::out_of_range 异常。
这是唯一的异常情况。count 无论多大都不会抛出异常,它会被安全裁剪。

std::string s = "hello";
auto sub = s.substr(10, 3);   // 抛出 std::out_of_range (10 > 5)

因此,安全调用通常需要提前检查 pos <= s.size()


5. 长度裁剪规则(重要)

实际返回的子串长度 = min(count, size() - pos)

  • count 大于从 pos 到末尾的长度,子串只到末尾。
  • count == npos,子串到末尾。
  • pos + count 超过 size(),安全裁剪。

示例:

std::string s = "abcde";
auto s1 = s.substr(2, 10);    // 从索引2开始,期望10个字符,实际只能取3个:"cde"
auto s2 = s.substr(2);        // 默认 count == npos:"cde"
auto s3 = s.substr(5);        // pos == size(),空字符串 ""

6. 底层行为与内存分配

  • substr 通常会分配新内存并拷贝字符,时间复杂度为 O(count)。
  • 返回的 string 和原字符串完全独立,修改子串不影响原串。
  • 对于小字符串(短于 SSO 阈值),可能会在栈上分配(Small String Optimization),不涉及堆操作。

7. C++20 移动优化

C++20 引入的右值引用重载 substr() && 允许从临时对象中“窃取”数据。
若原字符串是右值且子串恰好是从头部开始的整个字符串,可能会避免拷贝。标准规定该重载可能通过移动赋值或直接复用存储来实现,但实现细节由库决定。典型行为:如果 pos == 0count >= size(),则移动原字符串;否则可能需要部分拷贝,但可以复用已分配的内存。

示例:

std::string s = "Hello World";
auto sub = std::move(s).substr(0, 5);  // 调用 substr() &&,可能高效移动
// s 处于合法但未指定状态,不应再使用

对于日常编程,这个优化大多由编译器自动启用(如临时返回的 string)。


8. 基本使用示例

#include <string>
#include <iostream>

int main() {
    std::string s = "Hello, World!";
    
    // 提取 "Hello"
    auto sub1 = s.substr(0, 5);   // pos=0, count=5
    
    // 提取 "World!"
    auto sub2 = s.substr(7);      // pos=7, count默认npos,到末尾
    
    // 空子串
    auto empty_sub = s.substr(s.size()); // OK,返回""
    
    std::cout << sub1 << '\n' << sub2 << '\n';
    // 输出:
    // Hello
    // World!
}

9. 与其他取子串方式的比较

方法返回类型是否拷贝性能用途
string::substrstringO(count)需要独立拥有子串
string_view::substr (C++17)string_viewO(1)只读访问,不拥有数据
string::copy拷贝到外部缓冲区O(count)向 C 风格数组拷贝
assign / insert 等组合修改现有字符串可能根据需求就地修改
手动迭代器构造 stringstringO(count)从迭代器范围构造

如果不需要修改子串,且原字符串生命周期可控,使用 std::string_view::substr 可以零开销提取子串视图。


10. 常见陷阱与最佳实践

  1. pos 越界:调用前确保 pos <= s.size(),或使用 try/catch,或先比较。
  2. 返回空字符串不抛异常pos == size() 是合法的,返回空串。这与很多其他语言不同。
  3. count 不是硬限制:它不是要精确截取的长度,如果超过实际可用长度,只取到末尾。不要依赖 count 来检查长度。
  4. 临时字符串的优化:如果是临时对象,C++20 的右值重载会自动优化。C++17 及之前可用 std::move + substr 然后在下一行丢弃原变量,但优化不一定有。
  5. 避免无谓的拷贝:当只需要“观察”一部分内容时,考虑 std::string_view(C++17),或直接使用 const char* + 长度(需保证原串存活)。
  6. 异常安全substr 的异常只会因 pos 越界抛出,如果已经检查,可以标记 noexcept 或忽略。
  7. 不要与 replace 的区间混淆substr(pos, count) 是提取,replace(pos, count, ...) 是替换,参数含义一致,便于记忆。

11. 扩展使用示例:解析文件扩展名

std::string get_extension(const std::string& filename) {
    auto dot_pos = filename.rfind('.');
    if (dot_pos == std::string::npos)
        return "";
    return filename.substr(dot_pos + 1); // 从点之后到末尾
}

12. 总结

std::string::substr 是一个非常直观且安全的成员函数,遵循起始位置 + 长度的设计。它通过返回新字符串提供了语义清晰的子串提取,代价是需要一次内存拷贝。在现代 C++ 中,如果不需要拷贝,可以搭配 string_view 以获得零开销视图。了解其边界行为和 C++20 的移动优化,可以帮助你写出更高效、更健壮的代码。

Licensed under CC BY-NC-SA 4.0
Last updated on Jun 09, 2026 11:16 CST
comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy