cv.cm

コーディング · データ構造

二分木の巡回とは?前順・中順・後順・レベル順を解説

約7分で読めます

結論

二分木を巡回するとは、すべてのノードを決まった順序でちょうど1回ずつ訪問することです。深さ優先の3つの順序の違いは、ノード自身を訪問するタイミングだけです。前順(preorder)はノード、左、右の順、中順(inorder)は左、ノード、右の順、後順(postorder)は左、右、ノードの順に訪問します。レベル順(level-order)は幅優先で、キューを使って木を行ごとに訪問します。4つとも時間はO(n)です。

巡回の順序が重要な理由

木には唯一の自然な順序というものがないので、アルゴリズムは目的に合う順序を選びます。二分探索木を中順で巡回すると、キーがソート順で得られます。前順は親を子より先に訪問するので、木のコピーや、あとで再構築できる形での書き出しに必要な順序です。後順は子を親より先に訪問するので、木の削除、フォルダサイズの計算、式の木の評価に必要です。レベル順は最も浅いノードを最初に見つけ、木を行ごとに出力できます。

1つの例で見る4つの順序

次の木を使います。根は1で、子は2と3。ノード2は子4と5を持ち、ノード3は右の子6を持ちます。

        1
       / \
      2   3
     / \   \
    4   5   6
順序ルール結果
前順ノード、左、右1 2 4 5 3 6
中順左、ノード、右4 2 5 1 3 6
後順左、右、ノード4 5 2 6 3 1
レベル順行ごとに、左から右へ1 2 3 4 5 6

最初の3つの覚え方は簡単です。名前が、ノードを訪問する位置を表しています。pre(前)は両方の部分木より前、in(中)は部分木の間、post(後)は両方の部分木のあとです。左部分木は常に右より先に訪問します。

再帰による実装

再帰版は、定義をそのままコードにしたものです。関数ごとに、ノードを訪問する行が異なります。

class Node:
    def __init__(self, val, left=None, right=None):
        self.val, self.left, self.right = val, left, right
def preorder(node, out):
    if node:
        out.append(node.val)
        preorder(node.left, out)
        preorder(node.right, out)
    return out
def inorder(node, out):
    if node:
        inorder(node.left, out)
        out.append(node.val)
        inorder(node.right, out)
    return out
def postorder(node, out):
    if node:
        postorder(node.left, out)
        postorder(node.right, out)
        out.append(node.val)
    return out
root = Node(1, Node(2, Node(4), Node(5)), Node(3, None, Node(6)))
print(preorder(root, []))   # [1, 2, 4, 5, 3, 6]
print(inorder(root, []))    # [4, 2, 5, 1, 3, 6]
print(postorder(root, []))  # [4, 5, 2, 6, 3, 1]

スタックを使った前順・中順の反復実装

再帰はコールスタックを使います。深い木での再帰の上限を避けるには、スタックを自分で管理します。前順では、ノードをpopして訪問し、左の子が先に処理されるように、右の子を左の子より先にpushします。

中順では、左へ行けるところまで進みながらノードをpushしていきます。それ以上左へ行けなくなったらノードをpopして訪問し、その右の子へ移ります。

def preorder_iter(root):
    out, stack = [], [root] if root else []
    while stack:
        node = stack.pop()
        out.append(node.val)
        if node.right:
            stack.append(node.right)
        if node.left:
            stack.append(node.left)
    return out
def inorder_iter(root):
    out, stack, node = [], [], root
    while stack or node:
        while node:              # go as far left as possible
            stack.append(node)
            node = node.left
        node = stack.pop()
        out.append(node.val)
        node = node.right
    return out
print(preorder_iter(root))  # [1, 2, 4, 5, 3, 6]
print(inorder_iter(root))   # [4, 2, 5, 1, 3, 6]

後順とレベル順の反復実装

反復による後順が最も厄介です。すっきりした方法の1つはフラグを使うことです。各ノードを2回pushし、2回目には「子のあとに訪問する準備ができた」という印を付けます。レベル順はスタックの代わりにキューを使います。キューを1レベルずつ処理すれば、各ノードの深さも分かります。

from collections import deque
def postorder_iter(root):
    out, stack = [], [(root, False)] if root else []
    while stack:
        node, children_done = stack.pop()
        if children_done:
            out.append(node.val)
        else:
            stack.append((node, True))
            if node.right:
                stack.append((node.right, False))
            if node.left:
                stack.append((node.left, False))
    return out
def level_order(root):
    levels, queue = [], deque([root] if root else [])
    while queue:
        level = []
        for _ in range(len(queue)):   # nodes of the current level
            node = queue.popleft()
            level.append(node.val)
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        levels.append(level)
    return levels
print(postorder_iter(root))  # [4, 5, 2, 6, 3, 1]
print(level_order(root))     # [[1], [2, 3], [4, 5, 6]]

後順のもう1つのよくある手法は、変形した前順(ノード、右、左)を実行して、結果を反転することです。

時間計算量と空間計算量

巡回時間追加の空間理由
前順 / 中順 / 後順(再帰)O(n)O(h)コールスタックにはレベルごとに1フレーム積まれる
前順 / 中順 / 後順(反復)O(n)O(h)明示的なスタックには、せいぜい1本の経路と兄弟ノードが入る
レベル順O(n)O(w)キューには、せいぜい1レベル分が入る
Morris中順巡回O(n)O(1)nullの右リンクを一時的にスレッド(糸)につなぎ替える

ここで、nはノード数、hは高さ、wは最大の幅です。平衡木ではhは約log2(n)、wは約n/2になりえます。鎖状の木ではhはn、wは1です。

よくある間違い

  • 反復の前順で、左の子を右の子より先にpushしてしまう。スタックはLIFOなので、先に処理したい子を最後にpushします。
  • 空の木を忘れる:すべての関数は、root is Noneのときに空の結果を返すべきです。
  • レベル順で、listとpop(0)をキューとして使う。popのたびにO(n)かかります。collections.dequeを使いましょう。
  • 非常に深い木で再帰に頼る。Pythonは再帰の上限(デフォルトで約1000)でRecursionErrorを出して止まります。
  • 1つの巡回だけで木が特定できると期待する。前順だけでは曖昧です。前順と中順(値が重複しない場合)があれば、木を再構築できます。

どの巡回をいつ使うか

目的巡回
BSTのキーをソート順に列挙する、BSTを検証する、k番目に小さい要素を求める中順
木のコピーやシリアライズ、ディレクトリのアウトラインの出力前順
木の削除、部分木のサイズや高さの計算、式の木の評価後順
最小の深さ、右側から見た図、レベルごとの出力、最も近いノードレベル順

前順・中順・後順はいずれも深さ優先探索の形で、レベル順は木に適用した幅優先探索です。同じ考え方は一般のグラフにも広げられますが、その場合は訪問済みのノードの集合も必要です。

よくある質問

前順・中順・後順の違いは何ですか?

ノードを訪問する位置だけです。前順は部分木より前、中順は左右の部分木の間、後順は両方の部分木のあとにノードを訪問します。

二分探索木でソートされた出力が得られるのは、どの巡回ですか?

中順の巡回です。BSTでは左のキーがすべてより小さく、右のキーがすべてより大きいので、左、ノード、右の順に訪問すると、キーが昇順で得られます。

レベル順の巡回は、BFSと同じですか?

はい。レベル順の巡回は、木に対する幅優先探索です。キューを使い、深さの順にノードを訪問します。

再帰ではなく反復で巡回するのはなぜですか?

再帰の深さは木の高さに等しく、Pythonは再帰の上限を超えるとRecursionErrorを出します。明示的なスタックにはそのような上限がなく、途中で一時停止したり早期に終了したりするのも簡単です。

追加の空間O(1)で木を巡回できますか?

はい。Morris巡回では、各ノードの中順での先行ノードから、そのノードへ一時的にリンクを張るので、スタックが不要です。終了前に木を元に戻します。時間はO(n)です。

コーディング講座の一覧