1
\$\begingroup\$

(This post is the continuation of Bit vector in Java supporting O(1) rank() and O(log n) select(). It resides here (version 1.0.1).)

Basically, I have implemented everything harold suggested, except faster bit-shuffling in select(i). This time, rankThird is guaranteed to run in \$\mathcal{O}(1)\$ time, and selectThird in \$\mathcal{O}(\log n)\$ time.

Code

com.github.coderodde.util.RankSelectBitVector.java:

package com.github.coderodde.util;

/**
 * This class defines a packed bit vector that supports {@code rank()} operation
 * in {@code O(1)} time, and {@code select()} in {@code O(log n)} time.
 * 
 * @version 1.0.1
 * @since 1.0.0
 */
public final class RankSelectBitVector {
    
    /**
     * Indicates whether some bits were changed since the previous building of
     * the index data structures.
     */
    private boolean hasDirtyState = true;
    
    /**
     * The actual bit storage array.
     */
    private final long[] wordData;
    
    /**
     * The actual requested number of bits in this bit vector. Will be smaller 
     * than the total capacity.
     */
    private final int numberOfRequestedBits;
    
    /**
     * Denotes index of the most rightmost meaningful bit. Will be set to 
     * {@code numberOfRequestedBits - 1}.
     */
    private final int maximumBitIndex;
    
    /**
     * Caches the number of bits set to one (1).
     */
    private int numberOfSetBits;
    
    /**
     * The block size in the {@code first} table.
     */
    private int ell;
    
    /**
     * The block size in the {@code second} table.
     */
    private int k;
    
    // The following three tables hold the index necessary for efficient rank 
    // operation. According to internet, 'third' has space 
    // O(sgrt(n) * log log n * log n, 'second' has space O(n / log(n)), and
    // 'first' has space O(n / log^2(n)).
    private int[] first;
    private int[] second;
    private int[][] third;
    
    /**
     * Constructs a new bit vector.
     * 
     * @param numberOfRequestedBits the actual number of bits to support.
     */
    public RankSelectBitVector(int numberOfRequestedBits) {
        checkNumberOfRequestedBits(numberOfRequestedBits);
        
        this.numberOfRequestedBits = numberOfRequestedBits;
        
        // Calculate the actual number of storage bytes:
        int numberOfLongs = numberOfRequestedBits / Long.SIZE + 
                           (numberOfRequestedBits % Long.SIZE != 0 ? 1 : 0);
        
        numberOfLongs++; // Padding tail long in order to simplify the last 
                         // rank/select.
        
        wordData = new long[numberOfLongs];
        
        // Set the rightmost, valid index:
        this.maximumBitIndex = this.wordData.length * Long.SIZE - 1;
    }
    
    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder().append("[Bit vector, size = ");
        
        sb.append(getNumberOfSupportedBits())
          .append(" bits, data = ");
        
        int bitNumber = 0;
        
        for (int i = 0; i < getNumberOfSupportedBits(); i++) {
            sb.append(readBitImpl(i) ? "1" : "0");
            
            bitNumber++;
            
            if (bitNumber % 8 == 0) {
                sb.append(" ");
            }
        }
        
