17 数值计算

17.1 引言

当初设计C++语言时,数值计算并非其关注的重点。然而,数值计算在很多领域所发挥的作用,却日益显著,比如科学计算、数据库访问、网络安全、设备控制、图形图像处理、工业系统仿真、音视频、金融分析,等等。因此,C++业已成为许多大型系统中执行计算任务的强有力的工具之一。数值计算远不止于在简单的循环中处理浮点数序列,其间往往涉及非常复杂的数据结构和算法逻辑。所需数据结构和算法逻辑的复杂度越高,越能发挥出C++语言在数值计算方面的威力。迄今为止,C++已被广泛应用于科学、工程、金融等许多涉及复杂数值的计算任务中,而支持这类计算的各种功能和技术也逐渐发展起来,并成为标准库的一部分,以方便用户使用。

17.2 数学函数

标准库在<cmath>头文件中提供了许多数学函数,如参数类型为float、double和long double的sqrt、log和sin函数等,如下表所示:

数学函数说明
y=abs(x)绝对值:y=|x|
y=cell(x)向上取整:大于等于x且最接近它的整数
y=floor(x)向下取整:小于等于x且最接近它的整数
y=sqrt(x)平方根:y=x(x0)
y=sin(x)正弦:y=sin(x)
y=cos(x)余弦:y=cos(x)
y=tan(x)正切:y=tan(x)
y=asin(x)反正弦:y=arcsin(x)(x[1,1],y[π2,π2])
y=acos(x)反余弦:y=arccos(x)(x[1,1],y[0,π])
y=atan(x)反正切:y=arctan(x)(x(,+),y(π2,π2))
y=sinh(x)双曲正弦:y=sinh(x)=exex2
y=cosh(x)双曲余弦:y=cosh(x)=ex+ex2
y=tanh(x)双曲正切:y=tanh(x)=exexex+ex
y=exp(x)以e为底的指数:y=ex
y=exp2(x)以2为底的指数:y=2x
y=exp10(x)以10为底的指数:y=10x
y=log(x)以e为底的对数:y=logex(x>0)
y=log2(x)以2为底的对数:y=log2x(x>0)
y=log10(x)以10为底的对数:y=log10x(x>0)

这些函数的返回值类型与参数类型相同,其复数版本的声明位于<complex>头文件中。这些函数一旦出错,会将错误号设置在全局变量errno中,该全局变量的外部声明位于<cerrno>头文件中。定义域错误的错误号为EDOM,值域错误的错误号为ERANGE。这些函数在不发生错误的情况下,并不会自动清除错误号,因此需要在调用它们前手动清除错误号,以避免误判。例如:

<cmath>和<cstdlib>头文件中,还有更多数学函数。尤其是<cmath>头文件,里面不乏一些特殊数学函数,如beta(β函数,第一类欧拉积分)、rieman_zeta(黎曼ζ函数)、sph_bessel(第一类球贝塞尔函数),等等。

C++17:特殊数学函数

17.3 数值算法

标准库在<numeric>头文件中提供了一系列泛型数值算法函数,如accumulate等。下表列出了其中的一部分:

数值算法函数说明
x=accumulate(b,e,v)x为v加上,[b:e)范围内的元素相加的和
x=accumulate(b,e,v,f)用f代替“+”,执行accumulate操作
x=inner_product(b1,e1,b2,v)x为v加上,[b1:e1)范围内的元素,与从b2开始的元素,对应相乘再相加的和
x=inner_product(b1,e1,b2,v,f1,f2)分别用f1和f2代替“+”和“*”,执行inner_product操作
e2=partial_sum(b1,e1,b2)根据[b1:e1)范围内的元素,填充[b2:e2)范围内的元素,第一个元素直接拷贝,此后每个元素为[b1:e1)范围内对应位置的元素及其前面所有元素的累加和
e2=partial_sum(b1,e1,b2,f)用f代替“+”,执行partial_sum操作
e2=adjacent_difference(b1,e1,b2)根据[b1:e1)范围内的元素,填充[b2:e2)范围内的元素,第一个元素直接拷贝,此后每个元素为[b1:e1)范围内对应位置的元素减去前一个元素的差值
e2=adjacent_difference(b1,e1,b2,f)用f代替“-”,执行adjacent_difference操作
iota(b,e,v)根据v的值,填充[b:e)范围内的元素,依次是v、++v、++(++v)······
x=gcd(n,m)x为n和m的最大公约数
x=lcm(n,m)x为n和m的最小公倍数
x=midpoint(n,m)x为n和m的中点数

其中的b、e、b1、e1、b2、e2都是迭代器或指向数组元素的指针,v为序列中元素类型的值,f、f1、f2都是函数指针、匿名函数、函数对象等可调用对象。

这些算法可以适配各种类型的序列,因而可以用于概括常见操作(如累加、内积、部分和、邻差、递增等)的一般形态,甚至可以将对元素的操作参数化(如f、f1、f2等)。对于每个算法而言,除了带有操作参数的通用版本(如accumulate(b,e,i,f))以外,还有针对常见操作的简化版本(如accumulate(b,e,i))。

17.3.1 并行数值算法

<numeric>头文件中,除了提供前述串行数值算法外,还提供了一套并行数值算法。并行数值算法允许以未指定的顺序操作序列中的元素,并可以接受表示执行策略的参数,如seq、unseq、par、par_unseq等。下表列出了其中的一部分:

