数据结构:递归:泰勒展开式(Taylor Series Expansion)

目录

第一步:❓我们要解决什么?

第二步:将其类比为求自然数和

第三步:什么是每一项?

第四步:定义要计算的每一项(term)

第五步:定义递归函数结构

[🌳 调用树结构](#🌳 调用树结构)

完整代码

复杂度分析

[🔚 总结图(从定义到实现):](#🔚 总结图(从定义到实现):)


我们将用第一性原理来一步一步推导如何用 C++ 实现泰勒展开式(Taylor Series Expansion),并借助递归求自然数之和的思想进行类比和迁移。

第一步:❓我们要解决什么?

我们要用 C++ 写一个函数,计算 e^x 的近似值,基于泰勒展开公式。

第二步:将其类比为求自然数和

你已经知道:

cpp 复制代码
int sumOfNumbers(int n)
{
	if (n == 0)
		return 0;
	else
		return sumOfNumbers(n - 1) + n;
}

我们同样可以将泰勒级数看作是:

cpp 复制代码
f(x) = 第0项 + 第1项 + 第2项 + ... + 第n项

所以我们可以尝试用递归方法去表达每一项,累加所有项。

第三步:什么是每一项?

我们先思考"泰勒展开"的一项的数学结构:

我们从第一性原理推导出这个公式的两部分组成:

  • 分子是 x^n,也就是 x * x * x......(共n次)

  • 分母是 n!=n * (n−1) * (n−2)⋯1

所以我们必须做两件事:

  • 每次递归都乘一次 x → 累计分子

  • 每次递归都乘一次当前 n → 累计分母

从递归角度重新理解:

如果你在写一个 taylor(n) 递归函数,你会每次调用去表示:

  • 我要计算第 n 项,

  • 第 n 项是第 n−1 项 * x / n

于是我们可以这样递推:

✅ 这个推导说明:

我们不需要单独重复计算 x^n 和 n

我们可以利用上一步的结果,乘一个新的因子 x / n 就能得到下一项。


第四步:定义要计算的每一项(term)

❓问题来了:

如果我们想用递归函数一步一步计算:

cpp 复制代码
double taylor(int n, double x);

那么问题是:

  • 上一项计算的结果在哪里?

  • 本层计算需要的 x^(n-1) 和 (n-1)!怎么得来?

直觉尝试:用返回值传信息?

你可能会尝试每次都重新计算 x^n 和 n!,像这样:

cpp 复制代码
double taylor(int n, double x) {
    return taylor(n-1, x) + pow(x, n) / factorial(n);  // NOT efficient
}

问题:

  • 每一层都重复算一遍幂和阶乘

  • 没有重用信息

✅ 真正的优化思路:我们需要"带状态的递归"

我们希望每次调用都记住前一项计算中积累出来的 x^nn!

于是我们可以:

  • 在函数内部保留静态变量(或全局变量)

  • 让它在每一层递归中不断更新

我们引入三个全局变量:

cpp 复制代码
double num = 1; // 分子(x^n)
double den = 1; // 分母(n!)

每次递归做的事情就是:

  • 分子(幂函数)乘上 x

  • 分母(阶乘)乘上 n

  • 计算这一项 term = num / den

  • 递归进入下一层(直到 n == 0 为止)

为什么不用局部变量?

  • 每一层递归函数自己的局部变量在返回时会销毁,状态无法累积。

  • 静态/全局变量可以在整个递归调用链中持续保存状态。

第五步:定义递归函数结构

我们遵循:

  • 先处理"底部终止条件"(n == 0)

  • 然后递归地构造前一项的和

  • 最后处理当前这一项的贡献

否则:你将会提早计算当前项(还没到你这一层),状态错位。

Step 1:递归函数要怎么"停下来"?

在定义任何递归函数的时候,必须先回答一个问题:

✨ 什么时候这个函数应该"终止",不再递归?

这就是我们要设定的base case(基础情况)。

在泰勒展开中,第 0 项是已知的常数 1,所以我们在 n=0 时应该返回:1

cpp 复制代码
if (n == 0) return 1;

✅ 这是递归的"锚点",你必须先写,否则递归会无限下去。

Step 2:递归要先"构造前面的和"

✨思维实验:

假设你现在写的是:

cpp 复制代码
double taylor(int n, double x) {
    num *= x;
    den *= n;
    double res = taylor(n - 1, x);
    return res + num / den;
}

你发现问题了没?

你先更新了 num 和 den,然后才去计算上一项的结果,这就打乱了状态的对应关系------因为上一

项本来是 x^(n-1) / (x-1) !,但你已经把它提前变成 x^n / x !​ 了。

错误:我们在进入上一个递归之前,就已经变更了状态!

✅ 正确方式:

我们应该:

  1. 先去计算 上一项的累加和(taylor(n - 1))

  2. 回来以后再更新状态

  3. 然后把当前这一项加进去

cpp 复制代码
double taylor(int n, double x) {
    if (n == 0) return 1;

    double res = taylor(n - 1, x); // 🚶先去处理前面项
    num *= x;                      // ⏪回来再乘 x
    den *= n;                      // ⏪回来再乘 n
    return res + num / den;        // ⏫最后加上当前这一项
}

注意!这是典型的"递归先深入到底部(递归树的叶子)",然后在回溯的过程中逐层累计每一项。

🌳 调用树结构

示例:调用 taylor(3, x),即展开 4 项 (n=3)

我们每调用一次函数,都画出一层,并在返回时说明计算了哪一项。

cpp 复制代码
                                taylor(3)
                                  ↓
                                taylor(2)
                                  ↓
                                taylor(1)
                                  ↓
                                taylor(0) ← base case = 1

然后回溯计算(从下向上):

  • 返回 taylor(0) = 1

  • 回到 taylor(1):

    • 分子 num *= x → x

    • 分母 den *= 1 → 1

    • 加项 x^1/1! = x

  • 回到 taylor(2):

    • num *= x → x²

    • den *= 2 → 2

    • 加项 x^2 / 2!

  • 回到 taylor(3):

    • num *= x → x³

    • den *= 3 → 6

    • 加项 x^3 / 3!

cpp 复制代码
taylor(0) = 1
taylor(1) = taylor(0) + x / 1
taylor(2) = taylor(1) + x^2 / 2
taylor(3) = taylor(2) + x^3 / 6
cpp 复制代码
回溯顺序:
taylor(1) ← + x^1 / 1!     ← 分子: x,分母: 1
taylor(2) ← + x^2 / 2!     ← 分子: x²,分母: 2
taylor(3) ← + x^3 / 3!     ← 分子: x³,分母: 6

你看到没,正是因为我们在回溯阶段才处理当前项,才确保每一项都在它该在的位置!

调用层 n 分子(num) 分母(den) 当前项
3 3 x^3 3 !=6 x^3 / 6
2 2 x^2 2 !=2 x^2 / 2
1 1 x 1 !=1 x/1
0 0 1(初始值) 1(初始值) 1

完整代码

cpp 复制代码
double num = 1;  // 分子
double den = 1;  // 分母

double taylor(int n, double x) {
    if (n == 0) return 1;               // 1️⃣ 锚点:停止递归

    double res = taylor(n - 1, x);      // 2️⃣ 先构造前面所有项的和
    num *= x;                           // 3️⃣ 然后再更新状态
    den *= n;
    return res + num / den;             // 4️⃣ 当前项加进去
}

复杂度分析

这是一个非常经典的线性递归(linear recursion)结构。我们来详细分析其时间复杂度和空间复杂度。

⏱️时间复杂度分析(Time Complexity)

我们从函数调用过程来看:

  • 调用 taylor(n) 会递归调用 taylor(n-1)

  • taylor(n-1) 又会调用 taylor(n-2)

  • ...

  • 最后到底部 taylor(0) 返回 1,然后逐层回溯

整个递归链条是:

复制代码
taylor(n)
 → taylor(n-1)
   → taylor(n-2)
     ...
       → taylor(0)

总共会调用 n+1 次函数(从 0 到 n)。

每一层递归执行的是:

  • 一个函数调用(taylor(n - 1)

  • 两次乘法:num *= x, den *= n

  • 一次加法:res + num / den

这些操作都是常数时间 O(1)。

✅ 所以:时间复杂度为:O(n)

空间复杂度分析(Space Complexity)

这就有趣了,因为这是递归。

你没有使用堆内存(比如数组、链表),但递归函数本身会在调用栈上分配帧:

  • 每调用一次 taylor(n),系统会开辟一块栈帧来保存:

    • 参数 n, x

    • 局部变量 res

    • 返回地址

由于这个是线性递归(不是尾递归),函数不会在递归过程中被优化掉(除非特别启用尾递归优化,C++ 一般不保证)。

✅ 所以:空间复杂度为:O(n)

🔚 总结图(从定义到实现):

cpp 复制代码
数学定义:
     term_n = x^n / n!   ← 每一项都能用前一项 * (x/n) 得到

递归目标:
     taylor(n) = taylor(n-1) + term_n

中间状态:
     num ← num * x
     den ← den * n

于是代码:
    num = 1;
    den = 1;
    double taylor(n, x) {
        if n==0 return 1;
        res = taylor(n-1, x);
        num *= x; den *= n;
        return res + num / den;
    }
相关推荐
计信金边罗43 分钟前
是否存在路径(FIFOBB算法)
算法·蓝桥杯·图论
MZWeiei1 小时前
KMP 算法中 next 数组的构建函数 get_next
算法·kmp
Fanxt_Ja2 小时前
【JVM】三色标记法原理
java·开发语言·jvm·算法
luofeiju2 小时前
行列式的性质
线性代数·算法·矩阵
緈福的街口2 小时前
【leetcode】347. 前k个高频元素
算法·leetcode·职场和发展
-qOVOp-3 小时前
408第一季 - 408内容概述
数据结构
pen-ai3 小时前
【统计方法】基础分类器: logistic, knn, svm, lda
算法·机器学习·支持向量机
鑫鑫向栄3 小时前
[蓝桥杯]春晚魔术【算法赛】
算法·职场和发展·蓝桥杯
roman_日积跬步-终至千里3 小时前
【Go语言基础【3】】变量、常量、值类型与引用类型
开发语言·算法·golang
FrankHuang8884 小时前
使用高斯朴素贝叶斯算法对鸢尾花数据集进行分类
算法·机器学习·ai·分类