0%

前言

前几天发生了如下这么一段对话。

策划:我想给咱们Boss加一个新技能…..
程序:不,你不想(╯▔皿▔)╯。
策划: 是这样的,这个boss不是水母吗,我想给这个boss增加一个使用触须攻击玩家的范围性技能,触须是在玩家附近连续快速生成,类似地刺一样,生成前会有一定的延迟预警效果。
程序:所以这是一个区域限制性的技能,目标是给玩家施加一些走位的压力?
策划:是的。
程序:触须的位置选择规则是怎样的?
策划:是随机的。
程序:怎么个随机法,能不能描述的具体点?
策划:额,要不我整个演示视频你看下?
(策划花了点时间整了个演示PPT)
gif1
策划: 你看下这个效果演示?
程序: 嗷(恍然大悟状),所以这是在玩家附近的一个矩形区域内均匀地选取随机点,然后依次延迟一段时间后造成一个个小型范围AOE,对吧?
策划: 对的。
程序: 了解了,那么你需要AI里可以控制哪些参数,按我理解的话,应该是有区域的长宽、地刺的大小、数量以及地刺之间的间距以及….(巴拉巴拉一些其他参数,不赘述)。
策划: 只需要区域长宽、地刺数量以及地刺间的距离就行了,大小咱们改预制体就好了。
程序: 行,需求确定好了就开搞。
(之后又对了一些AI相关的问题)

尝试与分析

确定好了策划的需求,那么就开始分析实现。

这个需求的核心难点在于,如何均匀地选取随机点。

最开始想到了将矩形区域均匀分割成等大小的方格,然后先选择离玩家最近的那个方格做多次BFS,达到指定数量的方格后,然后在方格里再随机选择一个坐标点(类似下图这样生成7个绿色的随机点)
try

但是这样会导致一个问题:相邻的两个方格内的随机点有可能过近
tooclose

或者过远。
toofar

思考了半天, 最后不得不求助万能的谷歌, 搜索关键字Evenly distributed random point

最后发现泊松盘采样算法(Poisson Disk Sampling)是个不错的选择。

算法思路如下:

  • 1.输入点之间的最小间距r, 采样所在空间的维度d,比如二维平面d = 2
  • 2.初始化一个Grid,在Grid内生成均匀大小的方格,一般方格大小为 r / sqrt(d)
  • 3.输入一个起始点,然后创建两个点集合,一个是处理集合,一个是结果集合,把起始点放入这两个集合中。
  • 4.如果处理集合不为空,从中随机选取一个点a移除掉,进行第5步操作,否则终止算法输出结果集合。
  • 5.设置到a的最小距离为r,最大距离为2r。设定最大采样次数k,对第4步选取的点a进行圆环采样。
    • I. 圆环采样:以点a为中心生成一个圆环,在这个圆环中生成一个随机采样点b,并对b进行有效性检测。如果检测通过就将该点b放入结果结合当中,否则舍弃这个点b,尝试进行第n(n <= k)次随机点采样。采样达到k次后返回第4步继续循环。
    • II. 有效性判断逻辑
      • 判断其是否超出限定区域
      • 判断其所在的周围8个网格是否存在与其相邻过近的点, 如下图所示。
        pointsample

代码实现

实现的代码逻辑如下,注意泊松盘采样原算法是会将整个区域铺满的,这里为了优化,当采样点数量达到指定大小(selectPointCount)时,会提前终止采样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/// <summary>
/// 泊松盘采样等距离坐标点
/// </summary>
/// <param name="width">采样区域宽度</param>
/// <param name="height">采样区域高度</param>
/// <param name="minDistance">采样点之间最小间隔</param>
/// <param name="selectPointCount">采样点数量</param>
/// <param name="firstPoint">采样起始点</param>
/// <param name="k">单次采样迭代次数</param>
/// <returns>采样点列表</returns>
public static List<Vector2> PossionDiskSample(float width, float height, float minDistance, int selectPointCount, Vector2 firstPoint = default, int k = 10)
{
var results = new List<Vector2>(selectPointCount);
var processPoints = new List<Vector2>();
float cellSize = minDistance / (float)Math.Sqrt(2);

int col = Mathf.CeilToInt(width / cellSize);
int row = Mathf.CeilToInt(height / cellSize);

var grid = new Vector2[col, row];

results.Add(firstPoint);
processPoints.Add(firstPoint);
var xy = Point2ColRow(firstPoint, cellSize);
grid[xy.col, xy.row] = firstPoint;

while(processPoints.Count > 0 && results.Count < selectPointCount)
{
var curPoint = processPoints.PickRandomElement();
for(int i = 0;i < k; ++i)
{
var newPoint = NextRandomPoint(curPoint, minDistance);
if(IsValidPossionPoint(newPoint, grid, cellSize, minDistance))
{
results.Add(newPoint);
processPoints.Add(newPoint);
var col_row = Point2ColRow(newPoint, cellSize);
grid[col_row.col, col_row.row] = newPoint;
if (results.Count >= selectPointCount)
break;
}
}
}
return results;
}

//坐标位置转换为网格索引
private static (int col, int row) Point2ColRow(Vector2 point, float cellSize)
{
int col = Mathf.FloorToInt(point.x / cellSize);
int row = Mathf.FloorToInt(point.y / cellSize);
return (col, row);
}

private static Vector2 NextRandomPoint(Vector2 curPoint, float minDistance)
{
float angle = GameRand.InclusiveRange(0, 360);
float radius = GameRand.Range(minDistance, 2 * minDistance);
float x = curPoint.x + radius * Mathf.Cos(angle);
float y = curPoint.y + radius * Mathf.Sin(angle);
return new Vector2(x, y);
}

private static bool IsValidPossionPoint(Vector2 point, Vector2[,] grid, float cellSize, float radius)
{
var xy = Point2ColRow(point, cellSize);
int xmax = grid.GetLength(0);
int yMax = grid.GetLength(1);
//不在范围内
if (xy.col < 0 || xy.row < 0 || xy.col >= xmax || xy.row >= yMax)
return false;
//与邻近点过近
for (int i = xy.col - 1; i <= xy.col + 1; ++i)
{
for (int j = xy.row - 1; j <= xy.row + 1; ++j)
{
if (i < 0 || j < 0 || i >= xmax || j >= yMax)
continue;
if (grid[i, j] == Vector2.zero)
continue;
if (Vector2.Distance(point, grid[i, j]) < radius)
return false;
}
}
return true;
}

public static T PickRandomElement<T>(this ICollection<T> elementSet, bool removePicked = true, System.Random rand = null)
{
T res = default(T);
if (elementSet == null || elementSet.Count == 0)
{
Debug.LogError("Null or Empty Collections");
return res;
}
var randomIdx = -1;
if (rand == null)
randomIdx = UnityEngine.Random.Range(0, elementSet.Count);
else
randomIdx = rand.Next(0, elementSet.Count);
res = elementSet.ElementAt(randomIdx);
if (removePicked)
{
elementSet.Remove(res);
}
return res;
}

结果

最后表现如下图。

gif2

做完后的小插曲

程序:怎么样?是不是你想要的技能效果?
策划:嗯,机制上来说是这样的,不过怎么变闪电打击了?
程序:我觉得水母也能放电的嘛,这不河里吗?
策划:是挺合理的,但是…
程序: 其实还有一个更重要的原因。
策划:什么原因?
程序:咱们有美术和动画大佬来做表现吗?我画画贼烂,要不你试下?
bear
策划:…………..好像是这么个问题。
程序:咱还是先临时用免费资源处理吧,等找到美术大佬了再来改下动画表现。
策划:OKOK。

总结

  • 沟通时,确定对齐好双方的目标往往能够快速理解对方的意图。
  • 如果表达不好,或者对方无法理解文字描述的问题时,那么最好用画图、演示的方式来辅助自己传达意图,一图胜千言。
  • 以需求目的以及实践来驱动的算法学习,就个人而言其效率比纯刷题要高得多。

前言

复习下Lua闭包相关的知识

正文

定义

  • 闭包
    闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。
    以下为一个Lua闭包。
    1
    2
    3
    4
    5
    6
    7
    8
    local function func1()
    local x = 1
    local function func2()
    x = x + 1
    return x
    end
    return func2
    end
  • First-class Function

当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数, 这也是Lua通过闭包来支持的特性。
比如上面的函数也可以用以下形式来定义。

1
2
3
4
5
6
7
local func1 = function ()
local x = 1
return function ()
x = x + 1
return x
end
end

原理

闭包的结构

在Lua中,所有的函数其实都是一个闭包,如下图所示,GC代表的垃圾回收相关的数据,这里不赘述。

closure

Lua的闭包为扁平闭包(flat closure),它只保存它所需要的环境中的变量的引用,而不是保存整个环境的引用。
扁平闭包与“safe-for-space complexity”规则兼容,即“在某个局部变量的作用域内最后一次使用该变量后,该变量的绑定就不能被访问”。

定义代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct CClosure {
ClosureHeader;
lua_CFunction f;
TValue upvalue[1]; /* list of upvalues */
} CClosure;

typedef struct LClosure {
ClosureHeader;
struct Proto *p;
UpVal *upvals[1]; /* list of upvalues */
} LClosure;

typedef union Closure {
CClosure c;
LClosure l;
} Closure;

代码中的CClosureLClosure分别代表C闭包和Lua闭包。
其中LClosureProto为函数原型,包含了函数定义需要的所有信息,结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
typedef struct Proto {
CommonHeader;
lu_byte numparams; /* number of fixed (named) parameters */
lu_byte is_vararg;
lu_byte maxstacksize; /* number of registers needed by this function */
int sizeupvalues; /* size of 'upvalues' */
int sizek; /* size of 'k' */
int sizecode;
int sizelineinfo;
int sizep; /* size of 'p' */
int sizelocvars;
int sizeabslineinfo; /* size of 'abslineinfo' */
int linedefined; /* debug information */
int lastlinedefined; /* debug information */
TValue *k; /* constants used by the function */
Instruction *code; /* opcodes */
struct Proto **p; /* functions defined inside the function */
Upvaldesc *upvalues; /* upvalue information */
ls_byte *lineinfo; /* information about source lines (debug information) */
AbsLineInfo *abslineinfo; /* idem */
LocVar *locvars; /* information about local variables (debug information) */
TString *source; /* used for debug information */
GCObject *gclist;
} Proto;

