多变量逻辑表达式化简原理与应用:卡诺图化简法

开发 前端
我们简要介绍了卡诺图原理与其化简方法,这个工具和思想可以用在任何多参数有竞争或多种情况的场景下决定某个操作是否执行。这个方法在参数较多且状态复杂时使用,若逻辑状态较简单,则可直接用表达式写出,代码逻辑力求更清晰明了。

1、背景

本文主要介绍使用卡诺图化简多变量逻辑表达式的原理与方法,此方法是一种逻辑计算思想,在任意技术平台类似的多元化场景中均可适用。

本文以客户端的一个业务场景为例,从举例分析到实际应用的步骤,介绍卡诺图工具的使用,让我们轻松应对复杂交互或多条件判断的编码。

2、使用场景举例

开发中我们有时会遇到一些复杂条件的交互要求,它们共同操作公共控件,使得交互逻辑在流程中变得极为复杂,如何简便高效地处理和维护这些判断逻辑,是我们可以思考的方向。

先来看一个这样的业务场景,为方便说明已稍作简化。在一个拍摄页面中有如下几个控件:添加音乐按键,滤镜按键,拍摄键,特效选择器,特效清除键​(图1.1 所示红圈处),它们在拍照就绪状态下是都显示的,然后用户可能的操作流程有2种:拍照片和拍视频,而这2种操作可以分别有3个不同的效果选择:增加音乐,增加滤镜,增加特效,则此例中共有 2 * 3 = 6​ 种不同操作方式的组合,这些操作又分别涉及不同的控件,需要它们配合完成相应操作。即 m 种操作和 n 种效果,共 m*n 种组合方法。

而聪明的你则要处理它们全部的交互逻辑。此页面更多详细的交互要求可参考发布工具[拍摄页] 交互要求 [1]。

图片

图 1.1  拍摄页的不同状态UI与交互效果示意

主要交互要求是什么呢?参考看着上图,我们随便举几个粟子好了,比如:

🌰 已拍了照片或视频不能显示音乐键

🌰 滤镜浮层打开后要隐藏音乐键、拍摄键和特效选择器

🌰 关闭浮层后要把这些控件重新显示出来

🌰 有特效时要显示清除键

还有其他更多细节限于篇幅就不罗列了。多样化的操作带来逻辑的复杂变化,比如视频拍了一半,暂停来切换特效,而特效控件在有视频和无视频时展示的控件又不同,或删掉视频去选滤镜、选音乐等等,是不是已经晕了?这里面还有些其他可能想都想不到的细节出现,由此足以看出交互要求的复杂性了。如图1.1 所示,红圈处为页面在不同状态下发生的主要变化。

使用响应式编程或许可以解决部分问题,但由于每个控件状态并不是由单一参数决定的,所以我们仍需要在它们各自的监听方法里写全部相关参数的判断逻辑,这模式对解决此场景的根本问题似乎并无太多帮助。主要问题出在哪儿呢?

2.1 痛点

一句话总结问题:不同的交互方法之间产生了冗余的先后依赖而阻碍了业务的状态转移,使逻辑不够清晰,代码难以维护。

怎么办呢?你可能会说,我们可以按操作的内容来分类,把控件按操作类别分装入不同的父容器中,然后在相应操作方法里控制这容器即可。

嗯想法很不错,但实际这么做了然后我们会发现,例如说在上述场景里,在关闭浮层时,若已经拍了一段视频,则还需要单独判断音乐键的显示状态,或是此时还没选择特效,就不能把清除键也显示出来。

于是我们开始意识到,这个场景下的业务方法不能完全独立出来使用。抽象来说,这些操作之间相互有顺序的依赖,它们不能严格满足逻辑表达式加法交换律,即某些情况 A操作 和 B操作 的顺序不同会导致结果不同:

图片

还有许多类似的corner case,我们若把全部case分列出来,则它们的控制逻辑会像星辰一样散落在各个处理方法中,作为中介作用的容器也会层层叠叠地出现,然后我们还需要写一大坨处理各种子状态、容器控件的方法,再根据当前用户操作去相应地调用;当页面里的控件数量增多,或需求变化了,或控件要增多/减少时,整个流程就不再具有清晰的层级关系或先后逻辑了。

