Boolean algebra & digital logic
From true/false to silicon: Boolean laws and truth-table proofs, the six gates and NAND universality, half/full adders you simulate, Karnaugh-map minimization, and the feedback that becomes memory.
Learning objectives
- State and apply the core Boolean laws (identity, De Morgan, distribution, absorption).
- Read and write truth tables for AND/OR/NOT/NAND/NOR/XOR.
- Explain why NAND (and NOR) are universal gates.
- Build and simulate a half adder and a full adder in Python.
- Minimize a Boolean function with a Karnaugh map.
- Describe sequential logic — latches, flip-flops, clocks, state — conceptually.
1 · Boolean values & the core laws essential
Every if you have ever written is Boolean algebra in disguise. Think of the last time you wrote a condition like if user.is_admin and not user.is_banned — and then, three commits later, another engineer bolted an or onto it, and a fourth added a nested negation, until nobody could say for certain when the block actually ran. That tangle is a Boolean expression that was never simplified. The same maths that lets a chip designer shave gates off a circuit is the maths that would have collapsed that condition to something a human can read. Boolean algebra is not an academic warm-up; it is the grammar of every decision your code and your hardware make.
Here is the whole idea in one sentence: there are only two values, and a tiny handful of operations combine them, and the operations obey fixed algebraic laws — exactly like the + and × you learned in school, just with 1 and 0 instead of all the numbers. The everyday analogy is a light switch wired through a hallway: the light is on (1) or off (0), never "sort of on." Feed switches through AND (both must be up), OR (either will do), or NOT (flip it), and you can express any rule about when the light glows. That "only two values" constraint is what makes the algebra so clean — with just two states, you can literally check every case by hand.
The common mistake to name up front: people confuse Boolean + with ordinary addition and expect 1 + 1 = 2. It does not — Boolean + is OR, so 1 + 1 = 1 ("either input true → true, and it can't get truer than true"). Likewise · is AND, not multiplication in any counting sense, even though 1·1 = 1 and 1·0 = 0 happen to look familiar. Read + as "or" and · as "and" in your head every time, and the laws below stop looking like arithmetic tricks and start looking like plain statements about truth. Why the laws earn their own section: each one is a rewrite you're allowed to make without changing what the expression means, and in hardware every eliminated term is a physical gate you don't have to fabricate, power, or wait for.
Boolean algebra works on two values — 1/true and 0/false — with three operations: AND (·), OR (+) and NOT (¬ or ′). The laws below let you simplify expressions, which in hardware means fewer gates — cheaper, faster, cooler chips.
A·(A + B) into just A — identical on every input row, but two gates become a bare wire.
Read it left→right as a rewrite: a messy expression enters on the left, a law is applied in the middle, and a simpler-but-equivalent expression comes out on the right.
- The red box on the left is the original expression
A·(A + B)— it costs two real gates (one AND, one OR) to build in silicon. - The amber box in the middle is the law you are allowed to apply — here absorption,
A·(A+B)=A— one of the rewrite rules from the table below. - The green box on the right is the payoff: the whole expression collapses to
A, which needs no gate at all — it is just a wire. The two boxes have the same truth table, so nothing about the circuit's behaviour changed.
In short: Each law lets you swap an expression for an equal one; in hardware every term you delete is a gate you never have to fabricate, power, or wait for.
| Law | AND form | OR form |
|---|---|---|
| Identity | A · 1 = A | A + 0 = A |
| Null | A · 0 = 0 | A + 1 = 1 |
| Idempotent | A · A = A | A + A = A |
| Complement | A · ¬A = 0 | A + ¬A = 1 |
| Absorption | A · (A + B) = A | A + (A · B) = A |
| Distribution | A · (B + C) = A·B + A·C | A + (B·C) = (A+B)·(A+C) |
| De Morgan | ¬(A · B) = ¬A + ¬B | ¬(A + B) = ¬A · ¬B |
De Morgan is the workhorse: it lets you push a NOT through an AND/OR by flipping the operator. We can prove any law by exhausting the truth table — if both sides agree on all input rows, they're equal:
demorgan.py# ¬(A and B) == (¬A) or (¬B) for every A, B
def NOT(x): return 1 - x
def AND(a, b): return a & b
def OR(a, b): return a | b
print("A B | ¬(A·B) ¬A+¬B equal?")
ok = True
for A_ in (0, 1):
for B_ in (0, 1):
left = NOT(AND(A_, B_))
right = OR(NOT(A_), NOT(B_))
ok = ok and (left == right)
print(f"{A_} {B_} | {left} {right} {left == right}")
print("law holds for all rows:", ok)
A B | ¬(A·B) ¬A+¬B equal?
0 0 | 1 1 True
0 1 | 1 1 True
1 0 | 1 1 True
1 1 | 0 0 True
law holds for all rows: True
2 · Logic gates & truth tables essential
The leap that trips everyone up is realising the algebra is real hardware. In section 1, AND and OR were symbols on paper. Now picture a tiny electronic component — a few transistors — that takes two wires carrying voltage-high (1) or voltage-low (0) and produces a third wire whose voltage is the AND of the inputs. That component is a gate. It is not a metaphor for the algebra; it is the algebra made out of silicon and electrons. The first time this clicks, the whole computer stops being magic: it is millions of these little truth-obeying switches, wired together.
The tool for describing a gate exhaustively is the truth table — and it earns a moment of respect, because it is the single most honest thing in all of computer science. A truth table simply lists every possible input and the output for each. With two one-bit inputs there are exactly four rows (00, 01, 10, 11) — that's all of them, no cases hiding. Compare that to testing normal software, where the inputs are near-infinite and you can only ever sample: a gate's behaviour can be verified completely, forever, in four lines. When you write a truth table you are not summarising the gate, you are giving its full definition.
Think of a truth table like the settings chart on the back of a kitchen appliance: for each combination of dials, here is exactly what happens — no surprises, no "it depends." The common confusion to head off is between the negated gates and their bases. NAND is literally "AND, then flip the answer" (it's 1 everywhere AND is 0, and 0 only where AND is 1); NOR is "OR, then flip." And XOR — exclusive-or — is the one people misremember most: it is 1 only when the two inputs disagree, so it is really a "these two bits are different" detector, which is exactly why it becomes the sum bit of an adder later in this lesson. Read each column of the table below as its own gate, and notice how NAND/NOR are just the AND/OR columns turned upside down.
A gate is the physical realisation of a Boolean operation. Six matter. Note NAND (not-and) and NOR (not-or) are the negations of AND/OR, and XOR (exclusive-or) is 1 when the inputs differ.
Follow the signal left→right: two input wires enter a gate, and one output wire leaves it.
- The two blue boxes on the left are the input wires
AandB, each carrying a single bit — voltage-high1or voltage-low0. - The amber box in the middle is the gate itself: a few transistors that compute one Boolean operation (AND, OR, XOR, and so on) on whatever the two wires carry.
- The green box on the right is the single output bit — the operation's answer for that input pair.
- The line underneath is the payoff: with two one-bit inputs there are only
2×2 = 4possible input patterns (00, 01, 10, 11), so four rows list every case — that is why a truth table is a complete definition, not a sample.
In short: Two wires in, one wire out, four possible inputs — a gate is a function so small you can verify it completely in four lines.
| A | B | AND | OR | NAND | NOR | XOR |
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 1 | 1 | 0 |
| 0 | 1 | 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 | 1 | 0 | 1 |
| 1 | 1 | 1 | 1 | 0 | 0 | 0 |
gates.pygates = {
"AND": lambda a, b: a & b,
"OR": lambda a, b: a | b,
"NAND": lambda a, b: 1 - (a & b),
"NOR": lambda a, b: 1 - (a | b),
"XOR": lambda a, b: a ^ b,
}
def truth_table(name):
fn = gates[name]
print(f"-- {name} --")
for a in (0, 1):
for b in (0, 1):
print(f" {a} {b} -> {fn(a, b)}")
truth_table("NAND")
truth_table("XOR")
-- NAND --
0 0 -> 1
0 1 -> 1
1 0 -> 1
1 1 -> 0
-- XOR --
0 0 -> 0
0 1 -> 1
1 0 -> 1
1 1 -> 0
3 · NAND is universal intermediate
Here is a fact that sounds like a party trick but reshapes how you see every chip: one single gate type can build everything. Not AND and OR and NOT — just NAND, repeated. A modern CPU with billions of transistors could, in principle, be built from nothing but one gate design stamped out over and over. When you first hear it you assume there must be a catch. There isn't. This is the closest thing digital logic has to a magic-is-real moment, and it's worth slowing down for.
The idea you need is functional completeness (engineers say a gate set is "universal"): a set of operations is complete if you can express every possible Boolean function using only those operations. We already know {AND, OR, NOT} is complete — anything true/false can be written with those three. So the whole argument reduces to a single question: can NAND alone reproduce AND, OR, and NOT? If yes, then by substitution NAND can build the three, and the three can build anything, so NAND can build anything. That chain — NAND → {AND, OR, NOT} → everything — is the entire proof, and the table and lab below just fill in the three constructions.
The everyday analogy: think of NAND as a Lego brick that happens to be shaped so cleverly that you can assemble any other brick shape out of copies of it. You don't stock a warehouse of a hundred part types; you stock one, and your factory line never has to change tools. That is precisely why fabrication plants adore it — a single, deeply optimised, well-characterised gate laid down millions of times is cheaper and more reliable than a zoo of different gates. The mistake to avoid is thinking universality makes NAND the fastest or smallest way to build any given circuit — it usually isn't; a purpose-built design uses fewer gates. Universality is a statement about what's possible with one primitive, not about what's optimal. (NOR is universal too, by the mirror-image argument.) Read the constructions below as recipes: tie NAND's inputs together to get NOT, then bolt an inverter onto NAND to recover AND, and invert the inputs to recover OR.
A gate set is universal if you can build every other gate from it. NAND alone is universal — which is why fabs love it: one gate type, laid down millions of times, becomes an entire processor. The constructions:
Read it left→right: one NAND gate on the left, an arrow, and the gate it becomes on the right.
- The blue box on the left is a single NAND with both of its inputs tied to the same signal —
NAND(A,A), labelled "tie inputs." - The arrow is the substitution: feeding one wire into both NAND inputs turns the two-input gate into a one-input one.
- The green box on the right is the payoff:
NAND(A,A)behaves exactly likeNOT A— an inverter — the first of the three gates you rebuild from NAND alone.
In short: Tie a NAND's two inputs together and it becomes a NOT — the first step in showing NAND is universal.
| Target gate | Built from NAND |
|---|---|
| NOT A | NAND(A, A) |
| AND A,B | NOT(NAND(A, B)) = NAND(NAND(A,B), NAND(A,B)) |
| OR A,B | NAND(NOT A, NOT B) = NAND(NAND(A,A), NAND(B,B)) |
nand_universal.pydef NAND(a, b): return 1 - (a & b)
def NOT(a): return NAND(a, a)
def AND(a, b): return NOT(NAND(a, b))
def OR(a, b): return NAND(NOT(a), NOT(b))
for a in (0, 1):
for b in (0, 1):
print(a, b, "| NOT_a", NOT(a),
"AND", AND(a, b), "OR", OR(a, b))
0 0 | NOT_a 1 AND 0 OR 0
0 1 | NOT_a 1 AND 0 OR 1
1 0 | NOT_a 0 AND 0 OR 1
1 1 | NOT_a 0 AND 1 OR 1
4 · Combinational logic: half & full adders advanced
At some point everyone wonders: how does a computer actually add? It has no notion of numbers, no fingers to count on — just gates that flip voltages. The honest, slightly astonishing answer is that a computer adds the exact same way a schoolchild does long addition: it adds one column at a time and carries the overflow to the next column. The only difference is the columns are bits, and the "carry the one" is a wire. In this section you will build that adder out of the gates you already have, and by the end you'll have assembled the arithmetic core of a real CPU from nothing but XOR and AND.
First, the word that gates this whole section: combinational. It means the circuit has no memory — the output depends only on the inputs sitting on the wires right now, never on what happened a moment ago. Give it the same inputs and you always get the same outputs, instantly, like a vending machine that dispenses the same snack for the same button every time. (Contrast that with sequential logic in section 6, which remembers — that distinction is the hinge the rest of the lesson turns on.) Addition is combinational because the sum of two bits depends only on those two bits, full stop.
Now recall single-column addition: 1 + 1 is 2, which in binary is 10 — you write down 0 and carry a 1. So adding two bits needs two outputs: the sum bit you keep, and the carry bit you pass left. Here's the beautiful part, and the reason XOR mattered earlier: the sum bit is 1 exactly when the inputs differ (that's XOR — 0+1 or 1+0 gives sum 1), and the carry is 1 exactly when both are 1 (that's AND). Two gates, and you've built a half adder. The common confusion is why it's only "half": it can't accept a carry coming in from a lower column, so it's useless past the first bit. Bolt on that third input and you get a full adder, and chaining full adders — each one's carry-out feeding the next one's carry-in, exactly like carrying the one leftward — gives a ripple-carry adder that adds full multi-bit numbers. That is the very binary arithmetic from df6, now built from this lesson's gates.
Combinational logic has no memory: outputs depend only on current inputs. The classic example is binary addition. A half adder adds two bits and outputs a sum and a carry: sum = A XOR B, carry = A AND B. A full adder also takes a carry-in, so you can chain them into a ripple-carry adder for multi-bit numbers — this is arithmetic in df6, built from the gates in this lesson.
Read it left→right: the two input bits enter on the left and split into the two gates that produce the half adder's two outputs.
- The green box on the left is the pair of input bits
AandB— the two bits you want to add. - The blue box in the middle feeds those bits through XOR to give the sum bit, which is
1exactly when the two inputs differ. - The amber box on the right feeds the same bits through AND to give the carry bit, which is
1only when both inputs are1.
In short: Two bits in, two gates out — XOR makes the sum, AND makes the carry, and that pair is the entire half adder.
adders.pydef half_adder(a, b):
return (a ^ b, a & b) # (sum, carry)
def full_adder(a, b, cin):
s1, c1 = half_adder(a, b)
s2, c2 = half_adder(s1, cin)
return (s2, c1 | c2) # (sum, carry_out)
def ripple_add(x, y, bits=4):
"add two ints via chained full adders; return (result, overflow)"
carry, out = 0, 0
for i in range(bits):
a = (x >> i) & 1
b = (y >> i) & 1
s, carry = full_adder(a, b, carry)
out |= (s << i)
return out, carry # carry = overflow out of top bit
print("half_adder(1,1) =", half_adder(1, 1)) # (0, 1)
print("full_adder(1,1,1) =", full_adder(1, 1, 1)) # (1, 1)
print("7 + 6 (4-bit) ->", ripple_add(7, 6)) # 13 fits, no overflow
print("9 + 8 (4-bit) ->", ripple_add(9, 8)) # 17 > 15 -> wraps + carry
half_adder(1,1) = (0, 1)
full_adder(1,1,1) = (1, 1)
7 + 6 (4-bit) -> (13, 0)
9 + 8 (4-bit) -> (1, 1)
9 + 8 in 4 bits is 17, which needs 5 bits — the low 4 bits are 0001 and the carry-out is 1, the hardware overflow flag from df6.5 · Minimizing with Karnaugh maps advanced
Picture two circuits that do the identical job — same truth table, same outputs on every input — but one uses twelve gates and the other uses two. The twelve-gate version is what you get by transcribing a truth table naively; the two-gate version is what a good engineer ships. That gap is money, heat, and nanoseconds: fewer gates means a cheaper chip, less power drawn, and a shorter path for the signal to travel. Minimization is the craft of getting from the twelve to the two without changing what the circuit means, and the Karnaugh map is the friendliest tool for doing it by eye.
You can minimize with pure algebra — grinding the laws from section 1 until nothing simplifies — but that's error-prone and easy to give up on too early. The K-map is the same job turned into a picture, and it exploits one gorgeous trick: it lays the truth table out so that physically adjacent cells differ by exactly one variable. That ordering is called Gray code (00, 01, 11, 10 — notice only one bit flips between neighbours, unlike the natural 00, 01, 10, 11). The whole point of that layout is that when two adjacent cells are both 1, the variable that changed between them cannot matter to the output, so you cross it out. Grouping is just "spot the variable that doesn't matter, and delete it."
The mental model is a printed map where you draw rings around clusters of 1s: the bigger the ring, the more variables vanish, so you always circle the largest groups you can, and groups must be rectangles sized in powers of two (1, 2, 4, 8…). The single most common mistake beginners make is forgetting that the map wraps around — the left edge is adjacent to the right edge, and the top to the bottom, like a globe — so a group can straddle the seams; miss that and you'll circle two small groups where one big one was hiding. The other trap is circling a group whose size isn't a power of two, which simply doesn't correspond to any valid algebraic simplification. In the worked example below, watch how the two columns where C=1 form one clean group of four, and both A and B change inside it — so both drop out and a four-term expression collapses to the single wire F = C.
A Karnaugh map (K-map) is a visual way to minimise a Boolean function — fewer terms means fewer gates. You arrange the truth table so adjacent cells differ by one variable (Gray code order: 00, 01, 11, 10), then circle groups of 1s in powers of two. Each group drops the variable that changes across it.
B differs between them, B can't affect the output — so it drops, and two product terms fuse into one.
Read it left→right: two neighbouring map cells on the left, the rule applied in the middle, the shrunken term on the right.
- The two green cells are adjacent squares of the K-map that both hold a
1. One stands forA·¬B, the other forA·B— they sit side-by-side precisely because Gray-code ordering puts cells that differ by one variable next to each other. - The amber label names what changed between them:
Ais1in both, butBflips from0to1. Because the output is1whetherBis0or1,Bsimply doesn't matter here. - The blue box on the right is the payoff: the variable that changed drops out, so
A·¬B + A·Bcollapses to justA. - The line underneath scales the rule: a group of 2 removes one variable, a group of 4 removes two — so you always ring the largest power-of-two block you can find.
In short: Circle adjacent 1s; whichever variable changes inside the circle vanishes — bigger circles delete more variables and buy fewer gates.
Worked example. Function F(A,B,C) is 1 for these minterms: A=0,B=0,C=1; A=0,B=1,C=1; A=1,B=1,C=1; A=1,B=0,C=1 — i.e. whenever C=1. The K-map (rows A, columns BC in Gray order):
| A \ BC | 00 | 01 | 11 | 10 |
|---|---|---|---|---|
| 0 | 0 | 1 | 1 | 0 |
| 1 | 0 | 1 | 1 | 0 |
Both 1-columns are exactly the C=1 columns (01 and 11) across both rows — one group of four cells. A and B both change inside the group, so they drop out. The minimised function is simply F = C. K-maps turn a 4-term sum-of-products into a single wire.
kmap.py# original sum-of-products vs the minimized form F = C
def F_full(A, B, C):
return (((1-A) & (1-B) & C) | ((1-A) & B & C) |
(A & B & C) | (A & (1-B) & C))
def F_min(A, B, C):
return C
same = all(F_full(a, b, c) == F_min(a, b, c)
for a in (0, 1) for b in (0, 1) for c in (0, 1))
print("minimized form matches on all 8 rows:", same)
minimized form matches on all 8 rows: True
6 · Sequential logic: memory, clocks & state professional
Every circuit so far has had amnesia. The adder, the gates, the K-map — feed them inputs and they answer, but they never remember the last answer. That's fine for arithmetic, but it can't build a computer: a CPU has to hold a running total, keep a program counter, know which instruction is next. Something has to store a bit and keep storing it after the inputs go away. The question that opens this section is deceptively deep: how do you make a circuit remember, using only gates that forget?
The answer is one of the most elegant ideas in all of engineering: feedback. Take a gate's output and wire it back into its own input. Now the circuit's next state depends on its current state, and a value can chase its own tail around the loop, holding steady with no outside help. Cross-couple two NOR gates this way and you get an SR latch — one bit of genuine memory built from pure combinational parts. The everyday analogy is a seesaw that's been pushed fully to one side: it stays there, resting against the ground, until something actively shoves it the other way. The latch "rests" holding a 0 or a 1, and only a Set or Reset signal tips it over. Left alone, it remembers.
Raw feedback, though, is twitchy — the moment inputs jitter, the latch can react at unpredictable instants, and a big circuit full of latches updating whenever they please is a race-condition nightmare. The fix is the clock: a steady heartbeat (a square wave ticking millions or billions of times a second) that says "now, and only now, everyone update." A flip-flop is a latch disciplined to change only on the clock's edge — the instant it ticks — so the entire machine advances in lockstep, one clean step per tick. The mistake to avoid is imagining the clock stores anything; it doesn't, it only decides when storage happens. Bank a row of flip-flops together and you have a register (df8); the pattern of combinational logic computing the next value, plus registers holding the current value, plus a clock deciding when to swap them, is the template for every stateful machine — a counter, a traffic light, a CPU. This is exactly where state enters hardware, and it's why the model below is a state machine, not a wiring diagram.
Combinational logic forgets instantly. To remember a bit you need feedback. Cross-couple two NOR gates and you get an SR latch — the simplest 1-bit memory. A clock paces updates; a flip-flop is a latch that only changes on a clock edge. Registers (df8) are just banks of flip-flops. This is where state enters hardware.
Read it top→bottom: each arrow is the next stage in turning forgetful gates into a clocked bit of memory.
- The green box at the top is the
S,Rinputs — the set/reset signals that tell the circuit which value to store. - The blue box is the cross-coupled NOR gates: wiring each gate's output back into the other's input creates the feedback loop that lets a value hold itself.
- The purple box is the payoff of that feedback — a stable Q, one bit held steady with no further input.
- The amber box adds the clock edge: the timing signal that says when an update is allowed to happen.
- The red box at the bottom is the disciplined result — a flip-flop that latches on that clock edge, the single register bit a CPU is built from.
In short: Feedback holds a bit, the clock decides when it changes — together they turn combinational gates into one bit of memory.
We can model the latch's behaviour as a state machine in software (this is a behavioural model, not a gate-level circuit — real latches are analog feedback):
latch.pyclass SRLatch:
def __init__(self): self.Q = 0 # stored bit
def step(self, S, R):
if S and R:
raise ValueError("S=R=1 is invalid for an SR latch")
if S: self.Q = 1 # set
elif R: self.Q = 0 # reset
# else: hold previous Q (this is the "memory")
return self.Q
l = SRLatch()
print("set ->", l.step(1, 0))
print("hold ->", l.step(0, 0)) # remembers 1 with no input
print("reset ->", l.step(0, 1))
print("hold ->", l.step(0, 0)) # remembers 0
set -> 1
hold -> 1
reset -> 0
hold -> 0
✓ Checkpoint — you can move on when you can…
- Apply De Morgan and absorption to simplify a Boolean expression.
- Fill in the truth table for AND/OR/NAND/NOR/XOR from memory.
- Build NOT, AND and OR using only NAND gates.
- Trace a full adder and chain adders to add two 4-bit numbers.
- Group 1s on a K-map and read off the minimized expression.
- Explain how feedback + a clock turns gates into 1 bit of memory.
Knowledge check check yourself
Using a K-map or the laws, minimize F = A·B + A·¬B.
Show answer
F = A·(B + ¬B) = A·1 = A. On a K-map the two minterms sit side-by-side in the A=1 row and B changes across them, so B drops out, leaving F = A.Why is a single NAND gate enough to build an entire computer's logic?
Show answer
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Reading and writing truth tables is the first fluency check for any digital-logic work.
Your task: Write a function that prints the full truth table for a two-input XOR gate.
Requirements:
- Define XOR with
a ^ b - Iterate all four (a, b) combinations
- Print each row as
a b -> result
💡 Hint: XOR is 1 exactly when the two inputs differ.
Show solution
def XOR(a, b): return a ^ b
for a in (0, 1):
for b in (0, 1):
print(a, b, '->', XOR(a, b))
# 0 0 -> 0 / 0 1 -> 1 / 1 0 -> 1 / 1 1 -> 0XOR outputs 1 on the two rows where the inputs disagree — the basis of the half-adder sum bit.
Context: Proving a law by testing every input row is the discrete-math tool you fall back on when algebra feels risky.
Your task: Prove A + (A·B) = A holds for all inputs by checking the truth table in code.
Requirements:
- Compute the left side
A | (A & B)for all (A, B) - Compare against the right side
A - Report a single boolean: does it hold for every row?
💡 Hint: There are only four rows — exhaustion is a complete proof for a fixed number of variables.
Show solution
ok = all((A | (A & B)) == A
for A in (0, 1) for B in (0, 1))
print('absorption holds:', ok) # TrueFor two variables the truth table has four rows; agreeing on all of them is the proof. Intuitively, if A is 1 the OR is 1; if A is 0 then A·B is 0, so the OR is 0 — either way it equals A.
Context: Since NAND is universal, expressing a derived gate purely in NAND cements the concept and mirrors how silicon is actually laid out.
Your task: Implement XOR using only NAND calls, then verify it against Python's ^.
Requirements:
- Define a single
NAND(a, b)primitive - Compose XOR from NANDs only (no &, |, ^, or 1-x)
- Verify equality with
a ^ bacross all four rows
💡 Hint: The classic 4-NAND XOR: t = NAND(a,b); then XOR = NAND(NAND(a,t), NAND(b,t)).
Show solution
def NAND(a, b): return 1 - (a & b)
def XOR(a, b):
t = NAND(a, b)
return NAND(NAND(a, t), NAND(b, t))
ok = all(XOR(a, b) == (a ^ b)
for a in (0, 1) for b in (0, 1))
print('XOR-from-NAND correct:', ok) # TrueFour NAND gates realise XOR. (The 1 - (a & b) inside NAND is the gate's own definition, not a shortcut in the XOR itself.)
Context: A parameterised adder is the arithmetic heart of an ALU; making it width-configurable shows you understand carry propagation.
Your task: Generalise the ripple-carry adder to any bit width and confirm it detects unsigned overflow.
Requirements:
- Chain full adders across
bitspositions - Return both the wrapped result and the final carry-out
- Show
13 + 3at width 4 overflows (carry-out 1) - Show the same addition at width 5 does not overflow
💡 Hint: The carry-out of the most-significant full adder is exactly the unsigned overflow flag.
Show solution
def full_adder(a, b, cin):
s = a ^ b ^ cin
cout = (a & b) | (cin & (a ^ b))
return s, cout
def add(x, y, bits):
carry, out = 0, 0
for i in range(bits):
s, carry = full_adder((x>>i)&1, (y>>i)&1, carry)
out |= s << i
return out, carry
print(add(13, 3, 4)) # (0, 1) -> 16 wraps to 0, carry set
print(add(13, 3, 5)) # (16, 0) -> fits in 5 bitsSame circuit, different width. The carry-out tells you whether the true sum exceeded the representable range — the hardware overflow flag.
Context: Engineers must trust algebraic minimization; cross-checking the simplified form against the original by brute force is the professional safety net.
Your task: Minimize F = A·B·C + A·B·¬C + A·¬B·C and prove your minimized form is equivalent.
Requirements:
- Minimize by algebra or K-map to the simplest sum-of-products
- Implement both the original and the minimized form
- Assert they agree on all 8 input rows
- State the gate-count saving
💡 Hint: Group the first two terms (C drops) and note the third shares A·C; you should reach F = A·B + A·C = A·(B + C).
Show solution
def F_full(A, B, C):
return (A&B&C) | (A&B&(1-C)) | (A&(1-B)&C)
def F_min(A, B, C):
return A & (B | C)
assert all(F_full(a,b,c) == F_min(a,b,c)
for a in (0,1) for b in (0,1) for c in (0,1))
print('F = A(B + C) verified')A·B·C + A·B·¬C = A·B (C cancels); combined with A·¬B·C and factoring A gives A·(B + C) — three 3-input AND terms collapse to one AND and one OR.
Context: Real embedded systems are finite state machines driven by a clock; modelling one in software is how you validate the logic before it ever touches hardware.
Your task: Model a traffic light as a clocked finite state machine (GREEN → YELLOW → RED → GREEN) and run it for several clock ticks.
Requirements:
- Represent state explicitly and transition only on a
tick()(the clock edge) - Cycle GREEN → YELLOW → RED → GREEN deterministically
- Expose current state as output (Moore machine: output depends only on state)
- Run 6 ticks and print the sequence
- Note this is a behavioural model of clocked sequential logic, not a gate netlist
💡 Hint: A dict mapping each state to its successor makes the transition table explicit and testable.
Show solution
class TrafficLight:
NEXT = {'GREEN': 'YELLOW', 'YELLOW': 'RED', 'RED': 'GREEN'}
def __init__(self): self.state = 'RED' # stored in flip-flops
def tick(self): # clock edge
self.state = self.NEXT[self.state]
return self.state
fsm = TrafficLight()
print([fsm.tick() for _ in range(6)])
# ['GREEN', 'YELLOW', 'RED', 'GREEN', 'YELLOW', 'RED']The current state lives in flip-flops (registers); the NEXT table is combinational logic; the clock (tick) decides when the register updates. That combinational-plus-register-plus-clock pattern is every sequential circuit, from this controller up to a full CPU. This is a behavioural model — real hardware realises NEXT as gates and the state as clocked flip-flops.