有一陣子很好奇一個問題:MySQL究竟是如何將內存中的B+樹寫入到磁盤文件中的。明明是一棵樹,要怎樣才能存儲成線性的字節流呢?乾脆本身動手,試着實現一個簡單的版本,來幫助本身摸點門道。雖然想法很不錯,不過一上來就面對噩夢級別的B+樹也太爲難人了,所以就先從簡單的二叉樹入手吧。node
本文使用Common Lisp進行開發。git
首先定義這棵二叉搜索樹的節點的類型github
(defclass <node> () ((data :accessor node-data :initarg :data :documentation "節點中的數據") (left :accessor node-left :initarg :left :documentation "左子樹") (right :accessor node-right :initarg :right :documentation "右子樹")) (:documentation "二叉搜索樹的節點"))
基於節點進一步定義二叉樹的類型數組
(deftype <bst> () '(or <node> null))
如此一來,要建立節點和空樹都是渾然天成的事情了數據結構
(defun make-node (data left right) "建立一個二叉搜索樹的節點" (check-type data integer) (check-type left <bst>) (check-type right <bst>) (make-instance '<node> :data data :left left :right right)) (defun make-empty-bst () "建立一顆空樹" nil)
要判斷一顆二叉樹是否爲空樹只須要簡單包裝一下cl:null
函數便可函數
(defun empty-bst-p (bst) "檢查BST是否爲一個空的二叉搜索樹" (null bst))
爲了生成必要的測試數據,須要提供一個往二叉樹中添加數據的功能工具
(defun insert-node (bst data) "往一顆現有的二叉搜索樹BST中加入一個數據,並返回這顆新的二叉搜索樹" (check-type bst <bst>) (check-type data integer) (when (empty-bst-p bst) (return-from insert-node (make-node data (make-empty-bst) (make-empty-bst)))) (cond ((< data (node-data bst)) (setf (node-left bst) (insert-node (node-left bst) data)) bst) (t (setf (node-right bst) (insert-node (node-right bst) data)) bst)))
有了insert-node
即可以從空樹開始構築起一棵二叉搜索樹測試
(defun create-bst (numbers) "根據NUMBERS中的數值構造一棵二叉搜索樹。至關於NUMBERS中的數字從左往右地插入到一棵空的二叉搜索樹中" (check-type numbers list) (reduce #'(lambda (bst data) (insert-node bst data)) numbers :initial-value (make-empty-bst)))
如今來生成稍後測試用的二叉樹spa
(defvar *bst* (create-bst '(2 1 3)))
模仿命令行工具tree
的格式,提供一個打印二叉樹的功能命令行
(defun print-spaces (n) "打印N個空格" (dotimes (i n) (declare (ignorable i)) (format t " "))) (defun print-bst (bst) "打印二叉樹BST到標準輸出" (check-type bst <bst>) (labels ((aux (bst depth) (cond ((empty-bst-p bst) (format t "^~%")) (t (format t "~D~%" (node-data bst)) (print-spaces (* 2 depth)) (format t "|-") (aux (node-left bst) (1+ depth)) (print-spaces (* 2 depth)) (format t "`-") (aux (node-right bst) (1+ depth)))))) (aux bst 0)))
二叉樹*bst*
的打印結果以下
2 |-1 |-^ `-^ `-3 |-^ `-^
總算要開始實現將二叉樹寫入磁盤文件的功能了。將內存中的二叉樹寫入到文件中,至關於將樹形的數據結構轉換爲線性的存儲結構——畢竟磁盤上的文件能夠認爲就是線性的字節流。在這塊字節流中,除了要保存每個節點的數據以外,一樣重要的還有節點間的父子關係。
有不少種寫盤的方法。好比說,能夠模仿樹的順序存儲結構將二叉樹序列化到磁盤上。以上面的二叉樹*bst*
爲例,它是一棵滿二叉樹,若是採用順序存儲,那麼首先分配一個長度爲3的數組,在下標爲0的位置存儲根節點的數字2,在下標爲1的位置存儲左孩子的數字1,在下標爲2的位置存儲右孩子的數字3,以下圖所示
推廣到高度爲h
的二叉樹,則須要長度爲$2^h-1$的數組來存儲全部節點的數據。假設每個節點的數據都是32位整數類型,那麼一棵高度爲h
的二叉樹在磁盤上便須要佔據$4·(2^h-1)$個字節。這個作法雖然可行,但比較浪費存儲空間。它將節點間的父子關係用隱式的下標關係來代替,節省了存儲左右子樹的「指針」所需的空間,比較適合存儲滿二叉樹或接近滿的二叉樹。
對於稀疏的二叉樹,若是在序列化後的字節流中顯式地記錄節點間的父子關係,即可以節省不少不存在的節點所佔據的存儲空間。好比說,對於每個節點,都序列化爲磁盤上的12個字節:
以下圖所示
上面的數組表示磁盤上的一個文件,每個方格爲一個字節,每一個方格在文件內的偏移從左往右依次增大。因爲採用後序遍歷的方式依次序列化二叉樹中的節點數據和指針,所以左孩子首先被寫入文件,而後是右孩子,最後纔是根節點。推廣到全部的二叉樹,即是先將左右子樹追加寫入磁盤文件,再將根節點的數據、左子樹根節點在文件內的偏移,以及右子樹根節點在文件內的偏移追加到文件末尾;若是左右子樹是空的,那麼以偏移0表示。
這是一個遞歸的過程,而每一次遞歸調用應當返回兩個值:
bytes
root-bytes
bytes
即是右子樹開始寫入時的文件偏移,必須依靠這個信息肯定右子樹的每個節點在文件內的偏移;使用bytes
減去root-bytes
,再加上左子樹開始寫入時的偏移量,即可以得知左子樹的根節點在文件內的位置。最終實現寫盤功能的代碼以下
;;; 定義序列化二叉樹的函數 (defun write-fixnum/32 (n stream) "將定長數字N輸出爲32位的比特流" (check-type n fixnum) (check-type stream stream) (let ((octets (bit-smasher:octets<- n))) (setf octets (coerce octets 'list)) (dotimes (i (- 4 (length octets))) (declare (ignorable i)) (push 0 octets)) (dolist (n octets) (write-byte n stream)))) ;;; 這是一個遞歸的函數,寫入一棵二叉樹的邏輯,就是先寫入左子樹,再寫入右子樹,最後寫入根節點,也就是後序遍歷 ;;; 因爲要序列化爲字節流,所以須要用字節流中的偏移的形式代替內存中的指針,實現從根節點指向左右子樹 ;;; offset是開始序列化bst的時候,在字節流中所處的偏移,同時也是這顆樹第一個被寫入的節點在字節流中的偏移 ;;; 每次調用write-bst-bytes後的返回值有兩個,分別爲二叉樹一共寫入的字節數,以及根節點所佔的字節數 (defun write-bst-bytes (bst stream offset) "將二叉樹BST序列化爲字節寫入到流STREAM中。OFFSET表示BST的第一個字節距離文件頭的偏移" (check-type bst <bst>) (check-type stream stream) (check-type offset integer) (when (empty-bst-p bst) (return-from write-bst-bytes (values 0 0))) ;; 之後序遍歷的方式處理整棵二叉樹 (multiple-value-bind (left-bytes left-root-bytes) (write-bst-bytes (node-left bst) stream offset) (multiple-value-bind (right-bytes right-root-bytes) (write-bst-bytes (node-right bst) stream (+ offset left-bytes)) (write-fixnum/32 (node-data bst) stream) (if (zerop left-bytes) (write-fixnum/32 0 stream) (write-fixnum/32 (- (+ offset left-bytes) left-root-bytes) stream)) (if (zerop right-bytes) (write-fixnum/32 0 stream) (write-fixnum/32 (- (+ offset left-bytes right-bytes) right-root-bytes) stream)) ;; 之因此要加上12個字節,是由於在寫完了左右子樹以後,就緊鄰着寫根節點了。所以,根節點就是在從right-node-offset的位置,接着寫完右子樹的根節點後的位置,而右子樹的根節點佔12個字節 (let ((root-bytes (* 3 4))) (values (+ left-bytes right-bytes root-bytes) root-bytes))))) (defun write-bst-to-file (bst filespec) "將二叉樹BST序列化爲字節流並寫入到文件中" (check-type bst <bst>) (with-open-file (stream filespec :direction :output :element-type '(unsigned-byte 8) :if-exists :supersede) (write-fixnum/32 (char-code #\m) stream) (write-fixnum/32 (char-code #\y) stream) (write-fixnum/32 (char-code #\b) stream) (write-fixnum/32 (char-code #\s) stream) (write-fixnum/32 (char-code #\t) stream) (write-bst-bytes bst stream (* 5 4))))
如今能夠將*bst*
寫入文件了
(write-bst-to-file *bst* "/tmp/bst.dat")
使用hexdump
驗證寫入的效果
文件最開始的五個字節依次存儲着字符串"mybst"
的ASCII碼,爲的就是讓最先被寫入文件中的根節點——也就是二叉樹最左下角的節點——的偏移不爲0,以避免在後續反序列化的時候,從該節點的父節點中讀到左子樹的偏移爲0——這樣會被誤認爲是一棵空樹的。
有哪裏寫得很差的還請各位讀者不吝賜教。