更麻烦的是,我们每次看似简单的增删改操作,都要把相应业务的上下游方法全部梳理一遍,甚至和其他功能有交互的地方,也要关注到,这样的逻辑产生过高的复杂度也容易产生疏漏。要维护好这么一堆面条代码将不是一件令人愉快的事;若是对业务不熟悉就做修改,极易踩坑,导致改了A处又坏了B处,修了B处又影响了C处,正是因为各子业务的方法存在过度耦合,出bug就没完没了。

那么我们还有更优雅的方式来处理这种场景么?

2.2 逻辑状态抽象

不如我们先换个角度来看。

通过观察梳理业务要求[1]可发现,其实我们并不需要知道用户之前做过什么操作,因为控件的交互只与个别参数相关,即交互是依赖于某个变量而非业务流程。我们若将这些参数用bool值来确定,就可以唯一决定当前的页面状态,并且这些状态在一个交互中是相互独立的,即当前状态与它之前的状态无关。

也就是说:与其说当前操作需要什么交互,不如说当前操作发生时,相关参数处于什么状态,再由这些状态即可决定页面所需的交互。

例如,当滤镜浮层dismiss时,哪些控件需要重新显示出来呢?那得看此时是否已有录视频,是否已有特效,是否已有音乐等等,这些参数综合起来,就唯一决定了当前的状态,就可计算出所需逻辑。

很棒,我们走到这里问题已解决一大半了。即引出本文主题:卡诺图。

2.3 状态化简工具:卡诺图

若是满足上述这些条件,那么我们就可以使用逻辑表达式来方便地描述它们,而逻辑式的化简就可以用卡诺图方便地完成。

至此,我们就将问题由具体业务流程转化为:如何使用关联的变量建立逻辑式描述页面的状态。这样一来就把状态从流程中剥离出来了,你会发现,从这个角度看问题,无论流程怎么变,所做操作最终都会落入某个状态,这才是我们要找的关键联系:

相关变量 --(确定)--> 逻辑表达式 --(确定)--> 控件状态

这个过程,也是我们计算状态转移的方法,只用上面3步,掌握之后再遇到类似场景,我们完全可以无脑按套路来处理逻辑状态就ok了,让代码清爽简洁,敏感肌也能用。

2.4 举例使用

卡诺图具体怎么用呢?

我们还是继续看上面的例子,就写一个方法 :func refreshCurrentStatusUI(),用于在任意时刻需要刷新页面状态时获得所需状态,而不用关心具体当前的操作或流程是什么,以便把状态从业务流程中独立出来。

方法结构也是最基本的思路,方法有格式如下:

func refreshCurrentStatusUI()    // 获取当前变量状态    let A: Bool = isA()    let B: Bool = isB()    let C: Bool = isC()    let D: Bool = isD()    ...    // 设置控件交互    view01.isHidden = ABCD表达式1    view02.isHidden = ABCD表达式2    view03.isHidden = ABCD表达式3    ...}

在实际应用的方法体里,把与业务场景相关的参数状态列出来,比方说,获取与罗列的控件相关的几个必需变量,为了表述方便,这里用符号ABC来代表参数,当然你也可以用其他的符号。

各参数说明如下:

A:是否有视频

B:是否有特效

C:是否有子特效

D:是否已拍照填充了子特效

F:是否有音乐

按上述方式填充方法体,有几个参数你就列出几个,简单又直观:

func refreshCurrentStatusUI()    // 当前数据状态    let hasVideo: Bool = isProgressHasSections()           // A: 是否已录制视频    let hasEffect: Bool = hasSpecialEffect()               // B: 是否已选有特效    let hasSubEffect: Bool = hasSubStickers()              // C: 是否带有子特效    let hasSubEffectPhoto: Bool = hasSubStickersGetPhoto() // D: 是否已拍照填充了子特效 subEffect    let hasMusic: Bool = currentMusicData != nil || musicID > 0 // F: 是否有音乐...}

有一点细节需要注意的是:上述变量用到的值一定要在方法体里即用即取,不建议从方法外传值进来,因为多线程和并发的存在,则有的属性变量值可能在本方法开始调用后被其他线程或方法修改,即状态已发生了改变,而传入的值状态就已经滞后,导致本方法计算出的控件状态不是预期最新的状态了。

