CrunchAcademy · K-12

Math for Data Science · MDS-7

Discrete Mathematics

The math that computers actually run on — logic, counting, graphs, and number theory, every idea grounded in Python code.

Discrete mathematics is the foundation of computer science: it is the math of logic, counting, structure, and algorithms rather than continuous change. This course builds the subject from propositional logic through graph theory, number theory, and asymptotic analysis, pairing every concept with a hands-on Python implementation. By the end you can write rigorous proofs, count and model with combinatorics and graphs, reason about modular arithmetic for hashing and crypto, and analyze how fast an algorithm grows.

PrereqsMDS-1 Math Foundations for Data Science
Code inPython
7Units
49Lessons
119Worked examples

Course Outline

MDS-7 — units

Every lesson is self-contained: a full explanation, worked step-by-step examples (in Python), common mistakes, a try-it-yourself, and an interactive quiz. Jump to a unit:

Weeks 1-2 Unit 1: Logic & Proofs
evaluate-propositionsbuild-truth-tablesanalyze-conditionalsuse-quantifiersapply-inference-ruleswrite-proofsprove-by-induction
Lecture
Propositions, truth values, and logical connectives

Logic begins with propositions — statements that are definitively true or false — combined by connectives into compound claims.

A proposition is a declarative statement with a single, unambiguous truth value: True or False. 'It is raining' is a proposition; 'close the door' is not. Logical connectives build compound propositions from simpler ones: NOT (negation, flips the value), AND (conjunction, true only when both parts are true), OR (disjunction, true when at least one part is true), implication (p implies q), and biconditional (p if and only if q). This matters in CS because every if-statement, database query filter, and circuit reduces to a Boolean expression over propositions. In Python the connectives map directly to not, and, or — so reasoning about logic is reasoning about the code you write every day.

Worked Example 1

Problem. Let p = 'It is sunny' and q = 'We go hiking'. Express 'It is sunny and we do not go hiking' symbolically and evaluate it when p is True and q is False.

  1. Translate: 'and' is conjunction, 'do not go hiking' is NOT q, so the expression is p AND (NOT q).
  2. Substitute values: p = True, q = False, so NOT q = True.
  3. Evaluate the AND: True AND True. In Python: p, q = True, False; result = p and (not q).

Answer. p AND (NOT q) evaluates to True.

Worked Example 2

Problem. Evaluate the disjunction (p OR q) for all four combinations of p and q.

  1. List combinations: (T,T), (T,F), (F,T), (F,F).
  2. OR is true when at least one operand is true: (T,T)=T, (T,F)=T, (F,T)=T, (F,F)=F.
  3. Verify in Python: [a or b for a in (True,False) for b in (True,False)] gives [True, True, True, False].

Answer. p OR q is True, True, True, False for the four rows; only F OR F is False.

Common mistakes
  • Treating inclusive OR as exclusive: in logic 'p OR q' is true when BOTH are true. Use XOR only when you mean 'one but not both'.
  • Calling a question or command a proposition: only declarative statements with a definite truth value qualify.
✎ Try it yourself

Problem. Let p = 'The number is even' and q = 'The number is positive'. Write 'the number is even or not positive' symbolically and evaluate it for the number -4.

Solution. Symbolically: p OR (NOT q). For -4: it is even so p = True; it is not positive so q = False, making NOT q = True. The expression is True OR True = True. (Even one true disjunct suffices.) Answer: True.

Truth tables, tautologies, and logical equivalence

A truth table exhaustively lists a compound statement's value for every input combination, revealing tautologies, contradictions, and equivalences.

A truth table enumerates all 2^n combinations of n propositional variables and computes the resulting truth value of a compound statement in each row. A tautology is a statement that is True in every row (e.g., p OR NOT p); a contradiction is False in every row. Two statements are logically equivalent when their truth tables are identical column-for-column — they are interchangeable in any context. This is the foundation of program optimization and circuit simplification: if two expressions are equivalent, you can replace the costlier one with the cheaper one. In Python, itertools.product(*[(True,False)]*n) generates every row, so you can verify equivalences mechanically rather than by hand.

Worked Example 1

Problem. Build the truth table for p implies q.

  1. Rows: (T,T),(T,F),(F,T),(F,F). Implication is False only when p is True and q is False.
  2. (T,T)=T, (T,F)=F, (F,T)=T, (F,F)=T.
  3. In Python implication is (not p) or q: [(not p) or q for p in (True,False) for q in (True,False)] = [True, False, True, True].

Answer. p implies q is True, False, True, True down the rows.

Worked Example 2

Problem. Show that 'p implies q' is logically equivalent to 'NOT p OR q'.

  1. Truth table for p implies q: T,F,T,T (from Example 1).
  2. Truth table for NOT p OR q: (T,T): F or T = T; (T,F): F or F = F; (F,T): T or T = T; (F,F): T or F = T -> T,F,T,T.
  3. Columns match in all four rows, so the statements are equivalent. Verify: all(((not p) or q) == ((not p) or q) for ...) trivially, but compare against a separate implication function to confirm.

Answer. The two columns are identical (T,F,T,T), so p implies q is equivalent to NOT p OR q.

Worked Example 3

Problem. Is (p AND q) OR (NOT p) a tautology?

  1. Evaluate each row. (T,T): (T) or F = T. (T,F): (F) or F = F.
  2. Already a False row appears at (T,F), so it cannot be a tautology.
  3. (F,T): F or T = T; (F,F): F or T = T. Column: T,F,T,T.

Answer. No — it is False when p is True and q is False, so it is not a tautology.

Common mistakes
  • Forgetting that an implication with a false hypothesis is vacuously True — F implies anything is True.
  • Concluding two statements are equivalent from one matching row; equivalence requires ALL rows to match.
✎ Try it yourself

Problem. Use a truth table to decide whether NOT(p AND q) is equivalent to NOT p OR NOT q.

Solution. NOT(p AND q): (T,T): not T = F; (T,F): not F = T; (F,T): not F = T; (F,F): not F = T -> F,T,T,T. NOT p OR NOT q: (T,T): F or F = F; (T,F): F or T = T; (F,T): T or F = T; (F,F): T or T = T -> F,T,T,T. The columns match in all four rows, so they are equivalent — this is De Morgan's law. Answer: equivalent.

Conditional, biconditional, converse, and contrapositive

From a single implication you can form its converse, inverse, and contrapositive — only one of which is always equivalent to the original.

Given the conditional p implies q, three related statements arise: the converse q implies p, the inverse NOT p implies NOT q, and the contrapositive NOT q implies NOT p. A crucial fact is that an implication is logically equivalent to its contrapositive but NOT to its converse or inverse. The biconditional p if and only if q is true exactly when p and q share the same truth value, and equals (p implies q) AND (q implies p). This matters in CS because proofs and program logic often replace a hard-to-prove implication with its easier contrapositive, and because confusing a statement with its converse is one of the most common reasoning bugs (affirming the consequent). Knowing which forms are equivalent prevents invalid inferences.

Worked Example 1

Problem. For 'If n is divisible by 4, then n is even,' write the converse and contrapositive.

  1. Let p = 'n divisible by 4', q = 'n even'. Original: p implies q.
  2. Converse swaps: q implies p = 'If n is even, then n is divisible by 4' (false: 6 is even, not divisible by 4).
  3. Contrapositive negates and swaps: NOT q implies NOT p = 'If n is not even, then n is not divisible by 4' (true, like the original).

Answer. Converse: 'If n is even then n is divisible by 4' (false). Contrapositive: 'If n is odd then n is not divisible by 4' (true).

Worked Example 2

Problem. Verify by truth table that p implies q equals its contrapositive NOT q implies NOT p.

  1. p implies q: T,F,T,T (rows (T,T),(T,F),(F,T),(F,F)).
  2. NOT q implies NOT p = q OR NOT p: (T,T): T or F = T; (T,F): F or F = F; (F,T): T or T = T; (F,F): T or T = T -> T,F,T,T.
  3. Columns match -> equivalent. Python: impl=lambda p,q:(not p) or q; contra=lambda p,q:(not (not q)) or (not p); all rows equal.

Answer. Both columns are T,F,T,T, confirming the implication equals its contrapositive.

Common mistakes
  • Assuming the converse follows from the original (affirming the consequent) — 'p implies q' does not give 'q implies p'.
  • Forgetting the biconditional requires BOTH directions; p implies q alone is not p if and only if q.
✎ Try it yourself

Problem. Write the contrapositive of 'If a graph is a tree, then it has no cycles,' and state whether it is equivalent to the original.

Solution. Let p = 'graph is a tree', q = 'graph has no cycles'. Contrapositive: NOT q implies NOT p = 'If a graph has a cycle, then it is not a tree.' Because an implication is always logically equivalent to its contrapositive, this statement has the same truth value as the original. Answer: contrapositive is 'If a graph has a cycle then it is not a tree'; yes, it is equivalent.

Predicates and quantifiers (for all, there exists)

Predicates are propositions with variables; quantifiers bind those variables to make definite claims over a domain.

A predicate P(x) is a statement whose truth depends on the value of x — 'x > 5' is not true or false until x is given. Quantifiers turn predicates into propositions: the universal quantifier 'for all x, P(x)' claims P holds for every element of the domain, while the existential 'there exists x, P(x)' claims at least one element satisfies P. Negation follows De Morgan-style rules: NOT(for all x, P(x)) becomes 'there exists x, NOT P(x)', and NOT(there exists x, P(x)) becomes 'for all x, NOT P(x)'. Order matters when quantifiers nest. This is central to specifying program correctness ('for all inputs the output is sorted'), database queries, and formal verification. In Python, all() and any() over a domain directly implement universal and existential quantification.

Worked Example 1

Problem. Over the domain {2, 4, 6, 8}, evaluate 'for all x, x is even' and 'there exists x, x > 7'.

  1. Universal: check every element is even — 2,4,6,8 all even, so the claim is True. Python: all(x % 2 == 0 for x in [2,4,6,8]) -> True.
  2. Existential: is any element > 7? 8 > 7, so True. Python: any(x > 7 for x in [2,4,6,8]) -> True.
  3. Both quantified statements are now definite propositions with truth values.

Answer. 'For all x even' is True; 'there exists x > 7' is True.

Worked Example 2

Problem. Negate 'For all integers n, n^2 >= n' and decide if the negation is true.

  1. Negation of a universal: 'There exists an integer n such that n^2 < n.'
  2. Search for a counterexample. For 0 <= n: n^2 >= n holds. Try n between 0 and 1 — but integers only. Check negatives: n=-1 gives 1 >= -1, holds.
  3. Actually for all integers n^2 >= n, so the negation 'there exists n with n^2 < n' is False (the original is True).

Answer. Negation: 'There exists an integer n with n^2 < n.' This negation is False (the original universal statement is True over the integers).

Worked Example 3

Problem. Distinguish 'for all x there exists y: y > x' from 'there exists y for all x: y > x' over the integers.

  1. First: for every integer x, pick y = x + 1; then y > x always. So the statement is True.
  2. Second: claims one fixed y exceeds every x. No integer is larger than all integers, so it is False.
  3. Swapping quantifier order changed the truth value — order is significant.

Answer. 'For all x, there exists y > x' is True; 'there exists y, for all x, y > x' is False. Quantifier order matters.

Common mistakes
  • Swapping quantifier order and assuming the meaning is unchanged — 'for all/there exists' differs from 'there exists/for all'.
  • Negating 'for all x, P(x)' as 'for all x, NOT P(x)' instead of the correct 'there exists x, NOT P(x)'.
✎ Try it yourself

Problem. Over the domain of all real numbers, write the negation of 'There exists x such that x^2 = -1' and say whether the negation is true.

Solution. Negation of an existential is a universal with the predicate negated: 'For all real x, x^2 is not equal to -1.' Since a real square is always >= 0, no real x satisfies x^2 = -1, so the original existential is False and its negation is True. Answer: 'For all real x, x^2 != -1'; the negation is True.

Rules of inference and valid arguments

Valid arguments derive conclusions from premises using truth-preserving rules like modus ponens, independent of the actual facts.

An argument is a set of premises and a conclusion; it is valid when the conclusion is true in every case where all premises are true — validity is about form, not content. Core rules of inference include modus ponens (from p and p implies q, conclude q), modus tollens (from NOT q and p implies q, conclude NOT p), hypothetical syllogism (chain implications), and disjunctive syllogism (from p OR q and NOT p, conclude q). Recognizing valid forms — and the invalid look-alikes like affirming the consequent — is essential for writing correct proofs, designing decision logic, and avoiding fallacies in algorithms and AI reasoning. A valid argument with true premises is called sound.

Worked Example 1

Problem. Premises: 'If it rains, the game is cancelled' and 'It is raining.' What follows?

  1. Identify form: p implies q, with p. Let p = 'it rains', q = 'game cancelled'.
  2. Apply modus ponens: p and (p implies q) yield q.
  3. Conclusion: 'The game is cancelled.' This is valid regardless of weather facts.

Answer. By modus ponens, the game is cancelled.

Worked Example 2

Problem. Premises: 'If the file exists, the program runs' and 'The program did not run.' What follows?

  1. Form: p implies q, with NOT q. p = 'file exists', q = 'program runs'.
  2. Apply modus tollens: from p implies q and NOT q, conclude NOT p.
  3. Conclusion: 'The file does not exist.'

Answer. By modus tollens, the file does not exist.

Worked Example 3

Problem. Is this valid? 'If it rains, the streets are wet. The streets are wet. Therefore it rained.'

  1. Form: p implies q, q, therefore p — this is affirming the consequent.
  2. Counterexample: a street cleaner could wet the streets, so q can be true while p is false.
  3. Truth table check: row p=F, q=T makes both premises true but the conclusion p false.

Answer. Invalid — it commits the fallacy of affirming the consequent.

Common mistakes
  • Affirming the consequent: from p implies q and q, you cannot conclude p.
  • Denying the antecedent: from p implies q and NOT p, you cannot conclude NOT q.
✎ Try it yourself

Problem. Given 'If a number is prime and greater than 2, it is odd' and 'n is prime and greater than 2,' what valid conclusion follows, and by which rule?

Solution. Let p = 'n is prime and greater than 2' and q = 'n is odd'. The premises are p implies q and p. By modus ponens we conclude q. Answer: 'n is odd', by modus ponens.

Proof techniques: direct, contrapositive, and contradiction

These three methods are the workhorses for proving implications and existence claims rigorously.

A direct proof assumes the hypothesis p and derives the conclusion q through a chain of valid steps. A proof by contrapositive instead proves the equivalent statement NOT q implies NOT p, which is often easier (handy when the hypothesis is hard to use directly). A proof by contradiction assumes the statement is false, then derives an impossibility, forcing the original to be true — this is how we prove the square root of 2 is irrational and that there are infinitely many primes. Choosing the right technique is a core skill: it makes the difference between a clean proof and a stuck one. These methods underpin algorithm correctness arguments and the formal reasoning behind data-structure invariants.

Worked Example 1

Problem. Prove directly: if n is even, then n^2 is even.

  1. Assume the hypothesis: n is even, so n = 2k for some integer k.
  2. Compute n^2 = (2k)^2 = 4k^2 = 2(2k^2).
  3. Since 2k^2 is an integer, n^2 is 2 times an integer, hence even.

Answer. n^2 = 2(2k^2) is even, completing the direct proof.

Worked Example 2

Problem. Prove by contrapositive: if n^2 is even, then n is even.

  1. Contrapositive: if n is odd, then n^2 is odd. Prove this instead.
  2. Assume n is odd: n = 2k + 1. Then n^2 = 4k^2 + 4k + 1 = 2(2k^2 + 2k) + 1.
  3. This is 2(integer) + 1, hence odd. The contrapositive holds, so the original does too.

Answer. Since n odd forces n^2 odd, by contrapositive n^2 even forces n even.

Worked Example 3

Problem. Prove by contradiction that the square root of 2 is irrational.

  1. Assume the opposite: sqrt(2) = a/b in lowest terms (a, b share no common factor).
  2. Then 2 = a^2/b^2, so a^2 = 2 b^2, meaning a^2 is even, so a is even: a = 2c.
  3. Substitute: 4c^2 = 2 b^2, so b^2 = 2 c^2, making b even too. But then a and b share factor 2 — contradicting lowest terms.

Answer. The contradiction shows no such a/b exists, so sqrt(2) is irrational.

Common mistakes
  • Confusing contrapositive with converse: prove NOT q implies NOT p, not q implies p.
  • In contradiction proofs, forgetting to actually reach an impossibility — you must derive a clear false statement.
✎ Try it yourself

Problem. Prove that if 3n + 2 is odd, then n is odd. Choose and justify a technique.

Solution. Use the contrapositive: prove 'if n is even, then 3n + 2 is even.' Assume n is even, n = 2k. Then 3n + 2 = 6k + 2 = 2(3k + 1), which is 2 times an integer, hence even. This proves the contrapositive, so the original statement holds: if 3n + 2 is odd then n is odd. Contrapositive is chosen because reasoning from 'n even' is concrete, whereas using the odd hypothesis directly is awkward.

Mathematical induction

Induction proves a statement for all integers from a base value by showing it survives the step from n to n+1.

