王辉的博客

是什么让我对未知世界始终充满热情?

这周的codingdojo玩了一道很有收获的题,终于搞明白了保龄球的计分方法。原题可以在reddit上的daily programmer上找到,这里我分享一下我的解题思路。

机制

保龄球的一局可以分成两个阶段,从第一轮到第九轮是一个阶段,第十轮是第二个阶段。

一到九

一到九的这九轮中,每一轮最多有两次掷球机会。如果一下全中的话,直接进入下一轮。

一击全中

如果一次就打中十个瓶的话,就得了一个Strike, 用X做标记。之所以用X标记,是为了表示它的最终得分还暂时未知。这是因为一击全中是有奖励的,除了先得十分之外,接下来的两次掷球的得分,也会加到这个X上。

两击全中

如果用两次机会打完,就得了一个Spare, 用第一个球的得分加”/“来标记。比如说第一个球得6分,第二球4分,那么就是6/。同样地道理,/表示暂时未知,两击全中也有奖励,但只会加入接下来一次掷球的得分。

没有全中

如果两次机会用完,都还没有全中,那么这一轮的得分就直接知道了,第一球加第二球的得分。比如先得了6分,又得了2分,那么就记为62, 6+2得8分。如果的零分的话,用一横杠表示。

第十轮

第十轮比较特殊。这一轮至少有两次机会,最多可以有三次。如果在前两次之内能全中的话,那么就可以再掷第三球,否则的话,两次就完事。

除此之外,第十轮的每个球都单独计分,没有奖励机制。

举例

讲完了这些规则之后,大家可以猜一猜,保龄球一局的最高得分是多少?怎样才能的最高分呢,那就是每次都一击全中,由下表可见,最高分能达到300分。

轮数 1 2 3 4 5 6 7 8 9 10
结果 X X X X X X X X X XXX
裸分 10 10 10 10 10 10 10 10 10 10 10 10
奖励 20 20 20 20 20 20 20 20 20 0 0 0
总分 30 30 30 30 30 30 30 30 30 10 10 10

下面这个例子的总得分应该140分。

轮数 1 2 3 4 5 6 7 8 9 10
结果 62 71 X 9- 8/ X X 35 72 5/8
裸分 62 71 10 90 82 10 10 35 72 5 5 8
奖励 0 0 9 0 10 13 8 0 0 0 0 0
总分 8 8 19 9 20 23 18 8 9 5 5 8

算法

一旦理解了计分机制,算法就很直观了。建模的时候,主要有两个重点:

  • 计分应该分成两个阶段,前九轮和第十轮
  • 计分单位应该以球为单位,而不是以轮为单位

Scala很简洁很直观地实现了这个计分算法

计算前第九轮成绩的时候,一定不要忘记把第十轮的前两球考虑进去,以便奖励第九轮可能出现的全打

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
object Solution {

def score(frames: Array[String]) = {
val oneToNine = frames.init.flatMap(_.toCharArray)
val ten = frames.last.toCharArray
val scoreDictOneToTen = ScoreDict(oneToNine ++ ten.take(2))
val scoreOneToNine = oneToNine.zip(0 until oneToNine.size).map({ case (c, i) =>
c match {
case 'X' =>
scoreDictOneToTen.score(i) + scoreDictOneToTen.score(i + 1) + scoreDictOneToTen.score(i + 2)
case '/' =>
scoreDictOneToTen.score(i) + scoreDictOneToTen.score(i + 1)
case _ =>
scoreDictOneToTen.score(i)
}
}).sum

val scoreDictTen = ScoreDict(ten)
val scoreTen = ten.zip(0 until ten.size).map({ case (c, i) => scoreDictTen.score(i)}).sum
scoreOneToNine + scoreTen
}

case class ScoreDict(chars: Array[Char]) {
def score(i: Int): Int = toScore(if (i == 0) '*' else chars(i - 1), chars(i))

private def toScore(first: Char, second: Char): Int = second match {
case 'X' => 10
case '-' => 0
case '/' => 10 - toScore('*', first)
case num => num - '0'
}
}
}

如果你需要训练算法,或者准备面试,推荐你破解这些题目:https://github.com/huiwang/cracking-the-coding-interview

这是看完Aeron的设计原则之后的感触。关键的不是看他们设计原则本身是在讲什么,而是这些原则如何指导,对各种冲突的权衡与取舍。

不仅仅是在软件设计中,即便是生活里,冲突随处可见。拿出一个解决办法,满意了小明,却顾不上小红。另一个方案,取悦了小红,却委屈了小明。何去何从,我们需要取舍。

