將二叉樹寫到磁盤上

背景

有一陣子很好奇一個問題: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個字節:

  1. 下標爲0到3的4個字節,存儲的是節點中的數據;
  2. 下標爲4到7的4個字節,存儲的是節點的左子樹在文件中的偏移;
  3. 下標爲8到11的4個字節,存儲的是節點的右子樹在文件中的偏移

以下圖所示

上面的數組表示磁盤上的一個文件,每個方格爲一個字節,每一個方格在文件內的偏移從左往右依次增大。因爲採用後序遍歷的方式依次序列化二叉樹中的節點數據和指針,所以左孩子首先被寫入文件,而後是右孩子,最後纔是根節點。推廣到全部的二叉樹,即是先將左右子樹追加寫入磁盤文件,再將根節點的數據、左子樹根節點在文件內的偏移,以及右子樹根節點在文件內的偏移追加到文件末尾;若是左右子樹是空的,那麼以偏移0表示。

這是一個遞歸的過程,而每一次遞歸調用應當返回兩個值:

  1. 寫入的總字節數bytes
  2. 根節點所佔據的字節數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——這樣會被誤認爲是一棵空樹的。

有哪裏寫得很差的還請各位讀者不吝賜教。

閱讀原文

相關文章
相關標籤/搜索