在多人在线游戏中,排行榜是很重要的一个功能。多年游戏经验告诉我,排行榜不仅是对自身游戏角色实力的一种评判,还是一种让用户加大投入时间,甚至充值的驱动力。想一想,如果你离排行榜第一名只差一点点,这不爆肝一晚冲榜首
排行榜很重要,但排行榜却不是那么容易设计的。每个用户的得分都在实时变化,并且一般还得提供不同维度的排名,当用户群体一多,数据更新的操作就多了。如何保持高效的数据更新,便显得尤为重要
抽象而言,排行榜的最简形态无非就是每个玩家两个字段,用于区分不同玩家的Id字段以及用于排序的Score字段
以上思路基本可以完成排行榜的各项需求,而用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)操作,大大加快了执行效率
虽然优化后的排名查询效率已经达到O(N),但当玩家数量庞大时每次可能有十万级的查询请求,O(N)的复杂度显然不能满足要求
注意到,一般玩家的得分在一段时间内总是在一定范围内的,不可能有一天增长100倍的情况,于是我们可以把得分划分区间,借助线段树优化查询效率
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)。可以看出,这种做法的查询效率与玩家人数无关,只与预估的得分最大值有关,而对玩家的得分可以做映射压缩处理,将最大值减少到五位数甚至四位数,这样查询效率就很高了
当然这种做法也有不足,将要额外保存一整颗线段树在内存里,大大增加存储空间
上述实现还有一个问题,即只能根据分数查询自己的名次,无法查询第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
对于上述三种实现,我测试了随机插入、查询、更新操作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名的功能,而跳表可以提供这个功能但是效率要略微逊色