如何创建一个项目来愉快地刷 leetcode

1,158 阅读5分钟

个人在 leetcode 上也刷了不少题,最近打算用 typescript 来再次做以前用 javascript 完成的题目,看看近一年到底进步了没有。因此配置一个刷题的环境,支持单元测试和 typescript,并且能够帮我减少一些重复的类型定义,本文是记录项目创建的过程

项目链接:github.com/ZSX-DB/zsx-…

配置项目

  • 首先使用 yarn init 创建项目

  • leetcode 支持 typescript,目前如果你想用 typescript 进行刷题的话还是比较麻烦的,可能你需要先 tsc 编译成 js 文件再本地检验结果是否正确,因此来集成 typescript

    yarn add typscript --dev
    // 命令执行完就可以看到生成了 tsconfig.json
    npx tsc --init
    
  • 项目需要集成单元测试,以便判断题解的程序是否正确。我选用了 Jest 这个框架,注意需要在 tsconfig.json 的 types 数组下添加 "jest"

    // 添加 jest
    yarn add jest @types/jest --dev
    
  • 在 node 环境下你只能使用 CJS 模块,因此需要通过 babel 来配置 ES 模块,顺便通过 bebel 来配置支持 typescript

    yarn add --dev babel-jest @babel/core @babel/preset-env
    yarn add --dev @babel/preset-typescript
    

    然后在工程的根目录下创建一个 babel.config.js 文件用于配置与你当前Node版本兼容的Babel

    module.exports = {
        presets: [
            ['@babel/preset-env', { targets: { node: 'current' } }],
            '@babel/preset-typescript',
        ],
    };
    
  • 最后添加测试指令,在 package.json 添加下列代码

    "scripts": {
      "test": "jest"
    },
    

工程目录结构

zsx-leet (project name)
├── src
│   ├── tests    // tests 文件夹,存放测试代码,以 ***.spec.ts 命名
│   |   ├── 1.spec.ts    // 根据同名文件编写的测试代码
|   |   └── ...
│   ├── utils    // utils 文件夹,存放工具函数
│   ├── 1.ts    // 根据 leetcode 题目编号编写的解题代码
│   └── ...

导出常用类型

leetcode 有大量关于链表和二叉树等数据结构的题目,例如 function inorderTraversal(root: TreeNode | null): number[] {};, 我们总不能每一个相关的问题都定义一遍 TreeNode 吧。因此,最好全局导出,在项目根目录下建立 global.d.ts 文件,一处定义,全局使用

// global.d.ts
declare class ListNode {
    public val: number;
    public next: ListNode | null;
    constructor(val?: number, next?: ListNode | null);
}


declare class TreeNode {
    val: number;
    left: TreeNode | null;
    right: TreeNode | null;
    constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null);
}

转换函数

针对二叉树,leetcode 为了方便描述,抽象出的形式是一个包含了数字和 null 的数组,针对链表,则是一个纯数字数组,因此编写一个函数,将数组转换成正常的二叉树和链表,下面举例是链表的,二叉树比较复杂,具体地请看项目代码,都做好了注释当然。当然工具函数也做了单元测试,并且测试通过

// convert.ts
const toLinkedList = (list: number[]): ListNode | null => {
    const len: number = list.length
    if (len === 0) {
        return null
    }
    const initListNode: ListNode = { val: -1, next: null }
    const result: ListNode = { ...initListNode }
    let temp: ListNode | null = result
    for (let i = 0; i < list.length; i++) {
        temp.val = list[i]
        temp.next = (i !== len - 1) ? { ...initListNode } : null
        temp = temp.next
    }
    return result
}

// convert.spec.ts
test("toLinkedList", () => {
    expect(toLinkedList([])).toEqual(null)
    expect(toLinkedList([3, 2, 6])).toEqual({ val: 3, next: { val: 2, next: { val: 6, next: null } } })
    expect(toLinkedList([3, 1, 2, 4])).toEqual({ val: 3, next: { val: 1, next: { val: 2, next: { val: 4, next: null } } } })
})

Jest 使用

Jest 的具体使用可以去看文档,但我们只需要用到几个常用的 api 即可,toBetoEqual 在对应基本类型时无区别,如果函数返回的是引用类型,那么就只能使用 toEqual 了,这意味着 toEqual 适用所有情况,not 表示不等于,当你需要验证实际结果与预期结果不一致时就可以使用。

// https://leetcode-cn.com/problems/trapping-rain-water/
import trap from "../42";

test("42", () => {
    expect(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])).toBe(6)
    expect(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])).toEqual(6)
    expect(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])).not.toBe(5)
    expect(trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1])).not.toEqual(5)
})

如果一个题目 [100000, -100000][-100000, 100000] 都为正确,那么该怎么使用呢?这时就需要使用 toContainEqual 了。toContain 则同理,不过只适用于基本类型。

// https://leetcode.cn/problems/restore-the-array-from-adjacent-pairs/
import restoreArray from '../1743';

