TS 类型体操笔记 - 2257 MinusOne

540 阅读10分钟

概述

类型体操 是关于 TS 类型编程的一系列挑战(编程题目)。通过这些挑战,我们可以加深对 TS 类型系统的理解,举一反三,在实际工作中解决类似问题。

本文是我对 2257 MinusOne 的解题笔记。这个题比较有意思,有多种解题思路,涉及到 TS 类型系统下的简单数字计算、尾递归、字符映射等知识点,还需要了解到 TS 编译器现在的一些实现细节。

本文的读者是正在做 TS 类型体操,对 2257 MinusOne 题目感兴趣,希望获得更多解读的同学。读者需要具备一定的 TS 基础知识,这部分推荐阅读另一篇优秀博文 Typescript 类型编程,从入门到念头通达😇

题目

Given a number (always positive) as a type. Your type should return the number decreased by one.

For example:

type Zero = MinusOne<1> // 0
type FiftyFour = MinusOne<55> // 54

看到题目,快速思索了一下,TS 类型系统中确实是没有标准的数学运算能力的。但我们有构造数字的能力,那就是取元组的长度,如 [1,1,1]['lenght'] // 3。如此,我们可以像原始人一样,通过往篮子里加减东西,最后数数的方式来实现基础的加减运算。

思路一:递增计数

考虑这样一些有利因素

  • 我们提取元组的长度来获取具体的数字,如 [1,1,1]['lenght'] // 3
  • 我们可以通过增加确定个数的元素来增长元组,如 [1, ...Tuple] // 元组长度加 1

如此,我们可以利用递归,从一个空元组开始逐步往上数,数到目标数字减 1 的时候为止,然后返回这个元组的长度。

// U 是用来计数的元组,从 0 开始,数到多少,U 中就有多少个元素
type MinusOne<T extends number, U extends 1[] = []> =
  T extends 0 // 处理结果为 -1 的边界情况
    ? -1
    : [1, ...U]['length'] extends T // U.length + 1 等于目标数字 T 吗?(递归的终止条件)
      ? U['length'] // U.length === T - 1,这就是需要返回的答案
      : MinusOne<T, [1, ...U]> // 元组中的元素加 1,继续递归

playground

上述代码思路简洁清晰,能通过大部分测试用例,但却过不了 Expect<Equal<MinusOne<1101>, 1100>> 这个测试用例。

有些 issue 探讨了 TS 类型系统中的 尾递归 问题。仔细阅读上述文章后可以确认,上面的代码实际上是满足尾递归优化的,不然的话,连数字 50 的测试用例都过不了。实际上,上述代码不能通过数字 1101 的测试,是因为即使满足尾递归优化,目前的 TS 编译器仍然设置了 1000 的递归限制以避免出现死循环、大量资源消耗等情况,详情可参考 这个 PR

还有一些 issue 通过 0 extends 1 ? never : ... 这样的奇怪方式来『绕开』1000 的限制,但实际上这只是现在 TS 版本下的 hack 或 bug,不推荐作为解题手段。

那我们怎样才能解决这个问题呢?

算法增强:一次递归增加 2 个计数

在上述方案中,我们在一次递归中只增加了 1 个计数,所以算数字 1000 就需要 1000 次递归。如果我们在一次递归中能增加 2 个计数,那计算数字 1000 岂不就只需要 1000/2=500 次递归了?改造后的代码如下

// U 是用来计数的元组,从 0 开始,数到多少,U 中就有多少个元素
type MinusOne<T extends number, U extends 1[] = []> =
  T extends 0 // 处理结果为 -1 的边界情况
    ? -1
    : [1, 1, ...U]['length'] extends T // U.length + 2 等于目标数字 T 吗?
      ? [1, ...U]['length'] // 返回 U.length +1
      : [1, ...U]['length'] extends T // U.length + 1 等于目标数字 T 吗?
        ? U['length'] // U.length
        : MinusOne<T, [1, 1, ...U]> // 元组中的元素加 2,继续递归

playground

相比于原始方案,我们在一次递归中同时检查了元组尺寸加 1 和 加 2 两种情况,并在最后递归的时候将元组尺寸加 2 了,实现了一次递归增加 2 个计数的思路,所以这段代码已经可以通过所有测试了。

算法增强:一次递归增加 999 个计数

虽然我们已经解决了这个问题,但既然是做体操,不妨继续拉伸延展一下。