然后我们只需要继续在这个方法里,使用上述变量当下的值,计算每个控件状态的逻辑表达式,根据结果来刷新各控件状态即可:

// 拍摄键takeButton.isHidden          = !(hasVideo || hasSubEffectPhoto)
// [删除视频片段]按键deleteButton.isHidden = !(hasVideo || hasSubEffectPhoto)
// 特效选择器specialEffectPicker.isHidden = hasVideo || hasSubEffectPhoto
// 特效浮层键stickerSelectionButton.isHidden = !hasVideo && (hasEffect || hasSubEffect) || hasSubEffectPhoto // (!A)(B+C)+D
// 特效清除键specialEffectClearButton.isHidden = !hasEffect || (hasSubEffect || hasSubEffectPhoto) // !B || (C || D)

这样一来,即使后面需要增加更多控件,或增加逻辑参数,都可以在这个方法里统一处理,然后在需要刷新页面的地方调用,而我们不需要关心具体操作发生时页面的上一个状态。到这里,业务方法就大功告成了,我们保证了在任意操作流程中交互的状态都是可唯一确定的。

OK 下一步,逻辑表达式要怎么做呢?

3、卡诺图简介

卡诺图[2]可以用于表示和化简逻辑函数。

一个逻辑函数的卡诺图,就是将此函数的最小项表达式中的各最小项填入相应的特定方格图内,这样的方格图就是卡诺图。

举例,比如 4变量的卡诺图,记ABCD​为所用4个变量,行与列头为4个变量分别对应的取值(true 或 false,对应记为逻辑 1 或 0 方便计算),16个方格中相邻的数字0~15依顺序代表方格逻辑相邻:

图片

图3.1  卡诺图逻辑相邻格式

3.1 代数含义

一个逻辑函数,如果有 n 个变量,则有$$2^n$$个最小项,最小项为所有所有变量的积,也就是所有变量的与门逻辑。

3.2 特点

  • 逻辑函数有几个变量,就有几个因子
  • 每个变量都是它的一个因子
  • 每一变量或以原变量形式出现(A、B、C)​,或以非变量形式出现(!A、!B、!C)
  • 每个乘积的组合仅出现一次,且取值为1
  • 任何逻辑函数都可以转换成最小项的形式

3.3 相邻组合

任何一对相邻最小项可以组合为比原最小项本身少一个变量的单项。

3.4 逻辑相邻

  • 最上面的一列和最下面的一列逻辑相邻,可以相互化简
  • 最左边一列和最右边的一列逻辑相邻,可以相互化简
  • 四个角逻辑相邻,可以相互化简
  • 要更好地理解上述规则和逻辑相邻,我们需要使用格雷码。

4、格雷码编码规则

4.1 格雷码原理

画卡诺图的时候需要先将所有变量可能以格雷码的形式排列在方格两侧,所有变量有$$2^n$$个,格雷码的基本特点就是任意两个相邻的代码只有一位二进制数不同,主要用于在数字电路中变化时每次就只有一位发生变化,用以提高电路的稳定性[3]。

十进制数

二进制数

格雷码


十进制数

二进制数

格雷码

0

0000

0000


8

1000

1100

1

0001

0001


9

1001

1101

2

0010

0011


10

1010

1111

3

0011

0010


11

1011

1110

4

0100

0110


12

1100

1010

5

0101

0111


13

1101

1011

6

0110

0101


14

1110

1001

7

0111

0100


15

1111

1000

表3.1  格雷码编码规则

4.2 格雷码规则

自然二进制数到格雷码:保留二进制码的最高位作为格雷码的最高位,而次高位格雷码为二进制码的高位与次高位相异或,而格雷码其余各位与次高位的求法相类似。

格雷码到自然二进制数:保留格雷码的最高位作为自然二进制码的最高位,而次高位自然二进制码为高位自然二进制码与次高位格雷码做相异或计算,而自然二进制码的其余各位与次高位自然二进制码的求法相类似。

以一个四位二进制数来举例,二进制(abcd),依据规则转换为格雷码就是

图片

即异或计算【a, (a^b), (b^c), (c^d)】, 依据规则继续转化二进制的话就是 【a, (a^a^b), (a^a^b^b^c), (a^a^b^b^c^c^d)】,化简之后仍然可以得到(abcd)。