test("1743", () => {
    expect(restoreArray([[2, 1], [3, 4], [3, 2]])).toEqual([1, 2, 3, 4])
    expect([[-3, 1, 4, -2], [-2, 4, 1, -3]]).toContainEqual(restoreArray([[4, -2], [1, 4], [-3, 1]]))
    expect([[100000, -100000], [-100000, 100000]]).toContainEqual(restoreArray([[100000, -100000]]))
})

如果一个题目的答案是一个数组,数组内只要包含 1 2 3 4 就算正确,那么该怎么测试呢?还使用 toContainEqual 的话,就需要写 n * (n - 1) / 2 个可能答案了,数组长度少的话还可以,多了呢?因此,写一个函数来检测数组中所有的 item 是否一一对应

interface CheckItem {
    value: string
    match: boolean
}

/**
 * 适用于答案为数组,但顺序不固定的题目
 * @param items1
 * @param items2
 * @returns
 */
const detectItems = <T>(items1: T[], items2: T[]): boolean => {
    if (items1.length !== items2.length) {
        return false
    }
    const checkItems: CheckItem[] = items1.map(item => ({value: String(item), match: false}))
    for (const item of items2) {
        const idx: number = checkItems.findIndex(checkItem => checkItem.value === String(item) && checkItem.match === false)
        if (idx === -1) {
            return false
        } else {
            checkItems[idx].match = true
        }
    }
    return true
}

export {
    detectItems
}

使用也挺简单

https://leetcode.cn/problems/subsets-ii/
import subsetsWithDup from "../90";
import {detectItems} from "../utils/detect";

test("90", () => {
    expect(
        detectItems<number[]>(
            subsetsWithDup([1, 2, 2]), 
            [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]
        )
    ).toBeTruthy()
    expect(
        detectItems<number[]>(
            subsetsWithDup([0]), 
            [[], [0]]
        )
    ).toBeTruthy()
})

使用 yarn test 来测试代码是否正确,但通常我会使用 yarn test file_name,因为 yarn test 会测试 tests 文件夹下所有的代码是否正确,而我们通常只需要测试一小部分。对于测试用例的编写,一般只需要通过题目的示例,你就可以放心的提交了,如果不通过的话,分析原因,改正,把错误的用例添加进去

创建文件脚本

每次做一道题的时候,我们都需要创建两个文件,xxx.tsxxx.spec.ts ,然后再在里面写导出导入的代码,再加上 test("xxx", () => {}) 之类的模板代码,因此写一个脚本来自动创建文件。

"use strict";

const fs = require('fs');
const params = process.argv.slice(2);

if (params.length < 2) {
    throw new Error("You must enter enough parameters")
}

const [serial, code] = params;
const isFn = code.includes("function");

/** 可能为 function name 或 class name */
let exportName = "";
if (isFn) {
    for (let i = 9; i < code.length; i++) {
        if (code[i] === "(") {
            break;
        }
        exportName += code[i];
    }
} else {
    for (let i = 6; i < code.length; i++) {
        if (code[i] === " ") {
            break;
        }
        exportName += code[i];
    }
}

/** 用来判断使用 toBe 或 toEqual */
let type = '';
let toFn = '';
if (isFn) {
    const startIdx = code.indexOf(')') + 3
    for (let i = startIdx; i < code.length; i++) {
        if (code[i] === ' ') {
            break
        }
        type += code[i]
    }
}
if (['number', 'string', 'boolean'].includes(type)) {
    toFn = 'toBe'
} else {
    toFn = 'toEqual'
}


/** 如果是 function ,替换成箭头函数 */
const mainTemplate = isFn ?
    `${(code ?? "")
      .replace("function", "const")
      .replace("(", " = (")
      .replace("{", "=> {")
      .replace(";", "")}

export default ${exportName}
` :
    `${code}

export default ${exportName}    
`;

/** 测试文件模板 */
let expectTemplate = '';
if (isFn) {
    expectTemplate = `expect(${exportName}()).${toFn}()
    expect(${exportName}()).${toFn}()
    expect(${exportName}()).${toFn}()`
}
const testTemplate = `import ${exportName} from '../${serial}';

test("${serial}", () => {
    ${expectTemplate}
})
`

const createFiles = () => {
    // 生成基本文件
    fs.writeFile(`./src/${serial}.ts`, mainTemplate, (error) => {
        if (error) {
            throw error
        }
    })

    // 生成测试文件
    fs.writeFile(`./src/tests/${serial}.spec.ts`, testTemplate, (error) => {
        if (error) {
            throw error
        }
    })
}

// 判断文件是否存在,并作对应的处理
fs.access(`./src/${serial}.ts`, (error) => {
    if (error) /** 文件不存在 */ {
        createFiles()
    } else /** 文件存在 */ {
        throw new Error("The file already exists")
    }
})

使用也挺简单,node create xxx "function fn(){}" ,这样大大提高了效率。

尾声

文章发表在这里,希望得到大佬的指点,欢迎大家讨论!!!