扁平数据结构化算法实现和复杂度浅析

1,465 阅读4分钟

基于ts实现,主要因为可以少写点注释。

准备工作

数据结构定义

// 原始数据单元,类似数据库数据表的行。
type TItem = { pid: number, id: number, [key: string]: any }
// 原始数据集合,类似关系数据库数据表。
type TItemList = TItem[]
// 目标数据结构,通过children产生层级关系。
type TItemMap = TItem & { children: TItemMap[] }
// 目标数据集合。
type TItemMapList = TItemMap[]

Mock数据

写一个数据生成器,可以生成指定数量数据,方便后期进行性能测试:

const genItems = (count: number = 1000, group: number = 4) => {
    const arr: TItem[] = []
    let i = 0
    while(count >= ++i) {
        arr.push({
            id: i,
            pid: (i - 1) / group >> 0,
            ts: Date.now(),
            hash: Math.random()
        })
    }
    return arr.sort(() => Math.random() - 0.5)
}
// 生成11条数据,每个条数据最多3个子节点,乱序排列
// genItems(11, 3)
[
  {
    "id": 5,
    "pid": 1,
    "ts": 1626177721826,
    "hash": 0.7183450458733935
  },
  {
    "id": 3,
    "pid": 0,
    "ts": 1626177721826,
    "hash": 0.24706757764501863
  },
  {
    "id": 2,
    "pid": 0,
    "ts": 1626177721826,
    "hash": 0.7232386518898348
  },
  // ...
]

递归实现

面向JS开发时,一般情况下,如果能抽象出某种既定运行规则,应当优先考虑使用递归。

实现代码

const mapping_r = (items: TItemList, startId: number = 0): TItemMapList => 
    items
        .filter(({ pid }) => pid === startId)
        .map(({ id, ...rest }) => ({
            id,
            ...rest,
            children: mapping_r(items, id)
        }))

在这个场景中,可以看到每次调用mapping_r时,都需要做一次全量数据的filter,所以整体复杂度为O(n^2)

性能测试方法

const timer = (
    name: string,
    total: number,
    count: number,
    mapping: (items: TItemList) => TItemMapList
) => {
    console.log('='.repeat(16), name, '='.repeat(16))
    for(let i = 0; i < count; i++) {
        const items = genItems(total)
        console.time(`${name}_${total}_第${i + 1}轮`)
        const result = mapping(items)
        console.timeEnd(`${name}_${total}_第${i + 1}轮`)

    }
}

性能

timer('递归实现', 1000, 4, mapping_r)
timer('递归实现', 2000, 4, mapping_r)
timer('递归实现', 5000, 4, mapping_r)
timer('递归实现', 10000, 4, mapping_r)

运行结果

================ 递归实现 ================
递归实现_1000_第1轮: 24.209ms
递归实现_1000_第2轮: 19.346ms
递归实现_1000_第3轮: 17.443ms
递归实现_1000_第4轮: 6.561ms
================ 递归实现 ================
递归实现_2000_第1轮: 18.954ms
递归实现_2000_第2轮: 19.262ms
递归实现_2000_第3轮: 18.627ms
递归实现_2000_第4轮: 17.186ms
================ 递归实现 ================
递归实现_5000_第1轮: 85.696ms
递归实现_5000_第2轮: 84.699ms
递归实现_5000_第3轮: 82.379ms
递归实现_5000_第4轮: 86.283ms
================ 递归实现 ================
递归实现_10000_第1轮: 297.386ms
递归实现_10000_第2轮: 478.334ms
递归实现_10000_第3轮: 356.48ms
递归实现_10000_第4轮: 356.114ms

到5000条数据的时候,性能下降比较严重;到10000条的时候,这个性能就不能接受了。

这里强调一下:性能问题并不是递归造成的。

哈希表实现

实现代码

