Help us understand the problem. What is going on with this article?

非再帰型Segment TreeのPythonによる実装

はじめに

タイトルのとおり、この記事ではPythonによる非再帰型Segment Treeの実装を紹介したいと思います。
前提知識を「ほぼ」1 必要としないようにSegment Treeの説明から入るので、もう知ってるという方は読み飛ばしてください。→非再帰型Segment Treeの実装

Segment Treeとは

通称セグ木セグメント木などと呼ばれているデータ構造で、長さ $N$ の配列{$a_i$}$_{i=0}^{N-1}$に対して、モノイド $•$ に関する次の2つの操作がどちらも時間計算量が $O(logN)$ で行えます。

  • $update(i, x)$:  $a_i$ を $x$ に変更する
  • $query(l, r)$:  半開区間 $[l,r)$ に対して $a_l•a_{l+1}•$…$•a_{r-1}$ を返す

モノイドとして、一般的な足し算を考えると、$query(l, r)$ は $a_l$ から $a_{r-1}$ までの区間和、つまり $\Sigma_{i=l}^{r-1}a_i$ の結果に相当します。


なおモノイドとは次の条件を満たす演算子 $•$ のことです。

  • 任意の $a, b, c$ に対して、$(a•b)•c=a•(b•c)$ (結合律)
  • ある $e$ が存在して、任意の $a$ に対して、$e•a=a•e=a$ (単位元の存在)

モノイドの例としては、足し算や掛け算、$min$や$max$などが上げられます。

以下ではモノイドとして足し算 $+$ を例に挙げ、Segment Treeの説明を行います。


まず、愚直な実装を考えると次のようになります。

def update(i, x):
    a[i] = x

def query(l, r):
    res = 0
    for i in range(l, r):
        res += a[i]
    return res

これらの時間計算量は $update(i, x)$ が $O(1)$ と高速ですが、$query(l, r)$ が $O(N)$となっているため遅いです。


ここで次の図のようなデータ構造を考えます。( 結論から言うとこれが他ならぬSegment Treeです。)
segtree_1.jpg
※ []内の数字はSegment Treeにおけるインデックス、太字は入っているデータの値を示しています。


このSegment Treeを用いることで、 $update(i, x)$ の計算量は $O(logN)$ と増えますが、$query(l, r)$ の計算量も $O(logN)$ まで落とすことができます。

例を挙げると、例えば $query(1, 7)$ については下図の灰色の部分の和を求めればよく、愚直にやると5回必要な足し算が、たったの3回で済みます。
segtree_2.jpg

実装についてメインに説明したいので詳しい証明は行いませんが、あらゆる $l,r(l < r)$ の組み合わせに対して、$query(l, r)$ は $O(logN)$ で動作します。


では実装(再帰型)について説明します。なお説明を簡単にするため$N=8$と固定しますが、$8$ 以外の場合も同様です。

update(i, x)の実装

$update(i, x)$ について更新すべき値は $a_i$(上図の1番下の層), $a_i+a_j$(下から2番目の層), $a_i+a_j+a_k+a_l$(下から3番目の層), $a_i+a_j+a_k+a_l+$…(下から4番目の層)
の計4つです。この数はSegment Treeを完全二分木とみなしたときの木の高さと同じなので$O(logN)$です。

またインデックスを上図のようにしておくと、更新すべき値を持つインデックスは順番に
$i+N-1$, $((i+N-1)-1)/2$, $(((i+N-1)-1)/2-1)/2$, $((((i+N-1)-1)/2-1)/2-1)/2$
となっています。($/$は切り捨て除算とする。)
こちらも詳しい説明は省きますが、Segment Treeが完全二分木であることから分かります。

以上のことから、$update(i, x)$ の実装は次のとおりです。

def update(i, x):
    i += N-1 # 1番下の層におけるインデックス
    dat[i] = x

    # 層をのぼりながら値を更新
    while i > 0:
        i = (i-1) >> 1 # 1つ上の層のインデックス(完全二分木における親)

        # 下の層2つの和を代入(完全二分木における子同士の和)
        dat[i] = dat[i*2+1] + dat[i*2+2]

query(l, r)の実装