5、卡诺图简单逻辑化简

5.1 化简方法

逻辑化简的实际目标是尽可能地减少表达式中包含的项数以及各项包含的变量数[4]。下图为三变量卡诺图结构,m0~m7表示变量ABC的8种状态[7]。

图片

图5.1  三变量卡诺图

此图即为基本卡诺图的形式,两侧变量依据格雷码形式,目的就是画卡诺圈时要将里面的 1 全都包括在内[5]。

类似的,有4变量卡诺图描述16种状态,其结构如下所示:

图片

图5.2  四变量卡诺图

卡诺图填写完成后,就可以作卡诺圈了,通过圈选与合并状态,我们可以将逻辑多项式化简得到其最小项表达式。

5.2 卡诺圈原则

卡诺圈原则:所作卡诺圈尽量大,卡诺圈的数量尽量少

  • 卡诺图上处在相邻、相对、相重位置的小方格所代表的最小项为相邻最小项。
  • 两个小方格相邻, 或处于某行(列)两端时,所代表的最小项可以合并,合并后可消去一个变量。
  • 四个小方格组成一个大方格、或组成一行(列)、或处于相邻两行(列)的两端、或处于四角时,所的表的最小项可以合并,合并后可消去两个变量。
  • 八个小方格组成一个大方格、或组成相邻的两行(列)、或处于两个边行(列)时,所代表的最小项可以合并,合并后可消去三个变量。
  • 对于方格中带有未知变量x的,是可圈可不圈的,依据自己实际情况而定[6]。

5.3 化简举例与步骤

用上面拍摄页的例子,我们怎么用那几个变量,得到最终的交互结果呢?一起按步骤一步步来看。

5.3.1 抽出变量

各变量已有对应的取值方法,为方便使用变量,我们计算中用ABC符号来代表各参数,比如 A 代表是否已录视频,取0为false,1为true;其他变量同理,可写出:

let hasVideo          // A: 是否已录制视频let hasEffect:        // B: 是否已选有特效let hasSubEffect:     // C: 是否带有子特效let hasSubEffectPhoto // D: 是否已拍照填充了子特效 subEffectlet hasMusic          // F: 是否有音乐

然后拿出需要计算的控件,先用一个逻辑比较简单的,比如拍摄键 takeButton​ ,然后把与之相关的变量找出来,按照业务要求得知,它的显示与隐藏只和“是否有视频”、“是否填充子特效”这两个参数有关,当然你也可以把看似相关的其他参数带进去,在化简完成后,无关参数最终会被消去。

5.3.2 填充卡诺图

那么,与拍摄键相关的变量就是 hasVideo,hasSubEffectPhoto​ ,对应符号是 A,D ,分别取0和1值。在下图中,A为行,B为列,填充到2变量的卡诺图中:

图片

图5.3  初始化卡诺图

其中行代表A的取值,列为B的取值情况,它们相交处共同描述了AB全部4种取值:00,01,10,11。

然后,对应每一种取值情况,我们需要思考 拍摄键 takeButton​ 的显示状态,并在相应空格里填入0或1,含义由我们来定,这里取0为隐藏,1为显示较直观,那么有:

A = 0,B = 0 ​-->无视频 & 无填充子特效时,拍摄键要隐藏,所以取0,则在坐标(0,0)处填0;

A = 0,B = 1 ​-->无视频 & 有填充子特效时,拍摄键要显示,所以取1,则在坐标(0,1)处填1;

A = 1,B = 0 ​-->有视频 & 无填充子特效时,拍摄键要显示,所以取1,则在坐标(1,0)处填1;

A = 1,B = 1 ​-->有视频 & 有填充子特效时,拍摄键要显示,所以取1,则在坐标(1,1)处填1;

图片

图5.4  填写状态后的卡诺图

5.3.3 画卡诺圈

根据上面2.4逻辑相邻的最小项原则和4.1提到的画圈原则,把相邻的 1 圈起来,得到全部的卡诺圈:

图片

图5.5  画出卡诺圈

这个图里我们得到2个卡诺圈,全部卡诺圈表达式相加,对应写成逻辑式即得:

A + B

已是最简表达式,再把符号AB再换回代码中的变量,有:

(hasVideo || hasSubEffectPhoto)

因为代码中使用的是 .isHidden​属性,只要把所得表达式再取反即有 !(A+B),写为代码的逻辑式就是:

takeButton.isHidden = !(hasVideo || hasSubEffectPhoto)

至此我们就得到了这个控件的状态表达式,在任何时候调用这个刷新方法,就只依赖参数做显示。

5.3.4 四变量卡诺图举例

你可能觉得,上面的变量就两个,我自己列举一下也可写出表达式,不用走卡诺图。是的恭喜你已发现这个规律,一般2变量我们可以轻易handle,直接计算可能会比画图来的简单。但通过这个做法我们了解其原理与概念之后,现在一起来看个稍微复杂些的,4变量场景的表达式是如何计算的。

就用 特效浮层键 的交互状态来试试吧,对应图1.1中屏幕左下方那个半透明白色圆形的按键,在就绪状态或视频暂停状态下它会出现在拍摄键左侧:

图片

图5.6  特效浮层按键示意

同样先来看下计算后的最终结果:

// 特效浮层键stickerSelectionButton.isHidden    = !hasVideo && (hasEffect || hasSubEffect) || hasSubEffectPhoto // (!A)(B+C)+D

思考:它在什么情况下需要显示或隐藏呢?由业务要求[1]可知:是否录视频,是否有特效、子特效,是否拍了子特效照片,这几个参数就能决定其状态。

于是你问,那音乐参数F呢?我们通过研究具体需求[1]得知,无论有无音乐,都不会影响它的显示,因此F是个无关参数,计算时不用考虑。当然,你非要把它加进来也行,因为逻辑式化简后,无关参数都会被消去,不影响结果,这也是“最小项”这个名字的由来:计算出最少相关的参数。要知道,参数越少就越方便我们化简逻辑式,所以那些一眼看去就无关的参数可以不用加进来。若是计算后发现我们少带了某个参数或业务要求变化了,则再把它加进来重新计算表达式即可。

  • 确定参数

综上,我们所需参数为ABCD:

let hasVideo      // A: 是否已录制视频let hasEffect:    // B: 是否已选有特效let hasSubEffect: // C: 是否带有子特效let hasSubEffectPhoto // D: 是否已拍照填充了子特效 subEffect

画出4参数卡诺图框架:

图片

其中AB两个变量作为列写在一起,CD两个作为行写在一起,变量的顺序不是一定的,你也可以换着写,但格雷码0和1的位置在表头是固定的,且最终计算结果也是一样的。

AB列中,00代表AB取值 A=false; B=false​,01代表取值 A=false; B=true,依此类推。

于是每个格子就对应一个状态,比如第0行第2列,ABCD取值为0011,代表A=false; B=false; C =true; D=true 这个状态。所以4变量卡诺图可以把这些变量全排列的16个状态都表示出来,不会漏掉某个状态。

然后对于每个参数状态,我们思考目标控件的显示状态。这里为方便配合使用属性 .isHidden​,我们定义逻辑填入1为需要隐藏,0为不隐藏​。注意这里与上面例子2变量里的显示隐藏定义是反相的。参数含义与格子对应,比如,在0011状态时,代表状态含义是:

0011 = 无录视频,无特效,有子特效,子特效已拍照

本例有个特殊点,因为在需求逻辑上,参数B“无特效”比CD有更高优先级:即无特效时不可能有子特效,也不可能给子特效拍照,所以综合考虑,在状态0011时需要显示控件,即不隐藏,则在0011对应格子里填入0。

  • 填充卡诺图

按照上述方法,把全部16个格子都填上对应(控件需要隐藏)的状态,AB为列,CD为行:

图片

  • 按前面 4.2卡诺圈原则 作出全部的卡诺圈,把所有的 1 圈起来:

图片

  • 化简

根据卡诺圈写出逻辑表达式并化简为最小项表达式(为方便理解,卡诺圈与其表达式用对应的颜色):

图片

再把符号换回代码中的变量,即有:


// 特效浮层键:!A(B+C)+D
P = !hasVideo && (hasEffect || hasSubEffect) || hasSubEffectPhoto

至此,我们就把变量的逻辑状态与特效按键的状态对应起来了。