const mapping_l = (items: TItem[], startId: number = 0): TItemMap[] => {
    if(items.length < 1) {
        return []
    } else if(items.length === 1) {
        return [{ ...items[0], children: [] }]
    }
    const mapper = {} as { [key: string]: TItemMap | { children: TItemMap[] } }
    const tmp: number[] = []
    items.forEach(item => {
        const m = { ...item, children: [] }
        if(m.id in mapper) {
            // 占位合并
            mapper[m.id] = { ...item, children: [ ...mapper[m.id].children ]}
        } else {
            mapper[m.id] = m
        }
        if(!(m.pid in mapper)) {
            // 父节点占位
            mapper[m.pid] = { children: [] }
        }
        mapper[m.pid].children.push(m)
        if(m.pid === startId) {
            tmp.push(m.id)
        }
    })
    return tmp.map(id => mapper[id]) as TItemMap[]
}

整个过程只做了一遍循环,对mapperkey取值可看作O(1),整体复杂度为O(n)

其中有个小技巧:当对items进行有序遍历时,有可能子节点出现在父节点之前;此时先创建了一个结构

mapper[m.pid] = { children: [] }

对父节点所在的key进行占位;当父节点出现时,对二者进行合并。

性能

================ 哈希实现 ================
哈希实现_1000_第1轮: 0.759ms
哈希实现_1000_第2轮: 0.601ms
哈希实现_1000_第3轮: 0.585ms
哈希实现_1000_第4轮: 0.851ms
================ 哈希实现 ================
哈希实现_2000_第1轮: 1.032ms
哈希实现_2000_第2轮: 0.727ms
哈希实现_2000_第3轮: 2.025ms
哈希实现_2000_第4轮: 1.348ms
================ 哈希实现 ================
哈希实现_5000_第1轮: 2.14ms
哈希实现_5000_第2轮: 2.304ms
哈希实现_5000_第3轮: 1.588ms
哈希实现_5000_第4轮: 1.547ms
================ 哈希实现 ================
哈希实现_10000_第1轮: 4.215ms
哈希实现_10000_第2轮: 3.57ms
哈希实现_10000_第3轮: 2.712ms
哈希实现_10000_第4轮: 5.879ms

和上面的递归实现相比,性能差别确实非常大;而且在数据量增加的情况下,耗时基本呈线性增长。

扩展

ts的编译和运行

到测试性能的阶段,一开始直接使用ts-node进行测试,但感觉非常不靠谱。

所以对文件(flat.ts)所在目录做了npm初始化,安装typescript之后,执行:

npx tsc flat.ts && node flat.js

其中npx tsc flat.ts会在当前目录生成编译好的js文件,然后在node上运行监测。

参数化运行

为了方便对比,我们每次可以带参数运行。在代码中,我们尝试获取参数:

const [total = 1000, count = 4] = process.argv.slice(2)
timer('递归实现', Number(total), Number(count), mapping_r)
timer('哈希实现', Number(total), Number(count), mapping_l)

更改一下运行脚本:

% npx tsc flat.ts && node flat.js 1000
================ 递归实现 ================
递归实现_1000_第1轮: 18.47ms
递归实现_1000_第2轮: 16.042ms
递归实现_1000_第3轮: 16.605ms
递归实现_1000_第4轮: 7.061ms
================ 哈希实现 ================
哈希实现_1000_第1轮: 0.701ms
哈希实现_1000_第2轮: 0.687ms
哈希实现_1000_第3轮: 0.561ms
哈希实现_1000_第4轮: 0.895ms

% npx tsc flat.ts && node flat.js 2000
================ 递归实现 ================
递归实现_2000_第1轮: 58.678ms
递归实现_2000_第2轮: 21.475ms
递归实现_2000_第3轮: 20.842ms
递归实现_2000_第4轮: 14.497ms
================ 哈希实现 ================
哈希实现_2000_第1轮: 1.181ms
哈希实现_2000_第2轮: 1.256ms
哈希实现_2000_第3轮: 1.877ms
哈希实现_2000_第4轮: 2.078ms

% npx tsc flat.ts && node flat.js 5000 2
================ 递归实现 ================
递归实现_5000_第1轮: 177.188ms
递归实现_5000_第2轮: 71.924ms
================ 哈希实现 ================
哈希实现_5000_第1轮: 4.273ms
哈希实现_5000_第2轮: 6.698ms

这样,耗时比对就清晰多了,而且感觉很酷。

感谢

本篇缘起:《面试了十几个高级前端,竟然连(扁平数据结构转Tree)都写不出来》

本文中哈希实现参考了上文中的代码。

但我还是要吐槽一下这边文章的标题... :(

以上。