$query(l, r)$は「あるデータに代入されている区間の値」が $[l, r)$に完全に収まるときにその値を加算すればよいです。よって再帰を用いて以下のように書けます。

# 再帰関数query(l, r, k, L, R)
# L : 区間[L,R)の左端(初期値0), R : 区間[L,R)の右端(初期値N)
# k : 区間[L,R)に対する演算結果を保持しているインデックス(初期値0)

def query(l, r, k=0, L=0, R=None):
    # Rの初期化
    if R is None:
        R = N

    # もし区間[l,r)と区間[L,R)が交差しないならば0を返す
    if R <= l or r <= L:
        return 0

    # 区間[L,R)が区間[l,r)に完全に収まっていればその値を返す
    if l <= L and R <= r:
        return dat[k]

    # それ以外のとき、木を潜って2つの子を見る
    else:
        lres = query(l, r, k*2+1, L, (L+R) >> 1)
        rres = query(l, r, k*2+2, (L+R) >> 1, R)
        return lres + rres

Segment Tree(再帰型)の実装のまとめ

以上を踏まえてSegment Treeを実装すると次のとおりとなります。なおこれは一般のモノイドに対する実装です。

class SegmentTree:
    # 初期化処理
    # f : SegmentTreeにのせるモノイド
    # default : fに対する単位元
    def __init__(self, size, f=lambda x,y : x+y, default=0):
        self.size = 2**(size-1).bit_length() # 簡単のため要素数Nを2冪にする
        self.default = default
        self.dat = [default]*(self.size*2-1) # 要素を単位元で初期化
        self.f = f

    def update(self, i, x):
        i += self.size-1
        self.dat[i] = x
        while i > 0:
            i = (i-1) >> 1
            self.dat[i] = self.f(self.dat[i*2+1], self.dat[i*2+2])

    def query(self, l, r, k=0, L=0, R=None):
        if R is None:
            R = self.size
        if R <= l or r <= L:
            return self.default
        if l <= L and R <= r:
            return self.dat[k]
        else:
            lres = self.query(l, r, k*2+1, L, (L+R) >> 1)
            rres = self.query(l, r, k*2+2, (L+R) >> 1, R)
            return self.f(lres, rres)


非再帰型Segment Treeの実装

さて、ここまではやや長い前置きで、ここからが本題です。上記のSegmentTreeでは再帰関数を用いているため、やや遅いです。そこで非再帰関数による実装が有用となってきます。

再帰のときと同じ構造でも実装できますが、インデックスを1ずらして1-indexedにすることで実装が非常に楽になります。1-indexedにした場合、下図のような添え字となります。
segtree_3.jpg

1-indexedにすることで、あるノード $i$ について次の表のような関係が得られます。この関係が実装の上で便利です。

親のインデックス $i/2$
左側の子のインデックス $2i$
右側の子のインデックス $2i+1$
$a_i$の値が代入されているインデックス $i+N$


update(i, x)の実装

$update(i, x)$ の実装は、再帰型のときとほぼ同じで、上記の関係に気を付けるだけです。実装例は次のとおりです。

def update(i, x):
    i += N # 1番下の層におけるインデックス
    dat[i] = x

    # 層をのぼりながら値を更新
    while i > 0:
        i >>= 1 # 1つ上の層のインデックス(完全二分木における親)
        # 下の層2つの和を代入(完全二分木における子同士の和)
        dat[i] = dat[i*2] + dat[i*2+1]


query(l, r)の実装

$query(l, r)$の実装ですが、ここで、Segment Treeのあるデータを加算する必要十分条件について考えると、次のとおりであることが分かります。証明は省きます。
そのノードが表す区間が$[l,r)$に完全に収まっており、その親ノードが表す区間が$[l,r)$に完全に収まっていないとき

あるノードについて、表す区間が$[l,r)$に収まっており、親ノードの表す区間が方向にはみ出すとき、そのノードは側の子であるため、奇数のインデックスを持ちます。
反対に方向にはみ出す時、そのノードは側の子であるため、偶数のインデックスを持ちます。

