C++ STL之数值算法与Lambda详解:从使用到底层,再到面试八股

C++ STL之数值算法与Lambda详解:从使用到底层,再到面试八股

本文面向面试和日常开发,先讲调用,再讲原理,最后给口语化面试答案。


一、用法速查

1.1 <numeric> 数值算法

accumulate —— 左折叠累加

std::accumulate 对区间做左折叠:((a+b)+c)+d。O(n),要求二元运算,不要求可结合性。

#include <numeric>
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v{1, 2, 3, 4, 5};

    int sum = accumulate(v.begin(), v.end(), 0);
    cout << sum << "\n";                    // 15

    int prod = accumulate(v.begin(), v.end(), 1, multiplies<int>());
    cout << prod << "\n";                   // 120

    vector<string> vs{"a", "b", "c"};
    string s = accumulate(vs.begin(), vs.end(), string(""),
        [](string &acc, string &cur) { return acc + "-" + cur; });
    cout << s << "\n";                      // -a-b-c
}

陷阱:int overflow。 accumulate(v.begin(), v.end(), 0) 的初值 0int 类型,推导出的累加类型是 int。哪怕 v 里放的是 long long,结果也会在溢出后截断:

vector<long long> v{10000000000LL, 20000000000LL};
auto sum = accumulate(v.begin(), v.end(), 0);      // int 溢出!结果错误
auto sum2 = accumulate(v.begin(), v.end(), 0LL);   // 正确:初值 long long

reduce —— 可并行归约(C++17)

std::reduce 要求运算是结合的可交换的(半群),因此可以按二叉树结构归约,支持并行策略。

#include <execution>
#include <numeric>
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<long long> v(1000000, 1);

    auto s1 = reduce(v.begin(), v.end(), 0LL);
    auto s2 = reduce(execution::par_unseq, v.begin(), v.end(), 0LL);

    cout << s1 << " " << s2 << "\n";        // 1000000 1000000
}

不满足结合律的运算不能用 reduce: 浮点数加法不结合((a+b)+c != a+(b+c) 在小数和大数相加时),所以 par_unseq reduce 对浮点数的结果是非确定性的


inner_product —— 内积

两个区间对应元素相乘后累加,O(n)。

#include <numeric>
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> a{1, 2, 3};
    vector<int> b{4, 5, 6};

    int dot = inner_product(a.begin(), a.end(), b.begin(), 0);
    cout << dot << "\n";                    // 32

    int same = inner_product(a.begin(), a.end(), b.begin(), 0,
        plus<int>(), equal_to<int>());
    cout << same << "\n";                   // 0
}

adjacent_difference —— 相邻差

a[1]-a[0], a[2]-a[1], ...,第一个元素原样输出。

#include <numeric>
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v{1, 3, 6, 10, 15};
    vector<int> diff(v.size());

    adjacent_difference(v.begin(), v.end(), diff.begin());
    for (int x : diff) cout << x << " ";    // 1 2 3 4 5
    cout << "\n";

    vector<double> sales{100, 150, 200, 180};
    vector<double> growth(sales.size());
    adjacent_difference(sales.begin(), sales.end(), growth.begin(),
        [](double cur, double prev) { return (cur - prev) / prev; });
    for (double x : growth) cout << x << " ";   // 100 0.5 0.333 -0.1
    cout << "\n";
}

partial_sum —— 前缀和

a[0], a[0]+a[1], a[0]+a[1]+a[2], ...

#include <numeric>
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v{1, 2, 3, 4, 5};
    vector<int> ps(v.size());

    partial_sum(v.begin(), v.end(), ps.begin());
    for (int x : ps) cout << x << " ";      // 1 3 6 10 15
    cout << "\n";

    vector<int> fact(v.size());
    partial_sum(v.begin(), v.end(), fact.begin(), multiplies<int>());
    for (int x : fact) cout << x << " ";    // 1 2 6 24 120
    cout << "\n";
}

iota —— 连续赋值

[first, last) 依次赋值为 value, value+1, value+2, ...

#include <numeric>
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> v(5);
    iota(v.begin(), v.end(), 1);
    for (int x : v) cout << x << " ";       // 1 2 3 4 5
    cout << "\n";

    vector<int> idx(10);
    iota(idx.begin(), idx.end(), 0);        // 0 1 2 ... 9
}

1.2 Lambda 表达式

基本语法
[捕获](参数) -> 返回类型 { 主体 }
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    auto hello = [] { cout << "Hello\n"; };
    hello();

    auto add = [](int a, int b) -> int { return a + b; };
    cout << add(3, 4) << "\n";              // 7

    auto sub = [](int a, int b) { return a - b; };
    cout << sub(10, 3) << "\n";             // 7

    vector<int> v{1, 2, 3, 4, 5, 6};
    auto it = remove_if(v.begin(), v.end(), [](int x) { return x % 2 != 0; });
    v.erase(it, v.end());
    for (int x : v) cout << x << " ";       // 2 4 6
    cout << "\n";
}
捕获方式
#include <iostream>
using namespace std;