一些未注释或者不太好理解的变量意义如下:

  • numparams代表固定参数数量。
  • is_varargb 标识是否有可变参数
  • maxstacksize 是该函数需要的寄存器数量(Lua虚拟机是基于寄存器执行的),每个函数最多分配250个寄存器。比如下面的函数执行时, maxstacksize为7。

register

  • sizeupvalues, 函数闭包的上值数量。
  • sizek, 函数内部的常量数量。
  • sizecode, 执行的指令数量
  • sizelineinfo, 函数行数信息。
  • sizep, 内部定义的函数数量,即指针**p指向的函数列表大小。
  • sizelocvars, 局部变量数量。
  • 指针**p为所有定义在该函数内部的函数原型列表,通过该指针,Lua可以用递归的方式向上查找某个函数的外部函数变量上值。

构建Lua闭包的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void pushclosure (lua_State *L, Proto *p, UpVal **encup, StkId base,
StkId ra) {
int nup = p->sizeupvalues;
Upvaldesc *uv = p->upvalues;
int i;
LClosure *ncl = luaF_newLclosure(L, nup);
ncl->p = p;
setclLvalue2s(L, ra, ncl); /* anchor new closure in stack */
for (i = 0; i < nup; i++) { /* fill in its upvalues */
if (uv[i].instack) /* upvalue refers to local variable? */
ncl->upvals[i] = luaF_findupval(L, base + uv[i].idx);
else /* get upvalue from enclosing function */
ncl->upvals[i] = encup[uv[i].idx];
luaC_objbarrier(L, ncl, ncl->upvals[i]);
}
}

上值Upvalue

Lua闭包实现的核心构成部分是upvalue结构,它表示了一个闭包和一个变量的连接,结构如下。

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct UpVal {
CommonHeader;
lu_byte tbc; /* true if it represents a to-be-closed variable */
TValue *v; /* points to stack or to its own value */
union {
struct { /* (when open) */
struct UpVal *next; /* linked list */
struct UpVal **previous;
} open;
TValue value; /* the value (when closed) */
} u;
} UpVal;

上值UpValue有两种状态:open和closed,如下图所示。

upvalue_structure

当一个upvalue创建时,它处于open状态,这时候通过指针*v指向栈上的值,当upvalue被关闭时, 值会被拷贝到value字段中, 然后指针会指向为这个value
以下列函数为例:

1
2
3
4
5
6
function add3 (x) -- f1
return function (y) -- f2
return function (z) return x + y + z end -- f3
end
end
print(add3(1)(2)(3)) --打印 6

内层函数f3需要3个变量:xyz。其中yf3的上值, 当执行到f3内部时, 变量x可能已经销毁了,为了避免这种情况,f2的闭包会将x存为upvalue。因此x + y + z执行时,实际上是f3的两个upvaluez计算。等同于下面这个函数定义。

1
2
3
4
5
6
function add3 (x)
return function (y)
local dummy = x
return function (z) return dummy + y + z end
end
end

在上述描述的机制下,会有个问题:当f3闭包创建时,会将f2的上值拷贝过去,这样相当于f3多存了一份数据。

为了解决一个问题,UpVal结构中引入了一个open链表。该链表中upvalue顺序与栈中对应变量的顺序相同。当解释器需要一个变量的upvalue时,它首先遍历这个链表:如果找到变量对应的upvalue,则复用它,因此确保了共享,否则它会创建一个新的upvalue并将其插入到链表中正确的位置。

查找与新建上值得代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
UpVal *luaF_findupval (lua_State *L, StkId level) {
UpVal **pp = &L->openupval;
UpVal *p;
lua_assert(isintwups(L) || L->openupval == NULL);
while ((p = *pp) != NULL && uplevel(p) >= level) { /* search for it */
lua_assert(!isdead(G(L), p));
if (uplevel(p) == level) /* corresponding upvalue? */
return p; /* return it */
pp = &p->u.open.next;
}
/* not found: create a new upvalue after 'pp' */
return newupval(L, 0, level, pp);
}find

构建一个新的open upvalue之后的栈结构如下。
open_upvalues

当一个变量离开作用域不再被引用时,它链接的upvalue会尝试close自身,然后将栈上的值拷贝到内部, 从open链表上断掉链接,结构如图所示。

close_upvalues

核心代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int luaF_close (lua_State *L, StkId level, int status) {
UpVal *uv;
while ((uv = L->openupval) != NULL && uplevel(uv) >= level) {
TValue *slot = &uv->u.value; /* new position for value */
lua_assert(uplevel(uv) < L->top);
if (uv->tbc && status != NOCLOSINGMETH) {
/* must run closing method, which may change the stack */
ptrdiff_t levelrel = savestack(L, level);
status = callclosemth(L, uplevel(uv), status);
level = restorestack(L, levelrel);
}
luaF_unlinkupval(uv);
setobj(L, slot, uv->v); /* move value to upvalue slot */
uv->v = slot; /* now current value lives here */
if (!iswhite(uv))
gray2black(uv); /* closed upvalues cannot be gray */
luaC_barrier(L, uv, slot);
}
return status;
}

void luaF_unlinkupval (UpVal *uv) {
lua_assert(upisopen(uv));
*uv->u.open.previous = uv->u.open.next;
if (uv->u.open.next)
uv->u.open.next->u.open.previous = uv->u.open.previous;
}

编译器如何生成闭包

  • 编译器递归地编译内层函数。当编译器发现一个内层函数时,它停止为当前函数生成指令,转而为新的函数生成完整的指令。在编译内层函数时,编译器为该函数需要的所有upvalue创建一个列表,并映射到最后一个创建的闭包中。当函数结束时,编译器返回外层函数,并生成一条创建带有所需upvalue的闭包的指令。

  • 当编译器看到一个变量名时,它会查找该变量。查找的结果有三种:变量为函数内部的局部变量,变量为函数外部的局部变量,变量为全局变量。编译器首先查找函数内部的局部变量,若找到,则可确定变量保存在当前活动记录的某个固定的寄存器中。若没有找到,则递归地查找外层函数的变量;若没有找到变量名,则该变量是全局变量(与Scheme的某些实现相同,Lua的变量默认为全局),否则该变量是某个外层函数的局部变量。在这种情况下,编译器会添加(或复制)一个该变量的引用到当前函数的upvalue表中。如果一个变量在upvalue表中,则编译器使用它在表中的位置生成GETUPVALSETUPVAL指令。

  • 查找变量的过程是递归的,因此当一个变量被加入当前函数的upvalue表中时,该变量要么是来自外部函数的局部变量,要么是从已被加入外层函数的upvalue表复制引用过来。

以下方函数举个例子

1
2
3
4
5
6
7
function A (a)
local function B (b)
local function C (c)
return c + g + b + a
end
end
end
  • 编译器首先在当前局部变量表找到变量c,因此将其作为局部变量处理。
  • 然后编译器在当前函数找不到g,因此在它的外层函数B中查找,接着递归地在函数A和包裹程序块的匿名函数中查找。最终没有找到g,于是将其作为全局变量处理。
  • 编译器在当前函数中找不到变量b,向外递归在函数B中查找;在函数B的局部变量表中找到b,于是在函数Cupvalue表中加入b
  • 最后,编译器在当前函数中找不到a,因此在函数B中查找。在B中也没有找到,紧接着在A中查找, 发现aA的局部变量,于是将a加入B的upvalue表和C的upvalue表当中。

总结

Lua闭包其实就是一层层的函数套娃,某个执行过后的函数退出close后,如果被其内部函数引用了局部变量,那么会生成一个内闭的环境存下这些局部变量,然后内部函数通过upvalue列表去访问, 否则的话就去open共享链表上找上值。

参考资料

前言

复习下Lua元表相关的知识

正文

定义

  • 元表
    元表可以修改一个值在面对一个未知操作时的行为,是一个操作行为拓展的集合,常常用于拓展table的行为,也可用于userdata
  • 元方法
    可以理解为元表中的某个字段指向的拓展函数或者拓展表。

常用元方法字段

__index

当访问一个表中不存在的字段时,如果该表存在元表,那么解释器会尝试去查找该元方法,并由这个元方法提供最终结果。

应用:实现面向对象的继承

我们可以用元表的__index元方法来实现一个简单的单继承机制。

  • 类的继承实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
local definedClasses = {}

function class(_className, _baseClass)
assert(type(_className) == "string")
assert(type(_baseClass) == "table" or type(v) == "nil")
assert(definedClasses[_className] == nil, "duplicate class define, class name:" .. _className)
local _newclass = {__name=_className, super = _baseClass}
_newclass.__index = _newclass
definedClasses[_className] = _newclass
setmetatable(_newclass, {__index = _baseClass})

_newclass.ctor = function()
print("default ctor")
end

_newclass.new = function(...)
local _instance = {}
setmetatable(_instance, _newclass)
_instance:ctor(...)
return _instance
end

return _newclass
end
  • 构建一个a<-b<-c的继承链。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    local a = class("parent")

    function a:ctor()
    print("ctor a")
    end

    function a:Hello()
    print("parent Hello")
    end

    local b = class("child", a)
    function b:Hello()

    print("Child Hello ")
    end

    function b:ctor(...)
    print("ctor b")
    end

    local c = class("grandson", b)
  • 构建三个类的实例化对象进行测试。
    1
    2
    3
    4
    5
    6
    local insA = a:new()
    local insB = b:new()
    local insC = c:new()
    insA:Hello()
    insB:Hello()
    insC:Hello()
  • 输出如下结果,可以看到子类C发现自己的表中没有Hello这个字段,就通过元表索引到了B的同名方法进行了调用。
    class_print