Mathematical induction proves a statement P(n) holds for all integers n >= n0 in two parts: the base case verifies P(n0) directly, and the inductive step assumes P(k) (the inductive hypothesis) and proves P(k+1). Together they create a domino effect covering every n. Strong induction allows assuming P holds for all values up to k, useful for recursive definitions. Induction is the mathematical mirror of recursion and loops in programming: it is how we prove loop invariants, recurrence-based running times, and that recursive algorithms terminate correctly. Mastering the precise structure — clearly state the hypothesis and show exactly where you use it — separates valid proofs from hand-waving.

Worked Example 1

Problem. Prove by induction that 1 + 2 + ... + n = n(n+1)/2 for all n >= 1.

  1. Base case n=1: left side = 1, right side = 1*2/2 = 1. Holds.
  2. Inductive hypothesis: assume 1 + ... + k = k(k+1)/2.
  3. Step: 1 + ... + k + (k+1) = k(k+1)/2 + (k+1) = (k+1)(k+2)/2, which is the formula at n = k+1.

Answer. Base and step hold, so the formula is true for all n >= 1.

Worked Example 2

Problem. Prove that 2^n > n for all n >= 1.

  1. Base n=1: 2^1 = 2 > 1. Holds.
  2. Hypothesis: assume 2^k > k.
  3. Step: 2^(k+1) = 2 * 2^k > 2k = k + k >= k + 1 (since k >= 1), so 2^(k+1) > k+1.

Answer. By induction, 2^n > n for all n >= 1.

Worked Example 3

Problem. Prove that n^3 - n is divisible by 3 for all n >= 0.

  1. Base n=0: 0 - 0 = 0, divisible by 3. Holds.
  2. Hypothesis: assume k^3 - k = 3m for some integer m.
  3. Step: (k+1)^3 - (k+1) = k^3 + 3k^2 + 3k + 1 - k - 1 = (k^3 - k) + 3(k^2 + k) = 3m + 3(k^2 + k) = 3(m + k^2 + k), divisible by 3.

Answer. Both parts hold, so 3 divides n^3 - n for all n >= 0.

Common mistakes
  • Skipping or mis-stating the base case — without it the dominoes never start falling.
  • Failing to actually use the inductive hypothesis in the step; if you never invoke P(k), it is not an induction proof.
✎ Try it yourself

Problem. Prove by induction that 1 + 3 + 5 + ... + (2n - 1) = n^2 for all n >= 1.

Solution. Base case n=1: left = 1, right = 1^2 = 1. Holds. Inductive hypothesis: assume 1 + 3 + ... + (2k - 1) = k^2. Step: add the next odd term (2(k+1) - 1) = 2k + 1 to both sides: 1 + 3 + ... + (2k - 1) + (2k + 1) = k^2 + 2k + 1 = (k + 1)^2, which is the statement at n = k+1. Base and step hold, so the sum of the first n odd numbers equals n^2 for all n >= 1.

Key terms
  • Proposition — a declarative statement that is either true or false, but not both.
  • Logical connective — an operator such as AND, OR, NOT, implication, or biconditional that combines propositions.
  • Truth table — a table listing the truth value of a compound statement for every combination of its inputs.
  • Tautology — a compound statement that is true under every assignment of truth values.
  • Logical equivalence — two statements that have the same truth value in every case, written with the equivalence symbol.
  • Contrapositive — for an implication p implies q, the equivalent statement not-q implies not-p.
  • Quantifier — 'for all' (universal) or 'there exists' (existential), which binds a variable in a predicate.
  • Mathematical induction — a proof method establishing a base case and then an inductive step that carries truth from n to n+1.
Assignment · Build a truth-table engine

Write a Python function that takes a logical expression over a few variables (using AND, OR, NOT, and implication) and prints its full truth table. Use itertools.product to enumerate every combination of True/False inputs. Then use your engine to verify a logical equivalence of your choice, such as that an implication equals its contrapositive, and confirm that De Morgan's laws hold.

Deliverable · A script that prints labeled truth tables and a short note showing two equivalences your engine confirmed.

Quiz · 5 questions
  1. 1. Which statement is logically equivalent to the implication 'p implies q'?

  2. 2. The implication 'p implies q' is false in exactly which case?

  3. 3. Which of these is a tautology?

  4. 4. What is the negation of 'For all x, P(x)'?

  5. 5. In a proof by mathematical induction, the inductive step proves that:

You'll be able to

I can translate statements into propositional and predicate logic and evaluate them with truth tables.

I can identify valid arguments and choose an appropriate proof technique.

I can prove a statement by direct proof, contradiction, and mathematical induction.

Weeks 3-4 Unit 2: Sets, Relations & Functions
use-set-notationapply-set-operationscompute-power-setanalyze-relationsclassify-equivalenceclassify-functionsreason-cardinality
Lecture
Sets, subsets, and set-builder notation

Sets are unordered collections of distinct elements; set-builder notation defines them by a membership rule.

A set is a collection of distinct objects where order and repetition do not matter — {1, 2, 3} equals {3, 1, 1, 2}. The only question a set answers is whether an element belongs (membership). A is a subset of B if every element of A is also in B. Set-builder notation defines a set by a property, like {x : x is an even integer, 0 < x < 10}. Sets are the bedrock of data science and CS: relational databases, deduplication, type systems, and probability all rest on set theory. Python's set type mirrors the math exactly — membership with 'in', subset with <=, and comprehensions {x for x in ... if ...} that read like set-builder notation.

Worked Example 1

Problem. List the set A = {x : x is a positive integer and x divides 12} in roster form.

  1. Find every positive integer dividing 12: 1, 2, 3, 4, 6, 12.
  2. Collect them into a set (order irrelevant): A = {1, 2, 3, 4, 6, 12}.
  3. In Python: A = {x for x in range(1, 13) if 12 % x == 0} -> {1, 2, 3, 4, 6, 12}.

Answer. A = {1, 2, 3, 4, 6, 12}.

Worked Example 2

Problem. Is {2, 4} a subset of {1, 2, 3, 4, 5}? Is it a proper subset?

  1. Check each element of {2, 4} lies in {1,2,3,4,5}: 2 yes, 4 yes -> it is a subset.
  2. Proper subset means subset and not equal; {2,4} != {1,2,3,4,5}, so it is proper.
  3. Python: {2,4} <= {1,2,3,4,5} -> True; {2,4} < {1,2,3,4,5} -> True (proper).

Answer. Yes, {2, 4} is a proper subset of {1, 2, 3, 4, 5}.

Common mistakes
  • Thinking repetition or order matters — {1,1,2} is the same set as {1,2}.
  • Confusing the element 2 with the set {2}: 2 is in {1,2,3} but {2} is a subset, not an element, of it.
✎ Try it yourself

Problem. Write the set of perfect squares less than 30 in both set-builder and roster form, and state how many elements it has.

Solution. Set-builder: {n^2 : n is a positive integer and n^2 < 30}. Computing squares: 1, 4, 9, 16, 25 (next is 36 >= 30). Roster: {1, 4, 9, 16, 25}. In Python: {n*n for n in range(1, 30) if n*n < 30}. It has 5 elements.

Set operations: union, intersection, difference, and complement

These four operations combine and compare sets, and obey identities like De Morgan's laws.

