介绍一种稀有的数据结构,BinaryHashTree,这种结构利用了整数的可哈希特性。
其实很多面试题设计时的技巧都是针对具有偏序性但是不可哈希的数据类型的。但描述题目时为了方便理解往往会用整数这样的可哈希类型来代替,当我们利用整数的可哈希性,就可以突破一些常见面试题的上限或者完全绕开奇技淫巧。可以说是一种作弊一样的数据结构。
比如:
- Leecode 128. 最长连续序列 原本的最佳解法用到了非常特殊的哈希表技巧,使用二进制哈希树无脑解决。
- Leecode 215. 数组中的第K个最大元素 原本的最佳解法是快速选择算法或者WinnerTree,最佳时间复杂度O(n*log(k)),使用二进制哈希树可以突破时间复杂度到O(n)。
- Leecode 239. 滑动窗口最大值 原本的最佳解法是单调队列,有一定技巧性,使用二进制哈希树无脑解决。
基本思路
我们可以把一个整数变成二进制,再从高位到低位逐层做哈希,形成一颗哈希树。
每个整数都可以转换为二进制表示,例如:
rust
5 -> 101
3 -> 011
7 -> 111
这些二进制位决定了数字在树中的位置:
- 0表示左子树
- 1表示右子树
- 从高位到低位依次决定路径
让我们通过一个具体的例子来说明如何将数字转换为树结构:
假设我们要插入数字5(二进制:101):
scss
根节点
└── 1 (右)
└── 0 (左)
└── 1 (右) -> 存储5
插入数字3(二进制:011):
scss
根节点
├── 1 (右)
│ └── 0 (左)
│ └── 1 (右) -> 存储5
└── 0 (左)
└── 1 (左)
└── 1 (右) -> 存储3
插入数字7(二进制:111):
scss
根节点
├── 1 (右)
│ ├── 0 (左)
│ │ └── 1 (右) -> 存储5
│ └── 1 (右)
│ └── 1 (右) -> 存储7
└── 0 (左)
└── 1 (左)
└── 1 (右) -> 存储3
记整数的范围为m,存储的整数数量为n,此数据结构有以下特点:
- 插入时间复杂度 O(log(m))
- 删除时间复杂度 O(log(m))
- 遍历即有序,时间复杂度 O(n*log(m))
- 可以从任意数找到下一个更大的数,时间复杂度 O(log(m))
- 空间复杂度 O(n*log(m))
考虑到我们一般使用的整数类型不会超出32位无符号整型范围,log(m)最多为32,可以视为常数。在此前提下,此数据结构几乎是完美的数据结构,能够以O(n)的性能排序,更可以同时代替哈希表、堆(优先队列)、有序数组、排序二叉树等常见数据结构。
详细设计
补位对齐
在实际实现中,我们需要处理不同长度二进制数的对齐问题。例如:
rust
3 -> 11
5 -> 101
256 -> 100000000
为了保持树结构的一致性,我们需要:
-
补齐位数
- 找出所有数字中二进制位数最多的那个(这里是256,9位)
- 其他数字在前面补0,使其长度相同
- 例如: 3 -> 000000011 5 -> 000000101 256 -> 100000000
-
动态扩展
- 当插入的数字位数超过当前树的深度时
- 自动扩展树的深度
- 所有现有节点作为新树的左子树
- 新树的右子树为空
- 例如:当插入256时,需要将原来的3位树扩展为9位树
这种补位对齐机制确保了:
- 所有数字在树中都有唯一的路径
- 树的深度是固定的,不会无限增长
- 操作的时间复杂度保持在O(log m)范围内
递归查找
在实现查找操作时,我们采用递归的方式遍历树结构。每个节点包含以下信息:
- value: 存储的整数值(仅在叶子节点)
- count: 该值出现的次数(仅在叶子节点)
- left: 左子树(0路径)
- right: 右子树(1路径)
例如,对于数字5(二进制:101)的查找过程:
rust
根节点
└── 1 (右) -> 检查第1位是1,进入右子树
└── 0 (左) -> 检查第2位是0,进入左子树
└── 1 (右) -> 检查第3位是1,进入右子树
-> 找到值为5的叶子节点,count加1
当插入重复数字时,我们只需要增加对应叶子节点的count值,而不是创建新的节点。例如:
scss
插入序列:5, 3, 5, 7, 5
树结构:
根节点
├── 1 (右)
│ ├── 0 (左)
│ │ └── 1 (右) -> 叶子节点:value=5,count=3
│ └── 1 (右)
│ └── 1 (右) -> 叶子节点:value=7,count=1
└── 0 (左)
└── 1 (左)
└── 1 (右) -> 叶子节点:value=3,count=1
查找下一个更大值
在查找比某个值大的下一个值时,我们遵循以下步骤:
- 先找到当前值的叶子节点
- 从该叶子节点开始,向上回溯父节点
- 在回溯过程中:
- 如果当前节点是父节点的左子节点,则父节点的右子树中一定存在更大的值
- 如果当前节点是父节点的右子节点,继续向上回溯
- 找到第一个左子节点后,进入其父节点的右子树
- 在右子树中,尽可能向左走,找到最小的叶子节点
例如,在下面的树中查找比5大的下一个值:
scss
根节点
├── 1 (右)
│ ├── 0 (左)
│ │ └── 1 (右) -> 叶子节点:value=5,count=3
│ └── 1 (右)
│ └── 1 (右) -> 叶子节点:value=7,count=1
└── 0 (左)
└── 1 (左)
└── 1 (右) -> 叶子节点:value=3,count=1
查找过程:
- 找到值为5的叶子节点(路径:101)
- 向上回溯,发现是左子节点
- 进入父节点的右子树
- 找到子树中的最小节点
这种设计查找下一个更大值的时间复杂度为O(log m)
完整代码
解决以上问题,不难实现此数据结构,此处附上完整代码:
gist.github.com/wintercn/3b...
分析
因为JS本身代码运行较为耗时,此数据结构的时间复杂度优势仅在大数据量下能够体现。下图为本结构与JS内置排序算法的性能对比。
可以看出,数据量达到150万时,此数据结构性能才开始有优势。在JavaScript这样的动态语言中,只提倡用来处理面试题,不提倡实际应用。