TS 类型体操笔记 - 296 Permutation

1,650 阅读7分钟

概述

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

本文是我对 296 Permutation 的解题笔记。主要记录了

  • 题目与答案
  • 解读全排列问题的基础算法(Remove 算法)
  • 相关的 TS 知识点

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

题目与答案

先直接贴出题目与答案,呈现问题全貌。

题目

296 Permutaion

Implement permutation type that transforms union types into the array that includes permutations of unions.

type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C',

答案

type Permutation<All, Item = All> = 
  [All] extends [never] 
    ? []
    : Item extends All
      ? [Item, ...Permutation<Exclude<All, Item>>]
      : never

逐步解题

确定解题算法

开始解题的第一步,不是考虑要堆叠哪些 ts 特性,而是要选择解题算法。

全排列问题看似简单,但实际上是一个可以深挖很多的问题。但在『解决当前问题』这个上下文中,我们只需要找到一个合适的算法就可以了。

在哪里找算法呢?推荐阅读这篇文章:Generating Permutations - Topcoder ,它列举了这些算法

  • BASIC ALGORITHM 1: REMOVE(基础算法之 删除 算法)
  • BASIC ALGORITHM 2: INSERT(基础算法之 插入 算法)
  • BASIC ALGORITHM 3: LEXICOGRAPHIC(基础算法之 字典序 算法)
  • HEAP’S ALGORITHM(高级算法之 堆 算法)

选哪个算法呢? TS 类型编程中选算法,可以考虑这样一些关注点:

  • 声明式:结果是用表达式定义出来的,而不是对一堆变量不断修改调出来的
  • 递归:正确 + 清晰 > 高效
  • 简单

此处比较适合的算法是『基础算法之 删除 算法』

算法解读:全排列问题 基础算法之 删除 算法

这是一种分治 + 递归的简单算法。

我们先来观察一下元素集合 (1,2,3) 的全排列结果都有哪些:

  • [1, 2, 3]
  • [1, 3, 2]
  • [2, 1, 3]
  • [2, 3, 1]
  • [3, 1, 2]
  • [3, 2, 1]

我们将这些结果按首位元素进行分组:

首位元素排序方式排序方式(去掉首位)
1[1,2,3][2,3]
[1,3,2][3,2]
2[2,1,3][1,3]
[2,3,1][3,1]
3[3,1,2][1,2]
[3,2,1][2,1]

我们发现,分组后,在每一组中,如果将每一个排序方式的首位元素都去掉,那么这一组实际上就是初始元素集合排除掉分组元素后剩下的元素所形成的全排列(这也就是算法为什么叫 Remove 的原因)。

所以,整个算法可以表达为

import { without } from 'lodash';

type Element = any;
type Arrangement = any[];
type Permutation = (elements: Element[]) => Arrangement[];

const permutation: Permutation = (elements) =>
  elements.length === 1
    ? [elements]
    : elements
        .map((element) =>
          permutation(without(elements, element)).map((arrangement) => [
            element,
            ...arrangement,
          ])
        )
        .flat();

开始:处理起始与终止

选择并理解了我们要采用的算法之后,我们就可以开始将算法应用到 TS 世界来解题了。首先,建立一个起始状态

type Permutation<T> = []

考虑算法的终止条件

  • 当元素集合为空时,即 Tnever 时,我们返回 []
  • 当元素集合只有 1 个元素时,我们返回 [这个元素]
type Permutation<T> = 
  T extends never
    ? []
    : [T] // 这里不急,接下来会扩展

这样写有问题,如果测试代码就会发现 Permutation<never> 实际上返回的是 never,并非我们期望的 []

稍作修改,正确的写法是,将 T extends never 改为 [T] extends [never]

type Permutation<T> = 
  [T] extends [never]
    ? []
    : [T] // 这里不急,接下来会扩展

为什么这样改呢?可参考相关 TS 知识点 如何判断泛型类型 Tnever

继续:当元素集合中有不止一个元素时……

目前的代码,已经可以处理 0 个和 1 个元素的情况,当元素集合中有不止一个元素时,即 T 为联合类型时,我们需利用 分布式条件类型 对联合类型中的每一项都进行处理

type Permutation<T> = 
  [T] extends [never]
    ? []
    : T extends T
      ? [T]
      : never

出现了奇怪的语法,T extends T 是什么?

简单说,这是个用于遍历联合类型的固定模式,在 T extends T ? [T] : never 中,T 是联合类型中的每一个具体项,而最终结果是每一个 [T] 的联合。例如,此时若调用 Permutation<1 | 2>,其返回的结果会是 [1] | [2]

