Syntax
A computer stores every integer as a row of bits — switches that are either
0 or 1. Bitwise operators work on those individual switches rather than on
the number as a whole. To use them you need to read the same number in several bases, so that is
where we start.
Place value, and why bases exist
In ordinary decimal, each column is worth ten times the one to its right. The number 237 means
2×100 + 3×10 + 7×1. Nothing about that is fundamental — ten is
just how many fingers we have. Change the multiplier and you change the base.
Binary — base 2
Each column is worth twice the one to its right. Python writes binary literals with an
0b prefix:
0b1011
Read it by columns:
1 0 1 1 ↓ ↓ ↓ ↓ 8 4 2 1 ← place values 8 + 0 + 2 + 1 = 11
So 0b1011 and 11 are the same integer written two ways. Python will confirm
it:
>>> 0b1011 == 11 True
Octal — base 8
Columns worth 1, 8, 64, 512. Prefix 0o:
0o17 1 7 ↓ ↓ 8 1 1×8 + 7×1 = 15
Octal is mostly a historical curiosity now, with one survivor you use constantly: Unix file
permissions. chmod 755 is octal, and each digit is three bits of
read/write/execute.
Hexadecimal — base 16
Columns worth 1, 16, 256, 4096. Prefix 0x. Base 16 needs sixteen digit symbols and we
only have ten, so the last six are letters:
| Hex | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Dec | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
So:
0xff F F ↓ ↓ 16 1 15×16 + 15×1 = 240 + 15 = 255
One hex digit is exactly four bits. Four bits have sixteen possible values, and hex has sixteen digits — they line up perfectly. So two hex digits are exactly one byte, with no arithmetic needed:
0xF = 1111 0xA = 1010 0x3 = 0011 0xFF = 1111 1111 ← one byte, read straight off
Decimal has no such alignment — 255 tells you nothing about its bits at a glance. That is the entire reason every memory dump, colour code, MAC address, IPv6 address and hash you have ever seen is written in hex.
Worth memorising: the sixteen nibbles, both directions. It is rote, it takes twenty minutes, and it pays off for the rest of this course.
Converting between bases in Python
Three built-ins convert from an integer:
>>> bin(11) '0b1011' >>> oct(15) '0o17' >>> hex(255) '0xff'
All three return strings, not numbers — prefix included.
>>> type(bin(11)) <class 'str'> >>> bin(11) + 1 TypeError: can only concatenate str (not "int") to str
And int() converts the other way, taking the base as its second argument:
>>> int('ff', 16) # read 'ff' as base 16 255 >>> int('1011', 2) 11 >>> int('17', 8) 15 >>> int('0xff', 16) # prefix is tolerated 255 >>> int('ff', 0) # base 0 = infer from prefix; no prefix here, so: ValueError: invalid literal for int() with base 0: 'ff'
The same digit string means different numbers in different bases, which is the whole point:
>>> int('1010', 2), int('1010', 10), int('1010', 16) (10, 1010, 4112)
format() — padded binary for reading bits
bin() is awkward when you want to line bits up, because it strips leading zeros and adds
a prefix. format() fixes both:
>>> format(11, '08b') '00001011'
The format spec reads right to left:
'08b' │││ ││└── b = render in binary │└─── 8 = minimum total width └──── 0 = pad with zeros (not spaces)
Compare the three:
>>> format(11, 'b') # no padding '1011' >>> format(11, '8b') # pad to 8 with SPACES ' 1011' >>> format(11, '08b') # pad to 8 with ZEROS '00001011' >>> format(11, '016b') # 16-bit view '0000000000001011'
This is the tool you will reach for every time you want to see what an operator did. Keep it nearby.
The six bitwise operators
For every example below, these two numbers:
n = 11 = 1011 m = 5 = 0101
The operators compare the two rows column by column, independently. No column affects any other — that is what makes them different from arithmetic, where carries propagate.
& — AND
A column is 1 only when both bits are 1.
| A | B | A & B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
1011 (11) & 0101 (5) ──────── 0001 (1) >>> 11 & 5 1
Use it to ask questions. AND with a mask keeps only the bits you care about and zeroes everything else.
| — OR
A column is 1 when at least one bit is 1.
| A | B | A | B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
1011 (11) | 0101 (5) ──────── 1111 (15) >>> 11 | 5 15
Use it to add bits. OR turns bits on and never turns any off.
^ — XOR (exclusive or)
A column is 1 when the two bits differ.
| A | B | A ^ B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
1011 (11) ^ 0101 (5) ──────── 1110 (14) >>> 11 ^ 5 14
Use it to flip bits, and to cancel things out. XOR is the operator with real algebra behind it, which we come back to in the next section — it is the engine behind both of today's problems.
~ — NOT
Flips every bit. This is the one that surprises everybody:
>>> ~11 -12
You might have expected 4, reasoning that 1011 inverted is 0100. The
answer is −12 because Python integers have no fixed width. There is no
8-bit or 32-bit box for the flipped bits to live in — conceptually the number extends
leftwards forever, so inverting it flips infinitely many leading zeros into leading ones, which is
how negative numbers are represented.
The identity to remember:
~n == -(n + 1) >>> ~0, ~5, ~11, ~100 (-1, -6, -12, -101)
Day 02 explains why that identity holds, when we do two's complement properly. For now, treat it as a fact and note the practical consequence: in Python, if you want a bounded NOT you must supply the bound yourself by masking:
>>> ~11 & 0xFF # NOT within 8 bits 244 >>> format(244, '08b') '11110100' # 00001011 inverted, as expected
<< — left shift
Moves every bit left by k places, filling in zeros on the right.
11 = 1011 11 << 1: 1011 → 10110 = 16+4+2 = 22 11 << 2: 1011 → 101100 = 32+8+4 = 44 11 << 3: 1011 → 1011000 = 64+16+8 = 88
Shifting left by one doubles the number, because every bit moves into a column worth twice as much:
n << k == n × 2**k >>> 11 << 1, 11 << 2, 11 << 3 (22, 44, 88) >>> 1 << 10 # the idiomatic way to write 1024 1024
1 << k builds a number with a single bit set at position k. That is the most
useful shift in practice and you will see it in every idiom below.
>> — right shift
Moves every bit right by k places. Bits that fall off the right-hand end are discarded, which makes this lossy.
11 = 1011 11 >> 1: 1011 → 101 = 4+1 = 5 (the final 1 was dropped) 11 >> 2: 1011 → 10 = 2 = 2 11 >> 3: 1011 → 1 = 1 = 1
For non-negative integers this is floor division by a power of two:
n >> k == n // 2**k # for n ≥ 0 >>> 20 >> 1, 20 >> 2, 20 >> 3 (10, 5, 2)
>> is floor division, not truncation, and the difference shows up on
negatives. Python rounds toward negative infinity:
>>> -5 >> 1 -3 # floor(-2.5) = -3, not -2 >>> -1 >> 1 -1 # and -1 never goes anywhere, however far you shift
A negative number's bits are conceptually all ones to the left, so shifting right keeps feeding ones in. We explain that properly tomorrow.
Two integer methods worth knowing
.bit_length()
How many binary digits the number needs, ignoring leading zeros and ignoring the sign.
>>> (255).bit_length() 8 # 255 = 11111111 → eight digits
The pattern is that it ticks up at each power of two:
| n | binary | bit_length() |
|---|---|---|
| 0 | 0 | 0 (special case) |
| 1 | 1 | 1 |
| 2 | 10 | 2 |
| 3 | 11 | 2 |
| 4 | 100 | 3 |
| 7 | 111 | 3 |
| 8 | 1000 | 4 |
| 255 | 11111111 | 8 |
| 256 | 100000000 | 9 |
Useful shortcut: n.bit_length() is floor(log2(n)) + 1 for positive
n, but computed exactly, with no floating-point error. Prefer it over math.log2
whenever the answer must be exact.
.bit_count() — population count
How many bits are set to 1. Added in Python 3.10.
>>> (255).bit_count() 8 # 11111111 — eight ones >>> (11).bit_count() 3 # 1011 — three ones >>> (16).bit_count() 1 # 10000 — one one >>> (10).bit_count() 2 # 1010 — two ones
The traditional name is popcount, and you will see that word everywhere. On older
Python, bin(n).count('1') does the same thing far more slowly.
A real use: flags and permissions
This is where bitwise operations actually earn their place in ordinary code. Give each independent boolean its own bit:
READ = 1 # 001 WRITE = 2 # 010 DELETE = 4 # 100
Combine with OR:
>>> perms = READ | WRITE >>> perms 3 >>> format(perms, '03b') '011' 001 READ | 010 WRITE ────── 011 both, in one integer
Test with AND:
>>> perms & WRITE # 011 & 010 = 010 2 # non-zero → yes >>> perms & DELETE # 011 & 100 = 000 0 # zero → no if perms & WRITE: # the idiomatic test ...
Revoke with AND-NOT, toggle with XOR:
>>> perms & ~WRITE # clear the WRITE bit 1 >>> perms ^ DELETE # flip DELETE on (or off, if it were on) 7
Three booleans in one integer, each test a single CPU instruction. This exact pattern is how Unix file modes, TCP header flags, CPU status registers and most permission systems you have used are built.
The same operators in Rust
Rust does the identical bit arithmetic, but forces one thing into the open that Python hides: the width of the integer.
let a: u8 = 0b1011_1010; // unsigned, 8 bits let b: i32 = 0xff; // signed, 32 bits
Reading the type names: u is unsigned (zero and up), i is signed
(negatives allowed), and the number is how many bits.
| Type | Bits | Bytes | Range |
|---|---|---|---|
u8 | 8 | 1 | 0 … 255 |
u16 | 16 | 2 | 0 … 65,535 |
u32 | 32 | 4 | 0 … 4,294,967,295 |
u64 | 64 | 8 | 0 … 264−1 |
i8 | 8 | 1 | −128 … 127 |
i32 | 32 | 4 | −231 … 231−1 |
i64 | 64 | 8 | −263 … 263−1 |
usize | 64 here | 8 | pointer-sized; used for indexes and lengths |
The underscore in 0b1011_1010 is purely cosmetic — Rust lets you put it anywhere
in a numeric literal to group digits. 0b1011_1010, 0b10111010 and
186 are the same value. Use it to group bits in fours and hex in bytes:
1_000_000 0xff_ff 0b1010_1010
Operator differences
| Operation | Python | Rust |
|---|---|---|
| AND, OR, XOR | & | ^ | same |
| shifts | << >> | same |
| NOT | ~x | !x |
| popcount | x.bit_count() | x.count_ones() |
| leading zeros | — | x.leading_zeros() |
| trailing zeros | — | x.trailing_zeros() |
| rotate | — | x.rotate_left(k) |
| bytes of a value | struct.pack | x.to_le_bytes() |
Note that ! in Rust is both logical not (on bool) and bitwise not (on
integers) — the type decides which. Python needs two symbols, not and
~, because an integer is also truthy.
The methods Python does not have
Because Rust knows the width, it can answer questions Python cannot. Using
a: u8 = 0b1011_1010 throughout:
// 1 0 1 1 1 0 1 0 ← always exactly 8 bits, because u8 a.count_ones() // 5 — five bits are set a.count_zeros() // 3 a.leading_zeros() // 0 — the top bit is already 1 a.trailing_zeros() // 1 — one zero at the bottom end
Change the value and the width still anchors the answer:
let x: u8 = 0b0001_1010; // ^^^ three leading zeros x.leading_zeros() // 3 let y: u8 = 0b1011_1000; // ^^^ three trailing zeros y.trailing_zeros() // 3
leading_zeros is impossible in Python because an integer has no declared width —
there is no agreed number of leading zeros to count. That is the whole difference between the two
languages in one method.
Rotate versus shift
A shift throws bits away. A rotation wraps them around to the other end.
a = 1011_1010 a << 3 // SHIFT: 101 falls off the left and is lost 1101_0000 // = 208 a.rotate_left(3) // ROTATE: 101 comes back around on the right 1101_0101 // = 213
Step through the rotation one place at a time if it helps:
1011_1010 start 0111_0101 rotate 1 — the leading 1 reappears at the end 1110_1010 rotate 2 1101_0101 rotate 3 ✓
Rotation only makes sense when the width is known, which is why Python has no built-in for it. It matters because cryptography, hashing and checksums are built almost entirely out of rotations and XOR — no bit is ever allowed to be lost, because the operation has to be reversible.
Width is a real constraint
1u64 << 63 // fine — lands on the top bit of 64 1u32 << 31 // fine — lands on the top bit of 32 1u32 << 32 // PANIC in debug: shift amount ≥ width 1u32 << 63 // PANIC — meaningless in a 32-bit value
In Python, 1 << 63 and 1 << 6300 both just work, because the integer
grows to fit. In C the same shift is undefined behaviour, which is worse than a panic: the
compiler is entitled to assume it never happens and optimise accordingly. Rust's choice to panic
loudly in debug and wrap predictably in release is the deliberate middle path.
Semantics
You now have the operators. This section is the vocabulary built from them — the handful of expressions that appear in real bit-manipulation code, and why each one works rather than just that it does.
The idiom table
Learn these as phrases, not puzzles. i counts from 0 at the rightmost bit.
| Goal | Expression | Mechanism |
|---|---|---|
| read bit i | n >> i & 1 | slide it to position 0, mask off everything above |
| set bit i | n | (1 << i) | OR with a single-bit mask; OR never clears |
| clear bit i | n & ~(1 << i) | AND with that mask inverted |
| flip bit i | n ^ (1 << i) | XOR inverts exactly where the mask is 1 |
| lowest set bit, isolated | n & -n | see below |
| clear lowest set bit | n & (n - 1) | see below |
| is a power of two | n > 0 and n & (n-1) == 0 | exactly one bit set |
| low k bits only | n & ((1 << k) - 1) | (1<<k)-1 is k ones |
| count set bits | n.bit_count() | or Kernighan's loop, below |
That (1 << k) - 1 trick is worth its own moment, because it recurs constantly.
Subtracting one from a lone bit borrows all the way down and fills everything beneath it:
1 << 4 = 0001_0000 = 16 (1 << 4) - 1 = 0000_1111 = 15 ← exactly four ones
Why n & (n - 1) clears the lowest set bit
Subtracting one flips the lowest 1 to 0 and turns every zero below it into
a one. AND that against the original and the lower region cancels completely:
n = 0010_1100 (44) n - 1 = 0010_1011 (43) — lowest 1 cleared, zeros below became ones ───────── n&(n-1)= 0010_1000 (40) — the lowest set bit is gone, rest untouched
Repeat until zero and you have counted the set bits, visiting each exactly once. This is Brian Kernighan's algorithm:
def popcount(n): count = 0 while n: n &= n - 1 # remove the lowest set bit count += 1 return count
The loop runs once per set bit, not once per bit position — which for a sparse value is the difference between one iteration and sixty-four.
Why n & -n isolates the lowest set bit
This looks like nonsense until you know what negation does to the bits. Negating in two's complement means invert every bit, then add one (tomorrow's topic, taken on faith today):
n = 0010_1000 invert = 1101_0111 add 1 = 1101_1000 = -n n = 0010_1000 -n = 1101_1000 ───────── n & -n = 0000_1000 ← the lowest set bit, alone
The reason it works is the carry. Adding one after inverting propagates upward through the trailing
ones of the inverted value — which were the trailing zeros of the original —
and stops at the first position that had a 1. The result:
- Below the lowest set bit: original is all zeros, so the AND is zero.
- At the lowest set bit: both are
1, so it survives. - Above it: the carry stopped, so those bits are the plain inversion of the
original — every column has one
1and one0, and the AND kills them all.
It works in Python too, despite Python having no fixed width, because Python integers behave as though two's complement extends infinitely to the left. This expression is the foundation of the Fenwick tree you will meet on day 33.
XOR is the operator with algebra
Four properties. Every XOR trick in existence is a consequence of them:
| Property | Meaning |
|---|---|
x ^ x == 0 | self-inverse — anything XORed with itself vanishes |
x ^ 0 == x | zero is the identity |
x ^ y == y ^ x | commutative |
(x^y)^z == x^(y^z) | associative |
Commutativity and associativity together mean order does not matter at all. So if you XOR an entire array in which every value appears twice except one, the pairs annihilate each other no matter how far apart they sit, and what remains is the unpaired value:
[4, 1, 2, 1, 2] 4 ^ 1 ^ 2 ^ 1 ^ 2 = 4 ^ (1 ^ 1) ^ (2 ^ 2) ← reorder freely = 4 ^ 0 ^ 0 = 4
from functools import reduce from operator import xor reduce(xor, [4,1,2,1,2]) # 4 — O(n) time, O(1) space
Two more consequences you will use today:
- XOR is its own undo.
a ^ k ^ k == a. This is why the simplest (and weakest) ciphers are XOR with a key. - If
a ^ b == tthena ^ t == b. Rearranging works exactly like ordinary algebra. Problem 2 is built entirely on this.
An integer as a set
Once bit i means "element i is present", a single integer is a whole set and the set operations become single instructions:
s = 0 # empty set s |= 1 << 3 # add element 3 s |= 1 << 5 # add element 5 if s >> 3 & 1: ... # membership s &= ~(1 << 3) # remove element 3 a | b # union a & b # intersection a & ~b # difference a ^ b # symmetric difference a & b == a # a is a subset of b s.bit_count() # size
This is why competitive code is full of bitmasks, and it is what makes subset dynamic programming tractable at all — day 22.
Enumerating every subset of a mask
One genuinely non-obvious idiom, included because it has a complexity result attached:
sub = mask while sub: process(sub) sub = (sub - 1) & mask # next smaller submask # the loop exits on 0, so handle the empty submask separately if you need it
Subtracting one borrows through the mask's own bits; ANDing back with the mask discards whatever borrowed outside it. The effect is a clean descending walk through every subset.
Machine
The byte is the unit of address
A byte is eight bits and it is the smallest thing in a computer with its own address. Memory is a numbered sequence of bytes; addresses count bytes, never bits.
There is no address for a bit. You cannot hand the CPU the location of a single bit and ask it to flip that. To change one bit, the processor must load the whole byte or word containing it, apply a mask, and store the result back.
That is the real reason the idiom table above exists. Those expressions are not clever tricks layered on top of a bit-addressable machine — they are the only mechanism available. Bitfields in C and Rust look like named fields but compile into exactly this load–mask–store sequence.
A word is the processor's natural working width — 64 bits on Apple
silicon. Its general-purpose registers are x0 through x30, each 64 bits,
and w0–w30 name the lower 32 bits of those same registers rather
than separate storage.
The units, since they get used loosely: 4 bits is a nibble (one hex digit), 8 is a byte, and "half word", "word" and "double word" mean different sizes depending on whose manual you are reading — always check.
Endianness — byte order within a value
A 32-bit value occupies four consecutive bytes. Which end goes first is a genuine design choice,
and both answers are in use. Store 0x12345678 starting at address 1000:
little-endian (Apple silicon, x86) big-endian (network order) 1000: 78 ← least significant 1000: 12 ← most significant 1001: 56 1001: 34 1002: 34 1002: 56 1003: 12 1003: 78
Little-endian puts the least significant byte at the lowest address. It reads backwards to a human, but it has a concrete advantage: the low byte of a value sits at the same address as the value itself, so narrowing a 64-bit integer to 8 bits is free — you read fewer bytes from the identical pointer, with no offset arithmetic.
Big-endian matches how we write numbers and became the convention for network
protocols — which is why it is called network byte order, and why
htons/htonl exist in every socket library. Any time bytes cross between
machines or go into a file format, somebody has to commit to an order.
Python makes you state it explicitly, which is the right default:
import struct struct.pack('<I', 0x12345678).hex(' ') # '78 56 34 12' little struct.pack('>I', 0x12345678).hex(' ') # '12 34 56 78' big (0x12345678).to_bytes(4, 'little') # same thing, no struct
Rust the same, by method name — and to_ne_bytes for whatever the host
uses:
0x1234u16.to_le_bytes() // [0x34, 0x12] 0x1234u16.to_be_bytes() // [0x12, 0x34] 0x1234u16.to_ne_bytes() // native — [0x34, 0x12] on Apple silicon
Endianness is invisible until bytes leave your process. Then it is suddenly everything: binary file formats, network packets, memory-mapped hardware registers, hashes, checksums, and any C interop. A struct written to disk on one architecture and read on another, with no explicit byte order, is a bug that will wait years to surface.
What your processor actually executes
Every operator in altitude 1 is a single ARM64 instruction:
and x0, x1, x2 // x0 = x1 & x2 orr x0, x1, x2 // x0 = x1 | x2 eor x0, x1, x2 // x0 = x1 ^ x2 ("exclusive or", not "xor") mvn x0, x1 // x0 = ~x1 ("move not") lsl x0, x1, #3 // logical shift left lsr x0, x1, #3 // logical shift right — fills with zeros asr x0, x1, #3 // arithmetic shift right — copies the sign bit clz x0, x1 // count leading zeros rbit x0, x1 // reverse all 64 bits
Note that lsr and asr are different instructions. That is the hardware
reason Python's >> behaves differently on negative numbers — signed values
need the sign bit replicated, unsigned ones need zeros, and the compiler picks the instruction from
the type.
All of these are single-cycle ALU operations, and the core is wide enough to retire several per cycle. This is as cheap as computation gets on real hardware.
ARM64 has no scalar population count. The cnt instruction lives in the
NEON vector unit, so counting bits in a plain register means moving the value into a vector
register, running cnt per byte-lane, then addv to sum the lanes
— three instructions and a register-file crossing where x86 has one
popcnt.
It does not change any complexity claim, but it is exactly the kind of constant factor that decides a tight loop, and you will see it in the generated assembly yourself on day 42.
Cost
Every day in this course ends here: time, space, and where the thing is the wrong choice.
On the hardware: as cheap as it gets
A bitwise operation on a machine word is O(1) — one ALU instruction, roughly one cycle, several able to issue in the same cycle. There is no faster category of computation.
In CPython it is not O(1)
This is the most important cost lesson of the day, and it catches people who have only ever reasoned about bit operations in C.
A Python int is arbitrary precision. CPython stores it as an array of 30-bit
digits, so an operation on a big integer is a loop over that array:
| Operation | Complexity | Note |
|---|---|---|
a & b, a | b, a ^ b | O(max(digits)) | = O(n/30) for an n-bit value |
n << k | O(n + k) | allocates a larger integer; genuinely not free |
n >> k | O(n) | allocates a new, smaller integer |
n.bit_count() | O(digits) |
For values below 230 that is one digit, so effectively constant — but with roughly 30–50 nanoseconds of interpreter overhead per operation against something closer to a third of a nanosecond in Rust. A hundredfold constant factor that Big-O cannot see, and the reason day 41 exists.
And 1 << 10000 is not a shift at all in any hardware sense — it is an
allocation of a 334-digit integer.
Kernighan versus the naive loop
| Approach | Iterations | For 0b1000_0000 (one bit set in 64) |
|---|---|---|
| shift and test every position | O(bit_length) | 64 |
n &= n - 1 | O(popcount) | 1 |
Same complexity class in the worst case, sixty-four times apart in the case that actually occurs in sparse bitmask code.
An integer as a set, costed
For up to 64 elements, the comparison against a hash set is lopsided:
| Operation | set | bitmask |
|---|---|---|
| membership | O(1) — plus a hash, a probe, a pointer dereference | one AND |
| union / intersection | O(n) | one OR / one AND |
| size | O(1), stored | one popcount |
| iterate elements | O(n) | O(popcount) via n & -n |
| memory | ~54 bytes per element | 8 bytes total |
| cache behaviour | scattered heap allocations | one register |
One complexity result that surprises people
Enumerating every submask of every mask over n elements is O(3n), not O(4n) as the nested loop suggests.
The proof is a counting argument. For each of the n bits there are exactly three possibilities across the pair (mask, submask): in both, in the mask only, or in neither. It cannot be in the submask without being in the mask. Three independent choices per bit, n bits, so 3n pairs total.
When bitmasks are the wrong answer
- More than word-many elements. Past 64 you need a real bitset
(
bytearray,intwith explicit indexing, or a library), and the clean single-register story is gone. - Sparse universes. Three values drawn from a billion possibilities is a
set, not a mask. - Anywhere a future reader has to decode it. Clever bit-packing in application
code that nobody can read is a maintenance liability, not an optimisation. The flags example
earlier works because
READ,WRITEandDELETEare named. - Before measuring. If the bit-twiddling is not in a hot path, the clearer code wins by default.
Practice questions
Worth answering before revealing. No interpreter — the point is reading bits in your head.
01 Write 0xCAFE in binary. How many bytes does it occupy?
Translate each hex digit to its four bits independently:
C A F E 1100 1010 1111 1110
Four hex digits × 4 bits = 16 bits = 2 bytes. This is the payoff of the nibble alignment — no arithmetic was needed, just four table lookups.
02 What is ~0 in Python? And !0u8 in Rust? Why do they differ?
~0 is −1. !0u8 is 255.
Both invert every bit. The difference is how many bits there are. Rust's u8 has
exactly eight, so the result is 11111111 read as unsigned = 255. Python has no
width — the number conceptually extends left forever, so inverting zero produces
infinitely many ones, which in two's complement is −1.
Same operation, same bits, different frame. Consistent with ~n == -(n+1).
03 What does n & (n - 1) == 0 test, and which input does it get wrong?
It tests whether n is a power of two — a value with exactly one bit set, which the subtract-and-AND clears to nothing.
It gets zero wrong. 0 & -1 == 0, so zero passes the test
despite having no bits set at all. Hence the guard:
n > 0 and n & (n - 1) == 0
04 Explain the carry in n & -n.
Negation inverts then adds one. The add-one carries upward through the inverted value's trailing ones — which were the original's trailing zeros — and halts at the first position the original had set.
Below that position: the original is zero, so the AND is zero. At it: both are one, so it
survives. Above it: the carry stopped, so those bits are the plain inversion of the
original, meaning every column holds one 1 and one 0 and the AND
clears them. Only the lowest set bit remains.
05 0x0000FF00 is stored at address 2000 on Apple silicon. Which byte is at 2000?
0x00.
The four bytes of the value, most to least significant, are 00 00 FF 00. Apple
silicon is little-endian, so the least significant byte goes to the lowest
address:
2000: 00 ← least significant 2001: FF 2002: 00 2003: 00
06 Every element appears twice except one. How does a single XOR pass find it, and why does order not matter?
XOR the whole array. Because x ^ x == 0, every pair annihilates; because
x ^ 0 == x, the survivor passes through unchanged.
Order is irrelevant because XOR is commutative and associative, so the terms can be regrouped freely — the two members of a pair need not be adjacent. O(n) time, O(1) space, no hash map.
07 What is the time complexity of a & b in Python for two 1000-bit integers?
O(n) in the bit length — concretely O(digits), where CPython
uses 30-bit digits. A 1000-bit integer is ceil(1000/30) = 34 digits, so the
AND is a 34-iteration loop, not a single instruction.
The trap is assuming C semantics. In C this would be one instruction regardless; in Python it scales with magnitude.
08 Why is -1 >> 1 equal to -1 rather than a large positive number?
Because >> on a signed value is an arithmetic shift — it
replicates the sign bit rather than feeding in zeros. −1 is conceptually all ones, so
shifting right pulls in another one and leaves all ones: still −1.
It is also consistent with the floor-division definition: -1 // 2 is
-1, because floor(−0.5) is −1. Python's right shift and its floor
division agree on negatives, which is deliberate.
09 How many iterations does while n: n &= n-1 run for n = 0b10010000?
Two. The loop runs once per set bit, and 10010000 has two.
1001_0000 → 1000_0000 → 0000_0000
A naive shift-and-test loop would have run eight times for the same value — or sixty-four, in a 64-bit register.
10 Forty boolean flags, queried in a hot loop. Bitmask or set?
Bitmask. Forty fits inside one 64-bit word, so the entire structure is a single integer: 8 bytes rather than roughly 2 KB, one register rather than scattered heap allocations, and each query is an AND with no hashing and no pointer chase.
The one caveat is readability — name the bit positions as constants rather than writing literal shifts at the call site.
Coding problems
Both are pure bit manipulation and both rest on the XOR algebra above. Complexity stated before code, every time.
1 · Two unpaired numbers
medium O(n) time, O(1) spaceEvery element of an array appears exactly twice, except two elements which appear once. Return those two, in any order.
[1,2,1,3,2,5] → [3,5]
No hash map and no sorting — constant extra space.
Direction: one XOR pass over everything gives a ^ b, which is neither
answer on its own. But every bit set in that result is a position where a and
b disagree. Pick any one such bit and it partitions the entire array into two groups,
each containing exactly one of the two answers — and every duplicate pair stays together
in whichever group it lands. The idiom table has the tool for picking that bit cheaply.
2 · Maximum XOR of two numbers in an array
hard target O(32n)Given an array, find the maximum value of nums[i] ^ nums[j] over all pairs.
[3,10,5,25,2,8] → 28 # 5 ^ 25
The O(n²) double loop is correct and too slow.
Direction: build the answer greedily from the most significant bit downward. At each
bit, assume optimistically that you can make it a 1, then test whether any pair of
prefixes confirms that guess — using the rearrangement
a ^ b == t ⟹ a ^ t == b. A set of the prefixes seen so far does the
confirming in constant time per element.
Worked solutions land in github.com/Jeevan-Rai/god-of-code alongside the benchmarks and assembly dumps for each day.