上述方案中,我们通过硬编码的形式实现了一次递归增加 2 个计数,但如果还不够用,要增加 3 个计数呢?要增加 99 个计数呢?如果继续硬编码,不仅代码写起来一言难尽,而且也非常不便于调整。

我们能否实现一个方案,将这个计数参数化出来,能方便地调节它的大小呢?

一番努力后得到了如下方案

// 以 O(n) 的算法复杂度构造一个指定长度的普通元组
type Tuple<L extends number, T extends 1[] = []> = T["length"] extends L
  ? T
  : Tuple<L, [...T, 1]>;

// 在 From 元组和 To 元组之间,找到一个满足指定长度的元组并返回,如果找不到,返回 never
type FromToTuple<L extends number, From extends 1[] = [], To extends 1[] = []> = From['length'] extends L
  ? From
  : From['length'] extends To['length']
    ? never
    : FromToTuple<L, [1, ...From], To>

// 准备一个常量元组,表示每轮递归时增加的计数量
// 如果要改变步长,则改变这里的数字即可
type TupleMax = Tuple<999>

// 构造一个指定长度的元组
// 在每一轮递归中,增加一个较大的计数,这样可以减少触达目标数字时需要的递归次数
type SuperTuple<L extends number, C extends 1[] = [], P extends 1[] = []> = C["length"] extends L
  ? C
  : FromToTuple<L, P, C> extends never
    ? SuperTuple<L, [...TupleMax, ...C], C>
    : FromToTuple<L, P, C>

// 增加了一个 Minus 抽象,使得最终的 MinusOne 算法看起来更清晰些
type Minus<A extends number, B extends number> = SuperTuple<A> extends [ ...SuperTuple<B>, ... infer R extends 1[]] ? R['length'] : never

type MinusOne<T extends number> =
  T extends 0
    ? -1
    : Minus<T, 1>

playground

核心是

  1. 使用 SuperTuple 代替普通的 Tuple,在其每一轮递归中,增加 TupleMax 个计数,以减少需要的递归次数
  2. 而在每一轮递归中,基于 TupleMax 的长度去挨个找夹在这一轮的上界和下界的任务,则由 FromToTuple 去承担,避免硬编码

思路二:数字元组映射 + 元组尺寸倍增

我没有想到这种思路,但在翻阅形形色色的答案时发现总体上还有这样一路方案,以 @Chen0x00 的这个 issue 为代表,我们考虑这样一些有利条件

  • 数字的每一位是一个有限集合,即,0-9,我们可以原先创建它们对应的元组,如 [][1,1,1,1,1,1,1,1,1]
  • 元组尺寸的变为 10 倍,是一个固定的不大的数字,实现起来比较容易,效率也很高,如 [...A, ...A, ...A, ...A, ...A, ...A, ...A, ...A, ...A, ...A]

如此,我们可以将

  1. 将目标数字的每一位都拆开,变成一个数组
  2. 每一位数字映射为对应的元组,并通过倍增技术,将其尺寸调整为与数字所在位置相匹配
  3. 将这个数组中的所有元组展开,形成一个新的元组,并返回其尺寸,即为目标数字
  4. 减 1

简单分析一下,最外层递归数与数字位数一致,数字的位数也就个位数级别,所以这个量并不大。每次递归对每一位数字的处理,映射和倍增都没有产生额外的递归。总的来说,效率不错,也能支撑足够大的数字,是一个不错的思路。

附上代码

// from https://github.com/type-challenges/type-challenges/issues/2586

type Digital = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
type MakeDigitalArray<
  N extends Digital,
  T extends any[] = []
> = N extends `${T['length']}` ? T : MakeDigitalArray<N, [...T, 0]>
type Multiply10<T extends any[]> = [...T,...T,...T,...T,...T,...T,...T,...T,...T,...T]

type ToArray<
  S extends number|string,
  T extends any[] = []
> = `${S}` extends `${infer F}${infer L}`
      ? F extends Digital
        ? ToArray<L, [...Multiply10<T>, ...MakeDigitalArray<F>]>
        : never
      : T

type Minus<
  S extends number,
  N extends number
> = ToArray<S> extends [...ToArray<N>, ...infer L] ? L['length'] : 0

type MinusOne<S extends number> = Minus<S, 1>

思路三:基于符号的模拟计算 + 字符串与数字互转

