为了账号安全,请及时绑定邮箱和手机立即绑定

用Cypher玩生命游戏

从未启动的博客

在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基础课程

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消