__newindex

这个元方法会在向表内一个不存在的键索引赋值时触发。

应用:使用__newindex跟踪表的赋值操作

测试代码

1
2
3
4
5
6
7
8
9
10
11
12

local function track_func(t, key, val)
print(os.date("%c",os.time()) .. " insert a new key: " .. key)
rawset(t, key, val)
end

local track = {}
local mt = {__newindex = track_func }
setmetatable(track, mt)
track.a = "a"
track.b = "b"
track[1] = 1

输出如下
2

需要注意的是在track_func部内部使用的rawset来替代t[key] = val,从而避免赋值操作重复触发__newindex的递归死循环。

其他元方法

其他的元方法示例可以参见此处

所有元方法字段参见Lua Wiki的MetatableEvents章节

MetaTable的操作源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
static int luaB_getmetatable (lua_State *L) {
luaL_checkany(L, 1);
if (!lua_getmetatable(L, 1)) {
lua_pushnil(L);
return 1; /* no metatable */
}
luaL_getmetafield(L, 1, "__metatable");
return 1; /* returns either __metatable field (if present) or metatable */
}


static int luaB_setmetatable (lua_State *L) {
int t = lua_type(L, 2);
luaL_checktype(L, 1, LUA_TTABLE);
luaL_argexpected(L, t == LUA_TNIL || t == LUA_TTABLE, 2, "nil or table");
if (luaL_getmetafield(L, 1, "__metatable") != LUA_TNIL)
return luaL_error(L, "cannot change a protected metatable");
lua_settop(L, 2);
lua_setmetatable(L, 1);
return 1;
}

LUA_API int lua_setmetatable (lua_State *L, int objindex) {
TValue *obj;
Table *mt;
lua_lock(L);
api_checknelems(L, 1);
obj = index2value(L, objindex);
if (ttisnil(s2v(L->top - 1)))
mt = NULL;
else {
api_check(L, ttistable(s2v(L->top - 1)), "table expected");
mt = hvalue(s2v(L->top - 1));
}
switch (ttype(obj)) {
case LUA_TTABLE: {
hvalue(obj)->metatable = mt;
if (mt) {
luaC_objbarrier(L, gcvalue(obj), mt);
luaC_checkfinalizer(L, gcvalue(obj), mt);
}
break;
}
case LUA_TUSERDATA: {
uvalue(obj)->metatable = mt;
if (mt) {
luaC_objbarrier(L, uvalue(obj), mt);
luaC_checkfinalizer(L, gcvalue(obj), mt);
}
break;
}
default: {
G(L)->mt[ttype(obj)] = mt;
break;
}
}
L->top--;
lua_unlock(L);
return 1;
}

从源码中还可以发现一些元表的使用trick。

  • 可以设置某张表的__metatable为非空字段来屏蔽元表功能或者保护已设置好的元表。
  • Lua的C API除了可以对TableUserData设置元表外,还可以对其他类型也设置一个全局的元表。
  • 设置元表会触发LuaGC的屏障机制来避免对应的表在当前可能处于GC回收阶段的情况下被回收掉。
  • 函数setmetatable是有返回值的,值为设置metatabletable

总结

  • 元表非常适合用来拓展自己想要的额外逻辑。XLuaLua侧访问Unity和C#的对象机制就是通过UserData的元表机制来实现的。
  • 元表的设计思路很符合设计模式中的观察者模式,元表的各种字段就相当于各种操作事件,元表就相当于这一系列操作事件的订阅者,Lua虚拟机执行到代码特定位置时会触发相应的事件来调用元表中的元方法。

前言

抽空给开源项目金庸群侠传3D重制版贡献代码时,有开发者同好觉得该项目现有的一键打包功能不是特别方便导出MOD,于是写了个优化过的MOD导出工具,这里记录下一些思路和总结。

一键打包的问题

旧的一键打包存在以下几个缺陷。

  • 打包会囊括所有已经打好标签的AssetBundle,导致打包时间较长。
  • 一键打包过程不能中断,期间发生了错误也会一直进行,不利于即时修正问题。
  • 打包结束后的后续操作繁琐,需要手动将相关的MOD资源挪到输出目录下,而且还需要手动建立一个存储MOD相关信息的xml文件。

解决问题

那么如何解决这些问题?

  • 首先,我们要确定首要目标是提供一个方便开发者导出MOD的工具,那么最好是构建另一个全新的打包Pipeline,与现有的Pipeline独立开,尽量不影响原有的一键打包逻辑。之后所有的开发思路都以方便MOD导出为目标来构建。

  • 然后针对以上已存在的问题,提前整理好思路:

    • 打包时间太长。归根结底,打包时间太长还是因为一次性需要构建的资源太多。因此,可以让Unity只打包与该MOD相关的AssetBundle,在构建Pipeline中排除掉其他MOD的资源来节省时间。

    • 打包过程不能中断。首先确定下为什么打包过程不能中断,有没有什么办法可以在报错时提前中断,围绕这一思路去查阅相关资料(比如官方Manual里就提到可以开启AssetBundleStrictMode,这样错误一发生就会中断打包)。然后对于一些容易传入的错误参数,提前做好校验,在打包前尽可能将其排除掉并给使用者弹窗提示相关信息(比如校验MOD配置是否合法,校验输出路径是否合法)等等。

    • 打包结束需要手动配置资源。分析下每个MOD的文件结构,其实都非常类似,包含一个资源包、一个地图包、一个XML配置文件,而且每个MOD在编辑器内都是有相关的ScritableObject配置文件。那么可以根据配置文件确定好每个MOD的相关信息,然后构建时按照一定的规则将相关资源和文件输出到指定文件夹即可。

结果

按照以上思路,最后整了个简单的MOD导出工具

ModExportTool

该工具优化掉了旧一键打包工具的缺陷点,缩短了导出MOD的持续时间,新的MOD构建导出流程也得到了部分MOD开发者同好的赞赏与感谢。

遇到的一些坑点

AssetBundleBuild的一些隐晦问题

为了使得Unity只构建指定MOD相关的AssetBundle,该工具使用了参数为AssetBundleBuild的重载函数

1
public static AssetBundleManifest BuildAssetBundles(string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform);

代替了常用的打包函数

1
public static AssetBundleManifest BuildAssetBundles(string outputPath, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform);

前者需要手动传入AB名和其相关的资源路径。

坑点如下:

  • 分批次调用BuildPipeline的方式会导致AB依赖关系断裂

    • 原因: 为了让该工具也支持一次导出多个MOD,所以分了多次来调用打包API,结果打出来后的ABmanifest文件全都没有依赖关系,由于看不到引擎源码,猜测依赖关系构建可能是在BuildAssetBundles内部自动完成的。
    • 解决: 将所有需要打包的MOD配置缓存起来,只调用一次BuildAssetBundles即可,然后再根据配置缓存一次挪动到指定输出目录。
  • 打好的所有AB包磁盘容量大小总是比一键打包多200MB

    • 原因: 第一时间就想到了肯定是Resources目录的问题,毕竟之前就踩过坑了,用AssetStudio拆包看了下果然如此,
    • 解决: 将基础资源包也包含在pipleline中,这样Resources目录的资源会只达到基础资源包中,不过Resources资源冗余的问题依然存在(这个只能完全弃用该目录API方可解决,项目历史遗留问题)。

函数AssetDatabase.GetAssetPathsFromAssetBundle的坑

使用了该API来获取指定AB的所有资源路径来设置到AssetBundleBuild当中,结果构建时报错
error2

  • 原因: 猜测是GetAssetPathsFromAssetBundle这个API会返回所有相关的资源文件,并且AssetBundleBuild作为参数的BuildAssetBundles方法内部并不会自动排除掉不合法的资源文件。
  • 解决: 在获取路径时做一层过滤,排除掉脚本文件。
1
2
3
4
5
6
private string[] GetValidAssetPathsInBundle(string bundle)
{
var results = AssetDatabase.GetAssetPathsFromAssetBundle(bundle).
Where(assetPath => Path.GetExtension(assetPath) != ".cs").ToArray();
return results;
}

OdinInspector的坑

由于项目集成了该插件,索性顺势就用该插件编写工具窗口,结果在选择文件路径时总是会报错。

error3

  • 原因: 反编译了插件DLL看了下源码,发现是调用OnGUI绘制时在某些极端的情况下没有调用EndLayoutGroup
  • 解决: 查看了下插件版本,发现Odin是比较老的版本3.0.5,于是去插件官网搜了下最新的版本然后查看了下更新日志,发现这个问题在3.1.0版本之后修复掉了,于是手动更新了下,然后就正常了。

未修改资源时AssetBundle重新打包速度过慢

  • 原因: AssetBundle构建的是增量的,即重新打包时与输出目录已存在的AB文件的manifest进行文件差异比较,如果没有新增或者修改资源就还是使用旧包。但是分Mod文件夹时会挪走上一次打好的包,所以每次都会重头把相关的资源打一遍。
  • 解决: 统一预留一个缓存文件夹用于所有AB包的存放,输出完成后再将其分别拷贝至对应文件夹。

其他

导出工具源码参见 Jyx2Modtool.cs

前言

花了两三天写了一个小工具,用于将开源游戏金庸群侠传3D重制版的Mod上传到Steam创意工坊。

这里记录下使用QT开发的一些问题。

问题

使用信号槽机制connect相关回调时找不到可匹配的函数。


  • 原因: 部分UI类的slot函数存在多个同名函数重载的情况.
  • 解决: 使用QOverload<T>::of显示地指定对应函数地址。

QMake文件找不到win64的条件编译宏


  • 添加依赖库时需要区分x86x86_64Steamlib时发现没有针对64位系统的宏。
  • 解决: 使用自定义宏手动控制,如下。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    DEFINES += USE_x64 
    win32
    {
    contains(DEFINES, USE_x64){
    LIBS += -L$$PWD/steam/win64 -lsteam_api64
    }else{
    LIBS += -L$$PWD/steam/win32 -lsteam_api
    }
    }

