1、引言
产 品分类,多级的树状结构的论坛,邮件列表等许多地方咱们都会遇到这样的问题:如何存储多级结构的数据?在PHP的应用中,提供后台数据存储的一般是关系型 数据库,它可以保存大量的数据,提供高效的数据检索和更新服务。然而关系型数据的基本形式是纵横交错的表,是一个平面的结构,若是要将多级树状结构存储在 关系型数据库里就须要进行合理的翻译工做。接下来我会将本身的所见所闻和一些实用的经验和你们探讨一下:
层级结构的数据保存在平面的数据库中基本上有两种经常使用设计方法:
* 毗邻目录模式(adjacency list model)
* 预排序遍历树算法(modified preorder tree traversal algorithm)
我不是计算机专业的,也没有学过什么数据结构的东西,因此这两个名字都是我本身按照字面的意思翻的,若是说错了还请多多指教。这两个东西听着好像很吓人,其实很是容易理解。
2、模型
这里我用一个简单食品目录做为咱们的示例数据。
咱们的数据结构是这样的,如下是代码:php
- Food
- |
- |---Fruit
- | |
- | |---Red
- | | |
- | | |--Cherry
- | |
- | +---Yellow
- | |
- | +--Banana
- |
- +---Meat
- |--Beef
- +--Pork
复制代码node
为了照顾那些英文一塌糊涂的PHP爱好者mysql
- Food : 食物
- Fruit : 水果
- Red : 红色
- Cherry: 樱桃
- Yellow: 黄色
- Banana: 香蕉
- Meat : 肉类
- Beef : 牛肉
- Pork : 猪肉
复制代码算法
3、实现
一、毗邻目录模式(adjacency list model)
这种模式咱们常常用到,不少的教程和书中也介绍过。咱们经过给每一个节点增长一个属性 parent 来表示这个节点的父节点从而将整个树状结构经过平面的表描述出来。根据这个原则,例子中的数据能够转化成以下的表:
如下是代码:sql
- +-----------------------+
- | parent | name |
- +-----------------------+
- | | Food |
- | Food | Fruit |
- | Fruit | Green |
- | Green | Pear |
- | Fruit | Red |
- | Red | Cherry |
- | Fruit | Yellow |
- | Yellow | Banana |
- | Food | Meat |
- | Meat | Beef |
- | Meat | Pork |
- +-----------------------+
复制代码数据库
我 们看到 Pear 是Green的一个子节点,Green是Fruit的一个子节点。而根节点'Food'没有父节点。 为了简单地描述这个问题,这个例子中只用了name来表示一个记录。 在实际的数据库中,你须要用数字的id来标示每一个节点,数据库的表结构大概应该像这样:id, parent_id, name, descrīption。
有了这样的表咱们就能够经过数据库保存整个多级树状结构了。
显示多级树,若是咱们须要显示这样的一个多级结构须要一个递归函数。
如下是代码:数组
- <?php
- // $parent is the parent of the children we want to see
- // $level is increased when we go deeper into the tree,
- // used to display a nice indented tree
- function display_children($parent, $level) {
- // 得到一个 父节点 $parent 的全部子节点
- $result = mysql_query("
- SELECT name
- FROM tree
- WHERE parent = '" . $parent . "'
- ;"
- );
- // 显示每一个子节点
- while ($row = mysql_fetch_array($result)) {
- // 缩进显示节点名称
- echo str_repeat(' ', $level) . $row['name'] . "\n";
- //再次调用这个函数显示子节点的子节点
- display_children($row['name'], $level+1);
- }
- }
- ?>
复制代码数据结构
对整个结构的根节点(Food)使用这个函数就能够打印出整个多级树结构,因为Food是根节点它的父节点是空的,因此这样调用: display_children('',0)。将显示整个树的内容:函数
- Food
- Fruit
- Red
- Cherry
- Yellow
- Banana
- Meat
- Beef
- Pork
复制代码fetch
若是你只想显示整个结构中的一部分,好比说水果部分,就能够这样调用:display_children('Fruit',0);
几 乎使用一样的方法咱们能够知道从根节点到任意节点的路径。好比 Cherry 的路径是 "Food >; Fruit >; Red"。 为了获得这样的一个路径咱们须要从最深的一级"Cherry"开始, 查询获得它的父节点"Red"把它添加到路径中,而后咱们再查询Red的父节点并把它也添加到路径中,以此类推直到最高层的"Food",如下是代码:
- <?php
- // $node 是那个最深的节点
- function get_path($node) {
- // 查询这个节点的父节点
- $result = mysql_query("
- SELECT parent
- FROM tree
- WHERE name = '" . $node ."'
- ;"
- );
- $row = mysql_fetch_array($result);
- // 用一个数组保存路径
- $path = array();
- // 若是不是根节点则继续向上查询
- // (根节点没有父节点)
- if ($row['parent'] != '') {
- // the last part of the path to $node, is the name
- // of the parent of $node
- $path[] = $row['parent'];
- // we should add the path to the parent of this node
- // to the path
- $path = array_merge(get_path($row['parent']), $path);
- }
- // return the path
- return $path;
- }
- ?>;
复制代码
若是对"Cherry"使用这个函数:print_r(get_path('Cherry')),就会获得这样的一个数组了:
- Array (
- [0] => Food
- [1] => Fruit
- [2] => Red
- )
复制代码
接下来如何把它打印成你但愿的格式,就是你的事情了。
缺点:
这种方法很简单,容易理解,好上手。可是也有一些缺点。主要是由于运行速度很慢,因为获得每一个节点都须要进行数据库查询,数据量大的时候要进行不少查询才能完成一个树。另外因为要进行递归运算,递归的每一级都须要占用一些内存因此在空间利用上效率也比较低。
二、预排序遍历树算法
如今让咱们看一看另一种不使用递归计算,更加快速的方法,这就是预排序遍历树算法(modified preorder tree traversal algorithm)
这种方法你们可能接触的比较少,初次使用也不像上面的方法容易理解,可是因为这种方法不使用递归查询算法,有更高的查询效率。
我 们首先将多级数据按照下面的方式画在纸上,在根节点Food的左侧写上 1 而后沿着这个树继续向下 在 Fruit 的左侧写上 2 而后继续前进,沿着整个树的边缘给每个节点都标上左侧和右侧的数字。最后一个数字是标在Food 右侧的 18。在下面的这张图中你能够看到整个标好了数字的多级结构。(没有看懂?用你的手指指着数字从1数到18就明白怎么回事了。还不明白,再数一遍,注意移 动你的手指)。
这些数字标明了各个节点之间的关系,"Red"的号是3和6,它是 "Food" 1-18 的子孙节点。 一样,咱们能够看到 全部左值大于2和右值小于11的节点 都是"Fruit" 2-11 的子孙节点
如下是代码:
- 1 Food 18
- |
- +------------------------------+
- | |
- 2 Fruit 11 12 Meat 17
- | |
- +-------------+ +------------+
- | | | |
- 3 Red 6 7 Yellow 10 13 Beef 14 15 Pork 16
- | |
- 4 Cherry 5 8 Banana 9
复制代码
这样整个树状结构能够经过左右值来存储到数据库中。继续以前,咱们看一看下面整理过的数据表。
如下是代码:
- +----------+------------+-----+-----+
- | parent | name | lft | rgt |
- +----------+------------+-----+-----+
- | | Food | 1 | 18 |
- | Food | Fruit | 2 | 11 |
- | Fruit | Red | 3 | 6 |
- | Red | Cherry | 4 | 5 |
- | Fruit | Yellow | 7 | 10 |
- | Yellow | Banana | 8 | 9 |
- | Food | Meat | 12 | 17 |
- | Meat | Beef | 13 | 14 |
- | Meat | Pork | 15 | 16 |
- +----------+------------+-----+-----+
复制代码
注意:因为"left"和"right"在 SQL中有特殊的意义,因此咱们须要用"lft"和"rgt"来表示左右字段。 另外这种结构中再也不须要"parent"字段来表示树状结构。也就是 说下面这样的表结构就足够了。
如下是代码:
- +------------+-----+-----+
- | name | lft | rgt |
- +------------+-----+-----+
- | Food | 1 | 18 |
- | Fruit | 2 | 11 |
- | Red | 3 | 6 |
- | Cherry | 4 | 5 |
- | Yellow | 7 | 10 |
- | Banana | 8 | 9 |
- | Meat | 12 | 17 |
- | Beef | 13 | 14 |
- | Pork | 15 | 16 |
- +------------+-----+-----+
复制代码
好了咱们如今能够从数据库中获取数据了,例如咱们须要获得"Fruit"项下的全部全部节点就能够这样写查询语句:
- SELECT * FROM tree WHERE lft BETWEEN 2 AND 11;
复制代码
这个查询获得了如下的结果。
如下是代码:
- +------------+-----+-----+
- | name | lft | rgt |
- +------------+-----+-----+
- | Fruit | 2 | 11 |
- | Red | 3 | 6 |
- | Cherry | 4 | 5 |
- | Yellow | 7 | 10 |
- | Banana | 8 | 9 |
- +------------+-----+-----+
复制代码
看到了吧,只要一个查询就能够获得全部这些节点。为了可以像上面的递归函数那样显示整个树状结构,咱们还须要对这样的查询进行排序。用节点的左值进行排序:
- SELECT * FROM tree WHERE lft BETWEEN 2 AND 11 ORDER BY lft ASC;
复制代码
剩下的问题如何显示层级的缩进了。
如下是代码:
- <?php
- function display_tree($root) {
- // 获得根节点的左右值
- $result = mysql_query("
- SELECT lft, rgt
- FROM tree
- WHERE name = '" . $root . "'
- ;"
- );
- $row = mysql_fetch_array($result);
- // 准备一个空的右值堆栈
- $right = array();
- // 得到根基点的全部子孙节点
- $result = mysql_query("
- SELECT name, lft, rgt
- FROM tree
- WHERE lft BETWEEN '" . $row['lft'] . "' AND '" . $row['rgt'] ."'
- ORDER BY lft ASC
- ;"
- );
- // 显示每一行
- while ($row = mysql_fetch_array($result)) {
- // only check stack if there is one
- if (count($right) > 0) {
- // 检查咱们是否应该将节点移出堆栈
- while ($right[count($right) - 1] < $row['rgt']) {
- array_pop($right);
- }
- }
- // 缩进显示节点的名称
- echo str_repeat(' ',count($right)) . $row['name'] . "\n";
- // 将这个节点加入到堆栈中
- $right[] = $row['rgt'];
- }
- }
- ?>
复制代码
若是你运行一下以上的函数就会获得和递归函数同样的结果。只是咱们的这个新的函数可能会更快一些,由于只有2次数据库查询。
要获知一个节点的路径就更简单了,若是咱们想知道Cherry 的路径就利用它的左右值4和5来作一个查询。
- SELECT name FROM tree WHERE lft < 4 AND rgt >; 5 ORDER BY lft ASC;
复制代码
这样就会获得如下的结果:
如下是代码:
- +------------+
- | name |
- +------------+
- | Food |
- | Fruit |
- | Red |
- +------------+
复制代码
那么某个节点到底有多少子孙节点呢?很简单,子孙总数=(右值-左值-1)/2
- descendants = (right – left - 1) / 2
复制代码
不相信?本身算一算啦。
用这个简单的公式,咱们能够很快的算出"Fruit 2-11"节点有4个子孙节点,而"Banana 8-9"节点没有子孙节点,也就是说它不是一个父节点了。
很神奇吧?虽然我已经屡次用过这个方法,可是每次这样作的时候仍是感到很神奇。
这的确是个很好的办法,可是有什么办法可以帮咱们创建这样有左右值的数据表呢?这里再介绍一个函数给你们,这个函数能够将name和parent结构的表自动转换成带有左右值的数据表。
如下是代码:
- <?php
- function rebuild_tree($parent, $left) {
- // the right value of this node is the left value + 1
- $right = $left+1;
- // get all children of this node
- $result = mysql_query("
- SELECT name
- FROM tree
- WHERE parent = '" . $parent . "'
- ;"
- );
- while ($row = mysql_fetch_array($result)) {
- // recursive execution of this function for each
- // child of this node
- // $right is the current right value, which is
- // incremented by the rebuild_tree function
- $right = rebuild_tree($row['name'], $right);
- }
- // we've got the left value, and now that we've processed
- // the children of this node we also know the right value
- mysql_query("
- UPDATE tree
- SET
- lft = '" . $left . "',
- rgt= '" . $right . "'
- WHERE name = '" . $parent . "'
- ;"
- );
- // return the right value of this node + 1
- return $right + 1;
- }
- ?>
复制代码
固然这个函数是一个递归函数,咱们须要从根节点开始运行这个函数来重建一个带有左右值的树
- rebuild_tree('Food',1);
复制代码
这个函数看上去有些复杂,可是它的做用和手工对表进行编号同样,就是将立体多层结构的转换成一个带有左右值的数据表。
那么对于这样的结构咱们该如何增长,更新和删除一个节点呢?
增长一个节点通常有两种方法:
第一种,保留原有的name 和parent结构,用老方法向数据中添加数据,每增长一条数据之后使用rebuild_tree函数对整个结构从新进行一次编号。
第 二种,效率更高的办法是改变全部位于新节点右侧的数值。举例来讲:咱们想增长一种新的水果"Strawberry"(草莓)它将成为"Red"节点的最后 一个子节点。首先咱们须要为它腾出一些空间。"Red"的右值应当从6改为8,"Yellow 7-10 "的左右值则应当改为 9-12。依次类推咱们能够得知,若是要给新的值腾出空间须要给全部左右值大于5的节点 (5 是"Red"最后一个子节点的右值) 加上2。因此咱们这样进行数据库操做:
- UPDATE tree SET rgt = rgt + 2 WHERE rgt > 5;
- UPDATE tree SET lft = lft + 2 WHERE lft > 5;
复制代码
这样就为新插入的值腾出了空间,如今能够在腾出的空间里创建一个新的数据节点了, 它的左右值分别是6和7
- INSERT INTO tree SET lft=6, rgt=7, name='Strawberry';
复制代码
再作一次查询看看吧!怎么样?很快吧。 4、结语 好了,如今你能够用两种不一样的方法设计你的多级数据库结构了,采用何种方式彻底取决于你我的的判断,可是对于层次多数量大的结构我更喜欢第二种方法。若是查询量较小可是须要频繁添加和更新的数据,则第一种方法更为简便。 另外,若是数据库支持的话 你还能够将rebuild_tree()和 腾出空间的操做写成数据库端的触发器函数, 在插入和更新的时候自动执行, 这样能够获得更好的运行效率, 并且你添加新节点的SQL语句会变得更加简单。