有这样一种场景,在一个直播间内,在一个较长的时间内,有人加入群聊,也有人离开,在直播间的整个生命周期内,会产生很多的访问记录,这些记录都有进入时间和离开时间,业务场景是需要计算在直播间存续期间,在线人数最多的时间段,以及人数。
由于项目中直播间实际会控制最大人数在6人,而直播间存续时长一般也不会超过24小时,所以开始时偷了点懒,采用暴力方式处理。
暴力解法
遍历所有的时间段,将时间段按照起始/结束时间分割,比如用户A在线时间是(9:15-9:20),用户B是(9:18-9:30),两者在9:18-9:20期间同时在线,分割时间段后变成了(9:15-9:18),(9:18-9:20),(9:20-9:30),分割过程计算这三个时间段的在线人数分别为1、2、1。后面输入的时间段重复上述过程,遍历已经分割完的子时间段,再与些个时间段进行分割,并同时累加在线人数。当所有时间段分割完成后,遍历所有分割完的时间段对应的在线人数,最后输出时间段和在线人数。
显然这是一个N²复杂度的算法,如果每一个时间段,都会被其他所有时间段分割,那么输入的n个时间段最终会产生2n-1个时间段,所以实际上会比N²还要耗时,开始以为偷懒的暴力方法,事实证明,一点也不省事儿,实现代码如下
type timeSectionList struct {
tss []*timeSection
}
type timeSection struct {
s int64 //开始时间
e int64 // 结束时间
overlap int // 当前时间段重叠次数
}
func (this *timeSectionList) mergeTS(us, ue int64) {
check := func(s, e int64) (hs, he, ts, te int64) {
for _, t := range this.tss {
// 分九种情况处理
if s < t.s {
if e > t.e {
// t | s e
// one| s e
t.overlap++
return s, t.s, t.e, e
} else if e > t.s && e < t.e {
// t | s e
// one| s e
this.tss = append(this.tss, &timeSection{s: e, e: t.e, overlap: t.overlap})
t.overlap, t.e = t.overlap+1, e
return s, t.s, 0, 0
} else if e == t.e {
// t | s e
// one| s e
t.overlap++
return s, t.s, 0, 0
}
} else if s > t.s {
if e < t.e {
// t | s e
// one| s e
this.tss = append(this.tss, &timeSection{s: t.s, e: s, overlap: t.overlap})
this.tss = append(this.tss, &timeSection{s: e, e: t.e, overlap: t.overlap})
t.s, t.e, t.overlap = s, e, t.overlap+1
return 0, 0, 0, 0
} else if s < t.e && e > t.e {
// t | s e
// one| s e
this.tss = append(this.tss, &timeSection{s: t.s, e: s, overlap: t.overlap})
t.overlap++
t.s = s
return 0, 0, t.e, e
} else if e == t.e {
// t | s e
// one| s e
this.tss = append(this.tss, &timeSection{s: t.s, e: s, overlap: t.overlap})
t.s = s
t.overlap++
return 0, 0, 0, 0
}
} else {
// s == t.s
if e > t.e {
// t | s e
// one| s e
t.overlap++
return 0, 0, t.e, e
} else if e < t.e {
// t | s e
// one| s e
this.tss = append(this.tss, &timeSection{s: e, e: t.e, overlap: t.overlap})
t.overlap++
t.e = e
return 0, 0, 0, 0
} else if e == t.e {
// t | s e
// one| s e
t.overlap++
return 0, 0, 0, 0
}
}
}
this.tss = append(this.tss, &timeSection{s: us, e: ue, overlap: 1})
return 0, 0, 0, 0
}
hs, ht, ts, te := check(us, ue)
if hs != 0 {
this.mergeTS(hs, ht)
}
if ts != 0 {
this.mergeTS(ts, te)
}
}
func (this *timeSectionList)GetMaxDuration() timeSecion {
var (
max int
s, e time.Time
)
for _, ts := range this.tss {
s1 = time.Unix(ts.s, 0)
e1 = time.Unix(ts.e, 0)
if ts.overlap > max {
max = ts.overlap
s = s1
e = e1
} else if ts.overlap == max {
// 重叠次数相同时,返回最长的时段
if e1.Sub(s1) > e.Sub(s) {
s = s1
e = e1
}
}
}
return timeSecion{overlap:max, s:s, e:e}
}
除了性能不高外,实际的实现过程,需要考虑九种情况,包含各种起止时间重叠的情形。好在业务规模有限,也勉强撑着了。
优化思路
虽然这个功能不就就被下线了,但是心里有块石头始终放不下,实在是对N²复杂度不满意。
一开始考虑能否使用查找树来解决,但是由于时间段包含起始结束时间两个元素,所以这将会是一个定制的查找树,实现起来比暴力方法复杂,所以放弃了这个思路。
某日突然发觉,似乎栈非常适合用来解决这个问题。开始时间对应压栈,离开时间对应弹栈,栈内元素最多时,不就是想要的结果吗?
抽空整理了一下思路
拟定开始时间对应压栈动作、结束时间对应弹栈动作,则两类时间分别建立队列lstart、lend,并分别完成排序,创建栈结构,设当前最长时间段为stime-etime,同时在线人数为n,对应为栈的元素数量,还是以(9:15-9:20)(9:18-9:30)这两个时间段为例,有如下步骤:
- 从lstart队列头取第一个时间9:15压栈,并从lstart移除,此时在线人数为1,则n=1,stime=9:15,etime=nil
- 对比lstart与lend队列头元素,9:18<9:20,将9:18压栈,并从lstart移除,此时在线人数为2,比n大,则n=2,stime更新为9:18
- 对比lstart与lend队列头元素,由于lstart为空,需要取lend,首先弹栈,在线人数变为1,比n小,n不变,stime也不变,etime更新为9:20,并从lend移除
- 重复上一动作,lend被清空,此时n=2,stime=9:18,etime=9:20,至此算法结束
优化分析
综合来看,排序部分为NlogN的复杂度,出入栈操作为N,整体复杂度从暴力算法的N²下降到NlogN,实际项目应用中,起止时间保存在数据库中,如果库中对在时间列上有B+tree索引,则排序部分复杂度可以下降为N,那么整体的复杂度就是线性复杂度了。
排序部分代码就不写了,下面是栈操作代码
func MaxTimeDuration(start, end []time.Time) (int, time.Time, time.Time) {
var (
max int
ts time.Time
te time.Time
// 这个stack是自己封装的,代码在https://github.com/bournex/basic_training/blob/master/structures/base/stack/stack.go
stack = stack.MakeStack(100)
)
closeTime := func(e time.Time) {
l := stack.Length()
v, _ := stack.Pop()
s := v.(time.Time)
if l > max {
// 如果当前close的时间段重叠层级高于之前保存的,则更新ts,te
max = l
ts, te = s, e
} else if l == max {
// 如果当前close的时间段重叠层级等于当前保存的
// 判断时间长短,选择时间较长的那个
if !ts.IsZero() && !te.IsZero() {
if te.Sub(ts) < e.Sub(s) {
ts, te = s, e
}
} else {
ts, te = s, e
}
}
}
for len(start) > 0 {
s := start[0]
e := end[0] // start不为空则end不可能为空
if s.Before(e) {
start = start[1:]
stack.Push(s) // 压栈
} else if s.After(e) {
closeTime(e) // 弹栈
end = end[1:]
}
}
closeTime(end[0])
return max, ts, te
}
需要留意得是,如果在线人数最多的时段有多个,还需要查找出最长的时段。