使用此方法的优势在于:若是需求有变需要更改状态,则只需要列一遍参数,重新画卡诺图计算表达式即可,修改的地方也只有这一行的逻辑式,而不会影响其他控件的逻辑,也不用再走一遍改动后的业务流程。

6、拓展:五变量卡诺图

五变量以下最多十六方格,也可用上述方法解出得到。但在实际开发中极少需要到5变量,掌握4变量方法已足够应对大多数场景。此处仅作拓展了解。六变量以上方格过多,用此方法反倒麻烦[3]。

图片

图6.1  五变量卡诺图

它是由四变量最小项图构成的,将左边的一个四变量卡诺图按轴翻转 180 °而成。左边的一个四变量最小项图对应变量 E =0 ,轴左侧的一个对应 E =1 。

这样一来除了几何位置相邻的小方格满足邻接条件外,以轴对称的小方格也满足邻接条件,这一点需要注意。图中最小项编号按变量高低位的顺序为 EABCD 排列时,所对应的二进制码确定。

此时要注意列上变量排列的左右对称关系,对于既不含 E非也不含 E 的与项,可以填入 E非四变量卡诺图中然后以中间轴翻转 180 °,在 E 四变量卡诺图中对称位置也填上“ 1 ”。

五变量逻辑式化简的举例说明如下,每个步骤原理在上文已作详尽说明,此处不再赘述:

化简下式:

图片

  • 填入卡诺图:

图片

  • 作卡诺圈:

图片

合并与化简得:

图片

7、小结

我们简要介绍了卡诺图原理与其化简方法,这个工具和思想可以用在任何多参数有竞争或多种情况的场景下决定某个操作是否执行。这个方法在参数较多且状态复杂时使用,若逻辑状态较简单,则可直接用表达式写出,代码逻辑力求更清晰明了。

在上述场景的操作流程中,任何时候调用这个刷新方法,我们都可获得符合当下参数的页面状态,因为交互就只依赖参数做显示,而不需要考虑其前置状态。

卡诺图逻辑化简法优点有:简化了多参数多控件等复杂情况下逻辑与方法难以直观处理的情况,且在后续维护迭代中,也可独立地修改某个控件的刷新逻辑,不用担心影响其他控件或业务流程,每个交互的控件状态修改简易灵活,方便计算。

同时,卡诺图的使用也有其局限性,它比较适合4变量或以下的场景,在5变量及以上用此方法反而复杂,且在实际开发中,4变量场景已然是比较少见,则此法已足够我们处理大多数2~4变量的交互逻辑情况了。

Reference

[1] https://blog.shizhuang-inc.com/article/Nzc4Ng==

[2] https://blog.csdn.net/wangqinyi574110/article/details/116573015

[3] https://www.cnblogs.com/uiojhi/p/7517732.html

[4] https://zhuanlan.zhihu.com/p/158535749

[5] https://www.cnblogs.com/iBoundary/p/11303861.html

[6] https://www.pudn.com/news/6367746ba4b7e43a5e898044.html

[7] https://zhuanlan.zhihu.com/p/268750530

责任编辑:武晓燕 来源: 得物技术
相关推荐

2019-08-27 16:23:41

Docker虚拟化虚拟机

2017-04-11 15:43:39

JavaScript模块演化

2019-07-17 15:45:47

正则表达式字符串前端

2024-03-25 13:46:12

C#Lambda编程

2011-06-16 15:28:31

正则表达式

2011-07-11 12:33:30

JAVA

2014-01-05 17:41:09

PostgreSQL表达式

2009-06-10 18:08:14

2009-12-17 10:39:01

Ruby数学表达式

2009-12-15 09:43:50

Ruby case w

2021-08-31 07:19:41

Lambda表达式C#

2009-09-16 16:01:57

PHP正则表达式正则表达式的应用

2009-03-24 08:56:15

正则表达式格式清理字符串

2009-08-20 16:23:32

C#正则表达式语法

2009-05-05 09:30:01

2011-11-23 11:04:41

BGPAS_PATH正则表达式

2012-03-08 13:15:10

JavaStrutsOGNL

2018-09-27 15:25:08

正则表达式前端

2009-09-09 17:45:07

Linq表达式

2009-09-11 09:48:27

Linq Lambda
点赞
收藏

51CTO技术栈公众号