基于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[]
}
整个过程只做了一遍循环,对mapper
的key
取值可看作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)都写不出来》
本文中哈希实现参考了上文中的代码。
但我还是要吐槽一下这边文章的标题... :(
以上。