无法使用QWebEngineView依赖控件


  • 解决: QTCreator构建编译器默认选择的MingGW,但该环境下QWebEngineView不包含在依赖库里,换用MSVC编译即可。

切换到MSVC后编译错误 “ Visual Studio error C2001:常量中有换行符 “


  • 原因: MSVC编译时使用的默认编码是根据windows本地语言来的,中文运行环境下为GBK,但是QT保存编码为UTF-8。因此MSVC编译时如果代码文件包含中文就会出现该问题。

  • 解决: 项目的.pro文件里添加如下指令强制MSVC 使用UTF-8编码

    1
    2
    3
    4
    msvc {
    QMAKE_CFLAGS += /utf-8
    QMAKE_CXXFLAGS += /utf-8
    }

Release构建完成后只有exe没有相关DLL


  • 解决: CMD命令行运行 qwindeploy.exe [exe输出路径]即可生成相关依赖库。

总结

  • QT文档 + Google + Stackoverflow 能解决99%的问题。

前言

复习下SortedList的一些知识点。

源码地址

正文

定义


按照键来排序的键/值对列表,可以通过键和索引来访问数据。连续性存储结构,底层为两个数组,可动态扩容。

成员变量


修饰符 类型 变量名 作用简述
private TKey[] keys 键数据数组
private TValue[] values 值数据数组
private int _size 列表元素数量
private int _version 数据版本,用于校验对数组的操作是否合法
private IComparer comparer 排序用的比较函数
private KeyList keyList 键列表,任何对该列表的操作也会影响到SortedList本身
private ValueList valueList 值列表,任何对该列表的操作也会影响到SortedList本身
private Object syncRoot 多线程下使用的同步对象,不过现在基本废弃了。
private static TKey[] _emptyKeys 默认构造函数内keys初始化指向的只读数组
private static TValue[] _emptyValues 默认构造函数内values初始化指向的只读数组

构造函数

  1. 默认构造函数。和其他容器的默认构造函数大同小异,初始数据数组默认指向空的只读数组,下次插入必定触发扩容。
1
2
3
4
5
6
public SortedList() {
keys = emptyKeys;
values = emptyValues;
_size = 0;
comparer = Comparer<TKey>.Default;
}
  1. 预分配容量的构造函数。
1
2
3
4
5
6
7
public SortedList(int capacity) {
if (capacity < 0)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNumRequired);
keys = new TKey[capacity];
values = new TValue[capacity];
comparer = Comparer<TKey>.Default;
}
  1. 自定义比较器的构造,会先调用默认构造函数。
1
2
3
4
5
6
public SortedList(IComparer<TKey> comparer) 
: this() {
if (comparer != null) {
this.comparer = comparer;
}
}
  1. 自定义比较器以及分配容量的构造函数,会触发一次扩容。
1
2
3
4
public SortedList(int capacity, IComparer<TKey> comparer) 
: this(comparer) {
Capacity = capacity;
}
  1. 用另一个字典以及自定义比较器来初始化的构造函数, 调用了第4种构造函数,然后将数据拷贝过去, 并进行一次排序。
1
2
3
4
5
6
7
8
9
10
public SortedList(IDictionary<TKey, TValue> dictionary, IComparer<TKey> comparer) 
: this((dictionary != null ? dictionary.Count : 0), comparer) {
if (dictionary==null)
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.dictionary);

dictionary.Keys.CopyTo(keys, 0);
dictionary.Values.CopyTo(values, 0);
Array.Sort<TKey, TValue>(keys, values, comparer);
_size = dictionary.Count;
}
  1. 仅使用另一个字典初始化,调用的第5个构造函数。
1
2
3
public SortedList(IDictionary<TKey, TValue> dictionary) 
: this(dictionary, null) {
}

扩容机制

空数组情况下第一次扩容为4,其余情况为2倍扩容

1
2
3
4
5
6
7
8
private void EnsureCapacity(int min) {
int newCapacity = keys.Length == 0? _defaultCapacity: keys.Length * 2;
// Allow the list to grow to maximum possible capacity (~2G elements) before encountering overflow.
// Note that this check works even when _items.Length overflowed thanks to the (uint) cast
if ((uint)newCapacity > MaxArrayLength) newCapacity = MaxArrayLength;
if (newCapacity < min) newCapacity = min;
Capacity = newCapacity;
}

关键函数

查找

一般是根据key来找值,当然也支持通过数组下标来找值,使用ValueList[idx]访问即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int IndexOf(TKey key) {
if (((Object) key) == null)
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);

int i = Array.BinarySearch<TKey>(_dict.keys, 0,
_dict.Count, key, _dict.comparer);
if (i >= 0) return i;
return -1;
}

public bool ContainsKey(TKey key) {
return IndexOfKey(key) >= 0;
}

public bool ContainsValue(TValue value) {
return IndexOfValue(value) >= 0;
}

因为是列表本身是有序的,所以key用二分查找会更快,时间复杂度O(logN)

但是value的查找还是得用数组遍历来处理,时间复杂度O(N)

另外也可以根据下标来找key

1
2
3
4
5
private TKey GetKey(int index) {
if (index < 0 || index >= _size)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_Index);
return keys[index];
}

插入

可以像Dictionary那样来赋值来插入数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public TValue this[TKey key] {
get {
int i = IndexOfKey(key);
if (i >= 0)
return values[i];

ThrowHelper.ThrowKeyNotFoundException();
return default(TValue);
}
set {
if (((Object) key) == null) ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
int i = Array.BinarySearch<TKey>(keys, 0, _size, key, comparer);
if (i >= 0) {
values[i] = value;
version++;
return;
}
Insert(~i, key, value);
}
}

private void Insert(int index, TKey key, TValue value) {
if (_size == keys.Length) EnsureCapacity(_size + 1);
if (index < _size) {
Array.Copy(keys, index, keys, index + 1, _size - index);
Array.Copy(values, index, values, index + 1, _size - index);
}
keys[index] = key;
values[index] = value;
_size++;
version++;
}

如果二分查找找不到key,则会返回合适得插入位置下标的取反,取反返回的必是负数(代表列表中不存在这个key)。

插入位置的选取参见官方文档说明
1

大意就是如果存在比要插入的key更大的key_larger,则返回第一个key_larger的下标取反值。如果没有更大的键,则返回列表最后一个元素下标 + 1的取反值。

之后就是将目标位置之后的元素后挪1位,腾个地给新来的keyvalue

删除

既可以根据下标删除,也可以根据key来删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void RemoveAt(int index) {
if (index < 0 || index >= _size) ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_Index);
_size--;
if (index < _size) {
Array.Copy(keys, index + 1, keys, index, _size - index);
Array.Copy(values, index + 1, values, index, _size - index);
}
keys[_size] = default(TKey);
values[_size] = default(TValue);
version++;
}


public bool Remove(TKey key) {
int i = IndexOfKey(key);
if (i >= 0)
RemoveAt(i);
return i >= 0;
}

注意key不能为空。

裁剪容量

List一致,容量使用未达到90%就可以裁剪。

1
2
3
4
5
6
public void TrimExcess() {
int threshold = (int)(((double)keys.Length) * 0.9);
if( _size < threshold ) {
Capacity = _size;
}
}

总结

  1. 如果要使用数组下标访问数据可以通过ValueListKeyList两个索引器来访问,但需要注意的是这两个接口虽然是继承自IList<T>,但只能读数据,任何写操作(Remove,Insert,Clear,Add)都不被允许。

  2. 与其他容器一样,能预估容量提前分配就使用预分配构造函数。

  3. 在少量键值对数据需要保持有序的情况下使用该容器。

前言

复习下Queue的一些知识点。

源码地址

正文

定义


队列, 先进先出(FIFO)的顺序容器, 底层为数组。

成员变量


修饰符 类型 变量名 作用简述
private 泛型数组 _array 队列数组数据
private int _head 队首下标位置
private int _tail 队尾下标位置
private int _size 队列元素数量
private int _version 迭代时用于数据版本校验
private Object _syncRoot 多线程下的同步对象,已弃用,多线程建议使用ConcurrentQueue
private static 泛型数组 _emptyArray 默认构造函数初始化后指向的空数组

构造函数

三种构造函数:

  1. 默认构造函数,初始化数组数据为只读的空数组,下一次Enqueue就会触发扩容
    1
    2
    3
    public Queue() {
    _array = _emptyArray;
    }
  2. 预分配大小的构造函数
1
2
3
4
5
6
7
8
9
public Queue(int capacity) {
if (capacity < 0)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNumRequired);

_array = new T[capacity];
_head = 0;
_tail = 0;
_size = 0;
}
  1. 用其他容器初始化的构造函数,可能会触发多次扩容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Queue(IEnumerable<T> collection)
{
if (collection == null)
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);

_array = new T[_DefaultCapacity];
_size = 0;
_version = 0;

using(IEnumerator<T> en = collection.GetEnumerator()) {
while(en.MoveNext()) {
Enqueue(en.Current);
}
}
}

扩容机制

以当前队列元素数量2倍的容量来扩容,如果容器初始容量小于4, 那么会再额外分配最小容量为4的空间, 代码如下

1
2
3
4
5
6
7
8
if (_size == _array.Length) {
int newcapacity = (int)((long)_array.Length * (long)_GrowFactor / 100);
if (newcapacity < _array.Length + _MinimumGrow) {
newcapacity = _array.Length + _MinimumGrow;
}
SetCapacity(newcapacity);
}

重新分配数组内存,然后将旧数据拷贝过去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 private void SetCapacity(int capacity) {
T[] newarray = new T[capacity];
if (_size > 0) {
if (_head < _tail) {
Array.Copy(_array, _head, newarray, 0, _size);
} else {
Array.Copy(_array, _head, newarray, 0, _array.Length - _head);
Array.Copy(_array, 0, newarray, _array.Length - _head, _tail);
}
}

_array = newarray;
_head = 0;
_tail = (_size == capacity) ? 0 : _size;
_version++;
}

注意这里存在队首索引在队尾索引之后的情况(_head >= _tail),这是因为队列的逻辑结构是一个环形结构,后文会详细说明。

关键函数

Enqueue

将一个元素添加到队列尾部, 因为是环形结构,_tail的索引需要模除一下获得实际的下标位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void Enqueue(T item) {
if (_size == _array.Length) {
int newcapacity = (int)((long)_array.Length * (long)_GrowFactor / 100);
if (newcapacity < _array.Length + _MinimumGrow) {
newcapacity = _array.Length + _MinimumGrow;
}
SetCapacity(newcapacity);
}

_array[_tail] = item;
_tail = (_tail + 1) % _array.Length;
_size++;
_version++;
}

Dequeue

移除并返回队首元素

1
2
3
4
5
6
7
8
9
10
11
public T Dequeue() {
if (_size == 0)
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyQueue);

T removed = _array[_head];
_array[_head] = default(T);
_head = (_head + 1) % _array.Length;
_size--;
_version++;
return removed;
}

Peek

获取队首元素

1
2
3
4
5
6
public T Peek() {
if (_size == 0)
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyQueue);

return _array[_head];
}

Contains

从头到尾遍历判断元素是否在队列内

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public bool Contains(T item) {
int index = _head;
int count = _size;

EqualityComparer<T> c = EqualityComparer<T>.Default;
while (count-- > 0) {
if (((Object) item) == null) {
if (((Object) _array[index]) == null)
return true;
}
else if (_array[index] != null && c.Equals(_array[index], item)) {
return true;
}
index = (index + 1) % _array.Length;
}

return false;
}

环形结构说明

这里举例说明下,假设我们有个队列长度为6Queue<int>,然后把队列用数字 1~6 塞满。

1
Queue<int> q = new Queue<int>(new[] { 1, 2, 3, 4, 5, 6 });

这时候队列结构如下图。

queue1

接下来我们调用一次出列

1
q.Dequeue();

变化后的结构如下图

queue2

接着调用一次入列将数字7添加到队尾

1
q.Enqueue(7);

结构如下图

queue3

可以看到当_tail递增到数组末尾时(_tail == 6), 发现_head之前还有剩余空间,那么正好可以复用之前的空间,于是 _tail % 6(数组长度) = 0,正好是数组出队后预留的空位置,那么_tail就直接占用该位置即可。从逻辑结构上来说就好像数组的首尾拼在一起形成了一个顺时针的环状结构。

queue4

这样就可以循环复用空间直到队列变满为止(_size == length)。

为什么要使用环形结构?

除了可以复用空间外,还可以反过来思考下,如果逻辑上不使用环形结构会怎么样?
那么_head_tail会持续递增下去,当达到数组末尾后,如果有新的元素添加到末尾,那么就不得不把当前所有的元素都往前挪,然后更新_head_tail为新的下标。

即在不使用环形结构的情况下,入队操作可能会导致O(N)时间复杂度的操作,而使用索引来mod数组长度的操作来模拟环形队列,就可以保证在不扩容的情况下每次入队操作都是O(1)

总结

  1. 调用PeekDequeue注意队列是否为空,否则会抛异常。

  2. 预估好容量,降低多次扩容带来的开销。

  3. 该队列不是双端队列Deque,只能操作队首元素,需要操作队末元素的话,考虑换用List或者自己实现。

前言

复习下Stack的一些知识点。

源码地址

正文

定义


后进先出(LIFO)的顺序容器, 底层为数组。

成员变量


修饰符 类型 变量名 作用简述
private 泛型数组 _array 栈数据
private int _size 栈内元素数量
private int _version 迭代时用于数据版本校验
private Object _syncRoot 多线程下的同步对象,已弃用,多线程建议使用ConcurrentStack
private static 泛型数组 _emptyArray 默认构造函数初始化后指向的空数组

构造函数

共有三种构造函数:

  1. 默认构造函数初始化会指向一个静态只读空数组,下一次Push一定会触发扩容。
  2. 传入Capacity的构造函数则会分配对应长度的数组。
  3. 传入IEnumrable<T>的构造函数会先尝试转换为ICollection<T>,如果成功了会调用CopyTo复制目标数组元素; 否则的话就会分配默认大小(defaultCapacity4)的数组容量,然后生成迭代器不停去Push, 期间可能会触发多次扩容。

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public Stack() {
_array = _emptyArray;
_size = 0;
_version = 0;
}

public Stack(int capacity) {
if (capacity < 0)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNumRequired);
_array = new T[capacity];
_size = 0;
_version = 0;
}

public Stack(IEnumerable<T> collection)
{
if (collection==null)
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);

ICollection<T> c = collection as ICollection<T>;
if( c != null) {
int count = c.Count;
_array = new T[count];
c.CopyTo(_array, 0);
_size = count;
}
else {
_size = 0;
_array = new T[_defaultCapacity];

using(IEnumerator<T> en = collection.GetEnumerator()) {
while(en.MoveNext()) {
Push(en.Current);
}
}
}
}

扩容机制

Push添加新元素前进行判断,容量满了会以当前大小的2倍来扩容。

1
2
3
4
5
if (_size == _array.Length) {
T[] newArray = new T[(_array.Length == 0) ? _defaultCapacity : 2*_array.Length];
Array.Copy(_array, 0, newArray, 0, _size);
_array = newArray;
}

为什么几乎所有的容器扩容的容量是上一次的2倍? 可以参考知乎的讨论,以及C++ vector设计者Andrew Koenig的解释

概括来说,对于n个元素的插入操作会导致K次扩容,那么2倍扩容拷贝元素的总次数为

formula1

O(N)的时间复杂度, 扩容步骤如下图所示。

capacity

部分第三方实现会使用1.5倍作为扩容因子,这是为了N次扩容后,能够有机会复用之前的内存,对缓存更友好,不过在时间性能上会不如2倍扩容。而以2倍扩容,第N次需要的内存空间一定比前面N - 1次的扩容总量还要大,因此一定无法复用之前的内存空间。
两种因子的扩容比较参见下图。

reuse

操作函数

Push

向栈顶推入一个新元素,注意扩容触发的情况下时间复杂度为O(N)

1
2
3
4
5
6
7
8
9
public void Push(T item) {
if (_size == _array.Length) {
T[] newArray = new T[(_array.Length == 0) ? _defaultCapacity : 2*_array.Length];
Array.Copy(_array, 0, newArray, 0, _size);
_array = newArray;
}
_array[_size++] = item;
_version++;
}

Pop

弹出栈顶元素

1
2
3
4
5
6
7
8
public T Pop() {
if (_size == 0)
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyStack);
_version++;
T item = _array[--_size];
_array[_size] = default(T); // Free memory quicker.
return item;
}

Peek

获取栈顶元素

1
2
3
4
5
public T Peek() {
if (_size==0)
ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyStack);
return _array[_size-1];
}

Contains

遍历栈检查是否包含指定元素,注意如果T是值类型的话,那么每次遍历会有装箱的开销,这种情况下应谨慎使用此函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public bool Contains(T item) {
int count = _size;

EqualityComparer<T> c = EqualityComparer<T>.Default;
while (count-- > 0) {
if (((Object) item) == null) {
if (((Object) _array[count]) == null)
return true;
}
else if (_array[count] != null && c.Equals(_array[count], item) ) {
return true;
}
}
return false;
}

Clear

清空栈内所有元素

1
2
3
4
5
public void Clear() {
Array.Clear(_array, 0, _size);
_size = 0;
_version++;
}

TrimExcess

将容量裁剪到当前元素数量大小,只会在元素数量超过容量90%的情况下调用才会生效。

1
2
3
4
5
6
7
8
9
public void TrimExcess() {
int threshold = (int)(((double)_array.Length) * 0.9);
if( _size < threshold ) {
T[] newarray = new T[_size];
Array.Copy(_array, 0, newarray, 0, _size);
_array = newarray;
_version++;
}
}

总结


  1. 确定使用大小的情况下,尽可能地使用预分配大小的构造函数,以降低后续Push多次扩容的开销。

  2. 尽量不用IEnumrable<T>来初始化Stack

  3. 在栈内数据未知的情况下,每次调用Pop或者Peek前先判断栈内是否有元素(stack.Count > 0)。

前言

复习下HashSet的一些知识点。

源码地址

正文

定义


不重复元素集合, 结构与Dictionary类似,底层为两个数组bucketsslots

成员变量


修饰符 类型 变量名 作用简述
private int数组 m_buckets 位置映射数组
private Slot数组 m_slots 存储所有要查找的元素的数组
private int m_count 容器内当前的元素数量
private int m_lastIndex 最新插入的集合元素的位置
private int m_version 数据版本,用于校验对数组的操作是否合法
private int m_freeList 当前m_slots中指向的空节点
private IEqualityComparer m_comparer 用于查找时比较元素

Slot结构


可以看到与Dictionary内部的Entry结构非常相似,只是少了一个为Key的成员变量。

1
2
3
4
5
internal struct Slot {
internal int hashCode; // Lower 31 bits of hash code, -1 if unused
internal int next; // Index of next entry, -1 if last
internal T value;
}

构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
public HashSet()
: this(EqualityComparer<T>.Default) { }

public HashSet(int capacity)
: this(capacity, EqualityComparer<T>.Default) { }

public HashSet(IEqualityComparer<T> comparer) {
if (comparer == null) {
comparer = EqualityComparer<T>.Default;
}

this.m_comparer = comparer;
m_lastIndex = 0;
m_count = 0;
m_freeList = -1;
m_version = 0;
}

public HashSet(IEnumerable<T> collection)
: this(collection, EqualityComparer<T>.Default) { }

