转载自:https://mp.weixin.qq.com/s/EA4anAY-Ga09Ht6bmY7oug
本章有两个主要目标:
1.要定义append / 3,这是一个用于连接两个表的谓词,并说明了可以使用的谓词。
2.讨论两种反转表的方法:使用append / 3的朴素方法和使用累加器的更有效方法。
1 Append
我们将定义一个重要的谓词append / 3,其所有参数都是表。以声明的方式查看,当表L3是将表L1和L2串联在一起的结果时,append(L1,L2,L3)将成立(串联意味着将表首尾相连在一起)。例如,如果我们构成查询
?-append([a,b,c],[1,2,3],[a,b,c,1,2,3]).
或查询
?- append([a,[foo,gibble],c],[1,2,[[],b]],
[a,[foo,gibble],c,1,2,[[],b]]).
我们会得到答复。另一方面,如果我们构建查询
?- append([a,b,c],[1,2,3],[a,b,c,1,2]).
或查询
?-append([a,b,c],[1,2,3],[1,2,3,a,b,c]).
我们将得到答案 false。
从过程的角度来看,append / 3最明显的用途是将两个表连接在一起。我们可以简单地通过使用变量作为第三个参数来做到这一点:查询
?- append([a,b,c],[1,2,3],L3).
产生响应
L3 = [a,b,c,1,2,3].
但是(我们将很快看到)我们也可以使用append / 3拆分表。实际上,append / 3是真正的主力军。我们可以做很多事情,研究它是更好地了解Prolog中表处理的一种好方法。
append定义
定义append / 3的方法如下:
append([],L,L).
append([H|T],L2,[H|L3]):-append(T,L2,L3).
这是一个递归定义。基本情况只是说,将空表附加到任何表都会产生相同的表,这显然是正确的。
但是递归步骤呢?这就是说,当我们将一个非空表[H | T]与一个表L2连接在一起时,我们最终得到的表的头是H,而尾部是将T和L2连接在一起的结果。以图形方式考虑此定义可能会很有用:
但是这个定义的程序含义是什么?当我们使用append / 3将两个表粘合在一起时,实际发生了什么?让我们详细研究一下摆出查询?-append([a,b,c],[1,2,3],X)会发生什么。
当我们提出此查询时,Prolog会将其匹配到递归规则的开头,并在此过程中生成一个新的内部变量(例如_G518)。如果我们跟踪下一步会发生什么,我们将得到如下内容:
?- append([a,b,c],[1,2,3],X).
Call: (8) append([a, b, c], [1, 2, 3], _G518) ? creep
Call: (9) append([b, c], [1, 2, 3], _G587) ? creep
Call: (10) append([c], [1, 2, 3], _G590) ? creep
Call: (11) append([], [1, 2, 3], _G593) ? creep
Exit: (11) append([], [1, 2, 3], [1, 2, 3]) ? creep
Exit: (10) append([c], [1, 2, 3], [c, 1, 2, 3]) ? creep
Exit: (9) append([b, c], [1, 2, 3], [b, c, 1, 2, 3]) ? creep
Exit: (8) append([a, b, c], [1, 2, 3], [a, b, c, 1, 2, 3]) ? creep
X = [a, b, c, 1, 2, 3].
基本模式应该很清楚:在前四行中,我们看到Prolog在其第一个参数中沿表递减,直到它可以应用递归定义的基本情况为止。然后,如接下来的四行所示,它会逐步“填充”结果。这个“填写”过程如何进行?通过依次实例化变量_G593,_G590,_G587和_G518。但是,尽管掌握此基本模式很重要,但是它并不能告诉我们我们需要了解的append / 3的工作方式,因此让我们深入了解。这是查询append([a,b,c],[1,2,3],X)的搜索树。 我们将认真完成所有步骤,并仔细记录我们的目标是什么,以及实例化了哪些变量。
1.目标1:append([a,b,c],[1,2,3],_G518)。 prolog将其匹配到递归规则的开头(即append([H|T], L2, [H|L3]))。因此_G518合一为[a|L3],并且prolog具有新的目标append ([b, c], [1,2,3], L3)。它为L3生成一个新变量_g587,因此我们具有_g518 = [a | _G587]。
2. 目标2:append([b, c],[1,2,3],_G587)。 prolog将其匹配到递归规则的开头,因此_G587被合一为[b | L3],而prolog具有新的目标append([c],[1,2,3],L3)。它为L3生成内部变量_G590,因此我们具有_G587 = [b | _G590]。
3. 目标3:append([c],[1,2,3],_G590)。prolog将其匹配到递归规则的开头,因此_G590合一为[c | L3],而prolog具有新的目标append([],[1,2,3],l3)。它为L3生成内部变量_G593,因此我们具有_G590 = [c | _G593]。
4. 目标4:append([],[1,2,3],_G593)。最后:prolog可以使用基本子句(即append([],L,L))。然后在四个连续的匹配步骤中,Prolog将获得目标4,目标3,目标2和目标1的答案。
5. 目标4的答案:append([],[1,2,3],[1,2,3])。这是因为当我们将目标4(即,append([],[1,2,3],_G593)与基本子句匹配时,_G593被合一为[1,2,3]。
6. 对目标3的答案:append([c],[1,2,3],[c,1,2,3])。为什么?因为目标3是append([c],[1,2,3],_G590]),而_G590是表[c | _G593],我们已经将_G593合一为[1,2,3]。因此_G590合一为[c,1,2,3]。
7. 对目标2的答案:append([b,c],[1,2,3],[b,c,1,2,3])。为什么?因为目标2是append([b,c],[1,2,3],_G587]),而_G587是表[b | _G590],我们已经将_G590合一为[c,1,2,3 ]。因此_G587被合一为[b,c,1,2,3]。
8. 对目标1的答案:append([a,b,c],[1,2,3],[b,c,1,2,3])。为什么?因为目标2是append([a,b,c],[1,2,3],_G518]),而_G518是表[a | _G587],我们已经将_G587合一为[b,c,1,2,3]。因此_G518合一为[a,b,c,1,2,3]。
9. 这样,Prolog现在知道如何实例化原始查询变量X。它告诉我们X = [a,b,c,1,2,3],这就是我们想要的。
仔细研究此示例,并确保您完全了解变量实例化的模式,即:
_G518 = [a|_G587]
= [a|[b|_G590]]
= [a|[b|[c|_G593]]]
这种类型的模式是append / 3工作方式的核心,此外,它还展示了一个更笼统的主题:使用合一来构建结构。简而言之,对append / 3的递归调用建立了这种嵌套的变量模式,这些变量编码了所需的答案。当Prolog最终将最里面的变量_G593实例化为[1、2、3]时,答案如水晶般在颗粒周围形成雪花。但是产生结果的是合一而不是魔术。
使用 append
现在,我们了解了append / 3的工作原理,让我们看看如何使其能够工作。
append / 3的一个重要用途是将一个表分成两个连续的表。例如:
?- append(X,Y,[a,b,c,d]).
X = []
Y = [a,b,c,d] ;
X = [a]
Y = [b,c,d] ;
X = [a,b]
Y = [c,d] ;
X = [a,b,c]
Y = [d] ;
X = [a,b,c,d]
Y = [] ;
false.
也就是说,我们将要拆分的表(here [a,b,c,d])提供给append / 3作为第三个参数,并且我们将变量用于前两个参数。然后,Prolog搜索将变量实例化为两个表的方法,这些表串联在一起以提供第三个参数,从而将表分成两部分。而且,如该示例所示,通过回溯,Prolog可以找到将表分成两个连续表的所有可能方法。
这种能力意味着很容易用append / 3定义一些有用的谓词。让我们考虑一些例子。首先,我们可以定义一个查找表前缀的程序。例如,[a,b,c,d]的前缀为[],[a],[a,b],[a,b,c]和[a,b,c,d]。借助append / 3,可以很容易地定义一个程序prefix/ 2,这两个参数都是表,因此当P是L的前缀时,prefix(P,L)将成立。方法如下:
prefix(P,L):-append(P,_,L).
这就是说,当存在某些表时,表P是表L的前缀,使得L是将P与该表连接的结果。 (我们使用匿名变量,因为我们不在乎其他表是什么:我们只在乎是否存在这样的表或其他表。)此谓词成功找到表的前缀,此外,通过回溯,可以找到它们全部的匹配:
?- prefix(X,[a,b,c,d]).
X = [] ;
X = [a] ;
X = [a,b] ;
X = [a,b,c] ;
X = [a,b,c,d] ;
false
以类似的方式,我们可以定义一个查找表后缀的程序。例如,[a,b,c,d]的后缀为[],[d],[c,d],[b,c,d]和[a,b,c,d]。同样,使用append / 3可以很容易地定义suffix/ 2,这是一个谓词,其两个参数都是表,因此当S是L的后缀时,suffix(S,L)将成立:
suffix(S,L):- append(_,S,L).
也就是说,如果存在某个表,则表S是表L的后缀,从而使L是将该表与S连接在一起的结果。此谓词成功地找到了表的后缀,此外,通过回溯,可以找到它们全部的匹配:
?- suffix(X,[a,b,c,d]).
X = [a,b,c,d] ;
X = [b,c,d] ;
X = [c,d] ;
X = [d] ;
X = [];
false
确保您了解为什么结果按此顺序显示。
现在,定义一个程序可以很容易地找到表的子表。 [a,b,c,d]的子表是[],[a],[b],[c],[d],[a,b],[b,c],[c,d], [a,b,c],[b,c,d]和[a,b,c,d]。稍加思考,便发现表L的子表只是L的后缀的前缀。以图形方式考虑一下:
正如我们已经定义了用于产生表的后缀和前缀的谓词一样,我们只需将子表定义为:
sublist(SubL,L):- suffix(S,L), prefix(SubL,S).
也就是说,如果存在L的后缀S,而SubL是前缀,则SubL是L的子表。该程序未明确使用append / 3,但是在表象之下,它确实为我们工作,因为prefix / 2和suffix / 2都是使用append / 3定义的。
2 表反转
append / 3谓词很有用,并且重要的是知道如何使用它。但同样重要的是要知道它可能会导致效率低下,并且您可能不想一直使用它。
为什么append / 3是效率低下的根源?考虑一下它的工作方式,您会注意到一个弱点:append / 3不会在一个简单的动作中将两个表合并在一起。相反,它需要一直沿其第一个参数工作,直到找到表的末尾,然后才可以执行串联。
现在,这通常不会造成任何问题。例如,如果我们有两个表,而我们只想将它们连接起来,那可能还不错。当然,Prolog将需要减少第一个表的长度,但是如果表不太长,那么为使用append / 3的便利性付出的代价可能不是太高。
但是,如果前两个参数作为变量给出,问题可能会大不相同。如我们所见,在前两个参数中添加append / 3变量非常有用,因为这使Prolog可以搜索拆分表的方式。但是要付出代价:正是进行大量搜索,这才可能会导致效率很低的程序。
为了说明这一点,我们将研究反转表的问题。也就是说,我们将研究定义谓词的问题,该谓词将一个表(例如[a,b,c,d])作为输入并返回一个包含相同的元素以相反的顺序(此处为[d,c,b,a])。
现在,reverse谓词是一个有用的谓词。正如您现在已经意识到的那样,Prolog中的表从前面访问比从后面访问要容易得多。例如,要拉出表L的头部,我们要做的就是执行[H | _] = L;导致将H实例化到L的开头。但是要拔出任意表的最后一个元素则比较困难:我们不能简单地使用合一来做到这一点。另一方面,如果我们有一个谓词反转了表,则可以首先反转输入表,然后拉出反转表的开头,因为 会给我们原始表的最后一个元素。因此,reverse谓词可能是一个有用的工具。但是,由于我们可能必须逆转大型表,因此我们希望此工具高效。因此,我们需要仔细考虑问题。
这就是我们现在要做的。我们将定义两个reverse谓词:一个简单的谓词,它在append / 3的帮助下定义;一个更有效的(甚至更自然)的谓词,它使用累加器定义。
不考虑效率的问题使用append来反转表
这是对表进行反转所涉及的递归定义:
1.如果我们反转空白表,则会获得空白表。
2.如果反转表[H | T],则最终得到的是通过反转T并与[H]串联而获得的表。
要查看递归子句是否正确,请考虑表[a,b,c,d]。如果我们反转此表的末尾,则将获得[d,c,b]。将其与[a]结合可产生[d,c,b,a],与[a,b,c,d]相反。
借助append / 3,可以轻松地将此递归定义转换下面这样:
naiverev([],[]).
naiverev([H|T],R):- naiverev(T,RevT), append(RevT,[H],R).
现在,这个定义是正确的,但是它做了很多工作。查看此程序的痕迹非常有启发性。这表明该程序花费大量时间执行附加操作。这并不奇怪:毕竟,我们递归地调用append / 3。结果非常低效(如果运行跟踪,您将发现要花费大约90个步骤来反转八个元素的表)并且难以理解(谓词将大部分时间都花在了对append / 3的递归调用中)很难看到发生了什么)。
不是很好。但是,正如我们现在所看到的,还有更好的方法。
使用累加器来反转表
更好的方法是使用累加器。基本思想是简单自然的。我们的累加器将是一个表,当我们启动时它将为空。假设我们要反转[a,b,c,d]。首先,我们的累加器将为[]。因此,我们只是简单地将要尝试反转的表的开头添加到累加器的开头。然后我们继续处理尾巴,因此我们面临着将[b,c,d]反转的任务,而我们的累加器就是[a]。再次,我们将表的首部尝试反转,并将其添加到累加器的首部(因此,新的累加器为[b,a]) 并继续尝试反转[c,d]。同样,我们使用相同的想法,因此我们得到了一个新的累加器[c,b,a],并尝试反转[d]。不用说,下一步将产生累加器[d,c,b,a]和尝试反转[]的新目标。这是过程停止的地方:我们的累加器包含我们想要的反向表。总结一下:这个想法只是简单地遍历我们要反转的表,然后将每个元素依次推入累加器的头部,如下所示:
List: [a,b,c,d] Accumulator: []
List: [b,c,d] Accumulator: [a]
List: [c,d] Accumulator: [b,a]
List: [d] Accumulator: [c,b,a]
List: [] Accumulator: [d,c,b,a]
这将非常有效,因为我们只需要遍历表一次即可:我们不必浪费时间进行连接或其他无关的工作。
将这个想法放到Prolog中也很容易。这是累加器代码:
accRev([H|T],A,R):- accRev(T,[H|A],R).
accRev([],A,A).
这是经典的累加器代码:它遵循与上一章中讨论的算术示例相同的模式。递归子句负责切掉输入表的头部,并将其推入累加器。基本子句将暂停程序,并将累加器复制到最终参数。
与累加器代码一样,编写一个谓词为我们执行累加器所需的初始化是一个好主意:
rev(L,R):- accRev(L,[],R).
同样,在此程序上运行一些跟踪并将其与naiverev / 2进行比较是有益的。基于累加器的版本显然更好。例如,反转八个元素表大约需要20个步骤,而不考虑效率的问题的版本则需要90个步骤。而且,更容易跟踪。基于累加器的版本的思想比对append / 3的递归调用更为简单自然。
总结一下,append / 3是一个有用的程序,您当然不应该害怕使用它。但是,您还需要意识到这是效率低下的根源,因此在使用它时,请问自己是否有更好的方法。而且经常有。累加器的使用通常更好,并且(如rev / 2示例所示),累加器可以是处理表处理任务的自然方法。
3 练习
练习6.1 如果表是由两个完全相同的连续元素块组成的,那么我们称其为两倍。例如,[a,b,c,a,b,c]被加倍(它由[a,b,c]后跟[a,b,c]组成),因此[foo,gubble,foo,gubble]。另一个头,[foo,gubble,foo]没有加倍。编写一个谓词doubled(List),该谓词在List是加倍表时会成功。
练习6.2 回文是指前后拼写相同的单词或短语。例如,“ rotator”,“ eve”和“ nurses run”都是回文。编写谓语palindrome(List),以检查List是否为回文。例如,查询
?- palindrome([r,o,t,a,t,o,r]).
和
?- palindrome([n,u,r,s,e,s,r,u,n]).
Prolog应该回答true,但是要查询
?- palindrome([n,o,t,h,i,s]).
Prolog应该回答false.
练习6.3。编写一个谓词toptail(InList,OutList),如果InList是包含少于2个元素的表,则说否,并删除一个Intail,如果InList是包含至少2个元素的表,则删除InList的第一个和最后一个元素,并将结果作为OutList返回元素。例如:
toptail([a],T).
false
toptail([a,b],T).
T=[]
toptail([a,b,c],T).
T=[b]
(提示:在这里append / 3很有用。)
练习6.4。编写谓词last(List,X),仅当List是包含至少一个元素的表且X是该表的最后一个元素时才为true。以两种不同的方式执行此操作:
1.使用本文讨论的谓词rev / 2定义last / 2。
2.使用递归定义last / 2。
练习6.5。编写谓词swapfl(List1,List2),它检查List1是否与List2相同,只是交换了第一个和最后一个元素。在这里,append / 3可能会再次有用,但是也可以编写递归定义而无需使用append / 3(或任何其他)谓词。
练习6.6 对于那些喜欢逻辑难题的人来说,这是一个练习。
一条街道上有三个相邻的房屋,每个房屋都有不同的颜色,分别是红色,蓝色和绿色。不同国籍的人住在不同的房子里,他们都有不同的宠物,这里有一些关于他们的事实:
•英国人住在红房子里。
•美洲虎是西班牙家庭的宠物。
•日本人居住在蜗牛饲养者的右边。
•养蜗牛的住在蓝房子的左边。
谁养着斑马?不要自己动手:定义谓词zebra / 1,告诉您斑马所有者的国籍!
(提示:考虑一下房屋和街道的表示形式。在Prolog中编写四个约束。您可能会发现member / 2和sublist / 2有用。)
4 实践环节
练习6的目的是帮助您获得更多有关表操作的经验。我们首先建议您进行一些跟踪,然后再进行一些编程练习。
以下跟踪信息将帮助您掌握本文讨论的谓词:
1. 在实例化前两个参数而未实例化第三个参数的情况下执行append / 3的跟踪。例如,append([a,b,c],[[],[2,3],b],X)确保基本模式清晰。
2. 接下来,对用于拆分表的append / 3进行跟踪,即将前两个参数作为变量,最后一个参数实例化。例如,append(L,R,[foo,wee,blup])。
3. 在prefix/ 2和suffix/ 2上执行一些跟踪。为什么prefix/ 2首先找到较短的表,而suffix/ 2首先找到较长的表?
4. 在sublist / 2上执行一些跟踪。就像我们在课程中所说的,通过回溯,该谓词会生成所有可能的子表,但是正如您将看到的,它会多次生成多个子表。你知道为什么吗?
5. 在naiverev / 2和rev / 2上执行跟踪,并比较它们的行为。
现在进行一些编程工作:
1. 可以通过使用append / 3来编写成员谓词的单行定义。这样做。这个新成员的效率如何与标准成员相比?
2. 编写一个谓词set(InList,OutList),该谓词集将任意表作为输入,并返回一个表,其中输入表的每个元素仅出现一次。例如,查询
set([2,2,foo,1,foo,[],[]],X).
应该产生结果
X = [2,foo,1,[]].
(提示:使用成员谓词来测试是否已找到的项重复。)
3. 对于所有嵌套表,我们通过移除其包含为元素的任何表周围以及其元素包含为元素的所有表周围的所有方括号等来“拉平”表,以此类推。例如,当我们拉平表
[[a,b,[c,d],[[1,2]],foo]
我们得到了表
[a,b,c,d,1,2,foo]
当我们弄平表时
[a,b,[[[[[[[c,d]]]]]]],[[1,2]],foo,[]]
我们也得到
[a,b,c,d,1,2,foo]
编写一个谓词flatten(List,Flat),该谓词在第一个参数List变平为第二个参数Flat时成立。请在不使用append / 3的情况下完成。
好的,我们已经读到一半了。扁平化一个表是prolog编程的核心。你过得好吗?如果是的话,太好了。是时候继续前进了。