积分计算

前言

  最近需要计算一下曲线长度,无法直接得到被积函数的原函数,常规的积分解法牛顿莱布尼茨公式没法使用,所以只能使用数值积分计算积分。

  下面主要介绍两种数值积分方法:龙贝格积分(Romberg Quadrature) 和 高斯-克朗罗德积分(Gauss-kronrod Quadrature)。 下面附带的代码只做简单测试,不保证正确性,语言使用 Typescript。

Romberg 篇

  计算积分最简单的当然是使用复化梯形公式,即 \(I=\int_a^b{f(x)dx}=\frac{b-a}{2n}[f(a)+f(b)+2\sum\limits_{i=1}^{n-1}f(x_i)]= T_n, x_i=a+i*h, h=(b-a)/n\) ,若将 n 段每段一分为 2,可得到 \(T_{2n}=T_n/2+\frac{b-a}{2n}\sum\limits_{i=0}^{n-1}f(x_{i+1/2})\) 。考虑数列 \(T=\{T_1,T_2,T_{2^2},...,T_{2^k}\}\),显然该数列必收敛,最后收敛为对应积分,当 \(|T_{2^k}-T_{2^{k-1}}| < ε\)\(ε\) 为精度)时,可取 \(T_{2^k}\) 作为最后的积分结果。但是,直接利用梯形公司求解,收敛很慢,导致计算效率很差,所以需要使用理查德森(Richardson)外推法加速收敛,设 \(T_{2^k}^{(m)}\) 为 m 次加速值,当 m 为 0 时,表示没加速,为原梯形公司,则 \(T_{2^k}^{(m)} = \frac{4^m}{4^m-1}T_{2^{k+1}}^{(m-1)}-\frac{1}{4^m-1}T_{2^k}^{(m-1)}\),当 \(|T_{2^{k+1}}^{(m)}-T_{2^k}^{(m)}| < ε\) 时,则收敛,并取其中一值作为最终的积分值。未经修饰的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function rombergIntegrator(f: (x: number) => number, a: number, b: number, tolerance = 1e-8) {
let h = b - a;
let n = 1;
let preTK = ((f(a) + f(b)) * h) / 2;

let tkmArray = [];
let m = 0;
tkmArray[m] = preTK;

while (true) {
m += 1;
console.log(m, tkmArray[m - 1]);

let tk = getTK(f, preTK, n, a, h);
if (Math.abs(tk - preTK) < tolerance) return tk;

let preTKM = tkmArray[0];
let preTK1M = tk;
tkmArray[0] = tk;
for (let i = 1; i <= m; i++) {
let newTKM = getTKM(preTK1M, preTKM, i);
preTKM = tkmArray[i];
preTK1M = newTKM;
tkmArray[i] = newTKM;

if (preTKM !== undefined && Math.abs(preTK1M - preTKM) < tolerance) return preTK1M;
}

preTK = tk;
n *= 2;
h /= 2;
}

function getTK(f: (x: number) => number, preTK: number, n: number, a: number, h: number) {
let sum = 0;
for (let i = 0; i < n; i++) {
let x = a + (i + 0.5) * h;
sum += f(x);
}
return (preTK + h * sum) / 2;
}

function getTKM(preTK1M: number, preTKM: number, m = 0) {
let m4 = 1 << (2 * m); // 4 ** m;
return (m4 * preTK1M - preTKM) / (m4 - 1);
}
}

  由于采用闭型积分规则(积分上下限值参与积分计算),所以以上代码不适合计算两端点值被积函数值无限大的情况(如 1/4 圆弧积分等)。而且该方法不合适求取被积函数在积分区间内导数值变化较大(如被积函数在积分下限附近剧烈波动,在积分上限附近不变化等)的积分,因为该方法是均匀分段,这种情况将导致计算量剧增,这时就需要用到下面的自适应积分。

