> LeekScript 教程
这一篇我们来聊聊优化。优化的目的是提升算法的性能。一般说到优化,指的都是执行时间:目标是减少执行一系列指令所需的时间。
在 LeekScript 中,这个执行时间用一个数字来衡量:这段计算消耗的操作数。 所以我们会介绍一些优化程序的好习惯和好思路,它们适用于任何语言;也会介绍一些 LeekScript 特有的优化,这些优化与操作数的计算方式密切相关。
如果你还没看过,建议先重读LeekScript 教程中的这篇文章:操作数。
比如,在一个只调用一次的函数上省下 500 个操作数,最终也就只省下 500 个操作数。(很合理) 反过来,如果一个函数是在三层嵌套循环里被调用的,哪怕只省下 1 个操作数,也可能让总消耗减少好几万个操作数。
这就引出了第二点:
优化代码的一半工作,其实就是找出操作数都花到哪儿去了(找到之后再大声喊它们回来)。代码里到底什么最耗操作数?
这没有什么诀窍:动手测量!
LeekScript 为我们准备了一个非常好用的工具:getOperations。 这个函数能告诉你代码到目前为止已经消耗了多少操作数。
下面是一个测量函数开销的简单工具:
global __debug_operation; function startOp(){ __debug_operation = getOperations(); } function stopOp(title){ var ops = getOperations()-__debug_operation - 3; debug("Operations (" + title + ") : " + ops); }
startOp(); stopOp("test à vide"); // 空测试:0
startOp(); say("hello world"); stopOp("hello world"); // hello world: 30
这样就能确认 say 确实消耗 30 个操作数,和文档里写的一样。
你也可以(我也强烈推荐你这么做)给自己的 AI 写一些测量其他数据的工具,比如某个函数的调用次数及其平均开销。这样你就能更好地掌握 AI 中各部分开销的变化,以及某项优化到底带来了多大效果。
复杂度这篇文章会帮你理解一个算法为什么耗操作数,以及如何解决这个问题。简单来说,就是尽量避免循环嵌套循环。同时也要尽量缩小循环的规模,比如只遍历可达格子,而不是遍历地图上的所有格子。 需要注意的是:当要处理的元素足够多时,复杂度更低的算法消耗的操作数会少得多。在去抠那些细枝末节的优化之前,先想想这一点!
假设有下面这段代码:
var TP = getTP();
// 对敌人能打几次就打几次! for (var i = 0; i 回到前面的例子,这意味着在使用数组中的值之前,要先检查一下它:
global WEAPON_MAX_RANGE = [];
var pistolMaxRange;
var weaponMaxRange = WEAPON_MAX_RANGE[WEAPON_PISTOL]; if (weaponMaxRange !== null) { pistolMaxRange = weaponMaxRange; } else { var newMaxRange = getWeaponMaxRange(WEAPON_PISTOL); WEAPON_MAX_RANGE[WEAPON_PISTOL] = newMaxRange; pistolMaxRange = newMaxRange; }
在获取已知结果时,这比单纯的缓存要多花一些操作数。但这样就不必提前算好所有东西,也可以避免计算一些根本用不上的值。 不过,这样写代码还是很繁琐。幸好这类事情可以自动化。只需写一个函数,它的参数是:要记忆化的函数、存放结果的缓存,以及当结果不在缓存中时要传给被记忆化函数的参数:
function applyMemo(f, mem, x) { var ret = mem[x]; if (ret !== null) { return ret; } else { ret = f(x); mem[x] = ret; return ret; } }
以 getWeaponMaxRange(WEAPON_PISTOL) 为例,只需这样使用 applyMemo:
global WEAPON_MAX_RANGE = [];
var pistolMaxRange = applyMemo(getWeaponMaxRange, WEAPON_MAX_RANGE, WEAPON_PISTOL);
还可以让它用起来更简单:定义一个 memoize 函数,它接收一个要记忆化的函数作为参数,并返回一个记忆化之后的函数。(注意:用起来越方便,消耗的操作数就越多。最好根据你对性能和易用性的需求,估算一下哪种方法更合适。)
function memoize(f) { var mem = [];
return function(x) { return applyMemo(f, mem, x); }; }
以 getWeaponMaxRange(WEAPON_PISTOL) 为例,只需这样使用 memoize:
global mWeaponMaxRange = memoize(getWeaponMaxRange);
var pistolMaxRange = mWeaponMaxRange(WEAPON_PISTOL);
需要说明的是,这里给出的实现并不是最优化的版本。自己动手改进吧。
> 注:想写得更简洁,可以利用赋值表达式会返回所赋的值这一点:
记忆化很好用,对吧? 可是,如果一个函数有好几个参数,要怎样用这种方法存储它的结果呢?
哈希的原理是把信息编码到一个更小的空间里。根据维基百科,哈希函数是一种特殊的函数,它根据输入的数据计算出一个数字指纹,用来快速识别原始数据。
假设我们想存储 lineOfSight 函数的结果,它有 2 个参数(起点格子和终点格子),可以简单地这样写: 小提示:格子的取值范围是 0 到 612。
搞定,一点也不难!得到的哈希是一个数字,却包含了两个格子的信息!比如 cell1 = 13、cell2 = 310 时,哈希值就是 13310。 这里乘法消耗 5 个操作数,if 消耗 1 个,每次访问消耗 2 个,加起来至少 11 个操作数,而 lineOfSight 要消耗 30 个。
不过还能做得更好!
哎呀,我看到有人瞪大了眼睛。别怕,真的不难,我发誓! LeekScript 使用 32 位编码的数字,也就是说,一个数字由 32 个 0 或 1 存储。 这个方法的思路,就是利用其中的一部分空间来存放我们的数据。 百闻不如一见,回到 lineOfSight 函数: 首先,找到比我们要存储的最大数字更大的那个 2 的幂:
2^ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | ... | 31 ---|---|---|---|---|---|---|---|---|---|---|----|----|-----|--- 结果 |1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | | 2147483648
格子最大为 612,所以我们取 1024,也就是 10 位(2^10 = 1024)。
现在只需要用移位运算符把这些都存起来。你至少要会用其中两个:| 和 <<(可以去看看位运算符的用法温习一下)。 我们从一个空变量开始,它默认等于 0:
var hash = cell1 << 10; //放入左移 10 位后的 cell1 hash = hash | cell2; //在低位放入 cell2
把这段代码简化一下,就是 var hash = cell1 << 10 | cell2;,只消耗 3 个操作数! cell1 = 13、cell2 = 310 时,哈希值是 13622。 于是 hash 变量里存的是一个二进制数,其中 10 位留给 cell1,10 位留给 cell2。
最终这个函数只消耗 7 个操作数,提升不大,但也不容忽视。
当我们想在二进制哈希里存放更多信息时,它真正的威力才会显现:
要记忆化可达格子,需要哪些信息呢? 起点位置、可用的移动点(MP),再加上韭葱的 id(为什么不呢!)
首先要确定每项数据能占用多少位:
其实只要取一个足够大、不会被超过的数就行!随便挑一个,比如 20 MP。这样就需要 5 位。
什么什么??这些数字跟我们刚才定好的位数对不上啊,不是吗? 其实很简单:
mp 需要 5 位,所以 id 要左移 5 位,给 MP 腾出位置id 需要 6 位,所以格子要左移 5 + 6 = 11 位!很合理吧。注意一定要检查所用的总位数没有超过 64 位,否则多出来的位会消失得无影无踪! 这里一共是 10 + 6 + 5 = 21 位,绰绰有余!
为了不出错,可以拿一张纸把方案记下来:
数据 | cell | id | mp | -------|------|----|----| 大小(位) | 10 | 6 | 5 偏移 | 6 + 5 = 11 | 5 + 0 = 5 | 0
现在试试存储 2 个格子和一件武器/芯片吧!如果你做到了,就说明你已经掌握了二进制哈希的艺术。
如果要存储的数据超过 64 位,该怎么办?比如要处理一个格子数组,我们的二进制妙招就没法把它全部哈希进去了。
好在程序员从来不缺办法:我们来把它熬成一锅粥。 二进制方法的优点是,需要的时候还能把存进去的信息取出来。而这一次,我们要彻底放弃取回信息的希望。
对于数组,可以用一个神奇的公式:
原理很简单:每次把哈希值乘以一个相对于 element 足够大的数,再加上元素来更新它。 注意,这个函数能用(咳咳),但远远不能保证结果的唯一性:输入不同时输出也一定不同,这一点只能靠运气了。 (反正它从来没让我失望过)
使用这个函数时要记住:
array 必须很小(少于 15 个元素没问题,再多就要冒风险了)element 的是什么,免得哈希值的大小爆掉、丢失信息。有时候,为了提升性能,不得不牺牲代码的可读性。 这一节专门收录一些给抠操作数的玩家准备的“脏”技巧,使用风险自负! 建议只在调用非常频繁的底层函数中使用这些方法。
floor 函数返回一个数向下取整的结果。比如 floor(0.9) = 0、floor(199.724) = 199…… 巧的是,二进制运算符只用 1 个操作数就能把数字转换成整数! 所以用 数字 | 0,就能把数字转换成整数(默认向下取整),效果和 floor 一样!
只要 1 个操作数,又省下了 1 个操作数!
两个值的平均值可以这样计算:
一共 6 个操作数。用下面的写法,只要 2 个:
这里用二进制的移位运算符来实现除以 2。 注意! 由于隐式转换,结果等同于 floor((value1 + value2) / 2)。 (感谢 Kavaliov)
== 和 != 运算符各消耗 1 个操作数,这 1 个操作数就是多余的!
这里我们要利用后台的隐式转换,就像简化 if 时那样。 恰好 if(null) 和 if(0) 的结果都是 false,也就是说,这个 if 后面的条件分支不会执行。 如果确定映射里不会有 0、null 或布尔值,就可以干脆不写 ==。
Impossible de charger les données du jeu.
Vérifiez votre connexion et réessayez.