有向无环词图(DAWG)

原文:http://wutka.com/dawg.html

 

有向无环词图

有向无环词图(DAWG),是一种可以对词进行快速搜索的数据结构。图的入口是最先搜索到的字母。图的每个节点代表一个字母,你可以从一个节点跳转到其他节点,具体怎么跳转依赖于节点代表的字母是否与待搜索的字母匹配。

 

之所以称为有向图,是因为在两个节点之间只能沿特定的方向移动。换句话说,你只能从节点A移动到节点B,而不能从节点B移动到节点A。之所以称为无环,是因为在图中没有闭环。在图中不会出现从AB再到C最后再回到A的路径。如果出现了重新回到A的连接(当然在DAWG中这不会出现),有可能在搜索过程中导致死循环。

 

仅仅通过上面的文字说明还不太好理解,下面我们通过一个例子来进一步说明。假设我们有一个DAWG,该DAWG中包含CAT, CAN, DO和DOG四个词,如下图:

     C --Child--> A --Child--> N (EOW)

     |                                          |

     |                                      Next

   Next                                    |

     |                                         v

     |                                   T (EOW)

     v

     D--Child--> O (EOW) --Child --> G (EOW)

 

 

现在假设我们要验证一下“CAT”是否在DAWG中。我们从该图的入口节点开始搜索。因为“C”是我们要找的字母,我们选择了入口节点“C”。现在我们要找“CAT”中的下一个字母,也就是“A”。我们在“C”Child节点找到了“A”。继续在DAWG中搜索,我们现在到了“A”Child节点“N”,因为我们要找的是“T”,而不是当前节点的“N”,我们继续验证“N”Next节点,经过验证“N”Next节点正式我们要找的“T”。现在我们已经找到了“CAT”中的所有的字母,我们需要确认节点“T”是否具有词尾标记(End-of-Word Flag,简称EOW),在该DAWG中,节点T具有EOW标记,所以“CAT”是该DAWG中存储的一个单词。

 

 

在创建DAWG时,为了使占用的内存更小,我们往往将不同单词中结尾相同的部分共用一些节点。例如,我们想在DAWG中存储“DOG”“LOG”,理想的DAWG如下图:

  D --Child--> O --Child--> G(EOW)

   |                   ^

  Next             |

   |                   |

   v                   |

   L --Child----

从上图可以看出“DOG”“LOG”中的“OG”是共用的同一对节点。

 

创建DAWG

我不能确定接下来要介绍的创建DAWG的方法是否是最优的,但我确实能通过这种方法以一定的效率来创建DAWG(2分钟之内处理了160000个单词)

 

该方法的思路是首先创建一个树,树的叶代表词的结尾,树上可能有多个相同的叶。例如,“DOG”“LOG”,可以按下图的方式来存储:

  D --Child--> O --Child--> G (EOW)

  |

 Next

  |

  v

  L --Child-> O --Child--> G (EOW)

 

现在假设我们想往这个树中添加“DOGMA”。我们的处理方式和前面介绍的搜索方式类似。当我们找到节点“G”之后,发现该节点没有孩子节点,我们给该节点添加一个孩子节点“M”,然后再为节点“M”添加一个孩子节点“A”。如下图:

  D --Child--> O --Child--> G (EOW) --Child--> M --Child--> A (EOW)

  |

 Next

  |

  v

  L --Child-> O --Child--> G (EOW)

 

可以看出通过这种向树中添加节点的方法,不同单词间相同的开始部分可以共用相同的节点,但相同的结尾部分却不是共用的,仍是分开的。为了减少DAWG的大小,我们需要找到相同的结尾部分,然后进行合并。这时我们从叶节点(那些没有孩子节点的节点)开始处理。如果两个叶节点是相同的,我们就合并它们,并将所有指向它们的引用统一指向合并后的节点。判断两个节点是否相同的依据是:两个节点必须代表相同的字母;如果有下个节点(Next Node),下个节点也必须相同;如果有孩子节点,孩子节点也必须相同。

 

例如下面的树,树中含有“CITIES”“CITY”“PITIES”“PITY”

C --Child--> I --Child--> T --Child--> I --Child--> E --Child--> S (EOW)

 |                                                              |

 |                                                           Next

Next                                                       |

 |                                                             v

 |                                                          Y (EOW)

 P --Child--> I --Child--> T --Child--> I --Child--> E --Child--> S (EOW)

                                                                 |

                                                              Next

                                                                |

                                                                v

                                                             Y (EOW)

 

很明显,上图中有很多冗余信息。为了减少冗余,让节点“C”和节点“P”指向同一个节点“I”,是最简洁的存储方式。我的实现这个功能的算法包含的处理步骤稍多一些。

 

创建完树之后,我会遍历该树,并为树的每个节点打上标签,记录该节点的孩子节点的个数和深度(深度是从该节点走到叶子所需经过的最大节点数)。叶子节点的深度是0,孩子节点数也是0。对节点进行标记的主要目的是在寻找相同节点时,我们可以快速排除孩子数和深度不同的节点。当标记节点时,我还将深度相近的节点放到一个队列里,以便进一步加快搜索的速度。

 