Union (A union B) collects everything in either set; intersection (A intersect B) keeps only shared elements; difference (A minus B) keeps elements of A not in B; complement (A') is everything in the universal set outside A. These operations satisfy algebraic identities — commutativity, associativity, distributivity, and De Morgan's laws — that mirror logical AND/OR/NOT exactly. This is why set operations power SQL joins, search filters, and feature engineering in data science. Python's set supports them directly: A | B, A & B, A - B, and complement via U - A. Recognizing the identities lets you rewrite costly queries into cheaper equivalents.

Worked Example 1

Problem. Given A = {1,2,3,4} and B = {3,4,5,6}, compute A union B, A intersect B, and A minus B.

  1. Union: every element in either: {1,2,3,4,5,6}.
  2. Intersection: elements in both: {3,4}.
  3. Difference A minus B: in A but not B: {1,2}. Python: A|B, A&B, A-B give exactly these.

Answer. A union B = {1,2,3,4,5,6}; A intersect B = {3,4}; A minus B = {1,2}.

Worked Example 2

Problem. With universe U = {1,...,8}, A = {1,2,3,4}, B = {3,4,5,6}, verify De Morgan: (A union B)' = A' intersect B'.

  1. A union B = {1,2,3,4,5,6}, so (A union B)' = U minus that = {7,8}.
  2. A' = {5,6,7,8}, B' = {1,2,7,8}; their intersection = {7,8}.
  3. Both sides equal {7,8}. Python: U=set(range(1,9)); (U-(A|B)) == (U-A)&(U-B) -> True.

Answer. Both sides equal {7,8}, confirming De Morgan's law for sets.

Common mistakes
  • Treating A minus B as symmetric: A minus B is generally not equal to B minus A.
  • Forgetting the universal set when taking a complement — A' is only defined relative to a chosen universe U.
✎ Try it yourself

Problem. With U = {1,...,10}, A = {2,4,6,8,10} (evens), B = {3,6,9} (multiples of 3), compute A intersect B and (A union B)'.

Solution. A intersect B = elements in both = {6} (only 6 is even and a multiple of 3). A union B = {2,3,4,6,8,9,10}. Its complement relative to U is U minus that = {1,5,7}. Python: A&B == {6}; (set(range(1,11)) - (A|B)) == {1,5,7}. Answers: A intersect B = {6}; (A union B)' = {1,5,7}.

Power sets, Cartesian products, and cardinality

The power set lists all subsets; the Cartesian product pairs elements; cardinality measures size.

The power set P(A) is the set of all subsets of A, including the empty set and A itself; if |A| = n then |P(A)| = 2^n, because each element is independently in or out of a subset. The Cartesian product A x B is the set of all ordered pairs (a, b) with a in A and b in B, and |A x B| = |A| * |B|. Cardinality is just the size of a set. These ideas explain combinatorial explosion (why brute-forcing all subsets is exponential), relational database tables (rows are tuples from a Cartesian product), and feature spaces in machine learning. Python's itertools.product and itertools.combinations generate these structures.

Worked Example 1

Problem. List the power set of A = {a, b} and state its cardinality.

  1. Subsets of size 0: {}; size 1: {a}, {b}; size 2: {a, b}.
  2. Power set: {{}, {a}, {b}, {a, b}}.
  3. |A| = 2, so |P(A)| = 2^2 = 4, matching the count.

Answer. P(A) = {{}, {a}, {b}, {a, b}}; cardinality 4.

Worked Example 2

Problem. For A = {1, 2} and B = {x, y, z}, list A x B and give |A x B|.

  1. Pair each a in A with each b in B: (1,x),(1,y),(1,z),(2,x),(2,y),(2,z).
  2. Count: |A| * |B| = 2 * 3 = 6.
  3. Python: list(itertools.product([1,2], ['x','y','z'])) yields the 6 pairs.

Answer. A x B = {(1,x),(1,y),(1,z),(2,x),(2,y),(2,z)}; |A x B| = 6.

Worked Example 3

Problem. How many subsets of a 10-element set contain at least one element?

  1. Total subsets = 2^10 = 1024.
  2. Subtract the one subset with no elements (the empty set): 1024 - 1.
  3. Result: 1023. Python: 2**10 - 1 = 1023.

Answer. 1023 nonempty subsets.

Common mistakes
  • Forgetting the empty set and the full set are both members of the power set.
  • Confusing |P(A)| = 2^n with n^2 — the count is exponential, not quadratic.
✎ Try it yourself

Problem. A set A has 5 elements. How many elements does P(A) have, and how many of those subsets have exactly 2 elements?

Solution. |P(A)| = 2^5 = 32 total subsets. The number of 2-element subsets is the combination 5 choose 2 = 10. In Python: 2**5 = 32 and math.comb(5, 2) = 10. Answers: 32 subsets total, 10 of which have exactly 2 elements.

Relations and their properties (reflexive, symmetric, transitive)

A relation is a set of pairs; key properties classify how it links elements.

A relation R on a set A is a subset of A x A — a set of ordered pairs declaring which elements relate. Three properties matter most: reflexive (every element relates to itself, (a,a) in R for all a), symmetric (if (a,b) in R then (b,a) in R), and transitive (if (a,b) and (b,c) in R then (a,c) in R). Relations model 'less than', 'is connected to', 'is a friend of', and database foreign keys. Testing these properties is straightforward in Python by representing R as a set of tuples and checking the defining conditions with loops or comprehensions. These properties combine to define orderings and equivalence relations.

Worked Example 1

Problem. On A = {1,2,3}, is R = {(1,1),(2,2),(3,3),(1,2),(2,1)} reflexive, symmetric, transitive?

  1. Reflexive: need (1,1),(2,2),(3,3) — all present, so reflexive.
  2. Symmetric: (1,2) present and (2,1) present; no other off-diagonal pairs -> symmetric.
  3. Transitive: (1,2) and (2,1) require (1,1) — present; (2,1) and (1,2) require (2,2) — present. No violations -> transitive.

Answer. R is reflexive, symmetric, and transitive.

Worked Example 2

Problem. Is the relation 'less than' (<) on integers reflexive? Symmetric? Transitive?

  1. Reflexive: is a < a ever true? No, so NOT reflexive.
  2. Symmetric: if a < b does b < a? No (1 < 2 but not 2 < 1), so NOT symmetric.
  3. Transitive: if a < b and b < c then a < c — yes, so transitive.

Answer. '<' is transitive only; it is neither reflexive nor symmetric.

Common mistakes
  • Assuming a relation that is symmetric and transitive must be reflexive — it can fail reflexivity if some element relates to nothing.
  • Checking transitivity only on listed pairs you happen to notice; you must check every (a,b),(b,c) chain.
✎ Try it yourself

Problem. On A = {1,2,3}, classify R = {(1,1),(2,2),(3,3),(1,2),(2,3)} for reflexive, symmetric, transitive.

Solution. Reflexive: (1,1),(2,2),(3,3) all present -> reflexive. Symmetric: (1,2) is present but (2,1) is not -> NOT symmetric. Transitive: (1,2) and (2,3) require (1,3), which is missing -> NOT transitive. In Python you would check: all((b,a) in R for (a,b) in R) returns False (symmetry fails). Answer: reflexive only.

Equivalence relations and partitions

An equivalence relation (reflexive, symmetric, transitive) splits a set into disjoint equivalence classes.

A relation that is reflexive, symmetric, AND transitive is an equivalence relation. Its defining power is that it partitions the underlying set into equivalence classes: nonempty, disjoint subsets that together cover the whole set, where every element relates exactly to the members of its own class. 'Has the same remainder mod 5', 'is the same color', and 'is in the same connected component' are equivalence relations. This duality — equivalence relations correspond one-to-one with partitions — is fundamental for grouping, clustering, hashing into buckets, and union-find data structures. In Python you can compute classes by grouping elements that relate, then verify the classes are disjoint and cover the set.

Worked Example 1

Problem. On the integers, show 'a ~ b iff a - b is divisible by 3' is an equivalence relation and list its classes.

  1. Reflexive: a - a = 0 is divisible by 3 -> yes. Symmetric: if 3 | (a-b) then 3 | (b-a) -> yes. Transitive: if 3 | (a-b) and 3 | (b-c) then 3 | (a-c) -> yes.
  2. So it is an equivalence relation; classes are the residue classes mod 3.
  3. Classes: {..,-3,0,3,..}, {..,-2,1,4,..}, {..,-1,2,5,..}, i.e. remainders 0, 1, 2.

Answer. It is an equivalence relation; the three classes are integers with remainder 0, 1, and 2 mod 3.

Worked Example 2

Problem. Partition {1,...,6} by the relation 'same parity' and give the classes.

  1. Same parity is reflexive, symmetric, transitive -> equivalence relation.
  2. Group by parity: odds {1,3,5}, evens {2,4,6}.
  3. Check partition: disjoint and union is {1,..,6}. Python: groups = {}; for x in range(1,7): groups.setdefault(x%2, set()).add(x).

Answer. Two classes: {1,3,5} and {2,4,6}.

Common mistakes
  • Producing classes that overlap — equivalence classes must be disjoint; an element belongs to exactly one.
  • Forgetting to verify all three properties before calling a relation an equivalence relation.
✎ Try it yourself

Problem. Is 'a ~ b iff a and b have the same number of digits' an equivalence relation on positive integers? If so, describe two classes.

Solution. Reflexive: a has the same digit count as itself -> yes. Symmetric: if a and b have the same count, so do b and a -> yes. Transitive: same count is transitive -> yes. So it is an equivalence relation. Two classes: all 1-digit numbers {1,...,9} and all 2-digit numbers {10,...,99}. Each positive integer falls in exactly one class by its digit length.

Functions: injective, surjective, and bijective

Functions map each input to one output; injective, surjective, and bijective describe how inputs and outputs correspond.

A function f from A to B assigns exactly one output in B to each input in A. It is injective (one-to-one) if distinct inputs give distinct outputs (no collisions); surjective (onto) if every element of the codomain B is hit by some input; and bijective if both — establishing a perfect one-to-one correspondence with an inverse. These properties decide whether a function can be inverted, whether a hash has collisions, and whether two sets have equal size. In Python you can test injectivity by checking len(set(outputs)) == len(inputs), and surjectivity by checking set(outputs) == codomain. Bijections are the formal basis for counting by correspondence.

Worked Example 1

Problem. Is f(x) = 2x from integers to integers injective? Surjective?

  1. Injective: if 2a = 2b then a = b -> distinct inputs give distinct outputs -> injective.
  2. Surjective onto integers: is every integer 2x for some integer x? 3 = 2x has no integer solution -> NOT surjective.
  3. Python check on a sample: outputs = [2*x for x in range(-3,4)]; len(set(outputs)) == len(outputs) -> True (injective).

Answer. f(x) = 2x is injective but not surjective onto the integers.

Worked Example 2

Problem. For f: {1,2,3} -> {a,b} with f(1)=a, f(2)=b, f(3)=a, classify f.

  1. Injective: f(1) = f(3) = a, so two inputs share an output -> NOT injective.
  2. Surjective: outputs are {a, b} which equals the codomain -> surjective.
  3. Not both -> not bijective. Python: set(f.values()) == {'a','b'} -> True (onto); len(set(f.values())) < 3 -> not one-to-one.

Answer. f is surjective but not injective, so not bijective.

Worked Example 3

Problem. Show f(x) = x + 5 on the integers is bijective.

  1. Injective: x + 5 = y + 5 implies x = y -> one-to-one.
  2. Surjective: for any integer m, x = m - 5 gives f(x) = m -> onto.
  3. Both hold, so bijective; the inverse is g(x) = x - 5.

Answer. f(x) = x + 5 is bijective with inverse x - 5.

Common mistakes
  • Swapping the definitions: injective is about distinct OUTPUTS for distinct inputs; surjective is about covering the codomain.
  • Ignoring the codomain when judging surjectivity — onto depends on which target set you declare.
✎ Try it yourself

Problem. Classify f: {1,2,3,4} -> {1,2,3,4} defined by f(x) = (x mod 4) + 1 as injective, surjective, and/or bijective.

Solution. Compute outputs: f(1)=2, f(2)=3, f(3)=4, f(4)=(0)+1=1. Outputs are {2,3,4,1} = {1,2,3,4}. All four outputs distinct -> injective. Outputs cover the codomain -> surjective. Both -> bijective. In Python: outs=[(x%4)+1 for x in [1,2,3,4]] gives [2,3,4,1]; len(set(outs))==4 and set(outs)=={1,2,3,4}. Answer: bijective.

Countable vs. uncountable sets

Some infinite sets can be listed (countable); others, like the reals, cannot (uncountable).

A set is countable if its elements can be put in one-to-one correspondence with the natural numbers — that is, listed in a sequence (possibly infinite) so each element eventually appears. The integers and even the rationals are countable, despite seeming larger, because clever enumerations list them all. A set is uncountable if no such listing exists; Cantor's diagonal argument proves the real numbers are uncountable — there are strictly more reals than naturals. This distinction underlies computability theory: there are only countably many programs but uncountably many functions, so most functions are uncomputable. Understanding it sharpens intuition about what algorithms can and cannot do.

Worked Example 1

Problem. Show the set of even natural numbers is countable.

  1. Define a bijection with the naturals: n maps to 2n (0->0, 1->2, 2->4, ...).
  2. Every even number 2k is the image of k, and distinct n give distinct outputs -> bijection.
  3. A bijection with the naturals means countable. Python: enumerate gives n -> 2*n.

Answer. Yes — n maps to 2n is a bijection with the naturals, so the evens are countable.

Worked Example 2

Problem. Sketch why the real numbers in (0,1) are uncountable (Cantor's diagonal).

  1. Suppose all reals in (0,1) could be listed as decimals r1, r2, r3, ...
  2. Build a new number d whose k-th digit differs from the k-th digit of r_k (e.g., choose 5 if that digit is not 5, else 6).
  3. Then d differs from every r_k in at least one place, so d is not in the list — contradiction. No complete list exists.

Answer. The diagonal number d cannot be in any proposed list, so (0,1) is uncountable.

Common mistakes
  • Assuming an infinite set must be uncountable — the integers and rationals are infinite yet countable.
  • Thinking a subset of an uncountable set is automatically uncountable; subsets can be finite or countable.
✎ Try it yourself

Problem. Is the set of all finite-length strings over the alphabet {0,1} countable or uncountable? Justify.

Solution. It is countable. List strings by length, then lexicographically within each length: '' (empty), '0','1', '00','01','10','11', '000', ... Every finite string has a finite length and a finite position within that length, so each appears at some point in the list — a bijection with the naturals. (By contrast, INFINITE binary sequences are uncountable.) Answer: countable.

Key terms
  • Set — an unordered collection of distinct elements, with membership being the only property that matters.
  • Subset — a set whose every element also belongs to another set; the power set is the set of all subsets.
  • Cartesian product — the set of all ordered pairs (a, b) with a from A and b from B, written A x B.
  • Cardinality — the size of a set; the power set of an n-element set has 2^n elements.
  • Relation — a subset of a Cartesian product, pairing elements of one set with another.
  • Equivalence relation — a relation that is reflexive, symmetric, and transitive, partitioning a set into classes.
  • Injective (one-to-one) — a function that maps distinct inputs to distinct outputs.
  • Bijective — a function that is both injective and surjective, establishing a one-to-one correspondence.
Assignment · Set algebra and relation checker

Using Python's built-in set type, implement and verify the core set identities (such as De Morgan's laws and distributivity) on randomly generated sets. Then represent a relation as a set of tuples and write functions that test whether it is reflexive, symmetric, and transitive. Use your checker to decide whether a given relation is an equivalence relation and, if so, print its partition into equivalence classes.

Deliverable · A script that confirms two set identities by example and reports the reflexive/symmetric/transitive status of at least three sample relations.

Quiz · 5 questions
  1. 1. If a set has 4 elements, how many subsets does it have?

  2. 2. An equivalence relation must be:

  3. 3. A function f is surjective (onto) when:

  4. 4. What is the cardinality of the Cartesian product A x B if |A| = 3 and |B| = 5?

  5. 5. Which set is uncountable?

You'll be able to

I can perform set operations and reason about cardinality, power sets, and Cartesian products.

I can classify a relation and recognize an equivalence relation and its partition.

I can determine whether a function is injective, surjective, or bijective.

Weeks 5-6 Unit 3: Combinatorics & Counting
apply-counting-rulescount-permutationscount-combinationsapply-binomial-theoremcount-with-repetitionapply-pigeonholeapply-inclusion-exclusion
Lecture
The sum and product rules

The two basic counting rules break a problem into mutually exclusive choices (add) or sequential independent choices (multiply).

The sum rule says that if a task can be done in one of several mutually exclusive ways — m ways OR n ways with no overlap — the total is m + n. The product rule says that if a procedure consists of a sequence of independent steps with m choices then n choices, the total is m * n. Almost every counting problem decomposes into combinations of these two rules; getting the decomposition right is the whole game. They underpin counting passwords, database keys, decision trees, and the size of state spaces in algorithms. In Python, nested loops or itertools.product realize the product rule, while summing disjoint case counts realizes the sum rule.

Worked Example 1

Problem. A menu has 3 appetizers and 4 desserts. How many ways to order exactly one item that is either an appetizer or a dessert?

  1. The choices are mutually exclusive (one item, not both categories), so use the sum rule.
  2. Appetizers OR desserts: 3 + 4.
  3. Total = 7.

Answer. 7 ways.

Worked Example 2

Problem. A meal is one appetizer AND one dessert. How many distinct meals?

  1. Now both steps happen in sequence and independently -> product rule.
  2. 3 appetizers times 4 desserts = 3 * 4.
  3. Python: len(list(itertools.product(range(3), range(4)))) = 12.

Answer. 12 meals.

Worked Example 3

Problem. How many 4-character codes use an uppercase letter for each position (26 letters)?

  1. Four independent positions, each with 26 choices -> product rule repeated.
  2. 26 * 26 * 26 * 26 = 26^4.
  3. 26^4 = 456976. Python: 26**4.

Answer. 456,976 codes.

Common mistakes
  • Using the sum rule when cases overlap — if choices can co-occur, you must use the product rule or inclusion-exclusion.
  • Multiplying when steps are not independent (later choices constrained by earlier ones) without adjusting the counts.
✎ Try it yourself

Problem. A license plate is 2 letters followed by 3 digits. How many plates are possible (letters 26, digits 10)?

Solution. Each position is an independent step, so use the product rule across all five positions: 26 * 26 * 10 * 10 * 10 = 26^2 * 10^3 = 676 * 1000 = 676,000. In Python: 26**2 * 10**3 = 676000. Answer: 676,000 plates.

Permutations and arrangements

Permutations count ordered arrangements, where the order of selection matters.

A permutation is an ordered arrangement of objects. The number of ways to arrange all n distinct objects is n! (n factorial). The number of ordered arrangements of k objects chosen from n is P(n, k) = n! / (n - k)!, since the first position has n choices, the next n-1, and so on for k positions. When objects repeat, divide by the factorials of each repeated group to avoid overcounting. Permutations count rankings, schedules, and orderings of tasks, and appear in sorting analysis and password strength. Python's math.factorial, math.perm, and itertools.permutations compute and enumerate them directly.

Worked Example 1

Problem. How many ways can 4 distinct runners finish 1st, 2nd, and 3rd (a podium of 3 from 4)?

  1. Order matters (the positions are ranked), choosing 3 of 4 -> P(4,3) = 4!/(4-3)!.
  2. = 4!/1! = 24/1 = 24.
  3. Python: math.perm(4, 3) = 24.

Answer. 24 podium orderings.

Worked Example 2

Problem. How many distinct arrangements of the letters in 'LEVEL'?

  1. 5 letters total but with repeats: L appears 2x, E appears 2x, V appears 1x.
  2. Arrangements = 5! / (2! * 2! * 1!) = 120 / 4 = 30.
  3. Python: math.factorial(5)//(math.factorial(2)*math.factorial(2)) = 30.

Answer. 30 distinct arrangements.

Worked Example 3

Problem. In how many ways can 6 people sit in a row?

  1. All 6 distinct, all positions ordered -> 6!.
  2. 6! = 720.
  3. Python: math.factorial(6) = 720.

Answer. 720 seatings.

Common mistakes
  • Using permutations when order does not matter (you want combinations instead).
  • Forgetting to divide by the factorials of repeated elements, which overcounts identical arrangements.
✎ Try it yourself

Problem. How many distinct ways can the letters of 'BANANA' be arranged?

Solution. BANANA has 6 letters: A appears 3 times, N appears 2 times, B appears 1 time. Distinct arrangements = 6! / (3! * 2! * 1!) = 720 / (6 * 2) = 720 / 12 = 60. In Python: math.factorial(6)//(math.factorial(3)*math.factorial(2)) = 60. Answer: 60 arrangements.

Combinations and the binomial coefficient

Combinations count unordered selections, given by the binomial coefficient n choose k.

A combination is a selection where order does NOT matter. The number of ways to choose k items from n is the binomial coefficient C(n, k) = n! / (k! (n - k)!), read 'n choose k'. It equals the number of permutations P(n,k) divided by k!, because the k! orderings of any chosen set are all the same combination. Combinations count committees, lottery tickets, poker hands, and subsets of a fixed size, and appear throughout probability and machine learning (feature subsets). A key identity is C(n, k) = C(n, n - k). Python's math.comb(n, k) and itertools.combinations compute and list them.

Worked Example 1

Problem. How many 2-person teams can be formed from 5 people?

  1. Order within a team is irrelevant -> combinations: C(5, 2) = 5!/(2! 3!).
  2. = (5 * 4)/(2 * 1) = 10.
  3. Python: math.comb(5, 2) = 10.

Answer. 10 teams.

Worked Example 2

Problem. How many 5-card hands can be dealt from a 52-card deck?

  1. Order of cards in a hand does not matter -> C(52, 5).
  2. C(52,5) = 52!/(5! 47!) = (52*51*50*49*48)/(5*4*3*2*1).
  3. = 311875200 / 120 = 2,598,960. Python: math.comb(52, 5).

Answer. 2,598,960 hands.

Worked Example 3

Problem. Verify the identity C(8, 3) = C(8, 5).

  1. C(8,3) = 8!/(3! 5!) = (8*7*6)/(6) = 56.
  2. C(8,5) = 8!/(5! 3!) = same denominator product = 56.
  3. Equal, as guaranteed by C(n,k) = C(n, n-k). Python: math.comb(8,3) == math.comb(8,5).

Answer. Both equal 56, confirming the symmetry identity.

Common mistakes
  • Using combinations when order matters (then you need permutations).
  • Computing C(n,k) by forgetting the k! in the denominator, which yields the permutation count instead.
✎ Try it yourself

Problem. A pizza shop offers 7 toppings. How many ways to choose exactly 3 different toppings?

Solution. Order of toppings does not matter, so use combinations: C(7, 3) = 7!/(3! 4!) = (7 * 6 * 5)/(3 * 2 * 1) = 210/6 = 35. In Python: math.comb(7, 3) = 35. Answer: 35 ways.

The binomial theorem and Pascal's triangle

The binomial theorem expands (x + y)^n using binomial coefficients, which form Pascal's triangle.

The binomial theorem states that (x + y)^n = sum over k from 0 to n of C(n, k) x^(n-k) y^k. Each coefficient is a binomial coefficient, counting how many ways to choose which factors contribute a y. Pascal's triangle arranges these coefficients in rows, where each entry is the sum of the two above it — the identity C(n, k) = C(n-1, k-1) + C(n-1, k). This recurrence gives an efficient way to compute coefficients without factorials and connects counting to algebra. It appears in probability (binomial distribution), polynomial expansion, and dynamic-programming tables. In Python the triangle is built row by row from the previous row.

Worked Example 1

Problem. Expand (x + y)^3 using the binomial theorem.

  1. Coefficients C(3,0..3) = 1, 3, 3, 1.
  2. Terms: x^3, then 3 x^2 y, then 3 x y^2, then y^3.
  3. Combine: x^3 + 3x^2 y + 3x y^2 + y^3.

Answer. (x + y)^3 = x^3 + 3x^2 y + 3x y^2 + y^3.

Worked Example 2

Problem. Find the coefficient of x^2 in (x + 2)^5.

  1. General term: C(5, k) x^(5-k) 2^k. For x^2 we need 5 - k = 2, so k = 3.
  2. Coefficient = C(5, 3) * 2^3 = 10 * 8 = 80.
  3. Python: math.comb(5,3) * 2**3 = 80.

Answer. The coefficient of x^2 is 80.

Worked Example 3

Problem. Build row 4 of Pascal's triangle from row 3 (1,3,3,1).

  1. Each entry is the sum of the two above; pad with 0s at the ends.
  2. Row 4: 1, (1+3)=4, (3+3)=6, (3+1)=4, 1.
  3. Result 1,4,6,4,1 matches C(4,0..4). Python: row=[1]; build via [a+b for a,b in zip([0]+prev, prev+[0])].

Answer. Row 4 is 1, 4, 6, 4, 1.

Common mistakes
  • Mis-pairing the exponents: in C(n,k) x^(n-k) y^k the y exponent equals k, not n-k.
  • Forgetting to raise the constant inside, e.g. the 2 in (x+2)^n, to the power k.
✎ Try it yourself

Problem. What is the coefficient of x^4 in the expansion of (x + 1)^6?

Solution. General term: C(6, k) x^(6-k) 1^k. For x^4 we need 6 - k = 4, so k = 2. Coefficient = C(6, 2) * 1^2 = 15 * 1 = 15. In Python: math.comb(6, 2) = 15. Answer: 15.

Permutations and combinations with repetition (stars and bars)

When selections allow repetition, counts change; stars and bars counts distributions of identical items.

If you choose k items from n types with repetition allowed and order matters, the count is n^k. If order does not matter, the count is C(n + k - 1, k) — the stars and bars formula. Stars and bars models distributing k identical items into n distinct bins: line up k stars and n-1 bars to separate bins, and choose where the bars go. This counts nonnegative integer solutions to x1 + x2 + ... + xn = k, which appears in resource allocation, integer-composition problems, and multiset counting. In Python, itertools.combinations_with_replacement enumerates these multisets directly for verification.

Worked Example 1

Problem. How many ways to distribute 5 identical candies among 3 children (some may get none)?

  1. Stars and bars: k = 5 stars, n = 3 bins, so n - 1 = 2 bars.
  2. Count = C(k + n - 1, n - 1) = C(5 + 2, 2) = C(7, 2).
  3. = 21. Python: math.comb(7, 2) = 21.

Answer. 21 ways.

Worked Example 2

Problem. How many nonnegative integer solutions does x + y + z + w = 10 have?

  1. k = 10 (the total), n = 4 variables -> bars = 3.
  2. Count = C(10 + 4 - 1, 4 - 1) = C(13, 3).
  3. C(13,3) = 286. Python: math.comb(13, 3) = 286.

Answer. 286 solutions.

Worked Example 3

Problem. From 3 flavors of ice cream, how many ways to pick 4 scoops where repeats are allowed and order does not matter?

  1. Multiset selection: choose 4 from 3 types with repetition -> C(3 + 4 - 1, 4) = C(6, 4).
  2. = C(6, 2) = 15.
  3. Python: len(list(itertools.combinations_with_replacement(range(3), 4))) = 15.

Answer. 15 ways.

Common mistakes
  • Using n^k (ordered with repetition) when order should not matter — that overcounts; use stars and bars.
  • Miscounting the bars: for n bins there are n - 1 bars, not n.
✎ Try it yourself

Problem. How many ways can you buy 6 donuts choosing from 4 varieties (repeats allowed, order irrelevant)?

Solution. This is a multiset selection: choose 6 from 4 types with repetition. Stars and bars: C(6 + 4 - 1, 4 - 1) = C(9, 3) = (9 * 8 * 7)/(3 * 2 * 1) = 504/6 = 84. In Python: math.comb(9, 3) = 84, and len(list(itertools.combinations_with_replacement(range(4), 6))) also equals 84. Answer: 84 ways.

The pigeonhole principle

If more items than containers, some container holds at least two items — a simple idea with surprising power.

The pigeonhole principle states that if n items are placed into m containers and n > m, then at least one container holds two or more items. The generalized form: at least one container holds at least ceil(n / m) items. Though almost trivial to state, it proves striking facts: among 13 people two share a birth month, and in any group of n people two have the same number of acquaintances within the group. In CS it proves hash collisions are unavoidable, that lossless compression cannot shrink every input, and bounds in algorithm analysis. The skill is identifying what the 'pigeons' and 'holes' are.

Worked Example 1

Problem. Show that among any 13 people, two share a birth month.

  1. Pigeons = 13 people; holes = 12 months.
  2. Since 13 > 12, the pigeonhole principle forces some month to receive >= 2 people.
  3. Therefore at least two people share a birth month.

Answer. Yes — 13 people into 12 months guarantees a shared month.

Worked Example 2

Problem. Among 100 integers, must at least 10 share the same remainder mod 9?

  1. Holes = 9 possible remainders mod 9; pigeons = 100 integers.
  2. Generalized pigeonhole: some hole has at least ceil(100 / 9) items.
  3. ceil(100/9) = ceil(11.1) = 12 >= 10, so yes.

Answer. Yes — at least one remainder is shared by at least 12 of them (>= 10).

Common mistakes
  • Using n >= m instead of n > m for the basic form — you need strictly more pigeons than holes.
  • Forgetting the ceiling in the generalized version; ceil(n/m), not floor, gives the guaranteed minimum.
✎ Try it yourself

Problem. A drawer holds socks of 3 colors. How many socks must you pull (in the dark) to guarantee a matching pair?

Solution. Treat colors as 3 holes. Pulling 3 socks could give one of each color (no pair). Pulling a 4th sock: 4 pigeons into 3 holes forces at least one color to have >= 2 socks, i.e. a matching pair. By pigeonhole, 4 socks guarantee a pair. Answer: 4 socks.

Inclusion-exclusion for counting

To count a union of overlapping sets, add the sizes and subtract the overcounted overlaps in alternating signs.

The inclusion-exclusion principle corrects for double-counting when sets overlap. For two sets: |A union B| = |A| + |B| - |A intersect B|. For three: add the three single sizes, subtract the three pairwise intersections, then add back the triple intersection. In general, alternate signs over intersections of increasing size. This counts how many numbers in a range are divisible by at least one of several divisors, how many permutations avoid certain positions (derangements), and probabilities of unions of events. It is essential in combinatorics and probability. In Python you can verify it by direct set construction with the | operator and len().

Worked Example 1

Problem. In a class, 18 take math, 15 take art, 7 take both. How many take at least one?

  1. Two-set inclusion-exclusion: |M union A| = |M| + |A| - |M intersect A|.
  2. = 18 + 15 - 7.
  3. = 26.

Answer. 26 learners take at least one subject.

Worked Example 2

Problem. How many integers from 1 to 100 are divisible by 2 or 5?

  1. Divisible by 2: floor(100/2) = 50. By 5: floor(100/5) = 20. By both (10): floor(100/10) = 10.
  2. Inclusion-exclusion: 50 + 20 - 10 = 60.
  3. Python: len({n for n in range(1,101) if n%2==0 or n%5==0}) = 60.

Answer. 60 integers.

Worked Example 3

Problem. How many integers from 1 to 100 are divisible by 2, 3, or 5?

  1. Singles: floor(100/2)=50, floor(100/3)=33, floor(100/5)=20.
  2. Pairs: floor(100/6)=16, floor(100/10)=10, floor(100/15)=6. Triple: floor(100/30)=3.
  3. Total = (50+33+20) - (16+10+6) + 3 = 103 - 32 + 3 = 74.

Answer. 74 integers.

Common mistakes
  • Forgetting to subtract the intersection, which double-counts elements in both sets.
  • Getting the alternating signs wrong for three or more sets — subtract pairwise overlaps, then add back the triple overlap.
✎ Try it yourself

Problem. Of 50 surveyed people, 30 like coffee, 25 like tea, and 12 like both. How many like neither?

Solution. By inclusion-exclusion, those who like at least one = |coffee union tea| = 30 + 25 - 12 = 43. Those who like neither = total minus that = 50 - 43 = 7. In Python you could verify with set sizes: 30 + 25 - 12 = 43, then 50 - 43 = 7. Answer: 7 people like neither.

Key terms
  • Sum rule — if tasks are mutually exclusive, the number of ways to do one OR another is the sum of the counts.
  • Product rule — if a procedure has independent steps, the total count is the product of the per-step counts.
  • Permutation — an ordered arrangement; the number of ordered arrangements of k from n is n!/(n-k)!.
  • Combination — an unordered selection of k from n, counted by the binomial coefficient n choose k.
  • Binomial theorem — expands (x + y)^n using binomial coefficients as the coefficients of each term.
  • Stars and bars — a technique for counting selections with repetition, equivalent to distributing identical items into bins.
  • Pigeonhole principle — if n items go into m boxes with n greater than m, some box holds at least two items.
  • Inclusion-exclusion — counts a union by adding individual sizes and subtracting overlaps in alternating signs.
Assignment · Counting, verified by brute force

For a small problem of your choice (for example, the number of 5-card poker hands of a given type, or arrangements of a word with repeated letters), compute the answer with a closed-form combinatorial formula. Then write a Python brute-force enumeration using itertools.permutations or itertools.combinations that counts the same thing directly. Confirm the two numbers match and explain why the formula generalizes where brute force cannot.

Deliverable · A script that prints both the formula-based count and the brute-force count, with a note on when enumeration becomes infeasible.

Quiz · 5 questions
  1. 1. How many ways can you arrange 5 distinct books in a row on a shelf?

  2. 2. How many ways can a committee of 3 be chosen from 10 people?

  3. 3. The pigeonhole principle guarantees that among 13 people, at least two share:

  4. 4. In the binomial theorem, the coefficient of x^k y^(n-k) in the expansion of (x + y)^n is:

  5. 5. How many ways can you distribute 7 identical candies among 3 children (some may get none)?

You'll be able to

I can apply the sum and product rules to break a counting problem into steps.

I can count arrangements and selections using permutations, combinations, and stars and bars.

I can use the pigeonhole principle and inclusion-exclusion to solve counting problems.

Weeks 7-8 Unit 4: Graph Theory
define-graph-termsrepresent-graphsanalyze-connectivitywork-with-treestraverse-graphsidentify-euler-hamiltoncolor-graphs
Lecture
Graphs, vertices, edges, and degree

A graph is a set of vertices joined by edges; degree counts the edges at a vertex.

A graph G = (V, E) is a set of vertices V and a set of edges E, each edge joining a pair of vertices. Graphs model networks of all kinds: social connections, road maps, web links, and dependencies. The degree of a vertex is the number of edges incident to it. The handshake lemma states that the sum of all degrees equals twice the number of edges, because each edge adds one to the degree of each of its two endpoints. A consequence is that the number of odd-degree vertices is always even. Graphs may be directed (edges have direction) or undirected. In Python a graph is naturally a dictionary mapping each vertex to its list of neighbors.

Worked Example 1

Problem. A graph has vertices {A,B,C,D} and edges {A-B, A-C, B-C, C-D}. Find each degree and verify the handshake lemma.

  1. Degree A: edges A-B, A-C -> 2. Degree B: A-B, B-C -> 2. Degree C: A-C, B-C, C-D -> 3. Degree D: C-D -> 1.
  2. Sum of degrees = 2 + 2 + 3 + 1 = 8.
  3. Edges = 4, twice that = 8. Matches. Python: sum(len(adj[v]) for v in adj) == 2*num_edges.

Answer. Degrees A=2, B=2, C=3, D=1; degree sum 8 = 2 x 4 edges, confirming the handshake lemma.

Worked Example 2

Problem. Can a graph have exactly three vertices of odd degree?

  1. The handshake lemma forces the degree sum to be even (twice the edge count).
  2. The number of odd-degree vertices must be even, or their sum would be odd.
  3. Three is odd, so it is impossible.

Answer. No — the count of odd-degree vertices is always even, so three is impossible.

Common mistakes
  • Counting an edge once toward the degree sum — each edge contributes to TWO vertices' degrees.
  • In a directed graph, conflating in-degree and out-degree; track them separately.
✎ Try it yourself

Problem. A graph has 5 vertices with degrees 3, 3, 2, 2, 2. How many edges does it have?

Solution. By the handshake lemma, the sum of degrees equals twice the number of edges. Sum = 3 + 3 + 2 + 2 + 2 = 12. So edges = 12 / 2 = 6. (The degree sum is even, so such a graph is feasible.) Answer: 6 edges.

Representations: adjacency matrix and adjacency list

Graphs are stored as an adjacency matrix (dense) or an adjacency list (sparse-friendly).

An adjacency matrix is an n-by-n table where entry (i, j) is 1 if an edge joins vertices i and j and 0 otherwise; it uses O(n^2) space and answers 'is there an edge?' in O(1), but wastes space on sparse graphs. An adjacency list stores, for each vertex, a list of its neighbors; it uses O(n + m) space (m = edges), ideal for sparse graphs, and is what most algorithms use. The choice affects the running time of traversals and queries. In Python, an adjacency list is a dict of lists/sets, and a matrix is a list of lists. Symmetric matrices represent undirected graphs.

Worked Example 1

Problem. Build the adjacency matrix for an undirected graph on {0,1,2} with edges 0-1 and 1-2.

  1. Start with a 3x3 zero matrix. Set entries for each edge symmetrically.
  2. Edge 0-1: M[0][1]=M[1][0]=1. Edge 1-2: M[1][2]=M[2][1]=1.
  3. Matrix rows: [0,1,0], [1,0,1], [0,1,0].

Answer. [[0,1,0],[1,0,1],[0,1,0]].

Worked Example 2

Problem. Write the same graph as an adjacency list and give its space cost.

  1. Each vertex lists its neighbors: 0 -> [1], 1 -> [0, 2], 2 -> [1].
  2. Python: adj = {0:[1], 1:[0,2], 2:[1]}.
  3. Space is O(n + m) = O(3 + 2); for sparse graphs this beats the O(n^2) matrix.

Answer. adj = {0:[1], 1:[0,2], 2:[1]}, using O(n + m) space.

Common mistakes
  • Filling only one side of an undirected adjacency matrix — it must be symmetric, M[i][j] = M[j][i].
  • Using an O(n^2) matrix for a huge sparse graph, wasting memory where an adjacency list would be far smaller.
✎ Try it yourself

Problem. Give the adjacency matrix and adjacency list for the undirected triangle on vertices {0,1,2} (all pairs connected).

Solution. All three pairs are edges. Adjacency matrix (no self-loops, so diagonal is 0): [[0,1,1],[1,0,1],[1,1,0]]. Adjacency list: {0:[1,2], 1:[0,2], 2:[0,1]}. Each vertex has degree 2 and the matrix is symmetric, consistent with 3 undirected edges.

Paths, cycles, and connectivity

Paths are routes without repeated vertices; a connected graph has a path between every pair of vertices.

A walk is any sequence of edges; a path is a walk that does not repeat vertices; a cycle is a path that returns to its starting vertex. A graph is connected if there is a path between every pair of vertices; otherwise it splits into connected components. Connectivity is the most basic structural question and is answered by a single traversal: run BFS or DFS from any vertex and check whether it reaches all vertices. These ideas underlie network reliability, reachability in dependency graphs, and detecting whether data forms one cluster or several. In Python, counting the vertices a traversal visits tells you the size of a component.

Worked Example 1

Problem. In the graph {A-B, B-C, C-D}, is there a path from A to D? Name it.

  1. Follow edges from A: A-B, then B-C, then C-D reaches D with no repeated vertex.
  2. Path: A, B, C, D.
  3. Since a path exists, A and D are in the same component.

Answer. Yes — the path A -> B -> C -> D connects them.

Worked Example 2

Problem. A graph has edges {1-2, 2-3, 4-5}. How many connected components?

  1. Start a traversal at 1: reaches 1, 2, 3 -> one component {1,2,3}.
  2. Vertices 4, 5 are unreached; a traversal from 4 reaches 4, 5 -> second component {4,5}.
  3. No edge bridges the two groups. Python: count components by repeatedly BFS-ing from unvisited vertices.

Answer. Two connected components: {1,2,3} and {4,5}.

Common mistakes
  • Confusing a walk with a path — a path may not revisit a vertex; a cycle revisits only the start.
  • Assuming a graph is connected because it has many edges; you must verify a traversal reaches every vertex.
✎ Try it yourself

Problem. Graph edges: {A-B, B-C, C-A, D-E}. Is the graph connected? How many components, and which vertices form a cycle?

Solution. A traversal from A reaches A, B, C (via A-B, B-C, C-A) but never D or E. A traversal from D reaches D, E. So the graph is NOT connected; it has 2 components: {A,B,C} and {D,E}. The vertices A, B, C form a cycle A -> B -> C -> A. Answer: not connected, 2 components, cycle on A,B,C.

Trees and spanning trees

A tree is a connected acyclic graph with n-1 edges; a spanning tree connects all vertices of a graph using a tree.

A tree is a connected graph with no cycles. A tree on n vertices has exactly n - 1 edges, and there is a unique path between any two vertices; adding any edge creates a cycle, while removing any edge disconnects it. A spanning tree of a connected graph is a subgraph that is a tree and includes every vertex — it is the minimal connected backbone. Spanning trees power network design (minimal cabling), BFS/DFS trees, and minimum-spanning-tree algorithms (Kruskal, Prim) used in clustering and routing. Recognizing the n-1 edge count is a quick sanity check. In Python, BFS or DFS from a root naturally produces a spanning tree of the visited component.

Worked Example 1

Problem. A connected graph has 6 vertices. If it is a tree, how many edges does it have? If it has 8 edges, how many independent cycles must it contain?

  1. A tree on 6 vertices has n - 1 = 5 edges.
  2. With 8 edges, the number of extra edges beyond a spanning tree is 8 - 5 = 3.
  3. Each extra edge creates one independent cycle, so there are 3 independent cycles (the cycle rank).

Answer. A tree would have 5 edges; with 8 edges there are 8 - 5 = 3 independent cycles.

Worked Example 2

Problem. Find a spanning tree of the graph with edges {A-B, B-C, C-A, C-D} (4 vertices).

  1. The graph has 4 vertices, so a spanning tree needs 3 edges and must reach all of A,B,C,D.
  2. Run BFS from A: take A-B, B-C, then C-D; this reaches all four and adds 3 edges with no cycle (skip the cycle-closing edge C-A).
  3. Spanning tree edges: {A-B, B-C, C-D}.

Answer. A valid spanning tree is {A-B, B-C, C-D} (dropping the cycle edge C-A).

Common mistakes
  • Thinking any connected subgraph touching all vertices is a spanning tree — it must also be acyclic (exactly n-1 edges).
  • Forgetting that a graph can have many different spanning trees.
✎ Try it yourself

Problem. A graph is connected with 10 vertices and 9 edges. Is it necessarily a tree? Explain.

Solution. Yes. A connected graph on n vertices has at least n - 1 edges, and a connected graph with exactly n - 1 edges must be acyclic (an extra edge over n-1 would force a cycle). Here n = 10 and edges = 9 = n - 1, so the graph is connected, has no cycles, and is therefore a tree. Answer: yes, it must be a tree.

Graph traversal: breadth-first and depth-first search

BFS explores level by level using a queue; DFS goes as deep as possible using a stack or recursion.

Breadth-first search (BFS) starts at a source and visits all neighbors, then their neighbors, expanding in waves of increasing distance; it uses a FIFO queue and finds shortest paths (in edges) in unweighted graphs. Depth-first search (DFS) plunges along one branch until it dead-ends, then backtracks; it uses a stack or recursion and is the basis for cycle detection, topological sorting, and connected-component labeling. Both visit every vertex exactly once in O(n + m) time with a visited set to avoid revisits. They are the most-used graph algorithms in practice. In Python, BFS uses collections.deque; DFS uses recursion or an explicit list as a stack.

Worked Example 1

Problem. Graph adj = {A:[B,C], B:[A,D], C:[A,D], D:[B,C]}. Give the BFS visiting order from A.

  1. Queue starts [A], visited {A}. Dequeue A, enqueue its neighbors B, C -> visited {A,B,C}.
  2. Dequeue B, its unvisited neighbor D enqueued -> visited {A,B,C,D}. Dequeue C (D already visited). Dequeue D.
  3. Order visited: A, B, C, D. Python: from collections import deque; q=deque([A]).

Answer. BFS order from A: A, B, C, D.

Worked Example 2

Problem. For the same graph, give a DFS visiting order from A using recursion (neighbors in listed order).

  1. Visit A, recurse into first neighbor B. From B recurse into A (visited) then D.
  2. From D recurse into B (visited) then C. From C, neighbors A and D already visited -> backtrack.
  3. Order visited: A, B, D, C.

Answer. DFS order from A: A, B, D, C.

Worked Example 3

Problem. Use BFS to find the shortest path length from A to D in adj = {A:[B,C], B:[A,D], C:[A], D:[B]}.

  1. Track distance: dist[A]=0. Visit neighbors B, C at distance 1.
  2. From B, neighbor D gets distance 2; from C no new vertices.
  3. dist[D] = 2, and BFS guarantees this is the shortest (fewest edges).

Answer. Shortest path A to D has length 2 (A -> B -> D).

Common mistakes
  • Omitting the visited set, causing infinite loops on graphs with cycles.
  • Expecting DFS to find shortest paths — only BFS does that in unweighted graphs.
✎ Try it yourself

Problem. Graph adj = {1:[2,3], 2:[1,4], 3:[1,4], 4:[2,3]}. Give the BFS order from vertex 1 and the shortest distance from 1 to 4.

Solution. BFS from 1: queue [1], visit 1 (dist 0). Enqueue neighbors 2, 3 (dist 1). Dequeue 2, enqueue its unvisited neighbor 4 (dist 2). Dequeue 3 (4 already queued). Dequeue 4. Visiting order: 1, 2, 3, 4. Since 4 was first reached at distance 2 via 1 -> 2 -> 4, the shortest distance from 1 to 4 is 2.

Eulerian and Hamiltonian paths

Eulerian paths use every edge once; Hamiltonian paths visit every vertex once.

An Eulerian path traverses every edge exactly once; an Eulerian circuit does so and returns to start. A connected graph has an Eulerian circuit if and only if every vertex has even degree, and an Eulerian path (not circuit) if and only if exactly two vertices have odd degree — these conditions are checkable in O(n) time. A Hamiltonian path visits every vertex exactly once; deciding whether one exists is NP-complete, with no known efficient general test. The contrast is striking: Euler's edge condition is easy, Hamilton's vertex condition is hard. These power route planning (the Konigsberg bridges, mail delivery) and the traveling-salesman problem.

Worked Example 1

Problem. Does the graph with edges {A-B, B-C, C-A} (a triangle) have an Eulerian circuit?

  1. Compute degrees: A=2, B=2, C=2 — all even.
  2. Graph is connected and all degrees even -> Eulerian circuit exists.
  3. Example circuit: A -> B -> C -> A, using each edge once.

Answer. Yes — all degrees are even, so an Eulerian circuit exists (e.g., A-B-C-A).

Worked Example 2

Problem. A path graph A-B-C-D: does it have an Eulerian path? An Eulerian circuit?

  1. Degrees: A=1, B=2, C=2, D=1. Odd-degree vertices: A and D (exactly two).
  2. Exactly two odd-degree vertices -> Eulerian path exists (start A, end D), but not a circuit.
  3. Path: A -> B -> C -> D uses every edge once.

Answer. It has an Eulerian path (A to D) but no Eulerian circuit.

Common mistakes
  • Mixing up the conditions: Eulerian is about EDGES and degree parity; Hamiltonian is about VISITING every VERTEX.
  • Assuming an easy degree test exists for Hamiltonian paths — that problem is NP-complete.
✎ Try it yourself

Problem. A graph has degrees 4, 4, 3, 3, 2. Does it have an Eulerian circuit, an Eulerian path, or neither (assume connected)?

Solution. Count odd-degree vertices: degrees 3 and 3 are odd, the rest (4,4,2) are even -> exactly two odd-degree vertices. With zero odd vertices you would get a circuit; with exactly two you get an Eulerian path but not a circuit. Therefore: an Eulerian path exists (starting and ending at the two odd-degree vertices), but no Eulerian circuit.

Graph coloring and bipartite graphs

Coloring assigns colors so adjacent vertices differ; bipartite graphs are exactly the 2-colorable ones.

A proper graph coloring assigns a color to each vertex so that no edge joins two vertices of the same color; the chromatic number is the fewest colors needed. A graph is bipartite if its vertices split into two sets with edges only between the sets — equivalently, it is 2-colorable, equivalently it contains no odd-length cycle. You can test bipartiteness with a BFS/DFS that 2-colors vertices and checks for conflicts. Coloring models scheduling (exam timetables where conflicts cannot share a slot), register allocation in compilers, and frequency assignment. General k-coloring is NP-hard, but 2-coloring (bipartiteness) is solvable in linear time.

Worked Example 1

Problem. Is the cycle on 4 vertices (square A-B-C-D-A) bipartite?

  1. 2-color by BFS: A=red, neighbors B and D = blue, then C (neighbor of B and D) = red.
  2. Check every edge joins different colors: A-B (r,b), B-C (b,r), C-D (r,b), D-A (b,r) — all fine.
  3. Equivalently the only cycle has length 4 (even). So bipartite with parts {A,C} and {B,D}.

Answer. Yes — it is bipartite (2-colorable), parts {A,C} and {B,D}.

Worked Example 2

Problem. Is the triangle A-B-C (cycle of length 3) bipartite? What is its chromatic number?

  1. Try 2-coloring: A=red, B=blue, then C is adjacent to both A and B, needing a third color.
  2. It contains an odd cycle (length 3), so it is NOT bipartite.
  3. Three mutually adjacent vertices need 3 colors -> chromatic number 3.

Answer. Not bipartite (it has an odd cycle); chromatic number is 3.

Common mistakes
  • Thinking any graph with an even number of vertices is bipartite — bipartiteness depends on cycle parity, not vertex count.
  • Forgetting that a single odd cycle anywhere destroys bipartiteness.
✎ Try it yourself

Problem. Determine whether the cycle on 5 vertices (A-B-C-D-E-A) is bipartite, and give its chromatic number.

Solution. Attempt a 2-coloring around the cycle: A=red, B=blue, C=red, D=blue, E=red. But E is adjacent to A, and both are red — a conflict. The cycle has length 5, which is odd, so it is NOT bipartite. An odd cycle requires 3 colors (e.g., A=red, B=blue, C=red, D=blue, E=green works), so the chromatic number is 3. Answer: not bipartite; chromatic number 3.

Key terms
  • Graph — a set of vertices together with edges connecting pairs of them.
  • Degree — the number of edges incident to a vertex; the handshake lemma says degrees sum to twice the edge count.
  • Adjacency matrix — an n-by-n matrix whose entry (i, j) records whether an edge joins vertices i and j.
  • Adjacency list — a representation storing, for each vertex, the list of its neighbors; efficient for sparse graphs.
  • Path and cycle — a path is a walk with no repeated vertices; a cycle is a path that returns to its start.
  • Tree — a connected, acyclic graph; a tree on n vertices has exactly n-1 edges.
  • Breadth-first / depth-first search — systematic traversals exploring a graph layer by layer or as deep as possible first.
  • Bipartite graph — a graph whose vertices split into two sets with edges only between the sets; equivalently, 2-colorable.
Assignment · Build a graph and traverse it

Implement a graph in Python using an adjacency-list dictionary. Add functions to compute each vertex's degree and verify the handshake lemma. Then implement breadth-first search (using a queue) and depth-first search (using recursion or a stack), and use BFS to find the shortest path between two vertices in an unweighted graph. Test connectivity by checking whether a traversal reaches every vertex.

Deliverable · A script with an adjacency-list graph, working BFS and DFS, a shortest-path result, and a connectivity check on at least two example graphs.

Quiz · 5 questions
  1. 1. In any graph, the sum of all vertex degrees equals:

  2. 2. A tree with n vertices has exactly how many edges?

  3. 3. Which traversal naturally finds the shortest path (in edges) in an unweighted graph?

  4. 4. A graph has an Eulerian circuit if and only if it is connected and:

  5. 5. A graph is bipartite if and only if it:

You'll be able to

I can represent a graph as an adjacency matrix or list and compute vertex degrees.

I can implement breadth-first and depth-first search and use them to test connectivity.

I can recognize trees, bipartite graphs, and Eulerian or Hamiltonian paths.

Weeks 9-10 Unit 5: Number Theory & Modular Arithmetic
analyze-divisibilitycompute-gcdapply-modular-arithmeticfind-modular-inverseapply-fermat-eulermodel-hashingexplain-rsa
Lecture
Divisibility, primes, and the fundamental theorem of arithmetic

Primes are the multiplicative atoms of the integers; every integer factors uniquely into primes.

We say a divides b (written a | b) when b = a*k for some integer k. A prime is an integer greater than 1 whose only positive divisors are 1 and itself; all other integers above 1 are composite. The fundamental theorem of arithmetic guarantees that every integer greater than 1 has a unique prime factorization, up to the order of factors — this uniqueness is why primes are the building blocks of number theory. Factorization underlies cryptography, the structure of GCD/LCM, and hashing. In Python you can test divisibility with the modulo operator (b % a == 0) and factor by trial division up to the square root of n, since any composite has a factor at or below sqrt(n).

Worked Example 1

Problem. Find the prime factorization of 84.

  1. Divide by the smallest prime repeatedly: 84 / 2 = 42, 42 / 2 = 21.
  2. 21 is not even; divide by 3: 21 / 3 = 7.
  3. 7 is prime. Collect factors: 84 = 2 * 2 * 3 * 7 = 2^2 * 3 * 7.

Answer. 84 = 2^2 * 3 * 7.

Worked Example 2

Problem. Is 91 prime? Justify with trial division.

  1. Check primes up to sqrt(91) ~ 9.5: try 2 (no, odd), 3 (9+1=10 not div by 3), 5 (no), 7.
  2. 91 / 7 = 13 exactly, so 7 divides 91.
  3. Thus 91 = 7 * 13 is composite. Python: any(91 % p == 0 for p in range(2, 10)) -> True at p=7.

Answer. 91 is not prime; 91 = 7 * 13.

Common mistakes
  • Trial-dividing all the way to n instead of stopping at sqrt(n) — any composite has a factor at or below its square root.
  • Treating 1 as prime; by definition a prime must be greater than 1.
✎ Try it yourself

Problem. Find the prime factorization of 360 and state how many distinct prime factors it has.

Solution. Divide out 2s: 360/2=180, 180/2=90, 90/2=45 (three 2s). 45 is odd; divide by 3: 45/3=15, 15/3=5 (two 3s). 5 is prime. So 360 = 2^3 * 3^2 * 5. The distinct primes are 2, 3, and 5 -> 3 distinct prime factors. Answer: 360 = 2^3 * 3^2 * 5, with 3 distinct prime factors.

The Euclidean algorithm and greatest common divisor

The Euclidean algorithm finds the GCD by repeated remainder, far faster than factoring.

The greatest common divisor (GCD) of two integers is the largest integer dividing both. The Euclidean algorithm computes it using the key fact that GCD(a, b) = GCD(b, a mod b), repeatedly replacing the pair until the remainder is 0; the last nonzero remainder is the GCD. It runs in O(log(min(a,b))) steps — exponentially faster than factoring both numbers. GCD is foundational for reducing fractions, finding modular inverses, and cryptographic key math. The related LCM satisfies LCM(a,b) = a*b / GCD(a,b). In Python the algorithm is a short loop, and math.gcd does it built in.

Worked Example 1

Problem. Compute GCD(48, 18) with the Euclidean algorithm.

  1. 48 mod 18 = 12, so GCD(48,18) = GCD(18,12).
  2. 18 mod 12 = 6, so = GCD(12,6). 12 mod 6 = 0, so = GCD(6,0).
  3. Last nonzero remainder is 6. Python: while b: a, b = b, a % b -> a = 6.

Answer. GCD(48, 18) = 6.

Worked Example 2

Problem. Find GCD(1071, 462) and then LCM(1071, 462).

  1. 1071 mod 462 = 147; 462 mod 147 = 21; 147 mod 21 = 0. GCD = 21.
  2. LCM = a*b / GCD = 1071 * 462 / 21.
  3. 1071*462 = 494802; /21 = 23562. Python: math.gcd(1071,462)=21; 1071*462//21=23562.

Answer. GCD = 21, LCM = 23562.

Common mistakes
  • Stopping at the wrong step — the GCD is the last NONZERO remainder, just before the remainder becomes 0.
  • Computing LCM as a*b without dividing by the GCD, which overshoots unless the numbers are coprime.
✎ Try it yourself

Problem. Use the Euclidean algorithm to find GCD(252, 105), then reduce the fraction 252/105.

Solution. 252 mod 105 = 42; 105 mod 42 = 21; 42 mod 21 = 0. The last nonzero remainder is 21, so GCD(252,105) = 21. Reduce the fraction by dividing both by 21: 252/21 = 12 and 105/21 = 5, giving 12/5. In Python: math.gcd(252,105) = 21. Answer: GCD = 21; 252/105 reduces to 12/5.

Modular arithmetic and congruences

Modular arithmetic 'wraps around' at a modulus; congruence means two numbers leave the same remainder.

In modular arithmetic we work with remainders after dividing by a modulus n. We write a is congruent to b mod n when n divides (a - b) — equivalently, a and b leave the same remainder mod n. Addition, subtraction, and multiplication all respect congruence: you may reduce mod n at any point. This is the math of clocks (12-hour wrap), checksums, cyclic buffers, and hash tables. A powerful tool is fast modular exponentiation: to compute a^b mod n efficiently, square and reduce repeatedly. In Python, the % operator gives nonnegative remainders for positive n, and pow(a, b, n) computes modular powers efficiently.

Worked Example 1

Problem. Compute (17 + 25) mod 12 and (17 * 25) mod 12.

  1. Reduce first: 17 mod 12 = 5, 25 mod 12 = 1.
  2. Sum: (5 + 1) mod 12 = 6. Product: (5 * 1) mod 12 = 5.
  3. Python: (17+25)%12 = 6 and (17*25)%12 = 5.

Answer. (17 + 25) mod 12 = 6; (17 * 25) mod 12 = 5.

Worked Example 2

Problem. Is 38 congruent to 14 mod 8?

  1. Congruent mod 8 means 8 divides (38 - 14) = 24.
  2. 24 / 8 = 3 exactly, so 8 | 24.
  3. Equivalently 38 mod 8 = 6 and 14 mod 8 = 6 — same remainder.

Answer. Yes — both leave remainder 6, so 38 is congruent to 14 mod 8.

Worked Example 3

Problem. Compute 3^200 mod 7 using fast modular exponentiation.

  1. By Fermat (preview), but directly: 3^1=3, 3^2=2, 3^3=6, 3^4=4, 3^5=5, 3^6=1 mod 7 (cycle length 6).
  2. 200 mod 6 = 2, so 3^200 is congruent to 3^2 mod 7.
  3. 3^2 mod 7 = 2. Python: pow(3, 200, 7) = 2.

Answer. 3^200 mod 7 = 2.

Common mistakes
  • Forgetting you can reduce mod n at each step — reducing early keeps numbers small and avoids overflow.
  • Assuming Python's % can give a negative result for negative inputs; for a positive modulus n, a % n is always in [0, n).
✎ Try it yourself

Problem. Compute 7^100 mod 5.

Solution. Reduce the base: 7 mod 5 = 2, so 7^100 is congruent to 2^100 mod 5. Powers of 2 mod 5 cycle: 2^1=2, 2^2=4, 2^3=3, 2^4=1 (cycle length 4). Since 100 mod 4 = 0, 2^100 is congruent to 2^4 = 1 mod 5. In Python: pow(7, 100, 5) = 1. Answer: 1.

Modular inverses and linear congruences

A modular inverse undoes multiplication mod n; it exists exactly when the number is coprime to n.

The modular inverse of a mod n is an integer x with a*x congruent to 1 mod n. It plays the role of division in modular arithmetic. The inverse exists if and only if GCD(a, n) = 1 (a and n are coprime), and it is found with the extended Euclidean algorithm, which expresses GCD(a,n) = a*x + n*y; then x mod n is the inverse. Inverses solve linear congruences a*x congruent to b mod n and are central to RSA decryption and hashing. In Python 3.8+, pow(a, -1, n) returns the modular inverse directly when it exists, raising an error otherwise.

Worked Example 1

Problem. Find the inverse of 3 mod 7.

  1. Need x with 3x congruent to 1 mod 7. GCD(3,7)=1, so an inverse exists.
  2. Test small x: 3*5 = 15 = 14 + 1, and 15 mod 7 = 1.
  3. So x = 5. Python: pow(3, -1, 7) = 5.

Answer. The inverse of 3 mod 7 is 5.

Worked Example 2

Problem. Solve the linear congruence 4x congruent to 5 mod 9.

  1. GCD(4,9)=1, so 4 has an inverse mod 9. Find it: 4*7 = 28 = 27 + 1, so inverse of 4 is 7.
  2. Multiply both sides by 7: x congruent to 7*5 = 35 mod 9.
  3. 35 mod 9 = 8. Python: (pow(4,-1,9) * 5) % 9 = 8.

Answer. x congruent to 8 mod 9.

Common mistakes
  • Trying to invert a when GCD(a, n) is not 1 — no inverse exists then (e.g., 2 has no inverse mod 4).
  • Forgetting to reduce the final answer mod n into the range [0, n).
✎ Try it yourself

Problem. Find the inverse of 5 mod 11, then solve 5x congruent to 3 mod 11.

Solution. GCD(5,11)=1, so the inverse exists. Find x with 5x congruent to 1 mod 11: 5*9 = 45 = 44 + 1, and 45 mod 11 = 1, so the inverse of 5 is 9. To solve 5x congruent to 3, multiply both sides by 9: x congruent to 9*3 = 27 mod 11 = 5. In Python: pow(5,-1,11) = 9 and (9*3) % 11 = 5. Answer: inverse is 9; x congruent to 5 mod 11.

Fermat's little theorem and Euler's theorem

These theorems give shortcuts for exponents in modular arithmetic, powering fast computation and crypto.

Fermat's little theorem states that if p is prime and a is not divisible by p, then a^(p-1) is congruent to 1 mod p. Euler's theorem generalizes it: if GCD(a, n) = 1 then a^phi(n) is congruent to 1 mod n, where phi(n) (Euler's totient) counts integers in [1, n] coprime to n. These let you reduce huge exponents modulo p-1 (or phi(n)) before computing, and they give a quick way to find modular inverses: a^(p-2) is the inverse of a mod a prime p. They are the mathematical engine behind RSA, where decryption exponents are chosen using phi(n). In Python, pow(a, p-1, p) demonstrates the theorem.

Worked Example 1

Problem. Use Fermat's little theorem to compute 2^100 mod 7.

  1. 7 is prime, 2 not divisible by 7, so 2^6 is congruent to 1 mod 7.
  2. Reduce exponent mod 6: 100 mod 6 = 4, so 2^100 is congruent to 2^4 mod 7.
  3. 2^4 = 16, 16 mod 7 = 2. Python: pow(2, 100, 7) = 2.

Answer. 2^100 mod 7 = 2.

Worked Example 2

Problem. Find the inverse of 4 mod 7 using Fermat's little theorem.

  1. For prime p, the inverse of a is a^(p-2) mod p. Here a^(7-2) = 4^5 mod 7.
  2. 4^2 = 16 = 2 mod 7; 4^4 = 2^2 = 4 mod 7; 4^5 = 4^4 * 4 = 4*4 = 16 = 2 mod 7.
  3. So inverse is 2. Check: 4*2 = 8 = 1 mod 7. Python: pow(4, 5, 7) = 2.

Answer. The inverse of 4 mod 7 is 2.

Worked Example 3

Problem. Use Euler's theorem with n = 10 (phi(10) = 4) to compute 3^22 mod 10.

  1. GCD(3,10)=1, so 3^phi(10) = 3^4 is congruent to 1 mod 10.
  2. Reduce exponent mod 4: 22 mod 4 = 2, so 3^22 is congruent to 3^2 mod 10.
  3. 3^2 = 9. Python: pow(3, 22, 10) = 9.

Answer. 3^22 mod 10 = 9.

Common mistakes
  • Applying Fermat's theorem when a IS divisible by p — the hypothesis a not divisible by p is required.
  • Reducing the exponent mod p instead of mod p-1 (or mod phi(n)) — the cycle length is p-1, not p.
✎ Try it yourself

Problem. Compute 5^123 mod 11 using Fermat's little theorem.

Solution. 11 is prime and 5 is not divisible by 11, so by Fermat 5^10 is congruent to 1 mod 11. Reduce the exponent mod 10: 123 mod 10 = 3, so 5^123 is congruent to 5^3 mod 11. Compute 5^3 = 125, and 125 mod 11 = 125 - 121 = 4. In Python: pow(5, 123, 11) = 4. Answer: 4.

Hashing and modular arithmetic in practice

Hash functions use a modulus to map data into a fixed range of buckets, ideally spreading inputs evenly.

A hash function maps arbitrary data to a fixed range of integers, typically by computing a numeric value and reducing it mod m (the table size). Good hashing spreads inputs uniformly across buckets to keep lookups fast (near O(1) on average); poor hashing clusters inputs, causing collisions that degrade to O(n). Choosing m to be prime helps avoid patterns when keys share common factors. A classic string hash (polynomial rolling hash) treats characters as digits in some base and reduces mod a prime. This is the backbone of Python dicts and sets, caches, and bloom filters. You can measure quality by counting how many keys land in each bucket and checking the spread.

Worked Example 1

Problem. Hash the integers 0..9 into 7 buckets with h(k) = k mod 7. Which bucket gets two keys?

  1. Compute k mod 7 for each: 0->0, 1->1, 2->2, 3->3, 4->4, 5->5, 6->6, 7->0, 8->1, 9->2.
  2. Buckets 0, 1, 2 each receive two keys (0&7, 1&8, 2&9).
  3. Python: from collections import Counter; Counter(k % 7 for k in range(10)).

Answer. Buckets 0, 1, and 2 each get two keys.

Worked Example 2

Problem. Compute a polynomial rolling hash of 'cat' with base 31 mod 101 (a=0,...,z=25, so c=2, a=0, t=19).

  1. h = (((0 + 2) * 31 + 0) * 31 + 19) mod 101, building digit by digit (Horner's method).
  2. Start 2; *31 + 0 = 62; *31 = 1922, +19 = 1941; 1941 mod 101 = 1941 - 19*101 = 1941 - 1919 = 22.
  3. Python: h=0; for c in 'cat': h=(h*31 + (ord(c)-ord('a'))) % 101 -> 22.

Answer. The hash of 'cat' is 22.

Common mistakes
  • Using a power-of-two modulus carelessly, which only keeps low bits and can cluster structured keys.
  • Ignoring collisions — even a good hash has them; the table needs a collision-resolution strategy (chaining or probing).
✎ Try it yourself

Problem. Using h(k) = k mod 5, hash the keys 12, 25, 7, 5, 19 and report the bucket of each and any collisions.

Solution. Compute each mod 5: 12 mod 5 = 2; 25 mod 5 = 0; 7 mod 5 = 2; 5 mod 5 = 0; 19 mod 5 = 4. Buckets: bucket 0 holds {25, 5}, bucket 2 holds {12, 7}, bucket 4 holds {19}. Collisions occur in bucket 0 (25 and 5) and bucket 2 (12 and 7). In Python: Counter(k % 5 for k in [12,25,7,5,19]) shows counts {2:2, 0:2, 4:1}. Answer: 12->2, 25->0, 7->2, 5->0, 19->4; collisions in buckets 0 and 2.

Public-key crypto intuition (RSA at a high level)

RSA uses the ease of multiplying primes versus the hardness of factoring their product to enable public-key encryption.

RSA is a public-key cryptosystem. You pick two large primes p and q, compute n = p*q and phi(n) = (p-1)(q-1). Choose a public exponent e coprime to phi(n), and compute the private exponent d as the modular inverse of e mod phi(n). The public key is (n, e); the private key is (n, d). Encryption of a message m is c = m^e mod n; decryption is m = c^d mod n, which works because e*d is congruent to 1 mod phi(n) and Euler's theorem makes m^(ed) return m. Security rests on the fact that recovering p and q by factoring n is computationally infeasible for large n, even though multiplying them is easy. This asymmetry is what makes secure key exchange and digital signatures possible.

Worked Example 1

Problem. Build a toy RSA key with p = 3, q = 11, e = 7. Find n, phi(n), and d.

  1. n = p*q = 33; phi(n) = (3-1)(11-1) = 2*10 = 20.
  2. Check GCD(e, phi) = GCD(7, 20) = 1, so e = 7 is valid.
  3. d is the inverse of 7 mod 20: 7*3 = 21 = 20 + 1, so d = 3. Python: pow(7, -1, 20) = 3.

Answer. n = 33, phi(n) = 20, d = 3 (public key (33,7), private key (33,3)).

Worked Example 2

Problem. Using the toy key above, encrypt m = 4 and decrypt the result to confirm.

  1. Encrypt: c = m^e mod n = 4^7 mod 33. 4^2=16, 4^4=16^2=256 mod 33 = 25, 4^7 = 4^4 * 4^2 * 4 = 25*16*4 mod 33.
  2. 25*16 = 400 mod 33 = 4; 4*4 = 16, so c = 16. Python: pow(4, 7, 33) = 16.
  3. Decrypt: m = c^d mod n = 16^3 mod 33 = 4096 mod 33 = 4. Python: pow(16, 3, 33) = 4 — recovers m.

Answer. Encryption gives c = 16; decryption returns m = 4, confirming the keys work.

Common mistakes
  • Choosing e that is not coprime to phi(n) — then no private exponent d (modular inverse) exists.
  • Believing toy small primes are secure; real RSA needs primes hundreds of digits long so factoring n is infeasible.
✎ Try it yourself

Problem. With p = 5, q = 11, e = 3, compute n, phi(n), and the private exponent d.

Solution. n = p*q = 55. phi(n) = (5-1)(11-1) = 4 * 10 = 40. Check GCD(3, 40) = 1, so e = 3 is valid. The private exponent d is the inverse of 3 mod 40: we need 3d congruent to 1 mod 40. Testing, 3*27 = 81 = 80 + 1, and 81 mod 40 = 1, so d = 27. In Python: pow(3, -1, 40) = 27. Answer: n = 55, phi(n) = 40, d = 27.

Key terms
  • Divisibility — a divides b when b is an integer multiple of a, written a | b.
  • Prime — an integer greater than 1 whose only positive divisors are 1 and itself; primes are the building blocks of factorization.
  • Greatest common divisor (GCD) — the largest integer dividing two numbers, found efficiently by the Euclidean algorithm.
  • Congruence — a is congruent to b mod n when n divides (a - b); arithmetic 'wraps around' modulo n.
  • Modular inverse — an integer x with a*x congruent to 1 mod n, which exists exactly when GCD(a, n) = 1.
  • Fermat's little theorem — if p is prime and a is not divisible by p, then a^(p-1) is congruent to 1 mod p.
  • Hash function — a map from data to a fixed range, often using a modulus, designed to spread inputs evenly.
  • RSA — a public-key cryptosystem whose security rests on the difficulty of factoring the product of two large primes.
Assignment · Number theory toolkit

In Python, implement the Euclidean algorithm for GCD and extend it to find modular inverses (the extended Euclidean algorithm). Implement fast modular exponentiation using Python's pow(base, exp, mod) and verify Fermat's little theorem on several primes. Finally, build a tiny hash function that maps strings to buckets via a modulus, and measure how evenly it distributes a list of inputs.

Deliverable · A script demonstrating GCD, a modular inverse, a Fermat's-little-theorem check, and a histogram of hash-bucket counts.

Quiz · 5 questions
  1. 1. What is GCD(48, 18) by the Euclidean algorithm?

  2. 2. What is 17 mod 5?

  3. 3. A modular inverse of a modulo n exists if and only if:

  4. 4. By Fermat's little theorem, for a prime p and a not divisible by p, a^(p-1) mod p equals:

  5. 5. The security of RSA rests primarily on the difficulty of:

You'll be able to

I can find a GCD with the Euclidean algorithm and factor integers into primes.

I can compute with congruences and find modular inverses.

I can explain how modular arithmetic underlies hashing and the intuition behind RSA.

Weeks 11-12 Unit 6: Recurrences & Asymptotics (Big-O)
define-recurrencessolve-linear-recurrencesapply-substitutionapply-master-theoremuse-asymptotic-notationanalyze-running-timecompare-growth-empirically
Lecture
Sequences and recurrence relations

A recurrence defines each term of a sequence from earlier terms plus base cases.

A sequence is an ordered list of numbers; a recurrence relation defines each term using earlier terms together with one or more base cases. For example, the factorial f(n) = n * f(n-1) with f(0) = 1, or Fibonacci F(n) = F(n-1) + F(n-2) with F(0)=0, F(1)=1. Recurrences are the natural language of recursive algorithms: the work an algorithm does is often expressed as a recurrence in its input size. Understanding them lets you predict how recursive code behaves and convert it into iterative or memoized form. In Python, a recurrence translates directly into a recursive function (with base cases) or an iterative loop that builds up values from the base.

Worked Example 1

Problem. Given a(1) = 2 and a(n) = a(n-1) + 3, compute a(1) through a(5).

  1. a(1) = 2 (base). a(2) = a(1) + 3 = 5. a(3) = 5 + 3 = 8.
  2. a(4) = 8 + 3 = 11. a(5) = 11 + 3 = 14.
  3. This is arithmetic with common difference 3. Python: a=[None,2]; for n in range(2,6): a.append(a[-1]+3).

Answer. 2, 5, 8, 11, 14.

Worked Example 2

Problem. The Fibonacci recurrence is F(n) = F(n-1) + F(n-2), F(0)=0, F(1)=1. Find F(6).

  1. F(2)=F(1)+F(0)=1, F(3)=F(2)+F(1)=2, F(4)=F(3)+F(2)=3.
  2. F(5)=F(4)+F(3)=5, F(6)=F(5)+F(4)=8.
  3. Python iterative: a,b=0,1; for _ in range(6): a,b=b,a+b -> a=8.

Answer. F(6) = 8.

Common mistakes
  • Omitting base cases — without them the recurrence has no starting point and recursion never stops.
  • Off-by-one indexing: be precise about whether the sequence starts at n=0 or n=1.
✎ Try it yourself

Problem. A sequence has T(1) = 1 and T(n) = 2*T(n-1) + 1. Compute T(1) through T(5).

Solution. T(1) = 1 (base). T(2) = 2*1 + 1 = 3. T(3) = 2*3 + 1 = 7. T(4) = 2*7 + 1 = 15. T(5) = 2*15 + 1 = 31. The pattern is T(n) = 2^n - 1. In Python: t=1; for _ in range(4): t = 2*t + 1 ends at 31. Answer: 1, 3, 7, 15, 31.

Solving linear recurrences (including Fibonacci)

Linear recurrences with constant coefficients have closed-form solutions found via the characteristic equation.

A linear homogeneous recurrence with constant coefficients, like a(n) = c1*a(n-1) + c2*a(n-2), can be solved in closed form. Guess a(n) = r^n, substitute, and divide to get the characteristic equation r^2 = c1*r + c2. Its roots r1, r2 give the general solution a(n) = A*r1^n + B*r2^n, with A, B fixed by the base cases. For Fibonacci the roots are the golden ratio phi and its conjugate, giving Binet's formula. Closed forms let you compute the n-th term in O(1) (up to precision) and reveal the growth rate. In Python you can verify a closed form against the iterative sequence for small n.

Worked Example 1

Problem. Solve a(n) = 5a(n-1) - 6a(n-2) with a(0)=0, a(1)=1.

  1. Characteristic equation: r^2 = 5r - 6, i.e. r^2 - 5r + 6 = 0, factoring (r-2)(r-3)=0, roots 2 and 3.
  2. General solution a(n) = A*2^n + B*3^n. Apply bases: a(0)=A+B=0; a(1)=2A+3B=1.
  3. From A = -B: 2(-B)+3B = B = 1, so B=1, A=-1. Thus a(n) = 3^n - 2^n.

Answer. a(n) = 3^n - 2^n (check: a(2) = 9 - 4 = 5, and 5*1 - 6*0 = 5).

Worked Example 2

Problem. Write the characteristic equation for Fibonacci F(n) = F(n-1) + F(n-2) and give its roots.

  1. Guess r^n: r^2 = r + 1, i.e. r^2 - r - 1 = 0.
  2. Quadratic formula: r = (1 +/- sqrt(5))/2.
  3. Roots are phi = (1+sqrt5)/2 ~ 1.618 and psi = (1-sqrt5)/2 ~ -0.618.

Answer. Characteristic equation r^2 - r - 1 = 0 with roots (1 +/- sqrt(5))/2.

Common mistakes
  • Forgetting that a repeated root r requires a solution form (A + B*n)*r^n, not just A*r^n.
  • Solving for A and B before applying BOTH base cases — you need two equations for two unknowns.
✎ Try it yourself

Problem. Solve a(n) = 3a(n-1) - 2a(n-2) with a(0) = 2, a(1) = 3.

Solution. Characteristic equation: r^2 - 3r + 2 = 0, factoring (r-1)(r-2) = 0, roots 1 and 2. General solution a(n) = A*1^n + B*2^n = A + B*2^n. Apply bases: a(0) = A + B = 2; a(1) = A + 2B = 3. Subtract: B = 1, so A = 1. Closed form a(n) = 1 + 2^n. Check a(2) = 1 + 4 = 5, and 3*3 - 2*2 = 5. Answer: a(n) = 1 + 2^n.

The substitution and iteration methods

Substitution guesses a bound and proves it by induction; iteration unrolls the recurrence to spot a pattern.

Two general techniques solve recurrences when the characteristic method does not apply. The iteration (or unrolling) method repeatedly expands the recurrence, substituting the definition into itself until a summation pattern emerges, then sums it. The substitution method guesses a closed-form bound (often from intuition or unrolling) and proves it correct by induction, verifying the guess satisfies the recurrence. Both are essential for analyzing algorithm running times where the recurrence does not fit a neat formula. In CS these handle the cost recurrences of recursive algorithms; the answer feeds directly into Big-O analysis. A short Python check on small n can validate a guessed bound before you prove it.

Worked Example 1

Problem. Solve T(n) = T(n-1) + n with T(0) = 0 by iteration.

  1. Unroll: T(n) = T(n-1) + n = T(n-2) + (n-1) + n = ... = T(0) + 1 + 2 + ... + n.
  2. The sum 1 + 2 + ... + n = n(n+1)/2.
  3. So T(n) = n(n+1)/2, which is Theta(n^2). Python check: T(5) = 15 = 5*6/2.

Answer. T(n) = n(n+1)/2, i.e. Theta(n^2).

Worked Example 2

Problem. Solve T(n) = 2T(n/2) + n by iteration (n a power of 2, T(1)=1).

  1. Unroll: T(n) = 2T(n/2) + n = 4T(n/4) + 2(n/2) + n = ... after k levels: 2^k T(n/2^k) + k*n.
  2. Stop when n/2^k = 1, i.e. k = log2(n). Then T(n) = n*T(1) + n*log2(n) = n + n log2(n).
  3. So T(n) = Theta(n log n) — the merge-sort recurrence.

Answer. T(n) = n log2(n) + n = Theta(n log n).

Common mistakes
  • Stopping the unrolling at the wrong base — track exactly how many levels until you reach the base case.
  • In substitution, guessing a bound and forgetting to verify the inductive step actually closes (constants must work out).
✎ Try it yourself

Problem. Solve T(n) = T(n-1) + 1 with T(0) = 0 by iteration, and give its Big-O.

Solution. Unroll: T(n) = T(n-1) + 1 = T(n-2) + 1 + 1 = ... = T(0) + n*1 = 0 + n = n. Each level adds a constant 1 and there are n levels down to the base. So T(n) = n, which is Theta(n) (linear). A Python loop adding 1 n times confirms T(n) = n. Answer: T(n) = n = O(n).

Divide-and-conquer recurrences and the Master Theorem

The Master Theorem instantly solves recurrences of the form T(n) = a T(n/b) + f(n).

Divide-and-conquer algorithms split a problem of size n into a subproblems of size n/b and combine in f(n) work, giving T(n) = a T(n/b) + f(n). The Master Theorem compares f(n) to n^(log_b a): Case 1, if f(n) grows slower (f = O(n^(log_b a - e))), then T(n) = Theta(n^(log_b a)); Case 2, if they match (f = Theta(n^(log_b a))), then T(n) = Theta(n^(log_b a) * log n); Case 3, if f grows faster (with a regularity condition), then T(n) = Theta(f(n)). This single theorem analyzes merge sort, binary search, and many recursive algorithms without unrolling. The key first step is computing the critical exponent log_b a.

Worked Example 1

Problem. Solve T(n) = 2T(n/2) + n (merge sort) with the Master Theorem.

  1. a = 2, b = 2, f(n) = n. Critical exponent: log_b a = log2(2) = 1, so n^(log_b a) = n^1 = n.
  2. Compare f(n) = n to n^1 = n: they match -> Case 2.
  3. Case 2 gives T(n) = Theta(n^1 * log n) = Theta(n log n).

Answer. T(n) = Theta(n log n).

Worked Example 2

Problem. Solve T(n) = T(n/2) + 1 (binary search).

  1. a = 1, b = 2, f(n) = 1 = n^0. Critical exponent log2(1) = 0, so n^(log_b a) = n^0 = 1.
  2. f(n) = 1 matches n^0 = 1 -> Case 2.
  3. Case 2: T(n) = Theta(n^0 * log n) = Theta(log n).

Answer. T(n) = Theta(log n).

Worked Example 3

Problem. Solve T(n) = 4T(n/2) + n.

  1. a = 4, b = 2, f(n) = n. Critical exponent log2(4) = 2, so n^(log_b a) = n^2.
  2. Compare f(n) = n to n^2: f grows slower (n = O(n^(2-e))) -> Case 1.
  3. Case 1 gives T(n) = Theta(n^2).

Answer. T(n) = Theta(n^2).

Common mistakes
  • Comparing f(n) to the wrong quantity — you must compare it to n^(log_b a), not to n.
  • Applying the Master Theorem to recurrences it does not cover (e.g., subtractive T(n) = T(n-1) + ... or non-polynomial f).
✎ Try it yourself

Problem. Use the Master Theorem to solve T(n) = 3T(n/2) + n.

Solution. Here a = 3, b = 2, f(n) = n. The critical exponent is log_b a = log2(3) ~ 1.585, so n^(log_b a) = n^1.585. Compare f(n) = n^1 to n^1.585: f grows strictly slower (n = O(n^(1.585 - e))), which is Case 1. Therefore T(n) = Theta(n^(log2 3)) ~ Theta(n^1.585). (This is the recurrence behind Karatsuba multiplication.) Answer: T(n) = Theta(n^(log2 3)).

Asymptotic notation: Big-O, Big-Omega, and Big-Theta

Asymptotic notation describes how a function grows for large inputs, ignoring constants and lower-order terms.

Asymptotic notation classifies growth rates as input size n grows large. Big-O gives an upper bound: f(n) = O(g(n)) means f grows no faster than a constant multiple of g for large n. Big-Omega gives a lower bound: f grows at least as fast as g. Big-Theta is a tight bound: f is both O(g) and Omega(g), so they grow at the same rate. We drop constant factors and lower-order terms because they do not affect large-n behavior — 3n^2 + 5n + 7 is Theta(n^2). This vocabulary lets us compare algorithms independently of hardware. The standard hierarchy is O(1) < O(log n) < O(n) < O(n log n) < O(n^2) < O(2^n) < O(n!).

Worked Example 1

Problem. Give the tightest Big-Theta bound for f(n) = 3n^2 + 100n + 50.

  1. Identify the dominant term as n grows: 3n^2 dominates 100n and 50.
  2. Drop the constant factor 3 and lower-order terms.
  3. So f(n) = Theta(n^2).

Answer. f(n) = Theta(n^2).

Worked Example 2

Problem. Is 2^(n+1) = O(2^n)?

  1. Rewrite 2^(n+1) = 2 * 2^n.
  2. This is a constant (2) times 2^n, so it is bounded above by c*2^n with c = 2.
  3. Therefore yes, 2^(n+1) = O(2^n) (constant factors do not matter).

Answer. Yes — 2^(n+1) = 2 * 2^n = O(2^n).

Worked Example 3

Problem. Order these by growth: n!, n^2, log n, 2^n, n log n.

  1. Recall the hierarchy from slow to fast.
  2. log n < n^2 (polynomial) < ... but place each: log n, then n log n, then n^2, then 2^n, then n!.
  3. Exponential 2^n beats every polynomial; factorial n! beats 2^n.

Answer. log n < n log n < n^2 < 2^n < n!.

Common mistakes
  • Keeping constant factors or lower-order terms — O(3n^2) should be written O(n^2).
  • Confusing an upper bound (Big-O) with a tight bound (Big-Theta); O is only an upper limit and may be loose.
✎ Try it yourself

Problem. Give the tightest Big-Theta bound for f(n) = 5n^3 + 2n^2 log n + 1000, and state whether f(n) = O(n^4).

Solution. The dominant term is 5n^3 (it grows faster than 2n^2 log n and the constant 1000). Dropping the constant factor and lower-order terms gives f(n) = Theta(n^3). Since n^3 grows slower than n^4, f(n) is also O(n^4) (a valid but loose upper bound). Answer: f(n) = Theta(n^3); and yes, f(n) = O(n^4).

Analyzing the running time of algorithms

Counting the dominant operations as a function of input size yields an algorithm's time complexity.

To analyze an algorithm, count how many basic operations it performs as a function of input size n, then express that count in asymptotic notation. A single loop over n elements is O(n); nested loops over n are O(n^2); halving the problem each step is O(log n); a recursive divide-and-conquer is analyzed with the Master Theorem. We focus on the worst case (or average case) and the dominant term. This guides algorithm choice: an O(n log n) sort beats an O(n^2) one decisively for large data. In Python, you can confirm an analysis empirically by timing the code on growing inputs and checking the curve matches the predicted growth.

Worked Example 1

Problem. What is the time complexity of a doubly nested loop that runs j from 0 to n for each i from 0 to n?

  1. Outer loop runs n times; for each, the inner loop runs n times.
  2. Total operations = n * n = n^2.
  3. So the running time is Theta(n^2). Python: for i in range(n): for j in range(n): work().

Answer. Theta(n^2).

Worked Example 2

Problem. Analyze: a loop where i starts at n and is halved each iteration until it reaches 1.

  1. i takes values n, n/2, n/4, ..., 1 — the number of halvings is log2(n).
  2. Each iteration does constant work.
  3. Total = Theta(log n). Python: i=n; while i>=1: i//=2.

Answer. Theta(log n).

Worked Example 3

Problem. What is the worst-case complexity of linear search through n items, and of accessing a list by index?

  1. Linear search may inspect every element in the worst case (target absent or last): n comparisons -> O(n).
  2. Indexed access (lst[k]) jumps directly to a memory location: constant work -> O(1).
  3. So search is O(n), indexing is O(1).

Answer. Linear search is O(n); list indexing is O(1).

Common mistakes
  • Adding nested-loop costs instead of multiplying them — nested loops multiply, sequential loops add.
  • Reporting best case as the complexity; the standard measure is usually worst case (or stated average case).
✎ Try it yourself

Problem. An algorithm has an outer loop running n times; inside it, an inner loop runs from the current i up to n. What is the time complexity?

Solution. For outer index i, the inner loop runs about (n - i) times. Total operations = sum over i from 0 to n-1 of (n - i) = n + (n-1) + ... + 1 = n(n+1)/2. Dropping the constant 1/2 and lower-order term, this is Theta(n^2). (Even though the inner loop shrinks, the total is still quadratic.) Answer: Theta(n^2).

Comparing growth rates empirically

Timing algorithms on growing inputs lets you observe Big-O classes in practice and confirm theory.

Theory predicts asymptotic growth, but timing real code validates it and exposes constant factors that matter at practical sizes. The approach: run an algorithm on inputs of increasing size n, record the running time, and plot or tabulate time against n. An O(n) algorithm's time roughly doubles when n doubles; an O(n^2) algorithm's time roughly quadruples; an O(log n) algorithm barely changes. Comparing the empirical curve to the predicted shape confirms the analysis (or reveals a bug or a large hidden constant). In Python, time.perf_counter measures elapsed time and matplotlib (optional) plots the curves. The ratio of times at successive sizes is the most reliable diagnostic.

Worked Example 1

Problem. An O(n) routine takes 10 ms at n = 1000. Predict its time at n = 4000.

  1. Linear time scales proportionally with n. Factor: 4000 / 1000 = 4.
  2. Predicted time = 10 ms * 4 = 40 ms.
  3. Python: measure with time.perf_counter() before/after to confirm near 40 ms.

Answer. About 40 ms (4x the input gives 4x the time).

Worked Example 2

Problem. An O(n^2) routine takes 2 s at n = 1000. Predict its time at n = 3000.

  1. Quadratic time scales with the square of the factor. Factor in n: 3000/1000 = 3.
  2. Time scales by 3^2 = 9.
  3. Predicted time = 2 s * 9 = 18 s.

Answer. About 18 s (tripling n multiplies time by 9).

Common mistakes
  • Timing a single run and trusting it — measure several runs (and warm up) to reduce noise from caching and the OS.
  • Comparing absolute times across machines; the GROWTH RATIO between sizes is the reliable, portable signal.
✎ Try it yourself

Problem. An algorithm you suspect is O(n log n) takes 1.0 s at n = 1,000,000. Estimate its time at n = 2,000,000.

Solution. For O(n log n), doubling n multiplies the time by a factor of (2n log(2n)) / (n log n) = 2 * (log(2n)/log n). With n = 1,000,000, log2(n) ~ 20 and log2(2n) ~ 21, so the factor is about 2 * (21/20) = 2.1. Predicted time ~ 1.0 s * 2.1 = 2.1 s. (Just over double, because the log factor grows slightly.) Answer: roughly 2.1 seconds.

Key terms
  • Recurrence relation — an equation defining each term of a sequence in terms of earlier terms, plus base cases.
  • Linear recurrence — a recurrence where each term is a linear combination of previous terms, such as Fibonacci.
  • Master Theorem — a formula giving the asymptotic solution of divide-and-conquer recurrences of the form T(n) = a T(n/b) + f(n).
  • Big-O — an upper bound on growth: f(n) is O(g(n)) if it grows no faster than a constant multiple of g(n).
  • Big-Omega — a lower bound on growth; f(n) is Omega(g(n)) if it grows at least as fast as g(n).
  • Big-Theta — a tight bound; f(n) is Theta(g(n)) when it is both O(g(n)) and Omega(g(n)).
  • Time complexity — how an algorithm's running time scales with input size, expressed in asymptotic notation.
  • Logarithmic growth — growth proportional to log n, characteristic of divide-and-halve algorithms like binary search.
Assignment · Recurrences meet the stopwatch

Pick two algorithms with different complexities (for example, linear search at O(n) and binary search at O(log n), or a naive recursive Fibonacci vs. an iterative one). In Python, time each algorithm on inputs of growing size and plot running time against n. Compare the empirical curves to the Big-O predictions. For the recursive Fibonacci, write its recurrence and explain why its running time is exponential.

Deliverable · A script with timing data, a plot of running time vs. input size, and a short note connecting each curve to its Big-O class.

Quiz · 5 questions
  1. 1. Which growth rate is fastest (largest) as n grows large?

  2. 2. Binary search on a sorted array of n elements has time complexity:

  3. 3. Saying f(n) is Theta(g(n)) means:

  4. 4. For the divide-and-conquer recurrence T(n) = 2 T(n/2) + O(n), the solution is:

  5. 5. Why is the naive recursive Fibonacci so slow?

You'll be able to

I can set up and solve recurrence relations, including divide-and-conquer ones.

I can classify a function's growth using Big-O, Big-Omega, and Big-Theta.

I can analyze an algorithm's running time and confirm it empirically in Python.

Weeks 13-14 Unit 7: Boolean Algebra & Discrete Structures
apply-boolean-operationsuse-boolean-functionsderive-canonical-formsdesign-logic-circuitssimplify-boolean-expressionsmodel-state-machinesrelate-boolean-set-logic
Lecture
Boolean variables, operations, and identities

Boolean algebra works over the values 0 and 1 with AND, OR, NOT, governed by identities like De Morgan's laws.

Boolean algebra is the algebra of two values, 0 (false) and 1 (true), with operations AND (multiply-like), OR (add-like), and NOT (complement). It obeys identities that let you simplify expressions: identity (A AND 1 = A, A OR 0 = A), domination (A OR 1 = 1, A AND 0 = 0), idempotence (A AND A = A), complement (A AND NOT A = 0), distributivity, absorption (A OR (A AND B) = A), and De Morgan's laws (NOT(A AND B) = NOT A OR NOT B). These laws are the basis for optimizing digital circuits and simplifying conditional logic in code. In Python the operations map to and, or, not on booleans, so you can verify any identity by exhaustive truth-table comparison.

Worked Example 1

Problem. Simplify A AND (A OR B) using Boolean identities.

  1. By the absorption law, A AND (A OR B) absorbs to A.
  2. Intuition: whenever A is 1 the expression is 1; when A is 0 it is 0 — exactly A.
  3. Python check: all((a and (a or b)) == a for a in (0,1) for b in (0,1)) -> True.

Answer. A AND (A OR B) = A.

Worked Example 2

Problem. Use De Morgan's law to rewrite NOT(A OR B) and verify by truth table.

  1. De Morgan: NOT(A OR B) = NOT A AND NOT B.
  2. Truth table: (1,1): not(1)=0 vs 0 and 0 = 0; (1,0): 0 vs 0 and 1 = 0; (0,1): 0 vs 1 and 0 = 0; (0,0): 1 vs 1 and 1 = 1.
  3. Both columns 0,0,0,1 -> equal.

Answer. NOT(A OR B) = NOT A AND NOT B (both give 0,0,0,1).

Worked Example 3

Problem. Simplify (A AND B) OR (A AND NOT B).

  1. Factor out A by distributivity: A AND (B OR NOT B).
  2. B OR NOT B = 1 (complement law).
  3. So A AND 1 = A.

Answer. (A AND B) OR (A AND NOT B) = A.

Common mistakes
  • Applying De Morgan to only one operator — both the connective AND the operands flip: NOT(A AND B) = NOT A OR NOT B.
  • Confusing absorption (A OR (A AND B) = A) with distribution; absorption collapses the whole expression to A.
✎ Try it yourself

Problem. Simplify the Boolean expression (A OR B) AND (A OR NOT B).

Solution. Apply the distributive law (treating OR as the outer operation): (A OR B) AND (A OR NOT B) = A OR (B AND NOT B). Since B AND NOT B = 0 (complement law), this becomes A OR 0 = A. Verify in Python: all(((a or b) and (a or (not b))) == a for a in (0,1) for b in (0,1)) returns True. Answer: A.

Boolean functions and truth tables

A Boolean function maps binary inputs to a binary output and is fully specified by its truth table.

A Boolean function takes n binary inputs and produces one binary output; it is completely described by a truth table listing the output for all 2^n input combinations. Because there are 2^n rows and each can be 0 or 1, there are 2^(2^n) distinct Boolean functions of n variables. Any function can be built from AND, OR, NOT (a functionally complete set), and in fact from NAND alone. Boolean functions model every digital logic block and every Boolean condition in code. In Python, you can represent a function as a lambda over booleans and generate its truth table with itertools.product over (0,1) tuples, which is the basis for verification and simplification.

Worked Example 1

Problem. Write the truth table for the XOR function f(A,B) = A XOR B.

  1. XOR is 1 when inputs differ. Rows (A,B): (0,0),(0,1),(1,0),(1,1).
  2. Outputs: 0, 1, 1, 0.
  3. Python: [(a ^ b) for a in (0,1) for b in (0,1)] -> [0,1,1,0].

Answer. XOR truth table outputs: 0, 1, 1, 0 (1 exactly when A != B).

Worked Example 2

Problem. How many distinct Boolean functions of 2 variables exist?

  1. A 2-variable function has 2^2 = 4 truth-table rows.
  2. Each row's output is independently 0 or 1, giving 2^4 combinations.
  3. 2^4 = 16 distinct functions.

Answer. 16 distinct Boolean functions of 2 variables.

Worked Example 3

Problem. Express the majority function of A, B, C (output 1 when at least two inputs are 1) and check the row A=1,B=1,C=0.

  1. Majority = (A AND B) OR (A AND C) OR (B AND C).
  2. For A=1,B=1,C=0: (1 AND 1) OR (1 AND 0) OR (1 AND 0) = 1 OR 0 OR 0 = 1.
  3. Two of three inputs are 1, so output 1 is correct.

Answer. Majority(A,B,C) = (A AND B) OR (A AND C) OR (B AND C); for (1,1,0) it is 1.

Common mistakes
  • Confusing XOR with OR — OR is 1 when at least one input is 1, XOR is 1 only when an odd number are 1.
  • Miscounting functions: there are 2^(2^n) functions of n variables, not 2^n.
✎ Try it yourself

Problem. Write the truth table for f(A,B) = (NOT A) OR B and identify which familiar operation it equals.

Solution. Evaluate all rows. (A,B)=(0,0): (not 0) or 0 = 1 or 0 = 1. (0,1): 1 or 1 = 1. (1,0): 0 or 0 = 0. (1,1): 0 or 1 = 1. Outputs: 1, 1, 0, 1. This is 0 exactly when A=1, B=0 — precisely the truth table of the implication A implies B. So (NOT A) OR B equals A implies B. Answer: outputs 1,1,0,1; it is the implication A implies B.

Canonical forms: sum of products and product of sums

Any Boolean function can be written canonically as an OR of minterms (SOP) or an AND of maxterms (POS).

Every Boolean function has two canonical forms derived directly from its truth table. The sum-of-products (SOP) form is an OR of minterms: one AND term for each row where the output is 1, with each variable appearing true or complemented to match that row. The product-of-sums (POS) form is an AND of maxterms: one OR term for each row where the output is 0. These standard forms give a guaranteed (if not minimal) circuit for any function and are the starting point for simplification. They matter in circuit design and in converting truth tables into logic. In Python you read the SOP straight off the 1-rows of a generated truth table.

Worked Example 1

Problem. Find the SOP form of f(A,B) that is 1 on rows (A=0,B=1) and (A=1,B=1).

  1. Each 1-row becomes a minterm with variables matched: row (0,1) -> (NOT A AND B); row (1,1) -> (A AND B).
  2. OR the minterms: f = (NOT A AND B) OR (A AND B).
  3. Simplify by factoring B: B AND (NOT A OR A) = B AND 1 = B.

Answer. SOP: (NOT A AND B) OR (A AND B), which simplifies to B.

Worked Example 2

Problem. Find the POS form of f(A,B) that is 0 only on row (A=0,B=0).

  1. Each 0-row becomes a maxterm: a variable appears true if it is 0 in that row, complemented if it is 1.
  2. Row (0,0): the maxterm is (A OR B).
  3. Only one 0-row, so POS = (A OR B).

Answer. POS: (A OR B).

Common mistakes
  • Swapping the polarity rule: in an SOP minterm a variable is complemented when its value in that row is 0; in a POS maxterm the rule is reversed.
  • Building minterms from the 0-rows (or maxterms from the 1-rows) — SOP uses the 1-rows, POS uses the 0-rows.
✎ Try it yourself

Problem. Give the sum-of-products form for f(A,B) = A XOR B directly from its truth table.

Solution. XOR is 1 on rows where inputs differ: (A=0,B=1) and (A=1,B=0). Build a minterm for each, matching each variable (complement it when its value is 0): row (0,1) -> (NOT A AND B); row (1,0) -> (A AND NOT B). OR them: f = (NOT A AND B) OR (A AND NOT B). This is the canonical SOP for XOR. Answer: (NOT A AND B) OR (A AND NOT B).

Logic gates and digital circuits

Logic gates are hardware that implement Boolean operations; wiring them builds digital circuits.

A logic gate is a physical (or simulated) device implementing a Boolean operation: AND, OR, NOT, plus the derived NAND, NOR, XOR. Connecting gates so that outputs feed inputs builds combinational circuits that compute Boolean functions — adders, multiplexers, comparators. NAND and NOR are each functionally complete: any circuit can be built from NAND alone, which is why hardware favors them. The depth (longest gate path) affects circuit speed, and the gate count affects cost — so simplification matters. This is where Boolean algebra meets real computing. In Python you can simulate a circuit by composing functions for each gate and checking the overall truth table.

Worked Example 1

Problem. Build a half-adder: outputs Sum and Carry for two bits A and B. Give the gate expressions.

  1. Sum is 1 when exactly one input is 1 -> Sum = A XOR B.
  2. Carry is 1 only when both are 1 -> Carry = A AND B.
  3. Check A=1,B=1: Sum = 1 XOR 1 = 0, Carry = 1 -> binary 10 = decimal 2, correct (1+1).

Answer. Sum = A XOR B, Carry = A AND B.

Worked Example 2

Problem. Show how to build a NOT gate using only a NAND gate.

  1. NAND(A, A) = NOT(A AND A) = NOT A (by idempotence A AND A = A).
  2. So tying both NAND inputs to A yields NOT A.
  3. Python: nand = lambda a,b: not (a and b); nand(A, A) == (not A).

Answer. NOT A = NAND(A, A).

Common mistakes
  • Assuming you need separate AND/OR/NOT gates — NAND (or NOR) alone is functionally complete.
  • Ignoring gate depth: a logically correct circuit can still be slow if its critical path is long.
✎ Try it yourself

Problem. Express an OR gate using only NAND gates.

Solution. By De Morgan, A OR B = NOT(NOT A AND NOT B). First make NOT A = NAND(A,A) and NOT B = NAND(B,B). Then A OR B = NAND(NOT A, NOT B), because NAND(NOT A, NOT B) = NOT((NOT A) AND (NOT B)) = NOT A OR NOT(NOT B)... more directly: NAND(x,y)=NOT(x AND y), so NAND(NOT A, NOT B) = NOT(NOT A AND NOT B) = A OR B. So OR uses three NAND gates: two as inverters plus one combining them. Answer: A OR B = NAND(NAND(A,A), NAND(B,B)).

Simplification with Boolean algebra and Karnaugh maps

Simplifying Boolean expressions reduces gate count; Karnaugh maps make minimization visual.

Minimizing a Boolean expression cuts the number of gates and the circuit's cost and delay. You can simplify algebraically with the identities (especially absorption, complement, and distributivity), but it is easy to miss reductions. A Karnaugh map (K-map) lays out the truth table in a grid ordered by Gray code so that adjacent cells differ in exactly one variable. Grouping adjacent 1-cells into rectangles of size a power of two lets each group drop the variable that changes across it, yielding a minimal SOP. K-maps are practical for up to four or so variables. Both techniques start from the same canonical SOP and produce an equivalent but smaller expression, which you can verify equals the original on every input.

Worked Example 1

Problem. Simplify f = (A AND NOT B) OR (A AND B) algebraically.

  1. Factor out A: A AND (NOT B OR B).
  2. NOT B OR B = 1 (complement law).
  3. A AND 1 = A. The expression reduces from two AND terms to a single variable.

Answer. f = A.

Worked Example 2

Problem. Use a K-map idea to simplify f(A,B,C) = 1 on minterms where A=1 (i.e. ABC = 100,101,110,111).

  1. All four rows with A=1 are 1; in a K-map they form one group of four where B and C both vary.
  2. Variables that change across the group (B and C) drop out; only A stays.
  3. So f = A. Verify: f is 1 exactly when A=1, regardless of B,C.

Answer. f(A,B,C) = A.

Common mistakes
  • Making K-map groups that are not powers of two (1,2,4,8) — only such rectangles correspond to a valid simplification.
  • Forgetting that K-map cells wrap around the edges, so corner and edge cells can group together.
✎ Try it yourself

Problem. Simplify f(A,B) = (A AND B) OR (NOT A AND B) OR (A AND NOT B).

Solution. Group the first two terms by factoring B: (A AND B) OR (NOT A AND B) = B AND (A OR NOT A) = B AND 1 = B. So f = B OR (A AND NOT B). Now apply the absorption variant: B OR (A AND NOT B) = B OR A (since the term contributes only when B=0, where it equals A). Verify on all rows: f is 1 unless A=0 and B=0, matching A OR B. Answer: f = A OR B.

Finite state machines as discrete structures

A finite state machine has states and labeled transitions that model step-by-step computation.

A finite state machine (FSM) is a discrete structure with a finite set of states, a start state, transitions labeled by input symbols, and (for acceptors) a set of accepting states. At each step the machine reads an input and moves along the transition for that symbol. FSMs recognize regular languages and model lexical scanners, network protocols, vending machines, and UI flows. They are essentially labeled directed graphs, tying this unit back to graph theory. You can represent an FSM in Python as a dictionary mapping (state, symbol) to the next state, then process an input string by following transitions from the start state and checking whether it ends in an accepting state.

Worked Example 1

Problem. Design an FSM that accepts binary strings ending in 1. Give states and transitions, then test '101' and '110'.

  1. States: S0 (last bit 0 or start, non-accepting), S1 (last bit 1, accepting). Start S0.
  2. Transitions: on 0 go to S0; on 1 go to S1 (from either state).
  3. Run '101': S0 -1-> S1 -0-> S0 -1-> S1 (accept). Run '110': S0 -1-> S1 -1-> S1 -0-> S0 (reject).

Answer. '101' ends in S1 -> accepted; '110' ends in S0 -> rejected.

Worked Example 2

Problem. Model a turnstile FSM with states Locked and Unlocked; inputs 'push' and 'coin'. Describe the transitions.

  1. From Locked: 'coin' -> Unlocked; 'push' -> Locked (no effect).
  2. From Unlocked: 'push' -> Locked (person passes); 'coin' -> Unlocked (extra coin ignored).
  3. Python: trans = {('Locked','coin'):'Unlocked', ('Locked','push'):'Locked', ('Unlocked','push'):'Locked', ('Unlocked','coin'):'Unlocked'}.

Answer. Locked --coin--> Unlocked --push--> Locked, with self-loops for push-on-Locked and coin-on-Unlocked.

Common mistakes
  • Leaving a (state, input) pair with no defined transition — a deterministic FSM needs a transition for every symbol in every state.
  • Confusing the start state with an accepting state; they are independent roles.
✎ Try it yourself

Problem. Design an FSM that accepts binary strings containing an even number of 1s, and test the string '1101'.

Solution. Use two states tracking parity of 1s: Even (accepting, start) and Odd (non-accepting). Transitions: on 0 stay in the same state; on 1 flip Even<->Odd. Run '1101' from Even: read 1 -> Odd; read 1 -> Even; read 0 -> Even; read 1 -> Odd. It ends in Odd (non-accepting), so '1101' is rejected — correct, since it has three 1s (odd). In Python: state='Even'; for c in '1101': if c=='1': state = 'Odd' if state=='Even' else 'Even'. Answer: '1101' is rejected (odd number of 1s).

Connecting Boolean algebra to set algebra and logic

Boolean algebra, set algebra, and propositional logic are three faces of the same algebraic structure.

Boolean algebra, the algebra of sets, and propositional logic share the same laws because they are all instances of an abstract Boolean algebra. The correspondence: AND matches set intersection matches logical conjunction; OR matches set union matches disjunction; NOT matches set complement matches negation; 1 matches the universal set matches True; 0 matches the empty set matches False. Every identity transfers across all three — De Morgan's laws, distributivity, and absorption hold identically. Recognizing this unity means a proof or simplification in one domain instantly gives results in the others, and it explains why the same reasoning powers circuit design, database queries, and logical proofs. It is the unifying capstone of discrete structures.

Worked Example 1

Problem. Translate De Morgan's law across all three domains.

  1. Boolean: NOT(A AND B) = NOT A OR NOT B.
  2. Sets: complement(X intersect Y) = complement(X) union complement(Y).
  3. Logic: NOT(p AND q) = (NOT p) OR (NOT q). Same law, three notations.

Answer. All three express the same De Morgan identity, swapping AND/intersect/conjunction with OR/union/disjunction under negation.

Worked Example 2

Problem. Show the distributive law in set form mirrors Boolean distributivity.

  1. Boolean: A AND (B OR C) = (A AND B) OR (A AND C).
  2. Sets: X intersect (Y union Z) = (X intersect Y) union (X intersect Z).
  3. Verify with sets X={1,2}, Y={2,3}, Z={3,4}: left = {1,2} ∩ {2,3,4} = {2}; right = {2} ∪ {} = {2}. Match.

Answer. The set identity X ∩ (Y ∪ Z) = (X ∩ Y) ∪ (X ∩ Z) is the same law as Boolean distributivity.

Common mistakes
  • Mixing the identity elements: 1 corresponds to the universal set and True, while 0 corresponds to the empty set and False — not the reverse.
  • Assuming the correspondence is loose; the laws are identical, so a counterexample in one domain would break all three.
✎ Try it yourself

Problem. State the absorption law A OR (A AND B) = A in set notation and verify it with X = {1,2,3} and Y = {2,3,4,5}.

Solution. In set form, absorption is X ∪ (X ∩ Y) = X. Compute with X = {1,2,3}, Y = {2,3,4,5}: X ∩ Y = {2,3}; then X ∪ {2,3} = {1,2,3} ∪ {2,3} = {1,2,3} = X. The result equals X, confirming the law. This mirrors the Boolean identity A OR (A AND B) = A and the logical p OR (p AND q) = p. Answer: X ∪ (X ∩ Y) = X = {1,2,3}, verified.

Key terms
  • Boolean algebra — an algebraic system over the values 0 and 1 with operations AND, OR, and NOT.
  • Boolean function — a function from binary inputs to a binary output, fully described by a truth table.
  • Sum of products (SOP) — a canonical form writing a function as an OR of AND terms (minterms).
  • Product of sums (POS) — a canonical form writing a function as an AND of OR terms (maxterms).
  • De Morgan's laws — NOT(a AND b) equals NOT a OR NOT b, and NOT(a OR b) equals NOT a AND NOT b.
  • Logic gate — a hardware element implementing a Boolean operation such as AND, OR, NOT, NAND, or XOR.
  • Karnaugh map — a grid technique for visually simplifying Boolean expressions by grouping adjacent minterms.
  • Finite state machine — a discrete structure with states and labeled transitions, modeling step-by-step computation.
Assignment · Boolean functions and circuit simplification

Choose a Boolean function of three variables and write out its full truth table in Python. From the truth table, derive its sum-of-products canonical form. Then implement the function two ways (the unsimplified SOP and a hand-simplified version using Boolean identities) and verify in code that they produce identical outputs for all 8 input combinations. Explain which Boolean laws justified your simplification.

Deliverable · A script printing the truth table, the SOP form, both implementations, and a confirmation that they match on every input.

Quiz · 5 questions
  1. 1. By De Morgan's law, NOT(A AND B) is equivalent to:

  2. 2. In Boolean algebra, what does A OR (A AND B) simplify to?

  3. 3. A sum-of-products (SOP) expression is structured as:

  4. 4. How many rows does the truth table of a 3-variable Boolean function have?

  5. 5. A Karnaugh map is used to:

You'll be able to

I can manipulate Boolean expressions using the standard identities and De Morgan's laws.

I can express a Boolean function in canonical form and simplify it.

I can connect Boolean algebra to logic, set theory, and simple digital circuits.

Where this leads

Course milestones

Logic & proof checkpoint: build a truth-table engine and write a short proof by induction (Unit 1).
Structures lab: implement set algebra and a relation classifier, identifying equivalence relations and function types (Units 2-3).
Graph project: build a graph with adjacency lists and implement BFS/DFS with a shortest-path and connectivity check (Unit 4).
Number theory & crypto checkpoint: implement GCD, modular inverse, and fast exponentiation, then explain the RSA intuition (Unit 5).
Capstone — analysis & circuits: time algorithms against their Big-O predictions and verify a simplified Boolean circuit against its truth table (Units 6-7).

Free, forever. Math you can actually use.

Part of the open-source Crunch Academy tech-education path. Pair it with the data-science and ML tracks when you're ready.

All Math for Data Science courses Crunch Academy home