来自于 @elias-prine 的这个 issue,是目前看到最高效、可支撑数字最大的解决方案。

其核心思路有两点:

  1. 基于符号的模拟计算
  2. 字符串与数字互转

基于符号的模拟计算

什么意思呢?之前的方案中,我们都是使用元组来计数和做运算,然后通过 length 获取元组的尺寸。差异主要是如何构造这个元组,但无论如何,我们都必须要有这个元组来做物理支撑,就像结绳记事的古代人,数字越大,我们就必须要有一个同样大的元组。

但实际上,我们在学习超过 10 的加减法时,就已经不用受限于手指头的数量了,为啥?因为我们用抽象的数字符号和一套运算法则就可以在纸上轻松完成任务。同理,虽然 TS 的类型系统没有天然地提供数字计算能力,但如果需要创造的算法足够简单(比如这里的减 1),我们仍然可以考虑基于数字符号来对这套算法进行模拟。

仔细想想,其实减 1 这个问题挺好模拟的:

  1. 对一个数字,从右往左,逐位计算
  2. 如果当前位需要减 1,就将当前位的数字减 1 并替换上去即可;由于每一位数字无非就是 0-9 的有限集合,所以减 1 的结果可以直接通过映射来表示,比如,0-1=9, 1-1=0, 2-1=1, 3-1=2, ... 9-1=8
  3. 如果需要减 1 的这一位是 1-9,则在这一位做映射之后就结束了;否则(当前位是 0),当前位减 1 后,还需要继续将左侧的数字进行减 1(递归)

例如,18 减 1,先对 8 进行减 1,得 7,由于 8 属于 1-9,所以运算结束,最终结果就是 17。

再例如,520 减 1,先对 0 进行减 1,得 9,由于 0 不属于 1-9,所以还要继续对左侧的 52 减 1,得 51,最终结果拼起来就是 519。

字符串与数字互转

上述算法无论是基于字符串还是数组都能实现,关键是,入参和出参是数字,这里存在一个转换问题。

以字符串为例,在 template literal 的加持下,数字转字符串很容易,但计算完毕后,如何将结果字符串转为数字呢?

在 TS 4.8 以前,似乎仍然只能借助元组来数数,但这就和上面的思路没什么区别了。所幸的是,TS 4.8 增加了在模板字符串中推导变量类型的能力,现在,我们可以轻松地将字符串转为数字了。

代码

最终的代码基于 @elias-prineissue,做了适当调整,以便阐述

type NumberLiteral = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'

// 数字减 1 之后对应的数字
type MinusOneMap = {
  '0': '9',
  '1': '0',
  '2': '1',
  '3': '2',
  '4': '3',
  '5': '4',
  '6': '5',
  '7': '6',
  '8': '7',
  '9': '8',
}

// 字符串相关的工具函数

// 将字符串反转,例如,'abcd' => 'dcba'
type ReverseString<S extends string> = S extends `${infer First}${infer Rest}` ? `${ReverseString<Rest>}${First}` : ''

// 移除字符串开头的 0,例如 '00999' => '999'
type RemoveLeadingZeros<S extends string> = S extends '0' ? S : S extends `${'0'}${infer R}` ? RemoveLeadingZeros<R> : S

// 获取字符串排除最后一个字符的部分,例如 'abcd' => 'abc'
type Initial<T extends string> = ReverseString<T> extends `${infer First}${infer Rest}` ? ReverseString<Rest> : T

// 将字符串解析为数字,例如 '123' => 123
type ParseInt<T extends string> = RemoveLeadingZeros<T> extends `${infer Digit extends number}` ? Digit : never

// 基于一个数字的字符串格式,进行减 1 操作
// 本题的核心算法体现在这里
type MinusOneForString<S extends string> =
  S extends `${Initial<S>}${infer Last extends NumberLiteral}` // 取出最右侧的一位数字
    ? Last extends '0'
      ? `${MinusOneForString<Initial<S>>}${MinusOneMap[Last]}` // 如果是 0,则对当前位减 1 后,还要递归地对左侧的数字进行减 1
      : `${Initial<S>}${MinusOneMap[Last]}` // 如果不是 0,则对当前位减 1 后,直接返回最终结果
    : never

type MinusOne<T extends number> = 
  T extends 0 // 处理 T 为 0 这种边界情况,毕竟我们的计算器不支持负数
    ? -1 
    : ParseInt<MinusOneForString<`${T}`>>

playground

参考