自适应篇

  自适应积分主要包括两类:全局自适应积分和局部自适应积分,通常情况下全局自适应积分的会比局部自适应积分的表现要好,全局自适应积分一般通过二分递归实现,当满足一定条件时,递归终止,即通过二分分别计算两边的积分,若一边满足一定条件,则不继续划分,从而减少计算量。全局自适应积分中比较经典的有基于辛普森(Simpson)公式的自适应算法,普通辛普森积分公式为:\(I=\int_a^b{f(x)dx}=\frac{b-a}{6}[f(a)+4f(m)+f(b)]= S(a,b), m=(a+b)/2\),复化辛普森公式为 \(I=\int_a^b{f(x)dx}=\frac{h}{3}[f(a)+4\sum\limits_{i=1}^{n}f(x_{2i-1})+2\sum\limits_{i=1}^{n-1}f(x_{2i})+f(b)]= S(a,b)\),其中 \(x_i=a+i*h (i=1,2,3,...,2n),h=\frac{b-a}{2n}\),基于辛普森公式的自适应基本原理如下:令 \(S_2(a,b) = S(a,m)+S(m,b)\),m 为 a,b 中值,若 \(|S(a,b) - S_2(a,b)| < 15ε\),则取 \(S_2(a,b)\)\(S_2(a,b)+(S(a,b)-S_2(a,b))/15\) 作为该区间的积分值,否则,将区间二分递归,同时因为误差会累积,所以每次递归都需要将精度提高两倍,即 \(ε = ε/2\),如此最后的精度才能达到最初的精度。具体 ts 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function adaptiveSimpsonIntegrator(f: (x: number) => number, a: number, b: number, epsilon = 1e-8) {
let S = complexSimpson(f, a, b);
return adsimp(f, a, b, epsilon, S);

function adsimp(f: (x: number) => number, a: number, b: number, epsilon = 1e-8, S = 0): number {
let m = a + (b - a) / 2;
let LS = complexSimpson(f, a, m);
let RS = complexSimpson(f, m, b);
const S2 = LS + RS;
const tolerance = 15 * epsilon;
let delta = S - S2;
if (Math.abs(delta) < tolerance) return S2 + delta / 15;
else {
let doubleEPS = epsilon / 2;
return adsimp(f, a, m, doubleEPS, LS) + adsimp(f, m, b, doubleEPS, RS);
}
}

function complexSimpson(f: (x: number) => number, a: number, b: number, n = 1) {
const n2 = n * 2;
const h = (b - a) / n2;
let sum = f(a) + f(b);

for (let i = 1; i < n2; i += 2) {
sum += 4 * f(a + i * h);
}
for (let i = 2; i < n2 - 1; i += 2) {
sum += 2 * f(a + i * h);
}

return (sum * h) / 3;
}
}

  在 D.V. Fedorov 写的「Introduction to Numerical Methods with examples in Javascript」一书中介绍了一种全局自适应方法,即分别使用高阶和低阶的权值分别计算积分,两者之间的差值 \(E\) 作为误差估计,设绝对精度为 \(\delta\) ,相对精度为 \(\epsilon\) ,若 \(|E|<\delta+\epsilon*Q\),Q 为高阶权值计算的积分,则取 Q 作为积分值,否则将积分区间二分,同时使 \(\delta/\sqrt{2}\)\(\epsilon\) 保持不变。具体 ts 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function recursiveAdaptiveIntegrator(f: (x: number) => number, a: number, b: number, accuracy = 1e-15) {
return recursiveAdaptiveIntegrate(f, a, b, accuracy);

function recursiveAdaptiveIntegrate(
f: (x: number) => number,
a: number,
b: number,
accuracy = 1e-15,
epsilon = Number.EPSILON,
preFValue?: number[],
): number {
const abscissae = [1 / 6, 2 / 6, 4 / 6, 5 / 6];
const highOrderWeights = [2 / 6, 1 / 6, 1 / 6, 2 / 6];
const lowOrderWeights = [1 / 4, 1 / 4, 1 / 4, 1 / 4];
const isRecompute = [1, 0, 0, 1];
const h = b - a;

const fValue: number[] = [];
if (preFValue === undefined) {
abscissae.forEach((abscissa) => {
const x = a + abscissa * h;
fValue.push(f(x));
});
} else {
for (let k = 0, i = 0; i < abscissae.length; i++) {
if (isRecompute[i]) fValue.push(f(a + abscissae[i] * h));
else fValue.push(preFValue[k++]);
}
}

let highResult = 0;
let lowResult = 0;
for (let i = 0; i < highOrderWeights.length; i++) {
highResult += highOrderWeights[i] * fValue[i];
lowResult += lowOrderWeights[i] * fValue[i];
}
highResult *= h;
lowResult *= h;

const tolerance = accuracy + epsilon * Math.abs(highResult);
let errorEstimate = Math.abs(highResult - lowResult) / 3;
if (errorEstimate < tolerance) {
return highResult;
} else {
accuracy /= Math.sqrt(2);
let m = a + h / 2;
let midIndex = Math.trunc(abscissae.length / 2);
let leftFValue = fValue.slice(0, midIndex);
let rightFValue = fValue.slice(midIndex);
return (
recursiveAdaptiveIntegrate(f, a, m, accuracy, epsilon, leftFValue) +
recursiveAdaptiveIntegrate(f, m, b, accuracy, epsilon, rightFValue)
);
}
}
}

  该方法很巧妙的设计了一组插值点,使得当前计算的函数值正好可以被下次迭代所使用,从而提高性能,同时该方法可以得到 1/4 圆弧长,虽然精度只能到小数点后 8 位,至于 Shaun 写的其它测试函数,都能得到理想精度。

