Skip to content

利用线段树替代redis的有序集合实现粗排行榜

1078字约4分钟

2024-10-02

存在的问题

在现有的业务中使用了redis的有序集合对象作为排行榜,但是由于数据本身没有落地,而且用户数据太多,将近一千五百万的数据,导致redis占用内存特别大,需要进行优化。

优化的思路

因为排行榜只需要展示前100名用户数据,所以redis中只需要保存前100的数据,其他数据可以落地到mysql中。单个用户的分数查询,可以利用到mysql的索引,查询效率虽然比redis低,但是也可以接受。但是每个用户的排名,如果使用mysql,那么需要遍历索引树,查询效率必定很低,所以需要使用更高效的方法。

由于分数区间有限,大部分用户都出现在某个区间中,所以这里可以利用哈希表+红黑树,记录每个分数出现的次数,查询用户排名时,只需要将比这个用户分数高的分数对应的次数累加,即可将得出用户的分数排名。该方法对查询的时间复杂度为O(N),插入的时间复杂度为O(log1),时间复杂度与redis相同,而空间复杂度为reids的千分之一。

优化的方案

对于排名而言,查询的次数远远多于插入的次数,所以必须优化查询方案,最好能接近redis的查询复杂度O(logN)。这里可以考虑线段树,线段树是一种区间查询与更新的数据结构,整棵树类似于二叉树,详细情况不在这里讲解。

普通的线段树,存在两个问题。一使用的是二叉树,如果区间非常大,则树的深度会比较大,递归层次多。二是使用了数组实现,同理如果区间非常大,需要的数组也会占用很大的空间。在业务中,虽然分数基本集中在某个区间,但是分数的最大值将近3亿,所以不能使用普通的线段树实现。在这里我们使用的是多叉树实现,且用指针代替数组,不存在的区间不会占用不会额外占用空间。 go版本的实现方法如下。

package utils

import (
	"math"
)

type SegmentTree struct {
	root *stNode
}

type stNode struct {
	value     []int
	count     []int
	childNode []*stNode
}

func NewSegmentTree(maxValue int) *SegmentTree {
	var st = SegmentTree{}
	st.root = st.createNode(0, maxValue)
	return &st
}

func (st *SegmentTree) createNode(left int, right int) *stNode {
	var node = stNode{}
	var size = 128
	if right-left < 127 {
		size = right - left + 1
	}
	node.value = make([]int, size)
	node.count = make([]int, size)
	node.childNode = make([]*stNode, size)

	var skip = int(math.Ceil(float64(right-left) / 127))
	for i := 0; i < size; i++ {
		node.value[i] = skip*i + left
	}
	return &node
}

func (st *SegmentTree) BatchInsert(score int, count int) {
	if st.root == nil {
		return
	}
	var root = st.root
	var size = len(root.value)
	if score > root.value[size-1] { //分数超过最大值,直接返回,超过最大值的排序不再准确,需要重新构建线段树
		root.count[size-1] += count
		return
	}

	var cur = root
	for cur != nil {
		size = len(cur.value)
		for i := size - 2; i >= 0; i-- {
			if score >= cur.value[i] {
				cur.count[i] += count
				if cur.value[i]+1 < cur.value[i+1] && cur.childNode[i] == nil {
					cur.childNode[i] = st.createNode(cur.value[i], cur.value[i+1])
				}
				cur = cur.childNode[i]
				break
			}
		}
	}
}

func (st *SegmentTree) Insert(score int) {
	st.BatchInsert(score, 1)
}

func (st *SegmentTree) Delete(score int) {
	if st.root == nil {
		return
	}
	var root = st.root
	var size = len(root.value)
	if score > root.value[size-1] { //分数超过最大值,直接返回,超过最大值的排序不再准确,需要重新构建线段树
		root.count[size-1]--
		return
	}

	var cur = root
	for cur != nil {
		size = len(cur.value)
		for i := size - 2; i >= 0; i-- {
			if score >= cur.value[i] {
				cur.count[i]--
				cur = cur.childNode[i]
				break
			}
		}
	}
}

func (st *SegmentTree) Query(score int) int {
	if st.root == nil {
		return -1
	}
	var root = st.root
	var size = len(root.value)
	if score > root.value[size-1] { //分数超过最大值,直接返回,超过最大值的排序不再准确,需要重新构建线段树
		return root.count[size-1]
	}

	var rank = root.count[size-1]
	var cur = root
	for cur != nil {
		size = len(cur.value)
		for i := size - 2; i >= 0; i-- {
			if score >= cur.value[i] {
				cur = cur.childNode[i]
				break
			} else {
				rank += cur.count[i]
			}
		}
	}

	return rank
}

结论

如果在业务中需要用到排名,且分数重复比较多,集中在某个范围,也不需要用户的具体分数,只需要用户的排名,那么可以使用线段树进行优化,减少内存的占用。

贡献者: p_pctzhang