引言
TypeScript 的类型系统已经从基本的类型注释,发展成为一种大型且复杂的编程语言。在上篇文章中,我们打开了类型编程的潘多拉魔盒,我们知道TS的强大之处,不仅在于其类型检查系统,还在于其具有图灵完备特性,可以实现强大的类型级编程能力。
在学习一般的编程语言的时候,我们首先会学习变量声明和数据结构,其次是基本的代数运算、条件分支和循环,更高阶地,我们会接触到递归,通过递归,我们可以了解到排序、动态规划等各种算法。掌握这些特性可以让我们快速入门一门编程语言,并且编写出更复杂的应用。
TS类型编程也有类似的特性,本文继续延续类型编程这个话题,从类型编程角度,重点探讨TS中的递归,我们特别以实现计算斐波那契序列为例,展现出不同的实现算法,来感受TS类型编程的魅力。
应该指出的是,本文话题仅仅是一次有意思的学术探索,内容深度已经远远超出了我们日常对TS的普通使用,给出的代码不建议在生产环境中使用。
类型编程的思维
TS编程语言可以看成两部分组成:
- 普通编程:在运行时体现,使用值和函数
- 类型编程:在编译时体型,使用类型和泛型
对于普通编程,我们经常涉及到的元素是
- 数据结构
- 函数
- 控制流
- 循环
- 基本代数运算(加减乘除等)
有了这些特性,大部分的编程语言都是图灵完备的,可以实现任何算法的编写,实现各种各样的复杂运算。TS的类型编程也是图灵完备的,这说明这些概念同样在类型编程的世界里也有体现。
- 数据结构 → 对象和元祖
1 | // 数组 -> 元组类型 |
- 函数 → 泛型类型
1 | const f = (num) => num; |
- 控制流 → 条件类型
extends
1 | const ans = 2 === 1 ? true : false; |
- 循环 → 函数式编程的递归
1 | const reverse = (arr) => { |
- 基本代数运算(加减乘除等) → 通过访问元祖长度实现加减乘除
1 | // 类型编程的加减乘除运算 |
可以看出,正是TS类型有条件分支和递归能力,才有了实现强大类型编程的基础。
斐波那契序列的递归实现
在学习一门新的编程语言时,我们通常以打印“Hello World”作为入门案例。同样,通过递归实现斐波那契数列,也可以看作是学习递归算法的一个“Hello World”级别的案例。
作为数学中一个重要的序列,斐波那契序列在理论和实践应用中都有重要价值。深入理解它可以帮助我们更全面地掌握数字模式和算法思路。
斐波那契序列具有这样的特点:除了前两个数字0和1之外,任意一个数字都等于前两个数字之和。数列的展开式为:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987…。也就是说:
!https://cdn.staticaly.com/gh/janice143/picx-images-hosting@master/20230827/image.454v4zqwsaq0.png
实现斐波那契序列最简单的算法就是递归,下面是用JS编写的递归算法:
1 | const fib = (n) => { |
可以看到递归最重要的两个特性:
- base case:逃出递归的条件
- 调用自身
TS类型编程中的递归和JS的递归其实没什么太大区别,也具有这两个特性,只不过语法不一样。下面的代码是TS的纯类型编程版本:
1 | type Fib1<N extends number> = N extends 0 |
可以看到我们用两个extends
条件类型给出了两种base case, 第三种情况是不断调用自身。在进行类型编程的时候,我们思路其实就是按照算法来编写,然后按照TS提供的特性来实现。因为TS类型编程不能修改类型的状态,而且只用”表达式”,不用”语句”,这两个特点和函数式编程一样,所以进行类型编程的时候,我们采用的是函数式编程思想。
- 不能修改类型状态
TS可以用Infer
来存储一个类型,但是该类型不能被修改,保存新的类型值。普通编程语言一般都有for
循环,这是因为他们定义的变量的状态可以改变,通过可变的状态来作为计数器实现循环,但是TS的类型编程没有for
循环,要实现遍历则需要使用递归,通过递归函数的参数来保存或更新状态。
- 只用”表达式”,不用”语句”
TS的类型编程基本上只有一个表达式,例如X extends Y ? A : B
,这个表达式是一个单纯的运算过程,总有返回值。从斐波那契递归算法的例子可以看到,我们写的类型编程其实也就是一个比较长的表达式,而且表达式里可能有别的表达式。
动态规划和矩阵快速幂运算
递归方法具有很高的时间复杂度,因为有大量的重复计算。为了减少时间复杂度,斐波那契序列的计算也有很多优化算法,例如动态规划和矩阵快速幂运算。本节也用TS类型编程来实践一下。
动态规划
先讲动态规划,动态规划分两种:带有备忘录的自上而下和自下而上。下面给出了JS实现代码:
1 | // top down |
因为TS类型编程无法修改备忘录的状态,所以目前无法实现带有备忘录机制的纯类型编程。但是可以实现自下而上的动态规划,代码如下:
1 | type FibBottomUp<N extends number, Arr extends any[]=[0,1]> = |
可以看到我们通过Arr
的长度来实现循环迭代,元组Arr
的每个下标index
对应着fib(index)
结果。每次计算计算fib(n)
的时候,都会先计算fib(n-1)
和fib(n-2)
的值,这种思路就是自底向上的动态规划,求解最终问题之前,会从问题的子问题开始,逐步构建到最终问题。
矩阵快速幂
动态规划算法由于每个节点只会计算一次,所以时间复杂度是O(n)。矩阵快速幂比动态规划的时间复杂度更低。那么矩阵快速幂是怎么推导来的呢?
我们知道,斐波那契数列有递推公式:
$$
F_{n+2}=F_{n+1}+F_n, n ∈ 0,1,2..
$$
把这个公式改写成矩阵运算,可以得到下面的式子:
$$
\begin{bmatrix}
F_{n+2}\
F_{n+1}
\end{bmatrix}=\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}*\begin{bmatrix}
F_{n+1}\
F_{n}
\end{bmatrix}
$$
也就是说,当n=0, 1, 2的时候,我们又下列公式,
$$
\begin{bmatrix}
F_{2}\
F_{1}
\end{bmatrix}=\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}*\begin{bmatrix}
F_{1}\
F_{0}
\end{bmatrix}
$$
$$
\begin{bmatrix}
F_{3}\
F_{2}
\end{bmatrix}=\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}\begin{bmatrix}
F_{2}\
F_{1}
\end{bmatrix}=\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}^2\begin{bmatrix}
F_{1}\
F_{0}
\end{bmatrix}
$$
$$
\begin{bmatrix}
F_{4}\
F_{3}
\end{bmatrix}=\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}\begin{bmatrix}
F_{3}\
F_{2}
\end{bmatrix}=\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}^3\begin{bmatrix}
F_{1}\
F_{0}
\end{bmatrix}
$$
所以对于n
,我们有
$$
\begin{bmatrix}
F_{n}\
F_{n-1}
\end{bmatrix}=\begin{bmatrix}
1& 1\
1& 0
\end{bmatrix}^{n-1}*\begin{bmatrix}
F_{1}\
F_{0}
\end{bmatrix}
$$
所以只要求解出幂次部分就可以求解Fn
,而幂次部分求解可以使用快速幂公式,如下
$$
T^{n} = T^{n/2}*T^{n/2}, n为偶数,\
T^{n} = T^{n/2}*T^{n/2}*T, n为奇数,
$$
这说明要求解$T^n$只需求解$T^{n/2}$即可,而求解$T^{n/2}$,只需求解$T^{n/4}$,依次递推,我们可以发现整个幂运算其实就是不断二分,时间复杂度就是二分法的时间复杂度,为O(lgn)
.
知道了什么是矩阵快速幂,我们可以很容易得写出对应的类型代码:
1 | type Matrix = [number, number, number, number]; // [a, b, c, d] represents the matrix [[a, b], [c, d]] |
总结
本文给出了三种斐波那契序列算法的类型编程实现方式,最终的代码可以在查看这个playground。
虽然TS的类型编程很强大,但是在编写的时候,我不得不指出一些体感不好的地方,比如很难debug,使用递归会经常遇到”type instantiation is excessively deep and possibly infinite" error.“
报错,这个时候就得通过infer
来定义一个局部类型,再给这个类型加一个条件判断来解决,这会让代码更加复杂难懂。除此之外,我觉得类型编程的性能不高,而且难以衡量,比如当你想计算更大的n
的时候,上述给出的三种方法可能会算不出来,返回一个any
类型。如果想要看结果,只能通过把鼠标hover到这个类型上查看,没有一个console.log之类的方法打印出来。