/// <summary>
/// Implementation Notes:
/// Since resizes are relatively expensive (require rehashing), this attempts to minimize
/// the need to resize by setting the initial capacity based on size of collection.
/// </summary>
/// <param name="collection"></param>
/// <param name="comparer"></param>
public HashSet(IEnumerable<T> collection, IEqualityComparer<T> comparer)
: this(comparer) {
if (collection == null) {
throw new ArgumentNullException("collection");
}
Contract.EndContractBlock();

var otherAsHashSet = collection as HashSet<T>;
if (otherAsHashSet != null && AreEqualityComparersEqual(this, otherAsHashSet)) {
CopyFrom(otherAsHashSet);
}
else {
// to avoid excess resizes, first set size based on collection's count. Collection
// may contain duplicates, so call TrimExcess if resulting hashset is larger than
// threshold
ICollection<T> coll = collection as ICollection<T>;
int suggestedCapacity = coll == null ? 0 : coll.Count;
Initialize(suggestedCapacity);

this.UnionWith(collection);

if (m_count > 0 && m_slots.Length / m_count > ShrinkThreshold) {
TrimExcess();
}
}
}

// Initializes the HashSet from another HashSet with the same element type and
// equality comparer.
private void CopyFrom(HashSet<T> source) {
int count = source.m_count;
if (count == 0) {
// As well as short-circuiting on the rest of the work done,
// this avoids errors from trying to access otherAsHashSet.m_buckets
// or otherAsHashSet.m_slots when they aren't initialized.
return;
}

int capacity = source.m_buckets.Length;
int threshold = HashHelpers.ExpandPrime(count + 1);
// 将[被拷贝的hashset元素数量]最优素数大小的容量与当前hashset容量比较
if (threshold >= capacity) {
//当前容量更小就完全按照目标hashset大小容量拷贝
m_buckets = (int[])source.m_buckets.Clone();
m_slots = (Slot[])source.m_slots.Clone();

m_lastIndex = source.m_lastIndex;
m_freeList = source.m_freeList;
}
else {
//当前容量更大就遍历被拷贝的hashset来一个个添加
int lastIndex = source.m_lastIndex;
Slot[] slots = source.m_slots;
Initialize(count);
int index = 0;
for (int i = 0; i < lastIndex; ++i)
{
int hashCode = slots[i].hashCode;
if (hashCode >= 0)
{
AddValue(index, hashCode, slots[i].value);
++index;
}
}
Debug.Assert(index == count);
m_lastIndex = index;
}
m_count = count;
}

public HashSet(int capacity, IEqualityComparer<T> comparer)
: this(comparer)
{
if (capacity < 0)
{
throw new ArgumentOutOfRangeException("capacity");
}
Contract.EndContractBlock();

if (capacity > 0)
{
Initialize(capacity);
}
}

Dictionary的构造函数逻辑类似, 关键函数还是分为以初始容量初始化和以另外的集合来初始化的逻辑。
但是在以其他集合初始化新的HashSet时,会分为两种情况:
1.以另一个具有相同元素类型和比较器的HashSet来初始化。这种情况下会直接调用CopyFrom从目标HashSet中拷贝所有元素。

2.非HashSet的集合,如果实现ICollection接口,那么会先以ICollectionCount大小来初始化

1
2
3
ICollection<T> coll = collection as ICollection<T>;
int suggestedCapacity = coll == null ? 0 : coll.Count;
Initialize(suggestedCapacity);

然后调用UnionWith并集操作,执行并集操作后如果HashSet已使用容量不到总容量的1/3(ShrinkThreshold常量为3),则会调用裁剪函数优化HashSet底层存储数组的内存。

1
2
3
if (m_count > 0 && m_slots.Length / m_count > ShrinkThreshold) {
TrimExcess();
}

扩容机制


在添加新元素时发现达到容量上限时触发,还是以大于2倍当前容量的最小素数为基准扩容,此处逻辑与Dictionary的扩容逻辑非常相似,不赘述。代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
private void IncreaseCapacity() {
Debug.Assert(m_buckets != null, "IncreaseCapacity called on a set with no elements");

int newSize = HashHelpers.ExpandPrime(m_count);
if (newSize <= m_count) {
throw new ArgumentException(SR.GetString(SR.Arg_HSCapacityOverflow));
}

// Able to increase capacity; copy elements to larger array and rehash
SetCapacity(newSize, false);
}

/// <summary>
/// Set the underlying buckets array to size newSize and rehash. Note that newSize
/// *must* be a prime. It is very likely that you want to call IncreaseCapacity()
/// instead of this method.
/// </summary>
private void SetCapacity(int newSize, bool forceNewHashCodes) {
Contract.Assert(HashHelpers.IsPrime(newSize), "New size is not prime!");

Contract.Assert(m_buckets != null, "SetCapacity called on a set with no elements");

Slot[] newSlots = new Slot[newSize];
if (m_slots != null) {
Array.Copy(m_slots, 0, newSlots, 0, m_lastIndex);
}

if(forceNewHashCodes) {
for(int i = 0; i < m_lastIndex; i++) {
if(newSlots[i].hashCode != -1) {
newSlots[i].hashCode = InternalGetHashCode(newSlots[i].value);
}
}
}

int[] newBuckets = new int[newSize];
for (int i = 0; i < m_lastIndex; i++) {
int bucket = newSlots[i].hashCode % newSize;
newSlots[i].next = newBuckets[bucket] - 1;
newBuckets[bucket] = i + 1;
}
m_slots = newSlots;
m_buckets = newBuckets;
}

关键函数


主要分为容器常用函数与集合操作函数

容器常用函数

HashSet容器中, Add, Remove, TryGetValue, Contains等函数的逻辑与前一篇文章中分析的Dictionary的增删查逻辑是相似的。
概括下就是查找的时候通过bucket位置找对应链表,通过比较链表元素中的hashCode找到Slot(Dictionary里叫Entry)

添加的时候优先用缓存链表freeList的空slot,删除的时候将slot置回到freeList上。

代码就不再赘述, 接下来重点看下HashSet独有的一些操作函数。

集合操作函数

此类函数都是基于离散数学中集合的概念来编写的。

并集

将当前HashSet与另一个集合取并集,会修改当前HashSet, 时间复杂度O(N)

union

1
2
3
4
5
6
7
8
9
10
public void UnionWith(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

foreach (T item in other) {
AddIfNotPresent(item);
}
}
交集

将当前HashSet与另一个集合取交集,会修改当前HashSet, 时间复杂度O(N)

intersect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void IntersectWith(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

// intersection of anything with empty set is empty set, so return if count is 0
if (m_count == 0) {
return;
}

// if other is empty, intersection is empty set; remove all elements and we're done
// can only figure this out if implements ICollection<T>. (IEnumerable<T> has no count)
ICollection<T> otherAsCollection = other as ICollection<T>;
if (otherAsCollection != null) {
if (otherAsCollection.Count == 0) {
Clear();
return;
}

HashSet<T> otherAsSet = other as HashSet<T>;
// faster if other is a hashset using same equality comparer; so check
// that other is a hashset using the same equality comparer.
if (otherAsSet != null && AreEqualityComparersEqual(this, otherAsSet)) {
IntersectWithHashSetWithSameEC(otherAsSet);
return;
}
}

IntersectWithEnumerable(other);
}

这个函数有几个优化逻辑:

  1. 如果目标集合是空ICollection的话,那么直接清空这个HashSet(与空集的交集就是空集本身)。
  2. 如果目标集合是具有相同元素类型和比较器的HashSet,那么遍历移除掉目标集合已包含的元素,时间复杂度O(N)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /// <summary>
    /// If other is a hashset that uses same equality comparer, intersect is much faster
    /// because we can use other's Contains
    /// </summary>
    /// <param name="other"></param>
    private void IntersectWithHashSetWithSameEC(HashSet<T> other) {
    for (int i = 0; i < m_lastIndex; i++) {
    if (m_slots[i].hashCode >= 0) {
    T item = m_slots[i].value;
    if (!other.Contains(item)) {
    Remove(item);
    }
    }
    }
    }
  3. 如果目标集合仅仅是可迭代的,那么会先遍历目标集合,用一个BitArray来标记当前HashSet已包含的元素,然后再遍历当前HashSet移除掉所有未标记的元素。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61

    /// <summary>
    /// Iterate over other. If contained in this, mark an element in bit array corresponding to
    /// its position in m_slots. If anything is unmarked (in bit array), remove it.
    ///
    /// This attempts to allocate on the stack, if below StackAllocThreshold.
    /// </summary>
    /// <param name="other"></param>
    [System.Security.SecuritySafeCritical]
    private unsafe void IntersectWithEnumerable(IEnumerable<T> other) {
    Debug.Assert(m_buckets != null, "m_buckets shouldn't be null; callers should check first");

    // keep track of current last index; don't want to move past the end of our bit array
    // (could happen if another thread is modifying the collection)
    int originalLastIndex = m_lastIndex;
    int intArrayLength = BitHelper.ToIntArrayLength(originalLastIndex);
    BitHelper bitHelper;
    if (intArrayLength <= StackAllocThreshold) {
    int* bitArrayPtr = stackalloc int[intArrayLength];
    bitHelper = new BitHelper(bitArrayPtr, intArrayLength);
    }
    else {
    int[] bitArray = new int[intArrayLength];
    bitHelper = new BitHelper(bitArray, intArrayLength);
    }

    // mark if contains: find index of in slots array and mark corresponding element in bit array
    foreach (T item in other) {
    int index = InternalIndexOf(item);
    if (index >= 0) {
    bitHelper.MarkBit(index);
    }
    }

    // if anything unmarked, remove it. Perf can be optimized here if BitHelper had a
    // FindFirstUnmarked method.
    for (int i = 0; i < originalLastIndex; i++) {
    if (m_slots[i].hashCode >= 0 && !bitHelper.IsMarked(i)) {
    Remove(m_slots[i].value);
    }
    }
    }

    /// <summary>
    /// Used internally by set operations which have to rely on bit array marking. This is like
    /// Contains but returns index in slots array.
    /// </summary>
    /// <param name="item"></param>
    /// <returns></returns>
    private int InternalIndexOf(T item) {
    Debug.Assert(m_buckets != null, "m_buckets was null; callers should check first");

    int hashCode = InternalGetHashCode(item);
    for (int i = m_buckets[hashCode % m_buckets.Length] - 1; i >= 0; i = m_slots[i].next) {
    if ((m_slots[i].hashCode) == hashCode && m_comparer.Equals(m_slots[i].value, item)) {
    return i;
    }
    }
    // wasn't found
    return -1;
    }