标记完节点,并将节点按照深度进行排序之后,就可以从深度为0的节点开始处理了。如果节点“X”和节点“Y”相同,我便将所有指向“Y”,并将“Y”作为孩子节点的节点重新指向“X”。起初,我还允许所有指向“Y”,并将“Y”作为Next节点的节点重新指向“X”。虽然从数据结构的角度来讲,这是可以的;但是这样不能按照我需要的方式将DAWG存储在文件中。

 

“CITY-PITY”的图中,该算法将认为最后的节点“S”是相同的。虽然节点“Y”也是相同的,但是它们不是孩子节点,它们只是“Next”节点。就像前面介绍的,将相同的“Next”节点合并,将会使图的存储变的困难。处理完深度为0的节点(即叶子节点)后,图就变成了下面的样子。

C --Child--> I --Child--> T --Child--> I --Child--> E --Child--> S (EOW)

 |                                                              |                                            ^

 |                                                           Next                                         |

Next                                                       |                                             |

 |                                                             v                                             |

 |                                                           Y (EOW)                                 |

 P --Child--> I --Child--> T --Child--> I --Child--> E --Child------

                                                                 |

                                                              Next

                                                                 |

                                                                 v

                                                                Y (EOW)

 

接下来,算法将处理深度为1的节点,在“CITY-PITY”的例子中即节点“E”(虽然节点“T”到节点“Y”的路径长度也是1,但它的深度是3,因为它到节点“S”的路径长度是3.为了验证这连个节点“E”是否相同,首先需要看字母是否相同,这里字母是相同的。然后需要验证这两个节点的孩子是否相同。因为这两个节点“E”有相同的孩子节点,所以这两个节点“E”是相同的。所以我们现在合并节点“E”


C --Child--> I --Child--> T --Child--> I --Child--> E --Child--> S (EOW)

 |                                                              |                   ^

 |                                                           Next               |

Nex t                                                      |                    |

 |                                                             v                   |

 |                                                           Y (EOW)       |

 P --Child--> I --Child--> T --Child--> I –Child---->

                                                                 |

                                                              Next

                                                                 |

                                                                v

                                                             Y (EOW)

 

现在可以看到单词“PITES”中的节点“E”和节点“S”已经不再需要了。该算法将树进行了相当不错的裁剪。

 

接下来处理深度为2的节点,即节点“I”(有“ES”“Y”作为孩子的“I”节点)。通过同样的比较方法,我们发现这两节点“I”是相同的,我们将它们合并。重复进行这种处理,我们可以将图中的两个节点“T”合并,然后再将两个节点“I”“T”的父节点)。剩余的两个节点是“C”“P”,它们是不同的,所以不能将它们合并。最后树的样子如下:

C --Child--> I --Child--> T --Child--> I --Child--> E --Child--> S (EOW)

 |                   |                                         |

 |                   |                                      Next

Next             |                                        |

 |                   |                                        v

 |                   |                                     Y (EOW)

 P --Child---- 

 

文件格式

一种最简单的存储DAWG的方式是将每个节点用4个字节的数字表示。这4个字节的数字包含如下的信息:一个指向第一个孩子节点的引用;是否是一个单词结尾的标志;是否是最后一个Next节点的标志;该节点表示的字符。值得注意的是在这4个字节的数字中并不包含指向Next节点的指针。在实际存储时,当一个节点被写到文件后,紧跟在该节点后面的便是其Next节点。这也是为什么需要一个是否是最后一个Next节点的标志的原因。

 

假设存储格式如下:

· 用2个字节表示指向Child节点的指针,

· 用1个字节存储标志(1代表单词的结尾,2代表最后一个Next节点)

· 用1个字节来记录该节点代表的字符

 

则前文介绍的“CITY-PITY”的词图可以存储成:

00 00 03 00  Dummy null value, allows 0 to indicate no child

00 03 00 43  C, child at 4-byte-word # 3

00 03 02 50  P, child at 4-byte-word #3, end-of-list

00 04 00 49  I, child at 4-byte-word #4

00 05 00 54  T, child at 4-byte-word #5

00 07 00 49  I, child at 4-byte-word #7

//原文中上面一行有点小错误(原文是00 06 00 49)

00 00 03 59  Y, no child, end-of-word, end-of-list

00 08 00 45  E, child at 4-byte-word #8

00 00 03 53  S, no child, end-of-word, end of list

 

 

因为指向Child节点的指针是用两个字节表示的,因此最多只能有65536个节点。Hasbro Scrabble CDROM对存储方式做了改进,可以存储400万个节点。Hasbro Scrabble CDROM的存储方式是用左边的22个比特位来表示Child节点的偏移量,偏移量右边的一个比特位表示是否是最后一个Next节点,再右边一个表示是否是单词的最后一个字母,剩下的8个比特位存储该节点代表的字母。如果您要存储的节点超过400万,您仍可以对存储方式进行进一步改进,改进后最大可存储3200万个节点。改进的方法是用最左边的25个比特位来记录偏移量,接下来的2个比特位为标志位,最后的5个比特位表示节点代表的字母(数字0-25分别代表A-Z)。

 

在实际应用中您可以自己决定DAWG的第一个节点的偏移量。在上面的例子中第一个节点的偏移量是1.

 

 

 

 

 

 

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值