int main() {
    int a = 1, b = 2;

    auto by_val = [=] { cout << a << " " << b << "\n"; };
    by_val();                               // 1 2
    a = 10;
    by_val();                               // 还是 1 2

    auto by_ref = [&] { cout << a << " " << b << "\n"; };
    by_ref();                               // 10 2

    auto mix = [=, &b] {
        b = 20;
        return a + b;
    };
    cout << mix() << "\n";                  // 21
}
mutable —— 修改值捕获的副本

默认 Lambda 的 operator()const,值捕获的变量不能修改。加 mutable 解除限制——但只修改副本,不影响外部。

#include <iostream>
using namespace std;

int main() {
    int cnt = 0;

    auto good = [=]() mutable { ++cnt; return cnt; };
    cout << good() << "\n";                 // 1
    cout << good() << "\n";                 // 2
    cout << cnt << "\n";                    // 0
}

实用场景:生成自增 ID 的函数对象。

auto maker = [id = 0]() mutable { return ++id; };
cout << maker() << maker() << maker() << "\n";  // 123
初始化捕获(C++14)—— 移动不可复制对象进 Lambda
#include <iostream>
#include <memory>
using namespace std;

int main() {
    auto p = make_unique<int>(42);

    auto task = [ptr = move(p)] {
        cout << *ptr << "\n";
    };
    task();                                 // 42
}
泛型 Lambda(C++14)

参数类型用 auto 推导,相当于隐式的模板化 operator()

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    auto print = [](const auto &x) { cout << x << " "; };

    vector<int> vi{1, 2, 3};
    for_each(vi.begin(), vi.end(), print);  // 1 2 3
    cout << "\n";

    vector<string> vs{"a", "b", "c"};
    for_each(vs.begin(), vs.end(), print);  // a b c
    cout << "\n";

    auto same = []<typename T>(const T &a, const T &b) { return a == b; };
    cout << same(1, 1) << "\n";             // 1
}

二、底层原理

2.1 accumulate 和 reduce 的本质差异

accumulatereduce 的根本区别在于对运算的要求不同

accumulate = 左折叠(left fold)。 从左到右依次处理每个元素,运算顺序固定为 ((((init + a₁) + a₂) + a₃) + ... )。不要求结合律和交换律,结果确定可复现。

reduce = 归约(reduction)。 要求二元运算是可结合的可交换的,即构成一个半群。有了结合律,reduce 可以把区间拆成若干段,各自归约后合并结果——二叉树归约深度 O(log n),而非左折叠的 O(n)。有了交换律,par_unseq 可任意调换顺序。

accumulate
((a+b)+c)+d

串行链式
不能并行

reduce
(a+b)+(c+d)

二叉树归约
可并行

这就是为什么只有 reduce 可以配 execution::par_unseq——accumulate 的串行链式依赖不允许并发;reduce 的自由结合树天然适配 SIMD 和多线程拆分。

2.2 数值精度陷阱

accumulate 对大整数的溢出问题已经在 1.1 节提到:初值类型决定累加类型。更深层的问题是浮点数:

float a = 1e8, b = 1, c = -1e8;
accumulate: (a + b) + c = 1e8 + (-1e8) = 0(a + b 溢出尾数精度,b 被截断)
algebraic:  a + (b + c) = 1e8 + 0 = 1e8

浮点数加法不满足结合律。accumulate 固定从左到右,对某些输入序列可能会产生较大的舍入误差。但 reduce 在浮点上更危险——因为 par_unseq 的归约顺序不确定,每次运行结果可能不同,导致非确定性 bug。

2.3 Lambda 的编译器展开

Lambda 不是"轻量级函数",而是匿名仿函数对象

// 你写的:
auto add = [](int a, int b) { return a + b; };

// 编译器生成的类似代码:
struct __lambda_add {
    auto operator()(int a, int b) const { return a + b; }
};
__lambda_add add{};

关键点:

  1. 每个 Lambda 有唯一的 closure type。即使是完全相同代码的两个 Lambda,类型也不同:
auto f1 = [](int x) { return x; };
auto f2 = [](int x) { return x; };
// f1 和 f2 类型不同!不能互相赋值
  1. operator() 默认是 const。值捕获的变量在 operator() 内部是 const 引用,不能修改。加 mutable 后生成非 const 的 operator()
// [=] 值捕获的展开示意:
int a = 1;
auto f = [a]() mutable { ++a; };

// 编译器生成:
struct __lambda_f {
    int a;
    auto operator()() {
        return ++a;
    }
};
__lambda_f f{a};
  1. 捕获列表 = 成员变量[a] 生成一个 int a 成员;[&a] 生成一个 int &a 引用成员;[x = move(p)] 按移动后的值构造成员。

  2. 无捕获的 Lambda 可以转为函数指针

