概述
类型体操 是关于 TS 类型编程的一系列挑战(编程题目)。通过这些挑战,我们可以加深对 TS 类型系统的理解,举一反三,在实际工作中解决类似问题。
本文是我对 14080 FizzBuzz 的解题笔记。
本文的读者是正在做 TS 类型体操,对上述题目感兴趣,希望获得更多解读的同学。读者需要具备一定的 TS 基础知识,这部分推荐阅读另一篇优秀博文 Typescript 类型编程,从入门到念头通达😇。
题目和答案
先直接贴出题目与答案,呈现问题全貌。
题目
The FizzBuzz problem is a classic test given in coding interviews. The task is simple:
Print integers 1 to N, except:
- Print "Fizz" if an integer is divisible by 3;
- Print "Buzz" if an integer is divisible by 5;
- Print "FizzBuzz" if an integer is divisible by both 3 and 5.
For example, for N = 20, the output should be: 1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz, 16, 17, Fizz, 19, Buzz
In the challenge below, we will want to generate this as an array of string literals.
For large values of N, you will need to ensure that any types generated do so efficiently (e.g. by correctly using the tail-call optimisation for recursion).
答案
type FizzBuzzOne<
C extends number,
C3 extends number,
C5 extends number,
_FB = `${C3 extends 3 ? 'Fizz' : ''}${C5 extends 5 ? 'Buzz' : ''}`
> = _FB extends '' ? `${C}` : _FB
type FizzBuzz<
N extends number,
_R extends string[] = [],
_CT extends unknown[] = [unknown],
_C3T extends unknown[] = [unknown],
_C5T extends unknown[] = [unknown]
> =
_R['length'] extends N
? _R
: FizzBuzz<
N,
[..._R, FizzBuzzOne<_CT['length'], _C3T['length'], _C5T['length']>],
[..._CT, unknown],
(_C3T['length'] extends 3 ? [unknown] : [..._C3T, unknown]),
(_C5T['length'] extends 5 ? [unknown] : [..._C5T, unknown])
>
解题思路
题目贴心地强调了要注意效率,注意使用尾递归,所以我们可以更容易做类似决策。
这个题有两种解题方向(宏观思路):
- 自上而下拆解:对每一个数字,调用对应的判断逻辑,而每个逻辑独立地进行封装和进一步拆解
- 自下而上构建:在每一轮迭代中,同时暴露、处理各个字逻辑的底层细节
一般情况下,更推荐使用第一种思路,这样能够有效控制每一个模块的复杂度,更容易拆解、实现、理解、维护。
但在做这题时,我选择了第二种思路,理由是
- 子问题之间的重复性比较多
- 经过分析,我们是可以在一轮迭代中同时处理所有子问题
- 总的问题规模、复杂性有限(我的大脑尚且还可以全部加载、理解、处理 😅)
这样做的好处是,最终解会精干一些,但确实增加了直接理解的难度。(好了,既然做了选择就不唧唧了,接着干)
问题分析
问题的基本要素有
- 输入一个 N
- 输出 N 个结果,每个结果对应 1...N 中的一个数字
所以
- 我们至少需要遍历一次 1...N
- 需要构造任意数字 x => result 的映射逻辑
这个映射逻辑是判断 x 能否被数字 y 整除。由于 ts 类型系统中我们不能直接进行数学运算,所以这个判断需要降级为通过计数来实现:配置两个计数器,一个从 1 数到 x,另一个伴随着从 0 数到 y,每数到 y 就清空。这样,如果主计数器数到 x 时,整除计数器是是空的,说明 x 能被 y 整除。
这里就注意到了有趣现象:问题的主干需要一个主计数器,而判断整除的算法也需要一个主计数器,所以我们可以只配置一个主计数器,在同一轮计数的过程中同时解决整除 3 和整除 5 的问题,只需要在主计数器旁额外增加两个计数器就可以了。
算法设计
经过问题分析,我们大概确定需要这样一些参数或变量:
- 参数 N
- 变量 R,用于保存结果
- 变量 C,主计数器
- 变量 C3,整除 3 的计数器
- 变量 C5,整除 5 的计数器
大致描述一下算法:
- 输入参数 N
- 在每一轮中,同时增加 C,C3,C5,取到当前的数字 x
- 通过 C3、C5 判断 x 是否能被 3 或 5 整除,由此将 x 映射为 result(Fizz 或 Buzz 或 FizzBuzz 或数字),并将结果保存到 R
- 当 1...N 都处理完成后,返回 R 即可
代码分析
结合问题分析和算法设计,代码理解起来应该比较通畅了。这里对几个细节略做补充
FizzBuzzOne封装了将 x 映射为 result 字符串的逻辑,因为这些逻辑和主算法不在一个抽象层次,单独封装比较好- 主算法中,
_R['length'] extends N是迭代的终止判断条件