estoy-re-sebado's blog

By estoy-re-sebado, history, 11 months ago, In English

TLDR: just take this blog of mine and sprinkle some canonical skew-binary numbers and boom, you have a weird version of the Fenwick tree that is asymptotically faster that the Fenwick tree on an obscure operation.

It also turns out that we can perform point updates without commutativity or inverse operations, which the plain Fenwick tree can't do by itself.

The idea

I explained on my previous blog that we can think of the Fenwick tree as a set of intervals, and that we can use those intervals to answer any range query in $$$O(\log(N)^{2})$$$. But there is no real reason to use exactly the same intervals that the Fenwick tree uses. Instead we can try to use some other set of intervals to make things more efficient (at least in an asymptotic sense).

Instead, we use intervals based on the jump pointers described on that one blog from the catalog. That blog claims that we can jump any distance from any node in $$$O(\log(N))$$$ by following a greedy algorithm (the same greedy I used to perform range queries on a Fenwick tree). It follows that we can use the same kind of thing to perform range queries in $$$O(\log(N))$$$ time.

To be clear I put things in bullet points:

  • We want to compute range sum queries on some array A
  • The data structure stores tree[i] = sum of A(i-jmp[i], i] (yes, an open-closed interval)
  • The jumps are defined recursively: if jmp[i] == jmp[i-jmp[i]] then jmp[i+1] = 2*jmp[i]+1 and otherwise jmp[i+1] = 1
  • The greedy algorithm to perform queries goes as follows:
int A[MAXN+1], jmp[MAXN+1], tree[MAXN+1];

int range(int i, int j) { // inclusive, 1-indexed
  int ans = 0;
  while (j-i+1 > 0) {
    if (j-i+1 >= jmp[j]) {
      ans += tree[j];
      j -= jmp[j];
    } else {
      ans += A[j];
      j -= 1;
    }
  }
  return ans;
}

The above algorithm performs any query in $$$O(\log(N))$$$ time. This is due to some properties of skew-binary numbers (trust me bro...). Sadly, I haven't figured out how to find the jumps on-line but we can just precompute them:

void init() {
  jmp[1] = jmp[2] = 1;
  for (int i = 2; i <= MAXN; ++i) {
    if (jmp[i] == jmp[i-jmp[i]]) {
      jmp[i+1] = 2*jmp[i]+1;
    } else {
      jmp[i+1] = 1;
    }
  }
}

Some observations:

  • An interval is either a single element or the concatenation of two intervals plus one more element
  • The intervals are well-nested and they have power of two (minus one) length
  • It follows that each position is covered by up to $$$\log(N)+O(1)$$$ intervals

Taking advantage of these facts, we will do updates by recomputing all the intervals that cover the updated position. In particular, we will first recompute the interval that ends at the updated position, then the smallest interval that strictly covers it and so on.

The implementation is straightforward: just update the array then walk the sequence of increasingly larger intervals and recompute them.

void update(int i, int x) { // assign A[i] = x
  A[i] = x;
  if (jmp[i] == 1) tree[i] = x, i = cover(i);
  for (; i <= MAXN; i = cover(i)) {
    tree[i] = tree[i-1 - jmp[i-1]] + tree[i-1] + A[i];
  }
}

It is possible to find the next covering interval on-line (thanks bicsi!). Remember that we construct an interval of length $$$2k+1$$$ exactly when there are two adjacent intervals of length $$$k$$$.

This tells us that if the interval that starts at position i-jmp[i] has the same length as the one that starts at position i (namely jmp[i] == jmp[i-jmp[i]]), then these two will be covered by an interval of length 2*jmp[i]+1 that ends at position i+1.

In the opposite case we must have jmp[i] == jmp[i+jmp[i]] (imagine the jmp array is infinite) so it will be covered by an interval that ends at position i+jmp[i]+1.

int cover(int i) {
  return jmp[i] == jmp[i-jmp[i]] ? i + 1 : i + jmp[i] + 1;
}

Conclusion

We have invented a Fenwick tree-flavored data structure that does $$$O(\log(N))$$$ range queries and updates, and works with non-commutative operations that don't have an inverse. I had never heard of this particular data structure so I will claim this is a completely new, never seen before data structure.

But surprise: it's dog slow in practice. Don't use it to perform range queries. But maybe the idea could be useful in some ad-hoc problem or whatever.

UPD: After some changes I measured this to be about as fast as a recursive segment tree. (I will admit I was very sloppy, my measurement was just submitting a problem on CSES: https://cses.fi/problemset/task/1648/)

Thanks to ponysalvaje for explaining me how Fenwick tree works.

Thanks to bicsi for the idea of how to implement non-additive updates and finding the covering intervals on-line.

Full code
  • Vote: I like it
  • +55
  • Vote: I do not like it

»
10 months ago, # |
  Vote: I like it 0 Vote: I do not like it

Auto comment: topic has been updated by estoy-re-sebado (previous revision, new revision, compare).

»
10 months ago, # |
  Vote: I like it 0 Vote: I do not like it

Auto comment: topic has been updated by estoy-re-sebado (previous revision, new revision, compare).

»
10 months ago, # |
Rev. 3   Vote: I like it +5 Vote: I do not like it

Cool data structure! But sadly, an iterative segment tree (with exactly 2n memory — the same as what this data structure needs, due to range sums of two ranges stored for every index) is faster due to both caching and being able to compute covered segments on the fly. This blog reminds me of my more recent comment here — I wonder if this data structure can be naturally changed somehow to adapt to operations on trees/graphs.

  • »
    »
    10 months ago, # ^ |
      Vote: I like it 0 Vote: I do not like it

    Thank you! I had the idea for this blog for a few weeks but finally decided to sit down and finish writing it thanks to your comment!

»
10 months ago, # |
Rev. 2   Vote: I like it +5 Vote: I do not like it

"We can do this with less memory and time by storing the smallest interval that strictly covers each interval, and walking that as a linked list. It also looks trivial to construct given the way the jumps are constructed but this data structure is too useless for me to care." -- indeed, we should be able to do that. The idea for that should be quite simple:

I've linked the picture from the aforementioned blog here, for reference.

Say you are on a range (i - jmp[i], i]. There are exactly two consecutive such intervals. If you are in the left one wrt the picture above (i.e. jmp[i - jmp[i]] == jmp[i]), then the next range ends in i + 1. Otherwise, it ends in i + jmp[i] + 1.

Try to implement it like this and re-evaluate the benchmarks.

Note: This picture should give you a rough idea on how to handle non-additive updates, like set value on position.

  • »
    »
    10 months ago, # ^ |
      Vote: I like it 0 Vote: I do not like it

    Note: This picture should give you a rough idea on how to handle non-additive updates, like set value on position.

    Oooohhh I see. Each range is either a single element or two ranges plus an element. Very cool.

  • »
    »
    10 months ago, # ^ |
      Vote: I like it 0 Vote: I do not like it

    Do you mind if I add this information to the blog?

    • »
      »
      »
      10 months ago, # ^ |
        Vote: I like it +5 Vote: I do not like it

      Of course not, feel free to do that!

  • »
    »
    10 months ago, # ^ |
      Vote: I like it +6 Vote: I do not like it

    Cool idea! Sounds (and looks) quite similar to the linear binups

    • »
      »
      »
      10 months ago, # ^ |
      Rev. 2   Vote: I like it +1 Vote: I do not like it

      Awesome! I don't know it, is there a tutorial on cf?

      Edit: i found it online. That's what the catalog blog i referenced talks about