        return sb.append("]").toString();
    }
    
    /**
     * Preprocesses the internal data structures in {@code O(n)}.
     */
    public void buildIndices() {
        if (hasDirtyState == false) {
            // Nothing to do.
            return;
        }
        
        //// Deal with the 'first'.
        // n - total number of bit slots:
        int n = wordData.length * Long.SIZE;
        
        // elll - the l value:
        this.ell = (int) Math.pow(Math.ceil(log2(n) / 2.0), 2.0);
        this.first = new int[n / ell + 1];
        
        for (int i = ell; i < n; i++) {
            if (i % ell == 0) {
                int firstArraySlotIndex = i / ell;
                int startIndex = i - ell;
                int endIndex   = i - 1;
                
                first[firstArraySlotIndex]     =
                first[firstArraySlotIndex - 1] + 
                bruteForceRank(startIndex,
                               endIndex);
            }
        }
        
        //// Deal with the 'second'.
        this.k = (int) Math.ceil(log2(n) / 2.0);
        this.second = new int[n / k + 1];
        
        for (int i = k; i < n; i++) {
            if (i % k == 0) {
                second[i/k] = bruteForceRank(ell * (i / ell), i - 1);
            }
        }
        
        //// Deal with the 'third': four Russians' technique:
        this.third = new int[(int) Math.pow(2.0, k - 1)][];
        
        for (int selectorIndex = 0;
                 selectorIndex < third.length;
                 selectorIndex++) {
            
            third[selectorIndex] = new int[k - 1];
            third[selectorIndex][0] = (bitIsSet(selectorIndex, k - 2) ? 1 : 0);
            
            for (int j = 1; j < k - 1; j++) {
                third[selectorIndex][j] = 
                third[selectorIndex][j - 1] + 
                        (bitIsSet(selectorIndex, k - j - 2) ? 1 : 0);
            }
        }
        
        hasDirtyState = false;
    }
    
    /**
     * Returns the number of bits that are set (have value of one (1)).
     * 
     * @return the number of set bits.
     */
    public int getNumberOfSetBits() {
        return numberOfSetBits;
    }
    
    /**
     * Returns the number of bits this bit vector supports.
     * 
     * @return the number of bits supported.
     */
    public int getNumberOfSupportedBits() {
        return numberOfRequestedBits;
    }
    
    /**
     * Sets the {@code index}th bit to one (1).
     * 
     * @param index the index of the target bit.
     */
    public void writeBitOn(int index) {
        writeBit(index, true);
    }
    
    /**
     * Sets the {@code index}th bit to zero (0).
     * 
     * @param index the index of the target bit.
     */
    public void writeBitOff(int index) {
        writeBit(index, false);
    }
    
    /**
     * Writes the {@code index}th bit to {@code on}.
     * 
     * @param index the index of the target bit.
     * @param on    the selector of the bit: if {@code true}, the bit will be 
     *              set to one, otherwise set zero.
     */
    public void writeBit(int index, boolean on) {
        checkBitAccessIndex(index);
        writeBitImpl(index, on);
    }
    
    /**
     * Reads the {@code index}th bit where indexation starts from zero (0).
     * 
     * @param index the bit index.
     * @return {@code true} if and only if the {@code index}th bit is set.
     */
    public boolean readBit(int index) {
        checkBitAccessIndex(index);
        return readBitImpl(index);
    }
    
    /**
     * Returns the rank of {@code index}, i.e., the number of set bits in the 
     * subvector {@code vector[1..index]}. Runs in {@code O((log n)^2)} time.
     * 
     * @param index the target index.
     * @return the rank for the input target.
     */
    public int rankFirst(int index) {
        checkBitIndexForRank(index);
        makeSureStateIsCompiled();
        
        int startIndex = ell * (index / ell);
        int endIndex = index - 1;
        
        return first[index / ell] + bruteForceRank(startIndex, endIndex);
    }
    
    /**
     * Returns the {@code index}th rank. Runs in {@code O(log n)} time.
     * 
     * @param index the target index.
     * @return the rank of the input index.
     */
    public int rankSecond(int index) {
        checkBitIndexForRank(index);
        makeSureStateIsCompiled();
        
        int startIndex = k * (index / k);
        int endIndex = index - 1;
        
        return first[index / ell] +
               second[index / k] + 
               bruteForceRank(startIndex, 
                              endIndex);
    }
    
    /**
     * Returns the {@code index}th rank. Runs in {@code O(1)} time.
     * 
     * @param index the target index.
     * @return the rank of the input index.
     */
    public int rankThird(int index) {
        checkBitIndexForRank(index);
        makeSureStateIsCompiled();
        
        int f = first[index / ell];
        int s = second[index / k];
        
        int thirdEntryIndex = index % k - 1;
        
        if (thirdEntryIndex == -1) {
            return f + s;
        }
        
        int selectorIndex = computeSelectorIndex(index);
        
        return f + s + third[selectorIndex][thirdEntryIndex];
    }
    
    /**
     * Returns the index of the {@code index}th 1-bit. Relies on 
     * {@link #rankFirst(int)}, which runs in {@code O((log n)^2)}, which yields
     * {@code O((log n)^3)} running time for the {@code selectFirst}.
     * 
     * @param bitIndex the target index.
     * @return the index of the {@code index}th 1-bit.
     */
    public int selectFirst(int bitIndex) {
        checkBitIndexForSelect(bitIndex);
        return selectImplFirst(bitIndex, 0, getNumberOfSupportedBits());
    }
    
    /**
     * Returns the index of the {@code index}th 1-bit. Relies on 
     * {@link #rankSecond(int)}, which runs in {@code O(log n)}, which yields
     * {@code O((log n)^2)} running time for the {@code selectSecond}.
     * 
     * @param bitIndex the target index.
     * @return the index of the {@code index}th 1-bit.
     */
    public int selectSecond(int bitIndex) {
        checkBitIndexForSelect(bitIndex);
        return selectImplSecond(bitIndex, 0, getNumberOfSupportedBits());
    }
    
    /**
     * Returns the index of the {@code index}th 1-bit. Relies on 
     * {@link #rankThird(int)}, which runs in {@code O(1)}, which yields
     * {@code O(log n)} running time for the {@code selectThird}.
     * 
     * @param bitIndex the target index.
     * @return the index of the {@code index}th 1-bit.
     */
    public int selectThird(int bitIndex) {
        checkBitIndexForSelect(bitIndex);
        return selectImplThird(bitIndex, 0, getNumberOfSupportedBits());
    }
    
    private int selectImplFirst(int bitIndex,
                                int rangeStartIndex,
                                int rangeLength) {
        
        if (rangeLength == 1) {
            return rangeStartIndex;
        }
        
        int halfRangeLength = rangeLength / 2;
        int r = rankFirst(halfRangeLength + rangeStartIndex);
        
        if (r >= bitIndex) {
            return selectImplFirst(bitIndex, 
                                   rangeStartIndex,
                                   halfRangeLength);
        } else {
            return selectImplFirst(bitIndex, 
                                   rangeStartIndex + halfRangeLength,
                                   rangeLength - halfRangeLength);
        }
    }
    
    private int selectImplSecond(int bitIndex, 
                                 int rangeStartIndex,
                                 int rangeLength) {
        
        if (rangeLength == 1) {
            return rangeStartIndex;
        }
        
        int halfRangeLength = rangeLength / 2;
        int r = rankSecond(halfRangeLength + rangeStartIndex);
        
        if (r >= bitIndex) {
            return selectImplSecond(bitIndex, 
                                    rangeStartIndex,
                                    halfRangeLength);
        } else {
            return selectImplSecond(bitIndex, 
                                    rangeStartIndex + halfRangeLength,
                                    rangeLength - halfRangeLength);
        }
    }
    
    private int selectImplThird(int bitIndex,
                                int rangeStartIndex, 
                                int rangeLength) {
        
        if (rangeLength == 1) {
            return rangeStartIndex;
        }
        
        int halfRangeLength = rangeLength / 2;
        int r = rankThird(halfRangeLength + rangeStartIndex);
        
        if (r >= bitIndex) {
            return selectImplThird(bitIndex, 
                                   rangeStartIndex,
                                   halfRangeLength);
        } else {
            return selectImplThird(bitIndex, 
                                   rangeStartIndex + halfRangeLength,
                                   rangeLength - halfRangeLength);
        }
    }
    
    /**
     * The delegate for manipulating bits.
     * 
     * @param index the index of the target bit.
     * @param on    the flag deciding the value of the bit in question.
     */
    private void writeBitImpl(int index, boolean on) {
        boolean previousBitValue = readBit(index);
        
        if (on) {
            if (previousBitValue == false) {
                hasDirtyState = true;
                numberOfSetBits++;
            }
            
            turnBitOn(index);
        } else {
            if (previousBitValue == true) {
                hasDirtyState = true;
                numberOfSetBits--;
            }
            
            turnBitOff(index);
        }
    }
    
    /**
     * Implements the actual reading of a bit.
     * 
     * @param index the index of the target bit to read.
     * @return the value of the target bit.
     */
    boolean readBitImpl(int index) {
        int targetLongIndex = index / Long.SIZE;
        int targetLongBitIndex = index % Long.SIZE;
        long targetLong = wordData[targetLongIndex];
        return (targetLong & (1L << targetLongBitIndex)) != 0;
    }
    
    /**
     * Makes sure that the state of the internal data structures is up to date.
     */
    private void makeSureStateIsCompiled() {
        if (hasDirtyState) {
            buildIndices();
            hasDirtyState = false;
        }
    }
    
    /**
     * Turns the {@code index}th bit on. Indexation is zero-based.
     * 
     * @param index the target bit index.
     */
    private void turnBitOn(int index) {
        int targetLongIndex = index / Long.SIZE;
        int targetLongBitIndex = index % Long.SIZE;
        long mask = 1L;
        mask <<= targetLongBitIndex;
        wordData[targetLongIndex] |= mask;
    }
    
    /**
     * Turns the {@code index}th bit off. Indexation is zero-based.
     * 
     * @param index the target bit index.
     */
    private void turnBitOff(int index) {
        int targetLongIndex = index / Long.SIZE;
        int targetLongBitIndex = index % Long.SIZE;
        long mask = 1L;
        mask <<= targetLongBitIndex;
        wordData[targetLongIndex] &= ~mask;
    }
    
    private void checkBitIndexForSelect(int selectionIndex) {
        if (selectionIndex < 0) {
            throw new IndexOutOfBoundsException(
                    String.format(
                            "The input selection index is negative: " + 
                            "(%d). Must be within range [1..%d].\n",
                            selectionIndex,
                            numberOfSetBits));
        }
        
        if (selectionIndex == 0) {
            throw new IndexOutOfBoundsException(
                    String.format(
                            "The input selection index is zero (0). " + 
                            "Must be within range [1..%d].\n",
                            numberOfSetBits));
        }
        
        if (selectionIndex > numberOfSetBits) {
            throw new IndexOutOfBoundsException(
                    String.format(
                            "The input selection index is too large (%d). " + 
                            "Must be within range [1..%d].\n", 
                            selectionIndex, 
                            numberOfSetBits));
        }
    }
    
    private void checkBitIndexForRank(int index) {
        if (index < 0) {
            throw new IndexOutOfBoundsException(
                    String.format("Negative bit index: %d.", index));
        } 
        
        if (index > numberOfRequestedBits) {
            throw new IndexOutOfBoundsException(
                    String.format(
                            "Too large bit index (%d), number of bits " + 
                            "supported is %d.",
                            index, 
                            numberOfRequestedBits));
        }
    }
    
    private void checkBitAccessIndex(int accessIndex) {
        if (accessIndex < 0) {
            throw new IndexOutOfBoundsException(
                    String.format(
                            "Negative bit access index: %d.",
                            accessIndex));
        } 
        
        if (accessIndex >= getNumberOfSupportedBits()) {
            throw new IndexOutOfBoundsException(
                    String.format(
                            "Too large bit access index (%d), number of bits " + 
                            "supported is %d.",
                            accessIndex, 
                            getNumberOfSupportedBits()));
        }
    }
    
    /**
     * Returns {@code true} if and only if the {@code bitIndex}th bit in 
     * {@code value} is set.
     * 
     * @param value    the value of which to inspect the bit.
     * @param bitIndex the bit index.
     * @return {@code true} if and only if the specified bit is set.
     */
    private boolean bitIsSet(int value, int bitIndex) {
        return (value & (1 << bitIndex)) != 0;
    }
    
    int computeSelectorIndex(int i) {
        int startBitIndex = k * (i / k);
        int endBitIndex = Math.min(k * (i / k + 1) - 2, maximumBitIndex);
        
        int startLongIndex = startBitIndex / Long.SIZE;
        int endLongIndex = endBitIndex / Long.SIZE;
        
        if (startLongIndex == endLongIndex) {
            int bitRangeLength = endBitIndex - startBitIndex + 1;
            int omitBitCountRight = startBitIndex - Long.SIZE * startLongIndex;
            int omitBitCountLeft = 
                    Long.SIZE - omitBitCountRight - bitRangeLength;
                    
            long word = wordData[startLongIndex];
            word = Long.reverse(word);
            
            word  <<= omitBitCountRight;
            word >>>= omitBitCountLeft + omitBitCountRight;
            
            return (int) word;
        } else {
            
            int lengthWordLo = Long.SIZE - startBitIndex
                                        + Long.SIZE * startLongIndex;
            
            int lengthWordHi = endBitIndex - Long.SIZE * endLongIndex + 1;
            
            long wordLo = wordData[startLongIndex];
            long wordHi = wordData[endLongIndex];
            
            wordLo = preprocessLowWord(wordLo, lengthWordLo, lengthWordHi);
            wordHi = preprocessHighWord(wordHi, lengthWordHi);
            
            // Make room for bits from 
            wordLo <<= lengthWordHi;
            // Add bits
            wordLo |= wordHi;
            
            return (int)(wordHi | wordLo);
        }
    }
    
    private static long preprocessLowWord(long wordLo, 
                                          int lengthWordLo, 
                                          int lengthWordHi) {
        // Take 'lengthWordLo' most-significant bits of 'wordLo':
        wordLo >>>= Long.SIZE - lengthWordLo;
        
        // Reverse the bits of 'wordLo':
        wordLo = Long.reverse(wordLo);
        
        // Shift towards least-significant bits:
        wordLo >>>= Long.SIZE - lengthWordLo;
        return wordLo;
    }
    
    private static long preprocessHighWord(long wordHi, int lengthWordHi) {
        wordHi = Long.reverse(wordHi);
        wordHi >>>= Long.SIZE - lengthWordHi;
        return wordHi;
    }
    
    // Relies on Long.bitCount (possibly compiled to the POPCOUNT CPU 
    // instruction). Computes the rank of bit vector [startIndex..endIndex].
    private int bruteForceRank(int startIndex, int endIndex) {
        if (startIndex > endIndex) {
            return 0;
        }
        
        int startLongIndex = startIndex / Long.SIZE;
        int endLongIndex = endIndex / Long.SIZE;
        int rank = 0; 
        
        for (int longIndex = startLongIndex + 1;
                 longIndex < endLongIndex; 
                 longIndex++) {
            
            rank += Long.bitCount(wordData[longIndex]);
        }
        
        if (startLongIndex != endLongIndex) {
            // Deal with leading bits:
            int numberOfLeadingBits  = startIndex - startLongIndex * Long.SIZE;
            int numberOfTrailingBits = 
                    Long.SIZE - (endIndex - endLongIndex * Long.SIZE + 1);

            long word1 = wordData[startLongIndex];
            long word2 = wordData[endLongIndex];
            
            // Clear word1:
            word1 >>>= numberOfLeadingBits;
            word2 <<= numberOfTrailingBits;
            
            rank += Long.bitCount(word1) +
                    Long.bitCount(word2);
        } else {
            // Here, 'startLongIndex == endLongIndex':
            int rangeLength = endIndex - startIndex + 1;
            int numberOfLeadingBits = startIndex - startLongIndex * Long.SIZE;
            int numberOfTrailingBits = Long.SIZE - numberOfLeadingBits 
                                                 - rangeLength;
            
            if (numberOfLeadingBits + numberOfTrailingBits == Long.SIZE) {
                return rank;
            }
            
            // Grab the word:
            long word = wordData[startLongIndex];
            
            word >>>= numberOfLeadingBits;
            word  <<= numberOfLeadingBits + numberOfTrailingBits;
            
            rank += Long.bitCount(word);
        }
        
        return rank;
    }
    
    private void checkNumberOfRequestedBits(int numberOfRequestedBits) {
        if (numberOfRequestedBits == 0) {
            throw new IllegalArgumentException("Requested zero (0) bits.");
        }
        
        if (numberOfRequestedBits < 0) {
            throw new IllegalArgumentException(
                    String.format(
                            "Requested negative number of bits (%d).", 
                            numberOfRequestedBits));
        }
    }
    
    private static double log2(double v) {
        return Math.log(v) / Math.log(2.0);
    }
}