并行数值算法函数说明
x=reduce(b,e,v)相当于accumulate(b,e,v),只是不按顺序计算
x=reduce(b,e)相当于reduce(b,e,V{}),V为序列中元素的类型
x=reduce(p,b,e,v)相当于reduce(b,e,v),p为执行策略
x=reduce(p,b,e)相当于reduce(b,e),p为执行策略
e2=exclusive_scan(p,b1,e1,b2)相当于partial_sum(b1,e1,b2),只是不按顺序计算,p为执行策略,计算每个累加和时,排除输入范围内对应位置的元素
e2=inclusive_scan(p,b1,e1,b2)相当于partial_sum(b1,e1,b2),只是不按顺序计算,p为执行策略,计算每个累加和时,包含输入范围内对应位置的元素
x=transform_reduce(p,b,e,f,v)对[b:e)范围内的每个元素调用f,然后执行reduce
e2=transform_exclusive_scan(p,b1,e1,b2,f)对[b1:e1)范围内的每个元素调用f,然后执行exclusive_scan
e2=transform_inclusive_scan(p,b1,e1,b2,f)对[b1:e1)范围内的每个元素调用f,然后执行inclusive_scan

这里省略了带有操作参数的版本。

表示执行策略的参数,如seq、unseq、par、par_unseq等,位于<execution>头文件的std::execution命名空间中。

在决定使用并行数值算法前,最好先行测量,以评估这样做是否真的值得。

C++17:并行算法

17.4 复数

标准库提供了名为complex的复数类型,同时为了支持单精度浮点数(float)和双精度浮点数(double)类型的实虚部,将complex定义成一个类模板。类似下面这个样子:

complex支持常见的算术运算和数学函数。例如:

sqrt(平方根)、pow(幂)等数学函数的复数版本,声明于<complex>头文件中。

17.5 随机数

随机数在诸如测试、游戏、模拟、安全等领域中的应用颇多。为了满足多样化的应用需求,标准库在<random>头文件中提供了各种各样的随机数生成器。随机数生成器由两部分组成:

在选择分布的同时,还可以指定随机数的生成范围。例如:

运行上面的程序,输出如下统计结果:

标准库始终秉持通用性和性能毫不妥协的准则,因此它的随机数组件很难被视为“新手友好”的。适当使用类型别名和匿名函数,有望使代码的可读性有所提升。例如:

对于(任何背景的)新手而言,随机数库的通用接口有可能成为一个严重的障碍。定义一个简单的随机数工具类,不失为一种更好的选择。例如:

而使用它生成随机数的代码会变得非常简单。例如:

为了重复获得相同的随机数序列,或者为了确保每次产生的随机数序列不同,可以为引擎设置随机种子。种子相同序列相同,种子不同序列不同。例如:

重复序列对于确定性调试非常重要,而不重复序列则使伪随机数更相似于真随机数。当然,如果想得到真正的真随机数,则需要借助于random_device的支持。

C++11:随机分布和随机引擎

17.6 向量算术

vector被设计成一种通用机制,它可以存放不同类型的对象且足够灵活,能够适应容器、迭代器和算法的体系结构。它虽然名为vector(向量),但并不支持数学意义上的向量算术。为vector添加这类运算并不难,但其对通用性和灵活性的追求限制了为数值计算所需的优化。因此,标准库在<valarray>头文件中提供了名为valarray的模板类。与vector相比,valarray的通用性不强,但针对数值计算做了充分优化。valarray的定义形式类似下面这个样子:

valarray支持常见的算术运算和大多数数学函数。例如:

这些操作都是向量操作,即作用于向量中的每个元素。

此外,valarray还支持跨步访问,以实现多维计算。

17.7 类型属性

<limits>头文件中,标准库提供了名为numeric_limits的类模板,用于获取各种内置类型的属性,比如是否带有符号位,或者最大值是多少,等等。例如:

或者

甚至可以为自定义类型定义numeric_limits。

17.8 类型别名

基本类型,如short、int、long等的字长,是实现定义的。因此,它们在不同的C++实现中可能会不一致。如果需要显式指定整数的字长,可以使用定义在<stdint>头文件中的类型别名,如int16_t、int32_t、int64_t等。甚至可以用uint_least64_t定义至少64位字长的无符号整数。

形如“_t”形式的类型后缀,是源自C语言时代的历史遗迹。当时的人们认为,在名称上显式地反映出“这是一个类型的别名(而非别的什么东西)”很重要。

其它常见的类型别名,如size_t(sizeof操作符的结果类型)、ptrdiff_t(指针相减的结果类型)等,被收录在<stddef>头文件中。

C++11:整数类型别名,如int16_t、uint32_t、int_fast64_t等

17.9 数学常数

进行数学计算时,常常会用到一些数学常数,如e(自然常数:2.718281828459045)、π(圆周率:3.141592653589793)、γ(欧拉—马斯切罗尼常数:0.577215664901532)、φ(黄金分割比:5120.618)等。标准库在<numbers>头文件的std::numbers命名空间中,为这些数学常数提供了两种形式的定义:

例如:

从编程角度讲,float类型的π即pi_v<float>,和double类型的π即pi_v<double>,之间的差别很小,必须精确到小数点后16位左右才能看到,但在实际的科学计算中,这种差异所导致的结果却可能有着天壤之别。在诸如图形处理、人工智能等对数值精度要求较高的领域,浮点数的表达能力将变得越来越重要。

<numbers>头文件的std::numbers命名空间中,定义了很多常用的数学常数,例如:

基于这些基本的数学常数,还可以组合出更多针对特定领域数学常数。例如:

之后,就可以在代码中直接使用tau_v或者tau,表示类似2π这样的常量了。

C++20:pi、log10e等数学常数

17.10 建议