前言
刚做完类型体操 Sum (我的答案)和 Multiply(我的答案),我本以为自己的解答已经很优雅了,毕竟严格照搬了从幼儿园、小学起就已经学得滚瓜烂熟的中国人经典数学算法,而且也尽量做了简化、优化。
直到我翻看到 Sobes76rus 同志的真·优雅解答(#5814),我直接蚌埠住了。代码很短,但是我却看了很久,一遍遍地确认逻辑正确且解答了问题,并一遍遍地擦去心中的怀疑。然后,我又沉思了良久,为什么如此少量的逻辑,却抵得上别人一吨的代码呢?我的结论是,这位同志是真正地从问题和环境的 第一性 出发,去构造问题的解,而不像我们,只是懒惰地翻了翻脑海中的工具箱,在经验与启示中不断地进行拼凑组合以尝试各种可能性,最后得到的当然是能解决问题也符合认知但充斥着冗杂与多余细节的布鞋。
第一性
在 Typescript 的类型系统中是没有数学计算的,所以在解相关问题时,我们常常利用一些便利设施,比如 tuple,去构造一些辅助工具,比如加减、倍化、映射等,然后在各种数据结构中转来转去,以平衡效率,避免炸题。
但如果从第一性开始思考这些问题呢?
Typescript 类型系统的第一性是什么呢?大概是:
- 符号(字符串,可以拼接,可以提取,可以匹配)
- 递归
那解决这些数学问题的第一性又是什么呢?大概是:
- 等值比较
- +1 -1
少的可怜,但足够了。从它们开始思考解决方案,能避免掉入高层思维的陷阱中。我们习以为常的日常数学算法,是基于人脑的思考便利来设计的,放到计算机上,也能解决问题,但就不那么『优雅』了。
Sum 问题
题目
Implement a type Sum<A, B> that summing two non-negative integers and returns the sum as a string. Numbers can be specified as a string, number, or bigint.
For example,
type T0 = Sum<2, 3> // '5'
type T1 = Sum<'13', '21'> // '34'
type T2 = Sum<'328', 7> // '335'
type T3 = Sum<1_000_000_000_000n, '123'> // '1000000000123'
题目分析
挑战点是需要支持大数相加。
从第一性去考虑,我们只有:符号、递归、+1,那么做 A + B,只需要 A +1+1+1+1+1…… 一直加 B 次就可以了。
有人可能会说效率如何如何。做类型编程,首要考虑的不是执行效率,而是表达正确、清晰,并且不会因为很深或甚至无穷递归压垮编译器即可,所以简洁、优雅的代码胜过复杂、貌似高效的代码。
至于如何表达数字,直接基于符号(也就是字符串)即可,这样处理逻辑比较统一,也能支持大数表达。当然,由于算法层面我们需要从低位对齐,所以我们在运算中实际上是用逆序字符串来表达数字。
当然,在 TS 类型变成中,+1 -1 也是需要我们去实现的。但结合数的表达形式,它们的实现方式会不同。此时,我们只需要实现基于逆序字符串的数字的版本即可。
解答
结合上述分析,Sobes76rus 同志提供的解决方案如下
type Reverse<A extends string | number | bigint> = `${A}` extends `${infer AH}${infer AT}` ? `${Reverse<AT>}${AH}` : ''
type DigsNext = {'0': '1', '1': '2', '2': '3', '3': '4', '4': '5', '5': '6', '6': '7', '7': '8', '8': '9'}
type DigsPrev = {[K in keyof DigsNext as DigsNext[K]]: K}
// for reversed string format positive number
type AddOne<A> =
A extends `${infer AH}${infer AT}`
? AH extends '9'
? `0${AddOne<AT>}`
: `${DigsNext[AH & keyof DigsNext]}${AT}`
: '1'
// for reversed string format positive number
type SubOne<A> =
A extends `${infer AH}${infer AT}`
? AH extends '0'
? `9${SubOne<AT>}`
: `${DigsPrev[AH & keyof DigsPrev]}${AT}`
: never
// for reversed string format positive numbers
type Add<A, B> =
A extends `${infer AH}${infer AT}`
? B extends `${infer BH}${infer BT}`
? BH extends '0'
? `${AH}${Add<AT, BT>}`
: Add<AddOne<A>, SubOne<B>>
: A
: B
type Sum<A extends string | number | bigint, B extends string | number | bigint> = Reverse<Add<Reverse<A>, Reverse<B>>>
算法很质朴,计算很暴力,但代码却很优雅且有效。
算法中有个比较巧妙的地方,就是『拆东墙补西墙』,直接将 B 减下来并加到 A 上,这样 A 就起到了 ACC 的作用。
如果要挑一下骨头的话,Reverse 并非 尾递归, Add 在对每一位的相加时属于尾递归,但在进位时 并非 尾递归,这样的话,如果字符串较长,比如超过 50 位的话,编译器就会报错了(带报错的 playgorund)。
先实现,再优化,上述版本更好理解一些,如果已经理解了上述代码,我们可以进一步将 Reverse 和 Add 优化成尾递归版本,以支持更大范围的数值计算
// reverse a string, optimized as tail-recursion
type Reverse<A extends string | number | bigint, _Result extends string = ''> =
`${A}` extends `${infer AH}${infer AT}`
? Reverse<AT, `${AH}${_Result}`>
: _Result
type DigsNext = {'0': '1', '1': '2', '2': '3', '3': '4', '4': '5', '5': '6', '6': '7', '7': '8', '8': '9'}
type DigsPrev = {[K in keyof DigsNext as DigsNext[K]]: K}
// for reversed string format positive number
type AddOne<A> =
A extends `${infer AH}${infer AT}`
? AH extends '9'
? `0${AddOne<AT>}`
: `${DigsNext[AH & keyof DigsNext]}${AT}`
: '1'
// for reversed string format positive number
type SubOne<A> =
A extends `${infer AH}${infer AT}`
? AH extends '0'
? `9${SubOne<AT>}`
: `${DigsPrev[AH & keyof DigsPrev]}${AT}`
: never
// for reversed string format positive numbers, optimized as tail-recursion
type Add<A, B extends string, _Prefix extends string = ''> =
A extends `${infer AH}${infer AT}`
? B extends `${infer BH}${infer BT}`
? BH extends '0'
? Add<AT, BT, `${_Prefix}${AH}`>
: Add<AddOne<A>, SubOne<B>, _Prefix>
: `${_Prefix}${A}`
: `${_Prefix}${B}`
type Sum<A extends string | number | bigint, B extends string | number | bigint> = Reverse<Add<Reverse<A>, Reverse<B>>>
Multiply 问题
题目
This challenge continues from 476 - Sum, it is recommended that you finish that one first, and modify your code based on it to start this challenge.
Implement a type Multiply<A, B> that multiplies two non-negative integers and returns their product as a string. Numbers can be specified as string, number, or bigint.
For example,
type T0 = Multiply<2, 3> // '6'
type T1 = Multiply<3, '5'> // '15'
type T2 = Multiply<'4', 10> // '40'
type T3 = Multiply<0, 16> // '0'
type T4 = Multiply<'13', '21'> // '273'
type T5 = Multiply<'43423', 321543n> // '13962361689'
题目分析
挑战点是如何支持大数相乘。
做这个题时,我们手里已经有了可靠的 Sum, AddOne, SubOne 等数学工具,在第一性思考的基础上,配合它们,可以事半功倍。
如果说上述解 Sum 的方案让我眼前一亮的话,那么 Sobes76rus 同志解 Multiply 的方案则使我大吃一惊,万万没想到这么少的代码增量就可以从 Sum 解决 Multiply 问题。当然,这背后绝非代码写得多压缩、晦涩,而是思路、算法上的大道至简。
在思考这个问题时,我脑海里一直反复吟唱着的是『面向中国人』的经典乘法算法:
- 对于单个数字,使用 99 乘法表:一一得一,一二得二……
- 一个多位数字乘以单个数字,则用单个数字,对着多位数字从低位往高位相乘,补零,相加,balabla……
- 一个多位数字乘以另一个多位数字,则 balabala……
虽然我最终将其理清并面向编程做了一定的优化,但我已经早早地离解答这个问题的第一性越来越远了。
其实,在思考算法的过程中,有那么一刹那,我问了问自己,理解乘法需要这么复杂吗?当我还是个小 baby 的时候,最初是怎样去理解乘法的?妈妈说,A 乘以 B,就是 B 个 A 相加……没错,乘法的本质就是如此简单,寥寥数语就说清楚了,这是我离本题的第一性最近的时刻。
可惜,我下意识地和它擦肩而过了『怎么可能这样去加,那得加多少次……』
没错,人类这样去算乘法,那就加死了,但机器不在乎,而如此这番,我却能用最少的语言去告诉机器如何做乘法。只要小心做好尾递归的处理,让有限的空间不至于爆炸,那么这点计算量对机器来说是微乎其微的。
没有如果。我们错过的这条路,就是 Sobes76rus 同志的神来之路。
解答
结合上述分析,Sobes76rus 同志提供的解决方案如下
// ...上面是 Sum 的所有代码...
type Mul<A extends string, B extends string, _ACC = '0'> =
A extends '0' ? _ACC :
B extends '0' ? _ACC :
A extends `${infer AH}${infer AT}`
? AH extends '0'
? Mul<AT, `0${B}`, _ACC>
: Mul<SubOne<A>, B, Add<_ACC, B>>
: _ACC
type Multiply<A extends string | number | bigint, B extends string | number | bigint> = Reverse<Mul<Reverse<A>, Reverse<B>>>
That's all,这段代码忠实地翻译了妈妈的话:『A 乘以 B,就是 B 个 A 相加』。
当然,这段算法并非完全粗暴,而是有关窍的。它用 B 做计数器,当它的最低位扣减到 0 之后,在下一轮累加开始之前,将这个 0 挪到了 A 上(相当于 B=B/10, A=A*10,完全合法),从而缩减了计算量,让算法复杂度从 O(n) 降到了 O(m)(m 是 n 的位数),相当于本来要算 10000 次,现在算 5 次就可以了。这个关窍是必不可少的。复杂度下来了,代码仍然优雅,这就是算法的魅力所在。(这个 playground 演示去掉了上述优化,代码少了 2 行,但暴增的计算量使其并不实用)
上述算法是全面尾递归的,可以从 playground 中看到,配合优化过的 Sum 相关代码,即使补充了超长的数字相乘,仍然可以得出正确的结果,善哉。
尾记
真正优雅的代码,内核是优雅的算法,外在是清晰的组织。
我看了太多所谓『短小』『精干』『onliner』的代码,大多不过是形式上缩短命名,压缩格式,拧巴逻辑,让代码变得晦涩难懂,其实一点算不上优雅,如果只是追求极致的篇幅少,那干嘛不把代码 terse 一下呢?。优雅的代码,人看了赏心悦目,悦目在于其组织清晰,串联自然,信息密度适中,读起来清新舒畅;赏心在于,逻辑正确,算法优雅,在理解时给人以妙不可言之感,茅塞顿开之觉。
于我自己,之后面对类似挑战,如果觉得方案不够优雅,可多问问自己,我的思路是从哪个层次出发的?是第一性吗?
好的设计大多是美的。