Typical output

Seed = 1706596078076
Built the bit vector in 321 milliseconds.
Preprocessed the bit vector in 256 milliseconds.
--- Benchmarking rank operation ---
rankFirst() ran for 276 milliseconds.
rankSecond() ran for 247 milliseconds.
rankThird() ran for 441 milliseconds.
--- Benchmarking select operation ---
selectFirst() ran for 4351 milliseconds.
selectSecond() ran for 5212 milliseconds.
selectThird() ran for 7934 milliseconds.

Critique request

Please, tell me anything that comes to mind. However, I am most eager to hear comments on how to make the class in question perform faster.

\$\endgroup\$
0

1 Answer 1

1
\$\begingroup\$

The bit-reversals that are part of building a selectorIndex shouldn't be necessary. Perhaps without them, you may need the lookup table to have a different order, I didn't look into that too closely, but even if that's the case it wouldn't be too bad. In general it looks like the computation of the selector index could be simpler in other ways too.

For example to extract b bits from a single word (not tested),

if (startLongIndex == endLongIndex) {
    long word = wordData[startLongIndex];

    // move selected bits down to position zero
    word >>>= startBitIndex;
    
    // only keep b bits
    return (int)(word & ((1L << b) - 1));
}

The other case is of course a bit more complicated, maybe something like this (also not tested):