这个函数牺牲了部分空间(使用了bitArray来标记包含元素)来换取O(N)的时间复杂度,不然就得嵌套for循环以O(N^2)的时间复杂度来处理。
另外这个函数在HashSet元素数量小于等于3170(StackAllocThreshold为100)时会直接在栈上分配内存,超过后才会在堆上分配内存,
从而降低GC的开销。

1
2
3
4
5
6
7
8
9
10
int intArrayLength = BitHelper.ToIntArrayLength(originalLastIndex);
BitHelper bitHelper;
if (intArrayLength <= StackAllocThreshold) {
int* bitArrayPtr = stackalloc int[intArrayLength];
bitHelper = new BitHelper(bitArrayPtr, intArrayLength);
}

internal static int ToIntArrayLength(int n) {
return n > 0 ? ((n - 1) / IntSize + 1) : 0;
}

该函数基于BitArray位运算来标记,一个int32位,最多可以容纳32个byte标记, 实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
unsafe internal class BitHelper {   // should not be serialized

private const byte MarkedBitFlag = 1;
private const byte IntSize = 32;

// m_length of underlying int array (not logical bit array)
private int m_length;

// ptr to stack alloc'd array of ints
[System.Security.SecurityCritical]
private int* m_arrayPtr;

// array of ints
private int[] m_array;

// whether to operate on stack alloc'd or heap alloc'd array
private bool useStackAlloc;

/// <summary>
/// Instantiates a BitHelper with a heap alloc'd array of ints
/// </summary>
/// <param name="bitArray">int array to hold bits</param>
/// <param name="length">length of int array</param>
[System.Security.SecurityCritical]
internal BitHelper(int* bitArrayPtr, int length) {
this.m_arrayPtr = bitArrayPtr;
this.m_length = length;
useStackAlloc = true;
}

/// <summary>
/// Instantiates a BitHelper with a heap alloc'd array of ints
/// </summary>
/// <param name="bitArray">int array to hold bits</param>
/// <param name="length">length of int array</param>
internal BitHelper(int[] bitArray, int length) {
this.m_array = bitArray;
this.m_length = length;
}

/// <summary>
/// Mark bit at specified position
/// </summary>
/// <param name="bitPosition"></param>
[System.Security.SecuritySafeCritical]
internal unsafe void MarkBit(int bitPosition) {
if (useStackAlloc) {
int bitArrayIndex = bitPosition / IntSize;
if (bitArrayIndex < m_length && bitArrayIndex >= 0) {
m_arrayPtr[bitArrayIndex] |= (MarkedBitFlag << (bitPosition % IntSize));
}
}
else {
int bitArrayIndex = bitPosition / IntSize;
if (bitArrayIndex < m_length && bitArrayIndex >= 0) {
m_array[bitArrayIndex] |= (MarkedBitFlag << (bitPosition % IntSize));
}
}
}

/// <summary>
/// Is bit at specified position marked?
/// </summary>
/// <param name="bitPosition"></param>
/// <returns></returns>
[System.Security.SecuritySafeCritical]
internal unsafe bool IsMarked(int bitPosition) {
if (useStackAlloc) {
int bitArrayIndex = bitPosition / IntSize;
if (bitArrayIndex < m_length && bitArrayIndex >= 0) {
return ((m_arrayPtr[bitArrayIndex] & (MarkedBitFlag << (bitPosition % IntSize))) != 0);
}
return false;
}
else {
int bitArrayIndex = bitPosition / IntSize;
if (bitArrayIndex < m_length && bitArrayIndex >= 0) {
return ((m_array[bitArrayIndex] & (MarkedBitFlag << (bitPosition % IntSize))) != 0);
}
return false;
}
}

/// <summary>
/// How many ints must be allocated to represent n bits. Returns (n+31)/32, but
/// avoids overflow
/// </summary>
/// <param name="n"></param>
/// <returns></returns>
internal static int ToIntArrayLength(int n) {
return n > 0 ? ((n - 1) / IntSize + 1) : 0;
}
}

数据结构BitArray的结构如下图所示

bitarray

函数 MarkBit就是先通过bitPosition整除32找到对应的integer, 然后将bitPosition模除32得到其在这个integer的相对二进制位置,然后将目标二进制值置为1,并与原始值进行位或运算进行值合并。

IsMark就是将目标位置的二进制值取出来,进行位且运算判断目标位置二进制值是否为1

比如假设我们调用MarkBit(137), 计算137 / 32 = 4,找到下标为4的位置的int数据, 然后计算 137 % 32 = 9, 那么就将1左移到该int的第9位,然后位或运算将该integer的第9个二进制位置为1

如下图所示

markbit

同理 IsMark(137), 就是取到index4位置的integer第9位二进制值,将其与1进行位且运算判断该位置二进制是否为1,即是否被标记。

差集

移除当前HashSet与另一个集合重复的元素,会修改当前HashSet, 时间复杂度O(N)

except

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void ExceptWith(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

// this is already the enpty set; return
if (m_count == 0) {
return;
}

// special case if other is this; a set minus itself is the empty set
if (other == this) {
Clear();
return;
}

// remove every element in other from this
foreach (T element in other) {
Remove(element);
}
}
对称差集

集合A与集合B的对称差集定义为集合A与集合B中所有不属于A∩B的元素的集合。通俗来说就是A、B集合都有的元素都排除掉,不共有的元素就合并成一个集合。

symmetric

该操作会修改当前HashSet, 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void SymmetricExceptWith(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

// if set is empty, then symmetric difference is other
if (m_count == 0) {
UnionWith(other);
return;
}

// special case this; the symmetric difference of a set with itself is the empty set
if (other == this) {
Clear();
return;
}

HashSet<T> otherAsSet = other as HashSet<T>;
// If other is a HashSet, it has unique elements according to its equality comparer,
// but if they're using different equality comparers, then assumption of uniqueness
// will fail. So first check if other is a hashset using the same equality comparer;
// symmetric except is a lot faster and avoids bit array allocations if we can assume
// uniqueness
if (otherAsSet != null && AreEqualityComparersEqual(this, otherAsSet)) {
SymmetricExceptWithUniqueHashSet(otherAsSet);
}
else {
SymmetricExceptWithEnumerable(other);
}
}

该函数主要分为以下四种情况:

  1. 其中如果当前HashSet为空,那么对称差集就是另一个集合,这时候直接取并集合并即可

  2. 如果另一个集合就是当前HashSet本身,那么对称差集为空集合,直接清空HashSet

  3. 如果另一个集合也是HashSet并且两个HashSet有相同的比较器,那么直接遍历操作,共有的元素移除掉,不共有的元素则合并,时间复杂度O(N)

1
2
3
4
5
6
7
private void SymmetricExceptWithUniqueHashSet(HashSet<T> other) {
foreach (T item in other) {
if (!Remove(item)) {
AddIfNotPresent(item);
}
}
}
  1. 如果另一个集合仅仅是可迭代的集合,那么会调用SymmetricExceptWithEnumerable函数,代码逻辑如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[System.Security.SecuritySafeCritical]
private unsafe void SymmetricExceptWithEnumerable(IEnumerable<T> other) {
int originalLastIndex = m_lastIndex;
int intArrayLength = BitHelper.ToIntArrayLength(originalLastIndex);

BitHelper itemsToRemove;
BitHelper itemsAddedFromOther;
if (intArrayLength <= StackAllocThreshold / 2) {
int* itemsToRemovePtr = stackalloc int[intArrayLength];
itemsToRemove = new BitHelper(itemsToRemovePtr, intArrayLength);

int* itemsAddedFromOtherPtr = stackalloc int[intArrayLength];
itemsAddedFromOther = new BitHelper(itemsAddedFromOtherPtr, intArrayLength);
}
else {
int[] itemsToRemoveArray = new int[intArrayLength];
itemsToRemove = new BitHelper(itemsToRemoveArray, intArrayLength);

int[] itemsAddedFromOtherArray = new int[intArrayLength];
itemsAddedFromOther = new BitHelper(itemsAddedFromOtherArray, intArrayLength);
}

foreach (T item in other) {
int location = 0;
bool added = AddOrGetLocation(item, out location);
if (added) {
itemsAddedFromOther.MarkBit(location);
}
else {
if (location < originalLastIndex && !itemsAddedFromOther.IsMarked(location)) {
itemsToRemove.MarkBit(location);
}
}
}

// if anything marked, remove it
for (int i = 0; i < originalLastIndex; i++) {
if (itemsToRemove.IsMarked(i)) {
Remove(m_slots[i].value);
}
}
}

可以看到用了两个BitArray来分别标记需要移除的元素下标和需要添加的元素下标。函数逻辑第一次先遍历other集合,调用AddOrGetLocation函数来与当前HashSet的元素进行比较。AddOrGetLocation代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private bool AddOrGetLocation(T value, out int location) {
Debug.Assert(m_buckets != null, "m_buckets is null, callers should have checked");

int hashCode = InternalGetHashCode(value);
int bucket = hashCode % m_buckets.Length;
for (int i = m_buckets[hashCode % m_buckets.Length] - 1; i >= 0; i = m_slots[i].next) {
if (m_slots[i].hashCode == hashCode && m_comparer.Equals(m_slots[i].value, value)) {
location = i;
return false; //already present
}
}
int index;
if (m_freeList >= 0) {
index = m_freeList;
m_freeList = m_slots[index].next;
}
else {
if (m_lastIndex == m_slots.Length) {
IncreaseCapacity();
// this will change during resize
bucket = hashCode % m_buckets.Length;
}
index = m_lastIndex;
m_lastIndex++;
}
m_slots[index].hashCode = hashCode;
m_slots[index].value = value;
m_slots[index].next = m_buckets[bucket] - 1;
m_buckets[bucket] = index + 1;
m_count++;
m_version++;
location = index;
return true;
}