Gauss 篇

  高斯求积法是一种多项式插值积分法,同时由于不计算被积函数在区间两个端点处的值,所以高斯积分法采用的开型积分规则,高斯积分法的衍生方法有很多种,下面主要介绍高斯-勒让德(Gauss-Legendre Quadrature)以及其迭代改良的高斯-克朗罗德法。高斯-勒让德积分法的公式为积分的原始形态,即 \(\int_a^bf(x)dx=\sum\limits_{i=1}^{∞}w_if(x_{i})\approx\sum\limits_{i=1}^{n}w_if(x_{i})\) ,只不过 \(x_i \in [-1,1]\),并且 \(x_i\)\(w_i\) 都通过勒让德多项式求出,所以其原则上只能用在积分区间为 [-1, 1] 上的积分,但是可以将积分从任意区间通过简单的变形变换到 [-1, 1] 上,即 \(\int_a^b{f(x)dx} = \frac{b-a}{2}\int_{-1}^1{f(\frac{b-a}{2}t+\frac{b+a}{2})dt}\) ,从而可以将高斯-勒让德方法扩展到任意积分上。由于每个 n 对应的 \(x_i\)\(w_i\) 都可以查表可得,所以具体代码方面就很简单了,以 n = 4,即插值点个数为 4 为例,ts 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function gaussLegendreIntegrate(f: (x: number) => number, a: number, b: number, n: 4 | 8 = 4) {
const weightsAndAbscissae = getWeightsAndAbscissae(n);
const weights = weightsAndAbscissae.weights;
const abscissae = weightsAndAbscissae.abscissae;

const halfH = (b - a) / 2;

let sum = 0;
for (let i = 0; i < abscissae.length; i++) {
let xi = halfH * abscissae[i] + a + halfH;
sum += weights[i] * f(xi);
}

return sum * halfH;

function getWeightsAndAbscissae(n: 4 | 8 = 4) {
switch (n) {
case 8:
return {
weights: [
0.362683783378362,
0.362683783378362,
0.3137066458778873,
0.3137066458778873,
0.2223810344533745,
0.2223810344533745,
0.1012285362903763,
0.1012285362903763,
],
abscissae: [
-0.1834346424956498,
0.1834346424956498,
-0.525532409916329,
0.525532409916329,
-0.7966664774136267,
0.7966664774136267,
-0.9602898564975363,
0.9602898564975363,
],
};
break;

case 4:
default:
return {
weights: [0.6521451548625461, 0.6521451548625461, 0.3478548451374538, 0.3478548451374538],
abscissae: [-0.3399810435848563, 0.3399810435848563, -0.8611363115940526, 0.8611363115940526],
};
break;
}
}
}

  若要提高高斯-勒让德积分法的精度,可通过增加插值点或分多个区间进行积分来实现,但是由于没有误差估计,所以还是没法精确控制精度,对与某些被积函数积分精度高,但对于其它被积函数,积分精度却有限,当然可以简单的引入一些常用的误差估计法,但一般需要重新计算积分,导致效率很低,而高斯-克朗罗德法为其引入了一种基于 Kronrod 点的误差估计法,可充分利用现有计算值,从而达到有效控制精度的同时,性能没有太大的损失。设 \(G(f,n)=\sum\limits_{i=1}^{n}w_if(x_{i})\) 为具有 n 个插值点的高斯-勒让德法计算结果,\(GK(f,n) = \sum\limits_{i=1}^{n}w'_if(x_{i})+\sum\limits_{k=n+1}^{2n+1}w'_kf(x_{k})\) 为高斯-克朗罗德法的计算结果,则 \(|GK(f,n)-G(f,n)|\) 可作为误差估计,有了误差估计,最后再使用全局自适应策略,即可得到精度可控的高斯积分结果。具体 ts 代码如下,以 n = 7 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