取舍要有原则,可原则怎么定?像Aeron里做的那样,把你考虑到系统的各个方面按他们的重要性排序。如果说小明比小红重要,两个有冲突的解决方案的取舍,就应该从小明的角度来定。

软件的架构,最核心的步骤,就是定义这些取舍的原则,原则清晰了,系统的设计也就差不多了。

Rift,这是我既Pokerchips之后,参加的第二个codingame组织的比赛,淋漓尽致的释放了我对编程的热爱的同时,收获颇丰。

除了获得了,在1028名来自世界各地的参赛者中,名列前2%的成绩之外,这次参赛经历,首先使我对Scala有了更加实际的应用,加深了对这个语言的了解,也体会到了面相函数式编程在人工智能方面的优势。

其次,更加意识到,单元测试,特别是行为驱动开发(BDD),在软件开发中所发挥的巨大作用。再然后,就是大大培养了对人工智能游戏开发的兴趣,也慢慢开始开窍了。

最后,从学习方发方式来讲,更加深信不疑,一个好的学习习惯可以帮助一个人以最快的速度获取事物的本质和他们的内在联系,进而抓住解决问题的突破口。

Scala

从上一年接触了Scala之后,就一直想练练手。只是苦于一直没有找到能吸引我的项目。这次经历,通过实战运用,使我对这个语言有了更加深入的认识。

相对于Java来说,Scala有几个让我爱不释手的特点。

宣告式编程(Declarative programming)

宣告式编程,是一种编程方式。它给解释计算机想解决什么问题,而不是简单的给计算发送一条又一条的控制指令,告诉它如何解决问题。宣告式编程的最大好处,就是它帮助写一些没有副作用的优美函数,这对程序的确定性及可预见性有特别大的帮助。这个我可以另开一帖专门讨论。

不可变性 (Immutability)

不可变性,说的是一旦一个物体被创建了,他就不会再变了。这样做的好处是你把一个创建的对象,放在哪里,不管你把它给了谁,也不管你多长时间之后再来看它,它都会像你创建时的那样,纹丝不动。

简洁

Scala去除了一切不必要的冗繁的代码。我们可以把更多的精力放在问题本身,而不是语言本身。就像case class, type infer 等种种功能。


单元测试

有人说Codingame应该开发一个调试的功能,我觉得是完全没有必要的。反而是不应该受到鼓励的。正确的方法是写测试,写测试有很多好处,特别是在Codingame比赛的这个环境下,更应该写单元测试。

高效的反馈

为了验证程序设计的正确与否,很多人都是把它跑起来,然后看看结果对不对。这样以来,就大大延长了反馈的时间,比赛的长短先不说,从把代码粘贴过去,到跑起来,再到最后得到结果,也得花近一分钟的时间。而单元测试,则是实时的。特别是Scala的SBT工具,测试是持续在跑的,只要发现代码变化,测试就会重新跑一遍。

回归测试

单元测试的另外一个好处,就是有了它,你可以毫无后顾之忧的搞重构。重构是为了,优化代码的结构,使得它更灵活的朝更有利的方向发展,既不加新功能,也不能破坏已有的行为。不破坏已有的行为,是单元测试来保证的。

除了重构之外,无法避免的就要加新的行为,一个新的行为加入,是要保证回归测试的,不能拾个芝麻,留个西瓜。

不论是重构,还是要加新功能,我们关注的都是对已有行为的保护,所以这就要求测试要以测试行为为主。测试行为,不仅可以从行为的层面清晰的阐明问题的需求,又可以避免受到细节的变化对测试的影响。

具体结合Rift本身来讲,比赛进展到中程的时候,地图变了,游戏的策略也必须与时俱进。之前只关注1v1的竞赛着,之后就必须考虑1v2和1v3了。如此一来,就会有重构,有新行为的添加,但又得保证之前的1v1策略不受影响。测试就发挥的巨大的作用。


人工智能

两个比赛比完之后,我对人工智能产生了浓厚的兴趣。之前我之所以很痴迷于电脑游戏,是因为在游戏里我可以发挥我的创造性,自由的尝试我的各种想法。人工智能,有同样的魅力,并且有更大的空间去发挥想象力,去尝试和探索不同的策略,就像玩游戏一样。不同的是,人工智能的策略,不是玩出来的,是自己用手和大脑,一行一行,编出来的,就像你在开发一个机器人,机器人会完全服从你,执行你的想法。

同样,我想在写一篇文章来谈我对制定人工智能游戏策略的体会。


学习方式方法

