【TIS-100 攻略】第 10~11 关:信号模式检测器、序列峰值检测器

本文首发于 B 站《TIS-100》文集(https://www.bilibili.com/read/readlist/rl626023)。原创不易,转载请注明出处。
第 10 关《信号模式检测器》(Signal Pattern Detector)关卡展示

本关的 IN 会源源不断地提供数字。当最近三个数字均为 0 时,向 OUT 发送 1;否则向 OUT 端口发送 0。
由于提供的数字都是非负数,所以可以用【求和法】检查前三个数字是否都是 0:仅当三者相加的和为 0 时,三者才均为 0。那么,本算法的难点就在于【如何存下最近的三个数】。本题的代码如下:

上方的两个节点纯粹给中央节点传话(mov up right, mov left down)。
然后是中央节点。
首先将第一个数存入 bak(mov up acc)
(swp)
再将第二个数存入 acc(mov up acc)。
从收到第三个数开始,就需要不断往下传【最近的三个数】,由下方来计算三者之和了。现在 acc 里存的是【前一个数】,bak 里存的是【前两个数】。为了减少寄存器的交换次数,这里我们选择先把【前一个数】往下传(mov acc down),
再把【前两个数】从 bak 换到 acc 里(swp)
并往下传(mov acc down)。
传完后,我们从上方收取【当前数】,覆盖掉已经无用的【前两个数】(mov up acc)
并往下传(mov acc down)。
至此,acc 里存的是【当前数】,bak 里存的是【前一个数】。那么在收到下一个数的时候,acc 和 bak 就理所当然地变成了下一个数的【前一个数】和【前两个数】,和之前的逻辑又完全对上了。因此,我们在这时跳回第 4 行,将这套逻辑无限循环下去(jmp 4)。
最后是下方节点。
首先无脑向下传两个 0(mov 0 down)
(mov 0 down)
并清空 acc(sub acc)。
接下来就要开始从上方收取【最近的三个数】了。我们将收到的三个数相加(add up)
(add up)
(add up)
并检查和是否为 0。若和不为 0,则跳到第 2 行,向 OUT 传一个 0 并清空 acc(jnz 2, mov 0 down, sub acc)。
若和为 0,则改为向 OUT 传 1(mov 1 down)。
同时,由于此时 acc 已经为 0,所以不需要再额外执行清空 acc 的任务,直接跳到第 4 行(jmp 4)开始下一次的累加任务即可。
点击左下角的【RUN】,稍等片刻,便会弹出结算界面:

优化运行时间和代码行数
求和法是一种方案,但不是最好的解决方案。题目要求遇到 3 个或更多连续的 0 时输出 1,那么我们就按字面意思来理解:我们用一个计数器记录连续 0 的个数,并以此为依据输出信号不就行了吗,干嘛非得用求和法来做呢?
因此,我们将代码由原先的【求和法】改为现在这样的【计数法】:

上方的两个节点都没有变化,直接从中央节点开始看:
中央节点收到 IN 里的数字后(mov up acc),根据其是否为 0 向下发送不同的跳转信号。
若收到的是非 0 值,跳到第 5 行(jnz 5)。
若收到的是 0 值,则向下传 1(mov 1 down)。
传完 1 后跳回第 1 行,避免执行第 5 行的代码(jmp 1)。
若收到的是非 0 值,则跳转到此,向下传 8(mov 8 down)。
然后我们看下方节点:
首先等待上方传来的 1 或 8 信号(jro up)。现在已知收到 0 值时向下跳 1 行,收到非 0 值时向下跳 8 行。
收到 0 值时,我们需要令计数器 +1,并判断此时计数器是否 >= 3。判断 acc + 1 >= 3 相当于判断 acc >= 2,相当于判断 acc > 1,相当于判断 acc - 1 > 0。因此这里我们令 acc - 1(sub 1),
然后判断对应的值是否大于 0。大于 0 时,跳转到第 6 行执行(jgz 6)。
第 4~5 行的代码理所当然地仅当 acc - 1 <= 0 时才会被执行,此时 IN 里的确出现了连续的 0,但连续的数量尚未到达 3 个。我们需要向 OUT 传 0(mov 0 down),
接下来无条件跳转到第 7 行,跳过 jgz 判断成立时执行的第 6 行代码(jmp 7)。
当 acc - 1 > 0 时,按照要求,程序跳转到了第 6 行执行,此时 IN 里出现了 3 个或更多的 0,向 OUT 传 1(mov 1 down)。
传值完成后,我们需要将 acc - 1 还原成 acc + 1,执行 +2 指令便可还原(add 2)。
执行完毕后,跳回第 1 行,避免执行第 9 行的代码(jmp 1)。
而如果一开始收到的是非 0 值,那么我们会从上方收到 8 信号。此时向下跳 8 行,跳转到第 9 行,无脑向 OUT 传 0(mov 0 down)
并重置计数器(sub acc)。
点击左下角的【RUN】,稍等片刻,便会弹出结算界面:

还能再快一点吗?
如果计数器从 0 开始计的话,那么判断计数器是否 >= 3 的过程非常繁琐。那么我们不妨换一种思路:让计数器从 -2 开始计,这样每次只要 +1 并直接判断是否 > 0 就 OK 了,不需要又减又加。最终版本的程序如下:

中央节点发送的跳转偏移量由原先的 1、8 变成了 1、7。下方节点实实在在少了一行代码。下方节点的逻辑变成了这样:
首先将计数器置为 -2(mov -2 acc),
然后听取上方的指示(jro up)。
收到 0 值时,检查计数器是否到达了 0。已到达 0 时,跳到第 7 行(jez 7);尚未到达 0 时,按顺序执行。
若计数器尚未到达 0,给 OUT 传 0(mov 0 down)
并令计数器 +1(add 1)。
执行至此跳回第 2 行,避免执行后续代码(jmp 2)。
若计数器到达了 0,说明此前已经从 IN 收到了两个 0,此时收到的自然是第三个 0,这时候我们当然要给 OUT 传 1(mov 1 down)。
与此同时,当计数器到达了这个值以后,再计下去也是没有意义的事情了,因为除非收到非 0 值将计数器重置,否则向下传的数字都是 1。计数器不论是到达 0 还是到达更大的正数,我们对应的行为都是一样的,自然而然,计数器累加到 0 以后就没有再继续累加的必要了,直接跳回第 2 行等待下一次指令就 OK 了(jmp 2)。此时的计数器更像是一个 CD(冷却)计数指示,冷却完毕后的确没有再继续计数的必要了。
收到非 0 值时,向下跳 7 行,无脑给 OUT 传 0,然后自动跳回第一行,将计数器重置为 -2(mov 0 down, mov -2 acc)。
点击左下角的【RUN】,稍等片刻,便会弹出结算界面:

最终的三项指标为:运行时长 199,节点数量 4,代码行数 16。
第 11 关《序列峰值检测器》(Sequence Peak Detector)关卡展示

本关的 IN 会不断提供一些以 0 结尾的序列,你需要将每个序列的最小值传给 OUT.I,最大值传给 OUT.A。本关的代码如下:

上方的节点纯粹向下传话(mov up down)。
中央靠左的节点逻辑如下:
收到序列的第一个数字时(mov up acc)
先复制一份发给中间靠右的节点(mov acc right),
再向下传一份(mov acc down)。
从第二个数字开始(mov up acc)
首先还是复制一份给中间靠右的节点(mov acc right),然后需要判断收到的数字是否为 0,并以此为依据给下方节点发送不同的信号。
收到数字 0 时,说明达到了序列的结尾,跳到第 10 行代码处(jez a)。
收到非 0 数字时,给下方发送 1 的信号值(mov 1 down),
再将当前收到的数字发送两遍。先发送第一遍(mov acc down),
然后跳回到第 3 行复用代码,发送第二遍(jmp 3, mov acc down)。
收到 0 数字时,按照规定跳到第 10 行,给下方发送 5 的信号值(mov 5 down)。
中间靠右的节点的代码跟中间靠左的节点基本一致,只做了两处改动:①因为是从其左侧收取 IN 的数据,所以该节点里所有的 up 都改成了 left;②由于不需要再向它的右边传话,所以所有的 mov acc right 指令都被注释掉了。
下方的两个节点收到的都是同样的数据流,只不过左边找的是序列里的最小值,右边找的是序列里的最大值。我们把左边的节点当作典型来解释这段代码的含义。
首先,收到序列里的第一个数,将它设为迄今为止的最小值(mov up acc)。
接下来,上方节点会在收到非 0 值时发送一个 1 的信号,或在收到 0 值时发送一个 5 的信号。我们等待这个信号的出现(jro up)。
上方收到非 0 值时,我们会收到 1 信号,向下跳 1 行,计算历史最小和新挑战者的差值(sub up)。
若差值为正,说明挑战者比历史最小值还小,我们跳回第 1 行,将 acc(历史最小)置为新的挑战者的值(jmp 1, mov up acc)。
若差值为 0 或负,说明挑战者没有小过历史最小值。此时我们不改写历史最小值,令其维持原状。这里 acc 的值为【历史最小 - 挑战者】的值,所以我们需要在此基础上令 acc【+ 挑战者】,把 acc 的值恢复成历史最小值(add up)。
执行完第 5 步后,跳回到第 2 行,准备接收序列里的下一个数字(jmp 2)。可以看到,对于每个挑战者的值,下方节点都需要读两遍,一遍用于计算和历史最小值的差值,另一遍用于更新或维持最小值。所以上方节点仅在收到序列里的第一个数时向下传一遍,从第二个数开始,每个数都要向下传两遍。
上方收到 0 值时,我们会收到 5 信号,向下跳 5 行。因为 acc 记录的是历史最小,当到达序列末尾时,历史最小就成了全局最小。我们直接将 acc 中记录的全局最小值向下输出,即完成任务(mov acc down)。然后跳回第 1 行,准备收取下一个序列里的第一个数。
下方靠右的节点求的是序列里的最大值,acc 里存的是历史最大,不再是历史最小。另外第 4 行的 jgz 1 改成了 jlz 1。求最大值时,仍然是计算历史最大值和挑战者的差值。差值为负时,说明挑战者比历史最大值还大,此时需要更新历史最大值;差值为 0 或正时,说明挑战者比历史最大值小,此时需要维持历史最大值不变。该节点代的逻辑正好是左边节点的相反逻辑,所以将第 3 行的条件翻转一下就 OK 了,其余的代码都不需要改变。
点击左下角的【RUN】,稍等片刻,便会弹出结算界面:
