C++标准库的容器分为序列容器和关联容器。
序列容器简单的有vector,list,deque,复杂的还有配接器stack,queue,priority_queue。
关联容器简单的有set,map,复杂的有multiset,multimap,这都是基于RB-tree的,基于hashtable的也有hash-set,hash-map,hash-multiset,hash-multimap。
工作中很多容器并不常用,常用的无非这几个vector,list,set,map,queue。下面就简单总结一下这几个简单容器的适用需求(游戏功能逻辑方面的需求)。
1.vector
使用频率超高。通常我们使用数组是因为有明确的上限。而使用vector是因为有可能扩展。
有明确上限的,比如月签到系统。只需要一个31长度的数组,因为一个月最多31天。
没有明确上限的,比如成就系统。成就系统里的杀怪成就,一开始需求可能是10,100,1000,也就是杀怪10个,100个,1000个可以获得奖励。但是上线之后觉得数值不合适,需要多一个2000的条目。这种就非常适合用vector。
更多的情况其实是用数组和vector都行的。这种情况就需要编程者自己斟酌了。
看一个例子,等级礼包。如图:
我们先枚举出奖励的的各种状态。
enum RoleLevelRewardState
{
RLR_INVALID = 0, //不可领取
RLR_CAN_GET, //可领取
RLR_HAS_GET, //已领取
};
我们可以规定一个上限。这个上限是奖励的条目上限。如果策划需求改动了条目数量,比如加了一个100级的等级奖励,那我们就需要改动MAX_ROLE_REWARD这个值。
当然一般情况下,最好是扩展,而不改动已经存在的。因为要兼顾老玩家,比如老玩家已经100级了,前面领取过50,70,90级的奖励。但是这时策划需要增加一个60级的奖励…这就比较乱。最好和策划商量兼容老玩家。
如果是增加一个120级的奖励,这自然就比较简单。
static const int MAX_ROLE_REWARD = 3;
定义我们的数组:
int m_role_level_reward[MAX_ROLE_REWARD];
如果用vector,就是这样写:
std::vector<int> m_role_level_reward;
数组在构造函数里的初始化,或者重置清空通常是这样写:
memset(m_role_level_reward, 0, sizeof(m_role_level_reward));
vector初始化和清空就简单些:
m_role_level_reward.clear();
边界问题是我们需要重视的,在引用数组或vector元素时,都用首先判断边界。
bool UpdateRoleRewardState(int index)
{
if (index < 0 || index >= MAX_ROLE_REWARD) return false;
m_role_reward[index] = 1; //改变内容
return true;
}
更多的不同在于循环时:
for (int i = 0; i < MAX_ROLE_REWARD; ++i) // 数组
for (int i = 0; i < (int)m_role_level_reward.size() && i < MAX_ROLE_REWARD; ++i) // vector
(int)强转是因为.size()返回的是size_t类型,其实判断i < size已经足够,还要加上MAX_ROLE_REWARD是保证需求的数量限制和异常出现,较为稳妥。
当然vector用迭代器遍历更为标准。但下标遍历也是可以的。
2.list
有很多情况,需求可以使用list,也可以使用vector。其实只需要记得,如果需求需要随意在某个位置插入,随意删除某个位置的记录,改变整体顺序的就用list,其他都可以用vector。
list是个循环的双向链表,vector是连续空间。所以list可以随意在任何位置插入,随意删除某个位置的记录,而vector不行。vector的insert和erase代价都很大,会使得迭代器失效。
一个典型的例子,记录玩家杀死大boss的记录表。
首先它的数量肯定不确定的,所以数组不适合。而且要求只记录最近的50条,新的记录显示在前面。这就非常适合用list了。
因为我们肯定会有一个逻辑是记录数大于50的时候,删掉最后1条记录。再把新的记录加到前面。
static const MAX_KILL_RECORD_NUM = 50;
if ((int)m_list.size() > MAX_KILL_RECORD_NUM)
{
m_list.pop_back();
//m_list.push_front(); //插入的数据结构略过
}
list可以任意的insert,erase,pop,push都是代价很小的。
我们要遍历list,就不能像vector一样了。因为vector有[]操作,list没有。所以list需要用迭代器遍历。
3.map
map也是很常用的容器。与vector,list差别很大,它是一个关联容器。所谓关联容器就是有key和value对应。底层是红黑树(RB-Tree)实现的。
通常的使用场景是记录的数据有一个唯一的key作为索引的标志。比如结婚系统,夫妻的相关数据。
夫妻有关的数据结构比如是:
struct MarriageInfo
{
char man_name[64];
char woman_name[64];
int man_role_id;
int woman_role_id;
int love_value; //恩爱值
};
存在一个map里,key是夫妻俩的role_id组合。比如A的role_id是1000,B的role_id是1011,那么两人共同的夫妻数据key就是1000_1011,一个字符串类型。
std::map<string, MarriageInfo> m_marriage_data;
这个数据里有个唯一的key与其value对应。这种类型的数据通常用map合适。但其实你仔细想想,用vector也能实现需要的功能。那样的话数据结构里加一项类型key的项就行。取决于编程者的风格。
struct MarriageInfo
{
string marriage_key;
char man_name[64];
char woman_name[64];
int man_role_id;
int woman_role_id;
int love_value; //恩爱值
};
std::vector<MarriageInfo> m_marriage_data;
map在引用元素之前可以用key做find操作,来判断。
bool UpdateMarriageData(string key)
{
std::map<string, MarriageInfo>::iterator iter = m_marriage_data.find(key);
if (iter == m_marriage_data.end()) return false;
iter->second.love_value = 100;
return true;
}
插入一个map元素,通常可以make_pair一下,然后insert。也有一种简单的写法,就是直接[]引用。
bool InsertMarriageData(string key, MarriageInfo marriage_info)
{
std::map<string, MarriageInfo>::iterator iter = m_marriage_data.find(key);
if (iter == m_marriage_data.end()) return false;
m_marriage_data[key] = marriage_info;
return true;
}
4.set
set是其实是一种简单的map,它的value隐藏了,只显示key出来。set适用比较简单的需求。
比如记录玩家获得的物品奖励,已经获得过的物品不能再获得,物品id作为key。
std::set<ItemId> m_reward_record;
那么set和map的用法类似,是简化的map。取决于需求的复杂度。
5.queue
队列queue。queue其实是一种适配器。底层是deque实现,屏蔽了deque的部分功能,封装成一个队列。队列是一个后进先出的结构。
有一类需求适合,就是匹配需求。就拿吃鸡来说。首先进入的是一个等待副本。先进来的玩家进入排队。满100个人,队列就出去100个人,进入另外一个副本。
匹配实际是一个复杂的功能需求,取决于匹配的条件是什么。吃鸡的需求可能是满100个,送走100个。这100个人有什么等级限制啊,经验限制啊,小队限制啊,都是复杂的。
另外匹配还是一个轮询过程。轮询的优化策略也有讲究,不再展开。
但是毫无疑问queue队列很符合这类的需求。