在2025年初春,我们将看到Neo4j的查询语言Cypher的下一个主要版本的发布。这将被命名为Cypher 25。在首次发布中,将有一个新功能,即条件性查询功能。我想为该功能写一篇博客,并为此需要一个用例,所以我决定用Cypher实现康威生命游戏。我写完了整篇博客,感觉还行,但却突然意识到,我可以以一种更简洁的方式完成这个查询,而无需使用条件查询。
想象那种绝望吧!我有一个自己还挺满意的博客,但它的主要目的却已经找不到了(全都是因为这该死的语言太好了)。就在我以为一切都没希望的时候,我看到了我同事Jon Giffard发的一个竞赛,挑战用GraphQL编写生命游戏。我决定在生命游戏的帖子中给出一个提示,用另一种语言解决这个问题。正如我们接下来会看到的,Cypher非常适合解决这个问题。
顺便提一下,我确实想出了另一个例子来说明条件查询的用法,所以那篇博客还是会出来的。它将在今年晚些时候随着Cypher 25的发布一起发布。
生命游戏英国数学家约翰·何顿·康威在1970年发明了康威生命游戏,这是一种[细胞自动机模型]。
https://www.gameofflife.com/about_l.html
关于游戏的更多信息,请参见这里
在《生命游戏》里,你有一个游戏棋盘(网格),这个网格有X列和Y行。网格中的每个单元格要么是活的(通常用黑色正方形表示),要么是死的(白色正方形)。除了棋盘边缘的单元格外,每个单元格都有八个邻居(上、下、左、右以及四个对角线方向的单元格)。
对于每一世代(步长),一个细胞可以出生、死亡或保持现状(无论是活着还是死了)。细胞的命运取决于其周围活细胞的数量。如果邻居少于两个,它会因孤独而死;如果邻居多于三个,它会因饥饿而死。以下表格描述了在不同条件下细胞会发生什么:
活邻居数量(个) | 细胞状态 |
---|---|
小于2 | 死亡(如果还活着) |
2 | 保持现状 |
3 | 出生(如果死亡) |
大于3 | 死亡(无论是否活着) |
确保整个文本使用一致的术语,例如“保持现状”。
活邻居数 活细胞 死细胞
≤1 死亡,不再活 保持死亡状态
2 存活 继续保持死亡状态
3 存活 复活
≥4 死亡,不再活 保持死亡状态
这意味着如果一个活细胞有两或三个活邻居,它就能存活,否则它就会死去。如果一个死细胞有三个活邻居,它就能复活。
所以如果我们有一个5乘5的格子的游戏板,并从这个初始状态开始。
最初的状况
经过一个回合后就会:
在一秒钟后
又过了一个刻度之后:
两次滴答声之后
实现篇你可能以为康威在设计他的游戏时就已经是一位Cypher专家了,但考虑到Cypher甚至在他构思游戏的四十多年后才被发明,没有时间机器穿越,这似乎不太可能。然而,这个算法似乎天生就适合Cypher。
在使用像Java这样的传统语言实现时,一个棘手的部分是同一时间步的所有事件是密集的。这意味着如果一个细胞在这一时间步中诞生,在评估其邻居时不应再将其计算为新诞生。一个解决方案是在另一个游戏板副本中创建新的状态,然后将其作为当前状态。
但在像 Cypher 这样的声明性语言中,我们无需考虑这点,这使得它非常适合这样的任务。当我们使用 MATCH 后跟一个写操作比如 SET 时,后续的写操作不会影响之前的 MATCH 操作。
Java 实现版我们来看看初始状态是硬编码的(如前一节所述的例子)的Java实现可能看起来像什么:
public class 生命游戏 {
private static final int 周期 = 2;
private static final int 宽度 = 5;
private static final int 高度 = 5;
private static final int[][] 初始状态 = {{2,1},{1,2},{2,2},{3,2},{1,3},{3,3},{2,4}};
private final int 宽度;
private final int 高度;
private boolean[][] 游戏板;
public 生命游戏(int 宽度, int 高度, int[][] 初始状态) {
this.宽度 = 宽度;
this.高度 = 高度;
游戏板 = new boolean[宽度][高度];
for (int[] 单元格 : 初始状态) {
游戏板[单元格[0]][单元格[1]] = true;
}
}
public void 迭代() {
boolean[][] 新状态 = new boolean[宽度][高度];
for (int y = 0; y < 高度; y++) {
for (int x = 0; x < 宽度; x++) {
int 邻居数 = 计算邻居数(x, y);
if (邻居数 <= 1 || 邻居数 >= 4) {
新状态[x][y] = false;
}
else if (邻居数 == 3) {
新状态[x][y] = true;
}
else {
新状态[x][y] = 游戏板[x][y];
}
}
}
游戏板 = 新状态;
}
private int 计算邻居数(int x, int y) {
int 邻居数 = 0;
for (int yy = Math.max(y-1, 0); yy <= Math.min(y+1, 高度 - 1); yy++) {
for (int xx = Math.max(x-1, 0); xx <= Math.min(x+1, 宽度 - 1); xx++) {
if ((xx != x || yy != y) && 游戏板[xx][yy]) {
邻居数++;
}
}
}
return 邻居数;
}
public void 打印状态() {
for (int y = 0; y < 高度; y++) {
for (int x = 0; x < 宽度; x++) {
System.out.print(游戏板[x][y] ? "█" : " ");
}
System.out.println();
}
}
public static void main(String[] args) {
生命游戏 游戏 = new 生命游戏(宽度, 高度, 初始状态);
for (int i = 0; i < 周期; i++) {
游戏.迭代();
}
游戏.打印状态();
}
}
很可能写出一个更简洁且结构更好的实现,但是在 Java 中它仍然显得相当繁琐。相比之下,在 Cypher 中,统计邻居数量和管理双重状态之类的操作要简单得多。
加密实现对于 Cypher 实现,我们将把游戏板作为一个图存储在数据库中。该图由名为 Cell 的节点组成,每个节点都有八个邻居节点,彼此之间都有一种称为 NEIGHBOR 的关系(不考虑方向)。
这是用来实现康威生命游戏的图形(假设游戏板为5*5)
首先,我们需要计算网格中每个单元格的邻居数量,以供我们的tick查询使用。正如我们在Java代码中看到的,我们用一个嵌套的for循环遍历所有单元格,并用另一个嵌套的for循环计算每个单元格的邻居数量。而在Cypher中,我们仅用三行代码就能完成这两项任务。
MATCH (cell:Cell)
// 匹配与当前细胞相邻且存活的细胞
OPTIONAL MATCH (cell)-[:NEIGHBOUR_OF]-(:Cell {alive: true })
// 计算每个细胞的存活邻居数量
WITH cell, COUNT(*) AS liveNeighbours
// liveNeighbours 表示每个细胞的存活邻居数量
现在我们需要根据这一点来更新单元格的状态。本来想在这里展示条件查询,但后来发现用情况表达式解决这个问题更高效。
SET 单元格.alive =
IF 活邻居的数量 <= 1 THEN 单元格.alive = FALSE
WHEN 活邻居的数量 >= 4 THEN 单元格.alive = FALSE
WHEN 活邻居的数量 = 3 THEN 单元格.alive = TRUE
ELSE 保持单元格.alive的当前状态不变
Alternatively, for better readability and fluency:
如果活邻居的数量小于等于1,那么设置单元格.alive为FALSE;如果活邻居的数量大于等于4,同样设置单元格.alive为FALSE;如果活邻居的数量等于3,设置单元格.alive为TRUE;否则,保持单元格.alive的当前状态不变。
就这样,这是我们实现tick所需的所有内容。
完整的 Cypher为了与Java版本进行比较,我们需要设置初始状态、输出结果等过程。首先,我们要删除数据库中可能存在的任何先前游戏。
MATCH (c:Cell) DETACH DELETE c
/ 删除所有 Cell
节点 /
MATCH (c:Cell) DETACH DELETE c
然后我们就可以定义初始状态参数(如下所示,与Java版本中使用的相同)。
参数如下:
{
高度: 5,
宽度: 5,
单元格列表: [[2,1],[1,2],[2,2],[3,2],[1,3],[3,3],[2,4]]
}
现在让我们根据前一节中的图片创建游戏棋盘。我们将在每个格子里加上坐标,这样我们以后就可以把这个图可视化出来了。
UNWIND range(0,$height-1) AS row
WITH row
UNWIND range(0,$width-1) AS column
WITH row, column
CREATE (cell:Cell {coordinate: Point({x:column,y:row}), alive:false})
WITH row, column, cell
UNWIND [[row-1, column-1],[row-1, column],[row-1, column+1],[row, column-1]] AS neighbor
MATCH (other:Cell {coordinate: Point({x:neighbor[1],y:neighbor[0]})})
MERGE (other)-[:邻居]->(cell)
那么我们来设定初始状态:
展开 $cells 为 cell
匹配 (c:Cell {坐标: Point({x:cell[0],y:cell[1]})})
设置 c.活着 = 真
现在我们已经设置好了一切。那么,我们将按照我们想要tick的次数来运行tick查询(与之前使用的相同)。按照之前的示范,我们就tick两次(即运行此查询两次)。
MATCH (cell:Cell)
OPTIONAL MATCH (cell)-[:邻近的]-(:Cell {alive: true })
WITH cell, COUNT(*) AS 活邻居数 // 计算活邻居的数量
SET cell.alive =
CASE 活邻居数
WHEN <= 1 THEN FALSE // 如果活邻居数小于等于1,则cell不存活
WHEN >= 4 THEN FALSE // 如果活邻居数大于等于4,则cell不存活
WHEN = 3 THEN TRUE // 如果活邻居数等于3,则cell存活
ELSE cell.alive // 否则,cell的状态保持不变
END // 根据活邻居数来更新cell的状态
最后,我们希望能够打印状态信息。我们能够用X表示活细胞,用点表示死细胞,就像我们在Java版本中做的那样。
// 以下是 Cypher 查询,用于根据坐标对细胞进行排序并生成表示活细胞和死细胞的字符串
MATCH (cell:Cell)
// 匹配每个细胞 (cell:Cell)
WITH cell ORDER BY cell.coordinate.y ASC, cell.coordinate.x ASC
// 根据 y 和 x 坐标升序排序
WITH collect(cell) AS cells
// 收集所有细胞并命名为 cells
RETURN reduce(
res = "",
cell IN cells |
res +
CASE cell.coordinate.x = 0
WHEN true THEN "\n"
// 如果细胞的坐标 x 为 0,则添加换行符
ELSE ""
END +
CASE cell.alive
WHEN true THEN "X"
// 如果细胞是活的,则用 'X' 表示,否则用 '.' 表示
ELSE "."
END) AS result
你可能会注意到,我们在输出实际内容前总是打印一个空行。这在Neo4j查询工具中运行时看起来更整洁。该工具会用引号开头,通过在开头添加一个空行,我们可以使各行对齐得更好,这样就不会因为引号而导致第一行缩进。
如上所示的图案
"..X.."
".X.X."
"XX.XX"
".X.X."
"..X.."
一个图形化的版本.
现在我们有了一个可以在Java和Cypher之间进行比较的方式,两者都能在经过一段时间后输出ASCII状态。但有没有一种方法可以每次状态更新时都能看到图形化展示呢?在Java中,我们可以通过创建一个JFrame并在其中渲染状态来实现这一点。这虽然需要写更多的代码,但实现起来并不会太难。那么用Neo4j也能做到吗?
前不久,我写了一篇关于用Cypher渲染Mandelbrot分形的博客文章(博客)。我还用ASCII艺术形式展示过那个结果。但一位同事提到,我本可以用Neo4j Bloom得到一个半图形化的输出结果,我尝试了一下之后,结果非常令人印象深刻。
到目前为止,我一直用Aura Free进行这些实验,我们就用这个。它是免费的,包括了Bloom,并且对于我们这里使用的这些小图来说已经绰绰有余。如果你还没有一个实例的话,你可以点击上面的链接,用你的Google账户注册并创建一个免费的试用版。
我在上一节中使用名为 Query 的工具运行了所有查询,你可以通过控制台在这里访问 Query。
在 Aura 控制台中查看查询
在这个工具里,你可以按上一节中列出的相同顺序粘贴所有查询,你就能得到相同的结果。
我们现在想开始使用Bloom,但在去Bloom之前,让我们留在Query中,把一切都设置好。运行上一章中的所有查询,直到tick查询前(不要运行它)。你应该已经设好了游戏板和初始状态。
现在我们可以去Bloom。在控制台中,叫探索,是‘查询’下面的一个选项那里。
你会看到一个空的木工台。在顶部,你可以看到透视视图(Perspectives/Default Perspective,默认透视视图)。在其右侧,有一个书形图标。点击一下。
打开 Perspective 设计工具
在新弹出的工具栏中,点击最右边的保存的Cypher选项。现在点击添加搜索短语。在新出现的对话框里,将“显示所有单元格”作为搜索短语和描述, 并在Cypher查询框中输入:
匹配 (c:Cell) 返回 c
创建一个搜索词来查找所有单元格
接下来添加一个名为“Tick”的搜索短语,并使用上述整个tick查询,但在末尾添加一个 返回单元格。
匹配每个细胞(cell),并可选地与活着的邻居细胞建立关联,计算每个细胞的活着邻居数量,根据邻居数量的不同情况来设置细胞的存活状态。如果活着的邻居数量小于等于1或大于等于4,则细胞的状态设置为死亡;如果活着的邻居数量等于3,则细胞的状态设置为存活;否则,保持细胞的当前状态不变。最后,返回每个细胞的状态。
创建一个能帮你找到游戏的搜索关键词
现在关闭透视设计器(点击书本图标)。在搜索栏中选择显示所有单元,然后点击执行按钮(播放按钮)。我们现在可以看到游戏中的所有单元,但它们只是杂乱无章地排列着。
但是我们在每个单元格上添加了一个名为 坐标 的属性,该属性作为点来表示每个单元格在网格中的位置,这样 Bloom 就能理解布局方式了。
在右下角,有一个基于力的布局的选项。点击它,然后选择 坐标布局。
调整布局选项
在弹出窗口中,我们可以设置一些布局选项,特别是通过拖动X和Y轴的滑块来调整节点之间的距离。我更希望节点之间的距离稍微近一些,默认设置下节点之间的距离对我来说有点远。
设置坐标布局的属性
我们现在所有的细胞都是橙色的(你的情况可能是其他颜色),但我们想看看哪些细胞是活的,哪些是死的。我们不需要每个细胞上都有“细胞”这个词。请点击屏幕右边“细胞”标签旁边的圆圈。这个圆圈的颜色和你的细胞颜色一样(在我的情况下是橙色)。
点击(本例中的)橙色圆圈来调整标签显示设置
当你看到对话框中的颜色标签时,选择你想要死亡细胞显示的颜色。我会保留默认的橙色,但你可以选择你喜欢的任何颜色。
选择死亡细胞的颜色
现在点击顶部的文本选项,取消选中所有复选框,包括所有属性选项。
取消勾选此对话框里的所有复选框。
取消选中后,点击顶部的基于规则,点击添加基于规则的样式,选择活属性,选择为真在条件选择框中,勾选应用颜色选项,并选择活细胞的颜色(我选了黑色)。
把活细胞变得黑一点
现在我们看到的是带有颜色编码的生命游戏网格图,尽管它与我们之前看到的方向相反。这是因为Bloom将Y坐标视为向上而不是向下,像传统图表那样,而不是像电脑屏幕上的传统那样。
我们的生命游戏初始状态的图形界面
现在我们准备勾选“游戏”,并实时查看更改。只需在搜索框中选择勾选,点击播放图标,你就会看到游戏的变化。继续点击播放,看到游戏一步步变化。
在搜索框中勾选,以将你的游戏推进一个回合。
两次计时后得出的结果
戈斯珀滑翔机装置
那么这有什么意义呢?零玩家游戏有什么意义呢?好吧,挑战在于构思不会很快消亡的初始状态——甚至可能是无限的游戏。如果游戏回到初始状态,它就会无限地重复下去。这样的模式有时被称为“枪”,因为它们会从主模式中发射出飞船或滑翔机,这些飞船或滑翔机会无限地重复出现。
1970年由比尔·戈斯佩尔构思的第一个也是最著名的“枪”之一是Gosper 舵形枪。它由两个“方块”和两个结构(“皇后蜂穿梭”)组成,这两个结构在两个“方块”之间来回移动,周期性地发射滑翔器。这个模式每30代重复一次自身。
_ 一个“区块”是指从一代到下一代保持不变的模式。最常见的区块是由四个细胞以2乘2排列。每个活细胞恰好有三个活邻居,因此会继续存活,而其周围的死细胞没有恰好三个活邻居,因此也不会重生为活细胞。
生命游戏中的一个区块
如果我们想在我们实现的Cypher中测试Gosper glider gun,可以使用这些参数:
参数:表示一个包含高度、宽度和单元格坐标的对象
{
height: 40, // 高度为40
width: 40, // 宽度为40
cells: [[26,37],[24,36],[26,36],[14,35],[15,35],[22,35],[23,35],[36,35],[37,35],[13,34],[17,34],[22,34],[23,34],[36,34],[37,34],[2,33],[3,33],[12,33],[18,33],[22,33],[23,33],[2,32],[3,32],[12,32],[16,32],[18,32],[19,32],[24,32],[26,32],[12,31],[18,31],[26,31],[13,30],[17,30],[14,29],[15,29]], // 单元格坐标列表
}
如果我们使用上面提到的参数来进行设置和初始化,然后用我们之前讨论过的Bloom可视化来播放,我们就可以看到Gosper滑翔机枪(Gosper Glider Gun)就像这样运行。
戈斯珀滑翔器枪结构在Neo4j Bloom中进行可视化展示
摘要
好了,搞定。我们已经见识到了细胞自动机在Cypher中的高效实现,也看到了如何将Bloom既作为交互工具,也作为图形化工具来使用我们的自动机。
一定要看看赢取250美元礼品卡:使用GraphQL为AuraDB Beta版构建康威生命游戏。想了解更多关于Cypher的,可以看看在GraphAcademy上的Cypher基础课程。
共同学习,写下你的评论
评论加载中...
作者其他优质文章