如果比较后发现HashSet没有包含当前迭代的元素,就直接添加该元素到HashSet中,并将其标记为需要添加,否则的话要判断是不是之前循环时已经从other集合添加过来的,如果不是的话就说明这是HashSetother集合共有的元素,需要标记其为将要删除的元素。之后就是遍历HashSet的所有元素,将标记为需要删除的元素Remove掉。

子集

判断当前HashSet是否为一个集合的子集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public bool IsSubsetOf(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

// The empty set is a subset of any set
if (m_count == 0) {
return true;
}

HashSet<T> otherAsSet = other as HashSet<T>;
// faster if other has unique elements according to this equality comparer; so check
// that other is a hashset using the same equality comparer.
if (otherAsSet != null && AreEqualityComparersEqual(this, otherAsSet)) {
// if this has more elements then it can't be a subset
if (m_count > otherAsSet.Count) {
return false;
}

// already checked that we're using same equality comparer. simply check that
// each element in this is contained in other.
return IsSubsetOfHashSetWithSameEC(otherAsSet);
}
else {
ElementCount result = CheckUniqueAndUnfoundElements(other, false);
return (result.uniqueCount == m_count && result.unfoundCount >= 0);
}
}

子集判断主要分为以下几种情况

  1. 空集是任何集合的子集,所以HashSet元素数量为0直接返回true就行。
  2. 如果当前HashSet的元素数量超过了other的元素数量,那么肯定这个HashSet肯定就不是other的子集了。
  3. 如果当前HashSetother的比较器是一致的,那么直接判断other是否包含HashSet的所有元素就行了。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    private bool IsSubsetOfHashSetWithSameEC(HashSet<T> other) {

    foreach (T item in this) {
    if (!other.Contains(item)) {
    return false;
    }
    }
    return true;
    }
  4. 如果只能够迭代的话,那么会使用同之前求对称差集一样的思路,使用BitArray来标记计数已存在的元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
[System.Security.SecuritySafeCritical]
private unsafe ElementCount CheckUniqueAndUnfoundElements(IEnumerable<T> other, bool returnIfUnfound) {
ElementCount result;

// need special case in case this has no elements.
if (m_count == 0) {
int numElementsInOther = 0;
foreach (T item in other) {
numElementsInOther++;
// break right away, all we want to know is whether other has 0 or 1 elements
break;
}
result.uniqueCount = 0;
result.unfoundCount = numElementsInOther;
return result;
}


Debug.Assert((m_buckets != null) && (m_count > 0), "m_buckets was null but count greater than 0");

int originalLastIndex = m_lastIndex;
int intArrayLength = BitHelper.ToIntArrayLength(originalLastIndex);

BitHelper bitHelper;
if (intArrayLength <= StackAllocThreshold) {
int* bitArrayPtr = stackalloc int[intArrayLength];
bitHelper = new BitHelper(bitArrayPtr, intArrayLength);
}
else {
int[] bitArray = new int[intArrayLength];
bitHelper = new BitHelper(bitArray, intArrayLength);
}

// count of items in other not found in this
int unfoundCount = 0;
// count of unique items in other found in this
int uniqueFoundCount = 0;

foreach (T item in other) {
int index = InternalIndexOf(item);
if (index >= 0) {
if (!bitHelper.IsMarked(index)) {
// item hasn't been seen yet
bitHelper.MarkBit(index);
uniqueFoundCount++;
}
}
else {
unfoundCount++;
if (returnIfUnfound) {
break;
}
}
}

result.uniqueCount = uniqueFoundCount;
result.unfoundCount = unfoundCount;
return result;
}

最后判断other的元素不重复计数是否与当前Hashset元素数量相同即可,个人觉得后一步的unfoundcount判断其实可以忽略掉

1
return (result.uniqueCount == m_count && result.unfoundCount >= 0);
真子集

判断当前HashSet是否为另一个集合other的真子集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public bool IsProperSubsetOf(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

ICollection<T> otherAsCollection = other as ICollection<T>;
if (otherAsCollection != null) {
// the empty set is a proper subset of anything but the empty set
if (m_count == 0) {
return otherAsCollection.Count > 0;
}
HashSet<T> otherAsSet = other as HashSet<T>;
// faster if other is a hashset (and we're using same equality comparer)
if (otherAsSet != null && AreEqualityComparersEqual(this, otherAsSet)) {
if (m_count >= otherAsSet.Count) {
return false;
}
// this has strictly less than number of items in other, so the following
// check suffices for proper subset.
return IsSubsetOfHashSetWithSameEC(otherAsSet);
}
}

ElementCount result = CheckUniqueAndUnfoundElements(other, false);
return (result.uniqueCount == m_count && result.unfoundCount > 0);

}

逻辑基本与子集判断一致,排除掉hashset本身也是其子集的情况即可。

超集

判断当前HashSet是否为另一个集合other的超(父)集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public bool IsSupersetOf(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

// try to fall out early based on counts
ICollection<T> otherAsCollection = other as ICollection<T>;
if (otherAsCollection != null) {
// if other is the empty set then this is a superset
if (otherAsCollection.Count == 0) {
return true;
}
HashSet<T> otherAsSet = other as HashSet<T>;
// try to compare based on counts alone if other is a hashset with
// same equality comparer
if (otherAsSet != null && AreEqualityComparersEqual(this, otherAsSet)) {
if (otherAsSet.Count > m_count) {
return false;
}
}
}

return ContainsAllElements(other);
}

排除掉两种特殊情况后,直接调用ContainsAllElements遍历other来判断所有元素是否在HashSet中。

真超集

判断当前HashSet是否为另一个集合other的真超(父)集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public bool IsProperSupersetOf(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

// the empty set isn't a proper superset of any set.
if (m_count == 0) {
return false;
}

ICollection<T> otherAsCollection = other as ICollection<T>;
if (otherAsCollection != null) {
// if other is the empty set then this is a superset
if (otherAsCollection.Count == 0) {
// note that this has at least one element, based on above check
return true;
}
HashSet<T> otherAsSet = other as HashSet<T>;
// faster if other is a hashset with the same equality comparer
if (otherAsSet != null && AreEqualityComparersEqual(this, otherAsSet)) {
if (otherAsSet.Count >= m_count) {
return false;
}
// now perform element check
return ContainsAllElements(otherAsSet);
}
}
// couldn't fall out in the above cases; do it the long way
ElementCount result = CheckUniqueAndUnfoundElements(other, true);
return (result.uniqueCount < m_count && result.unfoundCount == 0);

}
集合重叠

判断两个集合是否重叠,与求交集逻辑类似,不同的是两个集合只要发现有一个共有的元素就可以结束遍历了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public bool Overlaps(IEnumerable<T> other) {
if (other == null) {
throw new ArgumentNullException("other");
}
Contract.EndContractBlock();

if (m_count == 0) {
return false;
}

foreach (T element in other) {
if (Contains(element)) {
return true;
}
}
return false;
}

总结


  1. 如果可以的话,在调用HashSet的部分操作函数时,尽量不用IEnumrable<T>作为参数。

  2. 使用BitArray标记遍历来处理逻辑,从而达到空间换时间的做法是一种很好的代码优化思路。

  3. 涉及到集合概念的应用场景可以多考虑使用HashSet代替Dictionary

  4. 在可预估集合元素数量的情况下,尽可能使用预分配容量的构造函数。

  5. 在能确认代码运行安全的情况下,一些临时操作的小批量数据可以尝试直接在栈上分配内存(stackalloc),来降低堆上分配内存后的GC开销。

前言

前几天逛B站无意翻到的,正好闲着没事就听完了这两小时的谈话内容。

视频地址

感受

如果站在行业外的人角度来看,还是能够了解到一些国内游戏业的不为人知的见闻,了解到几个公司老板尝试的一些战略方向。
如果是业内人士,并且有通过业内关系、人脉什么的稍微了解过,那么感受就是听君一席话如听一席话,整个Room Meeting就如标题所说的那样,这类似一个你下班后找几个朋友聚餐闲聊吹牛的社交互动。

不过米哈游和莉莉丝的CEO有些观念还是值得推崇的:以内容创作和玩法设计作为公司的主导方向。

私以为任何技术,其研发的最终目的都是服务于内容的制作,技术的迭代与应用是为了解决实际的问题,而不是作为在其他人踌躇懵懂时向其炫耀的资本。

个人觉得如果一个人在物质上并没有过分的追求,那么其实应该去尝试去追逐精神的诉求。换句话说,如果金钱物质上能够满足衣食住行的基本需求以及子孙后代的优良环境成长,那么剩下的一切我觉得都值得投入到满足自我精神需求(或者说追求理想)的社会活动当中。比如,我作为一个游戏开发者,我是以一个内容创作者或者生产者或者服务者的角色,来参与到社会生产活动当中,并通过生产的内容不断对整个社会产生积极效应(比如向玩家提供优质的消费内容,满足其丰富多彩的精神娱乐需求,或者尝试将游戏技术应用到其他行业,提升生产效率)。并在此过程中结识志同道合的人,满足他们的物质基本诉求(比如有房有车,没有基本生活压力),不停积累,带动更多的人投入到类似的事业当中,最终形成一个积极的行业循环。在国内这种环境下,这种想法也许是异想天开,但至少我个人还是会去尝试。

希望未来我也能拥有自己的团队,我们也许无法产出享誉世界的3A巨作,无法孵化出颠覆业界的尖端技术,但至少能够提供优质的内容让玩家感到快乐,能够给更多的内容创作者提供一个施展自己能力、实现自我理想的平台。