记录 1 道算法题
数据流中的第 k 大元素
希望可以讲清楚 “堆” 以及 “堆是怎么进行优化计算”
- 先说比较直接的方法。
可以维护一个前 k 大元素的数组,每次添加的时候都进行一次冒泡,把新添加的元素放到合适的地方,然后把最小的给弹出数组。
function KthLargest(k, nums) {
this.k = k
this.data = []
for (let i = 0; i < nums.length; i++) {
this.add(nums[i])
}
}
KthLargest.prototype.add = function (val) {
const data = this.data
// 如果还没够 k 个就直接推进去,然后排序
if (data.length < this.k) {
data.push(val)
} else if (val > data[data.length - 1]) {
// 如果够了就把最小的给替换掉,然后进行冒泡
data[data.length - 1] = val
}
let a = data.length - 1
// 从最后开始往前冒泡,每次推都会排序所以只要停下来就算完成
// 整个 data 是降序的
while (data[a] > data[a - 1]) {
const temp = data[a]
data[a] = data[a - 1]
data[a - 1] = temp
a--
}
return data[data.length - 1]
}
虽然已经优化成只需要排序 k 个,但执行时间成绩依然不好。
像这种第几大元素的可以用堆来解决。第 k 大就用最小堆,第 k 小就用最大堆。最小堆和最大堆也只是排序时是升序还是降序而已。上面其实已经有堆的意思了。
- 然后第二个优化的点是引入 搜索二叉树 的扁平数组版。首先先讲解一下这个数组版的实现。
[a, b, c, d, e, f, g, h, i ,j, k, l, m]
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
上面是节点以及给他们标上号,转成二叉树是,每一个标记都可以自己做父节点,或者做别人的子节点。
a
b c
d e f g
h i j k l m
0
1 2
3 4 5 6
7 8 9 10 11 12
这个扁平数组其实是跟层序遍历一样,一层层的推入数组中形成的。二叉树的节点的下标规律是,等差数列吧。
i
2n*i+1 2n*i+2
列举一下的话 父 左 右 父下标 左下标 右下标
a b c 0 1 2
b d e 1 3 4
c f g 2 5 6
d h i 3 7 8
e j k 4 9 10
f l m 5 11 12
当知道 父节点下标的时候 是满足上面的规律的。
但是知道左右子节点反推父节点的时候就不能用上面的规律了。
我们可以用 i >> 1, 取中间的下标的方法。和 Math.floor(i/2) 一样。但是需要注意的是算出来的结果是偏移了1位的,解决方法是(i - 1) >> 1 。下标再减1。
a b c d e f
0 1 2 3 4 5
从子节点推算父节点时
正常来说 应该 a b c 一组。
但是 i >> 1 计算出来的是 b c d 一组 1, 2 >> 1 , 3 >> 1。
所以先 -1 的话就对的上了。
a b c
0, (2-1) >> 1, (3-1) >> 1
之所以引入二叉树是为了优化计算,我们只需要知道第 k 大元素是什么就可以了,并不需要关系前面的 k - 1 个元素是怎么排序的。哪怕是 9, 10, 7, 5,也不影响第 4 大元素是 5。
二叉树之中顶上的根节点就是第 k 大元素,然后因为升序的关系,他的子节点都会比父节点大。表现在数组看是升序。
但是可能会有疑惑,二叉树怎么比较左节点和右节点,怎么进行冒泡。到底是怎么优化的。
接下来会结合代码和例子进行解释二叉树为什么能用。
我们首先初始化一个堆,然后把数据加入到堆里面。也是用上了add方法。
function KthLargest(k, nums) {
this.k = k
this.heap = new Heap()
for (let i = 0; i < nums.length; i++) {
this.add(nums[i])
}
}
KthLargest.prototype.add = function (val) {
const { heap } = this
heap.push(val) /* 升序排完,推入的时候会进行冒泡 */
if (heap.size() > this.k) {
heap.pop() /* 这里面把最后一个即最大数 覆盖掉第一个最小的数,然后冒泡 */
}
return heap.data[0] // 第一个就是第 k 大的数。
}
接下来是关键的堆的构造函数
// 先做些基础建设
class Heap {
constructor() {
// 升序数组
this.data = []
this.compare = (a, b) => a - b
}
size() {
return this.data.length
}
swap(n1, n2) {
const { data } = this
const temp = data[n1]
data[n1] = data[n2]
data[n2] = temp
}
// 推入堆中
push(val) {}
// 加入新的数 替换掉 第 k 大以外的
pop() {}
// 从 index 进行向上冒泡,一般是数组结尾
bubblingUp(index) {}
// 从 index 进行下沉, 一般是数组开头
bubblingDown(index) {}
}
向上冒泡的时候,普通版是一个个进行比较的冒泡,当引入二叉树的时候,就是和父节点进行比较,而不是一一比较,这时就会减少至少一半的比较。
只跟父节点进行比较会不会遗漏呢?答案是不会的。这颗树的特点是右节点的值会比左节点的值大,左右子节点都比父节点大。
冒泡时因为升序的关系,只要比父节点小,冒泡上去了,就一定比另外一个子节点小。
以 [9, 10, 7, 5] 为例。使用上面分析的父节点下标和子节点下标的关系。向上冒泡是根据子节点下标找父节点下标。下沉是根据父节点下标找子节点下标。
bubblingUp(index) {
const { data } = this
// 只有两个以上才需要冒泡
while(index > 0) {
const parent = (index - 1) >> 1
// 和 sort 规则一样 -1 进行交换
if (this.compare(data[index], data[parent]) < 0) {
this.swap(index, parent)
// 一直冒泡
index = parent
} else {
// 因为是升序,所以一旦停下来可以提前结束。
break
}
}
}
当数组里有一个的时候 [9], 推入 10。会计算 子节点下标: 1, 父节点下标: (1-1) >> 1 = 0, 比较 data[0] 和 data[1]。不用冒泡。
[9, 10], 推入 7。计算 子节点下标:2,父节点下标: (2-1) >> 1 = 0, 比较 data[2] 和 data[0]。进行交换。新子节点下标 0 ,结束冒泡
[7, 10, 9], 推入 5。计算 子节点下标:3,父节点坐标:(3-1) >> 1 = 1,比较 data[3] 和 data[1]。进行交换。 新子节点下标 1 ,继续冒泡
[7, 5, 9, 10], 这时候 子节点下标:1, 继续对 5 进行冒泡。父节点下标:0
最后得到的二叉树是
5
7 9
10
[5, 7, 9, 10]
然后下沉时每一层父节点都会和左右节点比较。这样比较确认每一个父子关系中,右节点都比左节点大。然后确保下沉时都会把左右节点里面最小的替换上去父节点
bubblingDown(index) {
const { data } = this
while(true) {
const left = index * 2 + 1
const right = index * 2 + 2
let parent = index
// 比较左节点和父节点, 先比较的左节点,所以最后右节点会比左节点大
if (this.compare(data[left], data[parent]) < 0) {
parent = left
}
// 当左节点比父节点小时,parent = left,进行左右节点的比较。
// 如果右节点比左节点小,则 parent = right,最后确认是 父节点和右节点交换。
// 如果右节点比左节点大,则父节点和左节点交换。
// 如果上面左节点的分支没有进去,左节点比父节点大,
// 则比较右节点和父节点谁大,要不要交换。
if (this.compare(data[right], data[parent]) < 0) {
parent = right
}
if (index !== parent) {
// 当比较了之后需要交换
this.swap(index, parent)
index = parent
} else {
// 不需要交换的时候就结束循环,不然就是死循环了,因为 index 不会改变
break
}
}
}
push 和 pop 就很简单了,只是引用冒泡和下沉的方法就行。
push(val) {
this.data.push(val)
// 刚推进去的数的下标
this.bubblingUp(this.size() - 1)
}
pop() {
if (this.size() === 0) return
const { data } = this
// 第 k 大的数
const discard = data[0]
// 最大的数
const newMember = data[pop]
// 如果数组里面只有一个数,弹完就没了, discard 和 newMember 是同一个就等于没弹,所以要区别判断。
if (this.size() > 0) {
data[0] = newMember
this.bubblingDown(0)
}
return discard // 体贴的返回了被踢出去的曾经第 k 大元素
}
以上就是JavaScript模拟堆的优化代码,希望讲清楚了。
下面是这道题的完整代码。
function KthLargest(k, nums) {
this.k = k
this.heap = new Heap()
for (let i = 0; i < nums.length; i++) {
this.add(nums[i])
}
}
KthLargest.prototype.add = function (val) {
const { heap } = this
heap.push(val) /* 升序排完 */
if (heap.size() > this.k) {
heap.pop() /* 把最后一个即最大数 覆盖掉第一个最小的数,然后排序 */
}
return heap.data[0]
}
class Heap {
constructor() {
this.data = []
this.compare = (a, b) => a - b
}
size() {
return this.data.length
}
swap(n1, n2) {
const { data } = this
const temp = data[n1]
data[n1] = data[n2]
data[n2] = temp
}
push(val) {
this.data.push(val)
this.bubblingUp(this.size() - 1)
}
pop() {
if (this.size() === 0) return null
const { data } = this
const discard = data[0]
const newMember = data.pop()
if (this.size() > 0) {
data[0] = newMember
this.bubblingDown(0)
}
return discard /* 被踢掉的曾经堆内最小节点 */
}
bubblingUp(index) {
/* 冒泡,把最小的冒到第一个,是按二叉树的父子关系,完全升序 */
while (index > 0) {
/* 一层层上去最后会聚焦到 0 */
/* 下标再减 1 这样就对的上二叉树的父子节点 */
const parent = (index - 1) >> 1
const { data } = this
if (this.compare(data[index], data[parent]) < 0) {
this.swap(parent, index)
index = parent
} else {
break
}
}
}
bubblingDown(index) {
/* 把最大的置换下去,按照二叉树的分布,放到叶子节点的下标,不是完全升序 */
const { data } = this
const last = this.size() - 1
while (true) {
/* 用二叉树而且不用保证升序的原因是只要第k大,并不关心顺序,二叉树的根节点就是第k大的数 */
const left = index * 2 + 1
const right = index * 2 + 2
let parent = index
if (left <= last && this.compare(data[left], data[parent]) < 0) {
parent = left
}
/* 在左右子节点中更小的那个,给挤上去 */
if (right <= last && this.compare(data[right], data[parent]) < 0) {
parent = right
}
if (index !== parent) {
this.swap(index, parent)
index = parent
} else {
break
}
}
}
}