具体可参考相关 TS 知识点 如何遍历/map 一个联合类型

继续:增加递归

根据算法,若初始联合类型是 T,其中的每一项是 Ti,则针对每一项 Ti,我们真正需要的并不是 [Ti],而是 [Ti, ...Permutation<Exclude<T, Ti>>]

根据定义,Permutation<...> 是一个元组的联合类型,形如 [...] | [...] | ...。当我们将其展开的时候,其效果也是 distributive 的,即,假设 PTPermutation<Exclude<T, Ti>> 从联合类型转化而成的元组类型,即 [[...], [...], ...],则 [Ti, ...Permutation<Exclude<T, Ti>>] 实际上等价于 [Ti, ...PT[0]] | [Ti, ...PT[1]] | ...,这不仅在类型上是正确的,也正是我们需要的结果。

为什么会这样?可参考相关 TS 知识点 解构元组的联合时的 distributive 效果

综上,我们继续将代码改造为

type Permutation<T> = 
  [T] extends [never]
    ? []
    : T extends T
      ? [T, ...Permutation<Exclude<T, T>>]
      : never

嗯,Exclude<T, T> 一看就有问题,主要是,T extends T 之后,T 实际上代表的是联合类型中的每一个具体项了,但我们实际上希望表达的是 Exclude<T, Ti>,也就是从总的联合类型中排除掉当前迭代中的 Ti 这一项而已。

继续改造,增加一个泛型变量 K,用它来代表 Ti,这样原本的 T 就可以保留下来了。

type Permutation<T, K=T> = 
  [T] extends [never]
    ? []
    : K extends K
      ? [K, ...Permutation<Exclude<T, K>>]
      : never

结束:润色代码

上述代码已经可以通过测试了。但 T, K 有些含义不明,适当润色一下

type Permutation<All, Item = All> = 
  [All] extends [never] 
    ? []
    : Item extends All
      ? [Item, ...Permutation<Exclude<All, Item>>]
      : never

好了,大功告成。

相关 TS 知识点

(未来,这部分可拆成单独的文章,方便引用)

分布式条件类型

官方说明:TypeScript: Documentation - Conditional Types

条件类型 中使用 泛型参数 时,如果泛型参数是 联合类型,则会产生 distributive 的效果。

什么是 distributive 效果?我画了张图来说明

image.png

解构元组的联合时的 distributive 效果

当我们解构元组的联合时,也会出现如同分布式条件类型那样的 distributive 效果(暂时没找到官方说明)。

demo playground

示例如下

type A = [1, 2] | [3, 4]
type B = ['a', 'b'] | ['c', 'd']

type C = [true, ...A, ...B]
// [true, 1, 2, "a", "b"] | [true, 1, 2, "c", "d"] | [true, 3, 4, "a", "b"] | [true, 3, 4, "c", "d"]

image.png

如何判断泛型类型 Tnever

在 TS 中,如果要判断一个泛型参数 T 是否是 never, 需要在 extends 语句中,将两边的类型用方括号包起来。 例如

// ❌
type IsNever<T> = T extends never ? true : false

// ✔
type IsNever<T> = [T] extends [never] ? true : false

为什么呢?

首先要理解 分布式条件类型

在分布式条件类型中,T extends ... 已经是在描述对联合类型中的某一个具体项的处理了,而在下面这个错误的例子中

// ❌
type IsNever<T> = T extends never ? true : false

type Result = IsNever<never>

IsNever<never> 中的 never 实际上是一个空的联合类型,一项都没有,所以 T extends ... 过程实际上被整体跳过了,所以最后的结果就是 never

因此,为了能判断 T 是不是 never,我们需要关闭 distributive 效果,具体方法前文已经提过了,就是在 extends 语句中,将两边的类型用方括号包起来。出处可参考 TypeScript: Documentation - Conditional Types 这篇文章的最后一段:

image.png

如何遍历/map 一个联合类型

思路:利用 分布式条件类型,构造一个泛型类型对输入类型进行映射,获得输出类型

例如

type A = 1 | 2 | 3

// 希望基于 A 再构造一个类型 B,值为 A 中每一项对应的元组,也就是 [1] | [2] | [3]

// 构造一个泛型类型作为中间映射函数(因为分布式条件类型只有在泛型 + 条件判断时才生效)
type MyMap<T> = T extends T ? [T] : never

type B = MyMap<A>
// [1] | [2] | [3]

此处,T extends T ? XXX : never 可以看做是固定模式。

(其实语法上有很多多余之处,比如,第二个 Tnever 其实 没什么用,但在现在的 ts 版本中(4.9),我们没有什么更好的办法。)

参考