在二叉樹中,每個節點有一個數據項,最多有兩個子節點。如果允許每個節點可以有更多的數據項和更多的子節點,就是多叉樹。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-樹是很合適的。主文件中記錄可以存成任何合適的順序。這種排列方法效率很高。把記錄添加到主文件末尾很快,在索引中插入新紀錄的索引記錄也很快,因為索引按樹形存儲。對大文件來說這樣做查找和插入操作都很快。
外部文件排序:
歸並排序是外部數據排序的首選方法。這是因為,這種方法比起其他大部分排序方法來說,磁盤訪問更多的涉及臨近的記錄而不是文件中隨機的部分。
第一步,讀取一塊,它的記錄在內部排序,然后把排完序的塊寫回到磁盤中。下一塊也同樣排序並寫回到磁盤中。直到所有的塊內部都有序為止。
第二步,讀取兩個有序的塊,合並成一個兩塊的有序的序列,再把它們寫回到磁盤。下次,把兩塊序列合成四塊的序列。這個過程繼續下去,直到所有成對的塊都合並過了為止。每次,有序序列的長度增長一倍,直到整個文件有序。假設內存有限,只能存取三個塊,則每次選擇兩個待排序的塊,還有個空的塊,當對這兩個待排序的塊排列時,逐漸填滿空的塊,當填滿時,就將其寫入磁盤中,在對其清空,這樣,兩個待排序的塊剩下的數據就可以利用空的塊再此排序。