using Fn = int(*)(int, int);
Fn p = [](int a, int b) { return a + b; };   // 无捕获→函数指针
int x = 0;
// Fn q = [x](int a, int b) { return a + b; };  // 编译错误

Lambda 表达式

闭包类型
唯一匿名类型

operator()
默认 const

mutable → 去掉 const

无捕获 → 可转函数指针

2.4 Lambda vs std::function

维度Lambdastd::function
类型每个 Lambda 唯一 closure type统一的类型擦除包装
调用开销直调 operator(),可内联虚函数表 / 函数指针间接调用,难以内联
构造开销零额外,栈上对象可能堆分配(大捕获),类型擦除有开销
灵活性仅限单个闭包可存储任意可调用对象,可赋值/重新绑定
auto add = [](int a, int b) { return a + b; };
int r1 = add(1, 2);     // 编译为:call __lambda_add::operator()

function<int(int,int)> f = [](int a, int b) { return a + b; };
int r2 = f(1, 2);       // 编译为:间接调用

工程建议:只在需要类型擦除(如回调注册、多态函数容器)时才用 std::function。普通场景直接用 auto 或模板接受 Lambda,零开销。


三、面试题 + 口语化答案

Q1:accumulate 和 reduce 有什么区别?

“核心区别是对运算的要求不同。accumulate 只要求二元运算,从左到右链式折叠,不能并行。reduce 要求可结合+可交换(半群),可以按二叉树归约,C++17 配 execution::par_unseq 并行加速。如果运算不满足结合律——比如浮点加法——accumulate 结果是确定的,reduce 在并行模式下是不确定的。”

Q2:Lambda 的 operator() 默认是 const 吗?怎么改?

“默认是 const 的。值捕获的变量在 Lambda 体内是只读的——想修改要用 mutable 关键字。加了 mutable 后,operator() 变成非 const 的,值捕获的副本可以修改,但不影响外部变量。”

Q3:[=][&] 的 dangling 陷阱是什么?

[=] 值捕获做快照,不会 dangling。但 [&] 引用捕获只保存引用——如果 Lambda 生命周期超出被引用变量(比如 Lambda 存入容器或作为回调,而变量是栈上局部变量),引用变成野指针。安全的做法是:Lambda 只在本作用域内同步调用时用 [&],需要异步或存起来时用 [=] 或显式值捕获。”

Q4:泛型 Lambda 怎么写?和模板函数有什么区别?

auto f = [](const auto &x, const auto &y) { return x + y; };

“C++14 引入。auto 参数相当于隐式生成了模板化的 operator(),每个参数类型独立推导。C++20 还可以写显式模板 Lambda []<typename T>(T x){...}。本质上就是一个简化了的模板仿函数。”

Q5:iota 的底层怎么实现?

“就是 while(first != last) { *first++ = value; ++value; }——每次把当前 value 赋给迭代器,然后 value 自增。名字来自 APL 语言的整数序列符号 ⍳。value 的自增类型由模板参数 T 决定,所以 iota(v.begin(), v.end(), 0) 是 int 递增,iota(v.begin(), v.end(), 0LL) 是 long long 递增。”

Q6:浮点数累加为什么不准?accumulate 和 reduce 谁更准?

“浮点数加法不满足结合律——(1e8 + 1) - 1e8 != 1e8 + (1 - 1e8)。大数和小数相加时,小数的尾数被截断。accumulate 从左到右顺序固定,结果确定,但不能避免误差。reduce 在并行时归约顺序不确定,每次运行结果可能不同——这对需要可复现结果的系统不可接受。工程上需要高精度累积时,用 Kahan summation 或 long double。”

Q7:为什么无捕获的 Lambda 可以转为函数指针,有捕获的不行?

“无捕获的 Lambda 的 closure type 可以生成一个 operator() 和一个同签名静态函数,后者可作独立函数指针传递。有捕获后 operator() 需要访问成员变量(捕获的值或引用),函数指针不可能携带额外上下文——所以无法直接转换。不过可以用 std::function 或模板来包装带捕获的 Lambda。”

Q8:std::function 包装 Lambda 有什么代价?

“三层代价。第一,构造开销std::function 做类型擦除,小对象可能栈上存储(SBO 优化),大对象堆分配。第二,调用开销:每次调用走虚函数或函数指针间接跳转,编译器无法内联,调用比裸 Lambda 慢 2-3 倍是正常的。第三,体积std::function 本身至少 32-48 字节。除非需要存储不同类型的可调用对象,否则永远用 auto 或模板接收 Lambda。”


一句话总结<numeric> 算法的核心差异在于 accumulate(左折叠,串行确定)和 reduce(半群归约,可并行)对运算的不同要求;Lambda 本质是编译器生成的匿名仿函数,每个 Lambda 类型唯一,operator() 默认 const——理解这些底层机制,是你在面试中把"会用"和"懂原理"区分开来的关键。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Ricky_Theseus

感谢大家,祝您生活愉快

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值