十二、234树
在二叉树中,每个节点有一个数据项,最多有两个子节点。如果允许每个节点可以有更多的数据项和更多的子节点,就是多叉树。2-3-4树就是多叉树,它的每个节点最多有四个子节点和三个数据项。
2-3-4树和红黑树一样是平衡树。它的效率比红黑树稍差,但编程容易。通过2-3-4树可以更容易地理解B-树。
B-树是另一种多叉树,专门用在外部存储中来组织数据。B-树中的节点可以有几十或几百个子节点。
2-3-4树名字中的2,3,4的含义是指一个节点可能含有的子节点的个数。对非叶子节点有三种可能的情况:
•有一个数据项的节点总是有两个子节点。
•有两个数据项的节点总是有三个子节点。
•有三个数据项的节点总是有四个子节点。
2-3-4树的搜索:查找特定关键字值的数据项和在二叉搜索树相类似。从根开始,除非查找的关键字值就是根,否则选择关键字值所在的合适范围,转向那个方向,直到找到为止。
2-3-4树的插入:新的数据项总是插在叶节点里,在树的最底层。如果插入到有子节点的节点里,子节点的编号就要发生变化以此来保持树的结构,这保证了节点的子节点比数据项多1。
插入:从根节点开始往下查找,查找时没有碰到满节点时,找到合适的叶节点后,只要把新数据项插入进去就可以了。
查找时碰到满节点时,节点必须分裂,假设要分裂节点的数据排列为ABC
•创建一个新的空节点,它是要分裂节点的兄弟,在分裂节点的右边。
•数据项C移到新节点中。
•数据项B移到要分裂节点的父节点中。
•数据项A保留在原来的位置上。
•新创建的节点和原来满的节点连到新节点上。
•顺着分裂的节点,继续向下查找插入点,找到合适的叶子节点,再插入。
•如果一开始查找插入点时就碰到满的根时,还要新创建新的根
所有满的节点都是在下行路途中分裂的。分裂不可能向回波及到树上面的节点。任何要分裂节点的父节点都不是满的。因此该节点不需要分裂就可以插入数据项B。如果父节点的子节点分裂时它已经有两个子节点了,它就变满了。但是,这只是意味着下次查找碰到它时才需要分裂。
// tree234.java import java.io.*; class DataItem { public long dData; // one data item public DataItem(long dd) // constructor { dData = dd; } public void displayItem() // display item, format "/27" { System.out.print("/"+dData); } } class Node { private static final int ORDER = 4; private int numItems; private Node parent; private Node childArray[] = new Node[ORDER]; private DataItem itemArray[] = new DataItem[ORDER-1]; // connect child to this node public void connectChild(int childNum, Node child) { childArray[childNum] = child; if(child != null) child.parent = this; } // disconnect child from this node, return it public Node disconnectChild(int childNum) { Node tempNode = childArray[childNum]; childArray[childNum] = null; return tempNode; } public Node getChild(int childNum) { return childArray[childNum]; } public Node getParent() { return parent; } public boolean isLeaf() { return (childArray[0]==null) ? true : false; } public int getNumItems() { return numItems; } public DataItem getItem(int index) // get DataItem at index { return itemArray[index]; } public boolean isFull() { return (numItems==ORDER-1) ? true : false; } public int findItem(long key) // return index of { // item (within node) for(int j=0; j<ORDER-1; j++) // if found, { // otherwise, if(itemArray[j] == null) // return -1 break; else if(itemArray[j].dData == key) return j; } return -1; } public int insertItem(DataItem newItem) { // assumes node is not full numItems++; // will add new item long newKey = newItem.dData; // key of new item for(int j=ORDER-2; j>=0; j--) // start on right, { // examine items if(itemArray[j] == null) // if item null, continue; // go left one cell else // not null, { // get its key long itsKey = itemArray[j].dData; if(newKey < itsKey) // if it's bigger itemArray[j+1] = itemArray[j]; // shift it right else { itemArray[j+1] = newItem; // insert new item return j+1; // return index to } // new item } } // shifted all items, itemArray[0] = newItem; // insert new item return 0; } public DataItem removeItem() // remove largest item { // assumes node not empty DataItem temp = itemArray[numItems-1]; // save item itemArray[numItems-1] = null; // disconnect it numItems--; // one less item return temp; // return item } public void displayNode() // format "/24/56/74/" { for(int j=0; j<numItems; j++) itemArray[j].displayItem(); // "/56" System.out.println("/"); // final "/" } } class Tree234 { private Node root = new Node(); // make root node public int find(long key) { Node curNode = root; int childNumber; while(true) { if(( childNumber=curNode.findItem(key) ) != -1) return childNumber; // found it else if( curNode.isLeaf() ) return -1; // can't find it else // search deeper curNode = getNextChild(curNode, key); } } // insert a DataItem public void insert(long dValue) { Node curNode = root; DataItem tempItem = new DataItem(dValue); while(true) { if( curNode.isFull() ) // if node full, { split(curNode); // split it curNode = curNode.getParent(); // back up // search once curNode = getNextChild(curNode, dValue); } // end if(node is full) else if( curNode.isLeaf() ) // if node is leaf, break; // go insert // node is not full, not a leaf; so go to lower level else curNode = getNextChild(curNode, dValue); } curNode.insertItem(tempItem); // insert new DataItem } public void split(Node thisNode) // split the node { // assumes node is full DataItem itemB, itemC; Node parent, child2, child3; int itemIndex; itemC = thisNode.removeItem(); // remove items from itemB = thisNode.removeItem(); // this node child2 = thisNode.disconnectChild(2); // remove children child3 = thisNode.disconnectChild(3); // from this node Node newRight = new Node(); // make new node if(thisNode==root) // if this is the root, { root = new Node(); // make new root parent = root; // root is our parent root.connectChild(0, thisNode); // connect to parent } else // this node not the root parent = thisNode.getParent(); // get parent // deal with parent itemIndex = parent.insertItem(itemB); // item B to parent int n = parent.getNumItems(); // total items? for(int j=n-1; j>itemIndex; j--) // move parent's { // connections Node temp = parent.disconnectChild(j); // one child parent.connectChild(j+1, temp); // to the right } // connect newRight to parent parent.connectChild(itemIndex+1, newRight); // deal with newRight newRight.insertItem(itemC); // item C to newRight newRight.connectChild(0, child2); // connect to 0 and 1 newRight.connectChild(1, child3); // on newRight } // gets appropriate child of node during search for value public Node getNextChild(Node theNode, long theValue) { int j; // assumes node is not empty, not full, not a leaf int numItems = theNode.getNumItems(); for(j=0; j<numItems; j++) // for each item in node { // are we less? if( theValue < theNode.getItem(j).dData ) return theNode.getChild(j); // return left child } // we're greater, so return theNode.getChild(j); // return right child } public void displayTree() { recDisplayTree(root, 0, 0); } private void recDisplayTree(Node thisNode, int level, int childNumber) { System.out.print("level="+level+" child="+childNumber+" "); thisNode.displayNode(); // display this node // call ourselves for each child of this node int numItems = thisNode.getNumItems(); for(int j=0; j<numItems+1; j++) { Node nextNode = thisNode.getChild(j); if(nextNode != null) recDisplayTree(nextNode, level+1, j); else return; } } } class Tree234App { public static void main(String[] args) throws IOException { long value; Tree234 theTree = new Tree234(); theTree.insert(50); theTree.insert(40); theTree.insert(60); theTree.insert(30); theTree.insert(70); while(true) { System.out.print("Enter first letter of "); System.out.print("show, insert, or find: "); char choice = getChar(); switch(choice) { case 's': theTree.displayTree(); break; case 'i': System.out.print("Enter value to insert: "); value = getInt(); theTree.insert(value); break; case 'f': System.out.print("Enter value to find: "); value = getInt(); int found = theTree.find(value); if(found != -1) System.out.println("Found "+value); else System.out.println("Could not find "+value); break; default: System.out.print("Invalid entry\n"); } } } public static String getString() throws IOException { InputStreamReader isr = new InputStreamReader(System.in); BufferedReader br = new BufferedReader(isr); String s = br.readLine(); return s; } public static char getChar() throws IOException { String s = getString(); return s.charAt(0); } public static int getInt() throws IOException { String s = getString(); return Integer.parseInt(s); } }
2-3-4树和红黑树看上去可能完全不同。但是,在某种意义上它们又是完全相同的。一个可以通过应用一些简单的规则变成另一个,而且使它们保持平衡的操作也是一样的。数学上称它们是同构的。
2-3-4树转变为红黑树:
•把2-3-4树中的每个2-节点转化为红黑树的黑色节点。
•把每个3-节点转化为一个子节点和一个父节点。子节点有两个自己的子节点:W和X或X和Y。父节点有另一个子节点:Y或W。哪个节点变成子节点或父节点都无所谓。子节点涂成红色,父节点涂成黑色。
• 把每个4-节点转化成一个父节点和两个子节点。第一个子节点有它自己的子节点W和X;第二个子节点拥有子节点Y和Z。子节点涂成红色,父节点涂成黑色。
2-3-4树的高度比红黑树要小,但是每个节点要查看的数据项变多了,这回增加查找时间。因为节点中用线性搜索来查看数据项,使查找时间增加的倍数和M成正比,即每个节点数据项的平均数量。总的查找时间和M*log4N成正比。因此,2-3-4树中增加每个节点的数据项数量可以抵偿树的高度的减少。2-3-4树中的查找时间与平衡二叉树如红黑树大致相等,都是O(logN)。
2-3树:2-3树的节点比2-3-4树少存一个数据项和少一个子节点。节点可以保存1个或2个数据项,可以有0个,1个,2个或3个子节点。其他的方面,父节点和子节点的关键字值的排列顺序是和2-3-4树一样的。向节点插入数据项简单一些,因为需要的比较和移动的次数少了。和2-3-4树中一样,所有新数据项都插入到叶节点中去,而且所有的叶节点都在树的最底层。
节点分裂:2-3-4树中在所有分裂完成之后才插入新数据项。2-3树中新的数据项必须参与分裂过程。它必须要插入到叶节点中去,这就不可能在下行路途中进行分裂。如果新数据项要插入的叶节点不满,新数据项可以立即插入进去,如果叶节点满了,该节点就得分裂。该节点的两个数据项和新数据项分在这三个节点里:已存在的节点,新节点,父节点。如果父节点不是满的,三个数据项中的中间数据项放到父节点中,操作就完成了。但是,如果父节点是满的,父节点也必须要分裂。它的两个数据项和自己分裂的子节点传上来的数据项必须分配到父节点,父节点的新的兄弟节点,以及父节点的父节点中去。如果父节点的父节点是满的,还是要分裂。分裂过程向上延续直到找到不满的父节点或者遇到根。如果根也是满的,就要创建一个新的根作为原来的根的父节点。向下插入的过程不理会遇到的节点是满还是不满。
外部存储:在磁盘驱动器中访问数据比在主存中要慢的多,一次需要访问很多记录。磁盘驱动器每次最少读或写一个数据块的数据。块的大小根据操作系统,磁盘驱动器的容量,以及其他因素而不同,但它总是2的倍数。假设一个块大小8192字节,每个记录大小是512字节,在一块中就能存储16条记录。
查找:
假设有500000个记录,如果在数据项都在主存中,当做数组按序排列,可以使用二分查找执行要log2N此比较,也就是19此,如果每次比较要10us,总共就是190微妙,速度是很快的。
但是现在处理的数据存储在磁盘上,当做数组按序排列。500000个记录,每块16个记录,那么一共有500000/16=31250快,取对数大约是15,所以理论上大约需要存取15次磁盘来找到要找的记录。每次访问需要10ms,总共需要150ms。这比内存访问要慢的多。
插入:
要在顺序有序排列的文件插入(或删除)一个记录时平均要移动一半的记录,因此要移动大约一半的块。移动每块都需要存取两次磁盘:一次读一次写。找到插入点时,把包含插入点的数据块读入到存储缓冲区中。块中最后一条记录保存住,移动适当数目的记录为要插入的新记录腾地方,之后就把缓冲区的内容写回到磁盘中去。下一步,第二块读到缓冲区中。保存它的最后一条记录,这块所有其他记录都向后移动一位,上一快的最后一条记录插入到缓冲区的开始处。之后缓冲区的内容再写回到磁盘中去。这个过程一直继续,直到所有在插入点后面的记录都重写过为止。假设有31250块,需要读和写15625块,每次读和写需要10ms,总共要用5分钟来插入一条记录。
怎样保存文件中的记录才能快速地查找,插入和删除记录呢?树是组织内存数据的一个好方法。树也可以应用于文件,但对外部数据需要和内存数据不一样的树,这种树就是多叉树,有点像2-3-4树,但每个节点有更多的记录,称为B-树。
每个块存放16个记录,那么就将这16个记录的块作为一个节点(记录不仅包含数据,也包含它指向的块的编号),那么它有17个子节点,就是17阶B-树。
查找:
在记录中按关键字查找和在内存的2-3-4树中查找很类似。首先,含有根的块读入到内存中,然后搜索算法开始在这个块中比较,当要查找的关键字大于块中的某个记录的关键字,小于块中的另一个记录的关键字,则去找这两个记录之间的那个子节点。持续这个过程直到找到正确的节点。如果到达叶节点还没有找到那条记录,则查找不成功。
B-树中所有的节点至少是半满的,所以每个节点至少有8条记录和9个子节点的链接。树的高度因此比log9N小一点,N是500000,这样树的高度大概是六层。因此,使用B-树只需要6次访问磁盘就可以在500000条记录的文件中找到任何记录了。每次访问10ms,这就需要花费60ms的时间。这比在顺序有序排列的文件中二分查找快的多。
插入:
B-树的插入过程更像2-3树,而不是2-3-4树。B-树插入过程与2-3-4树插入的不同:
•节点分裂时数据项平分:一半到新创建的节点中去,一半保留在原来的节点中。
•节点分裂像2-3树那样从底向上,而不是自顶向下。
•同样,还是像2-3树那样,原节点内中间数据项不上移,而是加上数据项后所组成的节点数据项序列的中间数据项上移。
先假设在B-树中不需要节点分裂的插入情况。只需要六次访问就可以找到插入点。之后还需要一次访问把保存了新插入记录的块写回到磁盘中去,一共是7次访问。
当节点需要分裂时,要读入分裂的节点,它的一半记录都要移动,并且要写回磁盘。新创建的节点要写入磁盘,必须要读取父节点,然后插入上移的记录,写回磁盘。这里就有5次访问,加上找到插入点需要的6次访问,一共是12次。相比在访问顺序文件中插入数据项所需要的500000次访问这是大大地改进了。
索引:
另一种加快文件访问速度的方法是用顺序有序排列存储记录但用文件索引连接数据。文件索引是由关键字-块对组成的列表。它按关键字排序。磁盘上原来的那些记录可以按任何顺序有序排列。这就是说新记录可以简单地添加到文件末尾,这样记录按照插入时间排序。
内存中的文件索引
索引比文件中实际记录小得多。它甚至可以完全放在内存中。
查找
应用将索引放在内存中的方法,使得操作电话本的文件比直接在顺序有序排列记录的文件中执行操作更快。例如,二分查找需要19次索引访问10us,然后在索引中找到实际的记录块的号码后,不可避免要花时间从文件中访问它。不过,这一次访问磁盘的时间只需要10ms。
插入
在索引文件中插入新数据项,要做两步。首先把这个数据项整个记录插入到主文件中去,然后把关键字和包括新数据项存储的块号码的记录插入到索引中。
因为索引是顺序有序排列的(可以看出数组按大小排序了),要插入新数据项,平均需要移动一半的索引记录。设内存中2us移动一个字节,则需要250000*32*2/1000000=16s,大约要16s来插入一个新记录。这比没有索引,在顺序有序排列的文件插入一条新记录要5分钟还是快多了。
当然,可以用更复杂的方法在内存中保存索引。例如把它存为二叉树,2-3-4树,红黑树。这些方法都大大减少了插入和删除的时间。每种情况下把索引存在内存中的方法都比文件顺序有序排列的方法快得多。有时比B-树都快。在索引文件的插入过程中真正的磁盘访问包括插入新纪录本身。通常,把文件的最后一块读入到内存中来,把新纪录添加在后面,然后把这块写回到磁盘上去。这个过程只需要两次文件访问。
多级索引:
索引方法的一个优点是多级索引,同一个文件可以创建不同关键字的索引。在一个索引中关键字可以是姓;另一个索引中是地址。索引和文件比起来很小,所以它并不会大量地增加数据存储量。当然,数据项从文件中删除的时候会麻烦一些,需要把所有索引中的那条索引记录删掉。
对内存来说索引太大
如果索引太大,不能放在内存中,它就需要按块分开存储在磁盘上。对大文件来说把索引保存成B-树是很合适的。主文件中记录可以存成任何合适的顺序。这种排列方法效率很高。把记录添加到主文件末尾很快,在索引中插入新纪录的索引记录也很快,因为索引按树形存储。对大文件来说这样做查找和插入操作都很快。
外部文件排序:
归并排序是外部数据排序的首选方法。这是因为,这种方法比起其他大部分排序方法来说,磁盘访问更多的涉及临近的记录而不是文件中随机的部分。
第一步,读取一块,它的记录在内部排序,然后把排完序的块写回到磁盘中。下一块也同样排序并写回到磁盘中。直到所有的块内部都有序为止。
第二步,读取两个有序的块,合并成一个两块的有序的序列,再把它们写回到磁盘。下次,把两块序列合成四块的序列。这个过程继续下去,直到所有成对的块都合并过了为止。每次,有序序列的长度增长一倍,直到整个文件有序。假设内存有限,只能存取三个块,则每次选择两个待排序的块,还有个空的块,当对这两个待排序的块排列时,逐渐填满空的块,当填满时,就将其写入磁盘中,在对其清空,这样,两个待排序的块剩下的数据就可以利用空的块再此排序。