function gaussKronrodIntegrator(f: (x: number) => number, a: number, b: number, accuracy = 1e-15) {
return recursiveAdaptiveIntegrate(f, a, b, accuracy);

function recursiveAdaptiveIntegrate(f: (x: number) => number, a: number, b: number, accuracy = 1e-12): number {
const gaussAbscissae = [
0.0,
-4.058451513773971669066064120769615e-1,
4.058451513773971669066064120769615e-1,
-7.415311855993944398638647732807884e-1,
7.415311855993944398638647732807884e-1,
-9.491079123427585245261896840478513e-1,
9.491079123427585245261896840478513e-1,
];
const gaussWeights = [
4.179591836734693877551020408163265e-1,
3.818300505051189449503697754889751e-1,
3.818300505051189449503697754889751e-1,
2.797053914892766679014677714237796e-1,
2.797053914892766679014677714237796e-1,
1.29484966168869693270611432679082e-1,
1.29484966168869693270611432679082e-1,
];

const kronrodAbscissae = gaussAbscissae.concat([
-2.077849550078984676006894037732449e-1,
2.077849550078984676006894037732449e-1,
-5.860872354676911302941448382587296e-1,
5.860872354676911302941448382587296e-1,
-8.648644233597690727897127886409262e-1,
8.648644233597690727897127886409262e-1,
-9.914553711208126392068546975263285e-1,
9.914553711208126392068546975263285e-1,
]);

const kronrodWeights = [
2.094821410847278280129991748917143e-1,
1.903505780647854099132564024210137e-1,
1.903505780647854099132564024210137e-1,
1.406532597155259187451895905102379e-1,
1.406532597155259187451895905102379e-1,
6.309209262997855329070066318920429e-2,
6.309209262997855329070066318920429e-2,
2.044329400752988924141619992346491e-1,
2.044329400752988924141619992346491e-1,
1.690047266392679028265834265985503e-1,
1.690047266392679028265834265985503e-1,
1.04790010322250183839876322541518e-1,
1.04790010322250183839876322541518e-1,
2.293532201052922496373200805896959e-2,
2.293532201052922496373200805896959e-2,
];

const halfH = (b - a) / 2;

let guassResult = 0;
let kronrodResult = 0;
for (let i = 0; i < gaussAbscissae.length; i++) {
let xi = halfH * gaussAbscissae[i] + a + halfH;
let yi = f(xi);
guassResult += gaussWeights[i] * yi;
kronrodResult += kronrodWeights[i] * yi;
}
for (let i = gaussAbscissae.length; i < kronrodAbscissae.length; i++) {
let xi = halfH * kronrodAbscissae[i] + a + halfH;
let yi = f(xi);
kronrodResult += kronrodWeights[i] * yi;
}

if (Math.abs(kronrodResult - guassResult) < accuracy / halfH) return kronrodResult * halfH;
else {
let m = a + (b - a) / 2;
accuracy /= 2;
return recursiveAdaptiveIntegrate(f, a, m, accuracy) + recursiveAdaptiveIntegrate(f, m, b, accuracy);
}
}
}

  简单测试了一下,Shaun 这里写的 gaussKronrodIntegrator 方法最大精度只能到 1e-15,到 16 位就报错递归深度太大了,圆的 1/4 弧长也没法算出来,当然这些问题可通过设置最大递归深度以及处理异常值来解决,Shaun 这里就不继续写了。

后记

  数值积分策略非常多,尤其是针对一些特殊的函数,可能只能使用一些特殊的策略才能计算,Shaun 这里只是介绍了一些比较基础常用的积分方法,能解决大部分积分问题,唯一需要注意一点的就是如何追求性能与精度之间的平衡,因为积分常常涉及到迭代求值,通常而言精度越高,迭代越多,求积时,同时也需要注意被积函数的异常值(如无穷大等),这时可能需要拆分或变换积分区间,并且使用开型积分规则的积分方法进行重新计算。

附录

一些常见的积分变换

\[ \int_a^bf(x) = \int_0^{b-a}f(a+t)dt \\ \int_a^b{f(x)dx} = \frac{b-a}{2}\int_{-1}^1{f(\frac{b-a}{2}t+\frac{b+a}{2})dt} \\ \int_{-∞}^{+∞}{f(x)dx} = \int_{-1}^1{f(\frac{t}{1-t^2})\frac{1+t^2}{(1-t^2)^2}dt} \\ \int_{a}^{+∞}{f(x)dx} = \int_{0}^1{f(a + \frac{t}{1-t})\frac{1}{(1-t)^2}dt} \\ \int_{-∞}^{a}{f(x)dx} = \int_{-1}^0{f(a + \frac{t}{1+t})\frac{-1}{(1+t)^2}dt} \]

参考资料

[1] 积分策略

[2] Gaussian Quadrature Weights and Abscissae

[3] Gauss-Kronrod Quadrature

[4] Gauss-Kronrod Quadrature Nodes and Weights