else {
    long wordLo = wordData[startLongIndex];
    long wordHi = wordData[endLongIndex];

    // move selected bits in low word down to position zero
    wordLo >>>= startBitIndex;
    // move selected bits in high word up by however many bits of the low word we take
    wordHi <<= -startBitIndex;

    // concatenate parts and only keep b bits
    return (int)((wordLo | wordHi) & ((1L << b) - 1));
}

The shifts by startBitIndex implicitly use startBitIndex & 63 which is the position within this word, of the least significant bit of the range of bits that we're extracting, so also the number of bits to discard by shifting right. The high word is shifted left by however many bits we took from the low word, which is 64 minus the amount we discarded, and we discarded startBitIndex & 63. So 64 - (startBitIndex & 63) & 63 = -startBitIndex & 63 (proof) and then the & 63 can be dropped since it's implicit. This doesn't rely on the size of a long though, the same thing works for int but then the proof that it works would have different numbers in it.

Here I didn't need any explicit "clearing the top bits of wordLo" step since the extracted bits always go right up to the top of wordLo (otherwise we'd be in the single-word case) and the right shift creates zeroes above the former top bit already.

b can just be k - 1 I think but I leave that to you - I think you can ignore maximumBitIndex here since any bit above that would be zero anyway, so no special care needs to be taken to avoid reading those bits (only to avoid indexing the array at an invalid index).

((1L << b) - 1) makes a mask of b set bits (starting from the least significant bit) as long as 0 ≤ b < 64, in other contexts it's often annoying that b = 64 doesn't work but that case shouldn't be possible in this context.

Also, using floating point computations for integers such as:

  • (int) Math.pow(Math.ceil(log2(n) / 2.0), 2.0)
  • (int) Math.ceil(log2(n) / 2.0)
  • (int) Math.pow(2.0, k - 1)

Possibly work, but look shady.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.