よって、左側については木を下からのぼっていき、親の区間がはみ出すならば(現在見ているノードのインデックスが奇数ならば)、そのノードの値を加算し、インデックスを1つ右にずらすということを繰り返せばよいです。
右側についても同様で、親の区間がはみ出すときに加算してインデックスを1つ左にずらせばよいです。ただし、半開区間で見ているので、はみ出すかどうかの判定は現在見ているノードのインデックスが奇数であるかどうかで行います。また加算するのは1つ左のノードであることに注意が必要です。

実装例は次のとおりです。

def query(l, r):
    # 1番下の層におけるインデックスに初期化
    l += N
    r += N

    # 左側の答えと右側の答えを0で初期化
    lres, rres = 0, 0

    # lとrが重なるまで上記の判定を用いて加算を実行
    while l < r:
        # lが奇数ならば、dat[l]を加算
        if l & 1:
            lres += dat[l]
            l += 1

        # rが奇数ならば、dat[r-1]を加算
        if r & 1:
            r -= 1
            rres += dat[r]

        # 木をのぼる
        l >>= 1
        r >>= 1

    res = lres + rres
    return res


非再帰型Segment Treeの実装のまとめ

以上のことを踏まえて、非再帰型Segment Treeを実装すると次のとおりとなります。なおこれは一般のモノイドに対する実装です。

class SegmentTree:
    # 初期化処理
    # f : SegmentTreeにのせるモノイド
    # default : fに対する単位元
    def __init__(self, size, f=lambda x,y : x+y, default=0):
        self.size = 2**(size-1).bit_length() # 簡単のため要素数Nを2冪にする
        self.default = default
        self.dat = [default]*(self.size*2) # 要素を単位元で初期化
        self.f = f

    def update(self, i, x):
        i += self.size
        self.dat[i] = x
        while i > 0:
            i >>= 1
            self.dat[i] = self.f(self.dat[i*2], self.dat[i*2+1])

    def query(self, l, r):
        l += self.size
        r += self.size
        lres, rres = self.default, self.default
        while l < r:
            if l & 1:
                lres = self.f(lres, self.dat[l])
                l += 1

            if r & 1:
                r -= 1
                rres = self.f(self.dat[r], rres) # モノイドでは可換律は保証されていないので演算の方向に注意
            l >>= 1
            r >>= 1
        res = self.f(lres, rres)
        return res

比較

Segment Treeを使って解くことができる問題を使って実行時間の比較を行いました。結果は次の表のとおりで、非再帰のほうが短い時間で実行できることが確認できました。

問題名 再帰 非再帰
AOJ DSL_2_A AC(3:08s) AC(1.73s)
AOJ DSL_2_B AC(3:73s) AC(1.78s)
AtCoder ABC_125_C TLE(2111ms) AC(968ms)

問題URL
AOJ DSL_2_A http://judge.u-aizu.ac.jp/onlinejudge/description.jsp?id=DSL_2_A

AOJ DSL_2_B http://judge.u-aizu.ac.jp/onlinejudge/description.jsp?id=DSL_2_B

AtCoder ABC_125_C https://atcoder.jp/contests/abc125/tasks/abc125_c

提出URL
AOJ DSL_2_A(再帰) http://judge.u-aizu.ac.jp/onlinejudge/review.jsp?rid=4326213#1

AOJ DSL_2_A(非再帰) http://judge.u-aizu.ac.jp/onlinejudge/review.jsp?rid=4326214#1

AOJ DSL_2_B(再帰) http://judge.u-aizu.ac.jp/onlinejudge/review.jsp?rid=4326207#1

AOJ DSL_2_B(非再帰) http://judge.u-aizu.ac.jp/onlinejudge/review.jsp?rid=4326206#1

AtCoder ABC_125_C(再帰) https://atcoder.jp/contests/abc125/submissions/11596118

AtCoder ABC_125_C(非再帰) https://atcoder.jp/contests/abc125/submissions/11596126

最後に

Qiitaどころか、生まれて初めて記事を書いたので至らぬことだらけだと思います。「ここがおかしい」「変なことが書いてある」等ございましたら、ご報告お願いします。
ありがとうございました。


  1. 計算量の概念や、Pythonの記法などについては理解しておく必要があります。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした