読者です 読者をやめる 読者になる 読者になる

hogecoder

つたじろう(Tsuta_J) 競技プログラミングの記録

Snackdown 2017 Pre-elimination A: Consecutive Snakes

これは良問だなあと思った。

問題概要

原文 → Contest Page | CodeChef

長さ  L (1 \leq L \leq 10^{9}) の蛇が数直線上に  N (1 \leq N \leq 10^{5}) 匹おり、 i 番目の蛇の左端の座標は  S_{i} (1 \leq S_{i} \leq 10^{9}) である。つまり、  i 番目の蛇は区間  \left[ S_{i}, S_{i} + L \right] にいる。

いま、この蛇たちを以下の条件が成り立つように移動させたい。

  • N 匹の蛇すべてについて、区間  \left[ A, B \right] (1 \leq A, B \leq 10^{9}) の中にいる
  • 左から  i 番目の蛇の右端と  i+1 番目の蛇の左端は繋がっている。つまり  1 番目の蛇が区間  \left[ X, X + L \right] にいるとき、 2 番目の蛇は  \left[ X + L, X + 2L \right] におり、 \dots N 番目の蛇は  \left[ X + (N-1)L, X + NL \right] にいる。

ある蛇が  \left[ X_{1}, X_{1} + L \right] から  \left[ X_{2}, X_{2} + L \right] に移動するとき、コストが  | X_{1} - X_{2} | かかる。全体コストはこのコストを  N 匹に対して計算した時の総和である。条件が成り立つように移動させた時の全体コストの最小値を出力せよ。

解説

まず、条件より以下のことがわかります。

  • 一番左にくる蛇の左端の座標を決定すると、全ての蛇の左端の座標が決まる
  • 移動前に左から  i 番目にいた蛇は、移動後も左から  i 番目にいたほうが良い

つまり、蛇を座標順にソートしておいて、一番左の蛇がどこにいるか決めてしまえば、コスト計算はできるということです。しかし、制約を見ると一番左の蛇の配置パターンは非常に多いことがわかるので、何か工夫が必要です。そこで、一旦定式化して考えましょう。

一番左の蛇の左端を  x とします。すると、 i 番目の蛇の左端は  x + i L と表せます (0-indexed)。移動前に左から  i 番目にいた蛇の左端を  S_{i} とすると、全体コストは以下のように書けます。

\begin{align} cost &=& \sum_{i=0}^{N-1} | S_{i} - (x + iL) | \\ &=& \sum_{i=0}^{N-1} | (S_{i} - iL) - x | \\ &=& \sum_{i=0}^{N-1} | T_{i} - x | \end{align}

 S_{i} - iL x の値の設定に関係なく決まるので、定数とみなして良いです。これを新たに  T_{i} とします。

 T_{i} をソートすると、 T_{0} \leq T_{1}\leq \dots \leq T_{N-1} のような形になります。さて、 T_{i} \leq x を満たす最大の  i j とすると、全体コストはこのように書けます。

 cost = \sum_{i=0}^{j} (x - T_{i}) + \sum_{i=j+1}^{N-1} (T_{i} - x)

コスト関数は絶対値付きの一次関数を足しあわせた形になっているので凸関数です。よって、 x について二分探索して解くことができます。

計算量はソート  O(N \log N)、二分探索パートでコスト計算時に upper_bound が必要なため  O(\log N \log C) です  (C = B - A)

ソースコード

累積和を 0-indexed にすると配列外参照が起こるので、そこだけ 1-indexed で書いています。

int T, N, L, A, B;
int s[100010];
int t[100010], acc[100010];

int get(int l, int r) {
    l++; r++;
    return acc[r] - acc[l-1];
}

int calc(int x) {
    int idx = upper_bound(t, t+N, x) - t;
    int vl = idx * x - get(0, idx-1);
    int vr = get(idx, N-1) - (N-idx) * x;
    return vl + vr;
}

signed main() {
    scanf("%lld", &T);
    rep(cases, 0, T) {
        scanf("%lld%lld%lld%lld", &N, &L, &A, &B);
        rep(i,0,N) scanf("%lld", &s[i]);
        sort(s, s+N);

        // 累積和は 1-indexed
        rep(i,0,N) t[i] = s[i] - i * L;
        sort(t, t+N);
        rep(i,0,N) acc[i+1] = acc[i] + t[i];

        // A <= x <= B を満たす x で最小を求めよう
        int lb = A - 1, ub = B - N * L;
        while(ub - lb > 1) {
            int mid = (ub + lb) / 2;
            if(calc(mid) < calc(mid+1)) ub = mid;
            else lb = mid;
        }
        cout << calc(ub) << endl;
    }
    return 0;
}

二分探索に落とし込めた時はちょっと感動した。自力で解けてよかった。

競プロをやりはじめてから一年が経ちました

競プロをやりはじめて、今日でちょうど一年です。半年記事 に引き続き、ポエム的なものを書いていきます。

続きを読む

AtCoder Regular Contest 038 B: マス目と駒

ちょっと迷ってしまったので。

問題概要

原文を参照してください → B: マス目と駒 - AtCoder Regular Contest 038 | AtCoder

解説

rec[i][j] := (i, j) で手番が回ってきた人は勝つか負けるか? となるような配列を作ります。

あるマス  (i, j) について、この値は以下のように決定できます。

  • 1 ターンで行ける範囲に「負け」を表すマスが少なくとも 1 つある場合、勝ち
  • 上記に当てはまらない場合 (移動先の候補がない時も含む)、負け

少しわかりにくいと思うので解説すると、 (i, j) から 1 ターン動いた先で手番が回ってくる人、つまり「相手」が負け確定になるようなマスがある場合、そこに移動することで自分が勝ち確定になります。反対に、そのようなマスが 1 つもない場合は自分が負け確定になります。あとはこれを右下からメモ化再帰で求めていけば良いです。

ソースコード

int h, w;
char board[110][110];

// rec[i][j] := (i, j) から始めたら勝つか負けるか?
int rec[110][110];

int dx[] = {0, 1, 1};
int dy[] = {1, 0, 1};

int solve(int x, int y) {
    int &r = rec[x][y];
    if(r != -1) return r;
    r = 1;
    rep(i,0,3) {
        int nx = x + dx[i], ny = y + dy[i];
        if(nx == h || ny == w || board[nx][ny] == '#') continue;
        if(solve(nx, ny) == 1) r = 0;
    }
    return r;
}

signed main() {
    cin >> h >> w;
    rep(i,0,h) rep(j,0,w) cin >> board[i][j];
    memset(rec, -1, sizeof(rec));
    cout << (solve(0, 0) ? "Second" : "First") << endl;
    return 0;
}

先攻か後攻かの情報も保持したほうがいいのかな、とか色々考えていたらすんなり書けませんでした。ゲームの問題はもう少し慣れが必要そうです。

Google Code Jam Round 1B 2017 C: Pony Express

想定解法と違う方法で解いたので一応メモ。

問題概要

街が  N  (2 \le N \le 100) つあり、街  i j を結ぶ道路の長さは  D_{ij} (km) (-1 \le D_{ij} \le 10^{9}, ただし  -1 の時は道路がないことを表す) である。

それぞれの街には馬がおり、街  i にいる馬は時速  s_{i} (km/h)  (1 \le s_{i} \le 10^{3}) で移動することができ、最大で  e_{i} (km)  (1 \le e_{i} \le 10^{9}) 移動できる。街と街の移動には馬を利用する (必要ならば途中の街で馬を乗り継ぐこともできる)。このとき、以下のクエリを  Q (1 \le Q \le 100) 処理せよ。

  •  U から  V に向けて、上記の制約を満たした乗り継ぎをしながら馬に乗って行くことを考えたとき、かかる時間の最小値を求めよ。

解説

拡張ダイクストラで求めることができます。

rec[i][j][k] := 街 i を出発時点で、今乗っている馬 j が距離 k を走った時にかかる累計時間の最小値 となるような配列を作ります。この配列を真面目に取ろうとしても  10^{2} \times 10^{2} \times 10^{9} のサイズの配列を取らなければならないので無理ですが、実際はそのほとんどが使われることが無いため、使うところだけ利用できるように map を利用して作ることにします。

 x から  y に行く際には、馬の走れる距離が十分にあると仮定すると以下の  2 つの遷移が考えられます。

  •  y に着いた後も、今乗っている馬に乗り続ける
  •  y に着いたら、街  y にいる馬に乗り換える

別の馬に乗り換えた場合、走行距離の情報がリセットされることに注意して遷移すれば良いです。制約を満たさない動き (たとえば、馬の走行距離の限界を超えるなど) をしないようにすることも重要です。

ソースコード

struct Elem {
    int city, horse, dist;
    double cost;
    Elem(int a, int b, int c, double d) : city(a), horse(b), dist(c), cost(d) {}
};

bool operator<(const Elem &a, const Elem &b) {
    return a.cost > b.cost;
}

int e[110], s[110];
int mat[110][110];
signed main() {
    int t; cin >> t;
    repq(cs,1,t) {
        int N, Q; cin >> N >> Q;
        rep(i,0,N) cin >> e[i] >> s[i];
        rep(i,0,N) rep(j,0,N) cin >> mat[i][j];
        printf("Case #%lld:", cs);
        rep(i,0,Q) {
            // city, horse, dist
            map<int, map<int, map<int, double> > > rec;
            int u, v; cin >> u >> v;
            double ans = DBL_MAX;
            u--; v--;
            priority_queue<Elem> q;
            q.push(Elem(u, u, 0, 0.0));
            while(!q.empty()) {
                Elem pick = q.top(); q.pop();
                int from = pick.city, d = pick.dist, usg = pick.horse;
                if(from == v) {
                    ans = min(ans, pick.cost);
                    break;
                }
                rep(to,0,N) {
                    if(mat[from][to] == -1) continue;
                    if(d + mat[from][to] > e[usg]) continue;
                    int nd = d + mat[from][to];
                    double c = (double)mat[from][to] / s[usg];
                    if(!rec[to][usg].count(nd) || rec[to][usg][nd] > rec[from][usg][d] + c) {
                        rec[to][usg][nd] = rec[from][usg][d] + c;
                        q.push(Elem(to, usg, nd, rec[to][usg][nd]));
                    }
                    if(!rec[to][to].count(0) || rec[to][to][0] > rec[from][usg][d] + c) {
                        rec[to][to][0] = rec[from][usg][d] + c;
                        q.push(Elem(to, to, 0.0, rec[to][to][0]));
                    }
                }
            }
            printf(" %.12lf", ans);
        }
        cout << endl;
    }
    return 0;
}

実はワーシャルフロイドを 2 回やる想定解法をまだ読んでいない・・・ (読もう)

TopCoder SRM 712 Div1 Easy: LR

オーバーフローで死ぬつらい問題。

問題概要

原文 → TopCoder Statistics - Problem Statement

環状になっているサイズ  n の数列  s, t がある (0-indexed)。 i 番目の要素に対して左隣は要素  i-1 であり、右隣は要素  i+1 である。 (ただし、 (n-1) 番目の要素の右隣は要素  0 である。)

数列に対して、以下の 2 種類の操作が可能である。

  • L: すべての要素に対して、その左隣の要素の値だけ加算する。
  • R: すべての要素に対して、その右隣の要素の値だけ加算する。

 s に対してこの 2 種類の操作を合計 100 回まで行って、数列  s を数列  t にすることができれば、その操作手順を出力せよ。できないならば、No solution を出力せよ。

解説

操作を見ればわかるように、各要素に対して加算の操作しかできないため s[i] > t[i] となるような要素が一つでもあればその時点で不可能です。また、最初から  s t が一致していれば操作する必要がありません。このチェックは最初にすべきでしょう。

今回の場合、総和を比較することによって操作回数を知ることができます。数列  s の総和を  S として、簡単に説明します。

どちらの操作においても、操作後には現時点での数列の総和ぶん増えます。これは各要素で隣にあるものを足しているので、各要素が 1 度ずつ加算されているということからわかりますね。ですので、操作をするごとに数列の総和は  S \rightarrow 2S \rightarrow 4S \rightarrow 8S \dots のように増えていくことになります。( t の総和  -  s の総和) に対してこの性質を利用して計算していけば、操作回数が特定できます。

また、L と R の並び順は関係ない こともわかります。これは L -> R と R -> L の結果が等しいことを利用すれば示せます。したがって、LL…L RR…R のような操作手順に帰着できるため、L と R の個数さえ分かれば良いことになります。

答えを導く方法の一つとして DP があります。 dp[i][j][k] := R を i 回、L を j 回行った時の k 番目の要素の値 となるような配列を作ります。この遷移は R を行うか L を行うかしかないので、その 2 つを書けば良いです。

ソースコード

ll dp[110][110][60];
class LR {
    public:
    string construct(vector<long long> s, vector<long long> t) {
        int n = (int)s.size();
        rep(i,0,n) if(s[i] > t[i]) return "No solution";
        if(s == t) return "";

        ll ssum = accumulate(s.begin(), s.end(), (ll)0);
        ll tsum = accumulate(t.begin(), t.end(), (ll)0);
        if(ssum == 0 || (tsum - ssum) % ssum) return "No solution";
        ll diff = (tsum - ssum) / ssum;
        ll turn = 1, d = 1;
        while(1) {
            if(diff - d < 0) return "No solution";
            if(diff - d == 0) break;
            diff -= d;
            d *= 2; turn++;
        }

        memset(dp, 0, sizeof(dp));
        rep(i,0,n) dp[0][0][i] = s[i];
        repq(x,1,turn) repq(i,0,x) {
            int j = x - i;
            bool ok = true;
            rep(k,0,n) {
                ll v = -1, w = -1;
                if(j != 0) v = dp[i][j-1][k] + dp[i][j-1][(k-1+n)%n];
                if(i != 0) w = dp[i-1][j][k] + dp[i-1][j][(k+1)  %n];
                dp[i][j][k] = max(v, w);
                if(dp[i][j][k] != t[k]) ok = false;
            }
            if(ok) return string(i, 'R') + string(j, 'L');
        }
        
        return "No solution";
    }
};

これ通せなかったのつらいなあ (本番は回数の決め打ちをしていなかったのでほげ)

遅延評価セグメント木をソラで書きたいあなたに

この記事は前記事: 「セグメント木をソラで書きたいあなたに」の続編です。セグ木をソラで実装するのはまだ厳しい・・・という方はまずはこちらの記事を読んでみてください。

tsutaj.hatenablog.com

この記事は「セグメント木はソラで書けるけど、遅延評価セグ木は書けない・・・」という人を対象にしています。

遅延評価って何?

まず遅延評価について軽く説明しておきます。

遅延評価というのは、値の伝播を遅らせるテクニックです。これは区間に対して更新を行うときに必要になる時が多いです。例えば、値の更新を普通のセグメント木でやろうとしたとき、範囲の長さを  L とするとこの範囲の長さだけクエリを呼ばなければならないため  O(L \log N) かかってしまいますが、遅延評価をすることでこれを  O(\log N) で実現できます。

どうしたらうまくいくのか、簡単に解説します。

配列 a = {1, 3, 2, 6, 5, 4, 7, 9} を普通のセグメント木(区間和) で表すと、以下のようになります。

f:id:tsutaj:20170330223205p:plain

区間和に対応したセグメント木なので、親は子の 2 値の合計を持っている、といった作りになりますね。

このセグメント木に対して、 \left[1, 5\right] 内の要素それぞれに 3 加算することを考えます。すると、通常ではこんなにも多くのノードを更新しなければなりません。

f:id:tsutaj:20170330223607p:plain

これでは効率が悪いですね。そこで、遅延評価用の配列を別に用意し、1 つのノードにつき 2 つの値を持つようにします。説明のため、普通のセグメント木にもあるような、値を覚える配列を「値配列」と、遅延評価用の配列を「遅延配列」と呼ぶことにします(造語)。遅延配列では何を持つのかというと、 自ノードの区間に一様に加えられた値 を持つことになります。

遅延配列をもつセグメント木は、このようなイメージになります。

f:id:tsutaj:20170330223212p:plain

例えば  \left[1, 5\right] に加算クエリが与えられた時、この区間 \left[1, 4\right]  \left[5, 5\right] にわけて考えます。この 2 つの区間に対応するノードについて、遅延配列を更新 します。(なぜなら、この 2 つの区間について一様に加算されているからです。)

f:id:tsutaj:20170330223214p:plain

遅延配列に値が入っているノードについては、いつか遅延配列の情報を値配列の方に伝播させなければいけません。これはどのタイミングで行えば良いかというと、「クエリでそのノードにアクセスしたとき」に更新すれば良いです。

f:id:tsutaj:20170330223217p:plain

遅延配列に値が入っているとき、値配列に入っている値は本来の値ではありません。遅延配列の値を加味してあげることで、初めて本来の値になります。ですので、そこにアクセスした時はまず「遅延配列の中身を反映させてから」処理を行う必要があるのです。

まとめると、

  • 遅延配列は、区間に一様に与えられた値を記憶している
  • 遅延配列に中身が入っているノードにアクセスした時、自分と自分の子に対して値の伝播が起こる
  • 親ノードは、値配列が確定した状態の子ノードを参照することで更新される

このような実装をすることで、必要なときだけ値の伝播が起こるため計算量がぐっと減ります。これで、遅延評価付きのセグメント木も書けるようになります!

実際に書いてみよう

それでは「区間加算・区間和クエリ」を扱えるような遅延評価付きのセグメント木を書いてみましょう。

初期化

初期化については、普通のセグメント木とほぼ同じです

というのも、最初に用意するときは、遅延配列に何も入っているはずがないですよね。実装は普通のセグメント木に遅延配列の宣言が増えただけです。

実際に書くとこのようになります。

struct LazySegmentTree {
private:
    int n;
    vector<ll> node, lazy;

public:
    LazySegmentTree(vector<ll> v) {
        int sz = (int)v.size();
        n = 1; while(n < sz) n *= 2;
        node.resize(2*n-1);
        lazy.resize(2*n-1, 0);

        for(int i=0; i<sz; i++) node[i+n-1] = v[i];
        for(int i=n-2; i>=0; i--) node[i] = node[i*2+1] + node[i*2+2];
    }
};

遅延評価

ノードに対して評価を行う関数を作りましょう。そのためには

  • 自分のノードは何番目なのか?
  • 自分のノードが指す範囲はどうなっているのか?

を知る必要があるため、 3 つの引数を取ります。

遅延配列に中身が入っている場合、評価を行う必要があります。この関数で何をすべきなのかというと、

  • 自ノードの値配列に値を伝播させる
  • 子ノードの遅延配列に値を伝播させる
  • 自分のノードの遅延配列を空にする

これができればよいです。最下段のノードの処理に気をつけながら書くと、次のようになります。

// k 番目のノードについて遅延評価を行う
void eval(int k, int l, int r) {

    // 遅延配列が空でない場合、自ノード及び子ノードへの
    // 値の伝播が起こる
    if(lazy[k] != 0) {
        node[k] += lazy[k];

        // 最下段かどうかのチェックをしよう
        // 子ノードは親ノードの 1/2 の範囲であるため、
        // 伝播させるときは半分にする
        if(r - l > 1) {
            lazy[2*k+1] += lazy[k] / 2;
            lazy[2*k+2] += lazy[k] / 2;
        }

        // 伝播が終わったので、自ノードの遅延配列を空にする
        lazy[k] = 0;
    }
}

区間加算

区間に対しての更新なので、前記事でいうところの 最上段から下がっていく イメージで見ていきます。

基本的には前回の値の取得クエリとやることは同じです。しかし、遅延評価の関数を適切に呼び出す必要があります。

要求区間が対象区間を完全に被覆している場合、区間に対して一様に値が与えられていると言えます。ですので遅延配列に値を入れた後、評価関数を呼び出します。

そうでない場合、子ノードの値配列をしっかり更新してから、子ノードの値をもらって更新することをします。これは再帰関数にすることで簡潔に書くことができます。

実際に書くとこのようになります。

void add(int a, int b, ll x, int k=0, int l=0, int r=-1) {
    if(r < 0) r = n;

    // k 番目のノードに対して遅延評価を行う
    eval(k, l, r);

    // 範囲外なら何もしない
    if(b <= l || r <= a) return;
    
    // 完全に被覆しているならば、遅延配列に値を入れた後に評価
    if(a <= l && r <= b) {
        lazy[k] += (r - l) * x;
        eval(k, l, r);
    }

    // そうでないならば、子ノードの値を再帰的に計算して、
    // 計算済みの値をもらってくる
    else {
        add(a, b, x, 2*k+1, l, (l+r)/2);
        add(a, b, x, 2*k+2, (l+r)/2, r);
        node[k] = node[2*k+1] + node[2*k+2];
    }
}

区間和の取得

これも前回の区間和クエリと書き方はほぼ同じです。違いはやはり遅延評価を呼ぶかどうかで、今回の場合は関数の最初に遅延評価を入れるだけでよいです。

実際に書くとこのような感じになります。

ll getsum(int a, int b, int k=0, int l=0, int r=-1) {
    if(r < 0) r = n;

    // 関数が呼び出されたらまず評価!
    eval(k, l, r);

    if(b <= l || r <= a) return 0;
    if(a <= l && r <= b) return node[k];
    ll vl = getsum(a, b, 2*k+1, l, (l+r)/2);
    ll vr = getsum(a, b, 2*k+2, (l+r)/2, r);
    return vl + vr;
}

コード例

遅延評価セグメント木を習得することで様々なクエリを処理できるようになります。今回は AOJ の DSL で登場するクエリ処理のコード例を載せておきます。

※「区間更新」を扱うときは、遅延配列のフラグが立っているかどうかの bool 配列を別に用意しなければならないため、注意が必要です。

区間最大クエリにしたりなどは比較的簡単にできます。また、工夫すれば区間最小値の個数を答えるクエリなども作れますので、ぜひやってみてください。

楽しいセグ木ライフをお送りください。それではー。

セグメント木をソラで書きたいあなたに

セグ木にいろいろ生やす問題がてんで解けない私なので、セグ木に慣れようと思い立ちました。そのためにはまずセグ木をもっと知らねばならないと思ったので、ソラで書けないか色々やっていました。

やってくうちにコツを掴んでソラで書けるようになってきたので、忘れないためにも記事を書くことにしました。ということで、何も見ずにセグ木を書くために押さえておきたいポイントを紹介していきます。

注意

この記事は、「セグ木がどんな形をしているかはわかるけど、実装はできない・・・」という方向けで、遅延評価が不要な、区間最小の基本的なセグメント木のみ扱っています。

そもそもセグ木って何という人は他記事を参考にしてください。
おすすめは iwiwi さんのスライド → プログラミングコンテストのためのデータ構造

また、すでにセグ木が書けるプロの方が見ても面白くないかもしれません。

記事では区間最小についてのみ扱っていますが、区間和もほぼ同じコードで実現できます (コード例は後述)。

まずは形を見よう

ノードの数

セグメント木は皆さんご存知のとおり、図で書くとこんな形をしています。

f:id:tsutaj:20170329204251p:plain

元のデータ数以上になる最小の 2 冪の数を  N とします。すると、ノードは全部で  2N - 1あります。なぜこうなるかは、各段にあるノードの数を 2 進で書いて足していくと簡単にわかります。

すなわち、

  • 最下段にはノードが  N 個ある
  • それより上の段のノードは全部で  N-1 個ある

ということになります。この性質は後に大事になるので、しっかり把握しておきましょう。

ノードの関係

セグメント木上で操作をするには、ノードの関係を明らかにする必要があります。ここでは木と同様に、自分のノードの直下にあるノードを「子」、自分のノードの真上にあるノードを「親」と呼ぶことにします。

木に例えるとこれは完全二分木になります。つまり、葉でない限り自分の子は必ず  2 個あるということです。

f:id:tsutaj:20170329204255p:plain

自分のノードの番号を  k をおくと、親や子にアクセスするには以下のようにします。

  • 親にアクセスするには、  \lfloor \frac{k-1}{2} \rfloor 番目にアクセスする
  • 子にアクセスするには、  2k+1 番目・  2k+2 番目にアクセスする

これを各所で使うことになります。

更新・取得クエリ

一般的なセグメント木では、値を更新するクエリと、値を取得するクエリの 2 種類を行うことになります。かなりざっくりとしていますが、各クエリに関してはこのようなイメージを持つと良いです。

  • 値の更新クエリは、最下段から上がっていくようにする
  • 値の取得クエリは、最上段から下がっていくようにする

雑ですが、このようなイメージを持って実装を重ねることで詰まることなく書けるようになると思います。

実際に書いてみよう

初期化

元となる配列があって、それをセグメント木で表してみることをやってみましょう。

まずは、セグメント木のサイズ (ノード数)を決定します。そのためには最下段のサイズを求める必要がありますが、これは先述のとおり、元のサイズ以上になる最小の 2 冪 です。この値を  N としたとき、セグメント木のサイズは  2N-1 です。

次に実際に値を入れていくのですが、最下段から値を入れていき、それ以降は下の段から順番に自分の子を参照することで値を入れていきます。上のノードの値を決めるには下のノードを参照しなければならないことを考えると、最下段から入れるのは自然な流れですね。

最下段のノードのインデックスはどうなるのか?と思うかもしれませんが、これには最下段より上の段のノードは全部で  N-1 個ある ことを利用します。前に  N-1 個の要素があるので、インデックスは元のインデックスに  N-1 を足せばよいですね。

これらをまとめると、次のような実装になります。

struct SegmentTree {
private:
    int n;
    vector<int> node;

public:
    // 元配列 v をセグメント木で表現する
    SegmentTree(vector<int> v) {
        // 最下段のノード数は元配列のサイズ以上になる最小の 2 冪 -> これを n とおく
        // セグメント木全体で必要なノード数は 2n-1 個である
        int sz = v.size();
        n = 1; while(n < sz) n *= 2;
        node.resize(2*n-1, INF);

        // 最下段に値を入れたあとに、下の段から順番に値を入れる
        // 値を入れるには、自分の子の 2 値を参照すれば良い
        for(int i=0; i<sz; i++) node[i+n-1] = v[i];
        for(int i=n-2; i>=0; i--) node[i] = min(node[2*i+1], node[2*i+2]);
    }
};

値の更新

 x 番目の要素を  val に更新する」ことを考えます。

これを行うには、 x 番目の要素が含まれる区間全てを更新する必要があります。上のノードを更新するには下のノードを見なければならないため、下のノードから更新していかなければいけないことがわかります。

最下段から上がっていく イメージで書いていきます。つまり、まずは最下段を更新し、あとはその親を更新することを繰り返せばよいです。

実際に書くとこんな感じになります。

void update(int x, int val) {
    // 最下段のノードにアクセスする
    x += (n - 1);

    // 最下段のノードを更新したら、あとは親に上って更新していく
    node[x] = val;
    while(x > 0) {
        x = (x - 1) / 2;
        node[x] = min(node[2*x+1], node[2*x+2]);
    }
}

値の取得

区間  [a, b) にある要素の最小値を答える」ことを考えます。

説明のため、クエリで与えられた区間 (実際に計算したい区間) を「要求区間」、各ノードがカバーしている区間を「対象区間」と定義します (造語)。

これを扱うには、以下の 3 つの情報が必要になります。

  • 要求区間はどのような区間か?
  • いま自分がいるノードは何番目か?
  • 自分がいるノードはどのような区間か? (対象区間はどのようになっているか?)

要求区間と対象区間の関係によって場合分けをします。

要求区間と対象区間が交わらない場合

この場合は、これ以上操作をすすめても意味がありませんので、答えに影響しない値を適当に返しておきます。

要求区間が対象区間を完全に被覆している場合

この場合、対象区間は要求区間の計算に必要です。そのため、現状の答えと比較して、更新が必要ならば更新をしていきます。

要求区間が対象区間の一部を被覆している場合

この場合、対象区間の子にあたる区間に移動して、完全に被覆するまで操作を行わなければなりません。

子に移動するので、「現在見ているノード」と「対象区間の情報」が変わります。子のノードのインデックスの取得は先述のとおりで、対象区間に関しては、子のノードが現在見ているノードを半分に分割したものであることを利用すると得られます。

最上段から下がっていくイメージでこれをまとめると、このようなコードになります。(ここが一番難しいと思いますので、わからなければ Twitter などで私に質問してください)

// 要求区間 [a, b) 中の要素の最小値を答える
// k := 自分がいるノードのインデックス
// 対象区間は [l, r) にあたる

int getmin(int a, int b, int k=0, int l=0, int r=-1) {
    // 最初に呼び出されたときの対象区間は [0, n)
    if(r < 0) r = n;

    // 要求区間と対象区間が交わらない -> 適当に返す
    if(r <= a || b <= l) return INF;

    // 要求区間が対象区間を完全に被覆 -> 対象区間を答えの計算に使う
    if(a <= l && r <= b) return node[k];

    // 要求区間が対象区間の一部を被覆 -> 子について探索を行う
    // 左側の子を vl ・ 右側の子を vr としている
    // 新しい対象区間は、現在の対象区間を半分に割ったもの
    int vl = getmin(a, b, 2*k+1, l, (l+r)/2);
    int vr = getmin(a, b, 2*k+2, (l+r)/2, r);
    return min(vl, vr);
}

コード例

AOJ にコードを上げていますので、参考までに。

以上です。遅延評価付きのセグ木についても後日記事でまとめます (たぶん)。