游戏中的排行榜Lua设计(简单实现,线段树,跳表)

Leader Board Design In Lua

Introduction

在多人在线游戏中,排行榜是很重要的一个功能。多年游戏经验告诉我,排行榜不仅是对自身游戏角色实力的一种评判,还是一种让用户加大投入时间,甚至充值的驱动力。想一想,如果你离排行榜第一名只差一点点,这不爆肝一晚冲榜首

排行榜很重要,但排行榜却不是那么容易设计的。每个用户的得分都在实时变化,并且一般还得提供不同维度的排名,当用户群体一多,数据更新的操作就多了。如何保持高效的数据更新,便显得尤为重要

Simple LeadBoard With Advance

抽象而言,排行榜的最简形态无非就是每个玩家两个字段,用于区分不同玩家的Id字段以及用于排序的Score字段

  • 根据Id获取玩家得分
  • 根据得分进行排序
  • 将结果输出到排行榜
  • 当玩家得分变化时更新得分并重新排序
  • 新玩家进入时则增加一条记录

以上思路基本可以完成排行榜的各项需求,而用LuaTable实现起来也是相当简单


function LeaderBoard:ctor()
    self.rank = {}
end

function LeaderBoard:addNewPlayer(entid, name, score)
    self.rank[#self.rank + 1] = {["entid"] = entid ["name"] = name, ["score"] = score}
    table.sort(self.rank)
end


function LeaderBoard:updateLeaderboard(entid, newScore)
    for i = 1, #self.rank do
        if self.rank[i]["entid"] = entid then
            self.rank[i]["score"] = newScore
        end
    end
    table.sort(self.rank)

end


function LeaderBoard:queryPlayerRank(entid)
    local score = getPlayerScore(entid)
    for i = 1, #self.rank do
        if self.rank[i]["score"] = score then
            return i
        end
    end
    local name = getPlayerName(entid)
    self:addNewPlayer(entid, name, score)
    return -1
end

上述实现的思路是当每次数据变动时都将Rank进行排序以保持Rank按得分有序,在查询时只需找到对应的得分在第几个即可

但仔细思考发现,要想获得自己的排名,根本没必要保持Rank有序,只需要遍历Rank求出得分比自己高的玩家个数即可,因此,可以就此优化添加和更新接口的实现:

function LeaderBoard:ctor()
    self.rank = {}
end

function LeaderBoard:addNewPlayer(entid, name, score)
    self.rank[entid] = {["name"] = name, ["score"] = score}
end


function LeaderBoard:updateLeaderboard(entid, name, oldScore, newScore)
    self.rank[entid]["score"] = newScore

end


function LeaderBoard:queryPlayerRank(entid)
    local rank = 0
    local myScore = self.rank[entid]["score"]
    for k, v in pairs(self.rank) do
        if v["score"] > myScore then
            rank = rank + 1
        end
    end
    return rank + 1
end

比对两者,可以发现优化后的算法查询排名复杂度不变O(N),但添加和更新都变成了O(1)操作,大大加快了执行效率

Segment Tree

虽然优化后的排名查询效率已经达到O(N),但当玩家数量庞大时每次可能有十万级的查询请求,O(N)的复杂度显然不能满足要求

注意到,一般玩家的得分在一段时间内总是在一定范围内的,不可能有一天增长100倍的情况,于是我们可以把得分划分区间,借助线段树优化查询效率

  • 构建一棵从1到最大值的线段树,最大值可以预估未来几个月玩家得分的上限,比如10万分
  • 线段树的每个节点由Start(区间左值), End(区间右值), Count(得分处于此区间的玩家人数)组成,且每个节点的左孩子节点的区间为当前区间的左半边,右孩子为当前区间的右半边,叶子节点则左值与右值相等,整棵树相当于把原始区间不断二分
  • 当增加一个新得分时,即插入一个节点,沿根节点不断选择得分所处区间的节点直至叶子节点,将这条路径上的每个节点的Count加1
  • 删除同理,即当Count大于0时将Count减一,其余与插入一样
  • 更新操作则为先删除后插入
  • 可以看出每个节点的Count代表着处于此区间的玩家人数,因此每次选择子节点若选择左孩子子节点,则代表当前有着右孩子节点Count的人数分数比你高,根据这条性质,查询排名时从根节点向下遍历到叶子节点,每次要往左走时则加上右孩子节点的Count,求和后即得所有分数比当前分数高的人数,即得排名
local sgmentTree = {}

function sgmentTree:node(min, max)
    return {Start = min, End = max}
end

function sgmentTree:new(min, max)
    if min > max then
        return nil
    end

    local root = sgmentTree:node(min, max)
    if min == max then
        root.left = nil
        root.right = nil
        root.count = 0
        return root
    end

    local mid = math.floor((min + max) / 2)
    root.left = sgmentTree:new(min, mid)
    root.right = sgmentTree:new(mid + 1, max)
    root.count = root.left.count + root.right.count
    return root


end

function sgmentTree:insert(root, key)
    if root == nil then 
        return nil 
    end

    if key < root.Start or key > root.End then
        return root
    end

    local node = root
    while node ~= nil do
        node.count = node.count + 1
        local mid = math.floor((node.Start + node.End) / 2)
        if key <= mid then
            node = node.left
        else
            node = node.right
        end
    end

    return root

end

function sgmentTree:delete(root, key)
    if root == nil then 
        return nil 
    end

    if key < root.Start or key > root.End then
        return root
    end

    local node = root
    while node ~= nil do
        if node.count == 0 then
            return root
        end
        node.count = node.count - 1
        local mid = math.floor((node.Start + node.End) / 2)
        if key <= mid then
            node = node.left
        else
            node = node.right
        end
    end

    return root

end

function sgmentTree:updateKey(root, oldKey, newKey)
    if newKey > root.End or newKey < root.Start then
        return root
    end
    sgmentTree:delete(root, oldKey)
    sgmentTree:insert(root, newKey)
    return root

end

function sgmentTree:queryRank(root, key)
    if root == nil then 
        return -1
    end

    if key < root.Start or key > root.End then
        return -1
    end

    local node = root
    local rank = 0
    while node ~= nil do
        local mid = math.floor((node.Start + node.End) / 2)
        if key <= mid then
            if node.right ~= nil then
                rank = rank + node.right.count
            end
            node = node.left
        else
            node = node.right
        end
    end
    return rank + 1
end

由于线段树每层将分数区间二分,因此树的高度不会超过log(maxScore),而每次插入、更新、查询的操作实际上只遍历了树高,因此复杂度也为log(maxScore)。可以看出,这种做法的查询效率与玩家人数无关,只与预估的得分最大值有关,而对玩家的得分可以做映射压缩处理,将最大值减少到五位数甚至四位数,这样查询效率就很高了

当然这种做法也有不足,将要额外保存一整颗线段树在内存里,大大增加存储空间

Skip List

上述实现还有一个问题,即只能根据分数查询自己的名次,无法查询第K名的信息

如果想要查询前K名以构造诸如前100名排行这样的功能,我们可以采用跳表实现,
即利用跳表最下层保留所有得分信息且有序的特点,取最后K个元素即可

当然,原生的跳表无法实现高效根据分数查询名次,需要在在每个Node里加了一个字段用于记录当前节点到下一个节点跳过了多少个最下一层的节点,在查询时不断累加即可知道跳过节点的总数

local min,floor,log,random = math.min,math.floor,math.log,math.random

local logb = function(n,base)
    return log(n) / log(base)
end


local p = 0.5

local skip_list = {

    clear = function(self)
        self.head   = {}
        self._levels= 1
        self._count = 0
        self._size  = 2^self._levels
    end,


    new = function(class,initial_size,comp)
        initial_size= initial_size or 100

        local levels= floor( logb(initial_size,1/p) )

        return setmetatable({
            head   = {},
            _levels= levels,
            _count = 0,
            _size  = 2^levels,
            comp   = comp or function(key1,key2) return key1 <= key2 end},
            class)
    end,

    length = function(self)
        return self._count
    end,


    find = function(self,key,value)
        local node = self.head
        local comp = self.comp
        -- Start search at the highest level
        for level = self._levels,1,-1 do
            while node[level] do
                local old= node
                node     = node[level]
                local c1 = comp(node.key,key)
                local c2 = not comp(key,node.key)

                if c1 ~= c2 then

                    if value then

                        local prev = node
                        while prev do
                            if prev.key == key and prev.value == value then
                                return prev.key,prev.value,prev
                            end
                            prev = prev[-1]
                        end
                        local next = node[1]
                        while next do
                            if next.key == key and next.value == value then
                                return next.key,next.value,next
                            end
                            next = next[1]
                        end
                        return
                    end
                    return node.key,node.value,node
                elseif c1 == false then
                    node = old
                    break
                end
            end 
        end
    end,

    insert = function(self,key,value)

        local levels = floor( log(1-random())/log(1-p) ) + 1
        levels       = min(levels,self._levels)
        local comp   = self.comp

        local new_node = {key = key, value = value}

        local node = self.head
        -- Search for the biggest node <= to our key on each level
        for level = self._levels,1,-1 do
            while node[level] and comp(node[level].key,key) do
                node = node[level]
            end
            -- Connect the nodes to the new node
            if level <= levels then
                new_node[-level] = node
                new_node[level]  = node[level]
                node[level]      = new_node
                if new_node[level] then
                    local next_node   = new_node[level]
                    next_node[-level] = new_node
                end
            end
        end


        self._count = self._count + 1
        if self._count > self._size then
            self._levels = self._levels + 1
            self._size   = self._size*2
        end
    end,

    _delete = function(self,node)
        local level = 1
        while node[-level] do
            local next = node[level]
            local prev = node[-level]
            prev[level]= next
            if next then next[-level] = prev end
            level = level + 1
        end
        self._count = self._count - 1
    end,


    delete = function(self,key,value)
        local k,v,node = self:find(key,value)
        if not node then return end
        self:_delete(node)
        return k,v
    end,


    pop = function(self)
        local node  = self.head[1]
        if not node then return end
        self:_delete(node)
        return node.key,node.value
    end,


    peek = function(self)
        local node = self.head[1]
        if not node then return end
        return node.key,node.value
    end,


    iterate = function(self,mode)
        mode = mode or 'normal'
        if not (mode == 'normal' or mode == 'reverse') then
            error('Invalid mode')
        end

        local node,incr = self.head[1],1

        if mode == 'reverse' then
            -- Search for the node at the end
            for level = self._levels,1,-1 do
                while node[level] do
                    node = node[level]
                end
            end
            incr = -1
        end
        return function()
            if node then
                local k,v= node.key,node.value
                -- Move to the next node
                node     = node[incr] 
                return k,v
            end
        end
    end,


    check = function(self)
        local level = 0
        local comp  = self.comp
        while self.head[level+1] do
            level      = level + 1
            local prev = self.head
            local node = self.head[level]
            while node do
                if prev ~= node[-level] then
                    local template = 'Node with key %d at level %d has invalid back reference!'
                    error( template:format(node.key,level) )
                end
                if node[level] then
                    local next_node= node[level]
                    local c1       = comp(node.key,next_node.key)
                    local c2       = not comp(next_node.key,node.key)
                    if not (c1 or c2) then
                        local template = 'Skip list is out of order on level %d: key %s is before %s!'
                        error(template:format(level,tostring(node.key),tostring(node[level].key)))
                    end
                    if next_node == node then
                        error('Node self reference!')
                    end
                end


                if level > 1 then
                    for direction = -1,1,2 do
                        if node[direction*level] and not node[direction*(level-1)] then
                            error(string.format('Missing node link at level %d',level-1))
                        end
                    end
                end

                prev = node
                node = node[level]
            end
        end
        do 
            local template = 'Node level %d exceeds maximum: %d'
            assert(level <= self._levels,template:format(#self.head,self._levels))
        end
        return true
    end,
}
skip_list.__index = skip_list

Performance Test

对于上述三种实现,我测试了随机插入、查询、更新操作100万次的效率(每个插入数据的得分在[1,100000]),结果如下:

-- Segment Tree
insert 1000000 element cost time is 2.966834
query 1000000 times cost time is 3.298179
update key 1000000 times cost time is 6.989900



-- simple
insert 1000000 element cost time is 0.428671
query 1000000 times cost time is Execution Timed Out(> 10s) 
update key 1000000 times cost time is 0.101379


-- skip list
insert 1000000 element cost time is 5.033151
query 1000000 times cost time is 6.830817
update key 1000000 times cost time is  Execution Timed Out(> 10s) 

可以看出,线段树的查询效率最优,而优化过的简单实现插入和删除的效率非常高

个人认为,算法的选择要看具体的应用场景和数据,当数据量较小时,完全可以使用简单实现,其他算法对于查询效率的优化并不明显,当数据量大时可以采用线段树的结构,但是无法提供查询前K名的功能,而跳表可以提供这个功能但是效率要略微逊色

你可能感兴趣的:(Unity)