这是我体会最为深刻的一点。又让我想起了之前的一些困惑,上大学的时候,我总是在问学那么多课,做那么多将来都用不上的光学试验,究竟是为了什么。现在我开始慢慢明白了,那是为了锻炼我们学习的方式方法,锻炼我门的观察能力,锻炼我门发现问题的能力,提高我门推断事物内部联系的能力,进而找到问题的关键点。

编好Rift这种多人人工智能对战游戏,就必须有这些能力。知己知彼,百战不怠。了解别人,要从观察它门的行为开始,之后要对他们的行为进行分析,进而推敲出来他们的战法和策略。好的策略,要学来自己用,不好的要抓住他们的弱点,一举拿下。

结语

收获和体会是多方面的,有很多都需要做更详细的解释。接下来,我会分享我在这个对战游戏里,所用的策略,以及如何更加高效的参加Codingame的比赛。最后,能顺利的参加完这个比赛,特别需要感谢小微的大力支持,小微,谢谢你。

并发(concurrency)和并行(parallelism)是两个完全不同的概念。

并发,从它的英语本意来看是竞争的意思。这是理解它的关键。没有竞争,并发就无从谈起。为什么会有竞争,那是因为资源是有限的。资源是一方面是有限的,一方面还要满足不同主体的需求,这就会遇到资源的共享。这样以来,就要指定一些规则,让大家有效,并且安全的利用这些资源。这里的并字,强调的是一种秩序。

并行,和竞争没有半毛钱的关系。并行指的是一个任务的属性,看它能不能被化分成相互独立,可以同时完成的小任务。比如说,有一个任务,是要数一本书一共有多少字,那么这个任务就可以被分成很多份,让小明数十页,小微数另外十页。此处的并字,侧重点是指任务能不能可分,能不能同时进行,来达到加速的目的。

两者,不是一个概念,不对立存在。不同的用法是为了达到不同的目的。一个很好的例子,就是JVM中不同的垃圾回收算法,有的是并发的,有的是并行的。

推荐文章(由hexo文章推荐插件驱动)

说有模块A,B,C。A依赖B和C,B依赖C。在A的POM中,可以只声明对B的依赖,而省略C。

有人说了,这样做不好,如果有一天B不依赖C了,而A仍然需要C,A就傻逼了,因为他躺着中了一枪。所以有人说,A一定要明确的声明对C的依赖,而不是靠B做一个传递依赖。

我觉得,任何事的对与错都不是绝对的,得看。

如果真的不好,与其保留这种名不正言不顺的做法,不如让Maven直接给它删了,何必模棱两可呢。避免许多口舌。

这里所说的级别是指一个测试所覆盖的系统的范围的大小。一个测试所覆盖的系统越多,它的级别也就应当越高。之所以想谈软件的级别,是因为对测试级别的不合理划分会引发更多的开发成本,降低开发效率。

在软件开发的整个生命周期中,在它的交付阶段,一个必不可少的步骤便是验收测试。验收测试的目的在于证明软件可以满足客户的所有需求。这个测试是软件测试中的最高级别的测试。它的特点有,系统覆盖范围最为全面,从用户界面,到算法逻辑,到数据存储,全部囊括其中。其次,测试必须基于用户的真实使用环境,一切都是真枪实弹,不能参杂任何的仿真的元素。最后,这种测试往往不能被完全自动化,它的执行会牵连到测试人员的直接参与。

从以上特点我们可以总结出来,一个测试的级别越高,它的执行成本就会越高。其中包括,资源环境的配置,人员的直接干预。这也就必然决定了测试的反馈时间延长(比如说配置环境需要半天,测试人员到位需要提前预约)。其次,由于大的覆盖范围,分析问题,鉴定问题的难度也会增加。因此,一个测试的级别越高,它被执行的次数就应该越少,它被执行的时间段就因该越晚。

我并不反对写高级别的测试,但我反对把一个验收测试当作单元测试用。

 

技术负债是敏捷开发里的一个词汇,讲的是为了快速解决一个问题,不惜代价,写一个解决方案。燃眉之急虽然接了,却使得代码更加混乱,导致下一次回答新需求的时候,难度更大,耗时更长。就是今天欠下的债,总归要还的。

其实这是把软件开发中遇到的问题,用更加简单易懂的方式,说给非技术人员说的。

然而,往往在很多非技术人员的眼里,负债是一件好事情。贷了款是负债了吧,不过这是一种发展投资的方式,反而会有利于企业的发展。

所以与其用这个可能引起歧义的概念来打比方,倒不如,说得更干脆,跟直接一点,有人便提出了的技术安全的概念。

0%